Skip to content

Commit 2782805

Browse files
huangyiireneclaude
andauthored
feat(security): 401 anonymous-deny body carries code: UNAUTHENTICATED alongside error/message (#9487) (#9824)
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. Claude-Session: https://claude.ai/code/session_01WeN7F6jQFpcqW2BN56RdPa Co-authored-by: Claude <noreply@anthropic.com>
1 parent bbd86ed commit 2782805

4 files changed

Lines changed: 49 additions & 8 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
"@objectstack/core": minor
3+
---
4+
5+
feat(security): the REST 401 anonymous-deny body carries `code: "UNAUTHENTICATED"` alongside the existing `error` / `message` keys (#9487)
6+
7+
Every other REST error family answers `{ error, code }`, with the machine code
8+
in `code` — the 401 family was the one outlier, answering
9+
`{ error: "UNAUTHENTICATED", message }` with no `code` key at all. A client
10+
keying on `body.code` (the shape the other families teach, and the first read
11+
of `@objectstack/client`'s `err.code`) read `undefined` for every
12+
authentication failure.
13+
14+
`ANONYMOUS_DENY_BODY` now carries `code: "UNAUTHENTICATED"` as well.
15+
**Additive only** (maintainer-ruled): no key is removed or moved — `error`
16+
keeps holding the same code value it always has, so every existing reader
17+
keeps working. The wire effect surfaces through `@objectstack/rest`'s
18+
`enforceAuth`, which writes this constant verbatim on every `/data`, `/meta`
19+
and `/reports` 401. This does not settle ADR-0112 D5 (flat vs nested envelope
20+
convergence); both declared envelope families are unchanged in kind.

packages/core/src/security/anonymous-deny.test.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,13 @@ describe('shouldDenyAnonymous — the shared HTTP anonymous-deny decision (#2567
4949

5050
it('exposes a stable 401 body + status for seams to return', () => {
5151
expect(ANONYMOUS_DENY_STATUS).toBe(401);
52-
expect(ANONYMOUS_DENY_BODY).toEqual({ error: 'UNAUTHENTICATED', message: expect.any(String) });
52+
// [#9487] `code` carries the machine code — the documented key every other
53+
// REST error family answers. ADDITIVE by maintainer ruling: `error` keeps
54+
// holding the same code value it always has, so no existing reader breaks.
55+
expect(ANONYMOUS_DENY_BODY).toEqual({
56+
error: 'UNAUTHENTICATED',
57+
code: 'UNAUTHENTICATED',
58+
message: expect.any(String),
59+
});
5360
});
5461
});

packages/core/src/security/anonymous-deny.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,9 @@ export const ANONYMOUS_DENY_CODE = 'UNAUTHENTICATED' as const;
4040
/** Human-facing message. */
4141
export const ANONYMOUS_DENY_MESSAGE = 'Authentication is required to access this endpoint.';
4242
/**
43-
* The **REST seam's** 401 body — flat `{ error, message }`. NOT the platform's
44-
* only one; see the two-envelope table below before you reuse this shape.
43+
* The **REST seam's** 401 body — flat `{ error, code, message }`. NOT the
44+
* platform's only one; see the two-envelope table below before you reuse this
45+
* shape.
4546
*
4647
* Exactly one consumer writes it: `@objectstack/rest`'s `enforceAuth`
4748
* (`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
5455
* {@link ANONYMOUS_DENY_MESSAGE}). What differs is the **wrapper**:
5556
*
5657
* - **REST seam** — `@objectstack/rest` `enforceAuth`, this constant, verbatim:
57-
* `{ error: 'UNAUTHENTICATED', message: '…' }`. The code is the value of the
58-
* top-level `error` key; there is no `success` key and no nesting.
58+
* `{ error: 'UNAUTHENTICATED', code: 'UNAUTHENTICATED', message: '…' }`.
59+
* The machine code lives in the top-level `code` key — the same documented
60+
* key every other REST error family answers (#9487, maintainer-ruled
61+
* ADDITIVE: `error` keeps carrying the code value it always has, so no
62+
* existing reader breaks). There is no `success` key and no nesting.
5963
* - **Dispatcher seams** — the five runtime domains `domains/ai.ts`,
6064
* `domains/meta.ts`, `domains/security.ts`, `domains/actions.ts` and
6165
* `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
6771
* (#4007) records the flat and wrapped envelopes as the two live ones, and
6872
* assigns retiring one of them to the envelope-convergence line (#3843 family).
6973
* Converging them is a breaking wire change; it is not this module's to make,
70-
* and this constant must not be read as if it had already happened.
74+
* and this constant must not be read as if it had already happened. The #9487
75+
* `code` key does NOT settle that question either way (ADR-0112 D5 stays
76+
* open): it aligns the flat family to the `{ error, code }` shape the other
77+
* flat REST error families already answer, without moving or removing a key.
7178
*
7279
* ## Reading this from a consumer (human or AI author)
7380
*
@@ -84,6 +91,7 @@ export const ANONYMOUS_DENY_MESSAGE = 'Authentication is required to access this
8491
*/
8592
export const ANONYMOUS_DENY_BODY = {
8693
error: ANONYMOUS_DENY_CODE,
94+
code: ANONYMOUS_DENY_CODE,
8795
message: ANONYMOUS_DENY_MESSAGE,
8896
} as const;
8997

packages/qa/dogfood/test/showcase-anonymous-deny-surfaces.dogfood.test.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,10 @@ const isRecord = (v: unknown): v is Record<string, unknown> =>
107107
/**
108108
* The REST seam's envelope — `@objectstack/rest` `enforceAuth` writing
109109
* `ANONYMOUS_DENY_BODY` verbatim. The machine code IS the top-level `error`
110-
* value; there is no wrapper around it and no `success` flag.
110+
* value — and, since #9487, also the top-level `code` key (additive); there is
111+
* no wrapper around it and no `success` flag. The predicate deliberately does
112+
* not require `code`: family membership is judged on the discriminating keys,
113+
* and the exact body is pinned strictly elsewhere in this file.
111114
*/
112115
const isRestFlatDeny = (body: unknown): boolean =>
113116
isRecord(body)
@@ -369,11 +372,14 @@ describe('showcase: anonymous posture is uniform across surfaces (#2567)', () =>
369372
// Each family is read in ITS OWN declared shape — no `??` chain across the
370373
// two, because a tolerant reader here would hide the day one of them
371374
// changes. `@objectstack/rest` returns the flat `ANONYMOUS_DENY_BODY`
372-
// (`{ error: <CODE>, message }`); the dispatcher returns its standard
375+
// (`{ error: <CODE>, code: <CODE>, message }` — `code` added by #9487,
376+
// additive, aligning the 401 family to the `{ error, code }` shape every
377+
// other REST error family answers); the dispatcher returns its standard
373378
// wrapper (`{ success: false, error: { code, message, httpStatus } }`).
374379
for (const body of rest) {
375380
expect(body).toEqual({
376381
error: 'UNAUTHENTICATED',
382+
code: 'UNAUTHENTICATED',
377383
message: 'Authentication is required to access this endpoint.',
378384
});
379385
}

0 commit comments

Comments
 (0)