From a07d1ff944a486dcc2afdb259a4534404f0be8ac Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 03:15:58 +0000 Subject: [PATCH] fix(runtime): per-request kernel lives on the request, not on the dispatcher (#5155) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One HttpDispatcher serves a whole host, but the kernel a request resolves to is per request. That answer was stored on the instance field `this.kernel`, written once per request by `resolveRequestScope()` and read by every service lookup afterwards — each behind at least one `await`. Two interleaved requests on a multi-tenant host therefore swapped data sources under each other: A resolved env-1, yielded, B resolved env-2, and A resumed reading env-2. `HttpProtocolContext` now carries `kernel`, written by `resolveRequestScope()` next to the `environmentId` / `dataDriver` / `executionContext` it already writes there. `this.kernel` is gone. Every kernel-reading member of `DomainHandlerDeps` / `ActionExecutionDeps` takes the request as its first parameter, so the dependency is visible at the call site and the compiler asks for it — chosen over AsyncLocalStorage, which would have reintroduced implicit mutable ambient context, the same defect in a new costume. Three host-level readers (`/ready`, its driver-health probe, the memoized `default-project` lookup) now name `defaultKernel` explicitly instead of reading whichever tenant resolved most recently. Covered by a deterministic interleaving regression test: request A parks inside its own identity resolution, request B runs to completion, A resumes. On the old code A is served env-2's i18n bundle. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01VkPSGsX9o17MsGv3Lbxu2w --- .changeset/dispatcher-per-request-kernel.md | 73 ++++++ .../runtime/src/action-body-identity.test.ts | 2 +- .../action-execution-calldata-query.test.ts | 26 +- packages/runtime/src/action-execution.ts | 50 ++-- packages/runtime/src/dispatcher-plugin.ts | 11 +- .../runtime/src/domain-handler-registry.ts | 35 ++- packages/runtime/src/domains/actions.ts | 10 +- ...ai-request-user-capability-channel.test.ts | 7 +- packages/runtime/src/domains/ai.ts | 4 +- packages/runtime/src/domains/analytics.ts | 2 +- packages/runtime/src/domains/auth.ts | 2 +- packages/runtime/src/domains/automation.ts | 2 +- .../src/domains/data-path-object.test.ts | 5 +- packages/runtime/src/domains/data.ts | 12 +- packages/runtime/src/domains/i18n.ts | 2 +- packages/runtime/src/domains/keys.ts | 4 +- packages/runtime/src/domains/mcp.ts | 19 +- packages/runtime/src/domains/meta.ts | 36 +-- packages/runtime/src/domains/notifications.ts | 2 +- packages/runtime/src/domains/packages.ts | 36 +-- packages/runtime/src/domains/security.ts | 2 +- packages/runtime/src/domains/share-links.ts | 6 +- packages/runtime/src/domains/ui.ts | 2 +- ...ispatcher.multi-tenant-concurrency.test.ts | 206 ++++++++++++++++ packages/runtime/src/http-dispatcher.ts | 226 ++++++++++++------ .../src/meta-overlay-read-your-writes.test.ts | 5 +- 26 files changed, 608 insertions(+), 179 deletions(-) create mode 100644 .changeset/dispatcher-per-request-kernel.md create mode 100644 packages/runtime/src/http-dispatcher.multi-tenant-concurrency.test.ts diff --git a/.changeset/dispatcher-per-request-kernel.md b/.changeset/dispatcher-per-request-kernel.md new file mode 100644 index 0000000000..a8c742f8fb --- /dev/null +++ b/.changeset/dispatcher-per-request-kernel.md @@ -0,0 +1,73 @@ +--- +"@objectstack/runtime": minor +--- + +fix(runtime): the HTTP dispatcher serves each request from its OWN resolved kernel — two tenants can no longer swap data sources under each other (#5155) + +A host constructs exactly **one** `HttpDispatcher` (`dispatcher-plugin.ts` +`start()`), and every route it serves shares that instance. The kernel a request +resolves to, however, is per request: on a multi-tenant host the injected +`kernelResolver` (ADR-0006) picks a different one per environment. + +That per-request answer was being stored on a dispatcher **instance field**, +`this.kernel`, written once per request by `resolveRequestScope()` and then read +by `resolveService()` / `getService()` / `getObjectQL()` / +`getRequestKernelService()` / `announceKernelEvent()` / `getRegisteredAiRoutes()` +— every one of them behind at least one `await`. Node's single thread is no +protection here: what it protects is code that does **not** hold mutable shared +state across an `await`, and this held it across several. + +So two interleaved requests on two environments produced this: + +1. request A resolves, `this.kernel` = env-1's kernel; +2. A yields at an `await` (session lookup, driver query); +3. request B resolves, `this.kernel` = env-2's kernel; +4. A resumes and resolves `objectql` / `metadata` / `automation` off **env-2**. + +One tenant's request reading another tenant's data source — a correctness and +isolation defect, not a performance one. Single-environment deployments were +never affected (`this.kernel === defaultKernel` always, so the write was +idempotent), which is exactly why no local run or CI job ever showed it. It is +now covered by a deterministic interleaving regression test +(`http-dispatcher.multi-tenant-concurrency.test.ts`), which fails on the old +code with request A being served env-2's data. + +**The fix: the resolved kernel travels on the request, and every facility that +reads a kernel takes the request explicitly.** `HttpProtocolContext` gains a +`kernel` field, written by `resolveRequestScope()` alongside the +`environmentId` / `dataDriver` / `executionContext` it already writes there. +There is no longer any `this.kernel` to rewrite. An `AsyncLocalStorage` carrier +was deliberately **not** used: it would have reintroduced implicit mutable +ambient context, which is the shape of this bug in a new costume. + +Three host-level readers moved to the host kernel explicitly, where they had +been reading whichever tenant resolved most recently: `/ready` (readiness is a +property of the replica), its driver-health probe, and the memoized +single-environment `default-project` lookup. + +**Migration — `DomainHandlerDeps` and `ActionExecutionDeps`.** Every +kernel-reading member now takes the request as its **first** parameter. If you +implement or call either contract (both are exported from +`@objectstack/runtime`; nothing in this monorepo or the sibling distributions +did): + +- `deps.resolveService(name, envId)` becomes `deps.resolveService(context, name, envId)` +- `deps.getService(name)` becomes `deps.getService(context, name)` +- `deps.getObjectQL(envId)` becomes `deps.getObjectQL(context, envId)` +- `deps.getRequestKernelService(name)` becomes `deps.getRequestKernelService(context, name)` +- `deps.announceKernelEvent(event, payload)` becomes `deps.announceKernelEvent(context, event, payload)` +- `deps.getRegisteredAiRoutes()` becomes `deps.getRegisteredAiRoutes(context)` + +`context` is the `HttpProtocolContext` the domain handler already receives. The +same rule applies to the `action-execution` helpers, which take it right after +`deps`: `callData`, `resolveAutomationService`, `dispatchFlowAction`, +`invokeBusinessAction`, `resolveRouteActionDeclaration`. + +`HttpDispatcher.getDiscoveryInfo(prefix)` gains an **optional** second argument, +the request context. Callers that serve `/discovery` straight off the host (the +adapters, the dispatcher plugin) need no change and now describe the host kernel +deterministically instead of whichever tenant asked last. + +`resolveProjectKernelObjectQL(context)` keeps its direct-caller kernel swap; +the swap is now written onto that context, so it stays visible to the rest of +that request and to nothing else. diff --git a/packages/runtime/src/action-body-identity.test.ts b/packages/runtime/src/action-body-identity.test.ts index afd8c54993..a34daa00d0 100644 --- a/packages/runtime/src/action-body-identity.test.ts +++ b/packages/runtime/src/action-body-identity.test.ts @@ -246,7 +246,7 @@ describe('#3914 — MCP run_action dispatch binds ctx.api and ctx.engine', () => }; const ec = { userId: 'user_42', tenantId: 'org_acme', positions: [], permissions: [] }; - await invokeBusinessAction(mcpDeps, 'close_case', { recordId: 'case_1' }, { + await invokeBusinessAction(mcpDeps, { request: {} } as any, 'close_case', { recordId: 'case_1' }, { driver: undefined, envId: 'platform', ec, diff --git a/packages/runtime/src/action-execution-calldata-query.test.ts b/packages/runtime/src/action-execution-calldata-query.test.ts index 10b6dc3b22..914705acb0 100644 --- a/packages/runtime/src/action-execution-calldata-query.test.ts +++ b/packages/runtime/src/action-execution-calldata-query.test.ts @@ -21,8 +21,16 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { callData, type ActionExecutionDeps } from './action-execution.js'; +import type { HttpProtocolContext } from './http-dispatcher.js'; const EC = { userId: 'u1', isSystem: false, positions: [], permissions: [] } as any; +/** + * The request `callData` is serving. [#5155] Every service lookup resolves off + * `context.kernel`, so the request has to be named at the call — a fake that + * ignored it would be modelling the shared-field shape this suite's subject no + * longer has. + */ +const REQ = { request: {} } as HttpProtocolContext; function makeHarness(opts: { withProtocol?: boolean } = {}) { const finds: any[] = []; @@ -39,7 +47,7 @@ function makeHarness(opts: { withProtocol?: boolean } = {}) { ...(protocol ? { protocol } : {}), }; const deps: ActionExecutionDeps = { - resolveService: (async (name: string) => services[name]) as any, + resolveService: (async (_ctx: HttpProtocolContext, name: string) => services[name]) as any, getObjectQL: async () => ql, }; return { deps, finds, findData }; @@ -58,19 +66,19 @@ describe("callData('query') fallback serves the query it was given (#4386)", () offset: 10, fields: ['id', 'title'], }; - const out = await callData(h.deps, 'query', { object: 'task', query }, undefined, undefined, EC); + const out = await callData(h.deps, REQ, 'query', { object: 'task', query }, undefined, undefined, EC); expect(h.finds).toHaveLength(1); expect(h.finds[0]).toMatchObject({ ...query, context: EC }); expect(out.records).toHaveLength(2); }); it('extracts query fields from bare params when params.query is absent — same source as the protocol path', async () => { - await callData(h.deps, 'query', { object: 'task', where: { status: 'open' }, limit: 3 }, undefined, undefined, EC); + await callData(h.deps, REQ, 'query', { object: 'task', where: { status: 'open' }, limit: 3 }, undefined, undefined, EC); expect(h.finds[0]).toMatchObject({ where: { status: 'open' }, limit: 3 }); }); it('a caller-supplied context is dropped, never honoured — server-derived only, matching findData', async () => { - await callData(h.deps, 'query', { object: 'task', query: { where: { a: 1 }, context: { isSystem: true } } }, undefined, undefined, EC); + await callData(h.deps, REQ, 'query', { object: 'task', query: { where: { a: 1 }, context: { isSystem: true } } }, undefined, undefined, EC); expect(h.finds[0].context).toBe(EC); }); @@ -78,7 +86,7 @@ describe("callData('query') fallback serves the query it was given (#4386)", () 'refuses %s with 501 instead of part-serving — nothing reaches ql.find', async (key) => { await expect( - callData(h.deps, 'query', { object: 'task', query: { where: { a: 1 }, [key]: 'x' } }, undefined, undefined, EC), + callData(h.deps, REQ, 'query', { object: 'task', query: { where: { a: 1 }, [key]: 'x' } }, undefined, undefined, EC), ).rejects.toMatchObject({ statusCode: 501 }); expect(h.finds).toHaveLength(0); }, @@ -86,24 +94,24 @@ describe("callData('query') fallback serves the query it was given (#4386)", () it('names the unservable keys and the served set in the refusal', async () => { await expect( - callData(h.deps, 'query', { object: 'task', query: { sort: '-x', select: 'id' } }, undefined, undefined, EC), + callData(h.deps, REQ, 'query', { object: 'task', query: { sort: '-x', select: 'id' } }, undefined, undefined, EC), ).rejects.toMatchObject({ message: expect.stringMatching(/'sort', 'select'.*where, fields, orderBy, limit, offset/s) }); }); it('an empty query still lists (the protocol path lists too) — no refusal, no predicate', async () => { - const out = await callData(h.deps, 'query', { object: 'task' }, undefined, undefined, EC); + const out = await callData(h.deps, REQ, 'query', { object: 'task' }, undefined, undefined, EC); expect(h.finds[0]).toMatchObject({ context: EC }); expect(out.total).toBe(2); }); it('null-valued keys are withdrawals, not unservable', async () => { - await callData(h.deps, 'query', { object: 'task', query: { sort: null, where: { a: 1 } } }, undefined, undefined, EC); + await callData(h.deps, REQ, 'query', { object: 'task', query: { sort: null, where: { a: 1 } } }, undefined, undefined, EC); expect(h.finds[0]).toMatchObject({ where: { a: 1 } }); }); it('with the protocol service present the fallback never runs — findData gets the query verbatim, wire spellings included', async () => { const withP = makeHarness({ withProtocol: true }); - await callData(withP.deps, 'query', { object: 'task', query: { sort: '-title', top: 5 } }, undefined, undefined, EC); + await callData(withP.deps, REQ, 'query', { object: 'task', query: { sort: '-title', top: 5 } }, undefined, undefined, EC); expect(withP.findData).toHaveLength(1); expect(withP.findData[0].query).toEqual({ sort: '-title', top: 5 }); expect(withP.finds).toHaveLength(0); diff --git a/packages/runtime/src/action-execution.ts b/packages/runtime/src/action-execution.ts index eacaa808ce..97653f08c0 100644 --- a/packages/runtime/src/action-execution.ts +++ b/packages/runtime/src/action-execution.ts @@ -19,6 +19,7 @@ import { validateActionParams, type ResolvedActionParam } from '@objectstack/spe import type { ExecutionContext } from '@objectstack/spec/kernel'; import type { IObjectQLEngine, ServiceSlotContract, ServiceSlotContracts } from '@objectstack/spec/contracts'; import { checkApiExposure } from './api-exposure.js'; +import type { HttpProtocolContext } from './http-dispatcher.js'; import { GLOBAL_ACTION_OBJECT_KEY, actionHandlerObjectKeys, @@ -78,21 +79,31 @@ function warnActionParamsOnce(key: string, message: string): void { * the pattern, alongside `ResolveOptions` in security/resolve-execution-context. * A lookup facade has to be typed everywhere it is re-declared, or the copy * that still says `any` becomes the way around all the others. + * + * [#5155] Both lookups take the REQUEST as their first parameter, for the + * reason spelled out on `DomainHandlerDeps` (of which this is the narrow view + * `HttpDispatcher.actionExecutionDeps` hands out): the object is shared by + * every request the host serves, so the kernel to resolve against is the + * request's, never the facade's. */ export interface ActionExecutionDeps { - resolveService(name: K, environmentId?: string): Promise | undefined>; - resolveService(name: string, environmentId?: string): any; - getObjectQL(environmentId?: string): Promise; + resolveService(context: HttpProtocolContext, name: K, environmentId?: string): Promise | undefined>; + resolveService(context: HttpProtocolContext, name: string, environmentId?: string): any; + getObjectQL(context: HttpProtocolContext, environmentId?: string): Promise; } /** * Direct data service dispatch — replaces broker.call('data.*'). * Tries protocol service first (supports expand/populate), falls back to ObjectQL. * + * @param requestContext - The request being served (#5155). Carries the kernel + * every service lookup below resolves against; see + * {@link HttpProtocolContext.kernel}. * @param dataDriver - Optional environment-scoped driver to use instead of kernel default * @param scopeId - Optional project ID for scoped service resolution (SharedProjectPlugin mode) */ -export async function callData(deps: ActionExecutionDeps, +export async function callData(deps: ActionExecutionDeps, + requestContext: HttpProtocolContext, action: string, params: any, dataDriver?: any, @@ -106,7 +117,7 @@ export async function callData(deps: ActionExecutionDeps, if (!executionContext?.isSystem && params?.object) { let def: any; try { - const meta = await deps.resolveService('metadata', scopeId); + const meta = await deps.resolveService(requestContext, 'metadata', scopeId); def = await (meta as any)?.getObject?.(params.object); } catch { def = undefined; // fall open to schema defaults (apiEnabled=true) @@ -117,9 +128,9 @@ export async function callData(deps: ActionExecutionDeps, } } - const protocol = await deps.resolveService('protocol', scopeId); - const qlService = dataDriver ?? await deps.getObjectQL(scopeId); - const ql = qlService ?? await deps.resolveService('objectql', scopeId); + const protocol = await deps.resolveService(requestContext, 'protocol', scopeId); + const qlService = dataDriver ?? await deps.getObjectQL(requestContext, scopeId); + const ql = qlService ?? await deps.resolveService(requestContext, 'objectql', scopeId); const qlOpts = executionContext ? { context: executionContext } : undefined; const findOpts = (extra?: any) => { const base = qlOpts ? { ...qlOpts } : {}; @@ -252,8 +263,8 @@ export async function callData(deps: ActionExecutionDeps, if (!Array.isArray(params.aggregations) || params.aggregations.length === 0) { throw { statusCode: 400, message: 'aggregate requires at least one aggregation' }; } - const engine = (await deps.getObjectQL(scopeId)) - ?? await deps.resolveService('objectql', scopeId).catch(() => null); + const engine = (await deps.getObjectQL(requestContext, scopeId)) + ?? await deps.resolveService(requestContext, 'objectql', scopeId).catch(() => null); if (engine && typeof engine.aggregate === 'function') { const rows = await engine.aggregate( params.object, @@ -383,13 +394,13 @@ export function headlessActionTypeError(_deps: ActionExecutionDeps, action: any, * the single availability probe behind `type: 'flow'` dispatch (both the * headless-invokability filter and the two invoke paths ask through it). */ -export async function resolveAutomationService(deps: ActionExecutionDeps, envId?: string): Promise { +export async function resolveAutomationService(deps: ActionExecutionDeps, requestContext: HttpProtocolContext, envId?: string): Promise { try { // [#4127 batch 4] Was `: any`, which voided the gate here. `execute` is // declared on IAutomationService, so this needed no contract work — only // for someone to notice, and three grep sweeps over `domains/*.ts` never // reached this file. The lint rule did. - const svc = await deps.resolveService('automation', envId); + const svc = await deps.resolveService(requestContext, 'automation', envId); return svc && typeof svc.execute === 'function' ? svc : null; } catch { return null; // no automation service on this kernel @@ -483,6 +494,7 @@ export function seedFlowActionParams(_deps: ActionExecutionDeps, * doesn't keep. */ export async function dispatchFlowAction(deps: ActionExecutionDeps, + requestContext: HttpProtocolContext, action: any, wiring: { objectName: string; @@ -494,7 +506,7 @@ export async function dispatchFlowAction(deps: ActionExecutionDeps, }, ): Promise { const { objectName, record, params, recordId, ec, envId } = wiring; - const automation = await resolveAutomationService(deps, envId); + const automation = await resolveAutomationService(deps, requestContext, envId); if (!automation) { throw new Error(flowActionUnavailableError(action)); } @@ -790,7 +802,8 @@ export function buildActionEngineFacade(_deps: ActionExecutionDeps, ql: any, ec? * attributable and org-scoped. Flow actions differ: the flow engine receives * the caller's identity below and honours `runAs` (ADR-0049). */ -export async function invokeBusinessAction(deps: ActionExecutionDeps, +export async function invokeBusinessAction(deps: ActionExecutionDeps, + requestContext: HttpProtocolContext, name: string, input: { objectName?: string; recordId?: string; params?: Record }, wiring: { @@ -821,7 +834,7 @@ export async function invokeBusinessAction(deps: ActionExecutionDeps, if (isSystemObjectName(objectName)) { throw new Error(`Action '${name}' is on a system object and is not exposed via MCP`); } - const hasAutomation = Boolean(await resolveAutomationService(deps, envId)); + const hasAutomation = Boolean(await resolveAutomationService(deps, requestContext, envId)); if (!isHeadlessInvokableAction(deps, action, hasAutomation)) { throw new Error( `Action '${name}' (type='${action?.type ?? 'script'}') cannot be invoked via MCP`, @@ -873,7 +886,7 @@ export async function invokeBusinessAction(deps: ActionExecutionDeps, // ── flow dispatch ── (shared with the REST /actions route, #3915) if (action.type === 'flow') { - const result = await dispatchFlowAction(deps, action, { objectName, record, params, recordId, ec, envId }); + const result = await dispatchFlowAction(deps, requestContext, action, { objectName, record, params, recordId, ec, envId }); return { ok: true, action: action.name, objectName, ...(recordId ? { recordId } : {}), result }; } @@ -881,7 +894,7 @@ export async function invokeBusinessAction(deps: ActionExecutionDeps, // [#4127] `executeAction` is // ObjectQL's own surface, outside IDataEngine; `getObjectQL` exists to reach // exactly that. Closing this needs ObjectQL's contract written, not a cast. - const ql: any = await deps.getObjectQL(envId); + const ql: any = await deps.getObjectQL(requestContext, envId); if (!ql || typeof ql.executeAction !== 'function') { throw new Error('Data engine not available for action dispatch'); } @@ -1093,6 +1106,7 @@ export async function executeRegisteredAction(_deps: ActionExecutionDeps, * lookup. */ export async function resolveRouteActionDeclaration(deps: ActionExecutionDeps, + requestContext: HttpProtocolContext, args: { ql: any; objectName: string; actionName: string; envId?: string }, ): Promise<{ action: any; obj: any; degraded?: boolean; reason?: string }> { const { ql, objectName, actionName, envId } = args; @@ -1140,7 +1154,7 @@ export async function resolveRouteActionDeclaration(deps: ActionExecutionDeps, // and belongs in the batch that adds the four undeclared auth members. // [#4127 batch 4] `loadDiagnosed` is on IMetadataService now, so this // reads the contract instead of guessing at it. - const meta = await deps.resolveService('metadata', envId); + const meta = await deps.resolveService(requestContext, 'metadata', envId); if (meta && typeof meta.loadDiagnosed === 'function') { const diag: any = await meta.loadDiagnosed('action', actionName); if (diag?.data && ownsRoute(diag.data)) return { action: diag.data, obj }; diff --git a/packages/runtime/src/dispatcher-plugin.ts b/packages/runtime/src/dispatcher-plugin.ts index 33f7151fd3..14f02a7f84 100644 --- a/packages/runtime/src/dispatcher-plugin.ts +++ b/packages/runtime/src/dispatcher-plugin.ts @@ -1332,12 +1332,17 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu // request kernel): a declared endpoint must reach the same // occupant the built-in route reaches, or "same operation, // same answer" (#5040 §4) stops being true. + // [#5155] Every lookup names the request it belongs + // to. `execDeps` is the ONE object the host built at + // start(); it cannot know which of the requests in + // flight is asking, and on a multi-tenant host guessing + // means answering out of another tenant's kernel. const execDeps = dispatcher.actionExecutionDeps; const metadataService = await execDeps - .resolveService('metadata', protocolContext.environmentId) + .resolveService(protocolContext, 'metadata', protocolContext.environmentId) .catch(() => undefined) as IMetadataService | undefined; const automationService = await execDeps - .resolveService('automation') + .resolveService(protocolContext, 'automation') .catch(() => undefined); const answer = await runAppEndpointStep({ @@ -1379,7 +1384,7 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu // object, and therefore the same pipeline, // `/data` calls (`domains/data.ts`). callData: (action, params, driver, scope, ec) => - callData(execDeps, action, params, driver, scope, ec), + callData(execDeps, protocolContext, action, params, driver, scope, ec), ...(automationService !== undefined ? { automationService } : {}), }, ...(protocolContext.executionContext !== undefined diff --git a/packages/runtime/src/domain-handler-registry.ts b/packages/runtime/src/domain-handler-registry.ts index b222cae9bd..9d66ab48b7 100644 --- a/packages/runtime/src/domain-handler-registry.ts +++ b/packages/runtime/src/domain-handler-registry.ts @@ -76,6 +76,25 @@ export interface DomainRoute { * WHOLE dependency contract, made explicit. Growing this interface is a * design decision, not a convenience: every addition couples all domains to * more dispatcher surface. + * + * ## Why every kernel-reading facility takes the request first (#5155) + * + * A host constructs exactly ONE `HttpDispatcher` and therefore exactly one of + * these objects — every route, every tenant, every concurrent request shares + * it. So it cannot hold "the kernel of the request currently in flight": on a + * multi-tenant host (a `kernelResolver` is registered) there is no such single + * value. It used to try, via a `this.kernel` field on the dispatcher written + * once per request, and the result was that a request resuming after an + * `await` resolved its services on whichever environment had resolved most + * recently — one tenant reading another tenant's data source. + * + * The per-request kernel therefore travels on the per-request object every + * handler already receives: {@link HttpProtocolContext.kernel}, written by + * `HttpDispatcher.resolveRequestScope`. Passing the context is not ceremony — + * it is what makes the dependency visible at the call site and impossible to + * forget, because the compiler asks for it. Do NOT add a facility here that + * reads a kernel without taking the request, and do not cache the resolved + * kernel anywhere that outlives one request. */ export interface DomainHandlerDeps { /** @@ -103,8 +122,8 @@ export interface DomainHandlerDeps { * answer with a change to the boot/criticality vocabulary; the ledger * extends past the enum instead. See {@link ServiceSlotContracts}. */ - resolveService(name: K, environmentId?: string): Promise | undefined>; - resolveService(name: string, environmentId?: string): any; + resolveService(context: HttpProtocolContext, name: K, environmentId?: string): Promise | undefined>; + resolveService(context: HttpProtocolContext, name: string, environmentId?: string): any; /** * Unscoped service lookup on the current kernel, typed by the slot. * @@ -123,7 +142,7 @@ export interface DomainHandlerDeps { * (`isServiceServeable` does it and also rejects a self-declared * non-handler, ADR-0076 D12). */ - getService(name: K): Promise | undefined>; + getService(context: HttpProtocolContext, name: K): Promise | undefined>; /** * Environment-scoped ObjectQL lookup with a registry-shape check * (resolves the `objectql` service and returns it only when it exposes @@ -140,7 +159,7 @@ export interface DomainHandlerDeps { * checked against the class by `implements` — so the honest type finally * exists, and this accessor uses it. */ - getObjectQL(environmentId?: string): Promise; + getObjectQL(context: HttpProtocolContext, environmentId?: string): Promise; /** * Service lookup on the request's RESOLVED (per-environment) kernel — * NOT the default kernel and NOT the scoped-factory path. Domains whose @@ -156,8 +175,8 @@ export interface DomainHandlerDeps { * this path and falls back to `resolveService` for the same `'objectql'` * slot, so before this the two arms of one expression had different types. */ - getRequestKernelService(name: K): Promise | undefined>; - getRequestKernelService(name: string): Promise; + getRequestKernelService(context: HttpProtocolContext, name: K): Promise | undefined>; + getRequestKernelService(context: HttpProtocolContext, name: string): Promise; /** Standard success envelope. */ success(data: any, meta?: any): { status: number; body: any }; /** @@ -188,7 +207,7 @@ export interface DomainHandlerDeps { * announce `metadata:reloaded` after a publish so boot-cached consumers * (the automation engine above all) re-sync without a restart. */ - announceKernelEvent(event: string, payload: unknown): Promise; + announceKernelEvent(context: HttpProtocolContext, event: string, payload: unknown): Promise; /** Host logger when one is attached to the dispatcher; domains fall back to console. */ logger?: any; /** Single-environment default environment id (createSingleEnvironmentPlugin), if registered. */ @@ -209,7 +228,7 @@ export interface DomainHandlerDeps { * The AI route table the AI plugin caches on the request kernel * (`__aiRoutes`); undefined until the plugin initializes it. */ - getRegisteredAiRoutes(): Array<{ method: string; path: string; handler: (req: any) => Promise; auth?: boolean }> | undefined; + getRegisteredAiRoutes(context: HttpProtocolContext): Array<{ method: string; path: string; handler: (req: any) => Promise; auth?: boolean }> | undefined; } /** diff --git a/packages/runtime/src/domains/actions.ts b/packages/runtime/src/domains/actions.ts index a7b9f01a9f..bfec89c0c5 100644 --- a/packages/runtime/src/domains/actions.ts +++ b/packages/runtime/src/domains/actions.ts @@ -127,7 +127,7 @@ export async function handleActionsRequest(deps: DomainHandlerDeps, path: string // [#4127] Same as the // action-execution site: `executeAction` is outside IDataEngine, and // ObjectQL's wider surface has no contract yet. - const ql: any = projectQl ?? await deps.getObjectQL(_context?.environmentId); + const ql: any = projectQl ?? await deps.getObjectQL(_context, _context?.environmentId); if (!ql || typeof ql.executeAction !== 'function') { return { handled: true, response: deps.error('Data engine not available', 503) }; } @@ -150,7 +150,7 @@ export async function handleActionsRequest(deps: DomainHandlerDeps, path: string // authored `action` rows) resolve here too, so a route whose action // never appears inside an object definition is gated and dispatched // like any other (#3915). - const declaration = await actionExec.resolveRouteActionDeclaration(deps, { + const declaration = await actionExec.resolveRouteActionDeclaration(deps, _context, { ql, objectName, actionName, @@ -239,7 +239,7 @@ export async function handleActionsRequest(deps: DomainHandlerDeps, path: string // A flow action on a kernel with no automation service is a deployment // gap, not a business failure — report it like the missing data engine // above (503) instead of burying it in a `{ success: false }` body. - if (actionType === 'flow' && !(await actionExec.resolveAutomationService(deps, _context?.environmentId))) { + if (actionType === 'flow' && !(await actionExec.resolveAutomationService(deps, _context, _context?.environmentId))) { return { handled: true, response: deps.error(actionExec.flowActionUnavailableError(actionDef), 503) }; } @@ -259,7 +259,7 @@ export async function handleActionsRequest(deps: DomainHandlerDeps, path: string let record: Record = {}; if (recordId && !actionExec.isObjectLessActionKey(objectName)) { try { - const got = await actionExec.callData(deps, 'get', { object: objectName, id: recordId }, _context.dataDriver, _context.environmentId, _context.executionContext); + const got = await actionExec.callData(deps, _context, 'get', { object: objectName, id: recordId }, _context.dataDriver, _context.environmentId, _context.executionContext); if (got?.record) record = got.record; } catch { /* record may not exist for new-record actions; pass empty */ } } @@ -315,7 +315,7 @@ export async function handleActionsRequest(deps: DomainHandlerDeps, path: string // here — RLS/FLS-bypassing elevation is a script-BODY property, and a // flow does not get it. if (actionType === 'flow') { - const result = await actionExec.dispatchFlowAction(deps, actionDef, { + const result = await actionExec.dispatchFlowAction(deps, _context, actionDef, { objectName, record, params: reqParams, diff --git a/packages/runtime/src/domains/ai-request-user-capability-channel.test.ts b/packages/runtime/src/domains/ai-request-user-capability-channel.test.ts index c47e5ecd8b..ebd37b9959 100644 --- a/packages/runtime/src/domains/ai-request-user-capability-channel.test.ts +++ b/packages/runtime/src/domains/ai-request-user-capability-channel.test.ts @@ -51,8 +51,11 @@ const TOOL_ROUTE = '/api/v1/ai/tools/:toolName/execute'; function makeDeps(seen: { req?: any }): DomainHandlerDeps { const aiService: any = { chat: async () => ({ text: 'ok' }) }; return { - resolveService: (async (name: string) => (name === 'ai' ? aiService : undefined)) as any, - getRegisteredAiRoutes: () => [ + // [#5155] The request comes first on every kernel-reading facility — + // including the AI route table, which is cached on the request's own + // kernel (`__aiRoutes`), not on the host's. + resolveService: (async (_ctx: HttpProtocolContext, name: string) => (name === 'ai' ? aiService : undefined)) as any, + getRegisteredAiRoutes: (_ctx: HttpProtocolContext) => [ { method: 'POST', path: TOOL_ROUTE, diff --git a/packages/runtime/src/domains/ai.ts b/packages/runtime/src/domains/ai.ts index e92e621534..ab864bfc75 100644 --- a/packages/runtime/src/domains/ai.ts +++ b/packages/runtime/src/domains/ai.ts @@ -34,7 +34,7 @@ export function createAiDomain(deps: DomainHandlerDeps): DomainRoute { export async function handleAIRequest(deps: DomainHandlerDeps, subPath: string, method: string, body: any, query: any, context: HttpProtocolContext): Promise { let aiService: IAIService | undefined; try { - aiService = await deps.resolveService('ai'); + aiService = await deps.resolveService(context, 'ai'); } catch { // AI service not registered } @@ -99,7 +99,7 @@ export async function handleAIRequest(deps: DomainHandlerDeps, subPath: string, }; // Try to get route definitions from the AI service's cached routes - const routes = deps.getRegisteredAiRoutes() as Array<{ + const routes = deps.getRegisteredAiRoutes(context) as Array<{ method: string; path: string; handler: (req: any) => Promise; auth?: boolean; }> | undefined; diff --git a/packages/runtime/src/domains/analytics.ts b/packages/runtime/src/domains/analytics.ts index e0850db612..4f7c515607 100644 --- a/packages/runtime/src/domains/analytics.ts +++ b/packages/runtime/src/domains/analytics.ts @@ -83,7 +83,7 @@ export async function handleAnalyticsRequest( context: HttpProtocolContext, query?: any, ): Promise { - const analyticsService = await deps.getService(CoreServiceName.enum.analytics); + const analyticsService = await deps.getService(context, CoreServiceName.enum.analytics); // Empty slot — or a slot filled by a self-declared stub (#4000), which is // the same amount of analytics capability. 404 handled by caller. if (!isServiceServeable(analyticsService)) return { handled: false }; diff --git a/packages/runtime/src/domains/auth.ts b/packages/runtime/src/domains/auth.ts index 2e752f798e..064883231d 100644 --- a/packages/runtime/src/domains/auth.ts +++ b/packages/runtime/src/domains/auth.ts @@ -50,7 +50,7 @@ export async function handleAuthRequest(deps: DomainHandlerDeps, _path: string, // that reached `handleAuth` directly WITH an auth service registered used to // get that mock's `mock_` session instead of real authentication. It // now gets the auth service; #4113 removed the mock entirely (see below). - const authService = await deps.getService(CoreServiceName.enum.auth); + const authService = await deps.getService(context, CoreServiceName.enum.auth); if (authService && typeof authService.handleRequest === 'function') { const response = await authService.handleRequest(context.request as Request); return { handled: true, result: response }; diff --git a/packages/runtime/src/domains/automation.ts b/packages/runtime/src/domains/automation.ts index a323e51709..74451b49f7 100644 --- a/packages/runtime/src/domains/automation.ts +++ b/packages/runtime/src/domains/automation.ts @@ -122,7 +122,7 @@ export function createAutomationDomain(deps: DomainHandlerDeps): DomainRoute { * GET /:name/runs/:runId/screen → the screen a paused run awaits */ export async function handleAutomationRequest(deps: DomainHandlerDeps, path: string, method: string, body: any, context: HttpProtocolContext, query?: any): Promise { - const automationService = await deps.getService(CoreServiceName.enum.automation); + const automationService = await deps.getService(context, CoreServiceName.enum.automation); // [#4058] Empty slot — or a slot filled by a self-declared non-handler // (`handlerReady: false`, ADR-0076 D12), which is the same amount of // automation capability. This domain is the sharpest case for the rule: a diff --git a/packages/runtime/src/domains/data-path-object.test.ts b/packages/runtime/src/domains/data-path-object.test.ts index 6d0a953e0f..593a2dacfd 100644 --- a/packages/runtime/src/domains/data-path-object.test.ts +++ b/packages/runtime/src/domains/data-path-object.test.ts @@ -18,7 +18,10 @@ function setup(objectDefs: Record = {}) { const protocol = { findData }; const metadata = { getObject: async (name: string) => objectDefs[name] }; const deps: any = { - resolveService: async (name: string) => (name === 'protocol' ? protocol : name === 'metadata' ? metadata : null), + // [#5155] The request is the first argument of every kernel-reading + // facility now; the fake takes it so a call site that forgot to pass + // one cannot silently resolve the wrong slot name. + resolveService: async (_ctx: any, name: string) => (name === 'protocol' ? protocol : name === 'metadata' ? metadata : null), getService: () => null, getObjectQL: async () => null, getRequestKernelService: async () => null, diff --git a/packages/runtime/src/domains/data.ts b/packages/runtime/src/domains/data.ts index 27b7dd001a..8d3c5d8483 100644 --- a/packages/runtime/src/domains/data.ts +++ b/packages/runtime/src/domains/data.ts @@ -70,7 +70,7 @@ export async function handleDataRequest(deps: DomainHandlerDeps, path: string, m // data (`data: body`, `query: normalized`) instead of splatting it, // and the GET-by-id branch even allowlists its query params against // exactly this kind of parameter pollution. - const result = await actionExec.callData(deps, 'query', { ...body, object: objectName }, _context.dataDriver, _context.environmentId, _context.executionContext); + const result = await actionExec.callData(deps, _context, 'query', { ...body, object: objectName }, _context.dataDriver, _context.environmentId, _context.executionContext); return { handled: true, response: deps.success(result) }; } @@ -84,7 +84,7 @@ export async function handleDataRequest(deps: DomainHandlerDeps, path: string, m 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); + const result = await actionExec.callData(deps, _context, 'get', { object: objectName, id, ...allowedParams }, _context.dataDriver, _context.environmentId, _context.executionContext); return { handled: true, response: deps.success(result) }; } @@ -92,7 +92,7 @@ export async function handleDataRequest(deps: DomainHandlerDeps, path: string, m 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); + const result = await actionExec.callData(deps, _context, 'update', { object: objectName, id, data: body }, _context.dataDriver, _context.environmentId, _context.executionContext); return { handled: true, response: deps.success(result) }; } @@ -100,7 +100,7 @@ export async function handleDataRequest(deps: DomainHandlerDeps, path: string, m 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); + const result = await actionExec.callData(deps, _context, 'delete', { object: objectName, id }, _context.dataDriver, _context.environmentId, _context.executionContext); return { handled: true, response: deps.success(result) }; } } else { @@ -116,14 +116,14 @@ export async function handleDataRequest(deps: DomainHandlerDeps, path: string, m // here could only agree by inspection. // // Spec: returns FindDataResponse = { object, records, total?, hasMore? } - const result = await actionExec.callData(deps, 'query', { object: objectName, query: { ...query } }, _context.dataDriver, _context.environmentId, _context.executionContext); + const result = await actionExec.callData(deps, _context, 'query', { object: objectName, query: { ...query } }, _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 result = await actionExec.callData(deps, _context, 'create', { object: objectName, data: body }, _context.dataDriver, _context.environmentId, _context.executionContext); const res = deps.success(result); res.status = 201; return { handled: true, response: res }; diff --git a/packages/runtime/src/domains/i18n.ts b/packages/runtime/src/domains/i18n.ts index 8eb4970ee0..469c0eac20 100644 --- a/packages/runtime/src/domains/i18n.ts +++ b/packages/runtime/src/domains/i18n.ts @@ -39,7 +39,7 @@ export async function handleI18nRequest( query: any, _context: HttpProtocolContext, ): Promise { - const i18nService = await deps.getService(CoreServiceName.enum.i18n); + const i18nService = await deps.getService(_context, CoreServiceName.enum.i18n); // [#4058] An empty slot and a slot filled by a self-declared non-handler // (`handlerReady: false`, ADR-0076 D12) are the same amount of i18n. Both // in-memory providers of this slot really translate, so both declare diff --git a/packages/runtime/src/domains/keys.ts b/packages/runtime/src/domains/keys.ts index 0aac0afdc3..397c3a5021 100644 --- a/packages/runtime/src/domains/keys.ts +++ b/packages/runtime/src/domains/keys.ts @@ -76,8 +76,8 @@ export async function handleKeysRequest( expiresAt = new Date(ms).toISOString(); } - const ql = (await deps.getObjectQL(context.environmentId)) - ?? (await deps.resolveService('objectql', context.environmentId)); + const ql = (await deps.getObjectQL(context, context.environmentId)) + ?? (await deps.resolveService(context, 'objectql', context.environmentId)); if (!ql || typeof ql.insert !== 'function') { return { handled: true, response: deps.error('Data service not available', 503) }; } diff --git a/packages/runtime/src/domains/mcp.ts b/packages/runtime/src/domains/mcp.ts index 94f82660ec..9abb611a4e 100644 --- a/packages/runtime/src/domains/mcp.ts +++ b/packages/runtime/src/domains/mcp.ts @@ -51,7 +51,7 @@ export async function handleMcpRequest(deps: DomainHandlerDeps, body: any, conte if (!isMcpServerEnabled()) { return { handled: true, response: deps.error('MCP server is not enabled for this environment', 404) }; } - const mcp: any = await deps.resolveService('mcp', context.environmentId); + const mcp: any = await deps.resolveService(context, 'mcp', context.environmentId); if (!mcp || typeof mcp.handleHttpRequest !== 'function') { return { handled: true, response: deps.error('MCP server is not available', 501) }; } @@ -180,7 +180,7 @@ export async function handleMcpSkillRequest(deps: DomainHandlerDeps, method: str }, }; } - const mcp: any = await deps.resolveService('mcp', context.environmentId); + const mcp: any = await deps.resolveService(context, 'mcp', context.environmentId); if (!mcp || typeof mcp.renderSkill !== 'function') { return { handled: true, response: deps.error('MCP server is not available', 501) }; } @@ -197,7 +197,7 @@ export async function handleMcpSkillRequest(deps: DomainHandlerDeps, method: str // now, so `?.()` reads a declared optional capability (an auth provider // without MCP/OAuth support fills this slot legitimately) instead of // guessing at a method the contract never mentioned. - const authService = await deps.resolveService('auth', context.environmentId); + const authService = await deps.resolveService(context, 'auth', context.environmentId); const url = authService?.getMcpResourceUrl?.(); if (typeof url === 'string' && url) mcpUrl = url; } catch { /* fall through to host derivation */ } @@ -247,7 +247,7 @@ export async function handleMcpSkillRequest(deps: DomainHandlerDeps, method: str async function getMcpResourceMetadataUrl(deps: DomainHandlerDeps, context: HttpProtocolContext): Promise { try { // [#4127] Same `: any` erasure as the skill route above; same fix. - const authService = await deps.resolveService('auth', context.environmentId); + const authService = await deps.resolveService(context, 'auth', context.environmentId); const url = authService?.getMcpResourceMetadataUrl?.(); return typeof url === 'string' && url ? url : null; } catch { @@ -315,8 +315,11 @@ export function buildMcpBridge(deps: DomainHandlerDeps, context: HttpProtocolCon const ec = context.executionContext; const envId = context.environmentId; const driver = (context as any).dataDriver; - const callData = actionExec.callData.bind(null, deps); - const getMeta = () => deps.resolveService('metadata', envId); + // [#5155] Both the facilities AND the request are bound here, once, so the + // bridge's tool surface below reads exactly as it did — while every call it + // makes stays pinned to THIS request's kernel. + const callData = actionExec.callData.bind(null, deps, context); + const getMeta = () => deps.resolveService(context, 'metadata', envId); return { listObjects: async () => { @@ -395,7 +398,7 @@ export function buildMcpBridge(deps: DomainHandlerDeps, context: HttpProtocolCon // identity forwarded. No `@objectstack/service-ai`. listActions: async () => { const meta: any = await getMeta(); - const hasAutomation = Boolean(await actionExec.resolveAutomationService(deps, envId)); + const hasAutomation = Boolean(await actionExec.resolveAutomationService(deps, context, envId)); const out: any[] = []; for (const { action, objectName, obj } of await actionExec.collectActionDeclarations(deps, meta)) { if (!objectName || isSystemObjectName(objectName)) continue; // fail-closed on sys_* @@ -414,6 +417,6 @@ export function buildMcpBridge(deps: DomainHandlerDeps, context: HttpProtocolCon runAction: async ( name: string, input: { objectName?: string; recordId?: string; params?: Record }, - ) => actionExec.invokeBusinessAction(deps, name, input ?? {}, { driver, envId, ec, getMeta, callData }), + ) => actionExec.invokeBusinessAction(deps, context, name, input ?? {}, { driver, envId, ec, getMeta, callData }), }; } diff --git a/packages/runtime/src/domains/meta.ts b/packages/runtime/src/domains/meta.ts index 9f0322a975..efc06cbea9 100644 --- a/packages/runtime/src/domains/meta.ts +++ b/packages/runtime/src/domains/meta.ts @@ -72,7 +72,7 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin // 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'); + const protocol = await deps.resolveService(_context, 'protocol'); if (protocol && typeof protocol.getMetaTypes === 'function') { try { const result = await protocol.getMetaTypes({}); @@ -82,7 +82,7 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin } } // PRIORITY 2: MetadataService fallback (types only, no entries) - const metadataService = await deps.resolveService('metadata', _context.environmentId); + const metadataService = await deps.resolveService(_context, 'metadata', _context.environmentId); if (metadataService && typeof (metadataService as any).getRegisteredTypes === 'function') { try { const types = await (metadataService as any).getRegisteredTypes(); @@ -105,7 +105,7 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin const name = parts[1]; const field = parts[3]; const from = query?.from !== undefined ? String(query.from) : undefined; - const qlService = await deps.getObjectQL(); + const qlService = await deps.getObjectQL(_context); 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) @@ -120,14 +120,14 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin 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); + const metadataService = await deps.getService(_context, 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); + const metaSvc = await deps.resolveService(_context, 'metadata', _context.environmentId); if (metaSvc && typeof (metaSvc as any).getPublished === 'function') { try { const fallbackData = await (metaSvc as any).getPublished(type, name); @@ -151,7 +151,7 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin // PUT /metadata/:type/:name (Save) if (method === 'PUT' && body) { // Try to get the protocol service directly - const protocol = await deps.resolveService('protocol'); + const protocol = await deps.resolveService(_context, 'protocol'); if (protocol && typeof protocol.saveMetaItem === 'function') { try { @@ -167,7 +167,7 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin } // Fallback: try MetadataService directly - const metaSvc = await deps.resolveService('metadata', _context.environmentId); + const metaSvc = await deps.resolveService(_context, 'metadata', _context.environmentId); if (metaSvc && typeof (metaSvc as any).saveItem === 'function') { try { const data = await (metaSvc as any).saveItem(type, name, body); @@ -193,7 +193,7 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin // 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 protocol = await deps.resolveService(_context, 'protocol') as any; const scopedEnv = typeof protocol?.getProjectId === 'function' ? protocol.getProjectId() : protocol?.environmentId; @@ -211,7 +211,7 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin } catch { /* fall through to registry / 404 */ } } - const qlService = await deps.getObjectQL(); + const qlService = await deps.getObjectQL(_context); if (qlService?.registry) { const data = qlService.registry.getObject(name); if (data) return { handled: true, response: deps.success(data) }; @@ -236,7 +236,7 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin const singularType = pluralToSingular(type); // Try Protocol Service First (Preferred) - const protocol = await deps.resolveService('protocol'); + const protocol = await deps.resolveService(_context, 'protocol'); if (protocol && typeof protocol.getMetaItem === 'function') { try { const organizationId = await deps.resolveActiveOrganizationId(_context); @@ -252,7 +252,7 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin } // Try MetadataService for runtime-registered types - const metaSvc = await deps.resolveService('metadata', _context.environmentId); + const metaSvc = await deps.resolveService(_context, 'metadata', _context.environmentId); if (metaSvc && typeof (metaSvc as any).getItem === 'function') { try { // ADR-0048 — thread `?package=` so single-item resolution is @@ -276,7 +276,7 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin // `_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'); + const protocol = await deps.resolveService(_context, 'protocol'); if (protocol && typeof protocol.listDrafts === 'function') { try { const organizationId = await deps.resolveActiveOrganizationId(_context); @@ -327,7 +327,7 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin }; } - const protocol = await deps.resolveService('protocol'); + const protocol = await deps.resolveService(_context, 'protocol'); if (!protocol || typeof (protocol as any).migrateStoredMetadata !== 'function') { return { handled: true, response: deps.error('Stored-metadata migration not supported', 501) }; } @@ -356,7 +356,7 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin const packageId = query?.package || undefined; // Try protocol service first for any type - const protocol = await deps.resolveService('protocol'); + const protocol = await deps.resolveService(_context, 'protocol'); if (protocol && typeof protocol.getMetaItems === 'function') { try { const organizationId = await deps.resolveActiveOrganizationId(_context); @@ -375,7 +375,7 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin } // Try MetadataService directly for runtime-registered metadata (agents, tools, etc.) - const metadataService = await deps.getService(CoreServiceName.enum.metadata); + const metadataService = await deps.getService(_context, CoreServiceName.enum.metadata); if (metadataService && typeof (metadataService as any).list === 'function') { try { let items = await (metadataService as any).list(typeOrName); @@ -396,7 +396,7 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin } // Try ObjectQL registry directly for object/type lookups - const qlService = await deps.getObjectQL(); + const qlService = await deps.getObjectQL(_context); if (qlService?.registry) { if (typeOrName === 'objects') { const objs = qlService.registry.getAllObjects(packageId); @@ -418,14 +418,14 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin 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'); + const protocol = await deps.resolveService(_context, '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); + const metadataService = await deps.resolveService(_context, 'metadata', _context.environmentId); if (metadataService && typeof (metadataService as any).getRegisteredTypes === 'function') { try { const types = await (metadataService as any).getRegisteredTypes(); diff --git a/packages/runtime/src/domains/notifications.ts b/packages/runtime/src/domains/notifications.ts index 85807d6af0..c03074cad5 100644 --- a/packages/runtime/src/domains/notifications.ts +++ b/packages/runtime/src/domains/notifications.ts @@ -50,7 +50,7 @@ export async function handleNotificationRequest( // error rather than a runtime discovery. That is the check missing when // #4087 shipped a `/storage` handler calling `upload(key, data)` with two // wrong arguments for months. - const service = await deps.resolveService( + const service = await deps.resolveService(context, CoreServiceName.enum.notification, context.environmentId, ) as INotificationService | undefined; diff --git a/packages/runtime/src/domains/packages.ts b/packages/runtime/src/domains/packages.ts index 42bde9da5c..4e607d69ac 100644 --- a/packages/runtime/src/domains/packages.ts +++ b/packages/runtime/src/domains/packages.ts @@ -54,7 +54,7 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin const parts = path.replace(/^\/+/, '').split('/').filter(Boolean); // Try to get SchemaRegistry from the ObjectQL service - const qlService = await deps.getObjectQL(); + const qlService = await deps.getObjectQL(_context); const registry = qlService?.registry; // If no registry available, return 503 @@ -103,7 +103,7 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin }; } let pkg: any; - const protocolSvc: any = await deps.resolveService('protocol').catch(() => null); + const protocolSvc: any = await deps.resolveService(_context, 'protocol').catch(() => null); if (protocolSvc && typeof protocolSvc.installPackage === 'function') { const out = await protocolSvc.installPackage({ manifest, settings: body.settings }); pkg = out?.package ?? out; @@ -144,7 +144,7 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin // POST /packages/:id/publish → publish package metadata if (parts.length === 2 && parts[1] === 'publish' && m === 'POST') { const id = decodeURIComponent(parts[0]); - const metadataService = await deps.getService(CoreServiceName.enum.metadata); + const metadataService = await deps.getService(_context, CoreServiceName.enum.metadata); if (metadataService && typeof (metadataService as any).publishPackage === 'function') { const result = await (metadataService as any).publishPackage(id, body || {}); return { handled: true, response: deps.success(result) }; @@ -159,7 +159,7 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin // dependency, unlike /publish above. if (parts.length === 2 && parts[1] === 'publish-drafts' && m === 'POST') { const id = decodeURIComponent(parts[0]); - const protocol = await deps.resolveService('protocol'); + const protocol = await deps.resolveService(_context, 'protocol'); if (protocol && typeof (protocol as any).publishPackageDrafts === 'function') { try { const organizationId = await deps.resolveActiveOrganizationId(_context); @@ -270,7 +270,7 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin ...(((result as any)?.unhiddenApps ?? []) as string[]).map((n) => `app/${n}`), ]; if (changed.length > 0) { - await deps.announceKernelEvent('metadata:reloaded', { changed }); + await deps.announceKernelEvent(_context, 'metadata:reloaded', { changed }); } } catch (e: any) { (result as any).rebindError = e?.message ?? 'metadata:reloaded announce failed'; @@ -293,7 +293,7 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin // path (no metadata-service dependency, unlike /revert below). if (parts.length === 2 && parts[1] === 'discard-drafts' && m === 'POST') { const id = decodeURIComponent(parts[0]); - const protocol = await deps.resolveService('protocol'); + const protocol = await deps.resolveService(_context, 'protocol'); if (protocol && typeof (protocol as any).discardPackageDrafts === 'function') { try { const organizationId = await deps.resolveActiveOrganizationId(_context); @@ -315,7 +315,7 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin // GET /packages/:id/commits → the commit timeline (newest-first). if (parts.length === 2 && parts[1] === 'commits' && m === 'GET') { const id = decodeURIComponent(parts[0]); - const protocol = await deps.resolveService('protocol'); + const protocol = await deps.resolveService(_context, 'protocol'); if (protocol && typeof (protocol as any).listCommits === 'function') { try { const organizationId = await deps.resolveActiveOrganizationId(_context); @@ -336,7 +336,7 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin // restored to their pre-commit version; the revert is itself a commit. if (parts.length === 4 && parts[1] === 'commits' && parts[3] === 'revert' && m === 'POST') { const commitId = decodeURIComponent(parts[2]); - const protocol = await deps.resolveService('protocol'); + const protocol = await deps.resolveService(_context, 'protocol'); if (protocol && typeof (protocol as any).revertCommit === 'function') { try { const organizationId = await deps.resolveActiveOrganizationId(_context); @@ -356,7 +356,7 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin // POST /packages/:id/rollback body { commitId } → roll the package // back THROUGH every commit newer than `commitId` (ADR-0067). if (parts.length === 2 && parts[1] === 'rollback' && m === 'POST') { - const protocol = await deps.resolveService('protocol'); + const protocol = await deps.resolveService(_context, 'protocol'); if (protocol && typeof (protocol as any).rollbackToPackageCommit === 'function') { if (!body?.commitId) { return { handled: true, response: deps.error('Body { commitId } is required', 400) }; @@ -379,7 +379,7 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin // POST /packages/:id/revert → revert package to last published state if (parts.length === 2 && parts[1] === 'revert' && m === 'POST') { const id = decodeURIComponent(parts[0]); - const metadataService = await deps.getService(CoreServiceName.enum.metadata); + const metadataService = await deps.getService(_context, CoreServiceName.enum.metadata); if (metadataService && typeof (metadataService as any).revertPackage === 'function') { await (metadataService as any).revertPackage(id); return { handled: true, response: deps.success({ success: true }) }; @@ -403,7 +403,7 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin // lets the env retire the "Local / Custom" scope once it has no orphans). if (parts.length === 2 && parts[1] === 'adopt-orphans' && m === 'POST') { const id = decodeURIComponent(parts[0]); - const protocol = await deps.resolveService('protocol'); + const protocol = await deps.resolveService(_context, 'protocol'); if (!protocol || typeof (protocol as any).reassignOrphanedMetadata !== 'function') { return { handled: true, response: deps.error('Orphan adoption not supported', 501) }; } @@ -425,7 +425,7 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin // "duplicate base"). Body { targetPackageId, targetName?, targetNamespace? }. if (parts.length === 2 && parts[1] === 'duplicate' && m === 'POST') { const id = decodeURIComponent(parts[0]); - const protocol = await deps.resolveService('protocol'); + const protocol = await deps.resolveService(_context, 'protocol'); if (!protocol || typeof (protocol as any).duplicatePackage !== 'function') { return { handled: true, response: deps.error('Package duplication not supported', 501) }; } @@ -480,7 +480,7 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin return { handled: true, response: deps.error('Body { name?, description?, version? } — nothing to update', 400) }; } - const protocol = await deps.resolveService('protocol'); + const protocol = await deps.resolveService(_context, 'protocol'); if (protocol && typeof (protocol as any).updatePackage === 'function') { try { const updated = await (protocol as any).updatePackage({ packageId: id, patch }); @@ -508,7 +508,7 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin // just the in-memory registry — the registry uninstall alone would // leave the rows and tables behind). let persisted: unknown = undefined; - const protocol = await deps.resolveService('protocol'); + const protocol = await deps.resolveService(_context, 'protocol'); if (protocol && typeof (protocol as any).deletePackage === 'function') { try { const organizationId = await deps.resolveActiveOrganizationId(_context); @@ -561,7 +561,7 @@ packageId: string, registry: any, context: HttpProtocolContext, ): Promise | null> { - const protocol = await deps.resolveService('protocol'); + const protocol = await deps.resolveService(context, 'protocol'); if (!protocol || typeof protocol.getMetaItems !== 'function') return null; const organizationId = await deps.resolveActiveOrganizationId(context); @@ -644,9 +644,9 @@ _context: HttpProtocolContext, // [#4127] `protocol` keeps its `any` — no written contract, so this is where // the ledger honestly ends. `metadata` and `ql` are both evidenced now, // `objectql` as of batch 3: it is the same instance the `data` slot holds. - const protocol: any = await deps.resolveService('protocol'); - const metadata = await deps.getService(CoreServiceName.enum.metadata); - const ql = await deps.resolveService('objectql'); + const protocol: any = await deps.resolveService(_context, 'protocol'); + const metadata = await deps.getService(_context, CoreServiceName.enum.metadata); + const ql = await deps.resolveService(_context, 'objectql'); if (!protocol || typeof protocol.getMetaItem !== 'function' || !ql || !metadata) { return { success: false, error: 'seed apply: required services unavailable' }; } diff --git a/packages/runtime/src/domains/security.ts b/packages/runtime/src/domains/security.ts index 8aa046b7e6..8101d8e77e 100644 --- a/packages/runtime/src/domains/security.ts +++ b/packages/runtime/src/domains/security.ts @@ -68,7 +68,7 @@ export async function handleSecurityRequest( // `ISecurityService`. The contract was written, `plugin-security` registers // the slot, and all three methods used below were already declared — the // slot name simply was not in the ledger, so nothing connected them. - const service = await deps.resolveService('security', context.environmentId); + const service = await deps.resolveService(context, 'security', context.environmentId); if (!service || typeof service.listAudienceBindingSuggestions !== 'function') { return { handled: true, response: deps.error('Security service not available', 503) }; } diff --git a/packages/runtime/src/domains/share-links.ts b/packages/runtime/src/domains/share-links.ts index f909333eb3..e067a87925 100644 --- a/packages/runtime/src/domains/share-links.ts +++ b/packages/runtime/src/domains/share-links.ts @@ -66,7 +66,7 @@ export async function handleShareLinksRequest( // doc-comment says "keep in sync with the SharingPlugin registration" — and a // drifted copy here resolves nothing, so every share link 501s with "Sharing // is not configured for this environment" on an environment where it is. - const svc = await deps.resolveService(SHARE_LINK_SERVICE, context.environmentId); + const svc = await deps.resolveService(context, SHARE_LINK_SERVICE, context.environmentId); if (!svc) { return { handled: true, response: deps.error('Sharing is not configured for this environment', 501) }; } @@ -99,10 +99,10 @@ export async function handleShareLinksRequest( // Inferred instead, so `engine.find` below is checked against IDataEngine. const getEngine = async () => { try { - const e = await deps.getRequestKernelService('objectql'); + const e = await deps.getRequestKernelService(context, 'objectql'); if (e) return e; } catch { /* fall through to scoped resolution */ } - return deps.resolveService('objectql', context.environmentId); + return deps.resolveService(context, 'objectql', context.environmentId); }; const asArray = (rows: any): any[] => (Array.isArray(rows) ? rows : Array.isArray(rows?.value) ? rows.value : []); const applyRedaction = (record: any, redactFields: string[]): any => { diff --git a/packages/runtime/src/domains/ui.ts b/packages/runtime/src/domains/ui.ts index 88242fa122..97d0a47737 100644 --- a/packages/runtime/src/domains/ui.ts +++ b/packages/runtime/src/domains/ui.ts @@ -35,7 +35,7 @@ export async function handleUiRequest( // Support both path param /view/obj/list AND query param /view/obj?type=list const type = parts[2] || query?.type || 'list'; - const protocol = await deps.resolveService('protocol'); + const protocol = await deps.resolveService(_context, 'protocol'); if (protocol && typeof protocol.getUiView === 'function') { try { diff --git a/packages/runtime/src/http-dispatcher.multi-tenant-concurrency.test.ts b/packages/runtime/src/http-dispatcher.multi-tenant-concurrency.test.ts new file mode 100644 index 0000000000..e835347e42 --- /dev/null +++ b/packages/runtime/src/http-dispatcher.multi-tenant-concurrency.test.ts @@ -0,0 +1,206 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #5155 — per-request kernel isolation on a MULTI-TENANT host. + * + * One `HttpDispatcher` instance serves every route of a host + * (`dispatcher-plugin.ts` `start()` constructs exactly one). The kernel a + * request is served from is therefore PER REQUEST, not per dispatcher: the + * host's `kernelResolver` picks it, and every service lookup the request makes + * afterwards — identity resolution, the domain handler, `callData` — must land + * on THAT kernel. + * + * Before this suite the resolved kernel lived on a mutable INSTANCE field + * (`this.kernel`), written once per request and read across every subsequent + * `await`. Node's single thread does not protect that: it protects code that + * does not hold mutable shared state across an `await`, and this held it across + * several. Two interleaved requests on two environments therefore produced + * "request A reads request B's data source" — a cross-tenant read, not a + * performance bug. + * + * The interleave here is DETERMINISTIC, not timing-dependent: request A parks + * inside its own identity resolution (its kernel's `auth` lookup awaits a gate + * the test controls), request B runs to completion while A is parked, then A is + * released and finishes. That is exactly the sequence the issue describes, with + * the scheduler taken out of the equation. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { HttpDispatcher } from './http-dispatcher.js'; +import type { KernelResolver, HttpProtocolContext } from './http-dispatcher.js'; + +interface Deferred { promise: Promise; resolve: () => void } +function deferred(): Deferred { + let resolve!: () => void; + const promise = new Promise((r) => { resolve = r; }); + return { promise, resolve }; +} + +/** + * A per-environment kernel whose every service is TAGGED with the environment + * it belongs to, so "which tenant served this request" is readable off the + * response body rather than inferred. + * + * `onLookup` is the test's interleaving gate: it runs before each async + * service lookup on THIS kernel. + */ +function makeTenantKernel( + tag: string, + onLookup?: (name: string) => Promise | void, +) { + const objectql = { + __tag: tag, + find: vi.fn(async () => [{ tenant: tag }]), + getObjects: vi.fn(() => ({})), + registry: { getObject: vi.fn(() => null), getRegisteredTypes: vi.fn(() => []) }, + }; + // The `i18n` slot is the probe: `/i18n/locales` is anonymous-reachable + // (no `shouldDenyAnonymous` gate) and reads the slot straight off the + // request's kernel via `deps.getService('i18n')`. + const i18n = { + __tag: tag, + getLocales: () => [`${tag}-locale`], + getDefaultLocale: () => `${tag}-locale`, + getTranslations: () => ({}), + getFieldLabels: () => ({}), + }; + const metadata = { + __tag: tag, + getObject: async () => undefined, + getRegisteredTypes: async () => [tag], + }; + const services: Record = { objectql, i18n, metadata }; + const kernel: any = { + __tag: tag, + getServiceAsync: async (name: string) => { + await onLookup?.(name); + return services[name] ?? null; + }, + getService: (name: string) => services[name] ?? null, + context: { getService: (name: string) => services[name] ?? null }, + }; + return kernel; +} + +/** + * The host / control-plane kernel a multi-tenant deployment boots the + * dispatcher with. It serves NO tenant slot — every scoped lookup falls + * through to the request's own kernel, which is the whole point of the seam. + */ +function makeHostKernel() { + const kernel: any = { + __tag: 'host', + getServiceAsync: async () => null, + getService: () => null, + context: { getService: () => null }, + }; + return kernel; +} + +function makeMultiTenantDispatcher(kernels: Record) { + const host = makeHostKernel(); + const resolveKernel = vi.fn(async (ctx: HttpProtocolContext) => { + const envId = (ctx.request?.headers ?? {})['x-environment-id']; + if (!envId) return undefined; + ctx.environmentId = envId; + return kernels[envId]; + }); + const resolver: KernelResolver = { resolveKernel }; + const dispatcher = new HttpDispatcher(host, undefined, { + kernelResolver: resolver, + enforceProjectMembership: false, + }); + return { dispatcher, host, resolveKernel }; +} + +const ctxFor = (envId: string): HttpProtocolContext => + ({ request: { headers: { 'x-environment-id': envId } } }) as HttpProtocolContext; + +describe('#5155 — HttpDispatcher serves each request from ITS OWN resolved kernel', () => { + it('an interleaved request does not move the first request onto the other tenant\'s kernel', async () => { + const gate = deferred(); + let parked = false; + const envOne = makeTenantKernel('env-1', async (name) => { + // Park request A exactly once, inside its own identity + // resolution — after its kernel was resolved, before its domain + // handler looks anything up. Every later lookup A makes happens + // after request B has resolved a DIFFERENT kernel. + if (name === 'auth' && !parked) { + parked = true; + await gate.promise; + } + }); + const envTwo = makeTenantKernel('env-2'); + const { dispatcher } = makeMultiTenantDispatcher({ 'env-1': envOne, 'env-2': envTwo }); + + const ctxA = ctxFor('env-1'); + const ctxB = ctxFor('env-2'); + + const requestA = dispatcher.dispatch('GET', '/i18n/locales', undefined, {}, ctxA); + // Let A reach the gate before B starts. + await vi.waitFor(() => expect(parked).toBe(true)); + + const responseB = await dispatcher.dispatch('GET', '/i18n/locales', undefined, {}, ctxB); + gate.resolve(); + const responseA = await requestA; + + const localesOf = (r: any) => r?.response?.body?.data?.locales?.map((l: any) => l.code ?? l); + + // B is uncontested — it must see its own environment either way. + expect(localesOf(responseB)).toEqual(['env-2-locale']); + // A resumed AFTER B resolved a different kernel. It must still be + // served from env-1: a request never inherits another request's + // environment. + expect(localesOf(responseA)).toEqual(['env-1-locale']); + }); + + it('the endpoint fallback path resolves services on the scope it resolved, not the latest one', async () => { + // The shape `dispatcher-plugin.ts`'s `setFallbackHandler` runs for a + // declarative endpoint (#5040 E5b): `resolveRequestScope(...)` and then + // `actionExecutionDeps.resolveService('metadata', …)`, with at least + // one `await` in between. Two concurrent endpoint requests interleave + // there exactly like two `dispatch()` calls do. + const envOne = makeTenantKernel('env-1'); + const envTwo = makeTenantKernel('env-2'); + const { dispatcher } = makeMultiTenantDispatcher({ 'env-1': envOne, 'env-2': envTwo }); + + const ctxA = ctxFor('env-1'); + const ctxB = ctxFor('env-2'); + + await dispatcher.resolveRequestScope(ctxA, '/app/orders'); + // …request A yields here (any `await` will do — a session lookup, a + // driver query). Request B arrives and resolves its own scope. + await dispatcher.resolveRequestScope(ctxB, '/app/invoices'); + + // Request A resumes and asks for the service its own call needs. + const metadataForA = await dispatcher.actionExecutionDeps + .resolveService(ctxA, 'metadata', ctxA.environmentId); + expect((metadataForA as any)?.__tag).toBe('env-1'); + + const metadataForB = await dispatcher.actionExecutionDeps + .resolveService(ctxB, 'metadata', ctxB.environmentId); + expect((metadataForB as any)?.__tag).toBe('env-2'); + }); + + it('records the resolved kernel on the request context, not on the dispatcher', async () => { + const envOne = makeTenantKernel('env-1'); + const envTwo = makeTenantKernel('env-2'); + const { dispatcher, host } = makeMultiTenantDispatcher({ 'env-1': envOne, 'env-2': envTwo }); + + const ctxA = ctxFor('env-1'); + const ctxB = ctxFor('env-2'); + await dispatcher.resolveRequestScope(ctxA, '/data/orders'); + await dispatcher.resolveRequestScope(ctxB, '/data/invoices'); + + // Each context carries ITS OWN kernel — the second resolution cannot + // reach back and rewrite the first. + expect((ctxA.kernel as any)?.__tag).toBe('env-1'); + expect((ctxB.kernel as any)?.__tag).toBe('env-2'); + + // …and an unresolvable request falls back to the host kernel without + // borrowing whichever tenant resolved last. + const ctxC: HttpProtocolContext = { request: { headers: {} } } as HttpProtocolContext; + await dispatcher.resolveRequestScope(ctxC, '/discovery'); + expect(ctxC.kernel).toBe(host); + }); +}); diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index dfaf9ff429..7ef81d0758 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -82,6 +82,29 @@ export interface HttpProtocolContext { * RBAC/RLS/FLS. Optional — anonymous requests carry an empty context. */ executionContext?: ExecutionContext; + /** + * The kernel THIS request is served from (#5155). + * + * Written by {@link HttpDispatcher.resolveRequestScope}: the host's + * {@link KernelResolver} picks it, or it is the dispatcher's own + * `defaultKernel` when no resolver is registered / the resolver declines. + * Every service lookup the request makes afterwards — identity + * resolution, the domain handler, `callData` — resolves off THIS field. + * + * It lives on the per-request context and NOT on the dispatcher because + * one `HttpDispatcher` serves every route of a host: a single mutable + * `this.kernel` written per request and read across the request's + * subsequent `await`s let two interleaved requests on a multi-tenant host + * swap data sources under each other — request A resuming after request B + * resolved a different environment read B's kernel. Node's single thread + * only protects code that does NOT hold mutable shared state across an + * `await`; that code held it across several. + * + * Undefined until `resolveRequestScope` has run. Consumers must never + * default it themselves — `HttpDispatcher.requestKernel()` owns the one + * "not placed in any environment → the host kernel" answer. + */ + kernel?: ObjectKernel; } export interface HttpDispatcherResult { @@ -161,8 +184,15 @@ export interface HttpDispatcherOptions { * is intentionally NOT marked `@deprecated` while no working replacement exists. */ export class HttpDispatcher { - private kernel: any; // Casting to any to access dynamic props like services - private defaultKernel: ObjectKernel; + /** + * The HOST kernel this dispatcher was constructed with — process-lifetime + * state, never per-request. Everything that describes the replica rather + * than a request (`/ready`, the driver-health probe, the single-environment + * default) reads it directly; a request reads `context.kernel` + * ({@link HttpProtocolContext.kernel}) instead. There is deliberately no + * `this.kernel` (#5155). + */ + private readonly defaultKernel: ObjectKernel; private defaultProject?: { environmentId: string; orgId?: string }; private kernelResolver?: KernelResolver; private scopeManager?: EnvironmentScopeManager; @@ -208,7 +238,6 @@ export class HttpDispatcher { * but its value is ignored. */ constructor(kernel: ObjectKernel, _envRegistryIgnored?: unknown, options?: HttpDispatcherOptions) { - this.kernel = kernel; this.defaultKernel = kernel; const resolveService = (name: string): any => { try { return (kernel as any).getService?.(name); } catch { return undefined; } @@ -237,15 +266,23 @@ export class HttpDispatcher { // The parameters are annotated because an arrow cannot be contextually // typed against an overloaded signature. Resolution below stays // name-based and unchanged — the typing lives in what the DOMAINS see. - resolveService: (name: string, environmentId?: string) => this.resolveService(name, environmentId), - getService: (name: string) => this.getService(name as Parameters[0]), - getObjectQL: (environmentId) => this.getObjectQLService(environmentId), - // Reads off the per-request RESOLVED kernel (`this.kernel` is set by - // dispatch() before any handler runs) — see the deps contract note. + // + // [#5155] Every kernel-reading facility takes the REQUEST as its first + // parameter. This object is built once and shared by every request the + // host serves, so it cannot hold "the kernel of the request in flight" + // — there is no such thing here. The request carries its own. + resolveService: (context: HttpProtocolContext, name: string, environmentId?: string) => + this.resolveService(this.requestKernel(context), name, environmentId), + getService: (context: HttpProtocolContext, name: string) => + this.getService(this.requestKernel(context), name as Parameters[1]), + getObjectQL: (context, environmentId) => + this.getObjectQLService(this.requestKernel(context), environmentId), + // Reads off the request's OWN resolved kernel — never the scoped + // factory, never the host kernel. See the deps contract note. // [#4127 batch 4] Annotated for the same reason as the two above: an // arrow cannot be contextually typed against an overloaded signature. - getRequestKernelService: async (name: string) => { - const k: any = this.kernel; + getRequestKernelService: async (context: HttpProtocolContext, name: string) => { + const k: any = this.requestKernel(context); return typeof k?.getServiceAsync === 'function' ? k.getServiceAsync(name) : k?.getService?.(name); @@ -255,8 +292,8 @@ export class HttpDispatcher { routeNotFound: (route) => this.routeNotFound(route), errorFromThrown: (e, fallbackStatus) => this.errorFromThrown(e, fallbackStatus), resolveActiveOrganizationId: (context) => this.resolveActiveOrganizationId(context), - announceKernelEvent: async (event, payload) => { - const k: any = this.kernel; + announceKernelEvent: async (context, event, payload) => { + const k: any = this.requestKernel(context); if (k?.context?.trigger) await k.context.trigger(event, payload); }, logger: (this as any).logger, @@ -267,7 +304,11 @@ export class HttpDispatcher { try { const projectKernel: any = await this.kernelResolver.resolveKernel(context, this.defaultKernel); if (projectKernel) { - this.kernel = projectKernel; + // The swap this seam owns, written where it belongs: on THIS + // request. Its callers keep making `deps.*` lookups + // afterwards and must see the swapped kernel — but only they + // may (#5155). + context.kernel = projectKernel; if (typeof projectKernel.getServiceAsync === 'function') { return await projectKernel.getServiceAsync('objectql').catch(() => null); } @@ -275,9 +316,28 @@ export class HttpDispatcher { } catch { /* fall back to defaultKernel resolution downstream */ } return null; }, - getRegisteredAiRoutes: () => (this.kernel as any)?.__aiRoutes, + getRegisteredAiRoutes: (context) => (this.requestKernel(context) as any)?.__aiRoutes, }; + /** + * The kernel a given request is served from — the ONE place that answers + * it (#5155). + * + * `resolveRequestScope` writes {@link HttpProtocolContext.kernel} on every + * request that goes through `dispatch()` or the declarative-endpoint + * fallback. A context that never went through it has not been placed in + * any environment, and the honest answer for one of those is the HOST + * kernel — which is what a single-environment deployment serves from + * anyway. It is emphatically NOT "whichever tenant resolved most + * recently", which is what a dispatcher-level field answered before. + * + * Domains must never write this fallback themselves: a `??` at a call site + * is how a second, quieter answer to "which kernel" gets born. + */ + private requestKernel(context: HttpProtocolContext | undefined): any { + return context?.kernel ?? this.defaultKernel; + } + /** * Whether this dispatcher serves a multi-tenant host (a `kernel-resolver` * is wired, so each request may resolve to a different per-project @@ -299,9 +359,15 @@ export class HttpDispatcher { * * It mutates `context` in place, exactly as it always did inside * `dispatch()`: the host's resolver writes `environmentId` (+ `dataDriver`), - * the identity step writes `executionContext`, and `this.kernel` is swapped + * the identity step writes `executionContext`, and `context.kernel` is set * to the kernel this request is served from. * + * That last one used to be a dispatcher FIELD (#5155). One dispatcher + * serves the whole host, so a field could only ever hold "the kernel of + * the request that resolved most recently" — and every reader of it sat + * behind at least one `await`. Writing it on the context is what makes two + * interleaved requests on two environments independent. + * * ## Its one out-of-band caller (#5040 E5b) * * The declarative-endpoint step runs in the transport's @@ -328,10 +394,15 @@ export class HttpDispatcher { // resolver registered → single-environment: every request serves from // `defaultKernel` with no environment context. this.prepareResolverHints(context, cleanPath); + // [#5155] The resolved kernel is written on the REQUEST, not on the + // dispatcher. Two requests interleaving across the awaits below (and + // across every await their handlers make afterwards) each keep their + // own; before this, the second resolution silently moved the first + // request onto the second one's environment. if (this.kernelResolver) { - this.kernel = (await this.kernelResolver.resolveKernel(context, this.defaultKernel)) ?? this.defaultKernel; + context.kernel = (await this.kernelResolver.resolveKernel(context, this.defaultKernel)) ?? this.defaultKernel; } else { - this.kernel = this.defaultKernel; + context.kernel = this.defaultKernel; } // Touch scope for TTL/LRU tracking in shared-kernel mode @@ -344,23 +415,26 @@ export class HttpDispatcher { // ctx.userId/roles/permissions/tenantId via opCtx.context. try { context.executionContext = await this.timedResolveExecutionContext({ - getService: (n: string) => this.resolveService(n, context.environmentId), + getService: (n: string) => this.resolveService(this.requestKernel(context), n, context.environmentId), // Resolve ObjectQL from the per-request kernel DIRECTLY. The scoped // `resolveService('objectql', envId)` factory can return a different // instance that doesn't see THIS env's rows (the gotcha // `handleActions` works around) — which made the api-key lookup miss // `sys_api_key` on the MCP path and reject valid keys with 401, while // REST accepted them (rest-server resolves identity via - // `kernel.getServiceAsync('objectql')`). Resolving off `this.kernel` - // keeps REST + MCP identity resolution aligned; falls back to the - // scoped path when the kernel can't hand back an objectql directly. + // `kernel.getServiceAsync('objectql')`). Resolving off THIS REQUEST's + // kernel keeps REST + MCP identity resolution aligned; falls back to + // the scoped path when the kernel can't hand back an objectql + // directly. [#5155] `context.kernel`, not a dispatcher field — this + // closure runs after several awaits, which is exactly where a shared + // field used to be rewritten by the next request. getQl: async () => { - const k: any = this.kernel; + const k: any = this.requestKernel(context); if (k && typeof k.getServiceAsync === 'function') { const ql = await k.getServiceAsync('objectql').catch(() => undefined); if (ql && (ql.registry || typeof ql.find === 'function')) return ql; } - return this.getObjectQLService(context.environmentId); + return this.getObjectQLService(this.requestKernel(context), context.environmentId); }, request: context.request, // OAuth 2.1 access tokens are honoured ONLY on the MCP @@ -435,8 +509,12 @@ export class HttpDispatcher { this.domainRegistry.register({ prefix: '/ready', match: 'exact', methods: ['GET'], handler: async () => { - const state: string = typeof (this.kernel as any)?.getState === 'function' - ? (this.kernel as any).getState() + // [#5155] The HOST kernel, deliberately: readiness is a + // property of this replica, not of whichever tenant happens to + // have made the last request. + const host: any = this.defaultKernel; + const state: string = typeof host?.getState === 'function' + ? host.getState() : 'running'; if (state !== 'running') { return { handled: true, response: this.error('Service not ready', 503, { state }) }; @@ -511,7 +589,8 @@ export class HttpDispatcher { // when the slot holds an engine without a driver-health surface. let engine: (IDataEngine & Partial>) | undefined; try { - engine = (this.kernel as any)?.getService?.('data'); + // [#5155] Host kernel — see the `/ready` handler. + engine = (this.defaultKernel as any)?.getService?.('data'); } catch { // 'data' not registered — no data plane to gate readiness on. } @@ -532,7 +611,11 @@ export class HttpDispatcher { private resolveDefaultProject(): { environmentId: string; orgId?: string } | undefined { if (this.defaultProject) return this.defaultProject; try { - const v = (this.kernel as any).getService?.('default-project'); + // [#5155] Host kernel: `createSingleEnvironmentPlugin` registers + // this slot on the host, and the answer is memoized for the + // process — reading it off a per-request kernel would freeze one + // request's environment into dispatcher-lifetime state. + const v = (this.defaultKernel as any).getService?.('default-project'); if (v?.environmentId) { this.defaultProject = v; return v; @@ -833,7 +916,7 @@ export class HttpDispatcher { // — the whole auth gate turns on it — but IAuthService never declared // it. `packages/rest` probes it the same way, so two independent // callers agree on a member the contract does not mention. - const authService = await this.resolveService('auth', context.environmentId); + const authService = await this.resolveService(this.requestKernel(context), 'auth', context.environmentId); if (!authService || typeof authService.isAuthGateActive !== 'function' || !authService.isAuthGateActive()) { return null; } @@ -905,7 +988,7 @@ export class HttpDispatcher { // project scoping on, which is where the flag defaults to true. // Anonymous callers were still denied elsewhere (#2567/#3963), so // this was specifically the signed-in non-member case. - const authService = await this.resolveService(CoreServiceName.enum.auth); + const authService = await this.resolveService(this.requestKernel(context), CoreServiceName.enum.auth); const api = authService?.api ?? (typeof authService?.getApi === 'function' ? await authService.getApi() : undefined); const sessionData = await api?.getSession?.({ headers: context.request?.headers, @@ -935,8 +1018,8 @@ export class HttpDispatcher { // Query sys_environment_member (control plane). try { - const qlService = await this.getObjectQLService(); - const ql = qlService ?? await this.resolveService('objectql'); + const qlService = await this.getObjectQLService(this.requestKernel(context)); + const ql = qlService ?? await this.resolveService(this.requestKernel(context), 'objectql'); if (!ql) return null; // No QL — cannot enforce; fail open. let rows = await ql.find('sys_environment_member', { @@ -976,7 +1059,15 @@ export class HttpDispatcher { * handlers use, so the reported service status is always consistent * with the actual runtime availability. */ - async getDiscoveryInfo(prefix: string) { + async getDiscoveryInfo(prefix: string, context?: HttpProtocolContext) { + // [#5155] Discovery describes the services of ONE kernel. Served from + // inside `dispatch()` it describes the request's own environment (the + // context is threaded through); served straight off the host by an + // adapter's `/discovery` route or the dispatcher plugin — neither of + // which resolves a request scope — it describes the HOST. Before this, + // both cases read a dispatcher field, so a host-level `/discovery` + // answered with whichever tenant had most recently made a request. + const kernel = this.requestKernel(context); // Resolve all services through the same async fallback chain // that request handlers (handleI18n, handleAuth, …) use. const [ @@ -985,31 +1076,31 @@ export class HttpDispatcher { protocolSvc, automationSvc, cacheSvc, queueSvc, jobSvc, mcpSvc, metadataSvc, dataSvc, ] = await Promise.all([ - this.resolveService(CoreServiceName.enum.auth), - this.resolveService(CoreServiceName.enum.search), - this.resolveService(CoreServiceName.enum.realtime), - this.resolveService(CoreServiceName.enum['file-storage']), - this.resolveService(CoreServiceName.enum.analytics), - this.resolveService(CoreServiceName.enum.ai), - this.resolveService(CoreServiceName.enum.notification), - this.resolveService(CoreServiceName.enum.i18n), + this.resolveService(kernel, CoreServiceName.enum.auth), + this.resolveService(kernel, CoreServiceName.enum.search), + this.resolveService(kernel, CoreServiceName.enum.realtime), + this.resolveService(kernel, CoreServiceName.enum['file-storage']), + this.resolveService(kernel, CoreServiceName.enum.analytics), + this.resolveService(kernel, CoreServiceName.enum.ai), + this.resolveService(kernel, CoreServiceName.enum.notification), + this.resolveService(kernel, CoreServiceName.enum.i18n), // [#4093] What `/ui` actually reads (domains/ui.ts). The `ui` SLOT // is vestigial — nothing in the platform registers it (plugin-dev's // shapeless placeholder was its only occupant, now retired), and // the domain never consulted it. - this.resolveService('protocol'), - this.resolveService(CoreServiceName.enum.automation), - this.resolveService(CoreServiceName.enum.cache), - this.resolveService(CoreServiceName.enum.queue), - this.resolveService(CoreServiceName.enum.job), + this.resolveService(kernel, 'protocol'), + this.resolveService(kernel, CoreServiceName.enum.automation), + this.resolveService(kernel, CoreServiceName.enum.cache), + this.resolveService(kernel, CoreServiceName.enum.queue), + this.resolveService(kernel, CoreServiceName.enum.job), // Not a CoreServiceName — plugin-mcp registers under the bare // 'mcp' key, the same string handleMcpRequest resolves. - this.resolveService('mcp'), + this.resolveService(kernel, 'mcp'), // [#4089] Resolved for its self-description alone: the `metadata` // slot is reported from what actually fills it, not hardcoded. - this.resolveService(CoreServiceName.enum.metadata), + this.resolveService(kernel, CoreServiceName.enum.metadata), // [#4130] Same, for the last hardcoded entry in the kernel block. - this.resolveService(CoreServiceName.enum.data), + this.resolveService(kernel, CoreServiceName.enum.data), ]); const hasAuth = !!authSvc; @@ -1443,7 +1534,7 @@ export class HttpDispatcher { try { // [#4127 FINDING, batch 5] // Third `.api` reader on the auth service; see the note above. - const authService = await this.resolveService(CoreServiceName.enum.auth); + const authService = await this.resolveService(this.requestKernel(context), CoreServiceName.enum.auth); const rawHeaders = context.request?.headers; let headers: any = rawHeaders; if (rawHeaders && typeof rawHeaders === 'object' && typeof (rawHeaders as any).get !== 'function') { @@ -1477,15 +1568,15 @@ export class HttpDispatcher { return handleAutomationRequest(this.domainDeps, path, method, body, context, query); } - private getServicesMap(): Record { - if (this.kernel.services instanceof Map) { - return Object.fromEntries(this.kernel.services); + private getServicesMap(kernel: any): Record { + if (kernel?.services instanceof Map) { + return Object.fromEntries(kernel.services); } - return this.kernel.services || {}; + return kernel?.services || {}; } - private async getService(name: CoreServiceName) { - return this.resolveService(name); + private async getService(kernel: any, name: CoreServiceName) { + return this.resolveService(kernel, name); } /** @@ -1494,9 +1585,10 @@ export class HttpDispatcher { * Only returns when a non-null service is found; otherwise falls through to the next step. * * When `scopeId` is provided, tries the SCOPED factory on `defaultKernel` first (SharedProjectPlugin - * mode). Falls back to the current `kernel` for singleton / legacy services. + * mode). Falls back to `kernel` — THE REQUEST'S OWN kernel, passed in by the + * caller (#5155) — for singleton / legacy services. */ - private async resolveService(name: string, scopeId?: string) { + private async resolveService(kernel: any, name: string, scopeId?: string) { // Prefer scoped lookup on defaultKernel when scopeId is given (shared-kernel / multi-environment mode) if (scopeId && typeof this.defaultKernel.getServiceAsync === 'function') { try { @@ -1507,31 +1599,31 @@ export class HttpDispatcher { } } // Prefer async resolution to support factory-based services (e.g. auth, analytics, protocol) - if (typeof this.kernel.getServiceAsync === 'function') { + if (typeof kernel?.getServiceAsync === 'function') { try { - const svc = await this.kernel.getServiceAsync(name); + const svc = await kernel.getServiceAsync(name); if (svc != null) return svc; } catch { // Service not registered or async resolution failed — fall through } } - if (typeof this.kernel.getService === 'function') { + if (typeof kernel?.getService === 'function') { try { - const svc = await this.kernel.getService(name); + const svc = await kernel.getService(name); if (svc != null) return svc; } catch { // Service not registered or sync resolution threw "is async" — fall through } } - if (this.kernel?.context?.getService) { + if (kernel?.context?.getService) { try { - const svc = await this.kernel.context.getService(name); + const svc = await kernel.context.getService(name); if (svc != null) return svc; } catch { // Service not registered — fall through } } - const services = this.getServicesMap(); + const services = this.getServicesMap(kernel); return services[name]; } @@ -1539,10 +1631,10 @@ export class HttpDispatcher { * Get the ObjectQL service which provides access to SchemaRegistry. * Tries multiple access patterns since kernel structure varies. */ - private async getObjectQLService(scopeId?: string): Promise { + private async getObjectQLService(kernel: any, scopeId?: string): Promise { // 1. Try via resolveService (handles scoped, async factories, sync, context, and map) try { - const svc = await this.resolveService('objectql', scopeId); + const svc = await this.resolveService(kernel, 'objectql', scopeId); if (svc?.registry) return svc; } catch { /* service not available */ } return null; @@ -1621,7 +1713,7 @@ export class HttpDispatcher { // Standard route: /discovery (protocol-compliant) // Legacy route: / (empty path, for backward compatibility — MSW strips base URL) if ((cleanPath === '/discovery' || cleanPath === '') && method === 'GET') { - const info = await this.getDiscoveryInfo(prefix ?? ''); + const info = await this.getDiscoveryInfo(prefix ?? '', context); return { handled: true, response: this.success(info) diff --git a/packages/runtime/src/meta-overlay-read-your-writes.test.ts b/packages/runtime/src/meta-overlay-read-your-writes.test.ts index f44ed539c0..be56c34644 100644 --- a/packages/runtime/src/meta-overlay-read-your-writes.test.ts +++ b/packages/runtime/src/meta-overlay-read-your-writes.test.ts @@ -80,6 +80,7 @@ describe('#4521 — read-your-writes between saveMeta and the dispatch path', () let protocol: ObjectStackProtocolImplementation; let ql: any; let deps: ActionExecutionDeps; + let requestContext: any; beforeEach(() => { registry = new SchemaRegistry({ multiTenant: false }); @@ -98,6 +99,8 @@ describe('#4521 — read-your-writes between saveMeta and the dispatch path', () resolveService: (async () => undefined) as any, getObjectQL: async () => ql, } as ActionExecutionDeps; + // [#5155] The request whose kernel these lookups resolve against. + requestContext = { request: {} } as any; }); const saveAction = (item: any, mode?: 'draft' | 'publish') => @@ -109,7 +112,7 @@ describe('#4521 — read-your-writes between saveMeta and the dispatch path', () }); const resolve = (actionName: string) => - resolveRouteActionDeclaration(deps, { + resolveRouteActionDeclaration(deps, requestContext, { ql, objectName: OBJECT_DEF.name, actionName,