From 2d547ebd180df7da866d2bfb078036cb98517e53 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Mon, 27 Jul 2026 09:15:26 +0800 Subject: [PATCH] =?UTF-8?q?feat(runtime):=20extract=20first=20four=20dispa?= =?UTF-8?q?tcher=20domain=20bodies=20into=20domains/=20modules=20=E2=80=94?= =?UTF-8?q?=20ADR-0076=20D11=20step=20=E2=91=A2=20PR-2=20(#2462)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves the /analytics, /i18n, /notifications and /security handler bodies out of the HttpDispatcher god class into per-domain modules under src/domains/, each running against the explicit DomainHandlerDeps contract (resolveService/getService/success/error — the whole dispatcher surface a domain may touch, made visible). Thin handleXxx delegates stay for direct callers; /notifications + /security leave the legacy if-chain for the registry, with new match:'segment' preserving their `=== p || startsWith(p+'/')` branch shape exactly. Key call: registration stays dispatcher-owned. The original plan said "move handlers to their owning service package", but most slots are multi-provider — i18n is served by I18nServicePlugin OR AppPlugin's in-memory fallback (app-plugin.ts auto-registers it for stacks declaring translation bundles); analytics by service-analytics OR the ObjectQLPlugin fallback. A route is the bridge to a SLOT, not the property of one provider — registration moving into one provider would 404 the others. Packages that DO own a slot exclusively can still self-register via registerDomainHandler(). Verified: seam suite 18 tests; runtime 617 green; http-conformance 41 cross-adapter assertions green; 25-package dependent closure builds with DTS (--force). Co-Authored-By: Claude Fable 5 --- .changeset/runtime-domain-body-extraction.md | 21 ++ .../src/domain-handler-registry.test.ts | 76 +++++ .../runtime/src/domain-handler-registry.ts | 56 +++- packages/runtime/src/domains/analytics.ts | 63 +++++ packages/runtime/src/domains/i18n.ts | 105 +++++++ packages/runtime/src/domains/notifications.ts | 77 +++++ packages/runtime/src/domains/security.ts | 101 +++++++ packages/runtime/src/http-dispatcher.ts | 262 +++--------------- packages/runtime/src/index.ts | 2 +- 9 files changed, 528 insertions(+), 235 deletions(-) create mode 100644 .changeset/runtime-domain-body-extraction.md create mode 100644 packages/runtime/src/domains/analytics.ts create mode 100644 packages/runtime/src/domains/i18n.ts create mode 100644 packages/runtime/src/domains/notifications.ts create mode 100644 packages/runtime/src/domains/security.ts diff --git a/.changeset/runtime-domain-body-extraction.md b/.changeset/runtime-domain-body-extraction.md new file mode 100644 index 0000000000..d0f91eb4c9 --- /dev/null +++ b/.changeset/runtime-domain-body-extraction.md @@ -0,0 +1,21 @@ +--- +"@objectstack/runtime": minor +--- + +feat(runtime): extract the first four dispatcher domain bodies into `domains/` modules — ADR-0076 D11 step ③, PR-2 (#2462) + +The `/analytics`, `/i18n`, `/notifications` and `/security` handler bodies +move out of the `HttpDispatcher` god class into per-domain modules under +`packages/runtime/src/domains/`, running against an explicit +`DomainHandlerDeps` contract (resolveService / getService / success / error — +the WHOLE dispatcher surface a domain may touch). The dispatcher keeps thin +`handleXxx` delegates for direct callers, and `/notifications` + `/security` +leave the legacy if-chain for the domain registry (new `match: 'segment'` +preserves their `=== p || startsWith(p + '/')` branch shape exactly). + +Route registration stays dispatcher-owned on purpose: most service slots are +multi-provider (i18n = I18nServicePlugin OR the AppPlugin in-memory fallback; +analytics = service-analytics OR the ObjectQLPlugin fallback), so a route is +the bridge to a SLOT, not the property of any one providing package. Zero +behavior change — http-conformance (41 cross-adapter assertions) and the +seam suite (18 tests) lock it. diff --git a/packages/runtime/src/domain-handler-registry.test.ts b/packages/runtime/src/domain-handler-registry.test.ts index f716abbb59..28b98824c3 100644 --- a/packages/runtime/src/domain-handler-registry.test.ts +++ b/packages/runtime/src/domain-handler-registry.test.ts @@ -152,3 +152,79 @@ describe('HttpDispatcher domain registry (D11 step ③)', () => { expect(result.response?.body?.echo).toBe('/custom-domain/thing'); }); }); + +// --------------------------------------------------------------------------- +// PR-2 — segment matching + extracted notification/security domains +// --------------------------------------------------------------------------- + +describe('DomainHandlerRegistry segment matching (PR-2)', () => { + it("match: 'segment' claims the prefix and slash-separated sub-paths, but not lexical extensions", () => { + const registry = new DomainHandlerRegistry(); + registry.register({ prefix: '/security', match: 'segment', handler: okHandler('s') }); + expect(registry.resolve('/security', 'GET')).toBeDefined(); + expect(registry.resolve('/security/suggested-bindings', 'GET')).toBeDefined(); + // The legacy `=== p || startsWith(p + '/')` shape: '/securityfoo' is NOT claimed. + expect(registry.resolve('/securityfoo', 'GET')).toBeUndefined(); + }); +}); + +describe('HttpDispatcher extracted domains (PR-2)', () => { + it('/notifications requires an authenticated user (401) when the service is wired', async () => { + const notification = { listInbox: vi.fn().mockResolvedValue([]), markRead: vi.fn(), markAllRead: vi.fn() }; + const result = await makeDispatcher({ notification }).dispatch('GET', '/notifications', undefined, {}, {} as any); + expect(result.handled).toBe(true); + expect(result.response?.status).toBe(401); + }); + + it('/notifications lists the inbox for an authenticated user (thin delegate carries the extracted body)', async () => { + const notification = { listInbox: vi.fn().mockResolvedValue([{ id: 'n1' }]), markRead: vi.fn(), markAllRead: vi.fn() }; + // Call the public delegate directly: dispatch() re-resolves identity + // from the (mock, auth-less) kernel and would overwrite the seeded + // executionContext with an anonymous one. + const context: any = { executionContext: { userId: 'u1' } }; + const result = await makeDispatcher({ notification }).handleNotification('', 'GET', undefined, { limit: '5' }, context); + expect(result.response?.status).toBe(200); + expect(notification.listInbox).toHaveBeenCalledWith('u1', expect.objectContaining({ limit: 5 })); + }); + + it('/security responds 503 when no security service is wired (legacy in-handler semantics)', async () => { + const result = await makeDispatcher().dispatch('GET', '/security/suggested-bindings', undefined, {}, {} as any); + expect(result.handled).toBe(true); + expect(result.response?.status).toBe(503); + }); + + it('/security denies anonymous callers unconditionally when the service is wired', async () => { + const security = { + listAudienceBindingSuggestions: vi.fn().mockResolvedValue([]), + confirmAudienceBindingSuggestion: vi.fn(), + dismissAudienceBindingSuggestion: vi.fn(), + }; + const result = await makeDispatcher({ security }).dispatch('GET', '/security/suggested-bindings', undefined, {}, {} as any); + expect(result.handled).toBe(true); + expect(result.response?.status).toBe(401); + expect(security.listAudienceBindingSuggestions).not.toHaveBeenCalled(); + }); + + it('/security lists suggestions for an authenticated caller (thin delegate carries the extracted body)', async () => { + const security = { + listAudienceBindingSuggestions: vi.fn().mockResolvedValue([{ id: 's1' }]), + confirmAudienceBindingSuggestion: vi.fn(), + dismissAudienceBindingSuggestion: vi.fn(), + }; + // Direct delegate call for the same reason as the notifications case. + const context: any = { executionContext: { userId: 'admin-1' } }; + const result = await makeDispatcher({ security }).handleSecurity('/suggested-bindings', 'GET', undefined, { status: 'open' }, context); + expect(result.response?.status).toBe(200); + expect(security.listAudienceBindingSuggestions).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'admin-1' }), + expect.objectContaining({ status: 'open' }), + ); + }); + + it('/securityfoo is NOT claimed by the security domain (segment semantics preserved from the if-chain)', async () => { + const security = { listAudienceBindingSuggestions: vi.fn() }; + const result = await makeDispatcher({ security }).dispatch('GET', '/securityfoo', undefined, {}, {} as any); + expect(security.listAudienceBindingSuggestions).not.toHaveBeenCalled(); + expect(result.response?.status ?? 404).not.toBe(200); + }); +}); diff --git a/packages/runtime/src/domain-handler-registry.ts b/packages/runtime/src/domain-handler-registry.ts index fa476cd78e..109f30f53b 100644 --- a/packages/runtime/src/domain-handler-registry.ts +++ b/packages/runtime/src/domain-handler-registry.ts @@ -11,17 +11,21 @@ * is the decomposition seam: `dispatch()` consults it FIRST, and domains are * migrated out of the if-chain one PR at a time. * - * Migration discipline (registry first, code moves later, ownership last): - * 1. This PR: the dispatcher wraps its existing `handleXxx` methods into + * Migration discipline (registry first, then extract bodies): + * 1. PR-1: the dispatcher wraps its existing `handleXxx` methods into * registry entries at construction time — same matching semantics, same * handler bodies, zero behavior change (locked by the http-conformance * cross-adapter suite). - * 2. Follow-up PRs: a domain's handler body moves into its owning service - * package, which registers the entry itself (via - * {@link HttpDispatcher.registerDomainHandler}). Service-absence - * semantics (today's in-handler 501 vs route-not-mounted 404) are decided - * per-domain at THAT point, together with the D12 honest-capabilities - * discovery contract. + * 2. PR-2..N: each domain's handler BODY moves to a module under + * `./domains/`, depending only on the explicit {@link DomainHandlerDeps} + * contract. Registration stays dispatcher-owned on purpose: most service + * slots are multi-provider (e.g. `i18n` is served by I18nServicePlugin + * OR the AppPlugin in-memory fallback; `analytics` by service-analytics + * OR the ObjectQLPlugin fallback), so a route is the bridge to a SLOT, + * not the property of any one providing package — moving registration + * into one provider would 404 the others. External packages that DO own + * a slot exclusively can still self-register via + * {@link HttpDispatcher.registerDomainHandler}. * * Matching semantics are deliberately faithful to the legacy if-chain, * INCLUDING its rough edges (`match: 'prefix'` on `/i18n` also matches @@ -56,13 +60,32 @@ export interface DomainRoute { /** * `'prefix'` — legacy `startsWith(prefix)` semantics (default). * `'exact'` — the path must equal the prefix exactly. + * `'segment'` — exact, or followed by `'/'` (the legacy + * `=== p || startsWith(p + '/')` branch shape; does NOT claim `/i18nxx`). */ - match?: 'prefix' | 'exact'; + match?: 'prefix' | 'exact' | 'segment'; /** Restrict to these UPPERCASE HTTP methods. Omit = all methods. */ methods?: string[]; handler: DomainHandler; } +/** + * The dispatcher facilities an extracted domain body is allowed to use — the + * WHOLE dependency contract, made explicit. Growing this interface is a + * design decision, not a convenience: every addition couples all domains to + * more dispatcher surface. + */ +export interface DomainHandlerDeps { + /** Environment-scoped service resolution (per-request kernel aware). */ + resolveService(name: string, environmentId?: string): any; + /** Unscoped service lookup on the current kernel (may return a Promise). */ + getService(name: string): any; + /** Standard success envelope. */ + success(data: any, meta?: any): { status: number; body: any }; + /** Standard error envelope. */ + error(message: string, code?: number, details?: any): { status: number; body: any }; +} + /** * First-match-wins routing table, in registration order. Kept deliberately * minimal — no wildcards, no params, no middleware: those belong to the real @@ -83,13 +106,22 @@ export class DomainHandlerRegistry { const m = method.toUpperCase(); for (const route of this.routes) { if (route.methods && !route.methods.includes(m)) continue; - if (route.match === 'exact' ? path === route.prefix : path.startsWith(route.prefix)) { - return route; - } + if (DomainHandlerRegistry.matches(route, path)) return route; } return undefined; } + private static matches(route: DomainRoute, path: string): boolean { + switch (route.match) { + case 'exact': + return path === route.prefix; + case 'segment': + return path === route.prefix || path.startsWith(route.prefix + '/'); + default: + return path.startsWith(route.prefix); + } + } + /** Registered routes, in match order (introspection / tests). */ list(): readonly DomainRoute[] { return this.routes; diff --git a/packages/runtime/src/domains/analytics.ts b/packages/runtime/src/domains/analytics.ts new file mode 100644 index 0000000000..d728abaa64 --- /dev/null +++ b/packages/runtime/src/domains/analytics.ts @@ -0,0 +1,63 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `/analytics` domain — extracted dispatcher body (ADR-0076 D11 step ③, + * PR-2). Bridges to whatever provides the `analytics` service slot: the + * service-analytics engine when installed, or the ObjectQLPlugin degraded + * fallback otherwise (deliberate fallback + `replaceService`, see ADR-0076 + * D10/D12) — which is exactly why route registration stays dispatcher-owned. + */ + +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 createAnalyticsDomain(deps: DomainHandlerDeps): DomainRoute { + return { + prefix: '/analytics', + handler: (req, context) => + handleAnalyticsRequest(deps, req.path.substring(10), req.method, req.body, context), + }; +} + +/** Body kept signature-compatible with the legacy `HttpDispatcher.handleAnalytics`. */ +export async function handleAnalyticsRequest( + deps: DomainHandlerDeps, + path: string, + method: string, + body: any, + context: HttpProtocolContext, +): Promise { + const analyticsService = await deps.getService(CoreServiceName.enum.analytics); + if (!analyticsService) return { handled: false }; // 404 handled by caller if unhandled + + const m = method.toUpperCase(); + const subPath = path.replace(/^\/+/, ''); + + // POST /analytics/query + if (subPath === 'query' && m === 'POST') { + // [#2852] Pass the request's execution context so the analytics + // service scopes each object by its per-object read filter (tenant + + // RLS). Without it, `getReadScope(object, undefined)` returned no + // filter and the query ran UNSCOPED — an authenticated caller saw + // rows RLS would otherwise hide. + const result = await analyticsService.query(body, context?.executionContext); + return { handled: true, response: deps.success(result) }; + } + + // GET /analytics/meta + if (subPath === 'meta' && m === 'GET') { + const result = await analyticsService.getMeta(); + return { handled: true, response: deps.success(result) }; + } + + // POST /analytics/sql (Dry-run or debug) + if (subPath === 'sql' && m === 'POST') { + // [#2852] Scope the generated SQL to the caller too, so a preview + // reflects the same per-object read filter the real query applies. + const result = await analyticsService.generateSql(body, context?.executionContext); + return { handled: true, response: deps.success(result) }; + } + + return { handled: false }; +} diff --git a/packages/runtime/src/domains/i18n.ts b/packages/runtime/src/domains/i18n.ts new file mode 100644 index 0000000000..9de488fa00 --- /dev/null +++ b/packages/runtime/src/domains/i18n.ts @@ -0,0 +1,105 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `/i18n` domain — extracted dispatcher body (ADR-0076 D11 step ③, PR-2). + * Serves translations / locales / labels from whatever provides the `i18n` + * service slot: I18nServicePlugin (service-i18n) when installed, or the + * AppPlugin in-memory fallback auto-registered for stacks that declare + * translation bundles — multi-provider slot, so route registration stays + * dispatcher-owned (moving it into one provider would 404 the other). + * + * Routes (path is the sub-path after `/i18n`): + * GET /locales → getLocales + * GET /translations/:locale → getTranslations (locale from path) + * GET /translations?locale=xx → getTranslations (locale from query) + * GET /labels/:object/:locale → getFieldLabels (both from path) + * GET /labels/:object?locale=xx → getFieldLabels (locale from query) + */ + +import { resolveLocale } from '@objectstack/core'; +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 createI18nDomain(deps: DomainHandlerDeps): DomainRoute { + return { + prefix: '/i18n', + handler: (req, context) => + handleI18nRequest(deps, req.path.substring(5), req.method, req.query, context), + }; +} + +/** Body kept signature-compatible with the legacy `HttpDispatcher.handleI18n`. */ +export async function handleI18nRequest( + deps: DomainHandlerDeps, + path: string, + method: string, + query: any, + _context: HttpProtocolContext, +): Promise { + const i18nService = await deps.getService(CoreServiceName.enum.i18n); + if (!i18nService) return { handled: true, response: deps.error('i18n service not available', 501) }; + + const m = method.toUpperCase(); + const parts = path.replace(/^\/+/, '').split('/').filter(Boolean); + + if (m !== 'GET') return { handled: false }; + + // GET /i18n/locales + if (parts[0] === 'locales' && parts.length === 1) { + const locales = i18nService.getLocales(); + return { handled: true, response: deps.success({ locales }) }; + } + + // GET /i18n/translations/:locale OR /i18n/translations?locale=xx + if (parts[0] === 'translations') { + const locale = parts[1] ? decodeURIComponent(parts[1]) : query?.locale; + if (!locale) return { handled: true, response: deps.error('Missing locale parameter', 400) }; + + let translations = i18nService.getTranslations(locale); + + // Locale fallback: try resolving to an available locale when + // the exact code yields empty translations (e.g. zh → zh-CN). + if (Object.keys(translations).length === 0) { + const availableLocales = typeof i18nService.getLocales === 'function' + ? i18nService.getLocales() : []; + const resolved = resolveLocale(locale, availableLocales); + if (resolved && resolved !== locale) { + translations = i18nService.getTranslations(resolved); + return { handled: true, response: deps.success({ locale: resolved, requestedLocale: locale, translations }) }; + } + } + + return { handled: true, response: deps.success({ locale, translations }) }; + } + + // GET /i18n/labels/:object/:locale OR /i18n/labels/:object?locale=xx + if (parts[0] === 'labels' && parts.length >= 2) { + const objectName = decodeURIComponent(parts[1]); + let locale = parts[2] ? decodeURIComponent(parts[2]) : query?.locale; + if (!locale) return { handled: true, response: deps.error('Missing locale parameter', 400) }; + + // Locale fallback for labels endpoint + const availableLocales = typeof i18nService.getLocales === 'function' + ? i18nService.getLocales() : []; + const resolved = resolveLocale(locale, availableLocales); + if (resolved) locale = resolved; + + if (typeof i18nService.getFieldLabels === 'function') { + const labels = i18nService.getFieldLabels(objectName, locale); + return { handled: true, response: deps.success({ object: objectName, locale, labels }) }; + } + // Fallback: derive field labels from full translation bundle + const translations = i18nService.getTranslations(locale); + const prefix = `o.${objectName}.fields.`; + const labels: Record = {}; + for (const [key, value] of Object.entries(translations)) { + if (key.startsWith(prefix)) { + labels[key.substring(prefix.length)] = value as string; + } + } + return { handled: true, response: deps.success({ object: objectName, locale, labels }) }; + } + + return { handled: false }; +} diff --git a/packages/runtime/src/domains/notifications.ts b/packages/runtime/src/domains/notifications.ts new file mode 100644 index 0000000000..90c7f6929c --- /dev/null +++ b/packages/runtime/src/domains/notifications.ts @@ -0,0 +1,77 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `/notifications` domain — extracted dispatcher body (ADR-0076 D11 step ③, + * PR-2). In-app notifications (ADR-0030): the inbox surface backed by the + * messaging service registered under the `notification` slot. Reads the L5 + * `sys_inbox_message` + `sys_notification_receipt` join; mark-read upserts + * the receipt keyed `(notification_id, user_id, channel:'inbox')`. The + * routes are `auth: true`, so an authenticated user is required. + * + * NOTE (cross-repo, see #2462 step-① re-scope): this domain has NO other + * HTTP owner anywhere — cloud hosts reach it through the `dispatch()` + * delegation, so this handler must keep working from the registry exactly + * as it did from the if-chain. + * + * Routes (path is the sub-path after `/notifications`): + * GET '' → listInbox (query: read, type, limit) + * POST /read → markRead (body: { ids: string[] }) + * POST /read/all → markAllRead + */ + +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 createNotificationsDomain(deps: DomainHandlerDeps): DomainRoute { + return { + prefix: '/notifications', + handler: (req, context) => + handleNotificationRequest(deps, req.path.substring(14), req.method, req.body, req.query, context), + }; +} + +/** Body kept signature-compatible with the legacy `HttpDispatcher.handleNotification`. */ +export async function handleNotificationRequest( + deps: DomainHandlerDeps, + path: string, + method: string, + body: any, + query: any, + context: HttpProtocolContext, +): Promise { + const service = await deps.resolveService(CoreServiceName.enum.notification, context.environmentId) as any; + if (!service || typeof service.listInbox !== 'function') return { handled: false }; + + const userId: string | undefined = context.executionContext?.userId; + if (!userId) { + return { handled: true, response: deps.error('Authentication required', 401) }; + } + + const m = method.toUpperCase(); + const subPath = path.replace(/^\/+/, '').replace(/\/+$/, ''); + + // GET /notifications — list the user's inbox joined with read-state. + if (subPath === '' && m === 'GET') { + const read = query?.read === undefined ? undefined : String(query.read) === 'true'; + const limit = query?.limit ? Number(query.limit) : undefined; + const type = query?.type ? String(query.type) : undefined; + const result = await service.listInbox(userId, { read, type, limit }); + return { handled: true, response: deps.success(result) }; + } + + // POST /notifications/read — mark specific notifications read. + if (subPath === 'read' && m === 'POST') { + const ids: string[] = Array.isArray(body?.ids) ? body.ids.map((x: unknown) => String(x)) : []; + const result = await service.markRead(userId, ids); + return { handled: true, response: deps.success(result) }; + } + + // POST /notifications/read/all — mark all of the user's inbox read. + if (subPath === 'read/all' && m === 'POST') { + const result = await service.markAllRead(userId); + return { handled: true, response: deps.success(result) }; + } + + return { handled: false }; +} diff --git a/packages/runtime/src/domains/security.ts b/packages/runtime/src/domains/security.ts new file mode 100644 index 0000000000..088afdcb05 --- /dev/null +++ b/packages/runtime/src/domains/security.ts @@ -0,0 +1,101 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `/security` domain — extracted dispatcher body (ADR-0076 D11 step ③, + * PR-2). The security admin surface (ADR-0090 D5/D9): suggested audience + * bindings. A package's `isDefault: true` permission set is an install-time + * SUGGESTION to bind it to the `everyone` position; these routes let an + * admin see and resolve those suggestions. The `security` service does the + * real gating (tenant-admin pre-check, and the confirm write runs under the + * audience-anchor + delegated-admin gates with the caller's execution + * context — never auto-bound, never system). + * + * NOTE (cross-repo, see #2462 step-① re-scope): cloud's per-env kernels can + * reach this via `dispatch()` delegation (`/share-links` pattern), so the + * handler must keep working from the registry exactly as from the if-chain. + * + * Routes: + * GET /security/suggested-bindings?status=&packageId= → list (reconciles first) + * POST /security/suggested-bindings/:id/confirm → create the anchor binding + * POST /security/suggested-bindings/:id/dismiss → decline the suggestion + */ + +import { + shouldDenyAnonymous, ANONYMOUS_DENY_STATUS, ANONYMOUS_DENY_CODE, ANONYMOUS_DENY_MESSAGE, +} from '@objectstack/core'; +import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js'; +import type { DomainHandlerDeps, DomainRoute } from '../domain-handler-registry.js'; + +export function createSecurityDomain(deps: DomainHandlerDeps): DomainRoute { + return { + prefix: '/security', + match: 'segment', + handler: (req, context) => + handleSecurityRequest(deps, req.path.substring(9), req.method, req.body, req.query, context), + }; +} + +/** Body kept signature-compatible with the legacy `HttpDispatcher.handleSecurity`. */ +export async function handleSecurityRequest( + deps: DomainHandlerDeps, + path: string, + method: string, + _body: any, + query: any, + context: HttpProtocolContext, +): Promise { + const service = await deps.resolveService('security', context.environmentId) as any; + if (!service || typeof service.listAudienceBindingSuggestions !== 'function') { + return { handled: true, response: deps.error('Security service not available', 503) }; + } + + const ec = context.executionContext; + // Admin surface — anonymous is denied UNCONDITIONALLY (`requireAuth: + // true` hardcoded), independent of the deployment posture: even a + // `requireAuth: false` demo must not let anonymous callers list or + // confirm audience bindings. Shares the decision + body with every + // other HTTP seam (#2567). + if (shouldDenyAnonymous({ requireAuth: true, userId: ec?.userId, isSystem: ec?.isSystem })) { + return { + handled: true, + response: deps.error(ANONYMOUS_DENY_MESSAGE, ANONYMOUS_DENY_STATUS, { code: ANONYMOUS_DENY_CODE }), + }; + } + + const m = method.toUpperCase(); + // split+filter drops leading/trailing/duplicate slashes without a + // regex over request-controlled input (CodeQL js/polynomial-redos). + const parts = path.split('/').filter(Boolean); + if (parts[0] !== 'suggested-bindings') return { handled: false }; + + try { + // GET /security/suggested-bindings + if (parts.length === 1 && m === 'GET') { + const status = query?.status ? String(query.status) : undefined; + const packageId = query?.packageId ? String(query.packageId) : undefined; + const result = await service.listAudienceBindingSuggestions(ec, { status, packageId }); + return { handled: true, response: deps.success(result) }; + } + + // POST /security/suggested-bindings/:id/confirm|dismiss + if (parts.length === 3 && m === 'POST') { + const id = decodeURIComponent(parts[1]); + if (parts[2] === 'confirm') { + const result = await service.confirmAudienceBindingSuggestion(ec, id); + return { handled: true, response: deps.success(result) }; + } + if (parts[2] === 'dismiss') { + const result = await service.dismissAudienceBindingSuggestion(ec, id); + return { handled: true, response: deps.success(result) }; + } + } + + return { handled: false }; + } catch (err: any) { + // The service throws typed errors carrying their HTTP status: + // PermissionDeniedError → 403, SuggestionNotFoundError → 404, + // SuggestionStateError → 409. + const status = typeof err?.statusCode === 'number' ? err.statusCode : 500; + return { handled: true, response: deps.error(err?.message ?? 'Security operation failed', status) }; + } +} diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index 65a5867086..2524345d36 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -1,7 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { - ObjectKernel, getEnv, resolveLocale, evaluateAuthGate, isAuthGateAllowlisted, + ObjectKernel, getEnv, evaluateAuthGate, isAuthGateAllowlisted, shouldDenyAnonymous, ANONYMOUS_DENY_STATUS, ANONYMOUS_DENY_CODE, ANONYMOUS_DENY_MESSAGE, } from '@objectstack/core'; import { isMcpServerEnabled } from '@objectstack/types'; @@ -14,7 +14,11 @@ import { validateActionParams, type ResolvedActionParam } from '@objectstack/spe import type { ExecutionContext } from '@objectstack/spec/kernel'; import { setPackageDisabled } from './package-state-store.js'; import { checkApiExposure } from './api-exposure.js'; -import { DomainHandlerRegistry, type DomainRoute } from './domain-handler-registry.js'; +import { DomainHandlerRegistry, type DomainRoute, type DomainHandlerDeps } from './domain-handler-registry.js'; +import { createAnalyticsDomain, handleAnalyticsRequest } from './domains/analytics.js'; +import { createI18nDomain, handleI18nRequest } from './domains/i18n.js'; +import { createNotificationsDomain, handleNotificationRequest } from './domains/notifications.js'; +import { createSecurityDomain, handleSecurityRequest } from './domains/security.js'; /** Minimal local interface — full EnvironmentScopeManager was removed in Phase R. */ interface EnvironmentScopeManager { @@ -227,12 +231,27 @@ export class HttpDispatcher { } /** - * ADR-0076 D11 step ③ — seed the domain registry with the first domains - * lifted out of the `dispatch()` if-chain. Handler BODIES stay as - * dispatcher methods in this PR (registry first, code moves later, - * ownership last — see {@link DomainHandlerRegistry}); each entry - * faithfully reproduces its legacy branch's matching + argument - * convention, so behavior is unchanged. + * The explicit dispatcher-facility contract extracted domain bodies run + * against (ADR-0076 D11 step ③ PR-2). One instance per dispatcher; + * methods bound here are the ONLY dispatcher surface a domain module may + * touch — see {@link DomainHandlerDeps}. + */ + private readonly domainDeps: DomainHandlerDeps = { + resolveService: (name, environmentId) => this.resolveService(name, environmentId), + // 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]), + success: (data, meta) => this.success(data, meta), + error: (message, code, details) => this.error(message, code, details), + }; + + /** + * ADR-0076 D11 step ③ — seed the domain registry with the domains lifted + * out of the `dispatch()` if-chain. Bodies of the four service-backed + * domains live under `./domains/` (PR-2); `/health` + `/ready` stay + * inline because their "body" IS dispatcher state (kernel lifecycle). + * Registration stays dispatcher-owned for multi-provider service slots — + * see {@link DomainHandlerRegistry} for the rationale. */ private registerBuiltinDomains(): void { // GET /health — liveness probe (was branch "0b"). @@ -264,24 +283,10 @@ export class HttpDispatcher { : { handled: true, response: this.error('Service not ready', 503, { state }) }; }, }); - // /analytics — bridge to the `analytics` service. NOTE: the fallback - // vs service-replace semantics live in the SERVICE layer - // (ObjectQLPlugin fallback + service-analytics `replaceService`, see - // ADR-0076 D10/D12) — this route entry must keep working whether the - // real engine or the degraded fallback is registered, which is why - // its handler registration stays dispatcher-owned for now. - this.domainRegistry.register({ - prefix: '/analytics', - handler: (req, context) => - this.handleAnalytics(req.path.substring(10), req.method, req.body, context), - }); - // /i18n — translations / locales / labels, served by the `i18n` - // service (501 inside the handler when the service is absent). - this.domainRegistry.register({ - prefix: '/i18n', - handler: (req, context) => - this.handleI18n(req.path.substring(5), req.method, req.query, context), - }); + this.domainRegistry.register(createAnalyticsDomain(this.domainDeps)); + this.domainRegistry.register(createI18nDomain(this.domainDeps)); + this.domainRegistry.register(createNotificationsDomain(this.domainDeps)); + this.domainRegistry.register(createSecurityDomain(this.domainDeps)); } /** @@ -2563,39 +2568,9 @@ export class HttpDispatcher { * Handles Analytics requests * path: sub-path after /analytics/ */ + /** Thin delegate — body extracted to `./domains/analytics.ts` (D11③ PR-2). */ async handleAnalytics(path: string, method: string, body: any, context: HttpProtocolContext): Promise { - const analyticsService = await this.getService(CoreServiceName.enum.analytics); - if (!analyticsService) return { handled: false }; // 404 handled by caller if unhandled - - const m = method.toUpperCase(); - const subPath = path.replace(/^\/+/, ''); - - // POST /analytics/query - if (subPath === 'query' && m === 'POST') { - // [#2852] Pass the request's execution context so the analytics - // service scopes each object by its per-object read filter (tenant + - // RLS). Without it, `getReadScope(object, undefined)` returned no - // filter and the query ran UNSCOPED — an authenticated caller saw - // rows RLS would otherwise hide. - const result = await analyticsService.query(body, context?.executionContext); - return { handled: true, response: this.success(result) }; - } - - // GET /analytics/meta - if (subPath === 'meta' && m === 'GET') { - const result = await analyticsService.getMeta(); - return { handled: true, response: this.success(result) }; - } - - // POST /analytics/sql (Dry-run or debug) - if (subPath === 'sql' && m === 'POST') { - // [#2852] Scope the generated SQL to the caller too, so a preview - // reflects the same per-object read filter the real query applies. - const result = await analyticsService.generateSql(body, context?.executionContext); - return { handled: true, response: this.success(result) }; - } - - return { handled: false }; + return handleAnalyticsRequest(this.domainDeps, path, method, body, context); } /** @@ -2611,41 +2586,9 @@ export class HttpDispatcher { * POST /read → markRead (body: { ids: string[] }) * POST /read/all → markAllRead */ + /** Thin delegate — body extracted to `./domains/notifications.ts` (D11③ PR-2). */ async handleNotification(path: string, method: string, body: any, query: any, context: HttpProtocolContext): Promise { - const service = await this.resolveService(CoreServiceName.enum.notification, context.environmentId) as any; - if (!service || typeof service.listInbox !== 'function') return { handled: false }; - - const userId: string | undefined = context.executionContext?.userId; - if (!userId) { - return { handled: true, response: this.error('Authentication required', 401) }; - } - - const m = method.toUpperCase(); - const subPath = path.replace(/^\/+/, '').replace(/\/+$/, ''); - - // GET /notifications — list the user's inbox joined with read-state. - if (subPath === '' && m === 'GET') { - const read = query?.read === undefined ? undefined : String(query.read) === 'true'; - const limit = query?.limit ? Number(query.limit) : undefined; - const type = query?.type ? String(query.type) : undefined; - const result = await service.listInbox(userId, { read, type, limit }); - return { handled: true, response: this.success(result) }; - } - - // POST /notifications/read — mark specific notifications read. - if (subPath === 'read' && m === 'POST') { - const ids: string[] = Array.isArray(body?.ids) ? body.ids.map((x: unknown) => String(x)) : []; - const result = await service.markRead(userId, ids); - return { handled: true, response: this.success(result) }; - } - - // POST /notifications/read/all — mark all of the user's inbox read. - if (subPath === 'read/all' && m === 'POST') { - const result = await service.markAllRead(userId); - return { handled: true, response: this.success(result) }; - } - - return { handled: false }; + return handleNotificationRequest(this.domainDeps, path, method, body, query, context); } /** @@ -2662,61 +2605,9 @@ export class HttpDispatcher { * POST /security/suggested-bindings/:id/confirm → create the anchor binding * POST /security/suggested-bindings/:id/dismiss → decline the suggestion */ + /** Thin delegate — body extracted to `./domains/security.ts` (D11③ PR-2). */ async handleSecurity(path: string, method: string, _body: any, query: any, context: HttpProtocolContext): Promise { - const service = await this.resolveService('security', context.environmentId) as any; - if (!service || typeof service.listAudienceBindingSuggestions !== 'function') { - return { handled: true, response: this.error('Security service not available', 503) }; - } - - const ec = context.executionContext; - // Admin surface — anonymous is denied UNCONDITIONALLY (`requireAuth: - // true` hardcoded), independent of the deployment posture: even a - // `requireAuth: false` demo must not let anonymous callers list or - // confirm audience bindings. Shares the decision + body with every - // other HTTP seam (#2567). - if (shouldDenyAnonymous({ requireAuth: true, userId: ec?.userId, isSystem: ec?.isSystem })) { - return { - handled: true, - response: this.error(ANONYMOUS_DENY_MESSAGE, ANONYMOUS_DENY_STATUS, { code: ANONYMOUS_DENY_CODE }), - }; - } - - const m = method.toUpperCase(); - // split+filter drops leading/trailing/duplicate slashes without a - // regex over request-controlled input (CodeQL js/polynomial-redos). - const parts = path.split('/').filter(Boolean); - if (parts[0] !== 'suggested-bindings') return { handled: false }; - - try { - // GET /security/suggested-bindings - if (parts.length === 1 && m === 'GET') { - const status = query?.status ? String(query.status) : undefined; - const packageId = query?.packageId ? String(query.packageId) : undefined; - const result = await service.listAudienceBindingSuggestions(ec, { status, packageId }); - return { handled: true, response: this.success(result) }; - } - - // POST /security/suggested-bindings/:id/confirm|dismiss - if (parts.length === 3 && m === 'POST') { - const id = decodeURIComponent(parts[1]); - if (parts[2] === 'confirm') { - const result = await service.confirmAudienceBindingSuggestion(ec, id); - return { handled: true, response: this.success(result) }; - } - if (parts[2] === 'dismiss') { - const result = await service.dismissAudienceBindingSuggestion(ec, id); - return { handled: true, response: this.success(result) }; - } - } - - return { handled: false }; - } catch (err: any) { - // The service throws typed errors carrying their HTTP status: - // PermissionDeniedError → 403, SuggestionNotFoundError → 404, - // SuggestionStateError → 409. - const status = typeof err?.statusCode === 'number' ? err.statusCode : 500; - return { handled: true, response: this.error(err?.message ?? 'Security operation failed', status) }; - } + return handleSecurityRequest(this.domainDeps, path, method, _body, query, context); } /** @@ -2730,72 +2621,9 @@ export class HttpDispatcher { * GET /labels/:object/:locale → getFieldLabels (both from path) * GET /labels/:object?locale=xx → getFieldLabels (locale from query) */ + /** Thin delegate — body extracted to `./domains/i18n.ts` (D11③ PR-2). */ async handleI18n(path: string, method: string, query: any, _context: HttpProtocolContext): Promise { - const i18nService = await this.getService(CoreServiceName.enum.i18n); - if (!i18nService) return { handled: true, response: this.error('i18n service not available', 501) }; - - const m = method.toUpperCase(); - const parts = path.replace(/^\/+/, '').split('/').filter(Boolean); - - if (m !== 'GET') return { handled: false }; - - // GET /i18n/locales - if (parts[0] === 'locales' && parts.length === 1) { - const locales = i18nService.getLocales(); - return { handled: true, response: this.success({ locales }) }; - } - - // GET /i18n/translations/:locale OR /i18n/translations?locale=xx - if (parts[0] === 'translations') { - const locale = parts[1] ? decodeURIComponent(parts[1]) : query?.locale; - if (!locale) return { handled: true, response: this.error('Missing locale parameter', 400) }; - - let translations = i18nService.getTranslations(locale); - - // Locale fallback: try resolving to an available locale when - // the exact code yields empty translations (e.g. zh → zh-CN). - if (Object.keys(translations).length === 0) { - const availableLocales = typeof i18nService.getLocales === 'function' - ? i18nService.getLocales() : []; - const resolved = resolveLocale(locale, availableLocales); - if (resolved && resolved !== locale) { - translations = i18nService.getTranslations(resolved); - return { handled: true, response: this.success({ locale: resolved, requestedLocale: locale, translations }) }; - } - } - - return { handled: true, response: this.success({ locale, translations }) }; - } - - // GET /i18n/labels/:object/:locale OR /i18n/labels/:object?locale=xx - if (parts[0] === 'labels' && parts.length >= 2) { - const objectName = decodeURIComponent(parts[1]); - let locale = parts[2] ? decodeURIComponent(parts[2]) : query?.locale; - if (!locale) return { handled: true, response: this.error('Missing locale parameter', 400) }; - - // Locale fallback for labels endpoint - const availableLocales = typeof i18nService.getLocales === 'function' - ? i18nService.getLocales() : []; - const resolved = resolveLocale(locale, availableLocales); - if (resolved) locale = resolved; - - if (typeof i18nService.getFieldLabels === 'function') { - const labels = i18nService.getFieldLabels(objectName, locale); - return { handled: true, response: this.success({ object: objectName, locale, labels }) }; - } - // Fallback: derive field labels from full translation bundle - const translations = i18nService.getTranslations(locale); - const prefix = `o.${objectName}.fields.`; - const labels: Record = {}; - for (const [key, value] of Object.entries(translations)) { - if (key.startsWith(prefix)) { - labels[key.substring(prefix.length)] = value as string; - } - } - return { handled: true, response: this.success({ object: objectName, locale, labels }) }; - } - - return { handled: false }; + return handleI18nRequest(this.domainDeps, path, method, query, _context); } /** @@ -4678,17 +4506,7 @@ export class HttpDispatcher { // /analytics moved to the domain registry (D11 step ③). - // In-app notifications (ADR-0030) — inbox list + receipt mark-read, - // backed by the messaging service registered under the `notification` slot. - if (cleanPath.startsWith('/notifications')) { - return this.handleNotification(cleanPath.substring(14), method, body, query, context); - } - - // Security admin surface (ADR-0090 D5/D9) — suggested audience - // bindings, dispatched to the `security` service (plugin-security). - if (cleanPath === '/security' || cleanPath.startsWith('/security/')) { - return this.handleSecurity(cleanPath.substring(9), method, body, query, context); - } + // /notifications and /security moved to the domain registry (D11 step ③). if (cleanPath.startsWith('/packages')) { return this.handlePackages(cleanPath.substring(9), method, body, query, context); diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 71f2dddc1c..6e455a44e3 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -48,7 +48,7 @@ export type { KernelResolver } from './http-dispatcher.js'; // ADR-0076 D11 step ③ — thin domain-handler registry seam; owning service // packages register normalized handlers via HttpDispatcher.registerDomainHandler. export { DomainHandlerRegistry } from './domain-handler-registry.js'; -export type { DomainRoute, DomainHandler, DomainRequest } from './domain-handler-registry.js'; +export type { DomainRoute, DomainHandler, DomainRequest, DomainHandlerDeps } from './domain-handler-registry.js'; export { MiddlewareManager } from './middleware.js'; // ── Security primitives ───────────────────────────────────────────────