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'; const LOCAL_ROOT = '/api/works/data-service'; const MAX_REQUEST_BYTES = 98_304; const MAX_CURSOR_LENGTH = 1_024; const COLLECTION_PATTERN = /^[a-z][a-z0-9_-]{0,47}$/; const DOCUMENT_ID_PATTERN = /^[A-Za-z0-9._~-]{1,128}$/; class DataServiceRouteError extends Error { constructor( readonly status: number, readonly code: string, message: string, readonly retryable = false, ) { super(message); } } function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } function boundedString(value: unknown, maximum: number): string | null { if (typeof value !== 'string') return null; return value && value.length <= maximum ? value : null; } 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'); } async function readBoundedJson(req: IncomingMessage): Promise> { const declared = req.headers['content-length']; const declaredValue = Array.isArray(declared) ? declared[0] : declared; if (declaredValue && /^\d+$/.test(declaredValue) && Number(declaredValue) > MAX_REQUEST_BYTES) { throw new DataServiceRouteError(413, 'request_too_large', 'Data Service request is too large'); } const chunks: Buffer[] = []; let size = 0; for await (const chunk of req) { const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); size += buffer.byteLength; if (size > MAX_REQUEST_BYTES) { throw new DataServiceRouteError(413, 'request_too_large', 'Data Service request is too large'); } chunks.push(buffer); } try { const value = JSON.parse(Buffer.concat(chunks).toString('utf8')) as unknown; if (!isRecord(value)) throw new Error('object required'); return value; } catch { throw new DataServiceRouteError(422, 'invalid_request', 'Data Service request is invalid'); } } function requireExactKeys(value: Record, keys: readonly string[]): void { const expected = new Set(keys); if (Object.keys(value).some((key) => !expected.has(key)) || keys.some((key) => !Object.prototype.hasOwnProperty.call(value, key))) { throw new DataServiceRouteError(422, 'invalid_request', 'Data Service request is invalid'); } } function decodeSegment(value: string, maximum: number, pattern: RegExp): string { let decoded: string; try { decoded = decodeURIComponent(value); } catch { throw new DataServiceRouteError(422, 'invalid_request', 'Data Service request is invalid'); } if (decoded.length > maximum || !pattern.test(decoded)) { throw new DataServiceRouteError(422, 'invalid_request', 'Data Service request is invalid'); } return decoded; } function parseCollection(value: string): string { return decodeSegment(value, 48, COLLECTION_PATTERN); } function parseDocumentId(value: string): string { const documentId = decodeSegment(value, 128, DOCUMENT_ID_PATTERN); if (documentId === '.' || documentId === '..') { throw new DataServiceRouteError(422, 'invalid_request', 'Data Service request is invalid'); } return documentId; } function requireQueryKeys(url: URL, allowed: readonly string[]): void { const accepted = new Set(allowed); if ([...url.searchParams.keys()].some((key) => !accepted.has(key))) { throw new DataServiceRouteError(422, 'invalid_request', 'Data Service request is invalid'); } } 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 { const values = url.searchParams.getAll('limit'); if (values.length === 0) return undefined; if (values.length !== 1 || !/^[1-9]\d*$/.test(values[0])) { throw new DataServiceRouteError(422, 'invalid_request', 'Data Service request is invalid'); } const limit = Number(values[0]); if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) { throw new DataServiceRouteError(422, 'invalid_request', 'Data Service request is invalid'); } return limit; } function parseCursor(url: URL): string | undefined { const values = url.searchParams.getAll('cursor'); if (values.length === 0) return undefined; const cursor = values.length === 1 ? boundedString(values[0], MAX_CURSOR_LENGTH) : null; if (!cursor) throw new DataServiceRouteError(422, 'invalid_request', 'Data Service request is invalid'); return cursor; } function parseIfMatch(req: IncomingMessage): number | undefined { const value = req.headers['if-match']; if (value === undefined) return undefined; if (Array.isArray(value) || !/^"[1-9]\d*"$/.test(value)) { throw new DataServiceRouteError(422, 'invalid_revision', 'Data Service document revision is invalid'); } const revision = Number(value.slice(1, -1)); if (!Number.isSafeInteger(revision) || revision < 1) { throw new DataServiceRouteError(422, 'invalid_revision', 'Data Service document revision is invalid'); } return revision; } 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 { if (!isRecord(value)) { throw new DataServiceRouteError(422, 'invalid_request', 'Data Service request is invalid'); } return value; } 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; } } export const handleDataServiceRoute = handleDataServiceRoutes;