feat(sms): add real instance message workflow

This commit is contained in:
chick
2026-07-19 02:12:57 +08:00
parent 38a26c2ce1
commit 0112e3320a
17 changed files with 781 additions and 450 deletions
@@ -6,6 +6,8 @@ export interface UpstreamRequest {
readonly secret?: string;
/** Redacted representation suitable for observability, never the password. */
readonly body?: '[REDACTED]';
/** Explicit one-shot SMS action payload. Implementations must not log or persist it. */
readonly sms?: { readonly phoneNumber: string; readonly content: string };
}
export interface UpstreamResponse {
readonly status: number;
@@ -0,0 +1,124 @@
import { describe, expect, it, vi } from 'vitest';
import { InstanceSessionStore } from '../connections/upstream-session-client.js';
import {
InstanceMessageService,
MessageServiceError,
parseMessageList,
} from './instance-message-service.js';
const instance = { id: 'alpha', origin: 'http://192.168.1.10:8080' };
const instances = { get: vi.fn(async (id: string) => (id === 'alpha' ? instance : undefined)) };
describe('InstanceMessageService', () => {
it('parses a bounded explicit message allowlist and never exposes pdu or excess fields', () => {
const messages = parseMessageList(
{
status: 200,
headers: {},
body: JSON.stringify({
status: 'success',
data: {
messages: [
{
id: 'm1',
direction: 'received',
phone_number: '+15550199',
content: 'hello',
timestamp: '2026-07-19T12:00:00Z',
status: 'delivered',
pdu: 'SECRET-PDU',
transport: 'modem',
password: 'secret',
},
],
},
}),
},
10,
);
expect(messages).toEqual([
{
id: 'm1',
direction: 'received',
phoneNumber: '+15550199',
content: 'hello',
timestamp: '2026-07-19T12:00:00Z',
status: 'delivered',
transport: 'modem',
},
]);
expect(JSON.stringify(messages)).not.toContain('PDU');
expect(JSON.stringify(messages)).not.toContain('secret');
});
it('uses exact owner, bounded query and matching optional session cookie', async () => {
const sessions = new InstanceSessionStore();
sessions.set('alpha', instance.origin, 'simadmin_session=opaque');
const request = vi.fn(async () => ({
status: 200,
headers: {},
body: '{"status":"success","data":{"messages":[]}}',
}));
const service = new InstanceMessageService({
instances: instances as never,
sessions,
request,
});
await expect(
service.list('alpha', { limit: 20, offset: 2, direction: 'outgoing' }),
).resolves.toEqual({ messages: [] });
expect(request).toHaveBeenCalledWith({
url: 'http://192.168.1.10:8080/api/sms/list?limit=20&offset=2&direction=outgoing',
method: 'GET',
headers: { accept: 'application/json', cookie: 'simadmin_session=opaque' },
});
});
it('sends once with an explicit payload and returns only safe success', async () => {
const request = vi.fn(async () => ({
status: 200,
headers: {},
body: '{"status":"success","message":"queued","data":{"token":"secret"}}',
}));
const service = new InstanceMessageService({
instances: instances as never,
sessions: new InstanceSessionStore(),
request,
});
await expect(
service.send('alpha', { phoneNumber: '+15550199', content: 'hello' }),
).resolves.toEqual({ sent: true });
expect(request).toHaveBeenCalledTimes(1);
expect(request).toHaveBeenCalledWith({
url: 'http://192.168.1.10:8080/api/sms/send',
method: 'POST',
headers: { accept: 'application/json', 'content-type': 'application/json' },
sms: { phoneNumber: '+15550199', content: 'hello' },
});
});
it('fails closed for missing owners, stale sessions and upstream status:error', async () => {
const sessions = new InstanceSessionStore();
sessions.set('alpha', 'http://192.168.1.99', 'simadmin_session=opaque');
const request = vi.fn(async () => ({
status: 200,
headers: {},
body: '{"status":"error","message":"recipient secret"}',
}));
const service = new InstanceMessageService({
instances: instances as never,
sessions,
request,
});
await expect(service.list('alpha', { limit: 10, offset: 0 })).rejects.toBeInstanceOf(
MessageServiceError,
);
await expect(service.list('missing', { limit: 10, offset: 0 })).rejects.toMatchObject({
code: 'NOT_FOUND',
});
sessions.clear('alpha');
await expect(
service.send('alpha', { phoneNumber: '+15550199', content: 'hello' }),
).rejects.toMatchObject({ code: 'UPSTREAM_FAILED' });
});
});
@@ -0,0 +1,191 @@
import type { InstanceService } from '../instances/instance-service.js';
import type {
InstanceSessionStore,
UpstreamResponse,
UpstreamSessionClientOptions,
} from '../connections/upstream-session-client.js';
export type MessageDirection = 'received' | 'sent' | 'incoming' | 'outgoing' | 'unknown';
export interface InstanceMessage {
readonly id: string;
readonly direction: MessageDirection;
readonly phoneNumber: string;
readonly content: string;
readonly timestamp: string;
readonly status: string;
readonly transport: string;
}
export interface MessageListQuery {
readonly limit: number;
readonly offset: number;
readonly direction?: Exclude<MessageDirection, 'unknown'>;
}
export interface SendMessageInput {
readonly phoneNumber: string;
readonly content: string;
}
export type MessageServiceErrorCode =
| 'NOT_FOUND'
| 'VALIDATION_FAILED'
| 'SESSION_INVALID'
| 'UPSTREAM_FAILED';
export class MessageServiceError extends Error {
constructor(readonly code: MessageServiceErrorCode) {
super(code);
this.name = 'MessageServiceError';
}
}
const MAX_RESPONSE_BYTES = 262_144;
const DIRECTIONS = new Set<MessageDirection>(['received', 'sent', 'incoming', 'outgoing']);
const record = (value: unknown): Record<string, unknown> | undefined =>
value !== null && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
const bounded = (value: unknown, maximum: number): string | undefined =>
(typeof value === 'string' || typeof value === 'number') &&
String(value).length <= maximum &&
!/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u.test(String(value))
? String(value)
: undefined;
export function validPhoneNumber(value: string): boolean {
return value.length >= 3 && value.length <= 32 && /^\+?[0-9][0-9 ()-]*$/u.test(value);
}
export function validMessageContent(value: string): boolean {
return (
value.length >= 1 &&
value.length <= 1600 &&
Buffer.byteLength(value, 'utf8') <= 6400 &&
!/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u.test(value)
);
}
export function parseMessageList(
response: UpstreamResponse,
limit: number,
): readonly InstanceMessage[] {
if (
response.status < 200 ||
response.status >= 300 ||
Buffer.byteLength(response.body, 'utf8') > MAX_RESPONSE_BYTES
)
throw new MessageServiceError('UPSTREAM_FAILED');
let root: Record<string, unknown> | undefined;
try {
root = record(JSON.parse(response.body));
} catch {
throw new MessageServiceError('UPSTREAM_FAILED');
}
if (root?.status !== 'success' && root?.status !== 'ok')
throw new MessageServiceError('UPSTREAM_FAILED');
const source = record(root.data)?.messages;
if (!Array.isArray(source) || source.length > limit)
throw new MessageServiceError('UPSTREAM_FAILED');
const output: InstanceMessage[] = [];
for (const item of source) {
const value = record(item);
const id = bounded(value?.id, 128);
const phoneNumber = bounded(value?.phone_number, 32);
const content = bounded(value?.content, 1600);
const timestamp = bounded(value?.timestamp, 64);
const status = bounded(value?.status, 32);
const transport = bounded(value?.transport, 32);
const rawDirection = bounded(value?.direction, 16);
if (
!id ||
!phoneNumber ||
!validPhoneNumber(phoneNumber) ||
content === undefined ||
timestamp === undefined ||
status === undefined ||
transport === undefined
)
continue;
const direction: MessageDirection =
rawDirection && DIRECTIONS.has(rawDirection as MessageDirection)
? (rawDirection as MessageDirection)
: 'unknown';
output.push({ id, direction, phoneNumber, content, timestamp, status, transport });
}
return output;
}
function parseSendSuccess(response: UpstreamResponse): void {
if (
response.status < 200 ||
response.status >= 300 ||
Buffer.byteLength(response.body, 'utf8') > 32_768
)
throw new MessageServiceError('UPSTREAM_FAILED');
try {
if (record(JSON.parse(response.body))?.status !== 'success')
throw new MessageServiceError('UPSTREAM_FAILED');
} catch (error) {
if (error instanceof MessageServiceError) throw error;
throw new MessageServiceError('UPSTREAM_FAILED');
}
}
export class InstanceMessageService {
constructor(
private readonly options: {
readonly instances: InstanceService;
readonly sessions: InstanceSessionStore;
readonly request: UpstreamSessionClientOptions['request'];
},
) {}
private async owner(instanceId: string) {
const instance = await this.options.instances.get(instanceId);
if (!instance) throw new MessageServiceError('NOT_FOUND');
const session = this.options.sessions.sessionFor(instanceId);
if (session && session.origin !== instance.origin)
throw new MessageServiceError('SESSION_INVALID');
return { instance, session };
}
async list(
instanceId: string,
query: MessageListQuery,
): Promise<{ readonly messages: readonly InstanceMessage[] }> {
if (
!Number.isSafeInteger(query.limit) ||
query.limit < 1 ||
query.limit > 100 ||
!Number.isSafeInteger(query.offset) ||
query.offset < 0 ||
query.offset > 10_000 ||
(query.direction !== undefined &&
query.direction !== 'incoming' &&
query.direction !== 'outgoing')
)
throw new MessageServiceError('VALIDATION_FAILED');
const { instance, session } = await this.owner(instanceId);
const direction = query.direction ? `&direction=${query.direction}` : '';
const response = await this.options.request({
url: `${instance.origin}/api/sms/list?limit=${query.limit}&offset=${query.offset}${direction}`,
method: 'GET',
headers: { accept: 'application/json', ...(session ? { cookie: session.cookie } : {}) },
});
return { messages: parseMessageList(response, query.limit) };
}
async send(instanceId: string, input: SendMessageInput): Promise<{ readonly sent: true }> {
if (!validPhoneNumber(input.phoneNumber) || !validMessageContent(input.content))
throw new MessageServiceError('VALIDATION_FAILED');
const { instance, session } = await this.owner(instanceId);
const response = await this.options.request({
url: `${instance.origin}/api/sms/send`,
method: 'POST',
headers: {
accept: 'application/json',
'content-type': 'application/json',
...(session ? { cookie: session.cookie } : {}),
},
sms: { phoneNumber: input.phoneNumber, content: input.content },
});
parseSendSuccess(response);
return { sent: true };
}
}
+7
View File
@@ -29,6 +29,7 @@ import { registerJobRoutes } from './interface/http/job-routes.js';
import { AuditQueryService } from './application/audit/audit-query-service.js';
import { registerAuditRoutes } from './interface/http/audit-routes.js';
import { InstanceResourceService } from './application/resources/instance-resource-service.js';
import { InstanceMessageService } from './application/messages/instance-message-service.js';
export interface SafeControlPlaneUpstream extends ConnectionTransport {
request: UpstreamSessionClientOptions['request'];
@@ -67,6 +68,11 @@ export function buildControlPlaneApp(options: ControlPlaneOptions): ControlPlane
sessions,
request: options.upstream.request,
});
const messages = new InstanceMessageService({
instances,
sessions,
request: options.upstream.request,
});
const resolver = new InstanceCredentialResolver({ db: options.db, store: options.store });
const login = options.now
? new InstanceLoginService({ db: options.db, client, resolver, now: options.now })
@@ -96,6 +102,7 @@ export function buildControlPlaneApp(options: ControlPlaneOptions): ControlPlane
login,
deletion,
resources,
messages,
registerDeletionPreparationRoute: false,
});
registerOperationRoutes(app, operationCatalogRegistry, secureExecution, deletion);
@@ -59,7 +59,6 @@ const origin = (raw: string): URL => {
(parsed.protocol !== 'http:' && parsed.protocol !== 'https:') ||
parsed.username ||
parsed.password ||
parsed.search ||
parsed.hash
)
throw new TransportError('UNSAFE_ORIGIN');
@@ -98,6 +98,53 @@ describe('SafeUpstreamGateway', () => {
});
});
it('allows only a bounded SMS list query and serializes an explicit SMS payload', async () => {
const get = vi.fn(async () => ({ status: 200, headers: {}, body: '{}' }));
const post = vi.fn(async () => ({ status: 200, headers: {}, body: '{}' }));
const gateway = new SafeUpstreamGateway({ transport: { get, post } });
await gateway.request({
url: 'http://192.168.1.20:8080/api/sms/list?limit=20&offset=0&direction=outgoing',
method: 'GET',
headers: { accept: 'application/json' },
});
await gateway.request({
url: 'http://192.168.1.20:8080/api/sms/send',
method: 'POST',
headers: { accept: 'application/json', 'content-type': 'application/json' },
sms: { phoneNumber: '+15550199', content: 'hello' },
});
expect(get).toHaveBeenCalledOnce();
expect(post).toHaveBeenCalledWith(
'http://192.168.1.20:8080/api/sms/send',
{ accept: 'application/json', 'content-type': 'application/json' },
'{"phone_number":"+15550199","content":"hello"}',
);
});
it('rejects unallowlisted SMS queries and malformed send payloads before transport', async () => {
const get = vi.fn(async () => ({ status: 200, headers: {}, body: '{}' }));
const post = vi.fn(async () => ({ status: 200, headers: {}, body: '{}' }));
const gateway = new SafeUpstreamGateway({ transport: { get, post } });
for (const url of [
'http://192.168.1.20/api/sms/list?limit=101&offset=0',
'http://192.168.1.20/api/sms/list?limit=10&offset=0&pdu=true',
'http://192.168.1.20/api/sms/list?offset=0&limit=10',
])
await expect(gateway.request({ url, method: 'GET', headers: {} })).rejects.toThrow(
'UPSTREAM_REQUEST_INVALID',
);
await expect(
gateway.request({
url: 'http://192.168.1.20/api/sms/send',
method: 'POST',
headers: {},
sms: { phoneNumber: 'bad\nnumber', content: 'hello' },
}),
).rejects.toThrow('UPSTREAM_REQUEST_INVALID');
expect(get).not.toHaveBeenCalled();
expect(post).not.toHaveBeenCalled();
});
it('does not allow a supplied redacted body marker to become a network request body', async () => {
const gateway = new SafeUpstreamGateway({
transport: {
@@ -46,12 +46,21 @@ export class SafeUpstreamGateway {
}
async request(request: UpstreamRequest): Promise<UpstreamResponse> {
const url = new URL(request.url);
const headerKeys = Object.keys(request.headers).sort().join(',');
if (request.method === 'GET') {
const smsList = url.pathname === '/api/sms/list';
const smsQuery = smsList
? /^(?:limit=([1-9]\d?)|limit=100)&offset=(0|[1-9]\d{0,4})(?:&direction=(incoming|outgoing))?$/u.exec(
url.search.slice(1),
)
: null;
if (
request.secret !== undefined ||
request.body !== undefined ||
(url.pathname !== '/api/stats' && url.pathname !== '/api/sim') ||
url.search ||
request.sms !== undefined ||
(headerKeys !== 'accept' && headerKeys !== 'accept,cookie') ||
(url.pathname !== '/api/stats' && url.pathname !== '/api/sim' && !smsQuery) ||
(!smsList && url.search) ||
url.hash ||
url.username ||
url.password ||
@@ -62,6 +71,31 @@ export class SafeUpstreamGateway {
throw new UpstreamError('UPSTREAM_REQUEST_INVALID');
return this.options.transport.get(request.url, request.headers);
}
if (url.pathname === '/api/sms/send') {
const sms = request.sms;
if (
request.method !== 'POST' ||
request.secret !== undefined ||
request.body !== undefined ||
!sms ||
(headerKeys !== 'accept,content-type' && headerKeys !== 'accept,content-type,cookie') ||
!/^\+?[0-9][0-9 ()-]{2,31}$/u.test(sms.phoneNumber) ||
sms.content.length < 1 ||
sms.content.length > 1600 ||
Buffer.byteLength(sms.content, 'utf8') > 6400 ||
/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u.test(sms.content) ||
url.search ||
url.hash ||
url.username ||
url.password
)
throw new UpstreamError('UPSTREAM_REQUEST_INVALID');
return this.options.transport.post(
request.url,
request.headers,
JSON.stringify({ phone_number: sms.phoneNumber, content: sms.content }),
);
}
if (url.protocol !== 'https:') throw new UpstreamError('UPSTREAM_INSECURE_AUTH');
if (request.method !== 'POST') throw new UpstreamError('UPSTREAM_REQUEST_INVALID');
if (request.url.endsWith('/api/auth/login')) {
@@ -20,6 +20,10 @@ import {
DeleteInstanceOperationError,
} from '../../application/operations/delete-instance-operation.js';
import type { InstanceResourceService } from '../../application/resources/instance-resource-service.js';
import {
MessageServiceError,
type InstanceMessageService,
} from '../../application/messages/instance-message-service.js';
export interface InstanceRoutesOptions {
readonly instances: InstanceService;
@@ -27,6 +31,7 @@ export interface InstanceRoutesOptions {
readonly login?: InstanceLoginService;
readonly deletion?: DeleteInstanceOperation;
readonly resources?: InstanceResourceService;
readonly messages?: InstanceMessageService;
readonly registerDeletionPreparationRoute?: boolean;
}
const problem = (
@@ -303,6 +308,21 @@ export function registerInstanceRoutes(app: FastifyInstance, options: InstanceRo
'The requested session operation could not be completed.',
),
);
if (error instanceof MessageServiceError) {
const status =
error.code === 'NOT_FOUND' ? 404 : error.code === 'VALIDATION_FAILED' ? 400 : 502;
return reply
.code(status)
.type('application/problem+json')
.send(
problem(
request,
status,
error.code,
'The requested message operation could not be completed.',
),
);
}
throw error;
}
};
@@ -345,6 +365,55 @@ export function registerInstanceRoutes(app: FastifyInstance, options: InstanceRo
options.resources!.get((request.params as { instanceId: string }).instanceId),
),
);
if (options.messages) {
app.get(
'/api/v1/instances/:instanceId/messages',
wrap(async (request) => {
const query = request.query as Record<string, unknown>;
if (Object.keys(query).some((key) => !['limit', 'offset', 'direction'].includes(key)))
throw new MessageServiceError('VALIDATION_FAILED');
const integer = (key: 'limit' | 'offset', fallback: number): number => {
const raw = query[key];
if (raw === undefined) return fallback;
if (typeof raw !== 'string' || !/^\d+$/u.test(raw))
throw new MessageServiceError('VALIDATION_FAILED');
return Number(raw);
};
const direction = query.direction;
if (direction !== undefined && direction !== 'incoming' && direction !== 'outgoing')
throw new MessageServiceError('VALIDATION_FAILED');
return options.messages!.list((request.params as { instanceId: string }).instanceId, {
limit: integer('limit', 50),
offset: integer('offset', 0),
...(direction ? { direction } : {}),
});
}),
);
app.post(
'/api/v1/instances/:instanceId/messages/send',
{
bodyLimit: 8_192,
schema: {
body: {
type: 'object',
additionalProperties: false,
required: ['phoneNumber', 'content'],
properties: {
phoneNumber: { type: 'string', minLength: 3, maxLength: 32 },
content: { type: 'string', minLength: 1, maxLength: 1600 },
},
},
},
},
wrap(async (request) => {
const value = record(request.body);
return options.messages!.send((request.params as { instanceId: string }).instanceId, {
phoneNumber: typeof value.phoneNumber === 'string' ? value.phoneNumber : '',
content: typeof value.content === 'string' ? value.content : '',
});
}),
);
}
app.patch(
'/api/v1/instances/:instanceId',
{ schema: { body: instancePatchSchema } },