Skip to content

Commit ac8fb76

Browse files
committed
test(runtime): measure what an unscoped /packages reaches under projectResolution 'required'
The escalation condition on this card asks whether the unconditionally mounted unscoped `/packages*` crosses an environment boundary on a `required` host. It does not, and this is the measurement. The unscoped mount supplies neither of `urlEnvironmentId`'s two sources, so the request names no environment of its own and is bound by the host resolver's documented order 2-6; the scoped URL is order 1, the stronger addressing primitive. Both mounts are the same handler behind the same `dispatch()` preamble, so the tenancy gate answers them identically: a non-member is refused `PROJECT_MEMBERSHIP_REQUIRED` before the domain on either, and a member reaches one byte-identical door. The fixture gives the two environments data planes that answer differently (registry-bearing vs registry-less), so the response itself names which environment served it -- and the positive control drives the same door, the same assertions and the same spies for a request the host DOES bind to another environment, so the no-leak legs are readings rather than a probe that could not have seen a crossing. Claude-Session: https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c Co-authored-by: Claude <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent d46deba commit ac8fb76

1 file changed

Lines changed: 305 additions & 0 deletions

File tree

Lines changed: 305 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,305 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #17432 — WHAT an unscoped `/packages*` reaches under
5+
* `projectResolution: 'required'`.
6+
*
7+
* ## Why this measurement exists, ahead of any repair
8+
*
9+
* The card is a doc/code disagreement: `content/docs/api/environment-routing.mdx`
10+
* says `required` registers ONLY environment-scoped routes for
11+
* data/meta/AI/automation/package handlers, while `dispatcher-plugin.ts` mounts
12+
* `/packages*` unscoped unconditionally — deliberately, and recorded at the
13+
* mount site. Which side is wrong cannot be decided from the prose, because the
14+
* sentence asserts an ISOLATION property: if the unscoped door lets a caller
15+
* reach ANOTHER environment's package data, the finding is a tenancy leak and
16+
* not drift, and the remedy is not a doc edit. So the property is measured
17+
* here, and the file stays as the pin on the answer.
18+
*
19+
* ## The mechanism, stated so the assertions can be read against it
20+
*
21+
* The dispatcher owns no environment resolution (ADR-0006 Phase 5). It
22+
* contributes two parsing HINTS — `routePath` and `urlEnvironmentId` — and the
23+
* host's `KernelResolver` resolves the environment and returns the kernel the
24+
* request is served from. `urlEnvironmentId` has exactly two sources
25+
* (`prepareResolverHints`): an `/environments/:id` segment in the path, and
26+
* `req.params.environmentId`. The unscoped mount supplies NEITHER, so an
27+
* unscoped `/packages` request reaches the resolver naming no environment of
28+
* its own and is bound by the host's documented order 2-6 (hostname /
29+
* `X-Environment-Id` / session / configured default / sole environment). The
30+
* scoped URL is order 1 — the STRONGER addressing primitive, not the weaker
31+
* one.
32+
*
33+
* Both mounts call `dispatcher.dispatch()` with the same pre-stripped subpath
34+
* (`/packages…`), the scoped one carrying `:environmentId` on `req.params`, so
35+
* the two calls below are the two real mounts rather than lookalikes.
36+
*
37+
* ## The observable, and why it is a RESPONSE rather than only a spy
38+
*
39+
* "Reached environment E's package data" is mechanically "the door read E's
40+
* `objectql`": `handlePackagesRequest` resolves its registry through
41+
* `deps.getObjectQL(_context)`, which reads the REQUEST's kernel and nothing
42+
* else, BEFORE the capability gate. So the fixture gives the two environments
43+
* data planes that answer differently — one registry-bearing, one not — and the
44+
* door's own status then names which environment it was bound to: `503`
45+
* ("Package service not available", the registry-less host default) versus the
46+
* `403 PERMISSION_DENIED` capability refusal that only a registry-bearing
47+
* environment can produce. Spies on `objectql` resolution and on
48+
* `sys_environment_member` corroborate it.
49+
*
50+
* ⚠️ Read the 403s for their CODE, never as "some refusal": three different
51+
* gates answer 403 on these paths and only one of them is about tenancy.
52+
* `PROJECT_MEMBERSHIP_REQUIRED` is the tenancy gate; `PERMISSION_DENIED` here
53+
* is the ADR-0106 D4 capability gate INSIDE the domain, i.e. proof of arrival.
54+
*
55+
* ## The positive control
56+
*
57+
* ⛔ "No leak" may not be asserted by a probe that could not have seen one. The
58+
* POSITIVE CONTROL drives the same door, the same assertion and the same
59+
* observables for a request the host's resolver does bind to `env_beta`: the
60+
* probe then reports `env_beta`'s data plane, through the identical channel the
61+
* no-leak legs read as the host default. The membership gate is armed the same
62+
* way (`sys_environment_member` answers with no row for a non-member), so a
63+
* path that REACHES it comes back `PROJECT_MEMBERSHIP_REQUIRED` and a path that
64+
* skips it does not — the two classes separated by the answer itself, which is
65+
* the `http-dispatcher.membership-skip-boundary` fixture's argument reused.
66+
*/
67+
68+
import { describe, it, expect, vi } from 'vitest';
69+
import { HttpDispatcher, type HttpProtocolContext, type KernelResolver } from './http-dispatcher.js';
70+
71+
const USER_ID = 'user-tenant-alpha';
72+
const TENANT_ORG = 'org-tenant';
73+
const ENV_ALPHA = 'env_alpha';
74+
const ENV_BETA = 'env_beta';
75+
76+
interface FakeKernel {
77+
label: string;
78+
kernel: any;
79+
/** How often this kernel was asked for `objectql` — the binding record. */
80+
objectqlAsked: () => number;
81+
/** Every `sys_environment_member` probe this kernel served. */
82+
memberQueries: () => any[];
83+
/** Every `registry.getAllPackages()` read — package rows actually served. */
84+
packageRowsRead: () => number;
85+
}
86+
87+
/**
88+
* One environment's kernel: its own `objectql` and its own `auth`.
89+
*
90+
* - `memberOf` — the environments whose `sys_environment_member` row this
91+
* kernel finds, so "not a member" is a fixture decision rather than an
92+
* accident of an unwired service (the gate fails open in many ways).
93+
* - `withRegistry` — whether its `objectql` carries a `registry`.
94+
* `getObjectQLService` returns null without one, so the packages door
95+
* answers 503 instead of reaching its capability gate. That difference is
96+
* what makes "which environment answered" readable off the response.
97+
*/
98+
function makeKernel(label: string, opts: { memberOf?: string[]; withRegistry?: boolean } = {}): FakeKernel {
99+
const memberOf = new Set(opts.memberOf ?? []);
100+
const memberQueries: any[] = [];
101+
let objectqlAsked = 0;
102+
let packageRowsRead = 0;
103+
104+
const registry = {
105+
getAllPackages: vi.fn(() => {
106+
packageRowsRead++;
107+
return [{ manifest: { id: `pkg.of.${label}` }, status: 'installed', enabled: true }];
108+
}),
109+
getPackage: vi.fn(() => undefined),
110+
getObject: vi.fn(() => null),
111+
getRegisteredTypes: vi.fn(() => []),
112+
};
113+
114+
const objectql: Record<string, unknown> = {
115+
find: vi.fn(async (object: string, q: any) => {
116+
if (object === 'sys_environment_member') {
117+
memberQueries.push(q?.where);
118+
return memberOf.has(q?.where?.environment_id) ? [{ id: 'row' }] : [];
119+
}
120+
return [];
121+
}),
122+
getObjects: vi.fn(() => ({})),
123+
};
124+
if (opts.withRegistry !== false) objectql.registry = registry;
125+
126+
const auth = {
127+
getApi: async () => ({
128+
getSession: async () => ({
129+
user: { id: USER_ID },
130+
session: { userId: USER_ID, activeOrganizationId: TENANT_ORG },
131+
}),
132+
}),
133+
};
134+
135+
const services: Record<string, any> = { objectql, auth };
136+
137+
const resolve = (name: string, scopeId?: string) => {
138+
// A non-shared-kernel host: nothing here is a SCOPED service, so a
139+
// scoped lookup declines and the dispatcher's `resolveService` chain
140+
// falls through to the request's own kernel.
141+
if (scopeId) return null;
142+
if (name === 'objectql') objectqlAsked++;
143+
return services[name] ?? null;
144+
};
145+
146+
const kernel: any = {
147+
getState: () => 'running',
148+
getService: (name: string, scopeId?: string) => resolve(name, scopeId),
149+
getServiceAsync: async (name: string, scopeId?: string) => resolve(name, scopeId),
150+
context: { getService: (name: string) => resolve(name) },
151+
};
152+
153+
return {
154+
label,
155+
kernel,
156+
objectqlAsked: () => objectqlAsked,
157+
memberQueries: () => memberQueries,
158+
packageRowsRead: () => packageRowsRead,
159+
};
160+
}
161+
162+
/**
163+
* A multi-environment host: two tenant environments with registry-bearing data
164+
* planes and a registry-LESS default kernel, plus a resolver implementing the
165+
* documented order restricted to the two steps the open-source dispatcher can
166+
* influence — the scoped URL (order 1) and `X-Environment-Id` (order 3). Steps
167+
* 2/4/5/6 are host strategy and are modelled by their outcome: "no environment
168+
* resolved", which routes to the default kernel.
169+
*/
170+
function makeHost(opts: { memberOf?: string[] } = {}) {
171+
const host = makeKernel('host-default', { withRegistry: false });
172+
const alpha = makeKernel(ENV_ALPHA, { memberOf: opts.memberOf });
173+
const beta = makeKernel(ENV_BETA, { memberOf: opts.memberOf });
174+
const byId: Record<string, FakeKernel> = { [ENV_ALPHA]: alpha, [ENV_BETA]: beta };
175+
const seen: Array<{ routePath?: string; urlEnvironmentId?: string; header?: string; resolved?: string }> = [];
176+
177+
const resolver: KernelResolver = {
178+
resolveKernel: (context: HttpProtocolContext, defaultKernel: any) => {
179+
const headers: any = context.request?.headers;
180+
const header: string | undefined = typeof headers?.get === 'function'
181+
? (headers.get('x-environment-id') ?? undefined)
182+
: headers?.['x-environment-id'];
183+
const resolved = context.urlEnvironmentId ?? header;
184+
seen.push({
185+
routePath: context.routePath,
186+
urlEnvironmentId: context.urlEnvironmentId,
187+
header,
188+
resolved,
189+
});
190+
if (!resolved) return undefined; // unscoped / single-environment
191+
context.environmentId = resolved;
192+
return byId[resolved]?.kernel ?? defaultKernel;
193+
},
194+
};
195+
196+
const dispatcher = new HttpDispatcher(host.kernel, undefined, {
197+
enforceProjectMembership: true,
198+
kernelResolver: resolver,
199+
});
200+
201+
/** The UNSCOPED mount — `registerPackageRoutes(prefix)`, no env anywhere. */
202+
const unscoped = (headers: Record<string, string> = {}) => dispatcher.dispatch(
203+
'GET', '/packages', undefined, {},
204+
{ request: { headers, params: {} } } as any,
205+
);
206+
207+
/** The SCOPED mount — same handler, `:environmentId` on `req.params`. */
208+
const scoped = (environmentId: string, headers: Record<string, string> = {}) => dispatcher.dispatch(
209+
'GET', '/packages', undefined, {},
210+
{ request: { headers, params: { environmentId } } } as any,
211+
);
212+
213+
return { host, alpha, beta, seen, unscoped, scoped };
214+
}
215+
216+
/** The tenancy gate's refusal — the ONE 403 on these paths that is about isolation. */
217+
const refusedForTenancy = (r: any) =>
218+
r?.response?.status === 403 && r?.response?.body?.error?.code === 'PROJECT_MEMBERSHIP_REQUIRED';
219+
220+
/** Arrival INSIDE the packages domain, on a registry-bearing environment. */
221+
const reachedPackagesDoor = (r: any) =>
222+
r?.response?.status === 403
223+
&& r?.response?.body?.error?.code === 'PERMISSION_DENIED'
224+
&& /Reading packages requires/.test(String(r?.response?.body?.error?.message ?? ''));
225+
226+
/** Arrival on the registry-LESS host default. */
227+
const servedByHostDefault = (r: any) =>
228+
r?.response?.status === 503
229+
&& /Package service not available/.test(String(r?.response?.body?.error?.message ?? ''));
230+
231+
describe('#17432 — the unscoped /packages door names no environment of its own', () => {
232+
it('the resolver sees NO environment hint from the unscoped mount, and one from the scoped mount', async () => {
233+
const h = makeHost({ memberOf: [ENV_BETA] });
234+
235+
await h.unscoped();
236+
expect(h.seen.at(-1)).toMatchObject({
237+
routePath: '/packages', urlEnvironmentId: undefined, resolved: undefined,
238+
});
239+
240+
// CONTROL for that `undefined`: the same field is populated the moment
241+
// the caller does name an environment, so it is a reading rather than
242+
// an unwired hint.
243+
await h.scoped(ENV_BETA);
244+
expect(h.seen.at(-1)).toMatchObject({ urlEnvironmentId: ENV_BETA, resolved: ENV_BETA });
245+
});
246+
247+
it('an unscoped request with no environment context is served by the HOST default, not by a tenant environment', async () => {
248+
const h = makeHost({ memberOf: [ENV_ALPHA, ENV_BETA] });
249+
250+
const r = await h.unscoped();
251+
252+
expect(servedByHostDefault(r)).toBe(true);
253+
expect(h.host.objectqlAsked()).toBeGreaterThan(0);
254+
expect(h.alpha.objectqlAsked()).toBe(0);
255+
expect(h.beta.objectqlAsked()).toBe(0);
256+
expect(h.alpha.packageRowsRead()).toBe(0);
257+
expect(h.beta.packageRowsRead()).toBe(0);
258+
});
259+
260+
it('POSITIVE CONTROL: the same probe DOES report a tenant environment when the host binds the request to one', async () => {
261+
// Same door, same assertions, same spies — only the host's resolver
262+
// differs in what it resolves (header, documented order 3). Were the
263+
// leg above vacuous, this one could not separate the two.
264+
const h = makeHost({ memberOf: [ENV_ALPHA, ENV_BETA] });
265+
266+
const r = await h.unscoped({ 'x-environment-id': ENV_BETA });
267+
268+
expect(reachedPackagesDoor(r)).toBe(true);
269+
expect(servedByHostDefault(r)).toBe(false);
270+
expect(h.beta.objectqlAsked()).toBeGreaterThan(0);
271+
expect(h.seen.at(-1)).toMatchObject({ urlEnvironmentId: undefined, resolved: ENV_BETA });
272+
});
273+
});
274+
275+
describe('#17432 — the unscoped door runs the SAME isolation gates as the scoped one', () => {
276+
it('a non-member is refused for TENANCY on both mounts, before the domain, with no package row served', async () => {
277+
const h = makeHost({ memberOf: [ENV_ALPHA] }); // NOT a member of env_beta
278+
279+
const viaHeader = await h.unscoped({ 'x-environment-id': ENV_BETA });
280+
const viaUrl = await h.scoped(ENV_BETA);
281+
282+
expect(refusedForTenancy(viaHeader)).toBe(true);
283+
expect(refusedForTenancy(viaUrl)).toBe(true);
284+
// Refused BEFORE the door: no arrival, no rows.
285+
expect(reachedPackagesDoor(viaHeader)).toBe(false);
286+
expect(reachedPackagesDoor(viaUrl)).toBe(false);
287+
expect(h.beta.packageRowsRead()).toBe(0);
288+
// …and the control plane really was asked, once per request.
289+
expect(h.beta.memberQueries()).toEqual([
290+
{ environment_id: ENV_BETA, user_id: USER_ID },
291+
{ environment_id: ENV_BETA, user_id: USER_ID },
292+
]);
293+
});
294+
295+
it('a member reaches the SAME door through either mount — the two answers are identical', async () => {
296+
const h = makeHost({ memberOf: [ENV_ALPHA, ENV_BETA] });
297+
298+
const viaHeader = await h.unscoped({ 'x-environment-id': ENV_BETA });
299+
const viaUrl = await h.scoped(ENV_BETA);
300+
301+
expect(reachedPackagesDoor(viaHeader)).toBe(true);
302+
expect(viaUrl.response.status).toBe(viaHeader.response.status);
303+
expect(viaUrl.response.body).toEqual(viaHeader.response.body);
304+
});
305+
});

0 commit comments

Comments
 (0)