From 280b1d898baee352f21b9904ec3fa8ea92441fb7 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Mon, 27 Jul 2026 15:03:20 +0800 Subject: [PATCH] =?UTF-8?q?feat(runtime):=20extract=20/meta=20and=20/data?= =?UTF-8?q?=20=E2=80=94=20ADR-0076=20D11=20step=20=E2=91=A2=20PR-10,=20ter?= =?UTF-8?q?minal=20cut=20(#2462)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The last two if-chain domains leave the dispatcher: - domains/meta.ts: the metadata surface (ADR-0033 draft-aware protocol paths, org-scoped reads, requireAuth gate via deps.isAuthRequired) with its exclusive slimDocList (ADR-0046) riding along. - domains/data.ts: CRUD/query over actionExec.callData; the multi-tenant unresolved-env 428 keys off a new semantic deps.isMultiTenantHost() instead of poking this.kernelResolver. dispatch() now contains ZERO domain branches — 18 domains resolve through the DomainHandlerRegistry; the gate stages + registry lookup is all that remains. createHonoApp's catch-all is ready for retirement (step ① of #2462). Verified: runtime 649, http-conformance 41, dogfood 351, full build green. Co-Authored-By: Claude Fable 5 --- .changeset/runtime-meta-data-extraction.md | 15 + .../runtime/src/domain-handler-registry.ts | 2 + packages/runtime/src/domains/data.ts | 149 ++++++ packages/runtime/src/domains/meta.ts | 379 ++++++++++++++ packages/runtime/src/http-dispatcher.ts | 495 +----------------- 5 files changed, 556 insertions(+), 484 deletions(-) create mode 100644 .changeset/runtime-meta-data-extraction.md create mode 100644 packages/runtime/src/domains/data.ts create mode 100644 packages/runtime/src/domains/meta.ts diff --git a/.changeset/runtime-meta-data-extraction.md b/.changeset/runtime-meta-data-extraction.md new file mode 100644 index 0000000000..4e4e279a35 --- /dev/null +++ b/.changeset/runtime-meta-data-extraction.md @@ -0,0 +1,15 @@ +--- +"@objectstack/runtime": minor +--- + +feat(runtime): extract the /meta and /data dispatcher domain bodies — ADR-0076 D11 step ③, PR-10, the terminal cut (#2462) + +The last two domains leave the dispatcher: `domains/meta.ts` (metadata +read/write incl. ADR-0033 draft-aware protocol paths, ADR-0046 doc slimming +riding along with its exclusive `slimDocList` helper) and `domains/data.ts` +(CRUD/query over the action-execution `callData` bridge; the multi-tenant +unresolved-environment 428 now keys off a semantic `isMultiTenantHost()` +deps member instead of poking `kernelResolver`). **The dispatch() if-chain +is now EMPTY of domains** — 18 domains resolve through the registry, and +`createHonoApp`'s catch-all is ready for retirement (step ① of #2462). +Zero behavior change — runtime 649, http-conformance 41, dogfood 351 green. diff --git a/packages/runtime/src/domain-handler-registry.ts b/packages/runtime/src/domain-handler-registry.ts index 45b510b720..4bb61d22bc 100644 --- a/packages/runtime/src/domain-handler-registry.ts +++ b/packages/runtime/src/domain-handler-registry.ts @@ -131,6 +131,8 @@ export interface DomainHandlerDeps { * dispatch()-routed requests (they already swapped). */ resolveProjectKernelObjectQL(context: HttpProtocolContext): Promise; + /** True when a host KernelResolver is registered (multi-tenant deployment). */ + isMultiTenantHost(): boolean; /** The deployment's `requireAuth` posture (lazily read — construction-order safe). */ isAuthRequired(): boolean; /** diff --git a/packages/runtime/src/domains/data.ts b/packages/runtime/src/domains/data.ts new file mode 100644 index 0000000000..31be1f804e --- /dev/null +++ b/packages/runtime/src/domains/data.ts @@ -0,0 +1,149 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `/data` domain — extracted dispatcher body (ADR-0076 D11 step ③, PR-10, + * the terminal cut). CRUD + query over `callData` (protocol-first with + * ObjectQL fallback, ADR-0049 exposure gate inside). On a multi-tenant + * host (a KernelResolver is registered) an unresolved environment answers + * 428 instead of silently serving the control plane. + */ + +import * as actionExec from '../action-execution.js'; +import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js'; +import type { DomainHandlerDeps, DomainRoute } from '../domain-handler-registry.js'; + +export function createDataDomain(deps: DomainHandlerDeps): DomainRoute { + return { + prefix: '/data', + handler: (req, context) => + handleDataRequest(deps, req.path.substring(5), req.method, req.body, req.query, context), + }; +} + +/** + * Handles Data requests + * path: sub-path after /data/ (e.g. "contacts", "contacts/123", "contacts/query") + */ +export async function handleDataRequest(deps: DomainHandlerDeps, path: string, method: string, body: any, query: any, _context: HttpProtocolContext): Promise { + const parts = path.replace(/^\/+/, '').split('/'); + const objectName = parts[0]; + + if (!objectName) { + return { handled: true, response: deps.error('Object name required', 400) }; + } + + // Check if environment is resolved for data-plane requests. A + // registered KernelResolver marks this host as multi-tenant (ADR-0006 + // Phase 5 — previously signalled by the env-registry service): a + // data-plane request that the resolver did not attach to an + // environment must not silently fall through to the host kernel. + if (!_context.dataDriver && deps.isMultiTenantHost()) { + return { + handled: true, + response: deps.error('Project not resolved. Please specify X-Environment-Id header or ensure hostname maps to a project.', 428) + }; + } + + const m = method.toUpperCase(); + + // 1. Custom Actions (query, batch) + if (parts.length > 1) { + const action = parts[1]; + + // POST /data/:object/query + if (action === 'query' && m === 'POST') { + // Spec: returns FindDataResponse = { object, records, total?, hasMore? } + const result = await actionExec.callData(deps, 'query', { object: objectName, ...body }, _context.dataDriver, _context.environmentId, _context.executionContext); + return { handled: true, response: deps.success(result) }; + } + + // GET /data/:object/:id + if (parts.length === 2 && m === 'GET') { + const id = parts[1]; + // Spec: Only select/expand are allowlisted query params for GET by ID. + // All other query parameters are discarded to prevent parameter pollution. + const { select, expand } = query || {}; + const allowedParams: Record = {}; + if (select != null) allowedParams.select = select; + if (expand != null) allowedParams.expand = expand; + // Spec: returns GetDataResponse = { object, id, record } + const result = await actionExec.callData(deps, 'get', { object: objectName, id, ...allowedParams }, _context.dataDriver, _context.environmentId, _context.executionContext); + return { handled: true, response: deps.success(result) }; + } + + // PATCH /data/:object/:id + if (parts.length === 2 && m === 'PATCH') { + const id = parts[1]; + // Spec: returns UpdateDataResponse = { object, id, record } + const result = await actionExec.callData(deps, 'update', { object: objectName, id, data: body }, _context.dataDriver, _context.environmentId, _context.executionContext); + return { handled: true, response: deps.success(result) }; + } + + // DELETE /data/:object/:id + if (parts.length === 2 && m === 'DELETE') { + const id = parts[1]; + // Spec: returns DeleteDataResponse = { object, id, deleted } + const result = await actionExec.callData(deps, 'delete', { object: objectName, id }, _context.dataDriver, _context.environmentId, _context.executionContext); + return { handled: true, response: deps.success(result) }; + } + } else { + // GET /data/:object (List) + if (m === 'GET') { + // ── Normalize HTTP transport params → Spec canonical (QueryAST) ── + // HTTP GET query params use transport-level names (filter, sort, top, + // skip, select, expand) which are normalized here to canonical + // QueryAST field names (where, orderBy, limit, offset, fields, + // expand) before forwarding to the data service layer. + // The protocol.ts findData() method performs a deeper normalization + // pass, but pre-normalizing here ensures the data service always receives + // Spec-canonical keys. + const normalized: Record = { ...query }; + + // filter/filters → where + // Note: `filter` is the canonical HTTP *transport* parameter name + // (see HttpFindQueryParamsSchema). It is normalized here to the + // canonical *QueryAST* field name `where` before data dispatch. + // `filters` (plural) is a deprecated alias for `filter`. + if (normalized.filter != null || normalized.filters != null) { + normalized.where = normalized.where ?? normalized.filter ?? normalized.filters; + delete normalized.filter; + delete normalized.filters; + } + // select → fields + if (normalized.select != null && normalized.fields == null) { + normalized.fields = normalized.select; + delete normalized.select; + } + // sort → orderBy + if (normalized.sort != null && normalized.orderBy == null) { + normalized.orderBy = normalized.sort; + delete normalized.sort; + } + // top → limit + if (normalized.top != null && normalized.limit == null) { + normalized.limit = normalized.top; + delete normalized.top; + } + // skip → offset + if (normalized.skip != null && normalized.offset == null) { + normalized.offset = normalized.skip; + delete normalized.skip; + } + + // Spec: returns FindDataResponse = { object, records, total?, hasMore? } + const result = await actionExec.callData(deps, 'query', { object: objectName, query: normalized }, _context.dataDriver, _context.environmentId, _context.executionContext); + return { handled: true, response: deps.success(result) }; + } + + // POST /data/:object (Create) + if (m === 'POST') { + // Spec: returns CreateDataResponse = { object, id, record } + const result = await actionExec.callData(deps, 'create', { object: objectName, data: body }, _context.dataDriver, _context.environmentId, _context.executionContext); + const res = deps.success(result); + res.status = 201; + return { handled: true, response: res }; + } + } + + return { handled: false }; +} diff --git a/packages/runtime/src/domains/meta.ts b/packages/runtime/src/domains/meta.ts new file mode 100644 index 0000000000..f466b427cd --- /dev/null +++ b/packages/runtime/src/domains/meta.ts @@ -0,0 +1,379 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `/meta` domain — extracted dispatcher body (ADR-0076 D11 step ③, PR-10, + * the terminal cut). The metadata read/write surface: type listing, item + * CRUD (ADR-0033 draft-aware via the protocol service), the ADR-0046 doc + * slimming, and org-scoped reads. The anonymous gate keys off the + * deployment's requireAuth posture through the deps seam. + */ + +import { + shouldDenyAnonymous, ANONYMOUS_DENY_STATUS, ANONYMOUS_DENY_CODE, ANONYMOUS_DENY_MESSAGE, +} from '@objectstack/core'; +import { pluralToSingular } from '@objectstack/spec/shared'; +import { CoreServiceName } from '@objectstack/spec/system'; +import * as actionExec from '../action-execution.js'; +import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js'; +import type { DomainHandlerDeps, DomainRoute } from '../domain-handler-registry.js'; + +export function createMetaDomain(deps: DomainHandlerDeps): DomainRoute { + return { + prefix: '/meta', + handler: (req, context) => + handleMetadataRequest(deps, req.path.substring(5), context, req.method, req.body, req.query), + }; +} + +/** + * ADR-0046: `doc` list responses omit `content` by default — manuals + * are the one metadata payload that grows unbounded, and the list + * surface only needs `name` + `label`. `?include=content` opts back in + * (single-item GET /metadata/doc/:name always returns the full body). + */ +function slimDocList(type: string, data: any, query?: Record): any { + if (type !== 'doc' || query?.include === 'content') return data; + const strip = (items: any[]) => + items.map((i) => { + if (!i || typeof i !== 'object') return i; + const { content: _content, ...rest } = i as Record; + return rest; + }); + if (Array.isArray(data)) return strip(data); + if (data && Array.isArray(data.items)) return { ...data, items: strip(data.items) }; + return data; +} + +/** + * Handles Metadata requests + * Standard: /metadata/:type/:name + * Fallback for backward compat: /metadata (all objects), /metadata/:objectName (get object) + */ +export async function handleMetadataRequest(deps: DomainHandlerDeps, path: string, _context: HttpProtocolContext, method?: string, body?: any, query?: any): Promise { + // Defense-in-depth: the metadata catch-all must honour the same + // `requireAuth` gate as the REST `/meta` routes (which serve `/meta` on + // the cloud runtime). Object/field schemas — SYSTEM-object schemas on a + // tenant-less host — must not be readable by anonymous callers when the + // deployment requires auth. No-op when `requireAuth` is off. + { + const ec: any = _context.executionContext; + if (shouldDenyAnonymous({ requireAuth: deps.isAuthRequired(), userId: ec?.userId, isSystem: ec?.isSystem })) { + return { + handled: true, + response: deps.error(ANONYMOUS_DENY_MESSAGE, ANONYMOUS_DENY_STATUS, { code: ANONYMOUS_DENY_CODE }), + }; + } + } + const parts = path.replace(/^\/+/, '').split('/').filter(Boolean); + + // GET /metadata/types + if (parts[0] === 'types') { + // PRIORITY 1: Try protocol service — it returns BOTH legacy + // `types: string[]` AND the richer `entries` array (with + // JSON Schemas, allowOrgOverride flags, domain, etc) needed by + // the metadata admin UI. It internally also merges + // MetadataService runtime types, so this path is strictly richer. + const protocol = await deps.resolveService('protocol'); + if (protocol && typeof protocol.getMetaTypes === 'function') { + try { + const result = await protocol.getMetaTypes({}); + return { handled: true, response: deps.success(result) }; + } catch (e: any) { + console.warn('[HttpDispatcher] protocol.getMetaTypes() failed:', e?.message); + } + } + // PRIORITY 2: MetadataService fallback (types only, no entries) + const metadataService = await deps.resolveService('metadata', _context.environmentId); + if (metadataService && typeof (metadataService as any).getRegisteredTypes === 'function') { + try { + const types = await (metadataService as any).getRegisteredTypes(); + return { handled: true, response: deps.success({ types }) }; + } catch (e: any) { + console.warn('[HttpDispatcher] MetadataService.getRegisteredTypes() failed:', e.message); + } + } + // Last resort: hardcoded defaults + return { handled: true, response: deps.success({ types: ['object', 'app', 'plugin'] }) }; + } + + // GET /metadata/objects/:name/state/:field?from=:state + // ADR-0020 D3.3 introspection: the legal next states declared by the + // object's `state_machine` validation rule for `:field`. Lets UIs / + // AI authors ask "from here, where can this record go?" instead of + // hard-coding the transition table. Returns `next: null` when no FSM + // governs the field, `next: []` for a declared dead-end state. + if (parts.length === 4 && (parts[0] === 'objects' || parts[0] === 'object') && parts[2] === 'state' && (!method || method === 'GET')) { + const name = parts[1]; + const field = parts[3]; + const from = query?.from !== undefined ? String(query.from) : undefined; + const qlService = await deps.getObjectQL(); + const schema = qlService?.registry?.getObject(name); + if (!schema) return { handled: true, response: deps.error('Object not found', 404) }; + // Dynamic import (matches the runtime convention for @objectstack/objectql) + // so the dispatcher module graph doesn't statically pull in the objectql barrel. + const { legalNextStates } = await import('@objectstack/objectql'); + const next = from === undefined ? null : legalNextStates(schema, field, from); + return { handled: true, response: deps.success({ object: name, field, from: from ?? null, next }) }; + } + + // GET /metadata/:type/:name(/:subname...)/published → get published version + // Supports compound names like `lead/views/all_leads/published`. + if (parts.length >= 3 && parts[parts.length - 1] === 'published' && (!method || method === 'GET')) { + const type = parts[0]; + const name = parts.slice(1, -1).join('/'); + const metadataService = await deps.getService(CoreServiceName.enum.metadata); + if (metadataService && typeof (metadataService as any).getPublished === 'function') { + const data = await (metadataService as any).getPublished(type, name); + if (data === undefined) return { handled: true, response: deps.error('Not found', 404) }; + return { handled: true, response: deps.success(data) }; + } + // Fallback — try MetadataService via resolveService + const metaSvc = await deps.resolveService('metadata', _context.environmentId); + if (metaSvc && typeof (metaSvc as any).getPublished === 'function') { + try { + const fallbackData = await (metaSvc as any).getPublished(type, name); + if (fallbackData !== undefined) return { handled: true, response: deps.success(fallbackData) }; + } catch { /* fall through */ } + } + return { handled: true, response: deps.error('Not found', 404) }; + } + + // /metadata/:type/:name where :name may itself contain slashes + // (e.g. /metadata/lead/views/all_leads → type='lead', name='views/all_leads'). + // Compound names are how the client expresses sub-resources of a type + // (a view of an object, a flow under an automation, etc.) and the + // metadata service treats the full string as the lookup key. + if (parts.length >= 2) { + const type = parts[0]; + const name = parts.slice(1).join('/'); + // Extract optional package filter from query string + const packageId = query?.package || undefined; + + // PUT /metadata/:type/:name (Save) + if (method === 'PUT' && body) { + // Try to get the protocol service directly + const protocol = await deps.resolveService('protocol'); + + if (protocol && typeof protocol.saveMetaItem === 'function') { + try { + const organizationId = await deps.resolveActiveOrganizationId(_context); + const result = await protocol.saveMetaItem({ type, name, item: body, organizationId, ...(packageId ? { packageId } : {}) }); + return { handled: true, response: deps.success(result) }; + } catch (e: any) { + // Preserve the 422 + structured spec-validation `issues` so + // the Studio can point at the offending field, not just a + // generic banner (the old path hardcoded 400 + dropped them). + return { handled: true, response: deps.errorFromThrown(e, 400) }; + } + } + + // Fallback: try MetadataService directly + const metaSvc = await deps.resolveService('metadata', _context.environmentId); + if (metaSvc && typeof (metaSvc as any).saveItem === 'function') { + try { + const data = await (metaSvc as any).saveItem(type, name, body); + return { handled: true, response: deps.success(data) }; + } catch (e: any) { + return { handled: true, response: deps.error(e.message || 'Save not supported', 501) }; + } + } + return { handled: true, response: deps.error('Save not supported', 501) }; + } + + try { + // Try specific calls based on type + if (type === 'objects' || type === 'object') { + // Check whether the kernel is project-scoped. When it is, + // the process-wide SchemaRegistry is unsafe to query + // directly — it would return objects that other projects + // wrote in this same process. Route through the Protocol + // service (which filters sys_metadata by environment_id) in that + // case, and fall back to the registry only for the + // unscoped (single-kernel / control-plane) path. + const protocol = await deps.resolveService('protocol') as any; + const scopedEnv = typeof protocol?.getProjectId === 'function' + ? protocol.getProjectId() + : protocol?.environmentId; + const scoped = scopedEnv !== undefined; + + if (scoped && typeof protocol.getMetaItem === 'function') { + try { + const organizationId = await deps.resolveActiveOrganizationId(_context); + const data = await protocol.getMetaItem({ type: 'object', name, organizationId }); + // Protocol returns `{ type, name, item }` — only + // treat the lookup as a hit when item is present. + if (data && (data.item ?? data)) { + return { handled: true, response: deps.success(data) }; + } + } catch { /* fall through to registry / 404 */ } + } + + const qlService = await deps.getObjectQL(); + if (qlService?.registry) { + const data = qlService.registry.getObject(name); + if (data) return { handled: true, response: deps.success(data) }; + } + + // Last-ditch protocol attempt for unscoped kernels whose + // registry missed (e.g. object persisted to DB but not + // yet hydrated). Skip when we already tried above. + if (!scoped && protocol && typeof protocol.getMetaItem === 'function') { + try { + const organizationId = await deps.resolveActiveOrganizationId(_context); + const data = await protocol.getMetaItem({ type: 'object', name, organizationId }); + if (data && (data.item ?? data)) { + return { handled: true, response: deps.success(data) }; + } + } catch { /* fall through to 404 */ } + } + return { handled: true, response: deps.error('Not found', 404) }; + } + + // Normalize plural URL paths to singular registry type names + const singularType = pluralToSingular(type); + + // Try Protocol Service First (Preferred) + const protocol = await deps.resolveService('protocol'); + if (protocol && typeof protocol.getMetaItem === 'function') { + try { + const organizationId = await deps.resolveActiveOrganizationId(_context); + // ADR-0033 draft-overlay preview: `?preview=draft` makes the + // detail read prefer a pending draft (falling back to active). + // Admin gating is layered on top in a follow-up (step 2). + const previewDrafts = query?.preview === 'draft'; + const data = await protocol.getMetaItem({ type: singularType, name, packageId, organizationId, previewDrafts }); + return { handled: true, response: deps.success(data) }; + } catch (e: any) { + // Protocol might throw if not found or not supported + } + } + + // Try MetadataService for runtime-registered types + const metaSvc = await deps.resolveService('metadata', _context.environmentId); + if (metaSvc && typeof (metaSvc as any).getItem === 'function') { + try { + // ADR-0048 — thread `?package=` so single-item resolution is + // package-scoped (prefer-local), matching list resolution. + const data = await (metaSvc as any).getItem(singularType, name, packageId); + if (data) return { handled: true, response: deps.success(data) }; + } catch { /* not found */ } + } + return { handled: true, response: deps.error('Not found', 404) }; + } catch (e: any) { + // Fallback: treat first part as object name if only 1 part (handled below) + // But here we are deep in 2 parts. Must be an error. + return { handled: true, response: deps.error(e.message, 404) }; + } + } + + // GET /metadata/_drafts?packageId=&type= (ADR-0033 pending-changes list) + // Surfaces draft-state metadata the active-only `getMetaItems` list hides, + // so the console can show what an AI authored but nobody published yet. + // `_drafts` is intercepted before the generic `:type` handler below so it + // is never mistaken for a metadata type name. + if (parts.length === 1 && parts[0] === '_drafts' && (!method || method.toUpperCase() === 'GET')) { + const protocol = await deps.resolveService('protocol'); + if (protocol && typeof protocol.listDrafts === 'function') { + try { + const organizationId = await deps.resolveActiveOrganizationId(_context); + const data = await protocol.listDrafts({ + packageId: query?.packageId || undefined, + type: query?.type || undefined, + organizationId, + }); + return { handled: true, response: deps.success(data) }; + } catch (e: any) { + return { handled: true, response: deps.error(e.message, 500) }; + } + } + return { handled: true, response: deps.error('Draft listing not supported', 501) }; + } + + // GET /metadata/:type (List items of type) OR /metadata/:objectName (Legacy) + if (parts.length === 1) { + const typeOrName = parts[0]; + // Extract optional package filter from query string + const packageId = query?.package || undefined; + + // Try protocol service first for any type + const protocol = await deps.resolveService('protocol'); + if (protocol && typeof protocol.getMetaItems === 'function') { + try { + const organizationId = await deps.resolveActiveOrganizationId(_context); + // ADR-0033 draft-overlay preview: `?preview=draft` overlays + // pending drafts on the active list so an (admin) reviewer can + // render the console off drafts before publishing. + const previewDrafts = query?.preview === 'draft'; + const data = await protocol.getMetaItems({ type: typeOrName, packageId, organizationId, previewDrafts }); + // Return any valid response from protocol (including empty items arrays) + if (data && (data.items !== undefined || Array.isArray(data))) { + return { handled: true, response: deps.success(slimDocList(typeOrName, data, query)) }; + } + } catch { + // Protocol doesn't know this type, fall through + } + } + + // Try MetadataService directly for runtime-registered metadata (agents, tools, etc.) + const metadataService = await deps.getService(CoreServiceName.enum.metadata); + if (metadataService && typeof (metadataService as any).list === 'function') { + try { + let items = await (metadataService as any).list(typeOrName); + // Respect package filter: MetadataService.list() returns ALL items, + // so filter by _packageId when a specific package is requested. + if (packageId && items && items.length > 0) { + items = items.filter((item: any) => item?._packageId === packageId); + } + if (items && items.length > 0) { + return { handled: true, response: deps.success({ type: typeOrName, items: slimDocList(typeOrName, items, query) }) }; + } + } catch (e: any) { + // MetadataService doesn't know this type or failed, continue to other fallbacks + // Sanitize typeOrName to prevent log injection (CodeQL warning) + const sanitizedType = String(typeOrName).replace(/[\r\n\t]/g, ''); + console.debug(`[HttpDispatcher] MetadataService.list() failed for type:`, sanitizedType, 'error:', e.message); + } + } + + // Try ObjectQL registry directly for object/type lookups + const qlService = await deps.getObjectQL(); + if (qlService?.registry) { + if (typeOrName === 'objects') { + const objs = qlService.registry.getAllObjects(packageId); + return { handled: true, response: deps.success({ type: 'object', items: objs }) }; + } + // Try listing items of the given type + const items = qlService.registry.listItems?.(typeOrName, packageId); + if (items && items.length > 0) { + return { handled: true, response: deps.success({ type: typeOrName, items }) }; + } + // Legacy: treat as object name + const obj = qlService.registry.getObject(typeOrName); + if (obj) return { handled: true, response: deps.success(obj) }; + } + return { handled: true, response: deps.error('Not found', 404) }; + } + + // GET /metadata — return available metadata types + if (parts.length === 0) { + // Prefer protocol service for the rich `entries` array (with + // JSON Schemas etc); fall back to MetadataService types-only. + const protocol = await deps.resolveService('protocol'); + if (protocol && typeof protocol.getMetaTypes === 'function') { + try { + const result = await protocol.getMetaTypes({}); + return { handled: true, response: deps.success(result) }; + } catch { /* fall through */ } + } + const metadataService = await deps.resolveService('metadata', _context.environmentId); + if (metadataService && typeof (metadataService as any).getRegisteredTypes === 'function') { + try { + const types = await (metadataService as any).getRegisteredTypes(); + return { handled: true, response: deps.success({ types }) }; + } catch { /* fall through */ } + } + return { handled: true, response: deps.success({ types: ['object', 'app', 'plugin'] }) }; + } + + return { handled: false }; +} diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index e746bb32a1..05fa6c3871 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -2,13 +2,11 @@ import { ObjectKernel, getEnv, evaluateAuthGate, isAuthGateAllowlisted, - shouldDenyAnonymous, ANONYMOUS_DENY_STATUS, ANONYMOUS_DENY_CODE, ANONYMOUS_DENY_MESSAGE, } from '@objectstack/core'; import { isMcpServerEnabled } from '@objectstack/types'; import { measureServerTiming, allowPerfDisclosure, isPerfDisclosurePrincipal } from '@objectstack/observability'; import { CoreServiceName } from '@objectstack/spec/system'; import { readServiceSelfInfo } from '@objectstack/spec/api'; -import { pluralToSingular } from '@objectstack/spec/shared'; import type { ExecutionContext } from '@objectstack/spec/kernel'; import { DomainHandlerRegistry, type DomainRoute, type DomainHandlerDeps } from './domain-handler-registry.js'; import * as actionExec from './action-execution.js'; @@ -26,6 +24,8 @@ import { createAuthDomain, handleAuthRequest } from './domains/auth.js'; import { createAiDomain, handleAIRequest } from './domains/ai.js'; import { createActionsDomain, handleActionsRequest } from './domains/actions.js'; import { createMcpDomains, handleMcpRequest, handleMcpSkillRequest, buildMcpBridge } from './domains/mcp.js'; +import { createMetaDomain, handleMetadataRequest } from './domains/meta.js'; +import { createDataDomain, handleDataRequest } from './domains/data.js'; /** Minimal local interface — full EnvironmentScopeManager was removed in Phase R. */ interface EnvironmentScopeManager { @@ -253,6 +253,7 @@ export class HttpDispatcher { }, logger: (this as any).logger, getDefaultEnvironmentId: () => this.resolveDefaultProject()?.environmentId, + isMultiTenantHost: () => !!this.kernelResolver, resolveProjectKernelObjectQL: async (context) => { if (!this.kernelResolver || !context.environmentId || context.environmentId === 'platform') return null; try { @@ -322,6 +323,8 @@ export class HttpDispatcher { this.domainRegistry.register(createAiDomain(this.domainDeps)); this.domainRegistry.register(createActionsDomain(this.domainDeps)); for (const route of createMcpDomains(this.domainDeps)) this.domainRegistry.register(route); + this.domainRegistry.register(createMetaDomain(this.domainDeps)); + this.domainRegistry.register(createDataDomain(this.domainDeps)); } /** @@ -404,24 +407,6 @@ export class HttpDispatcher { return this.error(e?.message ?? String(e), status, details); } - /** - * ADR-0046: `doc` list responses omit `content` by default — manuals - * are the one metadata payload that grows unbounded, and the list - * surface only needs `name` + `label`. `?include=content` opts back in - * (single-item GET /metadata/doc/:name always returns the full body). - */ - private slimDocList(type: string, data: any, query?: Record): any { - if (type !== 'doc' || query?.include === 'content') return data; - const strip = (items: any[]) => - items.map((i) => { - if (!i || typeof i !== 'object') return i; - const { content: _content, ...rest } = i as Record; - return rest; - }); - if (Array.isArray(data)) return strip(data); - if (data && Array.isArray(data.items)) return { ...data, items: strip(data.items) }; - return data; - } /** * 404 Route Not Found — no route is registered for this path. @@ -897,466 +882,14 @@ export class HttpDispatcher { } - /** - * Handles Metadata requests - * Standard: /metadata/:type/:name - * Fallback for backward compat: /metadata (all objects), /metadata/:objectName (get object) - */ - async handleMetadata(path: string, _context: HttpProtocolContext, method?: string, body?: any, query?: any): Promise { - // Defense-in-depth: the metadata catch-all must honour the same - // `requireAuth` gate as the REST `/meta` routes (which serve `/meta` on - // the cloud runtime). Object/field schemas — SYSTEM-object schemas on a - // tenant-less host — must not be readable by anonymous callers when the - // deployment requires auth. No-op when `requireAuth` is off. - { - const ec: any = _context.executionContext; - if (shouldDenyAnonymous({ requireAuth: this.requireAuth, userId: ec?.userId, isSystem: ec?.isSystem })) { - return { - handled: true, - response: this.error(ANONYMOUS_DENY_MESSAGE, ANONYMOUS_DENY_STATUS, { code: ANONYMOUS_DENY_CODE }), - }; - } - } - const parts = path.replace(/^\/+/, '').split('/').filter(Boolean); - - // GET /metadata/types - if (parts[0] === 'types') { - // PRIORITY 1: Try protocol service — it returns BOTH legacy - // `types: string[]` AND the richer `entries` array (with - // JSON Schemas, allowOrgOverride flags, domain, etc) needed by - // the metadata admin UI. It internally also merges - // MetadataService runtime types, so this path is strictly richer. - const protocol = await this.resolveService('protocol'); - if (protocol && typeof protocol.getMetaTypes === 'function') { - try { - const result = await protocol.getMetaTypes({}); - return { handled: true, response: this.success(result) }; - } catch (e: any) { - console.warn('[HttpDispatcher] protocol.getMetaTypes() failed:', e?.message); - } - } - // PRIORITY 2: MetadataService fallback (types only, no entries) - const metadataService = await this.resolveService('metadata', _context.environmentId); - if (metadataService && typeof (metadataService as any).getRegisteredTypes === 'function') { - try { - const types = await (metadataService as any).getRegisteredTypes(); - return { handled: true, response: this.success({ types }) }; - } catch (e: any) { - console.warn('[HttpDispatcher] MetadataService.getRegisteredTypes() failed:', e.message); - } - } - // Last resort: hardcoded defaults - return { handled: true, response: this.success({ types: ['object', 'app', 'plugin'] }) }; - } - - // GET /metadata/objects/:name/state/:field?from=:state - // ADR-0020 D3.3 introspection: the legal next states declared by the - // object's `state_machine` validation rule for `:field`. Lets UIs / - // AI authors ask "from here, where can this record go?" instead of - // hard-coding the transition table. Returns `next: null` when no FSM - // governs the field, `next: []` for a declared dead-end state. - if (parts.length === 4 && (parts[0] === 'objects' || parts[0] === 'object') && parts[2] === 'state' && (!method || method === 'GET')) { - const name = parts[1]; - const field = parts[3]; - const from = query?.from !== undefined ? String(query.from) : undefined; - const qlService = await this.getObjectQLService(); - const schema = qlService?.registry?.getObject(name); - if (!schema) return { handled: true, response: this.error('Object not found', 404) }; - // Dynamic import (matches the runtime convention for @objectstack/objectql) - // so the dispatcher module graph doesn't statically pull in the objectql barrel. - const { legalNextStates } = await import('@objectstack/objectql'); - const next = from === undefined ? null : legalNextStates(schema, field, from); - return { handled: true, response: this.success({ object: name, field, from: from ?? null, next }) }; - } - - // GET /metadata/:type/:name(/:subname...)/published → get published version - // Supports compound names like `lead/views/all_leads/published`. - if (parts.length >= 3 && parts[parts.length - 1] === 'published' && (!method || method === 'GET')) { - const type = parts[0]; - const name = parts.slice(1, -1).join('/'); - const metadataService = await this.getService(CoreServiceName.enum.metadata); - if (metadataService && typeof (metadataService as any).getPublished === 'function') { - const data = await (metadataService as any).getPublished(type, name); - if (data === undefined) return { handled: true, response: this.error('Not found', 404) }; - return { handled: true, response: this.success(data) }; - } - // Fallback — try MetadataService via resolveService - const metaSvc = await this.resolveService('metadata', _context.environmentId); - if (metaSvc && typeof (metaSvc as any).getPublished === 'function') { - try { - const fallbackData = await (metaSvc as any).getPublished(type, name); - if (fallbackData !== undefined) return { handled: true, response: this.success(fallbackData) }; - } catch { /* fall through */ } - } - return { handled: true, response: this.error('Not found', 404) }; - } - - // /metadata/:type/:name where :name may itself contain slashes - // (e.g. /metadata/lead/views/all_leads → type='lead', name='views/all_leads'). - // Compound names are how the client expresses sub-resources of a type - // (a view of an object, a flow under an automation, etc.) and the - // metadata service treats the full string as the lookup key. - if (parts.length >= 2) { - const type = parts[0]; - const name = parts.slice(1).join('/'); - // Extract optional package filter from query string - const packageId = query?.package || undefined; - - // PUT /metadata/:type/:name (Save) - if (method === 'PUT' && body) { - // Try to get the protocol service directly - const protocol = await this.resolveService('protocol'); - - if (protocol && typeof protocol.saveMetaItem === 'function') { - try { - const organizationId = await this.resolveActiveOrganizationId(_context); - const result = await protocol.saveMetaItem({ type, name, item: body, organizationId, ...(packageId ? { packageId } : {}) }); - return { handled: true, response: this.success(result) }; - } catch (e: any) { - // Preserve the 422 + structured spec-validation `issues` so - // the Studio can point at the offending field, not just a - // generic banner (the old path hardcoded 400 + dropped them). - return { handled: true, response: this.errorFromThrown(e, 400) }; - } - } - - // Fallback: try MetadataService directly - const metaSvc = await this.resolveService('metadata', _context.environmentId); - if (metaSvc && typeof (metaSvc as any).saveItem === 'function') { - try { - const data = await (metaSvc as any).saveItem(type, name, body); - return { handled: true, response: this.success(data) }; - } catch (e: any) { - return { handled: true, response: this.error(e.message || 'Save not supported', 501) }; - } - } - return { handled: true, response: this.error('Save not supported', 501) }; - } - - try { - // Try specific calls based on type - if (type === 'objects' || type === 'object') { - // Check whether the kernel is project-scoped. When it is, - // the process-wide SchemaRegistry is unsafe to query - // directly — it would return objects that other projects - // wrote in this same process. Route through the Protocol - // service (which filters sys_metadata by environment_id) in that - // case, and fall back to the registry only for the - // unscoped (single-kernel / control-plane) path. - const protocol = await this.resolveService('protocol') as any; - const scopedEnv = typeof protocol?.getProjectId === 'function' - ? protocol.getProjectId() - : protocol?.environmentId; - const scoped = scopedEnv !== undefined; - - if (scoped && typeof protocol.getMetaItem === 'function') { - try { - const organizationId = await this.resolveActiveOrganizationId(_context); - const data = await protocol.getMetaItem({ type: 'object', name, organizationId }); - // Protocol returns `{ type, name, item }` — only - // treat the lookup as a hit when item is present. - if (data && (data.item ?? data)) { - return { handled: true, response: this.success(data) }; - } - } catch { /* fall through to registry / 404 */ } - } - - const qlService = await this.getObjectQLService(); - if (qlService?.registry) { - const data = qlService.registry.getObject(name); - if (data) return { handled: true, response: this.success(data) }; - } - - // Last-ditch protocol attempt for unscoped kernels whose - // registry missed (e.g. object persisted to DB but not - // yet hydrated). Skip when we already tried above. - if (!scoped && protocol && typeof protocol.getMetaItem === 'function') { - try { - const organizationId = await this.resolveActiveOrganizationId(_context); - const data = await protocol.getMetaItem({ type: 'object', name, organizationId }); - if (data && (data.item ?? data)) { - return { handled: true, response: this.success(data) }; - } - } catch { /* fall through to 404 */ } - } - return { handled: true, response: this.error('Not found', 404) }; - } - - // Normalize plural URL paths to singular registry type names - const singularType = pluralToSingular(type); - - // Try Protocol Service First (Preferred) - const protocol = await this.resolveService('protocol'); - if (protocol && typeof protocol.getMetaItem === 'function') { - try { - const organizationId = await this.resolveActiveOrganizationId(_context); - // ADR-0033 draft-overlay preview: `?preview=draft` makes the - // detail read prefer a pending draft (falling back to active). - // Admin gating is layered on top in a follow-up (step 2). - const previewDrafts = query?.preview === 'draft'; - const data = await protocol.getMetaItem({ type: singularType, name, packageId, organizationId, previewDrafts }); - return { handled: true, response: this.success(data) }; - } catch (e: any) { - // Protocol might throw if not found or not supported - } - } - - // Try MetadataService for runtime-registered types - const metaSvc = await this.resolveService('metadata', _context.environmentId); - if (metaSvc && typeof (metaSvc as any).getItem === 'function') { - try { - // ADR-0048 — thread `?package=` so single-item resolution is - // package-scoped (prefer-local), matching list resolution. - const data = await (metaSvc as any).getItem(singularType, name, packageId); - if (data) return { handled: true, response: this.success(data) }; - } catch { /* not found */ } - } - return { handled: true, response: this.error('Not found', 404) }; - } catch (e: any) { - // Fallback: treat first part as object name if only 1 part (handled below) - // But here we are deep in 2 parts. Must be an error. - return { handled: true, response: this.error(e.message, 404) }; - } - } - - // GET /metadata/_drafts?packageId=&type= (ADR-0033 pending-changes list) - // Surfaces draft-state metadata the active-only `getMetaItems` list hides, - // so the console can show what an AI authored but nobody published yet. - // `_drafts` is intercepted before the generic `:type` handler below so it - // is never mistaken for a metadata type name. - if (parts.length === 1 && parts[0] === '_drafts' && (!method || method.toUpperCase() === 'GET')) { - const protocol = await this.resolveService('protocol'); - if (protocol && typeof protocol.listDrafts === 'function') { - try { - const organizationId = await this.resolveActiveOrganizationId(_context); - const data = await protocol.listDrafts({ - packageId: query?.packageId || undefined, - type: query?.type || undefined, - organizationId, - }); - return { handled: true, response: this.success(data) }; - } catch (e: any) { - return { handled: true, response: this.error(e.message, 500) }; - } - } - return { handled: true, response: this.error('Draft listing not supported', 501) }; - } - - // GET /metadata/:type (List items of type) OR /metadata/:objectName (Legacy) - if (parts.length === 1) { - const typeOrName = parts[0]; - // Extract optional package filter from query string - const packageId = query?.package || undefined; - - // Try protocol service first for any type - const protocol = await this.resolveService('protocol'); - if (protocol && typeof protocol.getMetaItems === 'function') { - try { - const organizationId = await this.resolveActiveOrganizationId(_context); - // ADR-0033 draft-overlay preview: `?preview=draft` overlays - // pending drafts on the active list so an (admin) reviewer can - // render the console off drafts before publishing. - const previewDrafts = query?.preview === 'draft'; - const data = await protocol.getMetaItems({ type: typeOrName, packageId, organizationId, previewDrafts }); - // Return any valid response from protocol (including empty items arrays) - if (data && (data.items !== undefined || Array.isArray(data))) { - return { handled: true, response: this.success(this.slimDocList(typeOrName, data, query)) }; - } - } catch { - // Protocol doesn't know this type, fall through - } - } - - // Try MetadataService directly for runtime-registered metadata (agents, tools, etc.) - const metadataService = await this.getService(CoreServiceName.enum.metadata); - if (metadataService && typeof (metadataService as any).list === 'function') { - try { - let items = await (metadataService as any).list(typeOrName); - // Respect package filter: MetadataService.list() returns ALL items, - // so filter by _packageId when a specific package is requested. - if (packageId && items && items.length > 0) { - items = items.filter((item: any) => item?._packageId === packageId); - } - if (items && items.length > 0) { - return { handled: true, response: this.success({ type: typeOrName, items: this.slimDocList(typeOrName, items, query) }) }; - } - } catch (e: any) { - // MetadataService doesn't know this type or failed, continue to other fallbacks - // Sanitize typeOrName to prevent log injection (CodeQL warning) - const sanitizedType = String(typeOrName).replace(/[\r\n\t]/g, ''); - console.debug(`[HttpDispatcher] MetadataService.list() failed for type:`, sanitizedType, 'error:', e.message); - } - } - - // Try ObjectQL registry directly for object/type lookups - const qlService = await this.getObjectQLService(); - if (qlService?.registry) { - if (typeOrName === 'objects') { - const objs = qlService.registry.getAllObjects(packageId); - return { handled: true, response: this.success({ type: 'object', items: objs }) }; - } - // Try listing items of the given type - const items = qlService.registry.listItems?.(typeOrName, packageId); - if (items && items.length > 0) { - return { handled: true, response: this.success({ type: typeOrName, items }) }; - } - // Legacy: treat as object name - const obj = qlService.registry.getObject(typeOrName); - if (obj) return { handled: true, response: this.success(obj) }; - } - return { handled: true, response: this.error('Not found', 404) }; - } - - // GET /metadata — return available metadata types - if (parts.length === 0) { - // Prefer protocol service for the rich `entries` array (with - // JSON Schemas etc); fall back to MetadataService types-only. - const protocol = await this.resolveService('protocol'); - if (protocol && typeof protocol.getMetaTypes === 'function') { - try { - const result = await protocol.getMetaTypes({}); - return { handled: true, response: this.success(result) }; - } catch { /* fall through */ } - } - const metadataService = await this.resolveService('metadata', _context.environmentId); - if (metadataService && typeof (metadataService as any).getRegisteredTypes === 'function') { - try { - const types = await (metadataService as any).getRegisteredTypes(); - return { handled: true, response: this.success({ types }) }; - } catch { /* fall through */ } - } - return { handled: true, response: this.success({ types: ['object', 'app', 'plugin'] }) }; - } - - return { handled: false }; + /** Thin delegate — body extracted to `./domains/meta.ts` (D11③ PR-10). */ + async handleMetadata(path: string, context: HttpProtocolContext, method: string = 'GET', body?: any, query?: any): Promise { + return handleMetadataRequest(this.domainDeps, path, context, method, body, query); } - /** - * Handles Data requests - * path: sub-path after /data/ (e.g. "contacts", "contacts/123", "contacts/query") - */ + /** Thin delegate — body extracted to `./domains/data.ts` (D11③ PR-10). */ async handleData(path: string, method: string, body: any, query: any, _context: HttpProtocolContext): Promise { - const parts = path.replace(/^\/+/, '').split('/'); - const objectName = parts[0]; - - if (!objectName) { - return { handled: true, response: this.error('Object name required', 400) }; - } - - // Check if environment is resolved for data-plane requests. A - // registered KernelResolver marks this host as multi-tenant (ADR-0006 - // Phase 5 — previously signalled by the env-registry service): a - // data-plane request that the resolver did not attach to an - // environment must not silently fall through to the host kernel. - if (!_context.dataDriver && this.kernelResolver) { - return { - handled: true, - response: this.error('Project not resolved. Please specify X-Environment-Id header or ensure hostname maps to a project.', 428) - }; - } - - const m = method.toUpperCase(); - - // 1. Custom Actions (query, batch) - if (parts.length > 1) { - const action = parts[1]; - - // POST /data/:object/query - if (action === 'query' && m === 'POST') { - // Spec: returns FindDataResponse = { object, records, total?, hasMore? } - const result = await this.callData('query', { object: objectName, ...body }, _context.dataDriver, _context.environmentId, _context.executionContext); - return { handled: true, response: this.success(result) }; - } - - // GET /data/:object/:id - if (parts.length === 2 && m === 'GET') { - const id = parts[1]; - // Spec: Only select/expand are allowlisted query params for GET by ID. - // All other query parameters are discarded to prevent parameter pollution. - const { select, expand } = query || {}; - const allowedParams: Record = {}; - if (select != null) allowedParams.select = select; - if (expand != null) allowedParams.expand = expand; - // Spec: returns GetDataResponse = { object, id, record } - const result = await this.callData('get', { object: objectName, id, ...allowedParams }, _context.dataDriver, _context.environmentId, _context.executionContext); - return { handled: true, response: this.success(result) }; - } - - // PATCH /data/:object/:id - if (parts.length === 2 && m === 'PATCH') { - const id = parts[1]; - // Spec: returns UpdateDataResponse = { object, id, record } - const result = await this.callData('update', { object: objectName, id, data: body }, _context.dataDriver, _context.environmentId, _context.executionContext); - return { handled: true, response: this.success(result) }; - } - - // DELETE /data/:object/:id - if (parts.length === 2 && m === 'DELETE') { - const id = parts[1]; - // Spec: returns DeleteDataResponse = { object, id, deleted } - const result = await this.callData('delete', { object: objectName, id }, _context.dataDriver, _context.environmentId, _context.executionContext); - return { handled: true, response: this.success(result) }; - } - } else { - // GET /data/:object (List) - if (m === 'GET') { - // ── Normalize HTTP transport params → Spec canonical (QueryAST) ── - // HTTP GET query params use transport-level names (filter, sort, top, - // skip, select, expand) which are normalized here to canonical - // QueryAST field names (where, orderBy, limit, offset, fields, - // expand) before forwarding to the data service layer. - // The protocol.ts findData() method performs a deeper normalization - // pass, but pre-normalizing here ensures the data service always receives - // Spec-canonical keys. - const normalized: Record = { ...query }; - - // filter/filters → where - // Note: `filter` is the canonical HTTP *transport* parameter name - // (see HttpFindQueryParamsSchema). It is normalized here to the - // canonical *QueryAST* field name `where` before data dispatch. - // `filters` (plural) is a deprecated alias for `filter`. - if (normalized.filter != null || normalized.filters != null) { - normalized.where = normalized.where ?? normalized.filter ?? normalized.filters; - delete normalized.filter; - delete normalized.filters; - } - // select → fields - if (normalized.select != null && normalized.fields == null) { - normalized.fields = normalized.select; - delete normalized.select; - } - // sort → orderBy - if (normalized.sort != null && normalized.orderBy == null) { - normalized.orderBy = normalized.sort; - delete normalized.sort; - } - // top → limit - if (normalized.top != null && normalized.limit == null) { - normalized.limit = normalized.top; - delete normalized.top; - } - // skip → offset - if (normalized.skip != null && normalized.offset == null) { - normalized.offset = normalized.skip; - delete normalized.skip; - } - - // Spec: returns FindDataResponse = { object, records, total?, hasMore? } - const result = await this.callData('query', { object: objectName, query: normalized }, _context.dataDriver, _context.environmentId, _context.executionContext); - return { handled: true, response: this.success(result) }; - } - - // POST /data/:object (Create) - if (m === 'POST') { - // Spec: returns CreateDataResponse = { object, id, record } - const result = await this.callData('create', { object: objectName, data: body }, _context.dataDriver, _context.environmentId, _context.executionContext); - const res = this.success(result); - res.status = 201; - return { handled: true, response: res }; - } - } - - return { handled: false }; + return handleDataRequest(this.domainDeps, path, method, body, query, _context); } /** @@ -1721,13 +1254,7 @@ export class HttpDispatcher { // 1. System Protocols (Prefix-based) // /auth moved to the domain registry (D11 step ③). - if (cleanPath.startsWith('/meta')) { - return this.handleMetadata(cleanPath.substring(5), context, method, body, query); - } - - if (cleanPath.startsWith('/data')) { - return this.handleData(cleanPath.substring(5), method, body, query, context); - } + // /meta and /data moved to the domain registry (D11 step ③ — the if-chain is now EMPTY of domains). // /mcp and /mcp/skill moved to the domain registry (D11 step ③).