From 5cb94dbdd216cdd338f4bbdfa963e854961a969f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 13:23:43 +0000 Subject: [PATCH 1/4] test(runtime): pin the tenancy-posture seam's two facts at the runtime door (red against main) Pins for #13906 decision 1 option A on the runtime resolver and the dispatcher wiring: a tenancy service that is registered and fails to build must raise AuthzStoreUnavailableError (503), and a tenancy service that was never registered must keep resolving quietly with no posture. Source untouched in this commit so the red run measures origin/main bytes. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ --- ...-tenancy-posture-failure-discrimination.md | 14 ++ ...-dispatcher.tenancy-posture-outage.test.ts | 237 ++++++++++++++++++ .../resolve-execution-context.test.ts | 151 +++++++++++ 3 files changed, 402 insertions(+) create mode 100644 .changeset/runtime-tenancy-posture-failure-discrimination.md create mode 100644 packages/runtime/src/http-dispatcher.tenancy-posture-outage.test.ts diff --git a/.changeset/runtime-tenancy-posture-failure-discrimination.md b/.changeset/runtime-tenancy-posture-failure-discrimination.md new file mode 100644 index 0000000000..546f00ce42 --- /dev/null +++ b/.changeset/runtime-tenancy-posture-failure-discrimination.md @@ -0,0 +1,14 @@ +--- +"@objectstack/runtime": patch +--- + +The runtime dispatcher door no longer admits a request on a tenancy posture it could not read. + +`resolveExecutionContext` reads the effective tenancy posture from the kernel's `tenancy` service, and both posture-conditional API-key refusals (`organization_required`, `organization_membership_ended`) run only when that posture is present. The read used to swallow every failure into "no posture", so a `tenancy` service that was **registered and failed to build** answered exactly like a deployment with no tenancy at all: the wall was skipped, and an API key stamped with an organization its owner had left — or carrying no organization — was admitted with full grants. + +The seam now carries the same discrimination the REST door already applies (#13906 decision 1, option A), by the registry's own brand rather than by message text: + +- **never registered** — the supported no-tenancy composition. Absorbed as before: no posture, no posture-conditional refusal, nothing changes for single-organization embedders. +- **registered and failed to build** — re-raised as `AuthzStoreUnavailableError`, so the door answers `503 SERVICE_UNAVAILABLE` ("the authorization store could not be read"), which is an existing member of the closed error vocabulary. A posture that could not be read is not a posture that is absent. + +Two nets between the resolver and the transport envelope are told the same thing, in the one shape `@objectstack/core` already prescribes for such seams (`rethrowAuthzStoreUnavailable`): the dispatcher's service facade hands the resolver the classified rejection for `tenancy` instead of collapsing it to `undefined`, and the identity step's catch re-raises only the branded outage while every other fault still degrades to an anonymous request. A consequence worth knowing: an authorization-store read failure (`AuthzStoreUnavailableError` from the permission tables) now also reaches this door as 503 instead of being served as an anonymous request. diff --git a/packages/runtime/src/http-dispatcher.tenancy-posture-outage.test.ts b/packages/runtime/src/http-dispatcher.tenancy-posture-outage.test.ts new file mode 100644 index 0000000000..bf655b05a7 --- /dev/null +++ b/packages/runtime/src/http-dispatcher.tenancy-posture-outage.test.ts @@ -0,0 +1,237 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#13906 decision 1 option A — at the RUNTIME door, measured end to end] + * + * The runtime resolver (`security/resolve-execution-context.ts`) has ONE + * production caller: `HttpDispatcher.resolveRequestScope`, reached from + * `dispatch()` and from the declarative-endpoint fallback. Between the + * resolver's tenancy read and the transport's error envelope sat THREE nets, + * every one of them collapsing "the posture could not be READ" into "there is + * no posture": + * + * 1. the resolver's own bare `catch { tenancyPosture = undefined }`; + * 2. the dispatcher's `getService` facade — `resolveService`, a capability + * PROBE whose fallback chain absorbs every rejection at every step and + * hands back `undefined`, so the resolver's catch was never even reached; + * 3. `resolveRequestScope`'s `catch { /* anonymous */ }` around the whole + * identity step. + * + * With all three in place a tenancy service that was REGISTERED AND FAILED TO + * BUILD read as "no wall": both posture-conditional API-key refusals were + * skipped and an ex-member's org-stamped key was admitted with full grants. + * The REST seam (`rest-server.ts`) already answers this class with + * `AuthzStoreUnavailableError` (503); this file pins the same answer on the + * runtime door, against a REAL `ObjectKernel` so the rejections under test are + * the registry's own (#13905: branded on "never registered", unbranded on + * "registered and could not be built"). + */ + +import { describe, it, expect } from 'vitest'; + +import { ObjectKernel, isAuthzStoreUnavailableError } from '@objectstack/core'; +import { ApiErrorSchema, BaseResponseSchema } from '@objectstack/spec/api'; + +import { HttpDispatcher } from './http-dispatcher.js'; +import { createDispatcherPlugin } from './dispatcher-plugin.js'; +import { hashApiKey } from './security/api-key.js'; + +const FUTURE = '2999-01-01T00:00:00Z'; +const RAW_EXMEMBER = 'osk_exmember_dispatcher_door'; + +function qlWith() { + const tables: Record = { + // Stamped org_A; the owner's ONLY current membership is org_B. + sys_api_key: [ + { id: 'k_ex', key: hashApiKey(RAW_EXMEMBER), revoked: false, user_id: 'u_exmember', active_organization_id: 'org_A', expires_at: FUTURE }, + ], + sys_member: [{ user_id: 'u_exmember', organization_id: 'org_B' }], + sys_user_permission_set: [], sys_permission_set: [], + sys_position: [], sys_position_permission_set: [], sys_user_position: [], + }; + return { + async find(object: string, opts: any) { + const rows = tables[object] ?? []; + const where = opts?.where ?? {}; + const matched = rows.filter((row) => { + for (const [k, v] of Object.entries(where)) { + if (v !== null && typeof v === 'object') { + if (Array.isArray((v as any).$in) && !(v as any).$in.includes(row[k])) return false; + continue; + } + if ((v ?? null) !== (row[k] ?? null)) return false; + } + return true; + }); + return typeof opts?.limit === 'number' ? matched.slice(0, opts.limit) : matched; + }, + }; +} + +type Tenancy = 'healthy-isolated' | 'factory-throws' | 'unregistered'; + +/** A REAL kernel, as the host would hand the dispatcher. */ +function kernelWith(tenancy: Tenancy): ObjectKernel { + // `gracefulShutdown: false` — a fixture kernel must not hook the test + // runner's process signals. + const kernel = new ObjectKernel({ skipSystemValidation: true, gracefulShutdown: false } as any); + kernel.registerService('objectql', qlWith()); + if (tenancy === 'healthy-isolated') { + kernel.registerService('tenancy', { posture: 'isolated' }); + } else if (tenancy === 'factory-throws') { + // The REAL failure class: the registry's own unbranded rejection. + kernel.registerServiceFactory('tenancy', () => { + throw new Error('tenancy backend unavailable'); + }); + } + // 'unregistered' → nothing: the branded not-registered rejection. + return kernel; +} + +function dispatcherOn(kernel: ObjectKernel) { + return new HttpDispatcher(kernel, undefined, { enforceProjectMembership: false }); +} + +/** The context shape the plugin hands `dispatch()`: `{ request }`, nothing resolved yet. */ +function requestWith(headers: Record): any { + return { request: { headers } }; +} + +/** Settle to the rejection, or to `undefined` when the call RESOLVED. */ +const rejectionOf = (p: Promise) => p.then(() => undefined, (e) => e); + +// --------------------------------------------------------------------------- +// §1 — the identity step (`resolveRequestScope`): what the door DERIVES +// --------------------------------------------------------------------------- + +describe('[#13906 / 1A] HttpDispatcher.resolveRequestScope — the tenancy posture seam on the dispatcher wiring', () => { + it('POSITIVE CONTROL: a healthy `isolated` tenancy service reaches the resolver on THIS wiring — the ex-member key is refused (guest)', async () => { + const context = requestWith({ 'x-api-key': RAW_EXMEMBER }); + await dispatcherOn(kernelWith('healthy-isolated')).resolveRequestScope(context, '/data/task'); + // The membership refusal fires and the request resolves as a GUEST — + // this is what distinguishes "the refusal was skipped" (next test) + // from "the refusal never applied to this fixture". + expect(context.executionContext).toBeDefined(); + expect(context.executionContext.userId).toBeUndefined(); + }); + + it('REPAIRED: tenancy REGISTERED AND FAILING (factory throws) → the identity step raises AuthzStoreUnavailableError (503) — no longer an admitted principal', async () => { + // SUPERSEDED PIN, quoted — what origin/main answered on this wiring: + // await dispatcher.resolveRequestScope(context, '/data/task'); // resolved + // expect(context.executionContext.userId).toBe('u_exmember'); // admitted, full grants + // `resolveService` absorbed the factory's rejection into `undefined`, + // the resolver read that as "no posture", and the Layer 0 refusal + // never ran. + const context = requestWith({ 'x-api-key': RAW_EXMEMBER }); + const err: any = await rejectionOf(dispatcherOn(kernelWith('factory-throws')).resolveRequestScope(context, '/data/task')); + expect(err, 'the identity step RESOLVED — the failed build read as "no wall"').toBeDefined(); + expect(isAuthzStoreUnavailableError(err)).toBe(true); + expect(err.code).toBe('SERVICE_UNAVAILABLE'); + expect(err.status).toBe(503); + expect(err.object).toBe('tenancy'); + // Nothing was written on the context — an outage leaves no principal behind. + expect(context.executionContext).toBeUndefined(); + }); + + it('SUPPORTED, unchanged: tenancy NEVER registered → quiet `undefined` posture, the key is admitted (the no-tenancy composition)', async () => { + // No wall exists here, and an org-stamped key working is by design. + // This is the composition a careless repair breaks; it must be + // byte-for-byte what it was. + const context = requestWith({ 'x-api-key': RAW_EXMEMBER }); + await dispatcherOn(kernelWith('unregistered')).resolveRequestScope(context, '/data/task'); + expect(context.executionContext.userId).toBe('u_exmember'); + expect(context.executionContext.tenantId).toBe('org_A'); + }); + + it('THE COLLAPSE IS ENDED: "registered and failed" and "never registered" no longer answer alike on this wiring', async () => { + const failedCtx = requestWith({ 'x-api-key': RAW_EXMEMBER }); + const failed: any = await rejectionOf(dispatcherOn(kernelWith('factory-throws')).resolveRequestScope(failedCtx, '/data/task')); + const absentCtx = requestWith({ 'x-api-key': RAW_EXMEMBER }); + await dispatcherOn(kernelWith('unregistered')).resolveRequestScope(absentCtx, '/data/task'); + expect(isAuthzStoreUnavailableError(failed)).toBe(true); + expect(absentCtx.executionContext.userId).toBe('u_exmember'); + }); + + it('every OTHER fault of the identity step still degrades to anonymous — only the branded outage is re-raised', async () => { + // The net around the identity step keeps its fail-closed shape for + // everything except the one class the ruling requires to stay loud: + // an engine whose `find` throws a plain Error is not an authz-store + // outage (the API-key lookup fails closed to "no key"), so the request + // resolves as a guest exactly as before. + const kernel = new ObjectKernel({ skipSystemValidation: true, gracefulShutdown: false } as any); + kernel.registerService('objectql', { find: async () => { throw new Error('plain engine fault'); } }); + kernel.registerService('tenancy', { posture: 'isolated' }); + const context = requestWith({ 'x-api-key': RAW_EXMEMBER }); + await dispatcherOn(kernel).resolveRequestScope(context, '/data/task'); + expect(context.executionContext).toBeDefined(); + expect(context.executionContext.userId).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// §2 — the door: the outage LEAVES `dispatch()` and reaches the envelope +// --------------------------------------------------------------------------- + +describe('[#13906 / 1A] the outage reaches the transport envelope as 503 SERVICE_UNAVAILABLE', () => { + it('`dispatch()` re-raises the outage — no net inside the pipeline turns it back into an anonymous 200/401', async () => { + const err: any = await rejectionOf( + dispatcherOn(kernelWith('factory-throws')).dispatch('GET', '/data/task', undefined, {}, requestWith({ 'x-api-key': RAW_EXMEMBER })), + ); + expect(err, '`dispatch()` RESOLVED — the outage was absorbed inside the pipeline').toBeDefined(); + expect(err.code).toBe('SERVICE_UNAVAILABLE'); + expect(err.status).toBe(503); + }); + + /** A fake `IHttpServer` recording the handlers the plugin mounts. */ + function makeFakeServer() { + const handlers: Record any> = {}; + const rec = (verb: string) => (path: string, handler: any) => { handlers[`${verb} ${path}`] = handler; }; + return { + handlers, + server: { get: rec('GET'), post: rec('POST'), put: rec('PUT'), delete: rec('DELETE'), patch: rec('PATCH') }, + }; + } + + async function mountOn(kernel: ObjectKernel) { + const { server, handlers } = makeFakeServer(); + const plugin = createDispatcherPlugin({ prefix: '/api/v1', securityHeaders: false }); + await plugin.start?.({ + getKernel: () => kernel, + getService: (n: string) => (n === 'http.server' ? server : undefined), + environmentId: undefined, + logger: { info() {}, warn() {}, error() {}, debug() {} }, + hook: () => {}, on: () => {}, + } as any); + return handlers; + } + + async function drive(handler: (req: any, res: any) => any, req: any) { + expect(handler, 'route must be mounted').toBeTypeOf('function'); + const res: any = { + statusCode: undefined, body: undefined, + status(c: number) { res.statusCode = c; return res; }, + header() { return res; }, + json(b: any) { res.body = b; return res; }, + end() { return res; }, + }; + await handler(req, res); + return { status: res.statusCode as number, body: res.body }; + } + + it('on the wire (real route, real `errorResponseBase`): 503 with a declared `SERVICE_UNAVAILABLE` envelope, never a served 2xx', async () => { + const handlers = await mountOn(kernelWith('factory-throws')); + const { status, body } = await drive(handlers['POST /api/v1/keys'], { headers: { 'x-api-key': RAW_EXMEMBER }, body: { name: 'k' }, query: {} }); + expect(status).toBe(503); + expect(BaseResponseSchema.safeParse(body).success).toBe(true); + expect(body?.success).toBe(false); + const parsed = ApiErrorSchema.safeParse(body?.error); + expect(parsed.error?.issues ?? []).toEqual([]); + expect(body?.error?.code).toBe('SERVICE_UNAVAILABLE'); + }); + + it('CONTROL on the wire: with tenancy never registered the same door serves — the no-tenancy composition is untouched', async () => { + const handlers = await mountOn(kernelWith('unregistered')); + const { status } = await drive(handlers['GET /api/v1/health'], { headers: {} }); + expect(status).toBe(200); + }); +}); diff --git a/packages/runtime/src/security/resolve-execution-context.test.ts b/packages/runtime/src/security/resolve-execution-context.test.ts index 3ceba57d7a..07b5f2fbbc 100644 --- a/packages/runtime/src/security/resolve-execution-context.test.ts +++ b/packages/runtime/src/security/resolve-execution-context.test.ts @@ -2,6 +2,8 @@ import { describe, it, expect } from 'vitest'; +import { ObjectKernel, isAuthzStoreUnavailableError } from '@objectstack/core'; + import { resolveExecutionContext } from './resolve-execution-context.js'; import { hashApiKey } from './api-key.js'; @@ -589,3 +591,152 @@ describe('#6216 — this face keeps the EXPLICIT GUEST entry, and its own named expect(ctx.accessToken).toBe('sess_tok_rt'); }); }); + +// --------------------------------------------------------------------------- +// [#13906 decision 1 option A — at the RUNTIME door] The tenancy-posture seam +// keeps "never registered" apart from "registered and FAILED to build". +// +// `resolveAuthzContext` gates BOTH posture-conditional API-key refusals +// (`organization_required`, `organization_membership_ended`) on a PRESENT +// posture. So `undefined` here is not neutral: it is "no wall". The REST seam +// (`rest-server.ts`) already separates the two facts by the registry's own +// brand; this file pins the same separation on the runtime resolver, fed by +// the registry's own async accessor — branded on "never registered", +// unbranded on "registered and could not be built" (#13905). +// +// Both keys are fixtures for the refusal the posture gates: one stamped with +// an organization its owner has LEFT, one carrying no organization at all. +// --------------------------------------------------------------------------- + +describe('[#13906 decision 1 A, runtime door] the tenancy posture seam tells "never registered" from "registered and failed"', () => { + const RAW_EXMEMBER = 'osk_exmember_runtime_door'; + const RAW_ORGLESS = 'osk_orgless_runtime_door'; + + function qlWith() { + const tables: Record = { + sys_api_key: [ + // Stamped org_A; the owner's ONLY current membership is org_B. + { id: 'k_ex', key: hashApiKey(RAW_EXMEMBER), revoked: false, user_id: 'u_exmember', active_organization_id: 'org_A', expires_at: FUTURE }, + // No organization at all. + { id: 'k_orgless', key: hashApiKey(RAW_ORGLESS), revoked: false, user_id: 'u_orgless', expires_at: FUTURE }, + ], + sys_member: [ + { user_id: 'u_exmember', organization_id: 'org_B' }, + { user_id: 'u_orgless', organization_id: 'org_A' }, + ], + sys_user_permission_set: [], sys_permission_set: [], + sys_position: [], sys_position_permission_set: [], sys_user_position: [], + }; + return { + async find(object: string, opts: any) { + const rows = tables[object] ?? []; + const where = opts?.where ?? {}; + return bounded(rows.filter((row) => { + for (const [k, v] of Object.entries(where)) { + if (v !== null && typeof v === 'object') { + if (Array.isArray((v as any).$in) && !(v as any).$in.includes(row[k])) return false; + continue; + } + if ((v ?? null) !== (row[k] ?? null)) return false; + } + return true; + }), opts); + }, + }; + } + + /** A REAL kernel: the registry whose rejections are the facts under test. */ + function kernelWith(tenancy: 'healthy-isolated' | 'factory-throws' | 'unregistered'): ObjectKernel { + // `gracefulShutdown: false` — a fixture kernel must not hook the test + // runner's process signals. + const kernel = new ObjectKernel({ skipSystemValidation: true, gracefulShutdown: false } as any); + if (tenancy === 'healthy-isolated') { + kernel.registerService('tenancy', { posture: 'isolated' }); + } else if (tenancy === 'factory-throws') { + // The REAL failure class (#13905: "registered and FAILED to construct"): + // the registry's own unbranded rejection, not a stub thrown at the seam. + kernel.registerServiceFactory('tenancy', () => { + throw new Error('tenancy backend unavailable'); + }); + } + // 'unregistered' → nothing: the branded not-registered rejection. + return kernel; + } + + /** + * The resolver fed straight by the registry's async accessor. `auth` and + * `settings` reject branded here too, and both of those seams already + * absorb (anonymous session; default localization) — only `tenancy` is an + * authorization INPUT. + */ + const viaKernel = (kernel: ObjectKernel, headers: Record) => ({ + getService: (name: string) => kernel.getServiceAsync(name), + getQl: async () => qlWith(), + request: { headers }, + }); + + /** Settle to the rejection, or to `undefined` when the call RESOLVED. */ + const rejectionOf = (p: Promise) => p.then(() => undefined, (e) => e); + + it('POSITIVE CONTROL: a healthy `isolated` tenancy service reaches the resolver through this facade — both keys are refused (guest)', async () => { + const exMember = await resolveExecutionContext(viaKernel(kernelWith('healthy-isolated'), { 'x-api-key': RAW_EXMEMBER })); + expect(exMember.userId).toBeUndefined(); + const orgLess = await resolveExecutionContext(viaKernel(kernelWith('healthy-isolated'), { 'x-api-key': RAW_ORGLESS })); + expect(orgLess.userId).toBeUndefined(); + }); + + it('SUPPORTED, unchanged: tenancy NEVER registered → the branded rejection is absorbed, no posture, no refusal', async () => { + // The no-tenancy composition: no wall exists, and an org-stamped key + // working there is by design. A repair that made this loud too would + // break every single-organization embedder — the brand, not the catch, + // is the discriminator. + const ctx = await resolveExecutionContext(viaKernel(kernelWith('unregistered'), { 'x-api-key': RAW_EXMEMBER })); + expect(ctx.userId).toBe('u_exmember'); + expect(ctx.tenantId).toBe('org_A'); + }); + + it('REPAIRED: tenancy REGISTERED AND FAILING (factory throws) → AuthzStoreUnavailableError, 503 SERVICE_UNAVAILABLE — never an admitted principal', async () => { + // SUPERSEDED PIN, quoted — what origin/main answered at this seam: + // expect(ctx.userId).toBe('u_exmember'); + // expect(ctx.tenantId).toBe('org_A'); + // A tenancy service that could not be CONSTRUCTED resolved to "no + // posture", which skipped the Layer 0 membership refusal, so the ex-member + // was admitted with full grants. A posture that could not be READ is not a + // posture that is ABSENT. + const err: any = await rejectionOf(resolveExecutionContext(viaKernel(kernelWith('factory-throws'), { 'x-api-key': RAW_EXMEMBER }))); + expect(err, 'the resolver RESOLVED — the failed build was admitted as "no wall"').toBeDefined(); + expect(isAuthzStoreUnavailableError(err)).toBe(true); + // ADR-0112 envelope: the code and status a door answers with. + expect(err.code).toBe('SERVICE_UNAVAILABLE'); + expect(err.status).toBe(503); + expect(err.object).toBe('tenancy'); + // The registry's own diagnostic is kept, not replaced. + expect(err.cause?.message).toBe('tenancy backend unavailable'); + }); + + it('SIBLING REFUSAL, same seam: the org-less key under a FAILED build is an outage (503), no longer admitted', async () => { + // SUPERSEDED PIN, quoted: expect(ctx.userId).toBe('u_orgless'); + const err: any = await rejectionOf(resolveExecutionContext(viaKernel(kernelWith('factory-throws'), { 'x-api-key': RAW_ORGLESS }))); + expect(err, 'the resolver RESOLVED — the org-less key was admitted on an unreadable posture').toBeDefined(); + expect(err.code).toBe('SERVICE_UNAVAILABLE'); + expect(err.status).toBe(503); + }); + + it('THE COLLAPSE IS ENDED: "registered and failed" and "never registered" no longer answer alike', async () => { + const failed: any = await rejectionOf(resolveExecutionContext(viaKernel(kernelWith('factory-throws'), { 'x-api-key': RAW_EXMEMBER }))); + const absent = await resolveExecutionContext(viaKernel(kernelWith('unregistered'), { 'x-api-key': RAW_EXMEMBER })); + expect(isAuthzStoreUnavailableError(failed)).toBe(true); + expect(absent.userId).toBe('u_exmember'); + }); + + it('the dispatcher-shaped PROBE facade (resolves `undefined`, never rejects) is untouched — quiet, no posture', async () => { + // Every other test in this file feeds this shape; pinned here beside the + // loud cases so the two facades are read together. + const ctx = await resolveExecutionContext({ + getService: async () => undefined, + getQl: async () => qlWith(), + request: { headers: { 'x-api-key': RAW_EXMEMBER } }, + }); + expect(ctx.userId).toBe('u_exmember'); + }); +}); From 7bda2b7e8e47d2445d80a4352226b7f1a4a49dd7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 13:25:40 +0000 Subject: [PATCH 2/4] test(runtime): un-nest a comment delimiter in the door pin header Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ --- .../src/http-dispatcher.tenancy-posture-outage.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/runtime/src/http-dispatcher.tenancy-posture-outage.test.ts b/packages/runtime/src/http-dispatcher.tenancy-posture-outage.test.ts index bf655b05a7..6861d1bce7 100644 --- a/packages/runtime/src/http-dispatcher.tenancy-posture-outage.test.ts +++ b/packages/runtime/src/http-dispatcher.tenancy-posture-outage.test.ts @@ -14,8 +14,8 @@ * 2. the dispatcher's `getService` facade — `resolveService`, a capability * PROBE whose fallback chain absorbs every rejection at every step and * hands back `undefined`, so the resolver's catch was never even reached; - * 3. `resolveRequestScope`'s `catch { /* anonymous */ }` around the whole - * identity step. + * 3. `resolveRequestScope`'s bare `catch` ("anonymous request") around the + * whole identity step. * * With all three in place a tenancy service that was REGISTERED AND FAILED TO * BUILD read as "no wall": both posture-conditional API-key refusals were From 566e1558ab1c12b37590e03e7e674b8d35c3d16c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 13:33:05 +0000 Subject: [PATCH 3/4] fix(runtime): the tenancy posture seam tells "never registered" from "registered and failed" at the runtime door MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolveExecutionContext swallowed every rejection of the tenancy read into "no posture", and no posture skips both posture-conditional API-key refusals — so a tenancy service that was registered and failed to build read as a deployment with no wall. Apply #13906 decision 1 option A the way rest-server.ts already does: absorb only the registry's branded "never registered" rejection; re-raise everything else as AuthzStoreUnavailableError (503 SERVICE_UNAVAILABLE). Two nets between the resolver and the transport envelope are told the same thing: the dispatcher's service facade hands the resolver the classified rejection for 'tenancy' (resolveService is a capability probe that collapsed it to undefined), and resolveRequestScope's catch re-raises only the branded outage via rethrowAuthzStoreUnavailable, degrading everything else to anonymous as before. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ --- ...-dispatcher.tenancy-posture-outage.test.ts | 38 ++++++++- packages/runtime/src/http-dispatcher.ts | 78 ++++++++++++++++++- .../src/security/resolve-execution-context.ts | 45 ++++++++++- 3 files changed, 153 insertions(+), 8 deletions(-) diff --git a/packages/runtime/src/http-dispatcher.tenancy-posture-outage.test.ts b/packages/runtime/src/http-dispatcher.tenancy-posture-outage.test.ts index 6861d1bce7..88e94ac1f7 100644 --- a/packages/runtime/src/http-dispatcher.tenancy-posture-outage.test.ts +++ b/packages/runtime/src/http-dispatcher.tenancy-posture-outage.test.ts @@ -218,9 +218,36 @@ describe('[#13906 / 1A] the outage reaches the transport envelope as 503 SERVICE return { status: res.statusCode as number, body: res.body }; } - it('on the wire (real route, real `errorResponseBase`): 503 with a declared `SERVICE_UNAVAILABLE` envelope, never a served 2xx', async () => { + // The wire route is `GET /automation` with the ex-member key, chosen by + // MEASUREMENT on the unrepaired tree so that each tenancy state answers + // differently and no domain-side 503 is in the way (`POST /keys` answers + // its own `503 Data service not available` against a fixture engine + // with no `insert`, so it cannot pin this seam): + // + // tenancy service | before (origin/main) | after + // ------------------|----------------------|------- + // healthy isolated | 401 UNAUTHENTICATED | 401 — the membership refusal, unchanged + // never registered | 501 NOT_IMPLEMENTED | 501 — admitted, then "no automation service", unchanged + // registered+FAILED | 501 NOT_IMPLEMENTED | 503 SERVICE_UNAVAILABLE + // + // The 501 on the failed leg is the defect on the wire: byte-for-byte the + // "never registered" answer, i.e. the ex-member was ADMITTED. + const AUTOMATION = 'GET /api/v1/automation'; + const withKey = { headers: { 'x-api-key': RAW_EXMEMBER }, query: {} }; + + it('POSITIVE CONTROL on the wire: healthy `isolated` tenancy → the ex-member key is refused on the anonymous floor (401)', async () => { + const handlers = await mountOn(kernelWith('healthy-isolated')); + const { status, body } = await drive(handlers[AUTOMATION], withKey); + expect(status).toBe(401); + expect(body?.error?.code).toBe('UNAUTHENTICATED'); + }); + + it('REPAIRED on the wire (real route, real `errorResponseBase`): registered and FAILING → 503 with a declared `SERVICE_UNAVAILABLE` envelope', async () => { + // SUPERSEDED PIN, quoted — measured on origin/main: + // expect(status).toBe(501); + // expect(body?.error?.code).toBe('NOT_IMPLEMENTED'); const handlers = await mountOn(kernelWith('factory-throws')); - const { status, body } = await drive(handlers['POST /api/v1/keys'], { headers: { 'x-api-key': RAW_EXMEMBER }, body: { name: 'k' }, query: {} }); + const { status, body } = await drive(handlers[AUTOMATION], withKey); expect(status).toBe(503); expect(BaseResponseSchema.safeParse(body).success).toBe(true); expect(body?.success).toBe(false); @@ -229,6 +256,13 @@ describe('[#13906 / 1A] the outage reaches the transport envelope as 503 SERVICE expect(body?.error?.code).toBe('SERVICE_UNAVAILABLE'); }); + it('THE COLLAPSE IS ENDED on the wire: never registered keeps its 501 (admitted, no automation service) — only the FAILED leg moved', async () => { + const handlers = await mountOn(kernelWith('unregistered')); + const { status, body } = await drive(handlers[AUTOMATION], withKey); + expect(status).toBe(501); + expect(body?.error?.code).toBe('NOT_IMPLEMENTED'); + }); + it('CONTROL on the wire: with tenancy never registered the same door serves — the no-tenancy composition is untouched', async () => { const handlers = await mountOn(kernelWith('unregistered')); const { status } = await drive(handlers['GET /api/v1/health'], { headers: {} }); diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index c9c024d6de..bd2c038e6f 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -2,6 +2,11 @@ import { ObjectKernel, getEnv, evaluateAuthGate, isAuthGateAllowlisted, + // [#13906 decision 1 A] The identity step's net re-raises ONLY the loud + // authz-store outage (the `.catch` shape `@objectstack/core` prescribes for + // every seam between `resolveAuthzContext` and a door), and the tenancy + // read is classified by the REGISTRY's own "never registered" brand. + rethrowAuthzStoreUnavailable, isServiceNotRegisteredError, } from '@objectstack/core'; import { isMcpServerEnabled, looksLikeInternalErrorLeak, INTERNAL_ERROR_MESSAGE, resolveThrownHttpError, demotedDeclaredCode, declaredUserMessage } from '@objectstack/types'; import { measureServerTiming, allowPerfDisclosure, isPerfDisclosurePrincipal } from '@objectstack/observability'; @@ -535,7 +540,20 @@ export class HttpDispatcher { // ctx.userId/roles/permissions/tenantId via opCtx.context. try { context.executionContext = await this.timedResolveExecutionContext({ - getService: (n: string) => this.resolveService(this.requestKernel(context), n, context.environmentId), + // [#13906 decision 1 A] `resolveService` is a capability PROBE: + // its fallback chain absorbs every rejection at every step and + // answers `undefined`, which is the right shape for "is this + // optional service installed" and the wrong shape for the ONE + // authorization INPUT the resolver reads through this facade. + // For `tenancy` the resolver must see the registry's CLASSIFIED + // rejection — branded "never registered" (absorbed, the + // supported no-tenancy composition) versus unbranded + // "registered and failed to build" (re-raised, so the door + // answers 503 instead of admitting on a posture it could not + // read). Every other name keeps the probe. + getService: (n: string) => n === 'tenancy' + ? this.resolveServiceOrLoud(this.requestKernel(context), n, context.environmentId) + : 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 @@ -564,7 +582,17 @@ export class HttpDispatcher { // (the scoped prefix is stripped only by the caller, later). acceptOAuthAccessToken: /^(?:\/projects\/[^/]+)?\/mcp(?:[/?]|$)/.test(cleanPath), }); - } catch { + } catch (err) { + // [#13906 decision 1 A / #13279] The ONE fault that must stay loud: + // an authorization input that exists and could not be read (a + // failed permission-store read, a `tenancy` service that is + // registered and failed to build). Swallowing it here answered an + // outage as an anonymous request — a 401 byte-identical to a caller + // with no credential, which is the "changed disguise" the shared + // `rethrowAuthzStoreUnavailable` exists to end. It re-raises that + // class by brand and returns `undefined` for everything else, so + // every other fault still degrades exactly as before: + rethrowAuthzStoreUnavailable(err); // anonymous request — leave executionContext undefined } } @@ -2120,6 +2148,52 @@ export class HttpDispatcher { return services[name]; } + /** + * [#13906 decision 1 A] Resolve a service whose ABSENCE is a supported + * composition but whose FAILURE is an outage — today only the `tenancy` + * read the identity step feeds `resolveExecutionContext`. + * + * `resolveService` above is a capability probe: every step of its chain + * absorbs every rejection and falls through, so a factory that threw and a + * name nothing registered both come back as `undefined`. That collapse is + * the defect #13906 repaired one seam over (`rest-server.ts`), and the + * classification here is the same one, taken from the REGISTRY rather than + * from message text (#13905): + * + * - branded "never registered" → `undefined`, quiet; + * - every other rejection (a factory that threw, a scoped registration + * resolved without a scope id, a circular service dependency) → + * re-raised unbranded, for the resolver to answer as + * `AuthzStoreUnavailableError` (503). + * + * The chain order is `resolveService`'s (scoped lookup on the host kernel + * first, then the request's own kernel), so WHICH registry answers is + * unchanged; only what a rejection MEANS is. A host with no async accessor + * (`KernelBase`-shaped, e.g. `LiteKernel`) supports no service factories, + * so "not registered" is the only fault it can report — it keeps the quiet + * probe, which is the same classification rather than a second collapse. + */ + private async resolveServiceOrLoud(kernel: any, name: string, scopeId?: string): Promise { + const classified = async (read: () => Promise): Promise<{ found: boolean; value?: any }> => { + try { + const svc = await read(); + return svc != null ? { found: true, value: svc } : { found: false }; + } catch (err) { + if (isServiceNotRegisteredError(err)) return { found: false }; + throw err; + } + }; + if (scopeId && typeof this.defaultKernel.getServiceAsync === 'function') { + const scoped = await classified(() => this.defaultKernel.getServiceAsync(name, scopeId)); + if (scoped.found) return scoped.value; + } + if (typeof kernel?.getServiceAsync === 'function') { + const own = await classified(() => kernel.getServiceAsync(name)); + return own.found ? own.value : undefined; + } + return this.resolveService(kernel, name, scopeId); + } + /** * Get the ObjectQL service which provides access to SchemaRegistry. * Tries multiple access patterns since kernel structure varies. diff --git a/packages/runtime/src/security/resolve-execution-context.ts b/packages/runtime/src/security/resolve-execution-context.ts index 74946d7268..9749c4abe3 100644 --- a/packages/runtime/src/security/resolve-execution-context.ts +++ b/packages/runtime/src/security/resolve-execution-context.ts @@ -15,8 +15,14 @@ * synthesis live in ONE place now (`@objectstack/core`), shared with the REST * server, so the two entry points can never drift on authorization again. * - * Always resolves — never throws. Anonymous requests yield - * `{ isSystem: false, positions: [], permissions: [] }`. + * Resolves for every request it can ANSWER — anonymous requests yield the + * guest envelope (`{ isSystem: false, positions: [], permissions: [] }`) — + * and throws `AuthzStoreUnavailableError` (503) for the one class of fault + * that leaves the answer undetermined: an authorization INPUT that exists and + * could not be read (a permission-store read that failed, #13279; a `tenancy` + * service that is registered and failed to build, #13906 decision 1 A). The + * dispatcher's net (`HttpDispatcher.resolveRequestScope`) re-raises exactly + * that class and degrades everything else to anonymous, as before. */ import type { ExecutionContext } from '@objectstack/spec/kernel'; @@ -30,6 +36,13 @@ import { assembleExecutionContextOrGuest, type EntryLocalization, effectiveTenancyPosture, + // [#13906 decision 1 A] The loud answer for an authorization input that + // exists and could not be read, and the REGISTRY's own "never registered" + // brand that lets the tenancy seam absorb the supported no-tenancy + // composition while every other rejection stays loud. Never message text + // (#13905). + AuthzStoreUnavailableError, + isServiceNotRegisteredError, } from '@objectstack/core'; /** @@ -168,11 +181,35 @@ export async function resolveExecutionContext(opts: ResolveOptions): Promise Date: Sat, 5 Sep 2026 13:44:47 +0000 Subject: [PATCH 4/4] docs(kernel): the identity-step sample no longer claims resolveExecutionContext always resolves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It rejects with AuthzStoreUnavailableError (503) when an authorization input exists and could not be read — false since #13279 for a failed permission-store read, and now also for a tenancy service that is registered and failed to build. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ --- content/docs/protocol/kernel/index.mdx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/content/docs/protocol/kernel/index.mdx b/content/docs/protocol/kernel/index.mdx index bcf15ffe23..91114d6d6f 100644 --- a/content/docs/protocol/kernel/index.mdx +++ b/content/docs/protocol/kernel/index.mdx @@ -293,8 +293,11 @@ that order. // 1. Identity — resolveExecutionContext() reads the better-auth session (or // API key), aggregates positions/permission sets/RLS membership, and layers -// locale + timezone on top. It always resolves; anonymous yields -// `{ isSystem: false, positions: [], permissions: [] }`. +// locale + timezone on top. Anonymous yields +// `{ isSystem: false, positions: [], permissions: [] }`. It rejects with +// AuthzStoreUnavailableError (503) only when an authorization input exists +// and could not be read — a failed permission-store read, or a tenancy +// service that is registered and failed to build. const context = await resolveExecutionContext({ getService, getQl, request }); // → ExecutionContext { userId, tenantId, locale, timezone, positions, // permissions, isSystem, ... }