feat(sms): add real instance message workflow
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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 } },
|
||||
|
||||
Reference in New Issue
Block a user