import type { FastifyInstance, FastifyRequest } from 'fastify'; import type { InstanceInput, InstancePageQuery, InstancePatch, PasswordUpdate, Instance, } from '@multi-simadmin/contracts'; import { InstanceLoginService, InstanceLoginServiceError, } from '../../application/connections/instance-login-service.js'; import { ConnectionProbe } from '../../application/connections/connection-probe.js'; import { InstanceService, InstanceServiceError, } from '../../application/instances/instance-service.js'; import { DeleteInstanceOperation, 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; readonly connections?: ConnectionProbe; readonly login?: InstanceLoginService; readonly deletion?: DeleteInstanceOperation; readonly resources?: InstanceResourceService; readonly messages?: InstanceMessageService; readonly registerDeletionPreparationRoute?: boolean; } const problem = ( request: FastifyRequest, status: number, code: string, detail: string, ): Record => ({ type: 'about:blank', title: status === 500 ? 'Internal Server Error' : status === 404 ? 'Not Found' : status === 412 ? 'Precondition Failed' : status === 409 ? 'Conflict' : 'Bad Request', status, code, detail, requestId: request.id, }); const domainProblem = (request: FastifyRequest, error: InstanceServiceError) => { const status = instanceErrorStatus[error.code]; return problem( request, status, error.code, 'The requested instance operation could not be completed.', ); }; const instanceErrorStatus = { VALIDATION_FAILED: 400, NOT_FOUND: 404, REVISION_CONFLICT: 412, HAS_JOB_HISTORY: 409, DUPLICATE_ID: 409, DUPLICATE_ORIGIN: 409, SECRET_STORE_FAILED: 500, DATABASE_FAILED: 500, SECRET_CLEANUP_FAILED: 500, COMPENSATION_FAILED: 500, COMPENSATION_PERSISTENCE_FAILED: 500, } as const satisfies Record< import('../../application/instances/instance-service.js').InstanceServiceErrorCode, number >; const etag = (instance: Instance): string => `"rev-${instance.revision}"`; const revision = (request: FastifyRequest): number => { const value = request.headers['if-match']; const match = typeof value === 'string' ? /^"rev-([1-9]\d*)"$/.exec(value) : null; if (!match) throw new InstanceServiceError('VALIDATION_FAILED', 'Invalid revision'); const parsed = Number(match[1]); if (!Number.isSafeInteger(parsed)) throw new InstanceServiceError('VALIDATION_FAILED', 'Invalid revision'); return parsed; }; const strings = (value: unknown): readonly string[] | undefined => Array.isArray(value) && value.every((item) => typeof item === 'string') ? value : undefined; const record = (body: unknown): Record => { if (body === null || typeof body !== 'object' || Array.isArray(body)) throw new InstanceServiceError('VALIDATION_FAILED', 'Request body must be an object'); return body as Record; }; const bodyInput = (body: unknown): InstanceInput => { const value = record(body); const tags = strings(value.tags); const patch: { name: string; origin: string; tags?: readonly string[]; password?: PasswordUpdate; } = { name: typeof value.name === 'string' ? value.name : '', origin: typeof value.origin === 'string' ? value.origin : '', }; if (tags) patch.tags = tags; if (value.password && typeof value.password === 'object') patch.password = value.password as PasswordUpdate; return patch; }; const bodyPatch = (body: unknown): InstancePatch => { const value = record(body); const tags = strings(value.tags); const patch: { name?: string; origin?: string; tags?: readonly string[]; password?: PasswordUpdate; } = {}; if (typeof value.name === 'string') patch.name = value.name; if (typeof value.origin === 'string') patch.origin = value.origin; if (tags) patch.tags = tags; if (value.password && typeof value.password === 'object') patch.password = value.password as PasswordUpdate; return patch; }; const page = (query: unknown): InstancePageQuery => { const value = query as Record; const optionalString = (key: string): string | undefined => { const item = value[key]; if (item === undefined) return undefined; if (typeof item !== 'string') throw new InstanceServiceError('VALIDATION_FAILED', `Invalid ${key}`); return item; }; const integer = (key: 'page' | 'pageSize', maximum?: number) => { const item = optionalString(key); if (item === undefined) return undefined; if (!/^[1-9]\d*$/.test(item)) throw new InstanceServiceError('VALIDATION_FAILED', `Invalid ${key}`); const parsed = Number(item); if (!Number.isSafeInteger(parsed) || (maximum !== undefined && parsed > maximum)) throw new InstanceServiceError('VALIDATION_FAILED', `Invalid ${key}`); return parsed; }; const queryValue: { page?: number; pageSize?: number; sort?: 'name' | 'status' | 'freshness' | 'updatedAt'; direction?: 'asc' | 'desc'; search?: string; tag?: string; capabilityStatus?: 'supported' | 'unsupported' | 'auth-required' | 'degraded' | 'unknown'; freshness?: 'fresh' | 'stale' | 'expired' | 'unknown'; credentialConfigured?: boolean; } = {}; const parsedPage = integer('page'); const parsedPageSize = integer('pageSize', 100); if (parsedPage !== undefined) queryValue.page = parsedPage; if (parsedPageSize !== undefined) queryValue.pageSize = parsedPageSize; const sort = optionalString('sort'); if (sort !== undefined) { if (!['name', 'status', 'freshness', 'updatedAt'].includes(sort)) throw new InstanceServiceError('VALIDATION_FAILED', 'Invalid sort'); queryValue.sort = sort as NonNullable; } const direction = optionalString('direction'); if (direction !== undefined) { if (direction !== 'asc' && direction !== 'desc') throw new InstanceServiceError('VALIDATION_FAILED', 'Invalid direction'); queryValue.direction = direction; } const search = optionalString('search'); if (search !== undefined) { if (search.length > 200) throw new InstanceServiceError('VALIDATION_FAILED', 'Invalid search'); queryValue.search = search; } const tag = optionalString('tag'); if (tag !== undefined) queryValue.tag = tag; const capabilityStatus = optionalString('capabilityStatus'); if (capabilityStatus !== undefined) { if ( !['supported', 'unsupported', 'auth-required', 'degraded', 'unknown'].includes( capabilityStatus, ) ) throw new InstanceServiceError('VALIDATION_FAILED', 'Invalid capabilityStatus'); queryValue.capabilityStatus = capabilityStatus as NonNullable< typeof queryValue.capabilityStatus >; } const freshness = optionalString('freshness'); if (freshness !== undefined) { if (!['fresh', 'stale', 'expired', 'unknown'].includes(freshness)) throw new InstanceServiceError('VALIDATION_FAILED', 'Invalid freshness'); queryValue.freshness = freshness as NonNullable; } const credentialConfigured = optionalString('credentialConfigured'); if (credentialConfigured !== undefined) { if (credentialConfigured !== 'true' && credentialConfigured !== 'false') throw new InstanceServiceError('VALIDATION_FAILED', 'Invalid credentialConfigured'); queryValue.credentialConfigured = credentialConfigured === 'true'; } return queryValue; }; const passwordUpdateSchema = { oneOf: [ { type: 'object', additionalProperties: false, required: ['action'], properties: { action: { const: 'preserve' } }, }, { type: 'object', additionalProperties: false, required: ['action', 'password'], properties: { action: { const: 'set' }, password: { type: 'string', minLength: 1 } }, }, { type: 'object', additionalProperties: false, required: ['action'], properties: { action: { const: 'clear' } }, }, ], } as const; const instanceProperties = { name: { type: 'string' }, origin: { type: 'string' }, tags: { type: 'array', items: { type: 'string' } }, password: passwordUpdateSchema, } as const; const instanceInputSchema = { type: 'object', additionalProperties: false, required: ['name', 'origin'], properties: instanceProperties, } as const; const instancePatchSchema = { type: 'object', additionalProperties: false, minProperties: 1, properties: instanceProperties, } as const; const loginInputSchema = { type: 'object', additionalProperties: false, properties: { password: { type: 'string', minLength: 1 } }, } as const; export function registerInstanceRoutes(app: FastifyInstance, options: InstanceRoutesOptions): void { const wrap = ( handler: ( request: FastifyRequest, reply: { header: (name: string, value: string) => unknown }, ) => Promise, ) => async ( request: FastifyRequest, reply: { code: (status: number) => typeof reply; header: (name: string, value: string) => typeof reply; type: (mime: string) => typeof reply; send: (body: unknown) => unknown; }, ) => { try { return await handler(request, reply); } catch (error) { if (error instanceof InstanceServiceError) return reply .code(instanceErrorStatus[error.code]) .type('application/problem+json') .send(domainProblem(request, error)); if (error instanceof DeleteInstanceOperationError) { const status = error.code === 'NOT_FOUND' ? 404 : error.code === 'REVISION_CONFLICT' ? 412 : 400; return reply .code(status) .type('application/problem+json') .send( problem( request, status, error.code, 'The destructive operation could not be confirmed.', ), ); } if (error instanceof InstanceLoginServiceError) return reply .code(error.code === 'NOT_FOUND' ? 404 : 409) .type('application/problem+json') .send( problem( request, error.code === 'NOT_FOUND' ? 404 : 409, error.code, '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; } }; app.get( '/api/v1/instances', wrap(async (request) => options.instances.list(page(request.query))), ); app.post( '/api/v1/instances', { schema: { body: instanceInputSchema } }, async (request, reply) => { try { const created = await options.instances.create(bodyInput(request.body)); return reply.header('ETag', etag(created)).code(201).send(created); } catch (error) { if (error instanceof InstanceServiceError) return reply .code(instanceErrorStatus[error.code]) .type('application/problem+json') .send(domainProblem(request, error)); throw error; } }, ); app.get( '/api/v1/instances/:instanceId', wrap(async (request, reply) => { const result = await options.instances.get( (request.params as { instanceId: string }).instanceId, ); if (!result) throw new InstanceServiceError('NOT_FOUND', 'Instance was not found'); reply.header('ETag', etag(result)); return result; }), ); if (options.resources) app.get( '/api/v1/instances/:instanceId/resources', wrap(async (request) => 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; 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: 2000 }, }, }, }, }, 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 } }, wrap(async (request, reply) => { const result = await options.instances.update( (request.params as { instanceId: string }).instanceId, revision(request), bodyPatch(request.body), ); reply.header('ETag', etag(result)); return result; }), ); if (options.connections) app.post( '/api/v1/instances/:instanceId/test-connection', wrap(async (request) => options.connections!.test((request.params as { instanceId: string }).instanceId), ), ); if (options.login) { app.post( '/api/v1/instances/:instanceId/login', { schema: { body: loginInputSchema } }, wrap(async (request) => { const body = request.body as Record | undefined; if (body?.password !== undefined && typeof body.password !== 'string') throw new InstanceServiceError('VALIDATION_FAILED', 'Password must be a string'); return options.login!.login( (request.params as { instanceId: string }).instanceId, body?.password, ); }), ); app.post( '/api/v1/instances/:instanceId/logout', wrap(async (request) => options.login!.logout((request.params as { instanceId: string }).instanceId), ), ); } if (options.deletion) { if (options.registerDeletionPreparationRoute !== false) { app.post( '/api/v1/operations/prepare', wrap(async (request) => options.deletion!.prepare(request.body as never, request.id)), ); } app.delete('/api/v1/instances/:instanceId', async (request, reply) => { const handled = await wrap(async (wrappedRequest) => { const preparationId = wrappedRequest.headers['x-preparation-id']; const confirmationToken = wrappedRequest.headers['x-confirmation-token']; if ( typeof preparationId !== 'string' || preparationId.length === 0 || typeof confirmationToken !== 'string' || confirmationToken.length < 20 ) throw new InstanceServiceError('VALIDATION_FAILED', 'Confirmation headers are required'); return options.deletion!.executeDelete({ instanceId: (wrappedRequest.params as { instanceId: string }).instanceId, revision: revision(wrappedRequest), preparationId, confirmationToken, actor: 'loopback-control-plane', requestId: wrappedRequest.id, }); })(request, reply); if (reply.sent) return; return reply.code(202).send(handled); }); } else { app.delete( '/api/v1/instances/:instanceId', wrap(async () => { throw new InstanceServiceError('VALIDATION_FAILED', 'Confirmed deletion is unavailable'); }), ); } }