Skip to content

Commit 6ed0c0f

Browse files
test(runtime): pin the guest principal an anonymous request executes as at authRequired:false endpoints (#22177)
Part of #22147 Clause-②: no ## Summary **The card's premise does not hold on `origin/main` `fec87e7e`.** An anonymous request admitted by an `authRequired: false` declared endpoint already executes as the guest principal that ruling C names: `principalKind: 'guest'`, `positions: ['guest']`, `isSystem: false`. It is not principal-less. This was measured on a real boot, by class. - **Why:** the dispatcher's scope resolution (`HttpDispatcher.resolveRequestScope` → `resolveExecutionContext`) takes the explicit guest entry, `assembleExecutionContextOrGuest`, on the runtime face. It has done so since commit `f586f1a89`. - **Where the misreading came from:** a doc comment in `endpoint-executor.ts` that said "`undefined` for anonymous". So this PR builds no behaviour change. It does two things: 1. **Pins the ruled posture**, each pin with an ablation, at two tiers: over a socket (runtime) and on a real boot (dogfood). Once #21908's engine-level deny lands, a guest and a principal-less call will both answer `403`. After that, these pins are the only check that can tell them apart. 2. **Corrects the three comments** that read as principal-less execution. The diff publishes nothing (measured below), so it carries no changeset. ## Who reaches what, before and after (by class) **Before = after.** No runtime behaviour changes. The readings below were measured at `fec87e7e` with a throwaway, uncommitted probe. The new pins assert the same facts at `49546b0c`. | Request (anonymous unless noted) | What is handed down | Security middleware | Driver | Answer | |---|---|---|---|---| | `object_operation` find, get or create, `authRequired: false` | the guest envelope, no user | Passes the gates that run before the principal-less hand-off (none fires). Is not handed through. Is refused at step 2, the CRUD check (ADR-0056 D2 deny baseline). | never reached | `403 PERMISSION_DENIED`; no byte of the record; a create writes nothing | | the same operation, `authRequired: true` | n/a | n/a | n/a | `401 UNAUTHENTICATED`, before the engine is asked anything | | a signed-in user at the `authRequired: false` endpoint | that user (`principalKind: 'human'`) | normal evaluation | reached when granted | served as that user | | `flow` with a `runAs: 'user'` target, `authRequired: false` | the automation service receives `positions: ['guest']`, with no user, no `isSystem` and no `runAs` | none: the run's data step never reaches the engine | never reached | `400 FLOW_FAILED`: the automation service refuses a user-less `runAs: 'user'` run fail-closed | | `flow` with a `runAs: 'system'` target, `authRequired: false` | the same hand-off as the row above | the flow's data steps run as the system principal, by the flow's own `runAs` declaration | reached | the flow's result (see the open question below) | **The guest's refusal is the same as a member's.** A signed-in member without a grant gets the same refusal at this door: the same throw site and the same `403 PERMISSION_DENIED`, byte-identical. The built-in `/data` route never evaluates a guest at all; on the REST face it answers an anonymous caller `401`. ## Pins and their ablations Each ablation was a one-shot, uncommitted mutation made with `scripts/ablation-replace.mjs`. Each was restored, and the restore was verified as blob equal to HEAD with an empty `git diff HEAD`. | Pin | Where | Ablation | Result | |---|---|---|---| | An anonymous `object_operation` reaches the engine as the guest, never principal-less | runtime socket test; dogfood find, get and create | **A1:** the runtime face's guest entry returns no context for a sessionless request | Socket: 2 of 21 red. Real boot: runtime rebuilt, marker proven in `dist/` and then absent after the restore rebuild; 4 of 6 red. The find and the get were **served the row (200)**, and the create **wrote a row (201)**. | | Denied, and nothing of the row disclosed | dogfood | A1 (above) | red, as above | | `authRequired: true` keeps answering 401 | runtime socket test; dogfood | **A2:** the policy's `authRequired` branch disabled | Socket: 5 of 21 red (this pin and 4 existing ones) | | `isSystem` is never set on the anonymous path | runtime socket test | **A3:** `isSystem: true` injected into the step's context | 2 of 21 red | | The flow executor is handed the guest, with no user and no elevation | runtime socket test; dogfood | A1 (the guest position disappears), and **A4:** an `isSystem` flag injected into the door's automation context | A1: red at both tiers. A4: 1 of 21 red. | | An authenticated request at `authRequired: false` runs as that user | runtime socket test; dogfood | **A5:** the user id dropped from the runtime face's input | 2 of 21 red | A4's first attempt was void. `ablation-replace` refused it before any test ran, because the replacement text repeated the anchor. It was redone with a replacement that does not. ## Evidence (at `49546b0c`) - `pnpm --filter @objectstack/runtime test` (the `local` project): 333 files, 4714 passed, 19 skipped. - `pnpm --filter @objectstack/runtime typecheck`: exit 0. The test-layer debt ledger held. - `pnpm --filter @objectstack/dogfood typecheck`: exit 0. `--listFiles` confirms the new file is compiled. - Dogfood: `declarative-endpoint-anonymous-guest` (6) and `declarative-endpoint-policy` (8), 14 passed. - Gates: all 67 commands the dispatch named exit 0. `dispatch-gates --ran`: 59 derived, 59 run, 0 NOT-MEASURED, 0 UNRUN. - `check:dual-build-cjs-loads` first answered PREREQUISITE NOT MET: 8 packages had no `dist/`. After building them it exits 0. - `pnpm lint` (full): exit 0. ## No changeset - **The diff publishes nothing from `@objectstack/runtime`.** Two of the three corrected comments sit on interfaces the package entry does not export. The third sits inside a function body, which the bundler strips. Built, none of the three new texts appears anywhere under `packages/runtime/dist/`. The control does appear there: the documentation of the exported `createDispatcherPlugin`. Tests do not ship. - **There is no release note to write**, because nothing changed for anonymous endpoints. - **This departs from the dispatch.** It asked for a `patch` changeset and forbade `skip-changeset`, both on the assumption that the PR would change code. This PR therefore carries neither, and `Check Changeset` will read red until the seat decides. ## Acceptance notes - **The identity-resolution fault path.** When identity resolution hits a fault it does not classify, the `catch` in `HttpDispatcher.resolveRequestScope` leaves `executionContext` undefined. At an `authRequired: false` endpoint, that call would then run principal-less. - A static read of the anonymous branch found no fault a request can steer. API-key admission and the session read each absorb their own faults. The tenancy read and the permission-store read re-raise loudly (`503`). - #21908's engine-level deny closes this path at the engine by construction. - The file is outside this card's claim, so this is noted here, not edited. - **The flow executor sees the guest only as its position.** `AutomationContext`, the spec contract, has no `principalKind` or `isSystem` member, so the flow executor receives the guest as the `guest` position alone. Carrying `principalKind` would need a `packages/spec` change, which is out of this card's scope. ## Open question for the seat **Does ruling C's "never the system principal" reach a flow's declared `runAs`?** An anonymous request at an `authRequired: false` `flow` endpoint can target a flow that declares `runAs: 'system'`. That flow's data steps run as the system principal: this is the flow's own explicit opt-in, and the request itself still arrives as the guest. The options, and a recommendation along the four axes, are in the dev report on #22147. This PR builds nothing for it. #22147 remains open for the seat, because its premise is falsified and the open question above needs a ruling. #21908 remains open and is not addressed here. Whether its `Blocked-by` line on #22147 still holds is the seat's call. --- _Generated by [Claude Code](https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 98998ca commit 6ed0c0f

5 files changed

Lines changed: 403 additions & 7 deletions

File tree

Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,307 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// The principal an anonymous request executes as at an `authRequired: false`
4+
// declared endpoint, on a real boot (#22147, ruling C).
5+
//
6+
// The ruling: an unauthenticated request at an endpoint an application declared
7+
// `authRequired: false` executes as the GUEST principal the runtime face's
8+
// explicit guest entry (`assembleExecutionContextOrGuest`) builds —
9+
// `principalKind: 'guest'`, `positions: ['guest']`, `isSystem: false` —
10+
// threaded into the `object_operation` data calls and the `flow` executor
11+
// alike. Never principal-less, never the system principal. With no grant bound
12+
// to the guest (no channel exists yet), its object operations are DENIED, and
13+
// `authRequired: true` keeps answering 401.
14+
//
15+
// Why a status code alone cannot hold this: a principal-less context and a
16+
// guest both end in `403 PERMISSION_DENIED` once the engine-level deny for the
17+
// principal-less hand-off lands, so `declarative-endpoint-policy`'s 403 stops
18+
// telling the two apart. What this file pins is the context the ENGINE is
19+
// handed (an engine spy), whether the DRIVER is reached (a middleware
20+
// registered after boot runs last, right before the driver), and what the
21+
// caller is told (status, envelope, and that no byte of the row comes back).
22+
//
23+
// The fixture is inline and throwaway on purpose, like the policy fixture: it
24+
// exists only to be probed. Every anonymous endpoint carries the armed rate
25+
// limit ADR-0121 D6 makes its publish obligation, so booting at all is a
26+
// positive assertion that the declarations are the accepted form.
27+
28+
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
29+
import { bootStack, type VerifyStack } from '@objectstack/verify';
30+
import { MetadataPlugin } from '@objectstack/metadata';
31+
import { defineStack } from '@objectstack/spec';
32+
import { ObjectSchema, Field } from '@objectstack/spec/data';
33+
import type { ApiEndpoint } from '@objectstack/spec/api';
34+
import type { Flow } from '@objectstack/spec/automation';
35+
import type { IObjectQLEngine } from '@objectstack/spec/contracts';
36+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
37+
import { tmpdir } from 'node:os';
38+
import { join } from 'node:path';
39+
40+
const OBJECT = 'anonguest_note';
41+
const SEEDED_NAME = 'anonguest-seeded-row-marker';
42+
43+
const Note = ObjectSchema.create({
44+
name: OBJECT,
45+
// [ADR-0090 D1] grandfather stamp: the gate under test is the principal the
46+
// endpoint door hands the engine, not record sharing.
47+
sharingModel: 'public_read_write',
48+
label: 'Anon Guest Note',
49+
pluralLabel: 'Anon Guest Notes',
50+
fields: { name: Field.text({ label: 'Name', required: true }) },
51+
});
52+
53+
/** start → get_record (by id) → end, under the default `runAs: 'user'`. */
54+
const ReadFlow: Flow = {
55+
name: 'anonguest_read',
56+
label: 'Read a note',
57+
type: 'autolaunched',
58+
runAs: 'user',
59+
variables: [
60+
{ name: 'noteId', type: 'text', isInput: true },
61+
{ name: 'found', type: 'text', isOutput: true },
62+
],
63+
nodes: [
64+
{ id: 'start', type: 'start', label: 'Start' },
65+
{
66+
id: 'get',
67+
type: 'get_record',
68+
label: 'Get note',
69+
config: { objectName: OBJECT, filter: { id: '{noteId}' }, outputVariable: 'found' },
70+
},
71+
{ id: 'end', type: 'end', label: 'End' },
72+
],
73+
edges: [
74+
{ id: 'e1', source: 'start', target: 'get' },
75+
{ id: 'e2', source: 'get', target: 'end' },
76+
],
77+
};
78+
79+
const ARMED = { enabled: true, windowMs: 60_000, maxRequests: 1_000 };
80+
const base = '/api/v1/apps/anonguest';
81+
82+
const endpoints: ApiEndpoint[] = [
83+
{
84+
name: 'anonguest_find', path: `${base}/notes`, method: 'GET', summary: 'find', description: 'anonymous find',
85+
type: 'object_operation', objectParams: { object: OBJECT, operation: 'find' },
86+
authRequired: false, rateLimit: ARMED,
87+
},
88+
{
89+
name: 'anonguest_get', path: `${base}/note`, method: 'GET', summary: 'get', description: 'anonymous get by id',
90+
type: 'object_operation', objectParams: { object: OBJECT, operation: 'get' },
91+
authRequired: false, rateLimit: ARMED,
92+
},
93+
{
94+
name: 'anonguest_create', path: `${base}/notes`, method: 'POST', summary: 'create', description: 'anonymous create',
95+
type: 'object_operation', objectParams: { object: OBJECT, operation: 'create' },
96+
authRequired: false, rateLimit: ARMED,
97+
},
98+
{
99+
// The control: the same operation, session-gated.
100+
name: 'anonguest_find_gated', path: `${base}/gated-notes`, method: 'GET', summary: 'gated', description: 'gated find',
101+
type: 'object_operation', objectParams: { object: OBJECT, operation: 'find' },
102+
authRequired: true,
103+
},
104+
{
105+
name: 'anonguest_read_flow', path: `${base}/read`, method: 'POST', summary: 'flow', description: 'anonymous flow',
106+
type: 'flow', target: ReadFlow.name,
107+
authRequired: false, rateLimit: ARMED,
108+
},
109+
];
110+
111+
const fixtureStack = defineStack({
112+
manifest: {
113+
id: 'com.dogfood.anonymous-endpoint-guest',
114+
namespace: 'anonguest',
115+
version: '0.0.0',
116+
type: 'app',
117+
name: 'Anonymous Endpoint Guest Fixture',
118+
description: 'Declared endpoints probed for the principal an anonymous request executes as.',
119+
},
120+
objects: [Note],
121+
flows: [ReadFlow],
122+
apis: endpoints,
123+
});
124+
125+
type Ctx = Record<string, unknown> | undefined;
126+
127+
let stack: VerifyStack;
128+
let tempDir: string;
129+
let adminToken: string;
130+
let seededId: string;
131+
/** The context each engine call on the fixture object was handed, in order. */
132+
const engineCalls: Array<{ method: string; context: Ctx }> = [];
133+
/** Operations on the fixture object that passed every middleware (reached the driver). */
134+
const driverReached: string[] = [];
135+
/** Every context the automation service was asked to run a flow with. */
136+
const flowContexts: Array<Record<string, unknown>> = [];
137+
138+
beforeAll(async () => {
139+
tempDir = mkdtempSync(join(tmpdir(), 'os-anon-guest-'));
140+
const artifactPath = join(tempDir, 'objectstack.json');
141+
writeFileSync(artifactPath, JSON.stringify(fixtureStack));
142+
stack = await bootStack(fixtureStack, {
143+
automation: true,
144+
extraPlugins: [
145+
new MetadataPlugin({
146+
rootDir: tempDir,
147+
watch: false,
148+
artifactWatch: false,
149+
registerSystemObjects: false,
150+
artifactSource: { mode: 'local-file', path: artifactPath },
151+
}),
152+
],
153+
});
154+
adminToken = await stack.signIn();
155+
156+
const seed = await stack.apiAs(adminToken, 'POST', `/data/${OBJECT}`, { name: SEEDED_NAME });
157+
expect(seed.status, await seed.clone().text()).toBe(201);
158+
seededId = ((await seed.json()) as { id: string }).id;
159+
expect(seededId).toBeTruthy();
160+
161+
const ql = stack.kernel.getService<IObjectQLEngine>('objectql');
162+
// Entry: what the engine API is handed. `find` / `findOne` take the options
163+
// second, `insert` third.
164+
for (const [method, at] of [['find', 1], ['findOne', 1], ['insert', 2]] as const) {
165+
const original = (ql as any)[method].bind(ql);
166+
vi.spyOn(ql as any, method).mockImplementation(async (...args: any[]) => {
167+
if (args[0] === OBJECT) engineCalls.push({ method, context: args[at]?.context });
168+
return original(...args);
169+
});
170+
}
171+
// Exit: registered after every plugin's middleware, so it runs LAST — only an
172+
// operation every security middleware let through reaches it, and the driver
173+
// call follows it directly.
174+
ql.registerMiddleware(async (op: any, next: () => Promise<void>) => {
175+
if (op.object === OBJECT) driverReached.push(op.operation);
176+
await next();
177+
});
178+
179+
const automation = stack.kernel.getService('automation') as { execute(n: string, c: unknown): Promise<unknown> };
180+
const execute = automation.execute.bind(automation);
181+
vi.spyOn(automation, 'execute').mockImplementation(async (name: string, context: any) => {
182+
flowContexts.push(context);
183+
return execute(name, context);
184+
});
185+
}, 120_000);
186+
187+
afterAll(async () => {
188+
vi.restoreAllMocks();
189+
await stack?.stop();
190+
if (tempDir) rmSync(tempDir, { recursive: true, force: true });
191+
});
192+
193+
function resetProbes() {
194+
engineCalls.length = 0;
195+
driverReached.length = 0;
196+
flowContexts.length = 0;
197+
}
198+
199+
/** The guest envelope's identity facts, and the absence of everything else. */
200+
function expectGuest(context: Ctx) {
201+
expect(context, 'the anonymous call reached the engine with NO execution context: principal-less').toBeDefined();
202+
expect(context).toMatchObject({ principalKind: 'guest', positions: ['guest'], isSystem: false });
203+
expect(context).not.toHaveProperty('userId');
204+
}
205+
206+
/** 403 PERMISSION_DENIED (ADR-0112: code + status), and no byte of the row. */
207+
async function expectDeniedWithoutDisclosure(res: Response) {
208+
const text = await res.text();
209+
expect(res.status, text).toBe(403);
210+
const body = JSON.parse(text) as { success?: boolean; data?: unknown; error?: { code?: string } };
211+
expect(body.success).toBe(false);
212+
expect(body.error?.code).toBe('PERMISSION_DENIED');
213+
expect(body).not.toHaveProperty('data');
214+
expect(text).not.toContain(SEEDED_NAME);
215+
expect(text).not.toContain(seededId);
216+
}
217+
218+
describe('[#22147] object_operation at authRequired:false — the anonymous request executes as the guest', () => {
219+
it('find: the engine is handed the guest, the driver is never reached, and the caller is denied with nothing disclosed', async () => {
220+
resetProbes();
221+
const res = await stack.api('/apps/anonguest/notes', { method: 'GET' });
222+
await expectDeniedWithoutDisclosure(res);
223+
expect(engineCalls.map((c) => c.method)).toEqual(['find']);
224+
expectGuest(engineCalls[0]!.context);
225+
expect(driverReached).toEqual([]);
226+
});
227+
228+
it('get by id: the same guest, the same refusal, and the addressed row is not echoed', async () => {
229+
resetProbes();
230+
const res = await stack.api(`/apps/anonguest/note?id=${encodeURIComponent(seededId)}`, { method: 'GET' });
231+
await expectDeniedWithoutDisclosure(res);
232+
expect(engineCalls.map((c) => c.method)).toEqual(['findOne']);
233+
expectGuest(engineCalls[0]!.context);
234+
expect(driverReached).toEqual([]);
235+
});
236+
237+
it('create: the guest is refused before the driver, so no row is written', async () => {
238+
resetProbes();
239+
const res = await stack.api('/apps/anonguest/notes', {
240+
method: 'POST',
241+
headers: { 'content-type': 'application/json' },
242+
body: JSON.stringify({ name: 'anonguest-anonymous-write' }),
243+
});
244+
await expectDeniedWithoutDisclosure(res);
245+
expect(engineCalls.map((c) => c.method)).toEqual(['insert']);
246+
expectGuest(engineCalls[0]!.context);
247+
expect(driverReached).toEqual([]);
248+
249+
// Read back as the admin: the seeded row is there (the read works), the
250+
// anonymous one is not (the write never landed).
251+
const after = await stack.apiAs(adminToken, 'GET', `/data/${OBJECT}`);
252+
const listed = await after.text();
253+
expect(after.status, listed).toBe(200);
254+
expect(listed).toContain(SEEDED_NAME);
255+
expect(listed).not.toContain('anonguest-anonymous-write');
256+
});
257+
258+
it('the same operation declared authRequired:true keeps answering 401, and the engine is never asked', async () => {
259+
resetProbes();
260+
const res = await stack.api('/apps/anonguest/gated-notes', { method: 'GET' });
261+
expect(res.status).toBe(401);
262+
expect(((await res.json()) as { error?: { code?: string } }).error?.code).toBe('UNAUTHENTICATED');
263+
expect(engineCalls).toEqual([]);
264+
});
265+
266+
it('an AUTHENTICATED request at the authRequired:false endpoint still runs as that user, never as the guest', async () => {
267+
resetProbes();
268+
const res = await stack.apiAs(adminToken, 'GET', '/apps/anonguest/notes');
269+
const text = await res.text();
270+
expect(res.status, text).toBe(200);
271+
expect(text).toContain(SEEDED_NAME);
272+
expect(engineCalls).toHaveLength(1);
273+
const context = engineCalls[0]!.context;
274+
expect(context).toMatchObject({ principalKind: 'human', isSystem: false });
275+
expect(typeof context?.userId).toBe('string');
276+
expect(context?.positions).not.toContain('guest');
277+
expect(driverReached).toEqual(['find']);
278+
});
279+
});
280+
281+
describe('[#22147] flow at authRequired:false — the flow executor is handed the guest', () => {
282+
it('carries the guest position with no user and no elevation, and the run\'s data call never reaches the engine principal-less', async () => {
283+
resetProbes();
284+
const res = await stack.api('/apps/anonguest/read', {
285+
method: 'POST',
286+
headers: { 'content-type': 'application/json' },
287+
body: JSON.stringify({ params: { noteId: seededId } }),
288+
});
289+
const text = await res.text();
290+
291+
expect(flowContexts).toHaveLength(1);
292+
const handed = flowContexts[0]!;
293+
expect(handed.positions).toEqual(['guest']);
294+
expect(handed).not.toHaveProperty('userId');
295+
expect(handed).not.toHaveProperty('isSystem');
296+
expect(handed).not.toHaveProperty('runAs');
297+
298+
// A `runAs: 'user'` run with no user is refused fail-closed by the
299+
// automation service before its data node touches the engine, and the
300+
// door reports the failed run (the shared flow-dispatch table).
301+
expect(res.status, text).toBe(400);
302+
expect(((JSON.parse(text)) as { error?: { code?: string } }).error?.code).toBe('FLOW_FAILED');
303+
expect(text).not.toContain(SEEDED_NAME);
304+
expect(engineCalls).toEqual([]);
305+
expect(driverReached).toEqual([]);
306+
});
307+
});

‎packages/runtime/src/api-endpoint-step.ts‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -156,10 +156,14 @@ export interface AppEndpointExecutionInput {
156156
/** The `callData` binding and the `automation` slot occupant. */
157157
deps: EndpointExecutorDeps;
158158
/**
159-
* The identity envelope the dispatcher resolved for this request, or
160-
* `undefined` for anonymous. Threaded into every delegated call so RLS/FLS
161-
* and the ADR-0049 exposure gate apply exactly as on the built-in route
162-
* (#5040 §4's red line; #4936 is what its absence looks like).
159+
* The identity envelope the dispatcher resolved for this request. Threaded
160+
* into every delegated call so RLS/FLS and the ADR-0049 exposure gate apply
161+
* exactly as on the built-in route (#5040 §4's red line; #4936 is what its
162+
* absence looks like). For an anonymous request admitted by an
163+
* `authRequired: false` endpoint this is the GUEST principal, never
164+
* `undefined` (#22147, ruling C) — see
165+
* `BuildEndpointExecutionContextInput.executionContext` in
166+
* `endpoint-executor.ts`.
163167
*/
164168
executionContext?: ExecutionContext;
165169
/** Environment scoping for service resolution. */

‎packages/runtime/src/dispatcher-plugin.endpoint-fallback.integration.test.ts‎

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -543,4 +543,72 @@ describe('the wired chain — policies, then the real pipeline (#5129)', () => {
543543

544544
expect(queries).toEqual([{ path: '/api/v1/apps/showcase/not-declared', method: 'GET' }]);
545545
});
546+
547+
// ── [#22147, ruling C] the principal an anonymous request executes as ──
548+
//
549+
// An unauthenticated request an `authRequired: false` endpoint admits
550+
// executes as the GUEST principal the runtime face's explicit guest entry
551+
// (`assembleExecutionContextOrGuest`) builds — never principal-less (which
552+
// the security middleware hands straight through, ADR-0096 E1) and never
553+
// the system principal. These pin the context the delegated call CARRIES,
554+
// which is the one fact a status code cannot show: this boot composes no
555+
// security plugin, and once a principal-less context is denied too, a
556+
// guest and a principal-less call answer the same 403. The real-boot
557+
// counterpart, with the deny it earns, is
558+
// `declarative-endpoint-anonymous-guest.dogfood.test.ts`.
559+
560+
it('[#22147] runs an anonymous object_operation at authRequired:false as the guest — never principal-less, never system', async () => {
561+
engineFinds.length = 0;
562+
const res = await fetch(`${baseUrl}/api/v1/apps/showcase/open-tasks`);
563+
expect(res.status).toBe(200);
564+
565+
const finds = engineFinds.filter((f) => f.object === 'showcase_task');
566+
expect(finds).toHaveLength(1);
567+
const context = finds[0]!.options?.context;
568+
expect(
569+
context,
570+
'the anonymous call reached the engine with NO execution context: the principal-less hand-off',
571+
).toBeDefined();
572+
expect(context).toMatchObject({ principalKind: 'guest', positions: ['guest'], isSystem: false });
573+
expect(context).not.toHaveProperty('userId');
574+
});
575+
576+
it('[#22147] hands the flow executor the guest for an anonymous call — the guest position, no user, no elevation', async () => {
577+
flowRuns.length = 0;
578+
const res = await fetch(`${baseUrl}/api/v1/apps/showcase/purge`, {
579+
method: 'POST',
580+
headers: { 'content-type': 'application/json' },
581+
body: JSON.stringify({ params: { olderThanDays: 30 } }),
582+
});
583+
expect(res.status).toBe(200);
584+
585+
expect(flowRuns).toHaveLength(1);
586+
const context = flowRuns[0]!.context;
587+
expect(context.positions).toEqual(['guest']);
588+
expect(context).not.toHaveProperty('userId');
589+
expect(context).not.toHaveProperty('isSystem');
590+
// The door never chooses an elevation for the run: `runAs` is the
591+
// flow's own declaration, read by the automation service at setup.
592+
expect(context).not.toHaveProperty('runAs');
593+
});
594+
595+
it('[#22147] runs an AUTHENTICATED request at authRequired:false as that user, never as the guest', async () => {
596+
engineFinds.length = 0;
597+
const res = await fetch(`${baseUrl}/api/v1/apps/showcase/open-tasks`, {
598+
headers: { 'x-test-user': 'admin1' },
599+
});
600+
expect(res.status).toBe(200);
601+
602+
const context = engineFinds.find((f) => f.object === 'showcase_task')?.options?.context;
603+
expect(context).toMatchObject({ userId: 'admin1', principalKind: 'human', isSystem: false });
604+
expect(context.positions).not.toContain('guest');
605+
});
606+
607+
it('[#22147] keeps answering 401 at authRequired:true, before the engine is asked anything', async () => {
608+
engineFinds.length = 0;
609+
const res = await fetch(`${baseUrl}/api/v1/apps/showcase/my-tasks`);
610+
expect(res.status).toBe(401);
611+
expect((await res.json() as { error: { code: string } }).error.code).toBe('UNAUTHENTICATED');
612+
expect(engineFinds.filter((f) => f.object === 'showcase_task')).toEqual([]);
613+
});
546614
});

0 commit comments

Comments
 (0)