import type { IncomingMessage, ServerResponse } from 'node:http'; import type { HostApiContext } from '../context'; import { sendJson } from '../route-utils'; import { type DataServiceErrorContext, type DataServiceHostResult, } from '../../../shared/data-service'; import { COLLECTION_PATTERN, parseCollection as parseCollectionSegment, parseCursor as parseCursorValue, parseData as parseDataValue, parseDocumentId as parseDocumentIdValue, parseIfMatch as parseIfMatchValue, parseLimit as parseLimitValue, readBoundedJson as readBoundedJsonBody, requireExactKeys as requireExactKeysValue, requireQueryKeys as requireQueryKeysValue, type ParserMessages, } from './data-service-parsers'; const LOCAL_ROOT = '/api/works/data-service'; const MAX_REQUEST_BYTES = 98_304; const PARSER_MESSAGES: ParserMessages = { invalidRequest: 'Data Service request is invalid', invalidRevision: 'Data Service document revision is invalid', requestTooLarge: 'Data Service request is too large', }; class DataServiceRouteError extends Error { constructor( readonly status: number, readonly code: string, message: string, readonly retryable = false, ) { super(message); } } function routeFailure( status: number, code: string, error: string, retryable = false, extra: { retry_after_seconds?: number; context?: DataServiceErrorContext } = {}, ): DataServiceHostResult { return { success: false, status, code, error, retryable, ...(extra.retry_after_seconds === undefined ? {} : { retry_after_seconds: extra.retry_after_seconds }), ...(extra.context === undefined ? {} : { context: extra.context }), data: null, }; } function unavailable(): DataServiceHostResult { return routeFailure(503, 'data_service_unavailable', 'Data Service is temporarily unavailable', true); } function methodNotAllowed(): DataServiceHostResult { return routeFailure(405, 'method_not_allowed', 'Data Service method is not allowed'); } function parserFailure(status: number, code: string, message: string): never { throw new DataServiceRouteError(status, code, message); } async function readBoundedJson(req: IncomingMessage): Promise> { return readBoundedJsonBody(req, MAX_REQUEST_BYTES, parserFailure, PARSER_MESSAGES); } function requireExactKeys(value: Record, keys: readonly string[]): void { requireExactKeysValue(value, keys, parserFailure, PARSER_MESSAGES.invalidRequest); } function parseCollection(value: string): string { return parseCollectionSegment(value, parserFailure, PARSER_MESSAGES.invalidRequest); } function parseDocumentId(value: string): string { return parseDocumentIdValue(value, parserFailure, PARSER_MESSAGES.invalidRequest); } function requireQueryKeys(url: URL, allowed: readonly string[]): void { requireQueryKeysValue(url, allowed, parserFailure, PARSER_MESSAGES.invalidRequest); } function requireConfirmed(url: URL): void { requireQueryKeys(url, ['confirmed']); if (url.searchParams.getAll('confirmed').length !== 1 || url.searchParams.get('confirmed') !== 'true') { throw new DataServiceRouteError( 400, 'confirmation_required', 'Explicit confirmation is required for this Data Service operation', ); } } function parseLimit(url: URL): number | undefined { return parseLimitValue(url, parserFailure, PARSER_MESSAGES.invalidRequest); } function parseCursor(url: URL): string | undefined { return parseCursorValue(url, parserFailure, PARSER_MESSAGES.invalidRequest); } function parseIfMatch(req: IncomingMessage): number | undefined { return parseIfMatchValue(req, parserFailure, PARSER_MESSAGES.invalidRevision); } function parseCollections(value: unknown): string[] { if (!Array.isArray(value) || value.length > 20 || value.some((item) => typeof item !== 'string' || !COLLECTION_PATTERN.test(item))) { throw new DataServiceRouteError(422, 'invalid_request', 'Data Service request is invalid'); } return value as string[]; } function parseData(value: unknown): Record { return parseDataValue(value, parserFailure, PARSER_MESSAGES.invalidRequest); } function sendResult(res: ServerResponse, result: DataServiceHostResult): void { res.setHeader('Cache-Control', 'private, no-store'); // Host IPC and the browser fallback both consume the safe envelope. Keep the // transport status successful so a remote Data Service status cannot be // mistaken for a Host transport failure by the existing proxy. sendJson(res, 200, result); } function isDataServicePath(pathname: string): boolean { return pathname === LOCAL_ROOT || pathname.startsWith(`${LOCAL_ROOT}/`); } export async function handleDataServiceRoutes( req: IncomingMessage, res: ServerResponse, url: URL, ctx: HostApiContext, ): Promise { if (!isDataServicePath(url.pathname)) return false; const operations = ctx.codingProducts?.dataService; if (!operations) { sendResult(res, unavailable()); return true; } try { const method = (req.method ?? 'GET').toUpperCase(); const projectListPath = `${LOCAL_ROOT}/projects`; const projectPath = `${LOCAL_ROOT}/project`; if (url.pathname === projectListPath) { requireQueryKeys(url, []); if (method !== 'GET') { sendResult(res, methodNotAllowed()); } else { sendResult(res, await operations.listProjects()); } return true; } if (url.pathname === projectPath) { if (method === 'PUT') { requireQueryKeys(url, []); const body = await readBoundedJson(req); requireExactKeys(body, ['collections']); sendResult(res, await operations.configure({ collections: parseCollections(body.collections) })); } else if (method === 'GET') { requireQueryKeys(url, []); sendResult(res, await operations.inspect()); } else if (method === 'DELETE') { requireConfirmed(url); sendResult(res, await operations.removeProject({ confirmed: true })); } else { sendResult(res, methodNotAllowed()); } return true; } if (url.pathname === `${projectPath}/reset`) { if (method !== 'POST') { sendResult(res, methodNotAllowed()); } else { requireConfirmed(url); sendResult(res, await operations.reset({ confirmed: true })); } return true; } const collectionPath = url.pathname.match( new RegExp(`^${LOCAL_ROOT}/project/collections/([^/]+)$`), ); if (collectionPath) { if (method !== 'DELETE') { sendResult(res, methodNotAllowed()); } else { requireConfirmed(url); sendResult(res, await operations.removeCollection({ collection: parseCollection(collectionPath[1]), confirmed: true, })); } return true; } const documentCollectionPath = url.pathname.match( new RegExp(`^${LOCAL_ROOT}/project/collections/([^/]+)/documents$`), ); if (documentCollectionPath) { const collection = parseCollection(documentCollectionPath[1]); if (method !== 'GET') { sendResult(res, methodNotAllowed()); } else { requireQueryKeys(url, ['limit', 'cursor']); sendResult(res, await operations.listDocuments({ collection, limit: parseLimit(url), cursor: parseCursor(url), })); } return true; } const documentPath = url.pathname.match( new RegExp(`^${LOCAL_ROOT}/project/collections/([^/]+)/documents/([^/]+)$`), ); if (documentPath) { const collection = parseCollection(documentPath[1]); const documentId = parseDocumentId(documentPath[2]); if (method === 'GET') { requireQueryKeys(url, []); sendResult(res, await operations.getDocument({ collection, document_id: documentId })); } else if (method === 'PUT') { requireQueryKeys(url, []); const body = await readBoundedJson(req); requireExactKeys(body, ['data']); const ifRevision = parseIfMatch(req); sendResult(res, await operations.putDocument({ collection, document_id: documentId, data: parseData(body.data), ...(ifRevision === undefined ? {} : { if_revision: ifRevision }), })); } else if (method === 'DELETE') { requireConfirmed(url); const ifRevision = parseIfMatch(req); sendResult(res, await operations.deleteDocument({ collection, document_id: documentId, ...(ifRevision === undefined ? {} : { if_revision: ifRevision }), confirmed: true, })); } else { sendResult(res, methodNotAllowed()); } return true; } sendResult(res, routeFailure(404, 'route_not_found', 'Data Service route was not found')); return true; } catch (error) { if (error instanceof DataServiceRouteError) { sendResult(res, routeFailure(error.status, error.code, error.message, error.retryable)); return true; } sendResult(res, unavailable()); return true; } }