From 5a23522695fea4ff32e44f6809e4e5cd3394335d Mon Sep 17 00:00:00 2001 From: os-musk Date: Wed, 9 Sep 2026 06:48:57 +0000 Subject: [PATCH 1/5] refactor(core): fold the six admission tenancy-posture classifications onto one helper The classification (branded "never registered" stays quiet, every other rejection becomes AuthzStoreUnavailableError('tenancy', err)) was hand-written at six admission seams. Any one of them degrading into a silent `catch` re-opens #13906. It is now `classifyAdmissionTenancyPosture` in @objectstack/core, with its own two-direction pins. The RESOLUTION deliberately did not move: rest-server's kernel-vs-provider branch, each seam's accessor-presence guard, and each seam's own reason why a missing async accessor stays quiet are per-seam facts a shared owner would have had to erase or carry a flag for. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg --- .../src/marketplace-install-local-plugin.ts | 48 ++-- .../admission-tenancy-posture.test.ts | 267 ++++++++++++++++++ .../src/security/admission-tenancy-posture.ts | 119 ++++++++ packages/core/src/security/index.ts | 11 + .../plugin-sharing/src/sharing-plugin.ts | 55 ++-- ...cctx-authz-input-seam-reachability.test.ts | 32 ++- packages/rest/src/rest-server.ts | 53 ++-- .../service-datasource/src/admin-routes.ts | 59 ++-- .../src/settings-service-plugin.ts | 57 ++-- .../src/storage-service-plugin.ts | 61 ++-- 10 files changed, 577 insertions(+), 185 deletions(-) create mode 100644 packages/core/src/security/admission-tenancy-posture.test.ts create mode 100644 packages/core/src/security/admission-tenancy-posture.ts 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..7df997bc1c --- /dev/null +++ b/packages/core/src/security/admission-tenancy-posture.test.ts @@ -0,0 +1,267 @@ +// 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'); + /** Source minus block comments — the doc names these symbols on purpose. */ + const CODE = SOURCE.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, ''); + + it('names no accessor, no kernel and no context — the resolution stays at each seam', () => { + for (const forbidden of ['getServiceAsync', 'getKernel', 'PluginContext', 'getService(']) { + expect(CODE).not.toContain(forbidden); + } + // POSITIVE CONTROL for the reading: the stripper left the real code behind. + expect(CODE).toContain('classifyAdmissionTenancyPosture'); + expect(CODE).toContain('isServiceNotRegisteredError'); + }); + + 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..274ba44754 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 e546203be8..d94bb9e3a9 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'), + ); } /** From 2f82156459e651c333473aa35371f3a5b4f46cf6 Mon Sep 17 00:00:00 2001 From: os-musk Date: Wed, 9 Sep 2026 07:03:09 +0000 Subject: [PATCH 2/5] chore: changeset for the admission tenancy-posture classification fold Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg --- ...dmission-tenancy-posture-classification.md | 59 +++++++++++++++++++ ...cctx-authz-input-seam-reachability.test.ts | 2 +- packages/rest/src/rest-server.ts | 2 +- 3 files changed, 61 insertions(+), 2 deletions(-) create mode 100644 .changeset/fold-admission-tenancy-posture-classification.md 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/rest/src/execctx-authz-input-seam-reachability.test.ts b/packages/rest/src/execctx-authz-input-seam-reachability.test.ts index 274ba44754..1186a3cfa0 100644 --- a/packages/rest/src/execctx-authz-input-seam-reachability.test.ts +++ b/packages/rest/src/execctx-authz-input-seam-reachability.test.ts @@ -185,7 +185,7 @@ describe('[#13906] §0 — the two seams are LIVE on today\'s tree, by symbol', // 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*\(\) => 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` diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index d94bb9e3a9..2ae215ce78 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -2804,7 +2804,7 @@ export class RestServer { let tenancyPosture; if (kernel && typeof kernel.getServiceAsync === 'function') { tenancyPosture = await classifyAdmissionTenancyPosture( - () => kernel.getServiceAsync!('tenancy') as any, + () => kernel.getServiceAsync('tenancy') as any, ); } else if (this.tenancyServiceProvider) { // [#15256 / 1A] The SINGLE-KERNEL branch — the wiring every From 293f2ed9e36b3a8cb7d3fceade5878b2e64964b9 Mon Sep 17 00:00:00 2001 From: os-musk Date: Wed, 9 Sep 2026 07:23:16 +0000 Subject: [PATCH 3/5] test(core): route the helper's source pin through the shared comment mask `pnpm check:comment-mask-adoption` reds on a private regex comment stripper; the shared `scripts/js-comment-mask.mjs` answers that question once. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg --- .../src/security/admission-tenancy-posture.test.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/core/src/security/admission-tenancy-posture.test.ts b/packages/core/src/security/admission-tenancy-posture.test.ts index 7df997bc1c..1097cfa001 100644 --- a/packages/core/src/security/admission-tenancy-posture.test.ts +++ b/packages/core/src/security/admission-tenancy-posture.test.ts @@ -34,6 +34,12 @@ import { describe, it, expect } from 'vitest'; import { readFileSync } from 'node:fs'; +// The ONE comment stripper (`pnpm check:comment-mask-adoption`) — a private +// regex pair joins two measured failure families, so §3's reading goes through +// the shared module. Typed by the hand-written `scripts/js-comment-mask.d.mts` +// beside it, so this `.mjs` specifier needs no shim. +import { maskComments } from '../../../../scripts/js-comment-mask.mjs'; + import { ObjectKernel } from '../kernel.js'; import { ServiceLifecycle } from '../plugin-loader.js'; import { isServiceNotRegisteredError } from '../service-not-registered.js'; @@ -249,8 +255,8 @@ describe('[#16013] §2 — the resolver is a THUNK, and the seams depend on how 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'); - /** Source minus block comments — the doc names these symbols on purpose. */ - const CODE = SOURCE.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, ''); + /** Source with comments BLANKED — the doc names these symbols on purpose. */ + const CODE = maskComments(SOURCE); it('names no accessor, no kernel and no context — the resolution stays at each seam', () => { for (const forbidden of ['getServiceAsync', 'getKernel', 'PluginContext', 'getService(']) { From 803fad5df4945fdb87000037c904efa5b4441723 Mon Sep 17 00:00:00 2001 From: os-musk Date: Wed, 9 Sep 2026 07:45:02 +0000 Subject: [PATCH 4/5] test(core): read the helper's implementation by SYMBOL SLICE, not by stripping comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `check:cross-package-test-inputs` reds on the shared comment mask's specifier: importing it makes this test's real inputs wider than its package, which needs a glob widening plus a `vitest.repo-tests.json` entry. The reading does not need comment stripping at all — the module doc is what names the forbidden symbols, and it sits ABOVE the declaration. Slicing from the declaration to end of file needs no stripper, keeps the test inside its package, and the preconditions of the slice (found, starts at the declaration, comment-free) are now themselves asserted instead of assumed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg --- .../admission-tenancy-posture.test.ts | 38 +++++++++++++------ 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/packages/core/src/security/admission-tenancy-posture.test.ts b/packages/core/src/security/admission-tenancy-posture.test.ts index 1097cfa001..8a88fc5fcb 100644 --- a/packages/core/src/security/admission-tenancy-posture.test.ts +++ b/packages/core/src/security/admission-tenancy-posture.test.ts @@ -34,12 +34,6 @@ import { describe, it, expect } from 'vitest'; import { readFileSync } from 'node:fs'; -// The ONE comment stripper (`pnpm check:comment-mask-adoption`) — a private -// regex pair joins two measured failure families, so §3's reading goes through -// the shared module. Typed by the hand-written `scripts/js-comment-mask.d.mts` -// beside it, so this `.mjs` specifier needs no shim. -import { maskComments } from '../../../../scripts/js-comment-mask.mjs'; - import { ObjectKernel } from '../kernel.js'; import { ServiceLifecycle } from '../plugin-loader.js'; import { isServiceNotRegisteredError } from '../service-not-registered.js'; @@ -255,16 +249,36 @@ describe('[#16013] §2 — the resolver is a THUNK, and the seams depend on how 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'); - /** Source with comments BLANKED — the doc names these symbols on purpose. */ - const CODE = maskComments(SOURCE); + 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(CODE).not.toContain(forbidden); + expect(IMPL).not.toContain(forbidden); } - // POSITIVE CONTROL for the reading: the stripper left the real code behind. - expect(CODE).toContain('classifyAdmissionTenancyPosture'); - expect(CODE).toContain('isServiceNotRegisteredError'); + // 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', () => { From 5a9c4fd55fdf6844438dbce3222af7f0b422af18 Mon Sep 17 00:00:00 2001 From: os-musk Date: Wed, 9 Sep 2026 08:24:18 +0000 Subject: [PATCH 5/5] fix(spec): the retirement walk stops reading tsup's transient bundle, and a vanished path is not a crash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Test Core (1/6)` failed on this PR's head with `ENOENT ... open 'packages/spec/tsup.config.bundled_8xzodswt4ct.mjs'` raised by `compliance-families-retirement.test.ts`'s `readFileSync`. The cause is the walk's POPULATION, not this PR's diff: - `turbo.json` gives `test:repo` `dependsOn: ["^build"]` — UPSTREAM builds only, never its own package's — so `@objectstack/spec#build` runs concurrently with the walk; - tsup bundles `tsup.config.ts` to `tsup.config.bundled_.mjs` beside it, loads it and deletes it. `.gitignore` already declares that class (`*.bundled_*.mjs`), but the walk is a filesystem walk, not a git walk, so it enumerated the artifact anyway; - `readdirSync` then `readFileSync` is not atomic, so the file is enumerated, deleted, and the read raises ENOENT. Reproduced deterministically on this tree: planting `packages/spec/tsup.config.bundled_REPROBE.mjs` containing a retired name makes the leg report it as an offender (exit 1) before this change, and exit 0 after — proof the artifact really was in the radius, under a filename that changes every run. Two halves, and ⛔ neither skips or re-scopes a real source: - the transient bundle is excluded by name pattern. It is a COPY of `tsup.config.ts`, which the walk still reads, so this removes no coverage; - a path that vanishes between enumeration and read is recorded and skipped — a file that no longer exists cannot be a reference that SURVIVES in the tree, which is exactly what the pin asserts. ⛔ Every non-ENOENT read fault is still re-raised. Both are pinned: a narrowness control (the pattern must not reach `tsup.config.ts`, `index.mjs` or any authored `.mjs`) and a vanished-path control with a positive read and an EISDIR leg. Ablating the pattern to `/\.mjs$/` reddens the narrowness control, so it can fail. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg --- .../compliance-families-retirement.test.ts | 87 ++++++++++++++++++- 1 file changed, 86 insertions(+), 1 deletion(-) 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()}\``); }