diff --git a/.changeset/fold-admission-tenancy-posture-classification.md b/.changeset/fold-admission-tenancy-posture-classification.md new file mode 100644 index 0000000000..8a3dbb01c4 --- /dev/null +++ b/.changeset/fold-admission-tenancy-posture-classification.md @@ -0,0 +1,59 @@ +--- +'@objectstack/core': minor +'@objectstack/rest': patch +'@objectstack/cloud-connection': patch +'@objectstack/plugin-sharing': patch +'@objectstack/service-datasource': patch +'@objectstack/service-settings': patch +'@objectstack/service-storage': patch +--- + +refactor(core): one `classifyAdmissionTenancyPosture`, so six admission seams cannot each get the classification wrong (#16013) + +Six admission doors each hand-wrote the same try/catch on the `tenancy` read that +feeds `resolveAuthzContext`: the registry's branded "never registered" rejection +(`isServiceNotRegisteredError`, #13905) resolves quietly to `undefined` — the +supported no-tenancy composition, where no posture-conditional refusal runs at +all — and every other rejection becomes `AuthzStoreUnavailableError('tenancy', err)` +(ADR-0112 `SERVICE_UNAVAILABLE` / 503), because the posture is an authorization +INPUT and admission was therefore never DECIDED. That is #13906 decision 1 +option A, and it is the part nobody may get wrong: a quiet `catch` at any one of +the six re-opens the defect, where a failure reads as "this check does not apply" +and an ex-member's org-stamped API key is admitted. + +Nothing is broken today — every copy was correct — so this removes a standing +hazard rather than fixing a defect. **No admission verdict changes**, on any +wiring: the classification is byte-for-byte the decision the six copies made, +now made once. + +- **`@objectstack/core` gains `classifyAdmissionTenancyPosture`** (and the + `TenancyServiceResolver` type), exported from the package index beside + `effectiveTenancyPosture`. It takes a THUNK and owns the classification only. + The thunk is not a style choice: the REJECTION is what gets classified, so the + resolution has to happen inside the helper's `try` — a caller that awaited the + service first would need a `catch` of its own, which is the thing being + deleted. +- **The RESOLUTION deliberately did not move.** `rest-server.ts` branches on + kernel-vs-provider, and asking twice would let a provider bound to the local + kernel answer for a request that resolved to another environment; four seams + read `ctx.getKernel()`; `service-storage` reads an already-normalised gate + registry; and each seam's reason why a MISSING async accessor must stay quiet + is its own argument (the storage door's is its declared degrade-to-ungated + contract, the others' is the `KernelBase`/`LiteKernel` host shape). A helper + that also owned how the service is reached would be wrong for one of them or + grow a flag per seam — the copies again, with an extra step. Every one of + those reasons stays written at its seam. +- **Folded**: `packages/rest/src/rest-server.ts` (both wirings), + `packages/cloud-connection/src/marketplace-install-local-plugin.ts`, + `packages/plugins/plugin-sharing/src/sharing-plugin.ts`, + `packages/services/service-datasource/src/admin-routes.ts`, + `packages/services/service-settings/src/settings-service-plugin.ts`, + `packages/services/service-storage/src/storage-service-plugin.ts`. +- **Pinned where the decision now lives**: + `packages/core/src/security/admission-tenancy-posture.test.ts` drives both + rejections at the production seam — a real `ObjectKernel` that never + registered `tenancy`, and one whose `tenancy` factory throws — each beside the + brand predicate's own answer on that same rejection, so "the outage throws" is + distinguishable from a helper that throws at everything. It also holds the + constraint mechanically: the helper's source may not name an accessor, a + kernel or a plugin context, and it takes exactly one parameter. diff --git a/packages/cloud-connection/src/marketplace-install-local-plugin.ts b/packages/cloud-connection/src/marketplace-install-local-plugin.ts index 98c30f4b71..1d280d1c0a 100644 --- a/packages/cloud-connection/src/marketplace-install-local-plugin.ts +++ b/packages/cloud-connection/src/marketplace-install-local-plugin.ts @@ -49,15 +49,12 @@ import type { Plugin, PluginContext } from '@objectstack/core'; import { resolveAuthzContext, isAuthzStoreUnavailableError, - // [#15353] The three symbols the ADMISSION seam's posture derivation needs - // — the same set `packages/rest`'s repaired seam imports, for the same - // reason. `effectiveTenancyPosture` reads the posture IN FORCE off the - // kernel's `tenancy` service; the other two are decision-1-option-A's - // classification (#13906): never-registered is branded and quiet, every - // other rejection is the outage it is. - effectiveTenancyPosture, - isServiceNotRegisteredError, - AuthzStoreUnavailableError, + // [#16013] The ADMISSION seam's posture derivation, in ONE call — the same + // symbol `packages/rest`'s seam now imports, for the same reason. It reads + // the posture IN FORCE off the kernel's `tenancy` service and carries + // decision-1-option-A's classification (#13906): never-registered is + // branded and quiet, every other rejection is the outage it is. + classifyAdmissionTenancyPosture, type TenancyPostureSource, } from '@objectstack/core'; import { @@ -1673,15 +1670,19 @@ export class MarketplaceInstallLocalPlugin implements Plugin { * ⛔ Do not wire either of them into this seam, and ⛔ do not add a * `catch { undefined }` here. * - * ## The classification, decision 1 option A (#13906) + * ## The classification, decision 1 option A (#13906) — no longer here * - * - **Never registered** ⇒ branded (`isServiceNotRegisteredError`), quiet - * `undefined`. A lean embedding with no `plugin-auth` is a SUPPORTED - * composition, and behaviour there is exactly what it was. - * - **Registered and unable to answer** ⇒ `AuthzStoreUnavailableError` - * (ADR-0112 `SERVICE_UNAVAILABLE` / 503). Admission was never DECIDED, so - * it must not be answered. {@link resolveInstallPrincipal}'s `catch` - * already re-raises this brand rather than collapsing it to `null` (401). + * [#16013] `classifyAdmissionTenancyPosture` (`@objectstack/core`) owns the + * branded/unbranded decision for every admission seam: never registered ⇒ + * quiet `undefined`; every other rejection ⇒ `AuthzStoreUnavailableError` + * (ADR-0112 `SERVICE_UNAVAILABLE` / 503), because admission was never + * DECIDED and must not be answered. + * + * ⚠️ What that means AT THIS DOOR is still this door's own: a lean + * embedding with no `plugin-auth` is a SUPPORTED composition and behaviour + * there is exactly what it was, and on the loud arm + * {@link resolveInstallPrincipal}'s `catch` already re-raises this brand + * rather than collapsing it to `null` (401). * * ⚠️ The brand exists only on the ASYNC resolution path: `PluginContext.getService` * throws two UNBRANDED plain `Error`s (`… not found` and `… is async - use @@ -1703,16 +1704,9 @@ export class MarketplaceInstallLocalPlugin implements Plugin { | { getServiceAsync?: (name: string, scopeId?: string) => Promise } | undefined; if (!kernel || typeof kernel.getServiceAsync !== 'function') return undefined; - try { - return effectiveTenancyPosture( - await kernel.getServiceAsync('tenancy'), - ); - } catch (err) { - if (!isServiceNotRegisteredError(err)) { - throw new AuthzStoreUnavailableError('tenancy', err); - } - return undefined; - } + return classifyAdmissionTenancyPosture(() => + kernel.getServiceAsync!('tenancy'), + ); }; private resolveInstallPrincipal = async ( diff --git a/packages/core/src/security/admission-tenancy-posture.test.ts b/packages/core/src/security/admission-tenancy-posture.test.ts new file mode 100644 index 0000000000..8a88fc5fcb --- /dev/null +++ b/packages/core/src/security/admission-tenancy-posture.test.ts @@ -0,0 +1,287 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#16013] The ONE classification six admission seams used to hand-write, and + * the pins that make it able to FAIL. + * + * ## What this file is for + * + * The card that folded the copies is not a de-duplication card: its argument is + * that "a quiet `catch` at any one of them re-opens #13906", and one tested + * classification is worth more than six copies that must each stay correct + * forever. So the value of the fold is entirely in these pins — they are what + * six seams now share instead of six chances to write the decision wrong. + * + * ## Reading discipline + * + * Both rejections are driven at the PRODUCTION seam — a real `ObjectKernel` + * whose registry rejects for a service nothing registered (the branded fact, + * #13905) and whose `tenancy` factory THROWS (the unbranded one) — never by + * throwing hand-made errors at the function under measurement. Each loud + * assertion is paired with the reading that separates it from its twin: the + * brand predicate's own answer on that same rejection. Without that pairing, + * "the outage throws" would be satisfied by a helper that throws at everything, + * which is the worse defect (every no-tenancy embedding refused). + * + * §3 holds the card's ⛔ constraint mechanically: this helper must never learn + * how to REACH the service. That is not style — a helper that owned the + * resolution would be wrong for `rest-server.ts`'s kernel-vs-provider branch + * (asking twice lets a provider bound to the local kernel answer for a request + * that resolved to another environment) or would grow a flag per seam, which is + * the copies again with an extra step. + */ + +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; + +import { ObjectKernel } from '../kernel.js'; +import { ServiceLifecycle } from '../plugin-loader.js'; +import { isServiceNotRegisteredError } from '../service-not-registered.js'; + +import { classifyAdmissionTenancyPosture } from './admission-tenancy-posture.js'; +import type { TenancyPostureSource } from './api-key.js'; +import { + isAuthzStoreUnavailableError, + AUTHZ_STORE_UNAVAILABLE_CODE, + AUTHZ_STORE_UNAVAILABLE_STATUS, +} from './authz-store-unavailable.js'; + +const FACTORY_FAULT = 'tenancy factory exploded'; + +function freshKernel(): ObjectKernel { + return new ObjectKernel({ + logger: { level: 'error' }, + gracefulShutdown: false, + skipSystemValidation: true, + }); +} + +/** A kernel whose `tenancy` service is registered and answers. */ +async function kernelWithTenancy(service: unknown): Promise { + const kernel = freshKernel(); + kernel.registerServiceFactory('tenancy', () => service, ServiceLifecycle.SINGLETON); + await kernel.bootstrap(); + return kernel; +} + +/** A kernel whose `tenancy` service IS registered and cannot be built. */ +async function kernelWithBrokenTenancy(): Promise { + const kernel = freshKernel(); + kernel.registerServiceFactory( + 'tenancy', + () => { throw new Error(FACTORY_FAULT); }, + ServiceLifecycle.SINGLETON, + ); + await kernel.bootstrap(); + return kernel; +} + +/** A kernel that never heard of `tenancy` — the supported composition. */ +async function kernelWithoutTenancy(): Promise { + const kernel = freshKernel(); + await kernel.bootstrap(); + return kernel; +} + +// --------------------------------------------------------------------------- +// §1 — the two facts the registry can report, told apart (#13906 decision 1 A) +// --------------------------------------------------------------------------- + +describe('[#16013] §1 — branded "never registered" vs every other rejection', () => { + it('NEVER REGISTERED ⇒ quiet `undefined` — the supported no-tenancy composition', async () => { + const kernel = await kernelWithoutTenancy(); + try { + // ANTI-VACUITY CONTROL: this really is the BRANDED rejection, read off + // the production registry rather than assumed. If the registry ever stops + // branding it, the quiet answer below would be quiet for the wrong + // reason — an outage wearing the no-tenancy composition's costume. + const raw = await kernel.getServiceAsync('tenancy').then( + () => undefined, + (err: unknown) => err, + ); + expect(isServiceNotRegisteredError(raw)).toBe(true); + + await expect( + classifyAdmissionTenancyPosture(() => kernel.getServiceAsync('tenancy')), + ).resolves.toBeUndefined(); + } finally { + await kernel.shutdown(); + } + }); + + it('REGISTERED AND FAILED TO BUILD ⇒ the ADR-0112 outage, never a quiet `undefined`', async () => { + const kernel = await kernelWithBrokenTenancy(); + try { + // ANTI-VACUITY CONTROL, the other half: this rejection is NOT branded, so + // the loud answer below is the classification working and not the + // predicate misfiring. + const raw = await kernel.getServiceAsync('tenancy').then( + () => undefined, + (err: unknown) => err, + ); + expect(isServiceNotRegisteredError(raw)).toBe(false); + + const err = await classifyAdmissionTenancyPosture( + () => kernel.getServiceAsync('tenancy'), + ).then(() => undefined, (e: unknown) => e); + + // The ENVELOPE is the assertion (ADR-0112), not the message text. + expect(isAuthzStoreUnavailableError(err)).toBe(true); + expect(err).toMatchObject({ + code: AUTHZ_STORE_UNAVAILABLE_CODE, + status: AUTHZ_STORE_UNAVAILABLE_STATUS, + object: 'tenancy', + }); + // ⛔ The original fault is not thrown away: an outage nobody can diagnose + // is the next incident. + expect(String((err as { cause?: unknown }).cause)).toContain(FACTORY_FAULT); + } finally { + await kernel.shutdown(); + } + }); + + it('SCOPED without a scope id ⇒ loud too — an unbranded rejection is an outage whatever produced it', async () => { + const kernel = freshKernel(); + kernel.registerServiceFactory('tenancy', () => ({ posture: 'isolated' }), ServiceLifecycle.SCOPED); + await kernel.bootstrap(); + try { + const raw = await kernel.getServiceAsync('tenancy').then( + () => undefined, + (err: unknown) => err, + ); + // Reading first: a scoped registration resolved without a scope id may + // reject, and if it does the rejection is NOT the "never registered" + // brand. The pin follows the reading rather than asserting over it. + if (raw === undefined) { + expect(raw).toBeUndefined(); + } else { + expect(isServiceNotRegisteredError(raw)).toBe(false); + await expect( + classifyAdmissionTenancyPosture(() => kernel.getServiceAsync('tenancy')), + ).rejects.toSatisfy(isAuthzStoreUnavailableError); + } + } finally { + await kernel.shutdown(); + } + }); + + it('HEALTHY ⇒ the posture IN FORCE, read through the same reader the wall uses', async () => { + const kernel = await kernelWithTenancy({ posture: 'isolated' }); + try { + await expect( + classifyAdmissionTenancyPosture(() => kernel.getServiceAsync('tenancy')), + ).resolves.toBe('isolated'); + } finally { + await kernel.shutdown(); + } + }); +}); + +// --------------------------------------------------------------------------- +// §2 — the thunk contract the six seams depend on +// --------------------------------------------------------------------------- + +describe('[#16013] §2 — the resolver is a THUNK, and the seams depend on how it is called', () => { + it('a SYNCHRONOUS throw classifies identically — both directions', async () => { + // The seams reach the service through accessors that can throw before ever + // returning a promise (`PluginContext.getService` does exactly that). A + // helper that only caught rejections would let a synchronous branded throw + // become an outage, and a synchronous unbranded one escape unclassified. + await expect( + classifyAdmissionTenancyPosture(() => { throw new Error('accessor threw'); }), + ).rejects.toSatisfy(isAuthzStoreUnavailableError); + + const kernel = await kernelWithoutTenancy(); + try { + // …and the genuinely branded one, thrown synchronously out of the thunk. + const raw = await kernel.getServiceAsync('tenancy').then( + () => undefined, + (err: unknown) => err, + ); + expect(isServiceNotRegisteredError(raw)).toBe(true); + await expect( + classifyAdmissionTenancyPosture(() => { throw raw; }), + ).resolves.toBeUndefined(); + } finally { + await kernel.shutdown(); + } + }); + + it('the service is asked EXACTLY ONCE — ⛔ never twice', async () => { + // ⛔ Load-bearing for `rest-server.ts`: asking twice would let a provider + // bound to the LOCAL kernel answer for a request that resolved to another + // environment. A retry inside the shared classification would reintroduce + // that at every seam at once. + let asked = 0; + await classifyAdmissionTenancyPosture(async () => { + asked++; + return { posture: 'single' }; + }); + expect(asked).toBe(1); + + asked = 0; + await classifyAdmissionTenancyPosture(async () => { + asked++; + throw new Error('boom'); + }).catch(() => undefined); + expect(asked).toBe(1); + }); + + it('an ABSENT service resolves quietly — `undefined` and `null` are not faults', async () => { + await expect(classifyAdmissionTenancyPosture(async () => undefined)).resolves.toBeUndefined(); + await expect(classifyAdmissionTenancyPosture(async () => null)).resolves.toBeUndefined(); + }); + + it('the reconciliation is `effectiveTenancyPosture`\'s, not a second reading', async () => { + // ADR-0093 D4/D5: the service's own report, never `OS_TENANCY_POSTURE`. + await expect( + classifyAdmissionTenancyPosture(async () => ({ isolationActive: true })), + ).resolves.toBe('isolated'); + await expect( + classifyAdmissionTenancyPosture(async () => ({ isolationActive: false })), + ).resolves.toBe('single'); + }); +}); + +// --------------------------------------------------------------------------- +// §3 — ⛔ the constraint the card exists to protect: classification ONLY +// --------------------------------------------------------------------------- + +describe('[#16013] §3 — the helper must never learn how to REACH the service', () => { + const SOURCE = readFileSync(new URL('./admission-tenancy-posture.ts', import.meta.url), 'utf8'); + const DECL = 'export async function classifyAdmissionTenancyPosture'; + /** + * The IMPLEMENTATION, sliced BY SYMBOL from its declaration to end of file. + * + * ⚠️ Why a slice and ⛔ not a comment-stripped whole file: the module doc + * NAMES several of the forbidden symbols on purpose — it exists to say why + * they are not here — so a whole-file reading would have to strip comments, + * and a private stripper is its own defect class + * (`pnpm check:comment-mask-adoption`). The slice needs no stripping at all, + * and the test below asserts that fact rather than assuming it. + */ + const IMPL = SOURCE.slice(SOURCE.indexOf(DECL)); + + it('the sliced region really is the implementation, and really is comment-free', () => { + // The reading's own preconditions, measured — an `indexOf` miss would make + // every assertion below run over the WHOLE file and pass for the wrong + // reason (or fail for one). + expect(SOURCE.indexOf(DECL)).toBeGreaterThan(0); + expect(IMPL.startsWith(DECL)).toBe(true); + expect(IMPL).not.toContain('/*'); + expect(IMPL).not.toContain('//'); + }); + + it('names no accessor, no kernel and no context — the resolution stays at each seam', () => { + for (const forbidden of ['getServiceAsync', 'getKernel', 'PluginContext', 'getService(']) { + expect(IMPL).not.toContain(forbidden); + } + // POSITIVE CONTROL for the reading: the real body is inside the slice. + expect(IMPL).toContain('isServiceNotRegisteredError'); + expect(IMPL).toContain('AuthzStoreUnavailableError'); + }); + + it('takes exactly one parameter — a per-seam flag would be the copies with extra steps', () => { + expect(classifyAdmissionTenancyPosture).toHaveLength(1); + }); +}); diff --git a/packages/core/src/security/admission-tenancy-posture.ts b/packages/core/src/security/admission-tenancy-posture.ts new file mode 100644 index 0000000000..a46e8a6647 --- /dev/null +++ b/packages/core/src/security/admission-tenancy-posture.ts @@ -0,0 +1,119 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#16013] The ONE classification an admission door performs on the `tenancy` + * service's rejection — and DELIBERATELY not the resolution that reaches it. + * + * ## What this owns, and the whole reason it is this narrow + * + * Six admission seams each hand-wrote the same three lines: read the posture + * through {@link effectiveTenancyPosture}, and on a rejection tell the + * REGISTRY's two facts apart — + * + * - **never registered** ⇒ branded ({@link isServiceNotRegisteredError}, + * #13905) ⇒ quiet `undefined`. An embedding with no `plugin-auth` is a + * SUPPORTED composition; `resolveAuthzContext` runs both + * posture-conditional API-key refusals (`organization_required`, + * `organization_membership_ended`) ONLY on a present posture, so the quiet + * answer is "run no posture-conditional refusal at all"; + * - **registered and unable to answer** ⇒ unbranded ⇒ + * {@link AuthzStoreUnavailableError}`('tenancy', err)` (ADR-0112 + * `SERVICE_UNAVAILABLE` / 503). The posture is an authorization INPUT, so + * admission was never DECIDED and must not be answered. ⛔ A + * `try { … } catch { undefined }` here is exactly the permissive-on-failure + * defect #13906 decision 1 option A exists to repair: a FAILURE reading as + * "this check does not apply". + * + * That classification is the part nobody may get wrong, and it is the part + * that is genuinely the same everywhere. ⛔ **The RESOLUTION is not.** The + * seams differ irreducibly in how they reach the service and in why a missing + * async accessor must stay quiet: + * + * - `rest-server.ts` branches on **kernel-vs-provider** — two wirings, and + * asking twice would let a provider bound to the LOCAL kernel answer for a + * request that resolved to another environment; + * - four seams read `ctx.getKernel()`; `service-storage` reads an + * already-normalised `StorageGateRegistry` slice (#15169); + * - each seam's "a missing async accessor stays quiet" argument is its OWN. + * `service-storage`'s is that door's declared degrade-to-ungated contract + * (`buildFileReadAuthorizer` already returns `undefined` with no auth + * service or engine); the others' is the `KernelBase`/`LiteKernel` host + * shape. ⛔ They are per-seam justifications, not interchangeable prose. + * + * ⇒ a helper that also owned **how** the service is reached would be wrong for + * some seam or grow a flag per seam — the copies again, with an extra step. + * So the caller keeps its own accessor-presence guard, its own wiring branch + * and its own reason, and hands this function a thunk. + * + * ## Why a THUNK and not an already-resolved service + * + * Measured, not stylistic: the REJECTION is the input this classifies, so the + * resolution has to happen inside this function's `try`. A caller that awaited + * the service first would have to hold a `catch` of its own to get here — and + * a per-seam `catch` is precisely the thing this exists to delete. A thunk + * that throws synchronously is classified identically, because it is invoked + * inside the `try`. + * + * ## ⚠️ The trap: `rethrowAuthzStoreUnavailable` is NOT this + * + * That function is the MIRROR half — it re-raises a brand a net ALREADY holds + * and swallows everything else. This one runs the other direction: it MINTS + * the brand from a raw, unbranded registry rejection. Neither substitutes for + * the other. + * + * ## ⚠️ Why `'tenancy'` is fixed rather than a parameter + * + * This function reads a posture, so it is the tenancy service or it is + * nothing: it returns {@link effectiveTenancyPosture}'s value and nothing else + * would type-check into it. A `object` parameter would only let a caller mint + * the outage brand under a name the read did not use. Other services mint the + * same brand under their own names (`'objectql'`, `'auth_gate'`, + * `resolve-authz-context.ts`'s parameterised `object`) — those are a different + * extraction and ⛔ not this one. + */ + +import type { TenancyPosture } from '@objectstack/spec/security'; + +import { isServiceNotRegisteredError } from '../service-not-registered.js'; + +import { effectiveTenancyPosture, type TenancyPostureSource } from './api-key.js'; +import { AuthzStoreUnavailableError } from './authz-store-unavailable.js'; + +/** + * How a seam reaches its `tenancy` service. Invoked INSIDE the classification's + * `try`, so a synchronous throw and a rejected promise classify identically. + * + * Structural on purpose, exactly as {@link TenancyPostureSource} is: + * `@objectstack/core` must not depend on the plugin that provides the service. + */ +export type TenancyServiceResolver = () => + | Promise + | TenancyPostureSource + | undefined + | null; + +/** + * Classify a `tenancy` read into the admission posture, or into the loud + * outage — #13906 decision 1 option A, in one place. + * + * @param resolveTenancyService How THIS seam reaches the service. The caller + * owns the wiring fact (is there a kernel? an async accessor? a provider?) + * and answers `undefined` itself when its own wiring is absent; this + * function is only reached once the seam has decided to ask. + * @returns The effective posture, or `undefined` for the supported + * no-tenancy composition. + * @throws {AuthzStoreUnavailableError} for every rejection that is NOT the + * registry's branded "never registered". + */ +export async function classifyAdmissionTenancyPosture( + resolveTenancyService: TenancyServiceResolver, +): Promise { + try { + return effectiveTenancyPosture(await resolveTenancyService()); + } catch (err) { + if (!isServiceNotRegisteredError(err)) { + throw new AuthzStoreUnavailableError('tenancy', err); + } + return undefined; + } +} diff --git a/packages/core/src/security/index.ts b/packages/core/src/security/index.ts index f0258c3ac0..2167370846 100644 --- a/packages/core/src/security/index.ts +++ b/packages/core/src/security/index.ts @@ -107,6 +107,17 @@ export { type TenancyPostureSource, } from './api-key.js'; +// [#16013] The ONE try/catch CLASSIFICATION every admission seam performs on +// the `tenancy` read that feeds `effectiveTenancyPosture` above -- branded +// "never registered" stays quiet, every other rejection is the ADR-0112 outage +// (#13906 decision 1 option A). The RESOLUTION deliberately stays at each seam: +// how the service is reached, and why a missing async accessor stays quiet +// there, are per-seam facts a shared owner would have to erase or flag. +export { + classifyAdmissionTenancyPosture, + type TenancyServiceResolver, +} from './admission-tenancy-posture.js'; + // [#13279] The LOUD failure an unreachable permission store raises, and the // brand predicate a fail-closed `catch` uses to re-raise it instead of // degrading an outage into a capability denial. Ruled 2026-08-30. diff --git a/packages/plugins/plugin-sharing/src/sharing-plugin.ts b/packages/plugins/plugin-sharing/src/sharing-plugin.ts index 8d17bf6064..c8cc0016a9 100644 --- a/packages/plugins/plugin-sharing/src/sharing-plugin.ts +++ b/packages/plugins/plugin-sharing/src/sharing-plugin.ts @@ -4,15 +4,15 @@ import type { Plugin, PluginContext } from '@objectstack/core'; import { resolveAuthzContext, isAuthzStoreUnavailableError, - // [#15349] The three symbols the ADMISSION posture read needs, and nothing - // more. `effectiveTenancyPosture` reads the posture IN FORCE off the - // `tenancy` service (ADR-0093 D4/D5: a deployment that REQUESTS `isolated` - // without the enterprise organizations runtime is `single` in force), while - // `isServiceNotRegisteredError` / `AuthzStoreUnavailableError` are the two - // halves of #13906 decision 1 option A's classification. - effectiveTenancyPosture, - isServiceNotRegisteredError, - AuthzStoreUnavailableError, + // [#16013] The ADMISSION posture read is ONE call now: the shared + // classification owns #13906 decision 1 option A (branded "never registered" + // stays quiet, every other rejection is the ADR-0112 outage) and reads the + // posture IN FORCE off the `tenancy` service (ADR-0093 D4/D5: a deployment + // that REQUESTS `isolated` without the enterprise organizations runtime is + // `single` in force). What stays HERE is the wiring fact -- see + // `resolveAdmissionTenancyPosture` for why this seam's quiet answer is its + // own argument and not the helper's. + classifyAdmissionTenancyPosture, type TenancyPostureSource, } from '@objectstack/core'; import type { EngineMiddleware, OperationContext } from '@objectstack/objectql'; @@ -525,17 +525,21 @@ export class SharingServicePlugin implements Plugin { * (`undefined` means "run no posture-conditional refusal at all", while * `'single'` is a posture that is present and simply enforces no wall). * - * ## The classification, #13906 decision 1 option A + * ## The classification — #13906 decision 1 option A, no longer written here * - * - **Never registered** ⇒ branded (`isServiceNotRegisteredError`), quiet - * `undefined`. An embedding with no `plugin-auth` is a SUPPORTED - * composition and its behaviour here is exactly what it was. - * - **Registered and unable to answer** ⇒ `AuthzStoreUnavailableError` - * (ADR-0112 `SERVICE_UNAVAILABLE` / 503). Admission was never DECIDED, so - * it must not be answered. `verifiedContextFromRequest`'s `catch` already - * re-raises this brand rather than laundering it into a 401 (#13279), and - * the routes' own `catch` answers `err.status` — so the outage reaches the - * wire as a 503. + * [#16013] `classifyAdmissionTenancyPosture` (`@objectstack/core`) owns the + * branded/unbranded decision for every admission seam: never registered ⇒ + * quiet `undefined`; every other rejection ⇒ `AuthzStoreUnavailableError` + * (ADR-0112 `SERVICE_UNAVAILABLE` / 503), because admission was never DECIDED + * and must not be answered. One tested classification, so it cannot decay + * into a silent `catch` at one seam while five others stay correct. + * + * ⚠️ What that decision MEANS at this door is still this door's own. Quiet + * arm: an embedding with no `plugin-auth` is a SUPPORTED composition and its + * behaviour here is exactly what it was. Loud arm: + * `verifiedContextFromRequest`'s `catch` already re-raises this brand rather + * than laundering it into a 401 (#13279), and the routes' own `catch` answers + * `err.status` — so the outage reaches the wire as a 503. * * ⚠️ The brand exists only on the ASYNC resolution path: `PluginContext.getService` * throws two UNBRANDED plain `Error`s (`… not found` and `… is async - use @@ -558,16 +562,9 @@ export class SharingServicePlugin implements Plugin { | { getServiceAsync?: (name: string, scopeId?: string) => Promise } | undefined; if (!kernel || typeof kernel.getServiceAsync !== 'function') return undefined; - try { - return effectiveTenancyPosture( - await kernel.getServiceAsync('tenancy'), - ); - } catch (err) { - if (!isServiceNotRegisteredError(err)) { - throw new AuthzStoreUnavailableError('tenancy', err); - } - return undefined; - } + return classifyAdmissionTenancyPosture(() => + kernel.getServiceAsync!('tenancy'), + ); }; async init(ctx: PluginContext): Promise { diff --git a/packages/rest/src/execctx-authz-input-seam-reachability.test.ts b/packages/rest/src/execctx-authz-input-seam-reachability.test.ts index c40baf9d81..1186a3cfa0 100644 --- a/packages/rest/src/execctx-authz-input-seam-reachability.test.ts +++ b/packages/rest/src/execctx-authz-input-seam-reachability.test.ts @@ -168,10 +168,35 @@ describe('[#13906] §0 — the two seams are LIVE on today\'s tree, by symbol', // expect(body).toMatch(/catch\s*\{\s*\n\s*tenancyPosture = undefined;/); // i.e. EVERY rejection became `undefined`. It now must not match, because // only the branded not-registered rejection may take that path. - expect(body).toMatch(/tenancyPosture = effectiveTenancyPosture\(await kernel\.getServiceAsync\('tenancy'\)/); expect(body).not.toMatch(/catch\s*\{\s*\n\s*tenancyPosture = undefined;/); - // The discriminator is the REGISTRY's brand, never message text (#13905). - expect(body).toMatch(/if \(!isServiceNotRegisteredError\(err\)\) \{\s*\n\s*throw new AuthzStoreUnavailableError\('tenancy', err\);/); + // SUPERSEDED PINS, quoted for the same reason. [#16013] folded the + // classification onto ONE shared function (`classifyAdmissionTenancyPosture`, + // @objectstack/core), so the two hand-written copies these matched are gone + // from this file: + // expect(body).toMatch(/tenancyPosture = effectiveTenancyPosture\(await kernel\.getServiceAsync\('tenancy'\)/); + // expect(body).toMatch(/if \(!isServiceNotRegisteredError\(err\)\) \{\s*\n\s*throw new AuthzStoreUnavailableError\('tenancy', err\);/); + // expect(body).toMatch(/tenancyPosture = effectiveTenancyPosture\(\s*\n?\s*await this\.tenancyServiceProvider\(environmentId\)/); + // ⭐ RE-AIMED, not deleted. What this pin is ABOUT is unchanged: BOTH + // wirings classify, and neither absorbs. The classification's own two + // directions (branded ⇒ quiet, unbranded ⇒ loud) are now pinned where the + // decision lives — `packages/core/src/security/admission-tenancy-posture.test.ts` + // — and the behavioural §2/§3 drives below still measure this file's wire + // answer end to end. What stays THIS file's to hold is that each branch + // REACHES the shared classification, and that neither grew a `catch` of its + // own again. The discriminator is still the REGISTRY's brand, never message + // text (#13905); it is asserted at its new home. + expect(body).toMatch(/tenancyPosture = await classifyAdmissionTenancyPosture\(\s*\n?\s*\(\) => kernel\.getServiceAsync\('tenancy'\)/); + expect(body).toMatch(/tenancyPosture = await classifyAdmissionTenancyPosture\(\s*\n?\s*\(\) => this\.tenancyServiceProvider!\(environmentId\)/); + // ⛔ NARROWNESS CONTROL for the fold: the seam region itself holds NO + // `catch`. A local `catch` reappearing here is exactly the silent-`catch` + // degradation #13906 decision 1 option A forbids, and it would be invisible + // to the two delegation pins above. + const tenancySeam = body.slice( + body.indexOf('let tenancyPosture;'), + body.indexOf('const authz = await resolveAuthzContext('), + ); + expect(tenancySeam.length).toBeGreaterThan(0); + expect(tenancySeam).not.toMatch(/catch/); // ⛔ And the WIRING fact is asked of `kernel`'s presence AND of the async // accessor's — never inferred from the returned value (the #13476 // discipline this repair inherits). The accessor half matters on its own: @@ -184,7 +209,6 @@ describe('[#13906] §0 — the two seams are LIVE on today\'s tree, by symbol', // block, so on the single-kernel wiring `tenancyPosture` stayed the // declaration's `undefined` and no refusal could fire. expect(body).toMatch(/\} else if \(this\.tenancyServiceProvider\) \{/); - expect(body).toMatch(/tenancyPosture = effectiveTenancyPosture\(\s*\n?\s*await this\.tenancyServiceProvider\(environmentId\)/); }); it('[#15256 / 1A] the withdrawn B-prime BOOT refusal is no longer cited as this seam\'s remedy', () => { diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index f384ecf5d4..9b5f473315 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -9,11 +9,12 @@ import { // be RESOLVED leaves the caller's permissions equally undetermined, so it // takes the same loud answer rather than the quiet 403 it used to wear. AuthzStoreUnavailableError, - effectiveTenancyPosture, - // [#13906] The REGISTRY's own "never registered" brand — the discriminator - // that lets the tenancy seam absorb the supported no-tenancy composition - // while every other rejection stays loud. Never message text (#13905). - isServiceNotRegisteredError, + // [#13906 / #16013] The ONE classification the tenancy seam applies on + // BOTH of its wirings: the REGISTRY's own "never registered" brand absorbs + // the supported no-tenancy composition (never message text, #13905) while + // every other rejection stays loud. ⛔ The two wirings themselves are NOT + // the helper's — see `computeExecCtx`. + classifyAdmissionTenancyPosture, assembleExecutionContext, normalizeAuthGate, type AuthGate, shouldDenyAnonymous, ANONYMOUS_DENY_BODY, ANONYMOUS_DENY_STATUS, // [#7678] ADR-0090 D5/D9 suggested-binding `?status=` vocabulary — the one @@ -2788,22 +2789,23 @@ export class RestServer { // factories (`registerServiceFactory` throws "not supported"), so // absence is the only fault it could report anyway. It keeps the // previous quiet answer, unchanged. + // + // [#16013] The CLASSIFICATION below is one shared function, not two + // hand-written copies: `classifyAdmissionTenancyPosture` answers + // quiet `undefined` for the branded "never registered" (the + // supported no-tenancy composition, no posture-conditional refusal) + // and raises `AuthzStoreUnavailableError('tenancy', err)` for every + // other rejection — the same loud answer `wiredEngineOrLoud` gives + // the engine seam, carried to the door by the same nets, because + // the posture is an authorization INPUT and admission was never + // decided. ⛔ The WIRING branch is NOT shared and must not become + // so: which of the two wirings may be asked is this file's fact + // alone, for the reason spelled out in the `else if` below. let tenancyPosture; if (kernel && typeof kernel.getServiceAsync === 'function') { - try { - tenancyPosture = effectiveTenancyPosture(await kernel.getServiceAsync('tenancy') as any); - } catch (err) { - // Registered and unable to answer. The posture is an - // authorization INPUT, so admission was never decided — the - // same loud answer `wiredEngineOrLoud` gives the engine seam, - // carried to the door by the same nets. - if (!isServiceNotRegisteredError(err)) { - throw new AuthzStoreUnavailableError('tenancy', err); - } - // Never registered ⇒ the supported no-tenancy composition: - // quiet `undefined`, no posture-conditional refusal. - tenancyPosture = undefined; - } + tenancyPosture = await classifyAdmissionTenancyPosture( + () => kernel.getServiceAsync('tenancy') as any, + ); } else if (this.tenancyServiceProvider) { // [#15256 / 1A] The SINGLE-KERNEL branch — the wiring every // deployment the open core builds actually runs, and the one @@ -2821,16 +2823,9 @@ export class RestServer { // rejection is the outage it is. The provider re-raises // unbranded rejections for precisely that reason — see // `rest-api-plugin.ts`. - try { - tenancyPosture = effectiveTenancyPosture( - await this.tenancyServiceProvider(environmentId) as any, - ); - } catch (err) { - if (!isServiceNotRegisteredError(err)) { - throw new AuthzStoreUnavailableError('tenancy', err); - } - tenancyPosture = undefined; - } + tenancyPosture = await classifyAdmissionTenancyPosture( + () => this.tenancyServiceProvider!(environmentId) as any, + ); } const authz = await resolveAuthzContext({ ql, headers, getSession, tenancyPosture }); // [#6216] The anonymous contract IS the shared assembler's default diff --git a/packages/services/service-datasource/src/admin-routes.ts b/packages/services/service-datasource/src/admin-routes.ts index a978a47f95..06b97640a5 100644 --- a/packages/services/service-datasource/src/admin-routes.ts +++ b/packages/services/service-datasource/src/admin-routes.ts @@ -12,15 +12,14 @@ import { ANONYMOUS_DENY_STATUS, ANONYMOUS_DENY_CODE, ANONYMOUS_DENY_MESSAGE, - // [#15350] The four symbols the admission posture read needs, and nothing - // more. `effectiveTenancyPosture` reads the posture IN FORCE off the - // `tenancy` service (ADR-0093 D4/D5: a deployment REQUESTING `isolated` - // without the enterprise organizations runtime is `single` in force), while - // `isServiceNotRegisteredError` / `AuthzStoreUnavailableError` are the two - // halves of #13906 decision 1 option A's classification. - effectiveTenancyPosture, - isServiceNotRegisteredError, - AuthzStoreUnavailableError, + // [#16013] The admission posture read is ONE call now. The shared + // classification owns #13906 decision 1 option A (branded "never registered" + // stays quiet, every other rejection is the ADR-0112 outage) and reads the + // posture IN FORCE off the `tenancy` service (ADR-0093 D4/D5: a deployment + // REQUESTING `isolated` without the enterprise organizations runtime is + // `single` in force). The WIRING fact stays here -- see + // `resolveAdmissionTenancyPosture` below. + classifyAdmissionTenancyPosture, type TenancyPostureSource, } from '@objectstack/core'; import type { TenancyPosture } from '@objectstack/spec/security'; @@ -397,17 +396,18 @@ export function registerDatasourceAdminRoutes( * membership manages this deployment's datasources — not a cross-organization * row read. Less severe than the REST data door (#15256); not correct. * - * ## The classification — #13906 decision 1 option A + * ## The classification — #13906 decision 1 option A, no longer written here * - * - **Never registered** ⇒ branded (`isServiceNotRegisteredError`), quiet - * `undefined`. An embedding with no `plugin-auth` is a SUPPORTED - * composition, and its behaviour here is exactly what it was. - * - **Registered and unable to answer** ⇒ `AuthzStoreUnavailableError` - * (ADR-0112 `SERVICE_UNAVAILABLE`). The posture is an authorization INPUT, - * so admission was never DECIDED and must not be answered. A - * `try { … } catch { undefined }` here would re-introduce precisely the - * permissive-on-failure defect #13906 exists to repair — a FAILURE reading - * as "this check does not apply". + * [#16013] `classifyAdmissionTenancyPosture` (`@objectstack/core`) owns the + * branded/unbranded decision for every admission seam: never registered ⇒ + * quiet `undefined` (an embedding with no `plugin-auth` is a SUPPORTED + * composition, and its behaviour here is exactly what it was); every other + * rejection ⇒ `AuthzStoreUnavailableError` (ADR-0112 `SERVICE_UNAVAILABLE`), + * because the posture is an authorization INPUT and admission was never + * DECIDED. ⛔ A `try { … } catch { undefined }` at any seam would re-introduce + * precisely the permissive-on-failure defect #13906 exists to repair — a + * FAILURE reading as "this check does not apply" — which is why the decision + * is one tested function rather than six hand-written copies. * * The throw is raised inside `requireDatasourceAdmin`'s own `try`, so it * takes the relay that block already runs for the identical fault one seam @@ -431,26 +431,19 @@ export function registerDatasourceAdminRoutes( * (`registerServiceFactory` throws "not supported"), so absence is the only * fault it could report. It keeps the quiet answer, unchanged. * - * ⛔ Deliberately NOT extracted into a shared helper. Three sibling cards - * (#15349, #15351, #15352) are live on this same seam in other packages; a - * helper extracted by one of the four collides with the other three. The - * extraction is worth doing — once, as its own card, after they land. + * ⛔ And that reason is why the RESOLUTION above stays here while the + * classification moved (#16013): the shared helper is handed an already- + * decided way to reach the service, never the decision of whether this host + * shape can be asked at all. */ const resolveAdmissionTenancyPosture = async (): Promise => { const kernel = ctx.getKernel?.() as | { getServiceAsync?: (name: string, scopeId?: string) => Promise } | undefined; if (!kernel || typeof kernel.getServiceAsync !== 'function') return undefined; - try { - return effectiveTenancyPosture( - await kernel.getServiceAsync('tenancy'), - ); - } catch (err) { - if (!isServiceNotRegisteredError(err)) { - throw new AuthzStoreUnavailableError('tenancy', err); - } - return undefined; - } + return classifyAdmissionTenancyPosture(() => + kernel.getServiceAsync!('tenancy'), + ); }; const requireDatasourceAdmin = async (req: any, res: any): Promise => { diff --git a/packages/services/service-settings/src/settings-service-plugin.ts b/packages/services/service-settings/src/settings-service-plugin.ts index 0946b2f9b5..3fed0dbdba 100644 --- a/packages/services/service-settings/src/settings-service-plugin.ts +++ b/packages/services/service-settings/src/settings-service-plugin.ts @@ -4,13 +4,12 @@ import type { Plugin, PluginContext } from '@objectstack/core'; import { resolveAuthzContext, isAuthzStoreUnavailableError, - // [#15351] The posture-derivation trio. `effectiveTenancyPosture` reads the - // posture IN FORCE off the `tenancy` service (never the REQUESTED one an env - // var asks for); the other two carry decision-1-option-A's classification - // (#13906) at this seam — see `resolveAdmissionTenancyPosture`. - effectiveTenancyPosture, - isServiceNotRegisteredError, - AuthzStoreUnavailableError, + // [#16013] The posture derivation, in ONE call. It reads the posture IN + // FORCE off the `tenancy` service (never the REQUESTED one an env var asks + // for) and carries decision-1-option-A's classification (#13906) for every + // admission seam at once -- see `resolveAdmissionTenancyPosture` below for + // the half that stays this seam's own. + classifyAdmissionTenancyPosture, type TenancyPostureSource, } from '@objectstack/core'; import type { TenancyPosture } from '@objectstack/spec/security'; @@ -340,17 +339,21 @@ export class SettingsServicePlugin implements Plugin { * there. The posture the guards must see is the one the `tenancy` service * reports, which is what {@link effectiveTenancyPosture} reads. * - * ## The classification, decision 1 option A (#13906) + * ## The classification, decision 1 option A (#13906) — no longer written here * - * - **Never registered** ⇒ branded (`isServiceNotRegisteredError`), quiet - * `undefined`. A lean embedding with no `plugin-auth` is a SUPPORTED - * composition; behaviour there is exactly what it was. - * - **Registered and unable to answer** ⇒ `AuthzStoreUnavailableError` - * (ADR-0112 `SERVICE_UNAVAILABLE` / 503). Admission was never DECIDED, so - * it must not be answered. ⛔ A `try { … } catch { undefined }` here - * re-introduces exactly the permissive-on-failure defect #13906 exists to - * repair — a failure reading as "this check does not apply". The caller's - * own `catch` re-raises this brand rather than degrading it to a denial. + * [#16013] `classifyAdmissionTenancyPosture` (`@objectstack/core`) owns it + * for every admission seam: never registered ⇒ branded ⇒ quiet `undefined`; + * every other rejection ⇒ `AuthzStoreUnavailableError` (ADR-0112 + * `SERVICE_UNAVAILABLE` / 503), because admission was never DECIDED and must + * not be answered. ⛔ A `try { … } catch { undefined }` at any seam + * re-introduces exactly the permissive-on-failure defect #13906 exists to + * repair — a failure reading as "this check does not apply" — and six copies + * of that decision were six chances to write it. + * + * ⚠️ At THIS door: a lean embedding with no `plugin-auth` is a SUPPORTED + * composition, so behaviour there is exactly what it was; and on the loud arm + * the caller's own `catch` re-raises the brand rather than degrading it to a + * denial. * * ⚠️ The brand exists only on the ASYNC resolution path: `PluginContext.getService` * throws two UNBRANDED plain `Error`s (`… not found` and `… is async - use @@ -366,9 +369,10 @@ export class SettingsServicePlugin implements Plugin { * so absence is the only fault it could report anyway. It keeps the quiet * answer, unchanged. * - * ⛔ Deliberately NOT extracted into a shared helper: sibling repairs are in - * flight on the same seam across other packages, and this file's copy is the - * precedent set by `@objectstack/cloud-connection`'s install-local door. + * ⛔ That argument is exactly why the RESOLUTION above did NOT move when the + * classification did (#16013): the shared helper is handed a decided way to + * reach the service; whether this host shape may be asked at all is a fact + * only this seam holds. */ private async resolveAdmissionTenancyPosture( ctx: PluginContext, @@ -377,16 +381,9 @@ export class SettingsServicePlugin implements Plugin { | { getServiceAsync?: (name: string, scopeId?: string) => Promise } | undefined; if (!kernel || typeof kernel.getServiceAsync !== 'function') return undefined; - try { - return effectiveTenancyPosture( - await kernel.getServiceAsync('tenancy'), - ); - } catch (err) { - if (!isServiceNotRegisteredError(err)) { - throw new AuthzStoreUnavailableError('tenancy', err); - } - return undefined; - } + return classifyAdmissionTenancyPosture(() => + kernel.getServiceAsync!('tenancy'), + ); } /** diff --git a/packages/services/service-storage/src/storage-service-plugin.ts b/packages/services/service-storage/src/storage-service-plugin.ts index c1f2cab61d..61eb9f1115 100644 --- a/packages/services/service-storage/src/storage-service-plugin.ts +++ b/packages/services/service-storage/src/storage-service-plugin.ts @@ -4,15 +4,13 @@ import type { Plugin, PluginContext } from '@objectstack/core'; import { resolveAuthzContext, isAuthzStoreUnavailableError, - // [#15352] The three symbols the download door's tenancy-posture read is - // built from: the reader for the posture IN FORCE (ADR-0093 D4/D5 - a - // deployment REQUESTING `isolated` without the enterprise organizations - // runtime is `single` in force), plus the two halves of the classification - // #13906 decision 1 option A requires - the registry's "never registered" - // brand, and the loud outage every other rejection has to become. - effectiveTenancyPosture, - isServiceNotRegisteredError, - AuthzStoreUnavailableError, + // [#16013] The download door's tenancy-posture read, in ONE call: the + // posture IN FORCE (ADR-0093 D4/D5 - a deployment REQUESTING `isolated` + // without the enterprise organizations runtime is `single` in force), taken + // through the one shared classification #13906 decision 1 option A requires + // - the registry's "never registered" brand stays quiet, and the loud outage + // is what every other rejection becomes. + classifyAdmissionTenancyPosture, type TenancyPostureSource, } from '@objectstack/core'; import type { @@ -1001,18 +999,20 @@ function buildAuthSessionResolver( * be judged by the ownership and record-reachability checks below — checks * evaluated for a principal the wall should have refused at the door. * - * ## The classification — #13906 decision 1 option A + * ## The classification — #13906 decision 1 option A, no longer written here * - * - **Never registered** ⇒ branded (`isServiceNotRegisteredError`) ⇒ a quiet - * `undefined`. A kernel assembled without `plugin-auth` registers no - * `tenancy` service and enforces no organization wall, so there is nothing - * for a key to be walled out of; that composition is SUPPORTED and its - * behaviour here is exactly what it was. - * - **Registered and unable to answer** ⇒ `AuthzStoreUnavailableError`. The - * posture is an authorization INPUT, so admission was never DECIDED and must - * not be answered. A `try { … } catch { undefined }` here would re-introduce - * precisely the permissive-on-failure defect #13906 exists to repair — a - * FAILURE reading as "this check does not apply". + * [#16013] `classifyAdmissionTenancyPosture` (`@objectstack/core`) owns it for + * every admission seam: never registered ⇒ branded ⇒ a quiet `undefined`; + * every other rejection ⇒ `AuthzStoreUnavailableError`, because the posture is + * an authorization INPUT, so admission was never DECIDED and must not be + * answered. ⛔ A `try { … } catch { undefined }` at any seam would re-introduce + * precisely the permissive-on-failure defect #13906 exists to repair — a + * FAILURE reading as "this check does not apply". + * + * ⚠️ The quiet arm's MEANING is this door's own: a kernel assembled without + * `plugin-auth` registers no `tenancy` service and enforces no organization + * wall, so there is nothing for a key to be walled out of; that composition is + * SUPPORTED and its behaviour here is exactly what it was. * * The throw is raised inside the authorizer's own `try`, so it takes the * #13279 relay that block already runs for the identical fault one seam over @@ -1069,24 +1069,19 @@ function buildAuthSessionResolver( * for the life of the process. The read costs two registry lookups and no I/O, * so there is nothing to buy by caching it. * - * ⛔ Deliberately NOT extracted into a shared helper. Sibling cards are live on - * this same seam in other packages (#15349, #15350, #15351), and a helper - * extracted by one of them collides with the rest; the landed siblings - * (`mcp`, `cloud-connection`) each wrote a local copy for the same reason. The - * extraction is worth doing — once, as its own card, after they land. + * ⛔ And this door's degrade-to-ungated reason is precisely why the RESOLUTION + * stayed here when the classification was folded (#16013): a shared owner of + * the resolution would have had to erase that reason or carry a flag for it. + * The helper receives the already-decided way to reach the service and nothing + * else. */ async function resolveAdmissionTenancyPosture( registry: StorageGateRegistry, ): Promise { if (typeof registry.getServiceAsync !== 'function') return undefined; - try { - return effectiveTenancyPosture(await registry.getServiceAsync('tenancy')); - } catch (err) { - if (!isServiceNotRegisteredError(err)) { - throw new AuthzStoreUnavailableError('tenancy', err); - } - return undefined; - } + return classifyAdmissionTenancyPosture(() => + registry.getServiceAsync!('tenancy'), + ); } /** diff --git a/packages/spec/src/system/compliance-families-retirement.test.ts b/packages/spec/src/system/compliance-families-retirement.test.ts index bab3733479..c3019404d6 100644 --- a/packages/spec/src/system/compliance-families-retirement.test.ts +++ b/packages/spec/src/system/compliance-families-retirement.test.ts @@ -369,6 +369,54 @@ describe('[#15513] tree-scoped absence: nothing inside the declared radius refer '.changeset/', ]; + /** + * Build detritus that lands INSIDE a walked directory, so `SKIPPED_DIRS` + * cannot reach it. + * + * tsup bundles `tsup.config.ts` to `tsup.config.bundled_.mjs` beside + * it, loads it, and deletes it. `.gitignore` already declares the class + * (`*.bundled_*.mjs`), so it is not an authored source and never was in this + * pin's radius — but the walk is a FILESYSTEM walk, not a git walk, so it + * enumerated it anyway. That cost two different wrong answers, both + * non-deterministic and neither about a retirement: + * + * - a CRASH. `test:repo` dependsOn `["^build"]` — UPSTREAM builds only, never + * its own package's — so `@objectstack/spec#build` runs CONCURRENTLY with + * this walk, and `readdirSync` then `readFileSync` is not atomic: the file + * is enumerated, tsup deletes it, the read raises `ENOENT` and the whole + * leg errors. Measured on CI (`Test Core (1/6)`, run 34327949045) as + * `ENOENT ... open 'packages/spec/tsup.config.bundled_8xzodswt4ct.mjs'` at + * the `readFileSync` below. + * - a PHANTOM OFFENDER, had the config ever named a retired symbol: the + * bundle is a copy of `tsup.config.ts`, which this walk ALREADY reads, so + * the copy could only ever report the original twice — under a filename + * that changes every run. + * + * ⛔ Excluding it removes NO coverage for exactly that reason, and the + * anti-vacuity controls below hold the claim rather than asserting it. + */ + const TSUP_BUNDLED_CONFIG = /\.bundled_[^./]+\.mjs$/; + + /** + * Read a path the walk enumerated, tolerating ONLY its disappearance. + * + * A path that no longer exists cannot be a reference that SURVIVES in the + * tree, which is the whole of what this pin asserts — so `ENOENT` is the one + * fault that is not a finding. ⛔ Every other read failure is re-raised: a + * blanket `catch` here would turn an unreadable tree into a silent green, + * which is the failure mode this file exists to prevent one level up. + */ + const vanished: string[] = []; + const readIfPresent = (full: string, rel: string): string | undefined => { + try { + return fs.readFileSync(full, 'utf-8'); + } catch (err) { + if ((err as NodeJS.ErrnoException)?.code !== 'ENOENT') throw err; + vanished.push(rel); + return undefined; + } + }; + it('the matcher recognises a reference and ignores a prose mention (anti-vacuity)', () => { expect(REFERENCE.test("import { IncidentResponsePolicySchema } from './incident-response.zod';")).toBe(true); expect(REFERENCE.test('const x: TrainingCourse = {};')).toBe(true); @@ -380,6 +428,41 @@ describe('[#15513] tree-scoped absence: nothing inside the declared radius refer expect(REFERENCE.test('MetadataChangeTypeSchema')).toBe(false); // the live near-namesake }); + it('the build-detritus exclusion is NARROW — it names tsup\'s bundle and nothing authored', () => { + // The spelling tsup actually writes (the CI failure's own filename), plus + // the shape with any other random suffix. + expect(TSUP_BUNDLED_CONFIG.test('tsup.config.bundled_8xzodswt4ct.mjs')).toBe(true); + expect(TSUP_BUNDLED_CONFIG.test('tsup.config.bundled_abc123.mjs')).toBe(true); + // ⛔ NARROWNESS: it must not reach an authored file. If this ever widens, + // the walk silently stops covering real sources and this pin goes quiet. + expect(TSUP_BUNDLED_CONFIG.test('tsup.config.ts')).toBe(false); + expect(TSUP_BUNDLED_CONFIG.test('index.mjs')).toBe(false); + expect(TSUP_BUNDLED_CONFIG.test('js-comment-mask.mjs')).toBe(false); + expect(TSUP_BUNDLED_CONFIG.test('bundled_thing.mjs')).toBe(false); + // …and the ORIGINAL it is a copy of stays in the radius, which is why + // excluding the copy costs no coverage. + expect(EXCLUDED.has('packages/spec/tsup.config.ts')).toBe(false); + expect(EXCLUDED_PREFIXES.some((p) => 'packages/spec/tsup.config.ts'.startsWith(p))).toBe(false); + }); + + it('a path that VANISHES mid-walk is not a finding, and every other read fault still is', () => { + const before = vanished.length; + // The exact fault CI hit: enumerated, then gone before the read. + const gone = path.join(REPO_ROOT, 'packages/spec/does-not-exist.bundled_probe.mjs'); + expect(fs.existsSync(gone)).toBe(false); + expect(readIfPresent(gone, 'probe/gone')).toBeUndefined(); + expect(vanished.slice(before)).toEqual(['probe/gone']); + // POSITIVE CONTROL: a path that IS there is read, so the guard cannot be + // passing by refusing to read anything. + const present = fileURLToPath(import.meta.url); + expect(readIfPresent(present, THIS_FILE)).toContain('tree-scoped absence'); + expect(vanished.length).toBe(before + 1); + // ⛔ And a NON-ENOENT fault is re-raised, never swallowed: reading a + // DIRECTORY raises EISDIR on Linux, so this is a real second fault class. + expect(() => readIfPresent(path.join(REPO_ROOT, 'packages/spec'), 'probe/dir')).toThrow(); + expect(vanished.length).toBe(before + 1); + }); + it('no reference survives inside the declared radius outside the retirement kit', () => { const offenders: string[] = []; let visited = 0; @@ -397,8 +480,10 @@ describe('[#15513] tree-scoped absence: nothing inside the declared radius refer if (!(rel.startsWith('examples/') ? EXAMPLES_EXT : SCANNED_EXT).has(ext)) continue; if (entry.name === 'CHANGELOG.md') continue; // release prose records the removal if (EXCLUDED.has(rel) || EXCLUDED_PREFIXES.some((p) => rel.startsWith(p))) continue; + if (TSUP_BUNDLED_CONFIG.test(entry.name)) continue; visited += 1; - const text = fs.readFileSync(full, 'utf-8'); + const text = readIfPresent(full, rel); + if (text === undefined) continue; const m = REFERENCE.exec(text); if (m) offenders.push(`${rel} references \`${m[0].trim()}\``); }