Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .changeset/anonymous-deny-401-code-key.md
Original file line numberDiff line numberDiff line change
@@ -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.
9 changes: 8 additions & 1 deletion packages/core/src/security/anonymous-deny.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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),
});
});
});
18 changes: 13 additions & 5 deletions packages/core/src/security/anonymous-deny.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)`),
Expand All@@ -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
Expand All@@ -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)
*
Expand All@@ -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;

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -107,7 +107,10 @@ const isRecord = (v: unknown): v is Record<string, unknown> =>
/**
* 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)
Expand DownExpand Up@@ -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: <CODE>, message }`); the dispatcher returns its standard
// (`{ error: <CODE>, code: <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.',
});
}
Expand Down
Loading