From bb6f63759098c854efa1a2f077194cba5fe2d690 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 10:06:29 +0000 Subject: [PATCH 1/4] wip(#15999): relay the authz-store outage envelope in storage + settings routes Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37 --- .../service-settings/src/settings-routes.ts | 54 ++++++++++++++++++- .../service-storage/src/storage-routes.ts | 44 ++++++++++++++- .../src/storage-service-plugin.ts | 22 ++++---- 3 files changed, 107 insertions(+), 13 deletions(-) diff --git a/packages/services/service-settings/src/settings-routes.ts b/packages/services/service-settings/src/settings-routes.ts index f5770cdc1b..cba6846da8 100644 --- a/packages/services/service-settings/src/settings-routes.ts +++ b/packages/services/service-settings/src/settings-routes.ts @@ -13,7 +13,13 @@ * `SettingsService`. */ -import type { IHttpServer, IHttpRequest, RouteHandler } from '@objectstack/spec/contracts'; +import type { IHttpServer, IHttpRequest, IHttpResponse, RouteHandler } from '@objectstack/spec/contracts'; +// [#15999] The BRAND predicate, never `instanceof` — this error crosses package +// boundaries and a monorepo resolves the same module through more than one path +// (`src` under vitest aliases, `dist` under the published `exports`), so two +// copies of the class make `instanceof` answer FALSE for a genuine instance. +// See `packages/core/src/security/authz-store-unavailable.ts`. +import { isAuthzStoreUnavailableError } from '@objectstack/core'; // The declared envelope is written in ONE place for the whole platform (#3973). // Its `extra` is `ApiError`'s own optional fields, so the undeclared siblings // #4224 retired from this module cannot come back through it either. @@ -56,6 +62,48 @@ export interface SettingsRoutesOptions { // wire a verified `contextFromRequest` (the plugin does). const defaultContext = (_req: IHttpRequest): SettingsContext => ({ enforced: true }); +/** + * [#15999, ruling item 3] Relay an authorization-store OUTAGE as the envelope + * it declares, instead of flattening it into this layer's untyped `500 + * INTERNAL_ERROR` tail. + * + * `SettingsServicePlugin`'s `verifiedContextFromRequest` already re-raises the + * brand rather than returning an enforced-but-empty context the routes would + * read as a denial (#13279). But it is called as `await ctxOf(req)` from INSIDE + * each route's own `try`, so until now the brand was caught here and re-encoded + * — `message` survived, `code` and `status` did not, and those are the two a + * client branches on. The declared `503` / `SERVICE_UNAVAILABLE` never reached + * the caller. + * + * RELAYED rather than re-raised, deliberately. A bare re-raise escapes to the + * transport, which today answers a bare `500 INTERNAL_ERROR "No response from + * handler"` — losing the message too — and the shared render that would give an + * escaped envelope its declared status does not exist yet (#16545). A relay + * answers before the throw escapes, so it is correct today and stays correct + * once #16545 lands (the shared render then only sees what no route relayed). + * The same shape `badRequest` in `service-datasource`'s `admin-routes.ts` has + * used for a service-thrown `503`/`SERVICE_UNAVAILABLE` since #6504. + * + * `status` / `code` are read OFF the error rather than written as digits: the + * envelope answered is the one the producer declared. + * + * Written once and called by all four route catches — this registrar's four + * `catch` blocks are one decision reached four ways, and a copy per handler is + * exactly how a family drifts apart on the arm that matters least often. + * + * ⛔ Scoped to the brand. Every other throw keeps its existing arm, including + * the untyped `500` tail — widening this to "anything carrying a status" would + * let an unrelated coded throw pick this layer's status. + * + * Returns `true` when the outage envelope was answered and the caller must + * stop. + */ +function relayAuthzStoreOutage(res: IHttpResponse, err: unknown): boolean { + if (!isAuthzStoreUnavailableError(err)) return false; + sendError(res, err.status, err.code, err.message); + return true; +} + export function registerSettingsRoutes( http: IHttpServer, service: SettingsService, @@ -70,6 +118,7 @@ export function registerSettingsRoutes( const manifests = service.listManifests(ctx); sendOk(res, { manifests }); } catch (err: any) { + if (relayAuthzStoreOutage(res, err)) return; if (err instanceof SettingsForbiddenError) { sendError(res, 403, 'SETTINGS_FORBIDDEN', err.message, { details: { namespace: err.namespace } }); } else { @@ -89,6 +138,7 @@ export function registerSettingsRoutes( // "configured" state and the env-lock affordances read the same as before. sendOk(res, { ...payload, values: redactSecretValues(payload.values, service.secretKeysOf(ns)) }); } catch (err: any) { + if (relayAuthzStoreOutage(res, err)) return; if (err instanceof SettingsForbiddenError) { sendError(res, 403, 'SETTINGS_FORBIDDEN', err.message, { details: { namespace: err.namespace } }); } else if (err instanceof UnknownNamespaceError) { @@ -140,6 +190,7 @@ export function registerSettingsRoutes( // have set). Same boundary, same redaction. sendOk(res, { values: redactSecretValues(result, secretKeys) }); } catch (err: any) { + if (relayAuthzStoreOutage(res, err)) return; if (err instanceof SettingsForbiddenError) { sendError(res, 403, 'SETTINGS_FORBIDDEN', err.message, { details: { namespace: err.namespace } }); } else if (err instanceof SettingsLockedError) { @@ -204,6 +255,7 @@ export function registerSettingsRoutes( }); } } catch (err: any) { + if (relayAuthzStoreOutage(res, err)) return; if (err instanceof SettingsForbiddenError) { sendError(res, 403, 'SETTINGS_FORBIDDEN', err.message, { details: { namespace: err.namespace } }); } else if (err instanceof UnknownNamespaceError) { diff --git a/packages/services/service-storage/src/storage-routes.ts b/packages/services/service-storage/src/storage-routes.ts index 3984411c37..b2d3f27491 100644 --- a/packages/services/service-storage/src/storage-routes.ts +++ b/packages/services/service-storage/src/storage-routes.ts @@ -4,6 +4,12 @@ import { randomUUID } from 'node:crypto'; import type { IHttpServer, IHttpRequest, IHttpResponse, IStorageService } from '@objectstack/spec/contracts'; // The declared envelope is written in ONE place for the whole platform (#3973). import { sendOk, sendError } from '@objectstack/types'; +// [#15999] The BRAND predicate, never `instanceof` — this error crosses package +// boundaries and a monorepo resolves the same module through more than one path +// (`src` under vitest aliases, `dist` under the published `exports`), so two +// copies of the class make `instanceof` answer FALSE for a genuine instance. +// See `packages/core/src/security/authz-store-unavailable.ts`. +import { isAuthzStoreUnavailableError } from '@objectstack/core'; import type { StorageMetadataStore, FileRecord, @@ -77,6 +83,11 @@ export interface StorageRoutesOptions { * - `deny` → 403 (session, but cannot read the parent record the file * belongs to / is attached to, and is not the owner) * - `allow` → a short-lived signed URL is issued + * A THROW is not a verdict: since #15999 an `AuthzStoreUnavailableError` + * raised by this authorizer is relayed as its declared `503` + * `SERVICE_UNAVAILABLE` rather than flattened into the `deny` 403 — the + * store was unreadable, so no verdict was ever reached. Every other throw + * still fails closed to `deny`. * A file with neither an attachments scope nor a field owner — an unclaimed * upload, an org logo — keeps the stable anonymous capability URL, as does * any file explicitly marked `acl: 'public_read'` (the opt-in for genuinely @@ -167,7 +178,8 @@ export function registerStorageRoutes( // them start being gated by this change. // // Returns the signed-URL TTL to use, or `false` if a response was already - // sent (401/403) and the handler must stop. + // sent (401/403, and since #15999 the `503 SERVICE_UNAVAILABLE` an + // authorization-store OUTAGE is answered with) and the handler must stop. const authorizeDownload = async ( file: FileRecord, req: IHttpRequest, @@ -181,7 +193,35 @@ export function registerStorageRoutes( let verdict: FileReadVerdict; try { verdict = await opts.authorizeFileRead(file, req); - } catch { + } catch (err) { + // [#15999, ruling item 3] An UNREADABLE authorization store is an outage, + // not a verdict. `buildFileReadAuthorizer` already re-raises the brand + // rather than returning `'deny'` (#13279) — and until now this `catch` + // absorbed that re-raise one frame up and rendered it as this gate's own + // `403`, which is precisely the confusion #13279 exists to prevent: an + // outage answered as a capability denial, indistinguishable on the wire + // from a genuine refusal. + // + // RELAYED here rather than re-raised. A bare re-raise escapes into the + // route's own outer `catch`, which answers `500 INTERNAL` — no longer + // wrong-but-informative, merely opaque — and the shared render that would + // give an escaped envelope its declared status does not exist yet + // (#16545). A relay answers the DECLARED envelope before the throw + // escapes, so it is correct today and stays correct once #16545 lands; + // the same shape `badRequest` in `service-datasource`'s `admin-routes.ts` + // has used for a service-thrown `503`/`SERVICE_UNAVAILABLE` since #6504. + // + // `status` / `code` are read OFF the error rather than written as digits: + // the envelope this answers is the one the producer declared. + // + // ⛔ Scoped to the brand on purpose. Every other fault still falls to + // `'deny'` below — this door must never fall open, and widening the arm + // to "anything that carries a status" would let an unrelated coded throw + // decide the answer. + if (isAuthzStoreUnavailableError(err)) { + sendError(res, err.status, err.code, err.message); + return false; + } verdict = 'deny'; // a failed authz check must never fall open } if (verdict === 'unauthenticated') { diff --git a/packages/services/service-storage/src/storage-service-plugin.ts b/packages/services/service-storage/src/storage-service-plugin.ts index b08838c3a2..bd69d9623c 100644 --- a/packages/services/service-storage/src/storage-service-plugin.ts +++ b/packages/services/service-storage/src/storage-service-plugin.ts @@ -866,16 +866,18 @@ function buildAuthSessionResolver( * (`isAuthzStoreUnavailableError(err)` re-raises instead of returning * `'deny'`). Deliberately NOT a second net. * - * ⚠️ MEASURED on this tree, and worth knowing before reading that relay as a - * 503: `registerStorageRoutes`' `authorizeDownload` wraps this authorizer in - * `catch { verdict = 'deny' }`, so on THIS door the re-raise is absorbed one - * frame up and a failed posture read renders as the download gate's own 403 - * refusal. Fail-CLOSED — never an admission — but not the branded status - * either. That flattening is PRE-EXISTING (it has swallowed the #13279 - * permission-store outage at this door since that card landed, out of the same - * `catch`), it is not something this card opened, and it is ⛔ not repaired - * here; the pin below asserts the outage CLASS rather than the digits so a - * later status repair does not have to redden a security test. + * ⚠️ Read as a 503 ON THE WIRE since #15999, and it was NOT one before. Until + * that card, `registerStorageRoutes`' `authorizeDownload` wrapped this + * authorizer in `catch { verdict = 'deny' }`, so the re-raise was absorbed one + * frame up and a failed posture read rendered as the download gate's own 403 + * refusal — fail-CLOSED, never an admission, but wearing the costume of a + * capability denial, which is the confusion #13279 exists to prevent. That + * flattening was PRE-EXISTING (it had swallowed the #13279 permission-store + * outage at this door since that card landed, out of the same `catch`) and was + * repaired by #15999's ruling item 3: the `catch` now RELAYS the declared + * `503` / `SERVICE_UNAVAILABLE` envelope. The pin below still asserts the + * outage CLASS — never 200, never a minted capability — and its 403 arm retired + * with that repair. * * ## Why `getServiceAsync`, and why its ABSENCE stays quiet ON THIS DOOR * From c73bc810904c1bbaad155632aae7e617802d8c04 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 10:10:15 +0000 Subject: [PATCH 2/4] test(#15999): pin the relayed outage envelope on the storage and settings doors Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37 --- ...settings-admission-tenancy-posture.test.ts | 27 +- ...settings-routes.authz-outage-relay.test.ts | 233 ++++++++++++++ ...ile-read-tenancy-posture-admission.test.ts | 52 +++- .../storage-routes.authz-outage-relay.test.ts | 291 ++++++++++++++++++ 4 files changed, 575 insertions(+), 28 deletions(-) create mode 100644 packages/services/service-settings/src/settings-routes.authz-outage-relay.test.ts create mode 100644 packages/services/service-storage/src/storage-routes.authz-outage-relay.test.ts diff --git a/packages/services/service-settings/src/settings-admission-tenancy-posture.test.ts b/packages/services/service-settings/src/settings-admission-tenancy-posture.test.ts index 3cc7274838..752fa15321 100644 --- a/packages/services/service-settings/src/settings-admission-tenancy-posture.test.ts +++ b/packages/services/service-settings/src/settings-admission-tenancy-posture.test.ts @@ -80,7 +80,8 @@ import type { SettingsContext } from './settings-service.types.js'; * need it and neither is visible from a response body: * * - the ADR-0112 envelope the broken-`tenancy` arm raises, which the settings - * route layer flattens to `500 INTERNAL_ERROR` (see the 503 describe below); + * route layer RELAYS as `503 SERVICE_UNAVAILABLE` since #15999 — it used to + * flatten it to `500 INTERNAL_ERROR` (see the 503 describe below); * - the `tenantId` this seam RETURNS, which is the half of this card that is * not about admission at all. */ @@ -608,21 +609,25 @@ describe('#15351 — decision 1 option A: a BROKEN tenancy service is an outage, it('the door does NOT admit, and nothing lands', async () => { const m = await mount({ kind: 'factory-throws' }); const res = await put(m, KEY(RAW_EXMEMBER_KEY)); - // ⚠️ MEASURED, not endorsed: the settings route layer has no - // `isAuthzStoreUnavailableError` arm, so the branded 503 the seam raises is - // flattened into `500 INTERNAL_ERROR` here. That flattening is PRE-EXISTING - // — #13279's permission-store re-raise already reached this same `else` - // branch — and repairing the transport's envelope mapping is a different - // defect from supplying the posture, so it is filed rather than ridden in. - // What this card owns is pinned either way: the outage is NOT a quiet - // admit. - expect(res.status).toBe(500); + // ⭐ THE 500 ARM RETIRED HERE (#15999 ruling item 3). This line read + // `expect(res.status).toBe(500)` and carried a note saying so was MEASURED + // and not endorsed: the settings route layer had no + // `isAuthzStoreUnavailableError` arm, so the branded 503 the seam raises + // was flattened into `500 INTERNAL_ERROR` by the same untyped `else` branch + // #13279's permission-store re-raise already reached. All four route + // catches now RELAY the declared envelope instead. + // + // What THIS card owns is unchanged and still asserted: the outage is not a + // quiet admit, and nothing lands. expect(m.settingRows()).toHaveLength(0); + expect(res.status).toBe(AUTHZ_STORE_UNAVAILABLE_STATUS); + expect((res.body as any)?.error?.code).toBe(AUTHZ_STORE_UNAVAILABLE_CODE); }); it('the GET listing is an outage too, never a silently empty list', async () => { const m = await mount({ kind: 'factory-throws' }); const res = await listNs(m, KEY(RAW_MEMBER_KEY)); - expect(res.status).toBe(500); + expect(res.status).toBe(AUTHZ_STORE_UNAVAILABLE_STATUS); + expect((res.body as any)?.error?.code).toBe(AUTHZ_STORE_UNAVAILABLE_CODE); }); }); diff --git a/packages/services/service-settings/src/settings-routes.authz-outage-relay.test.ts b/packages/services/service-settings/src/settings-routes.authz-outage-relay.test.ts new file mode 100644 index 0000000000..d75d68d42e --- /dev/null +++ b/packages/services/service-settings/src/settings-routes.authz-outage-relay.test.ts @@ -0,0 +1,233 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#15999 ruling item 3] Every settings route relays an authorization-store + * OUTAGE as the `503 SERVICE_UNAVAILABLE` the brand declares, instead of + * flattening it into this layer's untyped `500 INTERNAL_ERROR` tail. + * + * ## What was measured + * + * `SettingsServicePlugin`'s `verifiedContextFromRequest` re-raises + * `AuthzStoreUnavailableError` rather than returning an enforced-but-empty + * context the routes would read as a denial (#13279). But it is called as + * `await ctxOf(req)` from INSIDE each route's own `try`, so the brand was + * caught here and re-encoded: `message` survived, `code` and `status` did not — + * and those are the two a client branches on. + * + * ## The count on the card was wrong, and this file is why it matters + * + * The #15999 ruling says 「settings' three route catches」. Located by + * PREDICATE — a `catch` around a `ctxOf(req)` whose exit is a denial or a + * swallow — this registrar has **four**: `GET /api/settings`, + * `GET /api/settings/:namespace`, `PUT /api/settings/:namespace` and + * `POST /api/settings/:namespace/:actionId`. All four are driven below, so the + * fourth cannot be the one nobody remembered. + * + * ## RELAY, not re-raise + * + * A bare re-raise escapes to the transport, which answers a bare + * `500 INTERNAL_ERROR "No response from handler"` — losing the message the + * flattening at least preserved — and the shared render that would give an + * escaped ADR-0112 envelope its declared status is #16545 and has not landed. + * A relay answers before the throw escapes, so it is correct today and stays + * correct once #16545 lands. Same shape as `badRequest` in + * `service-datasource`'s `admin-routes.ts` since #6504. + * + * ## Controls + * + * A layer that answered 503 for everything would pass an outage-only suite + * while making every settings fault unreadable. So §1 drives the happy path and + * §3 pins the relay's WIDTH: `SettingsForbiddenError` still answers its 403, + * `UnknownNamespaceError` its 404, and a plain throw still lands on the untyped + * `500 INTERNAL_ERROR` tail that this repair deliberately did NOT widen. + * + * ⛔ Nothing here asserts `toThrow()`: the unrepaired layer never threw — it + * ANSWERED, with the wrong envelope. The claim is `status` + `code`. + */ + +import { describe, it, expect } from 'vitest'; +import { + AuthzStoreUnavailableError, + AUTHZ_STORE_UNAVAILABLE_CODE, + AUTHZ_STORE_UNAVAILABLE_STATUS, +} from '@objectstack/core'; +import type { IHttpRequest, IHttpResponse, RouteHandler } from '@objectstack/spec/contracts'; +import { registerSettingsRoutes } from './settings-routes.js'; +import { SettingsForbiddenError, UnknownNamespaceError } from './settings-service.types.js'; +import type { SettingsContext } from './settings-service.types.js'; + +const BASE = '/api/settings'; +const NS = 'mail'; + +interface Captured { status: number; body: any } + +/** + * A `SettingsService` stand-in that would SUCCEED for every verb. It exists so + * a green outage arm can only come from the context resolver — if the service + * were the thing failing, every arm below would pass for the wrong reason. + */ +function permissiveService() { + return { + listManifests: () => [{ namespace: NS }], + getNamespace: async () => ({ manifest: { namespace: NS }, values: {} }), + secretKeysOf: () => [] as string[], + setMany: async () => ({}), + runAction: async () => ({ ok: true }), + } as any; +} + +type Ctx = (req: IHttpRequest) => SettingsContext | Promise; + +function mount(contextFromRequest: Ctx, service: any = permissiveService()) { + const routes = new Map(); + const http = { + get: (p: string, h: RouteHandler) => { routes.set(`GET:${p}`, h); }, + post: (p: string, h: RouteHandler) => { routes.set(`POST:${p}`, h); }, + put: (p: string, h: RouteHandler) => { routes.set(`PUT:${p}`, h); }, + delete: () => {}, + patch: () => {}, + use: () => {}, + listen: async () => {}, + close: async () => {}, + }; + registerSettingsRoutes(http as any, service, { basePath: BASE, contextFromRequest }); + return routes; +} + +/** The four doors this registrar mounts — the predicate-found census, not the card's count. */ +const DOORS = [ + { name: 'GET /api/settings', key: `GET:${BASE}`, params: {}, body: undefined }, + { name: 'GET /api/settings/:namespace', key: `GET:${BASE}/:namespace`, params: { namespace: NS }, body: undefined }, + { name: 'PUT /api/settings/:namespace', key: `PUT:${BASE}/:namespace`, params: { namespace: NS }, body: { host: 'x' } }, + { + name: 'POST /api/settings/:namespace/:actionId', + key: `POST:${BASE}/:namespace/:actionId`, + params: { namespace: NS, actionId: 'test' }, + body: {}, + }, +] as const; + +async function drive(routes: Map, door: (typeof DOORS)[number]): Promise { + const handler = routes.get(door.key); + if (!handler) throw new Error(`fixture: no handler for ${door.key}`); + const captured: Captured = { status: 200, body: undefined }; + const res: any = { + json(data: any) { captured.body = data; return res; }, + send() { return res; }, + status(code: number) { captured.status = code; return res; }, + header() { return res; }, + }; + const [method, path] = door.key.split(/:(.+)/); + await handler( + { params: door.params, query: {}, body: door.body, headers: {}, method, path } as unknown as IHttpRequest, + res as IHttpResponse, + ); + return captured; +} + +const codeOf = (c: Captured) => c.body?.error?.code; +const OUTAGE: Ctx = () => { throw new AuthzStoreUnavailableError('sys_user_permission_set'); }; + +// --------------------------------------------------------------------------- +// §0 — The census control: the door table above IS the mounted surface. +// --------------------------------------------------------------------------- + +describe('[#15999] §0 — all four route catches exist and are the ones driven', () => { + it('the registrar mounts exactly the four doors this file drives', () => { + const routes = mount(() => ({ enforced: false })); + expect([...routes.keys()].sort()).toEqual(DOORS.map((d) => d.key).sort()); + }); +}); + +// --------------------------------------------------------------------------- +// §1 — Control: the doors still work when the context resolves. +// --------------------------------------------------------------------------- + +describe('[#15999] §1 — a resolvable context still reaches the service', () => { + it.each(DOORS)('CONTROL · $name answers 200', async (door) => { + const routes = mount(() => ({ enforced: false })); + const res = await drive(routes, door); + expect(res.status).toBe(200); + expect(res.body?.success).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// §2 — THE SUBJECT. +// --------------------------------------------------------------------------- + +describe('[#15999] §2 — an authorization-store outage reaches the caller as its declared envelope', () => { + it.each(DOORS)('REPAIRED: $name answers 503 SERVICE_UNAVAILABLE — was 500 INTERNAL_ERROR', async (door) => { + const routes = mount(OUTAGE); + const res = await drive(routes, door); + expect(res.status).toBe(AUTHZ_STORE_UNAVAILABLE_STATUS); + expect(res.status).toBe(503); + expect(codeOf(res)).toBe(AUTHZ_STORE_UNAVAILABLE_CODE); + // ⛔ The code that used to arrive, and that named the wrong component. + expect(codeOf(res)).not.toBe('INTERNAL_ERROR'); + }); + + it('the operator still learns WHICH read failed, and that it is not a denial', async () => { + const routes = mount(OUTAGE); + const res = await drive(routes, DOORS[2]); + expect(res.body?.error?.message).toContain('sys_user_permission_set'); + expect(res.body?.error?.message).toContain('not a permission denial'); + expect(res.body).toMatchObject({ success: false, error: { code: 'SERVICE_UNAVAILABLE' } }); + }); + + it('an async rejection is relayed too, not only a synchronous throw', async () => { + // `contextFromRequest` is declared to return `SettingsContext | Promise<…>` + // and the production resolver is async, so the rejected-promise path is the + // one that actually runs. + const routes = mount(async () => { throw new AuthzStoreUnavailableError('sys_permission_set_assignment'); }); + const res = await drive(routes, DOORS[0]); + expect(res.status).toBe(503); + expect(codeOf(res)).toBe(AUTHZ_STORE_UNAVAILABLE_CODE); + }); +}); + +// --------------------------------------------------------------------------- +// §3 — THE WIDTH PIN. Every other arm is untouched. +// --------------------------------------------------------------------------- + +describe('[#15999] §3 — the relay is scoped to the brand; every other arm is unchanged', () => { + it.each(DOORS)('$name still answers 403 SETTINGS_FORBIDDEN for a forbidden context', async (door) => { + const routes = mount(() => { throw new SettingsForbiddenError(NS, 'setup.access', 'read'); }); + const res = await drive(routes, door); + expect(res.status).toBe(403); + expect(codeOf(res)).toBe('SETTINGS_FORBIDDEN'); + }); + + it.each(DOORS)('$name still answers the untyped 500 INTERNAL_ERROR tail for a plain throw', async (door) => { + const routes = mount(() => { throw new Error('some unrelated fault'); }); + const res = await drive(routes, door); + expect(res.status).toBe(500); + expect(codeOf(res)).toBe('INTERNAL_ERROR'); + // The message channel this layer already preserved is preserved still. + expect(res.body?.error?.message).toBe('some unrelated fault'); + }); + + it('a look-alike carrying the declared status+code but NO brand is not relayed', async () => { + // The brand survives module duplication where `instanceof` does not + // (`authz-store-unavailable.ts` module doc); the converse is that a + // look-alike without it is not this error. + const routes = mount(() => { + throw Object.assign(new Error('look-alike'), { + status: AUTHZ_STORE_UNAVAILABLE_STATUS, + code: AUTHZ_STORE_UNAVAILABLE_CODE, + }); + }); + const res = await drive(routes, DOORS[1]); + expect(res.status).toBe(500); + expect(codeOf(res)).toBe('INTERNAL_ERROR'); + }); + + it('the namespace 404 arm is untouched — a service-thrown UnknownNamespaceError still wins', async () => { + const service = permissiveService(); + service.getNamespace = async () => { throw new UnknownNamespaceError(NS); }; + const routes = mount(() => ({ enforced: false }), service); + const res = await drive(routes, DOORS[1]); + expect(res.status).toBe(404); + expect(codeOf(res)).toBe('UNKNOWN_NAMESPACE'); + }); +}); diff --git a/packages/services/service-storage/src/file-read-tenancy-posture-admission.test.ts b/packages/services/service-storage/src/file-read-tenancy-posture-admission.test.ts index 649b9faf2b..edf2a4fb17 100644 --- a/packages/services/service-storage/src/file-read-tenancy-posture-admission.test.ts +++ b/packages/services/service-storage/src/file-read-tenancy-posture-admission.test.ts @@ -567,31 +567,44 @@ describe('[#15352] §5b — a `tenancy` service that was REGISTERED and FAILED i const OUTAGE: Wiring = { kind: 'factory-throws' }; /** - * ⚠️ MEASURED on this tree, and deliberately pinned as a CLASS rather than as - * digits: this door answers **`403 FILE_DOWNLOAD_DENIED`** on a failed - * posture read, not the `503 SERVICE_UNAVAILABLE` the - * `AuthzStoreUnavailableError` brand carries. + * ⭐ THE 403 ARM RETIRED HERE. This block used to pin the outage as a CLASS — + * `status` in `{403, 500, 503}` — because MEASURED on this tree the door + * answered **`403 FILE_DOWNLOAD_DENIED`** on a failed posture read, not the + * `503 SERVICE_UNAVAILABLE` the `AuthzStoreUnavailableError` brand carries. + * #15999's ruling item 3 repaired exactly that, and item 4 said in the same + * stroke that this pin's 403 arm retires with the fix. It is retired below: + * the digits are now asserted, and they are the declared ones. * - * The authorizer DOES re-raise the brand (`isAuthzStoreUnavailableError(err) - * ⇒ throw`, #13279) — but `registerStorageRoutes`' `authorizeDownload` wraps - * the whole authorizer in `catch { verdict = 'deny' }` one frame up, so on - * this door the re-raise is absorbed and rendered as the gate's own refusal. - * That flattening is PRE-EXISTING — it has swallowed the #13279 - * permission-store outage here since that card landed, out of the same - * `catch` — and is ⛔ not repaired by this one; it is filed separately. + * What happened: the authorizer always re-raised the brand + * (`isAuthzStoreUnavailableError(err) ⇒ throw`, #13279), and + * `registerStorageRoutes`' `authorizeDownload` absorbed that re-raise one + * frame up in `catch { verdict = 'deny' }`, rendering an outage as the gate's + * own capability refusal — the confusion #13279 exists to prevent. That + * `catch` now RELAYS the declared envelope instead (⛔ not a bare re-raise: + * the route's outer `catch` would answer `500 INTERNAL`, and the shared + * render for an escaped envelope is #16545 and has not landed). * - * The property this file is about is the SECURITY one: a FAILURE must not - * read as "this check does not apply". So the assertions below say the outage - * is never answered as an ADMISSION and never mints a capability, and leave - * the digits free — a later repair that promotes this to 503 must not have to - * redden a security pin. + * ⚠️ The SECURITY property this file is about is unchanged and still asserted + * first: a FAILURE must never read as "this check does not apply". Every arm + * below still says the outage is never answered as an ADMISSION and never + * mints a capability. The status assertion is additive to that, not a + * replacement for it — a door that answered 503 after minting a URL would + * pass a status-only suite and still have issued the capability. + * + * The relay's own controls (the `deny` 403 spellings that must SURVIVE, and + * every non-branded throw that must still fall closed) live in + * `storage-routes.authz-outage-relay.test.ts`. */ it('⛔ the ex-member is NOT admitted on a failed posture read — the defect a quiet `catch` would restore', async () => { const h = await mount(OUTAGE); const res = await h.call('url', FILE_OPEN, RAW_EXMEMBER_KEY); - expect([403, 500, 503]).toContain(res.status); + // The security half, unchanged and asserted first. expect(res.status).not.toBe(200); expect(h.minted()).toBe(0); + // The half #15999 repaired: the declared envelope, no longer the gate's own + // 403. `[403, 500, 503]` is what this line used to accept. + expect(res.status).toBe(503); + expect((res.json?.error as { code?: string } | undefined)?.code).toBe('SERVICE_UNAVAILABLE'); }); it('⛔ nor is the redirect door — no 302, no Location, no capability', async () => { @@ -601,6 +614,8 @@ describe('[#15352] §5b — a `tenancy` service that was REGISTERED and FAILED i expect(res.status).not.toBe(200); expect(res.headers.Location).toBeUndefined(); expect(h.minted()).toBe(0); + // The redirect sibling relays the same declared envelope (#15999). + expect(res.status).toBe(503); }); it('the outage is not selective either: a CURRENT member is refused too, and nothing is minted', async () => { @@ -611,6 +626,9 @@ describe('[#15352] §5b — a `tenancy` service that was REGISTERED and FAILED i const res = await h.call('url', FILE_OPEN, RAW_MEMBER_KEY); expect(res.status).not.toBe(200); expect(h.minted()).toBe(0); + // …and the healthy caller gets the same DIAGNOSABLE answer (#15999), not a + // refusal that would send an operator hunting for a permission problem. + expect(res.status).toBe(503); }); }); diff --git a/packages/services/service-storage/src/storage-routes.authz-outage-relay.test.ts b/packages/services/service-storage/src/storage-routes.authz-outage-relay.test.ts new file mode 100644 index 0000000000..3ed2fec95a --- /dev/null +++ b/packages/services/service-storage/src/storage-routes.authz-outage-relay.test.ts @@ -0,0 +1,291 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#15999 ruling item 3, the PRIORITY row] The parent-governed download gate + * answers an authorization-store OUTAGE as the `503 SERVICE_UNAVAILABLE` the + * brand declares — not as its own `403` capability denial. + * + * ## What was measured, and why this row came first + * + * `buildFileReadAuthorizer` re-raises `AuthzStoreUnavailableError` rather than + * returning `'deny'` (#13279). `registerStorageRoutes`' `authorizeDownload` + * then wrapped the whole authorizer call in `catch { verdict = 'deny' }` one + * frame up, so the re-raise was absorbed and the outage rendered as + * `403 FILE_DOWNLOAD_DENIED` / `403 ATTACHMENT_DOWNLOAD_DENIED`. Fail-CLOSED, + * never an admission — but indistinguishable on the wire from a genuine + * refusal, which is the exact confusion #13279 exists to prevent, and the worst + * shape in this card's six-site census (the datasource and settings families + * lost the envelope into a 500; this one lost it into a *verdict*). + * + * ## RELAY, not re-raise — and the measurement that decides it + * + * A bare re-raise from this `catch` escapes into the route's own outer + * `catch (err) { sendError(res, 500, 'INTERNAL', …) }`, which answers a bare + * `500 INTERNAL`: no longer wrong-but-informative, merely opaque. The shared + * render that would give an escaped ADR-0112 envelope its declared status is + * #16545 and has not landed. So this door RELAYS — it answers the declared + * envelope before the throw escapes, which is correct today and stays correct + * once #16545 lands (the shared render then only sees what no route relayed). + * `badRequest` in `service-datasource`'s `admin-routes.ts` has used the same + * shape for a service-thrown `503`/`SERVICE_UNAVAILABLE` since #6504. + * + * ## What the controls are for + * + * A door that answered 503 for EVERYTHING would pass an outage-only suite while + * taking every download offline, and a relay widened past the brand would let + * an unrelated coded throw pick this gate's status. So both directions are + * driven here: + * + * - `allow` still serves and still mints (§1); + * - `deny` still answers its own 403, in BOTH spellings — the field-owned + * `FILE_DOWNLOAD_DENIED` and the attachments-scope `ATTACHMENT_DOWNLOAD_DENIED` + * (§1), so the relay cannot be read as having replaced the refusal; + * - `unauthenticated` still answers 401 (§1); + * - ⭐ a NON-branded throw from the authorizer still falls closed to that same + * 403 (§3). That is the arm that pins the relay's WIDTH: it is scoped to the + * brand, and a failed authz check must never fall open. + * + * Both download doors are driven for every arm — `/files/:fileId/url` and the + * `/files/:fileId` redirect sibling are one decision reached two ways, and the + * outage repair is worth nothing if only one of them learned it. + * + * ⛔ Nothing here asserts `toThrow()`: an unrepaired door throws too. The claim + * is the ENVELOPE — `status` and `code` — per ADR-0112. + */ + +import { describe, it, expect } from 'vitest'; +import { promises as fs } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { + AuthzStoreUnavailableError, + AUTHZ_STORE_UNAVAILABLE_CODE, + AUTHZ_STORE_UNAVAILABLE_STATUS, +} from '@objectstack/core'; +import type { IHttpRequest, IHttpResponse, RouteHandler } from '@objectstack/spec/contracts'; +import { LocalStorageAdapter } from './local-storage-adapter.js'; +import { StorageMetadataStore } from './metadata-store.js'; +import { registerStorageRoutes } from './storage-routes.js'; + +const BASE = '/api/v1/storage'; + +/** The field-owned subject file — its refusal spelling is `FILE_DOWNLOAD_DENIED`. */ +const FIELD_OWNED = 'f_owned'; +/** The attachments-scope subject — its refusal spelling is `ATTACHMENT_DOWNLOAD_DENIED`. */ +const ATTACHED = 'f_attached'; + +interface Captured { + status: number; + body: any; + headers: Record; + /** Did the adapter MINT a capability? The fact a status-only suite cannot see. */ + minted: number; +} + +async function tmpAdapter(): Promise { + const rootDir = join(tmpdir(), `os-15999-${Date.now()}-${Math.random().toString(36).slice(2)}`); + await fs.mkdir(rootDir, { recursive: true }); + return new LocalStorageAdapter({ rootDir, signingSecret: 'test-secret-15999' }); +} + +async function seededStore(): Promise { + const store = new StorageMetadataStore(null); + await store.createFile({ + id: FIELD_OWNED, + key: `user/${FIELD_OWNED}.png`, + name: 'x.png', + status: 'committed', + acl: 'private', + scope: 'user', + ref_object: 'product', + ref_id: 'p1', + } as any); + await store.createFile({ + id: ATTACHED, + key: `attachments/${ATTACHED}.bin`, + name: 'y.bin', + status: 'committed', + acl: 'private', + scope: 'attachments', + } as any); + return store; +} + +type Authorizer = NonNullable[3]>['authorizeFileRead']; + +async function harness(authorizeFileRead: Authorizer) { + const storage = await tmpAdapter(); + const store = await seededStore(); + const routes = new Map(); + const http = { + get: (p: string, h: RouteHandler) => { routes.set(`GET:${p}`, h); }, + post: (p: string, h: RouteHandler) => { routes.set(`POST:${p}`, h); }, + put: (p: string, h: RouteHandler) => { routes.set(`PUT:${p}`, h); }, + delete: () => {}, + patch: () => {}, + use: () => {}, + listen: async () => {}, + close: async () => {}, + }; + let minted = 0; + const original = storage.getPresignedDownload.bind(storage); + (storage as any).getPresignedDownload = async (...args: unknown[]) => { + minted += 1; + return (original as any)(...args); + }; + registerStorageRoutes(http as any, storage as any, store, { basePath: BASE, authorizeFileRead }); + + return async (door: 'url' | 'redirect', fileId: string): Promise => { + const path = door === 'url' ? `${BASE}/files/:fileId/url` : `${BASE}/files/:fileId`; + const handler = routes.get(`GET:${path}`); + if (!handler) throw new Error(`fixture: no handler for GET ${path}`); + const captured: Captured = { status: 200, body: undefined, headers: {}, minted: 0 }; + const res: any = { + json(data: any) { captured.body = data; return res; }, + send() { return res; }, + status(code: number) { captured.status = code; return res; }, + header(n: string, v: string) { captured.headers[n] = v; return res; }, + }; + await handler( + { params: { fileId }, query: {}, body: undefined, headers: {}, method: 'GET', path } as IHttpRequest, + res as IHttpResponse, + ); + captured.minted = minted; + return captured; + }; +} + +const codeOf = (c: Captured) => c.body?.error?.code; +const DOORS: Array<'url' | 'redirect'> = ['url', 'redirect']; + +// --------------------------------------------------------------------------- +// §1 — Controls, in every direction, BEFORE any subject arm. +// --------------------------------------------------------------------------- + +describe('[#15999] §1 — the download gate still serves, still refuses, still challenges', () => { + it.each(DOORS)('CONTROL · `allow` serves and MINTS a capability (%s door)', async (door) => { + const call = await harness(async () => 'allow'); + const res = await call(door, FIELD_OWNED); + expect(res.status).toBe(door === 'url' ? 200 : 302); + expect(res.minted).toBe(1); + }); + + it.each(DOORS)('CONTROL · `deny` on a FIELD-OWNED file is still 403 FILE_DOWNLOAD_DENIED (%s door)', async (door) => { + const call = await harness(async () => 'deny'); + const res = await call(door, FIELD_OWNED); + expect(res.status).toBe(403); + expect(codeOf(res)).toBe('FILE_DOWNLOAD_DENIED'); + expect(res.minted).toBe(0); + }); + + it.each(DOORS)('CONTROL · `deny` on an ATTACHMENTS-scope file is still 403 ATTACHMENT_DOWNLOAD_DENIED (%s door)', async (door) => { + const call = await harness(async () => 'deny'); + const res = await call(door, ATTACHED); + expect(res.status).toBe(403); + expect(codeOf(res)).toBe('ATTACHMENT_DOWNLOAD_DENIED'); + expect(res.minted).toBe(0); + }); + + it.each(DOORS)('CONTROL · `unauthenticated` is still 401 AUTH_REQUIRED (%s door)', async (door) => { + const call = await harness(async () => 'unauthenticated'); + const res = await call(door, FIELD_OWNED); + expect(res.status).toBe(401); + expect(codeOf(res)).toBe('AUTH_REQUIRED'); + expect(res.minted).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// §2 — THE SUBJECT. An unreadable authorization store is an OUTAGE. +// --------------------------------------------------------------------------- + +describe('[#15999] §2 — an authorization-store outage is relayed as its declared envelope', () => { + it.each(DOORS)('REPAIRED: %s door answers 503 SERVICE_UNAVAILABLE — was 403 FILE_DOWNLOAD_DENIED', async (door) => { + const call = await harness(async () => { + throw new AuthzStoreUnavailableError('sys_user_permission_set'); + }); + const res = await call(door, FIELD_OWNED); + expect(res.status).toBe(AUTHZ_STORE_UNAVAILABLE_STATUS); + expect(res.status).toBe(503); + expect(codeOf(res)).toBe(AUTHZ_STORE_UNAVAILABLE_CODE); + // ⛔ Never the refusal it used to wear. + expect(codeOf(res)).not.toBe('FILE_DOWNLOAD_DENIED'); + // The SECURITY half is unchanged: an outage mints nothing. + expect(res.minted).toBe(0); + }); + + it.each(DOORS)('the attachments-scope door relays it too (%s door)', async (door) => { + const call = await harness(async () => { + throw new AuthzStoreUnavailableError('sys_attachment'); + }); + const res = await call(door, ATTACHED); + expect(res.status).toBe(503); + expect(codeOf(res)).toBe(AUTHZ_STORE_UNAVAILABLE_CODE); + expect(codeOf(res)).not.toBe('ATTACHMENT_DOWNLOAD_DENIED'); + expect(res.minted).toBe(0); + }); + + it('the operator learns WHICH read failed — the message names the object, and says it is not a denial', async () => { + const call = await harness(async () => { + throw new AuthzStoreUnavailableError('sys_user_permission_set'); + }); + const res = await call('url', FIELD_OWNED); + expect(res.body?.error?.message).toContain('sys_user_permission_set'); + expect(res.body?.error?.message).toContain('not a permission denial'); + // ⛔ And the envelope is the declared one, not a hand-rolled sibling. + expect(res.body).toMatchObject({ success: false, error: { code: 'SERVICE_UNAVAILABLE' } }); + }); + + it('the redirect door issues NO Location on an outage', async () => { + const call = await harness(async () => { + throw new AuthzStoreUnavailableError('sys_attachment'); + }); + const res = await call('redirect', ATTACHED); + expect(res.headers.Location).toBeUndefined(); + expect(res.status).toBe(503); + }); +}); + +// --------------------------------------------------------------------------- +// §3 — THE WIDTH PIN. The relay is scoped to the brand; everything else still +// falls closed. Without this arm the repair could be a blanket "any throw is a +// 503", which would fail OPEN on faults that must stay refusals. +// --------------------------------------------------------------------------- + +describe('[#15999] §3 — every OTHER throw still falls closed to the gate\'s own 403', () => { + it.each(DOORS)('a plain Error from the authorizer is still 403, never 503 (%s door)', async (door) => { + const call = await harness(async () => { throw new Error('some unrelated fault'); }); + const res = await call(door, FIELD_OWNED); + expect(res.status).toBe(403); + expect(codeOf(res)).toBe('FILE_DOWNLOAD_DENIED'); + expect(res.minted).toBe(0); + }); + + it('a throw carrying a DIFFERENT declared status does not get to pick this gate\'s answer', async () => { + // The brand is the predicate, not "has a `status`". A coded refusal from + // somewhere else in the authorizer must not be relayed as though the store + // were unreadable. + const call = await harness(async () => { + const e = Object.assign(new Error('teapot'), { status: 418, code: 'TEAPOT' }); + throw e; + }); + const res = await call('url', FIELD_OWNED); + expect(res.status).toBe(403); + expect(codeOf(res)).toBe('FILE_DOWNLOAD_DENIED'); + }); + + it('an object carrying ONLY the declared status+code but no brand is not relayed either', async () => { + // The brand survives module duplication where `instanceof` does not + // (`authz-store-unavailable.ts` module doc); the converse is that a + // look-alike without it is NOT this error, and must not be treated as one. + const call = await harness(async () => { + throw Object.assign(new Error('look-alike'), { + status: AUTHZ_STORE_UNAVAILABLE_STATUS, + code: AUTHZ_STORE_UNAVAILABLE_CODE, + }); + }); + const res = await call('url', FIELD_OWNED); + expect(res.status).toBe(403); + expect(codeOf(res)).toBe('FILE_DOWNLOAD_DENIED'); + }); +}); From 95b4406e18a129f2b547b557b5dabd56077440cb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 10:12:38 +0000 Subject: [PATCH 3/4] test(#15999): use the real ReadonlySet shape for secretKeysOf in the relay fixture Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37 --- .../src/settings-routes.authz-outage-relay.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/services/service-settings/src/settings-routes.authz-outage-relay.test.ts b/packages/services/service-settings/src/settings-routes.authz-outage-relay.test.ts index d75d68d42e..9e8cc5791b 100644 --- a/packages/services/service-settings/src/settings-routes.authz-outage-relay.test.ts +++ b/packages/services/service-settings/src/settings-routes.authz-outage-relay.test.ts @@ -70,7 +70,10 @@ function permissiveService() { return { listManifests: () => [{ namespace: NS }], getNamespace: async () => ({ manifest: { namespace: NS }, values: {} }), - secretKeysOf: () => [] as string[], + // `ReadonlySet` is the real `SettingsService` shape — an array + // makes the redaction helpers' `.size` read `undefined` and the PUT door + // answers 500, i.e. the control would fail for a fixture reason. + secretKeysOf: () => new Set(), setMany: async () => ({}), runAction: async () => ({ ok: true }), } as any; From 673ead75aa27f9da89bbd1a8d58b5e14ddfaba5a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 10:15:50 +0000 Subject: [PATCH 4/4] chore(#15999): changeset for the authz-store outage status relay Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37 --- .changeset/authz-store-outage-status-relay.md | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 .changeset/authz-store-outage-status-relay.md diff --git a/.changeset/authz-store-outage-status-relay.md b/.changeset/authz-store-outage-status-relay.md new file mode 100644 index 0000000000..9685c3a1d8 --- /dev/null +++ b/.changeset/authz-store-outage-status-relay.md @@ -0,0 +1,27 @@ +--- +"@objectstack/service-storage": patch +"@objectstack/service-settings": patch +--- + +An authorization-store OUTAGE now reaches the caller as the `503 SERVICE_UNAVAILABLE` it declares, on the storage download doors and on all four settings routes. + +`AuthzStoreUnavailableError` exists so an outage is distinguishable from a capability denial on the wire: it declares `status: 503` and `code: SERVICE_UNAVAILABLE`, and every producer in this family already re-raises it rather than laundering it into a verdict. Two consumers then flattened it back, each in its own way, so the declared envelope never arrived. + +**What changes on the wire.** Only on the path where the authorization store could not be READ — never when it legitimately returned no rows, and never for any other fault. + +| door | before | after | +| --- | --- | --- | +| `GET /api/v1/storage/files/:fileId/url` | `403 FILE_DOWNLOAD_DENIED` / `403 ATTACHMENT_DOWNLOAD_DENIED` | `503 SERVICE_UNAVAILABLE` | +| `GET /api/v1/storage/files/:fileId` | same 403, and no redirect | `503 SERVICE_UNAVAILABLE`, still no `Location` | +| `GET /api/settings` | `500 INTERNAL_ERROR` | `503 SERVICE_UNAVAILABLE` | +| `GET /api/settings/:namespace` | `500 INTERNAL_ERROR` | `503 SERVICE_UNAVAILABLE` | +| `PUT /api/settings/:namespace` | `500 INTERNAL_ERROR` | `503 SERVICE_UNAVAILABLE` | +| `POST /api/settings/:namespace/:actionId` | `500 INTERNAL_ERROR` | `503 SERVICE_UNAVAILABLE` | + +The storage row is the one worth reading twice: an outage was answered as a **permission denial**, byte-indistinguishable from a genuine refusal, which is the precise confusion the loud-outage discipline exists to prevent. The message now names the object whose read failed and says in words that this is not a permission denial. + +**What does NOT change.** The security posture is identical — these doors were already fail-CLOSED and still are, and the storage gate still mints no capability on an outage. Every other refusal keeps its status and code: `deny` is still `403`, `unauthenticated` still `401`, an unknown namespace still `404`, a forbidden settings context still `403`, and any fault that is not this branded outage still lands on the same untyped `500 INTERNAL_ERROR` tail it did before. The repair is scoped to the brand, not to "anything carrying a status". + +**Why `patch` and not `minor`.** No API is added, removed or renamed; no exported signature moves; no authorable key changes. This is a released package delivering an envelope it already declared — a bug fix, which this repo bumps `patch`. The change *is* observable, which is why the FROM → TO table above is in the changeset body rather than encoded in the bump: a version number carries no mapping, and this text is what an upgrading consumer greps in `CHANGELOG.md`. + +**If you branch on these statuses.** A client that treated the storage `403` as "this user may not have this file" was, during an outage, retrying or re-authenticating against a fault that no credential could fix; it should now treat `503` as retryable and leave the caller's permissions alone. A client that treated the settings `500` as an unrecoverable server error can now distinguish a transient store outage from a genuine internal fault.