diff --git a/.changeset/rest-env-resolution-kernel-resolver-seam.md b/.changeset/rest-env-resolution-kernel-resolver-seam.md new file mode 100644 index 000000000..45b580fd7 --- /dev/null +++ b/.changeset/rest-env-resolution-kernel-resolver-seam.md @@ -0,0 +1,20 @@ +--- +"@objectstack/rest": minor +--- + +feat(rest): unify request→environment resolution on the host's `kernel-resolver` seam — ADR-0076 D11 step ④ (#2462) + +The REST server kept its own parallel hostname/`X-Environment-Id` resolution +chain (duplicated inline in three places), while the HTTP dispatcher resolves +the same question through the host-injected ADR-0006 `kernel-resolver` seam — +so the same unscoped request could be attributed to different environments +depending on which HTTP surface served it. + +`RestApiPlugin` now adapts the host's `kernel-resolver` service (registered by +the cloud runtime next to `env-registry`; no cloud-side change needed) into a +new `RestRequestEnvResolver` seam, and `resolveRequestEnvironmentId` becomes +the single entry point every per-environment decision (protocol, i18n, +exec-ctx) flows through. Where a resolver is wired, its answer — including the +session-driven fallbacks the REST chain never had — is final; the legacy +built-in chain remains for OSS single-environment boots (no resolver +registered) and as the degradation path if the resolver throws. diff --git a/packages/rest/src/rest-api-plugin.ts b/packages/rest/src/rest-api-plugin.ts index 2484d5fce..4eda62096 100644 --- a/packages/rest/src/rest-api-plugin.ts +++ b/packages/rest/src/rest-api-plugin.ts @@ -1,7 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { Plugin, PluginContext, IHttpServer } from '@objectstack/core'; -import { RestServer, RestKernelManager, RestProtocol } from './rest-server.js'; +import { RestServer, RestKernelManager, RestProtocol, RestRequestEnvResolver } from './rest-server.js'; import { RestServerConfig } from '@objectstack/spec/api'; import { registerPackageRoutes } from './package-routes.js'; import { registerExternalDatasourceRoutes } from './external-datasource-routes.js'; @@ -96,6 +96,44 @@ export function createRestApiPlugin(config: RestApiPluginConfig = {}): Plugin { // Not running in runtime/multi-environment mode — fine. } + // ADR-0076 D11 step ④ — request→environment resolution unified on + // the host's ADR-0006 `kernel-resolver` seam. When the host + // registers one (cloud runtime does, next to `env-registry`), the + // REST server resolves a request's environment through the SAME + // strategy instance the HTTP dispatcher uses — session fallbacks + // included — instead of its own parallel hostname/header chain. + // The legacy chain remains as the fallback when no resolver is + // registered (OSS single-environment boots) or the resolver throws. + let requestEnvResolver: RestRequestEnvResolver | undefined; + try { + const kernelResolver = ctx.getService('kernel-resolver'); + if (kernelResolver && typeof kernelResolver.resolveKernel === 'function') { + // The resolver's session/default-project fallback levels + // resolve services from its `defaultKernel` argument — + // bind the hosting kernel's service surface. `getService` + // may throw on a missing service; the resolver handles + // that itself. + const hostKernelFacade = { + getService: (name: string) => ctx.getService(name), + getServiceAsync: async (name: string) => ctx.getService(name), + }; + requestEnvResolver = { + async resolveRequestEnvironmentId(req: unknown): Promise { + // No `routePath` hint: the REST consumers of this + // seam are all data-plane routes, never the + // resolver's control-plane skip prefixes. If a + // resolver strategy starts keying off routePath, + // add prefix-stripped assembly here. + const context: { request: unknown; environmentId?: string } = { request: req }; + await kernelResolver.resolveKernel(context, hostKernelFacade); + return context.environmentId; + }, + }; + } + } catch (e) { + // No kernel-resolver registered — legacy chain only. Fine. + } + // Optional default-project provider — registered by // `createSingleEnvironmentPlugin` in single-environment local mode. // Lets RestServer route bare `/api/v1/data/...` URLs into the @@ -219,7 +257,7 @@ export function createRestApiPlugin(config: RestApiPluginConfig = {}): Plugin { try { return ctx.getService(name) != null; } catch { return false; } }; try { - const restServer = new RestServer(server, protocol, config.api as any, kernelManager, envRegistry, defaultEnvironmentIdProvider, authServiceProvider, objectQLProvider, emailServiceProvider, sharingServiceProvider, reportsServiceProvider, approvalsServiceProvider, sharingRulesServiceProvider, i18nServiceProvider, analyticsServiceProvider, settingsServiceProvider, serviceExistsProvider, securityServiceProvider); + const restServer = new RestServer(server, protocol, config.api as any, kernelManager, envRegistry, defaultEnvironmentIdProvider, authServiceProvider, objectQLProvider, emailServiceProvider, sharingServiceProvider, reportsServiceProvider, approvalsServiceProvider, sharingRulesServiceProvider, i18nServiceProvider, analyticsServiceProvider, settingsServiceProvider, serviceExistsProvider, securityServiceProvider, requestEnvResolver); restServer.registerRoutes(); ctx.logger.info('REST API successfully registered'); diff --git a/packages/rest/src/rest-env-resolution.test.ts b/packages/rest/src/rest-env-resolution.test.ts new file mode 100644 index 000000000..6aa3d640e --- /dev/null +++ b/packages/rest/src/rest-env-resolution.test.ts @@ -0,0 +1,302 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ADR-0076 D11 step ④ (#2462) — request→environment resolution unified on the + * host's ADR-0006 `kernel-resolver` seam. + * + * Locks the resolution-chain contract of `resolveRequestEnvironmentId`: + * explicit id → host-injected RestRequestEnvResolver (normal return is + * FINAL, throw degrades) → legacy hostname/header chain → single-project + * default — and the RestApiPlugin adapter that binds the host's + * `kernel-resolver` service into that seam. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { RestServer, RestRequestEnvResolver, RestEnvRegistry } from './rest-server'; +import { createRestApiPlugin } from './rest-api-plugin'; + +// --------------------------------------------------------------------------- +// Mocks & Helpers +// --------------------------------------------------------------------------- + +function createMockServer() { + return { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + delete: vi.fn(), + patch: vi.fn(), + use: vi.fn(), + listen: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + }; +} + +function createMockProtocol() { + return { + getDiscovery: vi.fn().mockResolvedValue({ version: 'v0', endpoints: {} }), + getMetaTypes: vi.fn().mockResolvedValue([]), + getMetaItems: vi.fn().mockResolvedValue([]), + getMetaItem: vi.fn().mockResolvedValue({}), + findData: vi.fn().mockResolvedValue([]), + getData: vi.fn().mockResolvedValue({}), + createData: vi.fn().mockResolvedValue({ id: '1' }), + updateData: vi.fn().mockResolvedValue({}), + deleteData: vi.fn().mockResolvedValue({ success: true }), + }; +} + +const ANON_API = { api: { requireAuth: false } }; + +/** Node-style request with a Host header + optional X-Environment-Id. */ +function mockReq(headers: Record = {}): any { + return { headers: { host: 'tenant-a.example.com', ...headers }, url: '/api/v1/data/account' }; +} + +type RestServerArgs = { + envRegistry?: RestEnvRegistry; + defaultEnvironmentIdProvider?: () => string | undefined; + requestEnvResolver?: RestRequestEnvResolver; + kernelManager?: { getOrCreate: (id: string) => Promise }; +}; + +/** Build a RestServer with only the seams under test wired. */ +function buildRest(args: RestServerArgs = {}) { + const server = createMockServer(); + const protocol = createMockProtocol(); + const kernelManager = + args.kernelManager ?? + ({ + getOrCreate: vi.fn().mockResolvedValue({ + getServiceAsync: vi.fn().mockResolvedValue(undefined), + }), + } as any); + const rest = new RestServer( + server as any, + protocol as any, + ANON_API as any, + kernelManager as any, + args.envRegistry, + args.defaultEnvironmentIdProvider, + undefined, // authServiceProvider + undefined, // objectQLProvider + undefined, // emailServiceProvider + undefined, // sharingServiceProvider + undefined, // reportsServiceProvider + undefined, // approvalsServiceProvider + undefined, // sharingRulesServiceProvider + undefined, // i18nServiceProvider + undefined, // analyticsServiceProvider + undefined, // settingsServiceProvider + undefined, // serviceExistsProvider + undefined, // securityServiceProvider + args.requestEnvResolver, + ); + const resolve = (environmentId?: string, req?: any): Promise => + (rest as any).resolveRequestEnvironmentId(environmentId, req); + return { rest, server, protocol, kernelManager, resolve }; +} + +/** Legacy registry that resolves every hostname to `legacy-env`. */ +function legacyRegistry(): RestEnvRegistry & { resolveByHostname: ReturnType } { + return { + resolveByHostname: vi.fn().mockResolvedValue({ environmentId: 'legacy-env' }), + resolveById: vi.fn().mockResolvedValue({}), + } as any; +} + +// --------------------------------------------------------------------------- +// Resolution-chain contract +// --------------------------------------------------------------------------- + +describe('resolveRequestEnvironmentId (D11④ seam)', () => { + it('returns an explicit environmentId without consulting any resolver', async () => { + const resolver: RestRequestEnvResolver = { + resolveRequestEnvironmentId: vi.fn().mockResolvedValue('resolver-env'), + }; + const registry = legacyRegistry(); + const { resolve } = buildRest({ requestEnvResolver: resolver, envRegistry: registry }); + + await expect(resolve('explicit-env', mockReq())).resolves.toBe('explicit-env'); + expect(resolver.resolveRequestEnvironmentId).not.toHaveBeenCalled(); + expect(registry.resolveByHostname).not.toHaveBeenCalled(); + }); + + it('prefers the injected resolver over the legacy envRegistry chain', async () => { + const resolver: RestRequestEnvResolver = { + resolveRequestEnvironmentId: vi.fn().mockResolvedValue('resolver-env'), + }; + const registry = legacyRegistry(); + const { resolve } = buildRest({ requestEnvResolver: resolver, envRegistry: registry }); + + await expect(resolve(undefined, mockReq())).resolves.toBe('resolver-env'); + // The legacy chain must not even be consulted — one authority per host. + expect(registry.resolveByHostname).not.toHaveBeenCalled(); + }); + + it("treats the resolver's undefined as FINAL — legacy chain and default do not second-guess it", async () => { + const resolver: RestRequestEnvResolver = { + resolveRequestEnvironmentId: vi.fn().mockResolvedValue(undefined), + }; + const registry = legacyRegistry(); + const { resolve } = buildRest({ + requestEnvResolver: resolver, + envRegistry: registry, + defaultEnvironmentIdProvider: () => 'default-env', + }); + + // Both fallbacks COULD produce an id; the resolver's verdict wins anyway + // (e.g. it deliberately skipped a control-plane route). + await expect(resolve(undefined, mockReq())).resolves.toBeUndefined(); + expect(registry.resolveByHostname).not.toHaveBeenCalled(); + }); + + it('degrades to the legacy chain when the resolver throws', async () => { + const resolver: RestRequestEnvResolver = { + resolveRequestEnvironmentId: vi.fn().mockRejectedValue(new Error('resolver down')), + }; + const registry = legacyRegistry(); + const { resolve } = buildRest({ requestEnvResolver: resolver, envRegistry: registry }); + + await expect(resolve(undefined, mockReq())).resolves.toBe('legacy-env'); + }); + + it('runs the legacy hostname chain unchanged when no resolver is injected', async () => { + const registry = legacyRegistry(); + const { resolve } = buildRest({ envRegistry: registry }); + + await expect(resolve(undefined, mockReq())).resolves.toBe('legacy-env'); + expect(registry.resolveByHostname).toHaveBeenCalledWith('tenant-a.example.com'); + }); + + it('falls back to X-Environment-Id header, then the single-project default, when hostname misses', async () => { + const registry: RestEnvRegistry = { + resolveByHostname: vi.fn().mockResolvedValue(null), + resolveById: vi.fn().mockImplementation(async (id: string) => (id === 'header-env' ? {} : null)), + }; + const { resolve } = buildRest({ + envRegistry: registry, + defaultEnvironmentIdProvider: () => 'default-env', + }); + + await expect( + resolve(undefined, mockReq({ 'x-environment-id': 'header-env' })), + ).resolves.toBe('header-env'); + await expect(resolve(undefined, mockReq())).resolves.toBe('default-env'); + }); + + it('routes resolver-provided environments into kernelManager.getOrCreate via resolveProtocol', async () => { + const resolver: RestRequestEnvResolver = { + resolveRequestEnvironmentId: vi.fn().mockResolvedValue('resolver-env'), + }; + const perEnvProtocol = createMockProtocol(); + const kernelManager = { + getOrCreate: vi.fn().mockResolvedValue({ + getServiceAsync: vi.fn().mockResolvedValue(perEnvProtocol), + }), + }; + const { rest } = buildRest({ requestEnvResolver: resolver, kernelManager }); + + const resolved = await (rest as any).resolveProtocol(undefined, mockReq()); + expect(kernelManager.getOrCreate).toHaveBeenCalledWith('resolver-env'); + expect(resolved).toBe(perEnvProtocol); + }); +}); + +// --------------------------------------------------------------------------- +// RestApiPlugin adapter — binds the host's `kernel-resolver` service +// --------------------------------------------------------------------------- + +describe('RestApiPlugin kernel-resolver adapter (D11④)', () => { + function createMockPluginContext(services: Record) { + return { + registerService: vi.fn(), + getService: vi.fn((name: string) => { + if (services[name]) return services[name]; + throw new Error(`Service '${name}' not found`); + }), + getServices: vi.fn(() => new Map(Object.entries(services))), + hook: vi.fn(), + trigger: vi.fn().mockResolvedValue(undefined), + logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + getKernel: vi.fn(), + }; + } + + /** Base services every plugin boot needs. */ + function baseServices() { + return { + 'http.server': createMockServer(), + protocol: createMockProtocol(), + objectql: { registerObject: vi.fn(), find: vi.fn().mockResolvedValue([]) }, + }; + } + + it('wires the kernel-resolver service into the REST env seam (context.environmentId read-back)', async () => { + const resolveKernel = vi.fn().mockImplementation(async (context: any) => { + context.environmentId = 'cloud-env'; + return undefined; + }); + const services: Record = { + ...baseServices(), + 'kernel-resolver': { resolveKernel }, + 'kernel-manager': { + getOrCreate: vi.fn().mockResolvedValue({ + getServiceAsync: vi.fn().mockResolvedValue(undefined), + }), + }, + }; + const ctx = createMockPluginContext(services); + const plugin = createRestApiPlugin({ api: ANON_API as any }); + await plugin.init?.(ctx as any); + await (plugin as any).start(ctx as any); + + // Pull the registered GET /api/v1/data/:object handler and drive one + // unscoped request through it — the adapter must consult resolveKernel. + const server = services['http.server']; + expect(server.get.mock.calls.map((c: any[]) => c[0])).toContain('/api/v1/data/:object'); + const listRoute = server.get.mock.calls.find((c: any[]) => c[0] === '/api/v1/data/:object'); + const handler = listRoute![1]; + const res = { + json: vi.fn(), + status: vi.fn().mockReturnThis(), + send: vi.fn(), + setHeader: vi.fn(), + headersSent: false, + }; + await handler({ params: { object: 'account' }, query: {}, headers: { host: 'x.example.com' } }, res); + + expect(resolveKernel).toHaveBeenCalled(); + const [context, hostKernel] = resolveKernel.mock.calls[0]; + expect(context.request).toBeDefined(); + // The facade must expose the hosting kernel's service surface (the + // resolver's session/default-project levels resolve services off it). + expect(hostKernel.getService('protocol')).toBe(services.protocol); + await expect(hostKernel.getServiceAsync('protocol')).resolves.toBe(services.protocol); + // The resolver's answer must reach kernelManager.getOrCreate — the REST + // request is served from the SAME environment the dispatcher would pick. + expect(services['kernel-manager'].getOrCreate).toHaveBeenCalledWith('cloud-env'); + }); + + it('boots and serves without a kernel-resolver service (OSS single-environment mode)', async () => { + const services: Record = { ...baseServices() }; + const ctx = createMockPluginContext(services); + const plugin = createRestApiPlugin({ api: ANON_API as any }); + await plugin.init?.(ctx as any); + await (plugin as any).start(ctx as any); + + const server = services['http.server']; + const listRoute = server.get.mock.calls.find((c: any[]) => c[0] === '/api/v1/data/:object'); + expect(listRoute).toBeDefined(); + const res = { + json: vi.fn(), + status: vi.fn().mockReturnThis(), + send: vi.fn(), + setHeader: vi.fn(), + headersSent: false, + }; + await listRoute![1]({ params: { object: 'account' }, query: {}, headers: {} }, res); + // Served by the boot-time control protocol — no resolver, no crash. + expect(services.protocol.findData).toHaveBeenCalled(); + }); +}); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 3bce55c3d..3aeee818d 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -777,12 +777,39 @@ export interface RestEnvRegistry { resolveById?(environmentId: string): Promise; } +/** + * Request → environment resolution seam (ADR-0076 D11 step ④, #2462). + * + * When the host registers a `kernel-resolver` service (the ADR-0006 seam the + * HTTP dispatcher already consumes), `RestApiPlugin` wraps it in this shape so + * the REST server resolves a request's environment through the SAME strategy + * as the dispatcher — one answer per host for "which environment does this + * request belong to" — instead of the REST server's own parallel + * hostname/header chain. + * + * Contract: a normal return is FINAL — `undefined` means the resolver decided + * the request is unscoped (e.g. a control-plane route), and the legacy + * built-in chain must NOT second-guess it. Only a thrown error falls back to + * the legacy chain, so a misbehaving resolver degrades to pre-seam behavior + * instead of taking down REST routing. + */ +export interface RestRequestEnvResolver { + resolveRequestEnvironmentId(req: unknown): Promise; +} + export class RestServer { private protocol: RestProtocol; private config: NormalizedRestServerConfig; private routeManager: RouteManager; private kernelManager?: RestKernelManager; private envRegistry?: RestEnvRegistry; + /** + * Host-injected request→environment resolver (ADR-0076 D11 step ④). When + * present it is the AUTHORITY for unscoped-route environment resolution; + * the legacy `envRegistry` chain below only runs when this is absent or + * throws. See {@link RestRequestEnvResolver}. + */ + private requestEnvResolver?: RestRequestEnvResolver; /** * Short-TTL cache for `hostname → environmentId` (P1-4). `resolveByHostname` * is a control-plane lookup (typically a DB query) that otherwise runs on @@ -851,6 +878,7 @@ export class RestServer { settingsServiceProvider?: (environmentId?: string) => Promise, serviceExistsProvider?: (name: string) => boolean, securityServiceProvider?: (environmentId?: string) => Promise, + requestEnvResolver?: RestRequestEnvResolver, ) { this.protocol = protocol; this.config = this.normalizeConfig(config); @@ -870,6 +898,7 @@ export class RestServer { this.settingsServiceProvider = settingsServiceProvider; this.serviceExistsProvider = serviceExistsProvider; this.securityServiceProvider = securityServiceProvider; + this.requestEnvResolver = requestEnvResolver; } /** @@ -911,14 +940,30 @@ export class RestServer { } /** - * Resolve the environment a request targets: explicit id → tenant hostname - * → `X-Environment-Id` header → single-project default. Returns undefined - * for control-plane requests. Shared by every per-environment service - * resolution (protocol, analytics, …) so they can never disagree about - * which kernel a request belongs to. + * Resolve the environment a request targets. THE single entry point for + * every unscoped-route environment decision (protocol, i18n, exec-ctx, + * analytics, …) so they can never disagree about which kernel a request + * belongs to. + * + * Chain: explicit id → host-injected {@link RestRequestEnvResolver} + * (ADR-0076 D11 step ④ — the dispatcher's ADR-0006 `kernel-resolver` + * strategy; its normal return, including `undefined`, is final) → legacy + * built-in chain (tenant hostname → `X-Environment-Id` header) → single- + * project default. Returns undefined for control-plane requests. */ private async resolveRequestEnvironmentId(environmentId?: string, req?: any): Promise { if (environmentId) return environmentId; + // 1. Host-injected resolver seam. Where wired (cloud runtime), this is + // the SAME strategy instance the HTTP dispatcher uses, so REST and + // dispatcher routes always agree on a request's environment — + // including the session-driven fallbacks the legacy chain below + // never had. Normal returns are final; only a throw degrades to + // the legacy chain. + if (req && this.requestEnvResolver) { + try { + return await this.requestEnvResolver.resolveRequestEnvironmentId(req); + } catch { /* resolver failure → legacy chain */ } + } if (req && this.envRegistry && this.kernelManager) { const host = this.extractHostname(req); if (host) { @@ -982,33 +1027,10 @@ export class RestServer { */ private async resolveI18nService(environmentId?: string, req?: any): Promise { if (environmentId === 'platform') return undefined; - // Mirror resolveProtocol's fallback chain so unscoped routes (single- - // project dev servers, hostname-routed multi-tenants, X-Environment-Id - // headers) can still pick up per-project translation bundles. - if (!environmentId && req && this.envRegistry && this.kernelManager) { - const host = this.extractHostname(req); - if (host) { - try { - const result = await this.resolveHostnameCached(host); - if (result?.environmentId) environmentId = result.environmentId; - } catch { /* fall through */ } - } - if (!environmentId && typeof this.envRegistry.resolveById === 'function') { - const headerVal = this.extractProjectIdHeader(req); - if (headerVal) { - try { - const driver = await this.envRegistry.resolveById(headerVal); - if (driver) environmentId = headerVal; - } catch { /* fall through */ } - } - } - } - if (!environmentId && this.defaultEnvironmentIdProvider) { - try { - const def = this.defaultEnvironmentIdProvider(); - if (def) environmentId = def; - } catch { /* fall through */ } - } + // Shared resolution entry point (D11④) — previously this method + // hand-copied the hostname/header/default chain; now every consumer + // gets the one answer from resolveRequestEnvironmentId. + environmentId = await this.resolveRequestEnvironmentId(environmentId, req); // Multi-tenant kernel lookup first; falls back to the single-kernel // provider supplied by RestApiPlugin in dev / standalone mode. if (environmentId && this.kernelManager) { @@ -1205,30 +1227,13 @@ export class RestServer { try { // For multi-tenant hosts (objectos), incoming requests on unscoped // URLs like `/api/v1/data/:object` arrive with `environmentId === undefined`. - // The route's protocol resolver already maps hostname → environmentId - // (see resolveProtocol). We mirror that here so getSession() can - // find the right per-project auth service. Without this, the - // hostname-routed requests fall through to defaultEnvironmentIdProvider/ + // Resolve through the shared entry point (D11④) so getSession() + // finds the right per-project auth service — the same answer the + // route's protocol resolver got. Without this, hostname-routed + // requests fall through to defaultEnvironmentIdProvider/ // authServiceProvider (neither of which is wired in objectos) and // every authenticated user sees 401. - if (!environmentId && req && this.envRegistry && this.kernelManager) { - const host = this.extractHostname(req); - if (host) { - try { - const result = await this.resolveHostnameCached(host); - if (result?.environmentId) environmentId = result.environmentId; - } catch { /* fall through */ } - } - if (!environmentId && typeof this.envRegistry.resolveById === 'function') { - const headerVal = this.extractProjectIdHeader(req); - if (headerVal) { - try { - const driver = await this.envRegistry.resolveById(headerVal); - if (driver) environmentId = headerVal; - } catch { /* fall through */ } - } - } - } + environmentId = await this.resolveRequestEnvironmentId(environmentId, req); // Look up the auth service in the right kernel. For unscoped // single-environment apps the kernelManager will hand us the lone // tenant kernel; for multi-environment hosts we use the resolved