Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .changeset/runtime-domain-body-extraction.md
Original file line number Diff line number Diff line change
@@ -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.
76 changes: 76 additions & 0 deletions packages/runtime/src/domain-handler-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
56 changes: 44 additions & 12 deletions packages/runtime/src/domain-handler-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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;
Expand Down
63 changes: 63 additions & 0 deletions packages/runtime/src/domains/analytics.ts
Original file line number Diff line number Diff line change
@@ -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<HttpDispatcherResult> {
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 };
}
105 changes: 105 additions & 0 deletions packages/runtime/src/domains/i18n.ts
Original file line number Diff line number Diff line change
@@ -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<HttpDispatcherResult> {
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<string, string> = {};
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 };
}
Loading