Skip to content

Commit ba2ffbc

Browse files
os-litantclaude
andauthored
test(rest): measure what a swallowed execution context reads as at the packages door (#13153)
The packages door swallows a failing execution-context resolution into `undefined`. The thread carried a wire-level reading of that (401 vs 403) but not an INTERNAL one, and the three internal readings that response could reflect are different security postures: 1. a subject that holds nothing (evaluated, denies), 2. an evaluation that is skipped, 3. a fall-through to a default / system subject. Measured: (1), at both clauses of the gate. `refusePackageRequest` touches the context through optional chaining only, so `undefined` is the anonymous subject rather than a branch. On every wire-reachable method the anonymous floor decides and refuses (401); with that floor isolated, the capability clause reads the same `undefined` as holding the empty capability set and refuses again (403). It fails CLOSED — not a permission-adjacent fail-open. Also measured, and it corrects the reason on record: the wrapper's `.catch(() => undefined)` is the SECOND net. `computeExecCtx` wraps its whole body in `try { … } catch { return undefined; }`, so a production resolve fulfils with `undefined` on a fault instead of rejecting, and the fault-to-anonymous conversion happens one level below the swallow. No behaviour change: a new test file, plus a comment block on the wrapper whose emit is byte-identical under `removeComments` with the instrument calibrated in both directions. Claude-Session: https://claude.ai/code/session_01UjujZN219uFzBhSYfMykCd Co-authored-by: Claude <noreply@anthropic.com>
1 parent e7dfb1d commit ba2ffbc

2 files changed

Lines changed: 430 additions & 0 deletions

File tree

Lines changed: 399 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,399 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#12537, ruling of 2026-08-29 — option B, part 2 (the rider)] What a
5+
* SWALLOWED execution-context resolution reads as INSIDE the packages door's
6+
* permission decision.
7+
*
8+
* ## The question this file answers, and the question it does NOT
9+
*
10+
* The thread already carried a wire-level reading: a resolver that throws
11+
* SYNCHRONOUSLY reaches `sendThrownError`, and one that REJECTS is swallowed
12+
* and the caller sees the anonymous-deny floor. That is an answer about which
13+
* status code comes out. The rider asks something else, and the two are not
14+
* the same question: what does an `undefined` execution context MEAN to the
15+
* gate — is it
16+
*
17+
* (1) a SUBJECT that holds nothing (evaluated, denies),
18+
* (2) an evaluation that is SKIPPED, or
19+
* (3) a fall-through to a DEFAULT / SYSTEM subject?
20+
*
21+
* ⛔ Those three can produce byte-identical responses. (2) and (3) are
22+
* fail-open postures and (1) is not, so "the status code was the same" is not
23+
* evidence that the internal reading was the same. Every case below therefore
24+
* drives a DISCRIMINATOR — an input on which the three readings disagree —
25+
* and every "did not happen" reading is stated next to a same-shaped POSITIVE
26+
* CONTROL, because a zero from an instrument that was never shown to produce a
27+
* one is a false negative, not a measurement.
28+
*
29+
* ## Measured answer: (1). The fault is folded onto the ANONYMOUS subject.
30+
*
31+
* `refusePackageRequest` (`package-routes.ts`) reads the resolved context
32+
* through optional chaining only — `ctx?.userId`, `ctx?.isSystem`,
33+
* `ctx?.systemPermissions` — so `undefined` is not a branch, it is a subject
34+
* whose every field is absent:
35+
*
36+
* - `shouldDenyAnonymous({ userId: undefined, isSystem: undefined, ... })`
37+
* returns `true` ⇒ 401 `UNAUTHENTICATED`. The gate is REACHED and it
38+
* DENIES; it is not skipped (section 3) and it does not reach a system
39+
* subject (section 2).
40+
* - When the anonymous floor is not the deciding clause, the capability
41+
* clause evaluates the same `undefined` as a subject holding the EMPTY
42+
* capability set ⇒ 403 `FORBIDDEN` (section 4). `ctx?.isSystem` is
43+
* `undefined`, so the `isSystem` escape hatch is not taken either.
44+
*
45+
* ⇒ The swallow fails CLOSED at this door. It is NOT a permission-adjacent
46+
* fail-open. What it costs is DIAGNOSABILITY, and section 5 measures that
47+
* cost exactly: a faulting resolver, an absent resolver, and a genuinely
48+
* anonymous caller are ONE answer, indistinguishable on the wire.
49+
*
50+
* ## ⭐ The swallow at the wrapper is the SECOND net, not the first
51+
*
52+
* Section 6 measures the production supplier rather than assuming it.
53+
* `RestServer.computeExecCtx` wraps its whole body in `try { ... } catch {
54+
* return undefined; }`, so the production `resolveExecCtx` RESOLVES WITH
55+
* `undefined` on a fault instead of rejecting. The `.catch(() => undefined)`
56+
* on the packages-door wrapper `resolvePackageRouteExecutionContext` — and
57+
* the second one at `package-routes.ts` — therefore have nothing to catch on
58+
* that path. The fault-to-anonymous conversion happens INSIDE
59+
* `computeExecCtx`; removing either `.catch` would not by itself surface a
60+
* production fault. ⛔ That is measured here, not repaired: un-swallowing is
61+
* explicitly NOT ruled on this card.
62+
*
63+
* ## ⛔ What this file must not become
64+
*
65+
* Nothing here asserts a status code as an END in itself; every status is
66+
* read as the OBSERVABLE of a decision, and the decision is what is pinned.
67+
* A future edit that makes the gate read `undefined` as a system or default
68+
* subject reds section 2 and section 4 — which is the whole point of writing
69+
* the reading down rather than the response.
70+
*/
71+
72+
import { describe, it, expect, vi } from 'vitest';
73+
import { ANONYMOUS_DENY_CODE, ANONYMOUS_DENY_STATUS } from '@objectstack/core';
74+
import type { RouteHandler } from '@objectstack/spec/contracts';
75+
import { registerPackageRoutes } from './package-routes.js';
76+
import { RestServer } from './rest-server.js';
77+
78+
const PKGS = '/api/v1/packages';
79+
80+
interface Captured {
81+
status: number;
82+
body: any;
83+
}
84+
85+
/** A caller holding every capability these routes gate on. */
86+
const CLEARS_THE_GATE = async () => ({
87+
userId: 'u_pkg',
88+
systemPermissions: ['manage_metadata', 'studio.access', 'setup.access'],
89+
});
90+
91+
function mount(options: Record<string, unknown> = {}): Map<string, RouteHandler> {
92+
const routes = new Map<string, RouteHandler>();
93+
const server = {
94+
get: (p: string, h: RouteHandler) => { routes.set(`GET:${p}`, h); },
95+
post: (p: string, h: RouteHandler) => { routes.set(`POST:${p}`, h); },
96+
put: (p: string, h: RouteHandler) => { routes.set(`PUT:${p}`, h); },
97+
delete: (p: string, h: RouteHandler) => { routes.set(`DELETE:${p}`, h); },
98+
patch: () => {},
99+
use: () => {},
100+
listen: async () => {},
101+
close: async () => {},
102+
} as any;
103+
registerPackageRoutes(server, () => ({ list: async () => [] }) as any, '/api/v1', {
104+
resolveExecutionContext: CLEARS_THE_GATE,
105+
...options,
106+
} as any);
107+
return routes;
108+
}
109+
110+
async function drive(
111+
routes: Map<string, RouteHandler>,
112+
method: string,
113+
path: string,
114+
req: Record<string, any> = {},
115+
): Promise<Captured> {
116+
const handler = routes.get(`${method}:${path}`);
117+
if (!handler) throw new Error(`no handler for ${method} ${path}`);
118+
const captured: Captured = { status: 0, body: undefined };
119+
const res: any = {
120+
json(data: any) { captured.body = data; },
121+
send() {},
122+
status(code: number) { captured.status = code; return res; },
123+
header() { return res; },
124+
};
125+
await handler(
126+
{ params: {}, query: {}, body: undefined, headers: {}, method, path, ...req } as any,
127+
res,
128+
);
129+
return captured;
130+
}
131+
132+
/** `GET /packages` under one resolver wiring. `undefined` ⇒ no resolver wired. */
133+
const listUnder = (resolveExecutionContext: unknown, req: Record<string, any> = {}) =>
134+
drive(
135+
mount(resolveExecutionContext === undefined ? { resolveExecutionContext: undefined } : { resolveExecutionContext }),
136+
'GET',
137+
PKGS,
138+
req,
139+
);
140+
141+
/** The three ways this door can end up holding `undefined`. */
142+
const REJECTS = async () => { throw new Error('resolver fault'); };
143+
const RESOLVES_UNDEFINED = async () => undefined;
144+
145+
// ---------------------------------------------------------------------------
146+
// 1. POSITIVE CONTROLS — this harness can observe an ALLOW, and can observe
147+
// each refusal clause separately. Every zero below is read against these.
148+
// ---------------------------------------------------------------------------
149+
150+
describe('[#12537] controls — the instrument produces a one before any zero is read', () => {
151+
it('CONTROL (allow is observable): a fully capable context is served 200', async () => {
152+
const captured = await listUnder(CLEARS_THE_GATE);
153+
expect(captured.status).toBe(200);
154+
expect(captured.body?.success).toBe(true);
155+
});
156+
157+
it('CONTROL (the anonymous clause is observable): a named subject is NOT 401', async () => {
158+
const captured = await listUnder(async () => ({ userId: 'u_named', systemPermissions: [] }));
159+
expect(captured.status).not.toBe(ANONYMOUS_DENY_STATUS);
160+
expect(captured.status).toBe(403);
161+
expect(captured.body?.error?.code).toBe('FORBIDDEN');
162+
});
163+
164+
it('CONTROL (the resolver really runs): the door calls it exactly once per request', async () => {
165+
const resolver = vi.fn(REJECTS);
166+
await listUnder(resolver);
167+
expect(resolver.mock.calls.length).toBe(1);
168+
});
169+
170+
it('CONTROL (the rejection really rejects): the injected resolver is a rejecting promise', async () => {
171+
await expect(REJECTS()).rejects.toThrow('resolver fault');
172+
});
173+
});
174+
175+
// ---------------------------------------------------------------------------
176+
// 2. READING (3) FALSIFIED — `undefined` is NOT a default / system subject.
177+
// ---------------------------------------------------------------------------
178+
179+
describe('[#12537] a swallowed resolution does not fall through to a system subject', () => {
180+
it('a rejecting resolver is REFUSED, not served', async () => {
181+
const captured = await listUnder(REJECTS);
182+
expect(captured.status).toBe(ANONYMOUS_DENY_STATUS);
183+
expect(captured.body?.success).toBe(false);
184+
expect(captured.body?.error?.code).toBe(ANONYMOUS_DENY_CODE);
185+
});
186+
187+
it('CONTROL: a real system subject IS served — so "refused" above is a decision, not an artefact', async () => {
188+
const captured = await listUnder(async () => ({ isSystem: true }));
189+
expect(captured.status).toBe(200);
190+
expect(captured.body?.success).toBe(true);
191+
});
192+
});
193+
194+
// ---------------------------------------------------------------------------
195+
// 3. READING (2) FALSIFIED — the gate is not SKIPPED for `undefined`.
196+
// ---------------------------------------------------------------------------
197+
198+
describe('[#12537] a swallowed resolution does not bypass the gate', () => {
199+
it('the service is never reached when the resolver rejects', async () => {
200+
const list = vi.fn(async () => []);
201+
const routes = new Map<string, RouteHandler>();
202+
const server = {
203+
get: (p: string, h: RouteHandler) => { routes.set(`GET:${p}`, h); },
204+
post: () => {}, put: () => {}, delete: () => {}, patch: () => {},
205+
use: () => {}, listen: async () => {}, close: async () => {},
206+
} as any;
207+
registerPackageRoutes(server, () => ({ list }) as any, '/api/v1', {
208+
resolveExecutionContext: REJECTS,
209+
} as any);
210+
const captured = await drive(routes, 'GET', PKGS);
211+
expect(captured.status).toBe(ANONYMOUS_DENY_STATUS);
212+
// ⚠️ ZERO. Its control is the next assertion, on the SAME `list` spy shape.
213+
expect(list.mock.calls.length).toBe(0);
214+
});
215+
216+
it('CONTROL: the same spy DOES record a call when the gate is cleared', async () => {
217+
const list = vi.fn(async () => []);
218+
const routes = new Map<string, RouteHandler>();
219+
const server = {
220+
get: (p: string, h: RouteHandler) => { routes.set(`GET:${p}`, h); },
221+
post: () => {}, put: () => {}, delete: () => {}, patch: () => {},
222+
use: () => {}, listen: async () => {}, close: async () => {},
223+
} as any;
224+
registerPackageRoutes(server, () => ({ list }) as any, '/api/v1', {
225+
resolveExecutionContext: CLEARS_THE_GATE,
226+
} as any);
227+
const captured = await drive(routes, 'GET', PKGS);
228+
expect(captured.status).toBe(200);
229+
expect(list.mock.calls.length).toBe(1);
230+
});
231+
});
232+
233+
// ---------------------------------------------------------------------------
234+
// 4. READING (1) CONFIRMED — the CAPABILITY clause evaluates `undefined` as a
235+
// subject holding the EMPTY set.
236+
//
237+
// ⚠️ DISCLOSURE, so no reader mistakes this for a wire path: the packages
238+
// registrar mounts exactly four routes (POST publish, GET list, GET by id,
239+
// DELETE by id) and NO `OPTIONS` route, so a real preflight never reaches
240+
// these handlers. `method: 'OPTIONS'` is used here as the one INPUT that
241+
// makes the shared `shouldDenyAnonymous` yield without authenticating —
242+
// i.e. as an instrument for isolating the capability clause from the
243+
// anonymous clause, which otherwise short-circuits ahead of it. That
244+
// isolation is the only way to tell "evaluated and holds nothing" apart
245+
// from "never evaluated": on a plain GET both readings answer 401.
246+
// ---------------------------------------------------------------------------
247+
248+
describe('[#12537] the capability clause reads `undefined` as "holds nothing"', () => {
249+
it('past the anonymous clause, a swallowed resolution is 403 FORBIDDEN', async () => {
250+
const captured = await listUnder(REJECTS, { method: 'OPTIONS' });
251+
expect(captured.status).toBe(403);
252+
expect(captured.body?.error?.code).toBe('FORBIDDEN');
253+
expect(captured.body?.error?.message).toContain('studio.access');
254+
});
255+
256+
it('CONTROL: past the same clause, a CAPABLE context is served 200', async () => {
257+
const captured = await listUnder(CLEARS_THE_GATE, { method: 'OPTIONS' });
258+
expect(captured.status).toBe(200);
259+
expect(captured.body?.success).toBe(true);
260+
});
261+
262+
it('CONTROL: past the same clause, an explicit EMPTY capability set is the same 403', async () => {
263+
const captured = await listUnder(
264+
async () => ({ userId: 'u_named', systemPermissions: [] }),
265+
{ method: 'OPTIONS' },
266+
);
267+
expect(captured.status).toBe(403);
268+
expect(captured.body?.error?.code).toBe('FORBIDDEN');
269+
});
270+
});
271+
272+
// ---------------------------------------------------------------------------
273+
// 5. THE COST — the three origins of `undefined` are ONE answer.
274+
// This is the defect the card actually describes: not an over-permission,
275+
// a refusal whose stated reason is wrong for two of the three origins.
276+
// ---------------------------------------------------------------------------
277+
278+
describe('[#12537] a resolver FAULT is indistinguishable from anonymity and from no resolver', () => {
279+
it('rejecting resolver, resolver returning undefined, and no resolver agree byte for byte', async () => {
280+
const [faulted, anonymous, unwired] = await Promise.all([
281+
listUnder(REJECTS),
282+
listUnder(RESOLVES_UNDEFINED),
283+
listUnder(undefined),
284+
]);
285+
expect(faulted.status).toBe(ANONYMOUS_DENY_STATUS);
286+
expect(JSON.stringify(faulted)).toBe(JSON.stringify(anonymous));
287+
expect(JSON.stringify(faulted)).toBe(JSON.stringify(unwired));
288+
});
289+
290+
it('CONTROL: the same comparison SEPARATES two answers that differ', async () => {
291+
const [faulted, capable] = await Promise.all([listUnder(REJECTS), listUnder(CLEARS_THE_GATE)]);
292+
expect(JSON.stringify(faulted)).not.toBe(JSON.stringify(capable));
293+
});
294+
295+
it('every state-changing route reads the fault the same way', async () => {
296+
const routes = mount({ resolveExecutionContext: REJECTS });
297+
const del = await drive(routes, 'DELETE', `${PKGS}/:id`, { params: { id: 'com.acme.crm' } });
298+
const pub = await drive(routes, 'POST', `${PKGS}/publish`, {
299+
body: { manifest: { id: 'com.acme.crm', version: '1.0.0' } },
300+
});
301+
expect(del.status).toBe(ANONYMOUS_DENY_STATUS);
302+
expect(pub.status).toBe(ANONYMOUS_DENY_STATUS);
303+
});
304+
});
305+
306+
// ---------------------------------------------------------------------------
307+
// 6. THE PRODUCTION SUPPLIER — the wrapper's `.catch` is the SECOND net.
308+
// ---------------------------------------------------------------------------
309+
310+
describe('[#12537] the production resolver resolves with `undefined`; it does not reject', () => {
311+
const restWith = (authServiceProvider: (environmentId?: string) => Promise<any>) =>
312+
new RestServer(
313+
{ get: () => {}, post: () => {}, put: () => {}, delete: () => {}, patch: () => {}, use: () => {} } as any,
314+
{} as any,
315+
{} as any,
316+
undefined,
317+
undefined,
318+
undefined,
319+
authServiceProvider,
320+
);
321+
322+
/** Fulfilled-or-rejected, as an observable value rather than an assertion. */
323+
const settle = async (p: Promise<unknown>): Promise<'fulfilled' | 'rejected'> =>
324+
p.then(() => 'fulfilled' as const, () => 'rejected' as const);
325+
326+
it('CONTROL: the witness distinguishes a rejected promise from a fulfilled one', async () => {
327+
expect(await settle(Promise.reject(new Error('control')))).toBe('rejected');
328+
expect(await settle(Promise.resolve(1))).toBe('fulfilled');
329+
});
330+
331+
it('a faulting auth-service provider yields a FULFILLED `undefined` from the private resolver', async () => {
332+
const rest = restWith(() => { throw new Error('auth service exploded'); });
333+
const req = { params: {}, headers: {}, method: 'GET', path: PKGS };
334+
// ⭐ The PRIVATE resolver, read BEFORE the wrapper's `.catch` can act —
335+
// this is what shows the wrapper is not the thing converting the fault.
336+
const inner = (rest as any).resolveExecCtx(undefined, req);
337+
expect(await settle(inner)).toBe('fulfilled');
338+
expect(await inner).toBeUndefined();
339+
});
340+
341+
it('CONTROL: when the inner resolve DOES reject, the wrapper is what swallows it', async () => {
342+
const rest = restWith(async () => undefined);
343+
(rest as any).computeExecCtx = async () => { throw new Error('injected inner rejection'); };
344+
const req = { params: {}, headers: {}, method: 'GET', path: PKGS };
345+
// Same shape as the case above, with the one difference under test — so the
346+
// "fulfilled" reading there is a property of `computeExecCtx`, not of the
347+
// instrument.
348+
expect(await settle((rest as any).resolveExecCtx(undefined, req))).toBe('rejected');
349+
expect(await settle(rest.resolvePackageRouteExecutionContext(req))).toBe('fulfilled');
350+
expect(await rest.resolvePackageRouteExecutionContext({ ...req })).toBeUndefined();
351+
});
352+
});
353+
354+
// ---------------------------------------------------------------------------
355+
// 7. ⚠️ A CORRECTION to the reading this card's thread carried.
356+
//
357+
// The thread records "synchronous throw ⇒ 403 PERMISSION_DENIED" beside
358+
// "asynchronous rejection ⇒ 401" as if both were facts ABOUT THIS DOOR.
359+
// Only the second one is. A synchronous throw does not produce an
360+
// `undefined` context at all — it escapes the wrapper (which is NOT
361+
// `async`, so there is no promise for either `.catch` to attach to),
362+
// lands in the route's own `try`, and is answered by `sendThrownError` →
363+
// `resolveThrownHttpError`, which reads the STATUS OFF THE THROWN ERROR.
364+
// So the 403 is a property of the error that was injected, not a decision
365+
// this gate made: the same seam, thrown a plain `Error`, answers 500.
366+
//
367+
// ⇒ The two limbs are not two readings of one context. One is a refusal
368+
// DECIDED by the gate; the other is a status FORWARDED from a producer.
369+
// Nothing here re-opens the seam census: a production wrapper still cannot
370+
// throw synchronously (`req?.params?.environmentId` is optional-chained and
371+
// every downstream call resolves), so this limb remains the declared
372+
// test-only injection point.
373+
// ---------------------------------------------------------------------------
374+
375+
describe('[#12537] a SYNC throw is forwarded from the producer, not decided by the gate', () => {
376+
const throwsSync = (error: unknown) => () => { throw error; };
377+
378+
it('a coded producer error keeps ITS status — the thread\'s 403 is this, not a gate decision', async () => {
379+
const captured = await listUnder(
380+
throwsSync(Object.assign(new Error('nope'), { code: 'PERMISSION_DENIED', status: 403 })),
381+
);
382+
expect(captured.status).toBe(403);
383+
expect(captured.body?.error?.code).toBe('PERMISSION_DENIED');
384+
});
385+
386+
it('the SAME seam, thrown a plain Error, answers 500 — so the status tracks the error', async () => {
387+
const captured = await listUnder(throwsSync(new Error('nope')));
388+
expect(captured.status).toBe(500);
389+
expect(captured.body?.error?.code).not.toBe('PERMISSION_DENIED');
390+
});
391+
392+
it('and neither of those is the swallowed case: a REJECTION is still the 401 floor', async () => {
393+
const captured = await listUnder(
394+
async () => { throw Object.assign(new Error('nope'), { code: 'PERMISSION_DENIED', status: 403 }); },
395+
);
396+
expect(captured.status).toBe(ANONYMOUS_DENY_STATUS);
397+
expect(captured.body?.error?.code).toBe(ANONYMOUS_DENY_CODE);
398+
});
399+
});

0 commit comments

Comments
 (0)