From b0d5633c5a6be8874e4fbfe37201f2368f0b37a0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 00:55:40 +0000 Subject: [PATCH] feat(security): 401 anonymous-deny body carries code: UNAUTHENTICATED alongside error/message (#9487) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Maintainer-ruled additive change: every other REST error family answers { error, code } with the machine code in code; the 401 family was the one outlier. ANONYMOUS_DENY_BODY gains code: ANONYMOUS_DENY_CODE — no key removed or moved, so no existing reader breaks. The two strict pins that asserted the old two-key shape (core anonymous-deny.test.ts, dogfood showcase-anonymous-deny-surfaces) are updated to the new exact shape, not loosened. The dogfood two-family classifier is untouched: isRestFlatDeny judges family on discriminating keys and tolerates the additive code key (verified against a real booted showcase, 25/25 green). Does not settle ADR-0112 D5 (flat vs nested convergence, #9559): both declared envelope families are unchanged in kind. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WeN7F6jQFpcqW2BN56RdPa --- .changeset/anonymous-deny-401-code-key.md | 20 +++++++++++++++++++ .../core/src/security/anonymous-deny.test.ts | 9 ++++++++- packages/core/src/security/anonymous-deny.ts | 18 ++++++++++++----- ...se-anonymous-deny-surfaces.dogfood.test.ts | 10 ++++++++-- 4 files changed, 49 insertions(+), 8 deletions(-) create mode 100644 .changeset/anonymous-deny-401-code-key.md diff --git a/.changeset/anonymous-deny-401-code-key.md b/.changeset/anonymous-deny-401-code-key.md new file mode 100644 index 0000000000..f6430b43fc --- /dev/null +++ b/.changeset/anonymous-deny-401-code-key.md @@ -0,0 +1,20 @@ +--- +"@objectstack/core": minor +--- + +feat(security): the REST 401 anonymous-deny body carries `code: "UNAUTHENTICATED"` alongside the existing `error` / `message` keys (#9487) + +Every other REST error family answers `{ error, code }`, with the machine code +in `code` — the 401 family was the one outlier, answering +`{ error: "UNAUTHENTICATED", message }` with no `code` key at all. A client +keying on `body.code` (the shape the other families teach, and the first read +of `@objectstack/client`'s `err.code`) read `undefined` for every +authentication failure. + +`ANONYMOUS_DENY_BODY` now carries `code: "UNAUTHENTICATED"` as well. +**Additive only** (maintainer-ruled): no key is removed or moved — `error` +keeps holding the same code value it always has, so every existing reader +keeps working. The wire effect surfaces through `@objectstack/rest`'s +`enforceAuth`, which writes this constant verbatim on every `/data`, `/meta` +and `/reports` 401. This does not settle ADR-0112 D5 (flat vs nested envelope +convergence); both declared envelope families are unchanged in kind. diff --git a/packages/core/src/security/anonymous-deny.test.ts b/packages/core/src/security/anonymous-deny.test.ts index 63aa2a784d..65a954e0b0 100644 --- a/packages/core/src/security/anonymous-deny.test.ts +++ b/packages/core/src/security/anonymous-deny.test.ts @@ -49,6 +49,13 @@ describe('shouldDenyAnonymous — the shared HTTP anonymous-deny decision (#2567 it('exposes a stable 401 body + status for seams to return', () => { expect(ANONYMOUS_DENY_STATUS).toBe(401); - expect(ANONYMOUS_DENY_BODY).toEqual({ error: 'UNAUTHENTICATED', message: expect.any(String) }); + // [#9487] `code` carries the machine code — the documented key every other + // REST error family answers. ADDITIVE by maintainer ruling: `error` keeps + // holding the same code value it always has, so no existing reader breaks. + expect(ANONYMOUS_DENY_BODY).toEqual({ + error: 'UNAUTHENTICATED', + code: 'UNAUTHENTICATED', + message: expect.any(String), + }); }); }); diff --git a/packages/core/src/security/anonymous-deny.ts b/packages/core/src/security/anonymous-deny.ts index 59ec5c8b30..27ed8717ac 100644 --- a/packages/core/src/security/anonymous-deny.ts +++ b/packages/core/src/security/anonymous-deny.ts @@ -40,8 +40,9 @@ export const ANONYMOUS_DENY_CODE = 'UNAUTHENTICATED' as const; /** Human-facing message. */ export const ANONYMOUS_DENY_MESSAGE = 'Authentication is required to access this endpoint.'; /** - * The **REST seam's** 401 body — flat `{ error, message }`. NOT the platform's - * only one; see the two-envelope table below before you reuse this shape. + * The **REST seam's** 401 body — flat `{ error, code, message }`. NOT the + * platform's only one; see the two-envelope table below before you reuse this + * shape. * * Exactly one consumer writes it: `@objectstack/rest`'s `enforceAuth` * (`rest-server.ts` — `res.status(ANONYMOUS_DENY_STATUS).json(ANONYMOUS_DENY_BODY)`), @@ -54,8 +55,11 @@ export const ANONYMOUS_DENY_MESSAGE = 'Authentication is required to access this * {@link ANONYMOUS_DENY_MESSAGE}). What differs is the **wrapper**: * * - **REST seam** — `@objectstack/rest` `enforceAuth`, this constant, verbatim: - * `{ error: 'UNAUTHENTICATED', message: '…' }`. The code is the value of the - * top-level `error` key; there is no `success` key and no nesting. + * `{ error: 'UNAUTHENTICATED', code: 'UNAUTHENTICATED', message: '…' }`. + * The machine code lives in the top-level `code` key — the same documented + * key every other REST error family answers (#9487, maintainer-ruled + * ADDITIVE: `error` keeps carrying the code value it always has, so no + * existing reader breaks). There is no `success` key and no nesting. * - **Dispatcher seams** — the five runtime domains `domains/ai.ts`, * `domains/meta.ts`, `domains/security.ts`, `domains/actions.ts` and * `domains/automation.ts` do NOT use this constant. Each calls @@ -67,7 +71,10 @@ export const ANONYMOUS_DENY_MESSAGE = 'Authentication is required to access this * (#4007) records the flat and wrapped envelopes as the two live ones, and * assigns retiring one of them to the envelope-convergence line (#3843 family). * Converging them is a breaking wire change; it is not this module's to make, - * and this constant must not be read as if it had already happened. + * and this constant must not be read as if it had already happened. The #9487 + * `code` key does NOT settle that question either way (ADR-0112 D5 stays + * open): it aligns the flat family to the `{ error, code }` shape the other + * flat REST error families already answer, without moving or removing a key. * * ## Reading this from a consumer (human or AI author) * @@ -84,6 +91,7 @@ export const ANONYMOUS_DENY_MESSAGE = 'Authentication is required to access this */ export const ANONYMOUS_DENY_BODY = { error: ANONYMOUS_DENY_CODE, + code: ANONYMOUS_DENY_CODE, message: ANONYMOUS_DENY_MESSAGE, } as const; diff --git a/packages/qa/dogfood/test/showcase-anonymous-deny-surfaces.dogfood.test.ts b/packages/qa/dogfood/test/showcase-anonymous-deny-surfaces.dogfood.test.ts index 18abb7c2e4..dda75e5c19 100644 --- a/packages/qa/dogfood/test/showcase-anonymous-deny-surfaces.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-anonymous-deny-surfaces.dogfood.test.ts @@ -107,7 +107,10 @@ const isRecord = (v: unknown): v is Record => /** * The REST seam's envelope — `@objectstack/rest` `enforceAuth` writing * `ANONYMOUS_DENY_BODY` verbatim. The machine code IS the top-level `error` - * value; there is no wrapper around it and no `success` flag. + * value — and, since #9487, also the top-level `code` key (additive); there is + * no wrapper around it and no `success` flag. The predicate deliberately does + * not require `code`: family membership is judged on the discriminating keys, + * and the exact body is pinned strictly elsewhere in this file. */ const isRestFlatDeny = (body: unknown): boolean => isRecord(body) @@ -369,11 +372,14 @@ describe('showcase: anonymous posture is uniform across surfaces (#2567)', () => // Each family is read in ITS OWN declared shape — no `??` chain across the // two, because a tolerant reader here would hide the day one of them // changes. `@objectstack/rest` returns the flat `ANONYMOUS_DENY_BODY` - // (`{ error: , message }`); the dispatcher returns its standard + // (`{ error: , code: , message }` — `code` added by #9487, + // additive, aligning the 401 family to the `{ error, code }` shape every + // other REST error family answers); the dispatcher returns its standard // wrapper (`{ success: false, error: { code, message, httpStatus } }`). for (const body of rest) { expect(body).toEqual({ error: 'UNAUTHENTICATED', + code: 'UNAUTHENTICATED', message: 'Authentication is required to access this endpoint.', }); }