Skip to content

Commit 2a07fe8

Browse files
committed
fix(plugin-hono-server): the current-user faces assemble their ExecutionContext through the shared assembler
`makeExecutionContextResolver` built the envelope for `/auth/me/permissions`, `/auth/me/localization` and `/me/apps` as a hand-rolled object literal cast `as any` — sitting beside `assemble-execution-context.ts`, the module whose whole purpose is to make a partial envelope unrepresentable by CLOSING the field set with a type. It omitted six fields of that closed set: `principalKind`, `onBehalfOf`, `audience`, `accessToken`, `authGate` and `oauthScopes`. That is the #6071 drift class exactly — a field exists on `ExecutionContext`, one transport carries it, another silently does not. The resolver now calls `assembleExecutionContext`, the fail-closed default entry (#6216 Option A), and passes every per-face divergence explicitly instead of omitting it: no OAuth door, no localization (the endpoint reads that cascade itself since #15387), no session bearer, no auth gate. The declared return type narrows from `any` to `ExecutionContext | undefined`, so a field added to `ExecutionContext` from here on fails to compile until this face decides it. All three faces answer byte-identical bodies. `principalKind` becomes `'human'`, which its only reachable reader on these routes — the security plugin's `context?.principalKind === 'agent'` — already treated an absent value as. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
1 parent ef3a138 commit 2a07fe8

3 files changed

Lines changed: 431 additions & 23 deletions

File tree

.changeset/lucky-poems-invite.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
"@objectstack/plugin-hono-server": minor
3+
---
4+
5+
The current-user endpoints assemble their `ExecutionContext` through the shared assembler
6+
7+
`makeExecutionContextResolver` — the session-to-context resolver behind
8+
`/auth/me/permissions`, `/auth/me/localization` and `/me/apps` — built its envelope as a
9+
hand-rolled object literal cast `as any`, beside the module that exists to make exactly
10+
that shape unrepresentable. Six fields of the closed entry set were omitted:
11+
`principalKind`, `onBehalfOf`, `audience`, `accessToken`, `authGate` and `oauthScopes`.
12+
13+
It now calls `assembleExecutionContext` (`@objectstack/core`), the fail-closed entry every
14+
other HTTP transport already uses, with each per-face divergence passed explicitly rather
15+
than left out silently. `principalKind` is `'human'` on these faces — the value the shared
16+
assembler derives for a session-backed principal — and the five remaining fields are
17+
withheld on the record. The resolver's declared return type narrows from `any` to
18+
`ExecutionContext | undefined`, so a field added to `ExecutionContext` from now on fails to
19+
compile here until this face decides it.
20+
21+
All three endpoints answer byte-identical bodies: the omission had no reachable consumer on
22+
these routes, and the repair keeps it that way.
Lines changed: 338 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,338 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// #15747 — the current-user faces assemble their `ExecutionContext` through the
4+
// SHARED assembler, not a hand-rolled literal.
5+
//
6+
// `makeExecutionContextResolver` built the envelope for all three current-user
7+
// routes as an object literal cast `as any`. `assembleExecutionContext`
8+
// (@objectstack/core) exists precisely to make that shape unrepresentable — it
9+
// CLOSES the field set with a type, so a transport entry point cannot silently
10+
// omit a field — and the resolver sat beside it omitting six of them:
11+
//
12+
// principalKind · onBehalfOf · audience · accessToken · authGate · oauthScopes
13+
//
14+
// (`locale` / `timezone` / `currency` were the seventh through ninth. #15387
15+
// repaired those AT THE ENDPOINT — `/auth/me/localization` reads the
16+
// localization cascade itself and no longer reads them off this envelope at
17+
// all — so they are withheld here on the record rather than resolved twice.)
18+
//
19+
// ## What these cases measure, and why each is not vacuous
20+
//
21+
// The envelope is handed to exactly ONE consumer on these faces:
22+
// `ISecurityService.resolvePermissionSetsForContext`. So the tests capture the
23+
// context that ARRIVES there — the same instrumentation #6071 used on the REST
24+
// face (`packages/rest/src/rest-exec-ctx-principal-kind.test.ts`) — through the
25+
// REAL registered routes, driven with `app.request()`. Nothing here mocks the
26+
// resolver.
27+
//
28+
// ⚠️ An assertion that `principalKind` is merely PRESENT would be close to
29+
// vacuous: its only reachable reader on these faces is the security plugin's
30+
// `const isAgent = context?.principalKind === 'agent'`, and `undefined` and
31+
// `'human'` are indistinguishable there. So the cases assert the two things
32+
// that genuinely differ:
33+
//
34+
// 1. the envelope's KEY SET equals the shared assembler's output exactly —
35+
// which fails both when a field is omitted and when one is over-filled;
36+
// 2. `'agent'` is UNREACHABLE on this face — the load-bearing leg of the
37+
// card's LATENT grade, pinned so that acquiring an OAuth door here turns
38+
// it red instead of silently promoting the hazard to a live defect.
39+
40+
import { describe, it, expect, vi } from 'vitest';
41+
import { Hono } from 'hono';
42+
import { assembleExecutionContext, resolveUserAuthzGrants, ENTRY_EXECUTION_CONTEXT_FIELDS } from '@objectstack/core';
43+
import { registerCurrentUserEndpoints } from './current-user-endpoints';
44+
45+
const ME_PERMISSIONS = '/api/v1/auth/me/permissions';
46+
const ME_LOCALIZATION = '/api/v1/auth/me/localization';
47+
const ME_APPS = '/api/v1/me/apps';
48+
49+
const USER = 'usr_member';
50+
const EMAIL = 'member@example.com';
51+
const ACTIVE_ORG = 'org_active';
52+
const GRANTED = 'showcase_ops';
53+
54+
type Row = Record<string, any>;
55+
56+
/** The sets the `security` service hands back — whole, as the contract publishes them. */
57+
const RESOLVED = [
58+
{
59+
name: GRANTED,
60+
label: 'Showcase Ops',
61+
systemPermissions: ['showcase.export_data'],
62+
tabPermissions: { exports: 'visible' },
63+
objects: { showcase_order: { allowRead: true, allowEdit: true } },
64+
fields: { 'showcase_order.total': { readable: true, editable: true } },
65+
},
66+
];
67+
68+
const APPS = [
69+
{ name: 'exports', requiredPermissions: ['showcase.export_data'] },
70+
{ name: 'open', requiredPermissions: [] },
71+
];
72+
73+
/** `where` matcher: scalar equality plus the `$in` form the resolver sends. */
74+
function matches(row: Row, where: Row | undefined): boolean {
75+
return Object.entries(where ?? {}).every(([key, cond]) => {
76+
// REFUSE an unsupported combinator rather than reading it as a field
77+
// name — a silent `false` would look exactly like a row that did not
78+
// match, and a case would pass for the wrong reason.
79+
if (key.startsWith('$')) throw new Error(`fake driver: unsupported operator ${key}`);
80+
const value = row[key] ?? null;
81+
if (cond && typeof cond === 'object' && Array.isArray((cond as any).$in)) {
82+
return (cond as any).$in.includes(value);
83+
}
84+
return value === (cond ?? null);
85+
});
86+
}
87+
88+
const TABLES: Record<string, Row[]> = {
89+
sys_user: [{ id: USER, email: EMAIL }],
90+
sys_member: [{ user_id: USER, organization_id: ACTIVE_ORG, role: 'member' }],
91+
sys_user_position: [],
92+
sys_user_permission_set: [
93+
{ id: 'ups1', user_id: USER, permission_set_id: 'ps_ops', organization_id: ACTIVE_ORG },
94+
],
95+
sys_position: [],
96+
sys_position_permission_set: [],
97+
sys_permission_set: [{ id: 'ps_ops', name: GRANTED }],
98+
};
99+
100+
const makeQl = () => ({
101+
find: async (object: string, opts: any) => {
102+
const rows = (TABLES[object] ?? []).filter((r) => matches(r, opts?.where));
103+
return typeof opts?.limit === 'number' ? rows.slice(0, opts.limit) : rows;
104+
},
105+
registry: { getAllApps: () => APPS, getAllObjects: () => [] },
106+
getSchema: () => undefined,
107+
});
108+
109+
/**
110+
* Mount the REAL routes on a REAL Hono app.
111+
*
112+
* @param authHeaders headers the request presents (the OAuth-door case sends a
113+
* JWT-shaped bearer here).
114+
*/
115+
function mount() {
116+
/** Every context that ARRIVED at the one consumer, in call order. */
117+
const seen: any[] = [];
118+
/**
119+
* The OAuth verifier the `/mcp` door calls
120+
* (`resolve-execution-context.ts` → `authService.verifyMcpAccessToken`).
121+
* Present on the service so that "this face never calls it" is a MEASURED
122+
* absence rather than an absent method that could never have been called.
123+
*/
124+
const verifyMcpAccessToken = vi.fn(async () => ({
125+
userId: USER,
126+
scopes: ['data:read', 'actions:execute'],
127+
clientId: 'cli_agent_1',
128+
}));
129+
130+
const services: Record<string, unknown> = {
131+
auth: {
132+
verifyMcpAccessToken,
133+
api: {
134+
getSession: async () => ({
135+
user: { id: USER, email: EMAIL },
136+
session: { activeOrganizationId: ACTIVE_ORG },
137+
}),
138+
},
139+
},
140+
objectql: makeQl(),
141+
metadata: { list: async () => [] as unknown[] },
142+
security: {
143+
resolvePermissionSetsForContext: async (context: any) => {
144+
seen.push(context);
145+
return RESOLVED;
146+
},
147+
},
148+
};
149+
150+
const app = new Hono();
151+
registerCurrentUserEndpoints({
152+
rawApp: app,
153+
ctx: {
154+
logger: { debug() {}, warn() {} },
155+
getService: <T,>(name: string): T => {
156+
if (!(name in services)) throw new Error(`[Kernel] Service '${name}' not found`);
157+
return services[name] as T;
158+
},
159+
},
160+
});
161+
return { app, seen, verifyMcpAccessToken };
162+
}
163+
164+
const get = (app: any, path: string, headers?: Record<string, string>) =>
165+
app.request(`http://localhost${path}`, headers ? { headers } : undefined);
166+
167+
/**
168+
* The envelope the SHARED assembler produces for this fixture's principal —
169+
* the reference these faces must agree with. Built from the same
170+
* `ResolvedAuthzContext` the resolver resolves, with every per-face divergence
171+
* passed explicitly, exactly as the resolver passes it.
172+
*/
173+
const reference = async () =>
174+
assembleExecutionContext({
175+
// The SAME grant resolution the faces run — `resolveUserAuthzGrants` is
176+
// shared INPUT, not the subject. What is under test is the ASSEMBLY
177+
// step after it, which is the step that was hand-rolled.
178+
authz: {
179+
...(await resolveUserAuthzGrants(makeQl(), USER, { tenantId: ACTIVE_ORG })),
180+
userId: USER,
181+
tenantId: ACTIVE_ORG,
182+
},
183+
oauth: undefined,
184+
localization: undefined,
185+
requestLocale: undefined,
186+
accessToken: undefined,
187+
authGate: undefined,
188+
})!;
189+
190+
describe('[#15747] the current-user faces assemble through the shared assembler', () => {
191+
it('/auth/me/permissions hands its consumer the assembler-shaped envelope', async () => {
192+
const { app, seen } = mount();
193+
await get(app, ME_PERMISSIONS);
194+
195+
expect(seen).toHaveLength(1);
196+
// THE measurement. Before the repair the literal emitted 11 keys and
197+
// `principalKind` was not among them; the assembler emits the closed
198+
// set minus the fields withheld on the record.
199+
expect(Object.keys(seen[0]).sort()).toEqual(Object.keys(await reference()).sort());
200+
});
201+
202+
it('/me/apps hands its consumer the SAME envelope shape', async () => {
203+
const { app, seen } = mount();
204+
await get(app, ME_APPS);
205+
206+
expect(seen).toHaveLength(1);
207+
expect(Object.keys(seen[0]).sort()).toEqual(Object.keys(await reference()).sort());
208+
});
209+
210+
it('every key the faces emit belongs to the closed entry set', async () => {
211+
const { app, seen } = mount();
212+
await get(app, ME_PERMISSIONS);
213+
214+
const closed = new Set<string>(ENTRY_EXECUTION_CONTEXT_FIELDS as readonly string[]);
215+
expect(Object.keys(seen[0]).filter((k) => !closed.has(k))).toEqual([]);
216+
});
217+
218+
it('the principal is a HUMAN, and the six omitted fields are decided rather than dropped', async () => {
219+
const { app, seen } = mount();
220+
await get(app, ME_PERMISSIONS);
221+
const ctx = seen[0];
222+
223+
// The one omitted field with a live downstream reader
224+
// (`plugin-security`: `context?.principalKind === 'agent'`).
225+
expect(ctx.principalKind).toBe('human');
226+
// The other five: WITHHELD on this face, and `emit()` drops an
227+
// `undefined` decision — so their absence is now the assembler's
228+
// recorded answer rather than a hand-rolled omission. Asserted
229+
// together with the key-set equality above, which is what makes this
230+
// pair a statement about the whole set rather than about six names.
231+
for (const field of ['onBehalfOf', 'audience', 'accessToken', 'authGate', 'oauthScopes']) {
232+
expect(ctx[field]).toBeUndefined();
233+
}
234+
});
235+
236+
// ⭐ The card's LATENT grade rests on exactly this: `principalKind` is read
237+
// downstream ONLY to test for `'agent'`, and an agent principal requires an
238+
// OAuth access token naming an authorized client — which the shared
239+
// assembler produces from its `oauth` input, and which reaches it from the
240+
// `/mcp` dispatch door ALONE (`acceptOAuthAccessToken`). This face resolves
241+
// its principal from the better-auth SESSION and never opens that door.
242+
//
243+
// If this case ever goes red, the grade has flipped: an absent
244+
// `principalKind` would then be reachable as something other than 'human'
245+
// and the omission becomes a live, security-relevant defect.
246+
it('an OAuth-shaped bearer cannot produce an AGENT principal on this face', async () => {
247+
const { app, seen, verifyMcpAccessToken } = mount();
248+
await get(app, ME_PERMISSIONS, {
249+
authorization: 'Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.e30.sig',
250+
});
251+
252+
// The face never consults the OAuth verifier — the method is on the
253+
// service and would have answered an agent principal had it been asked.
254+
expect(verifyMcpAccessToken).not.toHaveBeenCalled();
255+
expect(seen[0].principalKind).toBe('human');
256+
expect(seen[0].onBehalfOf).toBeUndefined();
257+
expect(seen[0].oauthScopes).toBeUndefined();
258+
});
259+
260+
// The blast radius #15387 declined to take on: converting the resolver
261+
// changes the envelope handed to /auth/me/permissions and /me/apps too.
262+
// These three cases are the before/after on each face's WIRE.
263+
it('/auth/me/permissions answers the same body', async () => {
264+
const { app } = mount();
265+
expect(await (await get(app, ME_PERMISSIONS)).json()).toEqual({
266+
authenticated: true,
267+
userId: USER,
268+
tenantId: ACTIVE_ORG,
269+
// The MEASURED before-state, not a guess: org membership folds
270+
// to `org_member` and the ADR-0090 D5 `everyone` anchor is
271+
// implicit. Recorded here so the after-run asserts identity with
272+
// what this face answered before the conversion.
273+
positions: ['org_member', 'everyone'],
274+
permissionSets: [GRANTED],
275+
objects: { showcase_order: { allowRead: true, allowEdit: true } },
276+
fields: { 'showcase_order.total': { readable: true, editable: true } },
277+
systemPermissions: ['showcase.export_data'],
278+
tabPermissions: { exports: 'visible' },
279+
});
280+
});
281+
282+
it('/me/apps answers the same body', async () => {
283+
const { app } = mount();
284+
expect(await (await get(app, ME_APPS)).json()).toEqual({
285+
apps: [{ name: 'exports', requiredPermissions: ['showcase.export_data'] }, { name: 'open', requiredPermissions: [] }],
286+
});
287+
});
288+
289+
it('/auth/me/localization answers the same body', async () => {
290+
const { app } = mount();
291+
// Reads the localization cascade itself since #15387 — it consults the
292+
// envelope for `userId` / `tenantId` and nothing else, so converting
293+
// the resolver must leave this wire untouched.
294+
expect(await (await get(app, ME_LOCALIZATION)).json()).toEqual({
295+
authenticated: true,
296+
currency: null,
297+
locale: 'en-US',
298+
timezone: 'UTC',
299+
});
300+
});
301+
302+
it('an unauthenticated request still reaches no consumer at all', async () => {
303+
// The fail-closed half: `assembleExecutionContext` answers `undefined`
304+
// for a principal-less authz context, and these faces answered
305+
// `{authenticated:false}` / `{apps:[]}` before it. Both must hold, or
306+
// the conversion would have turned a 'no session' answer into a guest
307+
// ENVELOPE reaching enforcement — the thing
308+
// `assembleExecutionContextOrGuest` is the named entry for, and which
309+
// this face deliberately does NOT adopt.
310+
const seen: any[] = [];
311+
const services: Record<string, unknown> = {
312+
auth: { api: { getSession: async () => undefined } },
313+
objectql: makeQl(),
314+
metadata: { list: async () => [] as unknown[] },
315+
security: {
316+
resolvePermissionSetsForContext: async (context: any) => {
317+
seen.push(context);
318+
return RESOLVED;
319+
},
320+
},
321+
};
322+
const app = new Hono();
323+
registerCurrentUserEndpoints({
324+
rawApp: app,
325+
ctx: {
326+
logger: { debug() {}, warn() {} },
327+
getService: <T,>(name: string): T => {
328+
if (!(name in services)) throw new Error(`[Kernel] Service '${name}' not found`);
329+
return services[name] as T;
330+
},
331+
},
332+
});
333+
334+
expect(await (await get(app, ME_PERMISSIONS)).json()).toEqual({ authenticated: false });
335+
expect(await (await get(app, ME_APPS)).json()).toEqual({ apps: [] });
336+
expect(seen).toEqual([]);
337+
});
338+
});

0 commit comments

Comments
 (0)