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
44 changes: 43 additions & 1 deletion packages/core/src/security/anonymous-deny.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,49 @@ export const ANONYMOUS_DENY_STATUS = 401 as const;
export const ANONYMOUS_DENY_CODE = 'UNAUTHENTICATED' as const;
/** Human-facing message. */
export const ANONYMOUS_DENY_MESSAGE = 'Authentication is required to access this endpoint.';
/** The single 401 body shape every seam returns: `{ error, message }`. */
/**
* 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.
*
* Exactly one consumer writes it: `@objectstack/rest`'s `enforceAuth`
* (`rest-server.ts` — `res.status(ANONYMOUS_DENY_STATUS).json(ANONYMOUS_DENY_BODY)`),
* which owns the `/data/*` and `/meta` surfaces.
*
* ## Two live envelopes, one denial (#5632)
*
* Every HTTP seam shares the DECISION ({@link shouldDenyAnonymous}) and the
* semantics ({@link ANONYMOUS_DENY_STATUS} / {@link ANONYMOUS_DENY_CODE} /
* {@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.
* - **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
* `deps.error(ANONYMOUS_DENY_MESSAGE, ANONYMOUS_DENY_STATUS, { code: ANONYMOUS_DENY_CODE })`,
* so the wire body is the dispatcher's standard wrapper:
* `{ success: false, error: { code, message, httpStatus } }`.
*
* Both shapes are **live and sanctioned** — ADR-0112's 2026-07-30 amendment
* (#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.
*
* ## Reading this from a consumer (human or AI author)
*
* Read the envelope the seam you called DECLARES — flat from `/data` + `/meta`,
* wrapped from a dispatcher-mounted surface. Do **not** write a tolerant
* `body.error?.code ?? body.error` chain that swallows both: that fallback is
* precisely where an envelope regression hides, and this docstring claiming to
* be "the single shape every seam returns" is what used to invite it (#5632).
*
* Both shapes are pinned against a real booted showcase by
* `packages/qa/dogfood/test/showcase-anonymous-deny-surfaces.dogfood.test.ts`,
* which classifies every anonymous 401 into exactly one of the two families and
* fails on a third dialect or on a seam that changes family.
*/
export const ANONYMOUS_DENY_BODY = {
error: ANONYMOUS_DENY_CODE,
message: ANONYMOUS_DENY_MESSAGE,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,12 @@

import { describe, it, expect, beforeAll } from 'vitest';
import { type VerifyStack } from '@objectstack/verify';
import {
ANONYMOUS_DENY_BODY,
ANONYMOUS_DENY_CODE,
ANONYMOUS_DENY_MESSAGE,
ANONYMOUS_DENY_STATUS,
} from '@objectstack/core';
import { getSharedShowcase } from './shared-showcase.js';

const OBJ = '/data/showcase_private_note';
Expand All @@ -55,6 +61,89 @@ const ACTION = '/actions/showcase_task/showcase_mark_done/anon-probe-id';
// genuinely exists in the app's metadata.
const FLOW = 'showcase_reassign_wizard';

// ── #5632 — the TWO declared anonymous-401 envelopes, as executable rules ───
//
// `ANONYMOUS_DENY_BODY`'s docstring in `@objectstack/core` used to call itself
// "the single 401 body shape every seam returns". It never was: it is the REST
// seam's shape, and the five dispatcher-mounted domains answer their own
// wrapper. #5632 narrowed that docstring; the declarations here are the
// executable half of the same statement, so the narrowed comment cannot rot
// back into a lie without CI saying so.
//
// ── Division of labour with the #5631 slice at the bottom of this file ─────
//
// That case reads EACH FAMILY in its own declared shape and pins the code and
// message the two share, spelled as string literals. Two things it cannot fail
// on, by construction:
// 1. a body that satisfies NEITHER declared envelope yet still survives —
// `toMatchObject` ignores unknown keys, so a re-nested or hybrid third
// dialect can pass it as long as the matched subset is still in there;
// 2. drift between the literals spelled in this file and the constants
// `@objectstack/core` actually exports — the literals would just go stale.
//
// The cases added below close exactly those two and nothing else: every
// anonymous 401 body is classified into EXACTLY ONE of the two declared
// families by mutually exclusive predicates, that family is checked against a
// declared seam→owner map, and only then are the code and message read —
// through THAT family's own reader — and compared to the exported constants.
// Deliberately NO `??` chain across the families: a tolerant cross-family read
// is the very shape #5632 exists to keep out of this codebase.

type DenyFamily = 'rest-flat' | 'dispatcher-wrapper';

const isRecord = (v: unknown): v is Record<string, unknown> =>
typeof v === 'object' && v !== null && !Array.isArray(v);

/**
* 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.
*/
const isRestFlatDeny = (body: unknown): boolean =>
isRecord(body)
&& typeof body.error === 'string'
&& typeof body.message === 'string'
&& !('success' in body);

/**
* The dispatcher's envelope — what `deps.error(msg, status, { code })` writes
* in `domains/{ai,meta,security,actions,automation}.ts`. The code lives one
* level down under `error.code` and the status is restated in the body.
*/
const isDispatcherWrapperDeny = (body: unknown): boolean => {
if (!isRecord(body) || body.success !== false || 'message' in body) return false;
const err = body.error;
return isRecord(err)
&& typeof err.code === 'string'
&& typeof err.message === 'string'
&& typeof err.httpStatus === 'number';
};

// The closed world. The two predicates are mutually exclusive where it counts:
// `error` is a STRING in one and an OBJECT in the other, and each rejects the
// other's discriminating key. Anything else on the wire matches neither.
const DENY_ENVELOPES: Record<DenyFamily, (body: unknown) => boolean> = {
'rest-flat': isRestFlatDeny,
'dispatcher-wrapper': isDispatcherWrapperDeny,
};

/**
* Every declared envelope a body satisfies. The contract is EXACTLY ONE entry:
* an empty result means a third dialect reached the wire, two entries would
* mean the declarations stopped being mutually exclusive, and the wrong single
* entry means that seam changed family.
*/
const declaredFamiliesOf = (body: unknown): DenyFamily[] =>
(Object.keys(DENY_ENVELOPES) as DenyFamily[]).filter((family) => DENY_ENVELOPES[family](body));

/** Read code + message with the family's OWN reader. Never a cross-family `??`. */
const readDenial = (family: DenyFamily, body: unknown): { code: unknown; message: unknown } => {
if (!isRecord(body)) return { code: undefined, message: undefined };
if (family === 'rest-flat') return { code: body.error, message: body.message };
const err: Record<string, unknown> = isRecord(body.error) ? body.error : {};
return { code: err.code, message: err.message };
};

describe('showcase: anonymous posture is uniform across surfaces (#2567)', () => {
let stack: VerifyStack;
let memberToken: string;
Expand Down Expand Up @@ -200,4 +289,78 @@ describe('showcase: anonymous posture is uniform across surfaces (#2567)', () =>
expect([...codes]).toEqual(['UNAUTHENTICATED']);
expect([...messages]).toEqual(['Authentication is required to access this endpoint.']);
});

// ── #5632: every 401 body is IN, and only in, one declared envelope ──────
//
// The ownership map. `owner` names the producer, so a failure reads as "this
// seam changed family" rather than "some assertion moved".
//
// Coverage, stated as measured rather than as assumed: five dispatcher
// domains hold an anonymous gate (ai / meta / security / actions /
// automation) and only the last two are drivable on THIS boot — probed on
// the same shared showcase stack these cases use:
// - `GET /ai/status` answers 501 `NOT_IMPLEMENTED` (no
// `@objectstack/service-ai` ships in the open framework, and that
// domain's gate sits BEHIND its route match, so it never runs here);
// - `GET /security/permissions` answers 404 (the showcase registration
// path mounts no `/security`);
// - `/meta` on this stack is served by `@objectstack/rest`, so it exercises
// the flat family, not the dispatcher's meta domain.
// The wrapper family is therefore represented by actions + automation. Adding
// a row is the whole change needed the day another domain becomes reachable.
const DENIED_SEAMS: Array<{
seam: string;
owner: string;
family: DenyFamily;
call: () => Promise<Response>;
}> = [
{ seam: 'GET /meta', owner: '@objectstack/rest enforceAuth', family: 'rest-flat', call: () => anon('GET', '/meta') },
{ seam: `GET ${OBJ}`, owner: '@objectstack/rest enforceAuth', family: 'rest-flat', call: () => anon('GET', OBJ) },
{ seam: 'POST /actions/:object/:action/:id', owner: 'runtime domains/actions.ts', family: 'dispatcher-wrapper', call: () => anon('POST', ACTION, { params: {} }) },
{ seam: 'POST /automation/:name/trigger', owner: 'runtime domains/automation.ts', family: 'dispatcher-wrapper', call: () => anon('POST', `/automation/${FLOW}/trigger`, {}) },
{ seam: 'GET /automation', owner: 'runtime domains/automation.ts', family: 'dispatcher-wrapper', call: () => anon('GET', '/automation') },
{ seam: 'DELETE /automation/:name', owner: 'runtime domains/automation.ts', family: 'dispatcher-wrapper', call: () => anon('DELETE', `/automation/${FLOW}`) },
];

it.each(DENIED_SEAMS)(
'anonymous $seam denies in exactly one declared envelope — $family, the one $owner writes (#5632)',
async ({ seam, family, call }) => {
const res = await call();
expect(res.status, `${seam}: anonymous must be denied with the shared status`).toBe(ANONYMOUS_DENY_STATUS);

const body = await res.json();

// ∈ the two declared shapes, and in ONLY one of them. No match at all =
// a third dialect reached the wire; the other family = this seam swapped
// wrappers. Both are red, and the message carries the body that did it.
expect(
declaredFamiliesOf(body),
`${seam}: body must satisfy the ${family} envelope and no other — got ${JSON.stringify(body)}`,
).toEqual([family]);

// Only now the values, read through THAT family's own reader and compared
// to the exported constants themselves — so this pin follows the constants
// if they ever move, instead of quietly disagreeing with them.
const { code, message } = readDenial(family, body);
expect(code, `${seam}: machine code must be ANONYMOUS_DENY_CODE`).toBe(ANONYMOUS_DENY_CODE);
expect(message, `${seam}: message must be ANONYMOUS_DENY_MESSAGE`).toBe(ANONYMOUS_DENY_MESSAGE);
},
);

it('`ANONYMOUS_DENY_BODY` is the REST seam body, and NOT the dispatcher one (#5632)', async () => {
const flat = await anon('GET', '/meta').then((r) => r.json());
const wrapped = await anon('GET', '/automation').then((r) => r.json());

// The narrowed docstring's positive claim, on the wire: the exported
// constant IS what the REST seam writes, whole.
expect(flat, 'the REST seam must write ANONYMOUS_DENY_BODY verbatim').toEqual(ANONYMOUS_DENY_BODY);

// ...and its negative half, which is the part that was missing. If the
// envelope-convergence line (#3843 family) ever lands and the dispatcher
// adopts the flat body, THIS is the case that goes red and says the comment
// above `ANONYMOUS_DENY_BODY` is due for a rewrite — the constant would
// have become platform-wide, which is exactly what it must not claim today.
expect(wrapped, 'a dispatcher seam must NOT be writing the REST constant').not.toEqual(ANONYMOUS_DENY_BODY);
expect(declaredFamiliesOf(wrapped)).toEqual(['dispatcher-wrapper']);
});
});
Loading