diff --git a/.changeset/runtime-domain-extraction-batch3.md b/.changeset/runtime-domain-extraction-batch3.md new file mode 100644 index 0000000000..65a7f661e4 --- /dev/null +++ b/.changeset/runtime-domain-extraction-batch3.md @@ -0,0 +1,16 @@ +--- +"@objectstack/runtime": minor +--- + +feat(runtime): extract /keys, /storage and /ui dispatcher domain bodies — ADR-0076 D11 step ③, PR-3 (#2462) + +Continues the per-domain decomposition: three more handler bodies move out +of `HttpDispatcher` into `domains/keys.ts` (incl. the zero-tolerance +API-key-mint security contract), `domains/storage.ts` and `domains/ui.ts`, +running on the explicit `DomainHandlerDeps` contract (extended with +`getObjectQL` for the data-plane domains). The `/keys` legacy branch's +`'/keys?'` query-string form is reproduced with a second registry entry; +storage drops its strictly-redundant `kernel.services` index-access fallback +(dead under Map-shaped services, duplicate under object-shaped ones). Thin +`handleXxx` delegates remain for direct callers. Zero behavior change — +locked by the 41-assertion http-conformance suite and 6 new seam tests. diff --git a/packages/runtime/src/domain-handler-registry.test.ts b/packages/runtime/src/domain-handler-registry.test.ts index 7626fb7750..f1abb64b2e 100644 --- a/packages/runtime/src/domain-handler-registry.test.ts +++ b/packages/runtime/src/domain-handler-registry.test.ts @@ -236,3 +236,68 @@ describe('HttpDispatcher extracted domains (PR-2)', () => { expect(result.response?.status ?? 404).not.toBe(200); }); }); + +// --------------------------------------------------------------------------- +// PR-3 — keys / storage / ui extraction +// --------------------------------------------------------------------------- + +describe('HttpDispatcher extracted domains (PR-3: keys/storage/ui)', () => { + it('POST /keys rejects anonymous callers with 401 (identity gate inside the extracted body)', async () => { + const result = await makeDispatcher().dispatch('POST', '/keys', { name: 'k' }, {}, {} as any); + expect(result.handled).toBe(true); + expect(result.response?.status).toBe(401); + }); + + it('GET /keys answers 405 (mint is POST-only), and /keysfoo is NOT claimed (segment semantics)', async () => { + const dispatcher = makeDispatcher(); + const wrongMethod = await dispatcher.dispatch('GET', '/keys', undefined, {}, {} as any); + expect(wrongMethod.response?.status).toBe(405); + const lexical = await dispatcher.dispatch('POST', '/keysfoo', { name: 'k' }, {}, {} as any); + expect(lexical.response?.status ?? 404).not.toBe(405); + }); + + it('POST /keys mints a key pinned to the caller (thin delegate carries the extracted body)', async () => { + const insert = vi.fn().mockResolvedValue({ id: 'key-row-1' }); + const objectql = { + insert, + find: vi.fn().mockResolvedValue([]), + getObjects: vi.fn().mockReturnValue({}), + registry: { getObject: vi.fn().mockReturnValue(null), getRegisteredTypes: vi.fn().mockReturnValue([]) }, + }; + const context: any = { executionContext: { userId: 'caller-1' } }; + // Direct delegate call — dispatch() would re-resolve identity off the + // auth-less mock kernel and overwrite the seeded executionContext. + const result = await makeDispatcher({ objectql }).handleKeys('POST', { name: 'CI Key', user_id: 'attacker' }, context); + expect(result.response?.status).toBe(201); + const row = insert.mock.calls[0][1]; + expect(insert.mock.calls[0][0]).toBe('sys_api_key'); + // user_id pinned to caller; body's user_id ignored; only the hash stored. + expect(row.user_id).toBe('caller-1'); + expect(row.key).not.toBe(result.response?.body?.data?.key); + expect(result.response?.body?.data?.key).toBeTruthy(); + }); + + it('/storage responds 501 when file-storage is not configured (extracted body keeps in-handler semantics)', async () => { + const result = await makeDispatcher().dispatch('POST', '/storage/upload', { blob: 1 }, {}, {} as any); + expect(result.handled).toBe(true); + expect(result.response?.status).toBe(501); + }); + + it('/storage/upload uploads through the file-storage service', async () => { + const upload = vi.fn().mockResolvedValue({ id: 'f1' }); + const result = await makeDispatcher({ 'file-storage': { upload, download: vi.fn() } }) + .dispatch('POST', '/storage/upload', { some: 'file' }, {}, {} as any); + expect(result.response?.status).toBe(200); + expect(upload).toHaveBeenCalledTimes(1); + }); + + it('/ui/view/:object serves the protocol getUiView result; 503 without a protocol service', async () => { + const getUiView = vi.fn().mockResolvedValue({ view: 'list-def' }); + const ok = await makeDispatcher({ protocol: { getUiView } }).dispatch('GET', '/ui/view/account/list', undefined, {}, {} as any); + expect(ok.response?.status).toBe(200); + expect(getUiView).toHaveBeenCalledWith({ object: 'account', type: 'list' }); + + const missing = await makeDispatcher().dispatch('GET', '/ui/view/account', undefined, {}, {} as any); + expect(missing.response?.status).toBe(503); + }); +}); diff --git a/packages/runtime/src/domain-handler-registry.ts b/packages/runtime/src/domain-handler-registry.ts index 109f30f53b..d227820cad 100644 --- a/packages/runtime/src/domain-handler-registry.ts +++ b/packages/runtime/src/domain-handler-registry.ts @@ -80,6 +80,13 @@ export interface DomainHandlerDeps { resolveService(name: string, environmentId?: string): any; /** Unscoped service lookup on the current kernel (may return a Promise). */ getService(name: string): any; + /** + * Environment-scoped ObjectQL lookup with a registry-shape check + * (resolves the `objectql` service and returns it only when it exposes + * `.registry`; null otherwise). The data-plane domains (/keys today, + * /data /meta when they migrate) depend on this. + */ + getObjectQL(environmentId?: string): Promise; /** Standard success envelope. */ success(data: any, meta?: any): { status: number; body: any }; /** Standard error envelope. */ diff --git a/packages/runtime/src/domains/keys.ts b/packages/runtime/src/domains/keys.ts new file mode 100644 index 0000000000..0aac0afdc3 --- /dev/null +++ b/packages/runtime/src/domains/keys.ts @@ -0,0 +1,125 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `/keys` domain — extracted dispatcher body (ADR-0076 D11 step ③, PR-3). + * + * Generates a `sys_api_key` and returns the raw secret EXACTLY ONCE + * (`POST /keys`). This is the only mint path — the raw key is never stored + * (only its sha256 hash) and never re-displayable. + * + * Security (zero-tolerance): + * - Requires an authenticated principal; `user_id` is PINNED to that + * caller and is NEVER read from the request body (no impersonation). + * - Body is whitelisted to `name` (+ optional `expires_at`); any + * `key` / `id` / `user_id` / `revoked` in the body is ignored, so a + * caller cannot forge a known-secret or escalate. + * - `scopes` are intentionally NOT accepted from the body in v1: the + * verify path ADDS scopes to the principal's permissions, so honouring + * arbitrary body scopes would be an escalation vector. A generated key + * therefore acts exactly AS the caller (via `user_id` resolution). + * Narrowing/scoped keys need subset-enforcement — deferred. + * - The raw key and its hash never enter logs or error messages. + * - The row is written with an elevated `{ isSystem: true }` context + * because `sys_api_key` is protection-locked; safe because the row's + * contents are fully server-controlled (user_id pinned to caller). + */ + +import { generateApiKey } from '../security/api-key.js'; +import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js'; +import type { DomainHandlerDeps, DomainRoute } from '../domain-handler-registry.js'; + +/** + * The legacy branch matched `=== '/keys' || startsWith('/keys/') || + * startsWith('/keys?')` — a segment match PLUS the query-string form some + * adapters pass through in `path`. Two entries reproduce that exactly. + */ +export function createKeysDomains(deps: DomainHandlerDeps): DomainRoute[] { + const handler: DomainRoute['handler'] = (req, context) => + handleKeysRequest(deps, req.method, req.body, context); + return [ + { prefix: '/keys', match: 'segment', handler }, + { prefix: '/keys?', handler }, + ]; +} + +/** Body kept signature-compatible with the legacy `HttpDispatcher.handleKeys`. */ +export async function handleKeysRequest( + deps: DomainHandlerDeps, + method: string, + body: any, + context: HttpProtocolContext, +): Promise { + if (method !== 'POST') { + return { handled: true, response: deps.error('Method not allowed', 405) }; + } + + const ec = context.executionContext; + if (!ec || !ec.userId) { + return { handled: true, response: deps.error('Unauthorized: sign in to generate an API key', 401) }; + } + + // ── Whitelist the body. Only `name` and optional `expires_at`. ── + const rawName = typeof body?.name === 'string' ? body.name.trim() : ''; + const name = rawName || 'API Key'; + + let expiresAt: string | undefined; + if (body?.expires_at != null && body.expires_at !== '') { + const ms = typeof body.expires_at === 'number' + ? (body.expires_at < 1e12 ? body.expires_at * 1000 : body.expires_at) + : Date.parse(String(body.expires_at)); + if (Number.isNaN(ms)) { + return { handled: true, response: deps.error('Invalid expires_at: must be a parseable date', 400) }; + } + if (ms <= Date.now()) { + return { handled: true, response: deps.error('Invalid expires_at: must be in the future', 400) }; + } + expiresAt = new Date(ms).toISOString(); + } + + const ql = (await deps.getObjectQL(context.environmentId)) + ?? (await deps.resolveService('objectql', context.environmentId)); + if (!ql || typeof ql.insert !== 'function') { + return { handled: true, response: deps.error('Data service not available', 503) }; + } + + // Generate AFTER validation so we never mint on a rejected request. + const generated = generateApiKey(); + + // Server-controlled row. user_id is pinned to the caller; only the hash + // is persisted. NOTHING from the body can set key/id/user_id/revoked. + const row: Record = { + name, + key: generated.hash, + prefix: generated.prefix, + user_id: ec.userId, + revoked: false, + }; + if (expiresAt) row.expires_at = expiresAt; + + let inserted: any; + try { + inserted = await ql.insert('sys_api_key', row, { context: { isSystem: true } }); + } catch { + // Never surface the underlying error (could echo row contents). + return { handled: true, response: deps.error('Failed to create API key', 500) }; + } + const id = inserted?.id ?? (Array.isArray(inserted) ? inserted[0]?.id : undefined); + + // Raw key returned ONCE. Do not log it. + return { + handled: true, + response: { + status: 201, + body: { + success: true, + data: { + id, + name, + prefix: generated.prefix, + key: generated.raw, + ...(expiresAt ? { expires_at: expiresAt } : {}), + }, + }, + }, + }; +} diff --git a/packages/runtime/src/domains/storage.ts b/packages/runtime/src/domains/storage.ts new file mode 100644 index 0000000000..9a8c9c2207 --- /dev/null +++ b/packages/runtime/src/domains/storage.ts @@ -0,0 +1,85 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `/storage` domain — extracted dispatcher body (ADR-0076 D11 step ③, PR-3). + * Upload / download bridge to the `file-storage` service. Download results + * may be a redirect or a stream — those come back as `result:{type:...}` for + * the HTTP adapter to realize (the dispatcher envelope can't carry them). + * + * Routes (path is the sub-path after `/storage`): + * POST /upload → upload (body is the file/stream) + * GET /file/:id → download (redirect | stream | metadata) + */ + +import { CoreServiceName } from '@objectstack/spec/system'; +import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js'; +import type { DomainHandlerDeps, DomainRoute } from '../domain-handler-registry.js'; + +export function createStorageDomain(deps: DomainHandlerDeps): DomainRoute { + return { + prefix: '/storage', + handler: (req, context) => + handleStorageRequest(deps, req.path.substring(8), req.method, req.body, context), + }; +} + +/** Body kept signature-compatible with the legacy `HttpDispatcher.handleStorage`. */ +export async function handleStorageRequest( + deps: DomainHandlerDeps, + path: string, + method: string, + file: any, + context: HttpProtocolContext, +): Promise { + // The legacy body had `getService(...) || this.kernel.services?.['file-storage']`. + // The second leg was strictly redundant: resolveService's fallback chain + // already ends at the services map (and when `services` is a Map, the + // legacy index access returned undefined anyway), so it is dropped here. + const storageService = await deps.getService(CoreServiceName.enum['file-storage']); + if (!storageService) { + return { handled: true, response: deps.error('File storage not configured', 501) }; + } + + const m = method.toUpperCase(); + const parts = path.replace(/^\/+/, '').split('/'); + + // POST /storage/upload + if (parts[0] === 'upload' && m === 'POST') { + if (!file) { + return { handled: true, response: deps.error('No file provided', 400) }; + } + const result = await storageService.upload(file, { request: context.request }); + return { handled: true, response: deps.success(result) }; + } + + // GET /storage/file/:id + if (parts[0] === 'file' && parts[1] && m === 'GET') { + const id = parts[1]; + const result = await storageService.download(id, { request: context.request }); + + // Result can be URL (redirect), Stream/Blob, or metadata + if (result.url && result.redirect) { + // Must be handled by adapter to do actual redirect + return { handled: true, result: { type: 'redirect', url: result.url } }; + } + + if (result.stream) { + // Must be handled by adapter to pipe stream + return { + handled: true, + result: { + type: 'stream', + stream: result.stream, + headers: { + 'Content-Type': result.mimeType || 'application/octet-stream', + 'Content-Length': result.size + } + } + }; + } + + return { handled: true, response: deps.success(result) }; + } + + return { handled: false }; +} diff --git a/packages/runtime/src/domains/ui.ts b/packages/runtime/src/domains/ui.ts new file mode 100644 index 0000000000..7e3f8a4735 --- /dev/null +++ b/packages/runtime/src/domains/ui.ts @@ -0,0 +1,52 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `/ui` domain — extracted dispatcher body (ADR-0076 D11 step ③, PR-3). + * Serves rendered view metadata from the `protocol` service. + * + * Routes (path is the sub-path after `/ui`): + * GET /view/:object[/:type] → getUiView (type also accepted as ?type=) + */ + +import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js'; +import type { DomainHandlerDeps, DomainRoute } from '../domain-handler-registry.js'; + +export function createUiDomain(deps: DomainHandlerDeps): DomainRoute { + return { + prefix: '/ui', + handler: (req, context) => + handleUiRequest(deps, req.path.substring(3), req.query, context), + }; +} + +/** Body kept signature-compatible with the legacy `HttpDispatcher.handleUi`. */ +export async function handleUiRequest( + deps: DomainHandlerDeps, + path: string, + query: any, + _context: HttpProtocolContext, +): Promise { + const parts = path.replace(/^\/+/, '').split('/').filter(Boolean); + + // GET /ui/view/:object (with optional type param) + if (parts[0] === 'view' && parts[1]) { + const objectName = parts[1]; + // Support both path param /view/obj/list AND query param /view/obj?type=list + const type = parts[2] || query?.type || 'list'; + + const protocol = await deps.resolveService('protocol'); + + if (protocol && typeof protocol.getUiView === 'function') { + try { + const result = await protocol.getUiView({ object: objectName, type }); + return { handled: true, response: deps.success(result) }; + } catch (e: any) { + return { handled: true, response: deps.error(e.message, 500) }; + } + } else { + return { handled: true, response: deps.error('Protocol service not available', 503) }; + } + } + + return { handled: false }; +} diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index 2524345d36..1f6d119292 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -19,6 +19,9 @@ import { createAnalyticsDomain, handleAnalyticsRequest } from './domains/analyti import { createI18nDomain, handleI18nRequest } from './domains/i18n.js'; import { createNotificationsDomain, handleNotificationRequest } from './domains/notifications.js'; import { createSecurityDomain, handleSecurityRequest } from './domains/security.js'; +import { createKeysDomains, handleKeysRequest } from './domains/keys.js'; +import { createStorageDomain, handleStorageRequest } from './domains/storage.js'; +import { createUiDomain, handleUiRequest } from './domains/ui.js'; /** Minimal local interface — full EnvironmentScopeManager was removed in Phase R. */ interface EnvironmentScopeManager { @@ -28,7 +31,6 @@ import { resolveExecutionContext, isPermissionDeniedError, } from './security/resolve-execution-context.js'; -import { generateApiKey } from './security/api-key.js'; /** Browser-safe UUID generator — prefers Web Crypto, falls back to RFC 4122 v4 */ function randomUUID(): string { @@ -241,6 +243,7 @@ export class HttpDispatcher { // Deps take plain strings (domain modules pass CoreServiceName enum // values anyway); the dispatcher method's parameter is the enum type. getService: (name) => this.getService(name as Parameters[0]), + getObjectQL: (environmentId) => this.getObjectQLService(environmentId), success: (data, meta) => this.success(data, meta), error: (message, code, details) => this.error(message, code, details), }; @@ -287,6 +290,9 @@ export class HttpDispatcher { this.domainRegistry.register(createI18nDomain(this.domainDeps)); this.domainRegistry.register(createNotificationsDomain(this.domainDeps)); this.domainRegistry.register(createSecurityDomain(this.domainDeps)); + for (const route of createKeysDomains(this.domainDeps)) this.domainRegistry.register(route); + this.domainRegistry.register(createStorageDomain(this.domainDeps)); + this.domainRegistry.register(createUiDomain(this.domainDeps)); } /** @@ -1473,101 +1479,9 @@ export class HttpDispatcher { return 'global'; } - /** - * Generate a `sys_api_key` and return the raw secret EXACTLY ONCE - * (`POST /keys`). This is the only mint path — the raw key is never stored - * (only its sha256 hash) and never re-displayable. - * - * Security (zero-tolerance): - * - Requires an authenticated principal; `user_id` is PINNED to that - * caller and is NEVER read from the request body (no impersonation). - * - Body is whitelisted to `name` (+ optional `expires_at`); any - * `key` / `id` / `user_id` / `revoked` in the body is ignored, so a - * caller cannot forge a known-secret or escalate. - * - `scopes` are intentionally NOT accepted from the body in v1: the - * verify path ADDS scopes to the principal's permissions, so honouring - * arbitrary body scopes would be an escalation vector. A generated key - * therefore acts exactly AS the caller (via `user_id` resolution). - * Narrowing/scoped keys need subset-enforcement — deferred. - * - The raw key and its hash never enter logs or error messages. - * - The row is written with an elevated `{ isSystem: true }` context - * because `sys_api_key` is protection-locked; safe because the row's - * contents are fully server-controlled (user_id pinned to caller). - */ + /** Thin delegate — body (incl. the zero-tolerance security contract) extracted to `./domains/keys.ts` (D11③ PR-3). */ async handleKeys(method: string, body: any, context: HttpProtocolContext): Promise { - if (method !== 'POST') { - return { handled: true, response: this.error('Method not allowed', 405) }; - } - - const ec = context.executionContext; - if (!ec || !ec.userId) { - return { handled: true, response: this.error('Unauthorized: sign in to generate an API key', 401) }; - } - - // ── Whitelist the body. Only `name` and optional `expires_at`. ── - const rawName = typeof body?.name === 'string' ? body.name.trim() : ''; - const name = rawName || 'API Key'; - - let expiresAt: string | undefined; - if (body?.expires_at != null && body.expires_at !== '') { - const ms = typeof body.expires_at === 'number' - ? (body.expires_at < 1e12 ? body.expires_at * 1000 : body.expires_at) - : Date.parse(String(body.expires_at)); - if (Number.isNaN(ms)) { - return { handled: true, response: this.error('Invalid expires_at: must be a parseable date', 400) }; - } - if (ms <= Date.now()) { - return { handled: true, response: this.error('Invalid expires_at: must be in the future', 400) }; - } - expiresAt = new Date(ms).toISOString(); - } - - const ql = (await this.getObjectQLService(context.environmentId)) - ?? (await this.resolveService('objectql', context.environmentId)); - if (!ql || typeof ql.insert !== 'function') { - return { handled: true, response: this.error('Data service not available', 503) }; - } - - // Generate AFTER validation so we never mint on a rejected request. - const generated = generateApiKey(); - - // Server-controlled row. user_id is pinned to the caller; only the hash - // is persisted. NOTHING from the body can set key/id/user_id/revoked. - const row: Record = { - name, - key: generated.hash, - prefix: generated.prefix, - user_id: ec.userId, - revoked: false, - }; - if (expiresAt) row.expires_at = expiresAt; - - let inserted: any; - try { - inserted = await ql.insert('sys_api_key', row, { context: { isSystem: true } }); - } catch { - // Never surface the underlying error (could echo row contents). - return { handled: true, response: this.error('Failed to create API key', 500) }; - } - const id = inserted?.id ?? (Array.isArray(inserted) ? inserted[0]?.id : undefined); - - // Raw key returned ONCE. Do not log it. - return { - handled: true, - response: { - status: 201, - body: { - success: true, - data: { - id, - name, - prefix: generated.prefix, - key: generated.raw, - ...(expiresAt ? { expires_at: expiresAt } : {}), - }, - }, - }, - }; + return handleKeysRequest(this.domainDeps, method, body, context); } /** @@ -3346,88 +3260,14 @@ export class HttpDispatcher { } } - /** - * Handles Storage requests - * path: sub-path after /storage/ - */ + /** Thin delegate — body extracted to `./domains/storage.ts` (D11③ PR-3). */ async handleStorage(path: string, method: string, file: any, context: HttpProtocolContext): Promise { - const storageService = await this.getService(CoreServiceName.enum['file-storage']) || this.kernel.services?.['file-storage']; - if (!storageService) { - return { handled: true, response: this.error('File storage not configured', 501) }; - } - - const m = method.toUpperCase(); - const parts = path.replace(/^\/+/, '').split('/'); - - // POST /storage/upload - if (parts[0] === 'upload' && m === 'POST') { - if (!file) { - return { handled: true, response: this.error('No file provided', 400) }; - } - const result = await storageService.upload(file, { request: context.request }); - return { handled: true, response: this.success(result) }; - } - - // GET /storage/file/:id - if (parts[0] === 'file' && parts[1] && m === 'GET') { - const id = parts[1]; - const result = await storageService.download(id, { request: context.request }); - - // Result can be URL (redirect), Stream/Blob, or metadata - if (result.url && result.redirect) { - // Must be handled by adapter to do actual redirect - return { handled: true, result: { type: 'redirect', url: result.url } }; - } - - if (result.stream) { - // Must be handled by adapter to pipe stream - return { - handled: true, - result: { - type: 'stream', - stream: result.stream, - headers: { - 'Content-Type': result.mimeType || 'application/octet-stream', - 'Content-Length': result.size - } - } - }; - } - - return { handled: true, response: this.success(result) }; - } - - return { handled: false }; + return handleStorageRequest(this.domainDeps, path, method, file, context); } - /** - * Handles UI requests - * path: sub-path after /ui/ - */ + /** Thin delegate — body extracted to `./domains/ui.ts` (D11③ PR-3). */ async handleUi(path: string, query: any, _context: HttpProtocolContext): Promise { - const parts = path.replace(/^\/+/, '').split('/').filter(Boolean); - - // GET /ui/view/:object (with optional type param) - if (parts[0] === 'view' && parts[1]) { - const objectName = parts[1]; - // Support both path param /view/obj/list AND query param /view/obj?type=list - const type = parts[2] || query?.type || 'list'; - - const protocol = await this.resolveService('protocol'); - - if (protocol && typeof protocol.getUiView === 'function') { - try { - const result = await protocol.getUiView({ object: objectName, type }); - return { handled: true, response: this.success(result) }; - } catch (e: any) { - return { handled: true, response: this.error(e.message, 500) }; - } - } else { - return { handled: true, response: this.error('Protocol service not available', 503) }; - } - } - - return { handled: false }; + return handleUiRequest(this.domainDeps, path, query, _context); } /** @@ -4479,22 +4319,14 @@ export class HttpDispatcher { return this.handleMcp(body, context); } - if (cleanPath === '/keys' || cleanPath.startsWith('/keys/') || cleanPath.startsWith('/keys?')) { - return this.handleKeys(method, body, context); - } + // /keys moved to the domain registry (D11 step ③). if (cleanPath.startsWith('/graphql')) { if (method === 'POST') return this.handleGraphQL(body, context); // GraphQL usually GET for Playground is handled by middleware but we can return 405 or handle it } - if (cleanPath.startsWith('/storage')) { - return this.handleStorage(cleanPath.substring(8), method, body, context); // body here is file/stream for upload - } - - if (cleanPath.startsWith('/ui')) { - return this.handleUi(cleanPath.substring(3), query, context); - } + // /storage and /ui moved to the domain registry (D11 step ③). if (cleanPath.startsWith('/automation')) { return this.handleAutomation(cleanPath.substring(11), method, body, context, query);