@openparachute/hub 0.6.5-rc.8 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/account-setup.test.ts +310 -6
  3. package/src/__tests__/account-vault-admin-token.test.ts +35 -3
  4. package/src/__tests__/admin-channel-token.test.ts +173 -0
  5. package/src/__tests__/admin-connections-credentials.test.ts +1320 -0
  6. package/src/__tests__/admin-connections.test.ts +1154 -0
  7. package/src/__tests__/admin-csrf-belt.test.ts +346 -0
  8. package/src/__tests__/admin-module-token.test.ts +311 -0
  9. package/src/__tests__/admin-vaults.test.ts +590 -0
  10. package/src/__tests__/api-invites.test.ts +166 -6
  11. package/src/__tests__/api-modules-ops.test.ts +70 -5
  12. package/src/__tests__/api-modules.test.ts +262 -79
  13. package/src/__tests__/audience-gate.test.ts +752 -0
  14. package/src/__tests__/hub-db.test.ts +36 -0
  15. package/src/__tests__/hub-server.test.ts +585 -21
  16. package/src/__tests__/invites.test.ts +91 -1
  17. package/src/__tests__/lifecycle.test.ts +238 -3
  18. package/src/__tests__/module-manifest.test.ts +305 -8
  19. package/src/__tests__/serve-boot.test.ts +133 -2
  20. package/src/__tests__/service-spec-discovery.test.ts +109 -0
  21. package/src/__tests__/setup-gate.test.ts +13 -7
  22. package/src/__tests__/setup-wizard.test.ts +228 -1
  23. package/src/__tests__/vault-name.test.ts +20 -5
  24. package/src/__tests__/well-known.test.ts +44 -8
  25. package/src/__tests__/ws-bridge.test.ts +573 -0
  26. package/src/__tests__/ws-connection-caps.test.ts +456 -0
  27. package/src/account-setup.ts +94 -23
  28. package/src/account-vault-admin-token.ts +43 -14
  29. package/src/admin-channel-token.ts +135 -0
  30. package/src/admin-connections.ts +1882 -0
  31. package/src/admin-login-ui.ts +64 -15
  32. package/src/admin-module-token.ts +197 -0
  33. package/src/admin-vaults.ts +399 -12
  34. package/src/api-hub-upgrade.ts +4 -3
  35. package/src/api-invites.ts +92 -12
  36. package/src/api-modules-ops.ts +41 -16
  37. package/src/api-modules.ts +238 -116
  38. package/src/api-tokens.ts +8 -5
  39. package/src/audience-gate.ts +268 -0
  40. package/src/chrome-strip.ts +8 -1
  41. package/src/commands/lifecycle.ts +187 -47
  42. package/src/commands/serve-boot.ts +80 -3
  43. package/src/commands/setup.ts +4 -4
  44. package/src/connections-store.ts +191 -0
  45. package/src/grants.ts +50 -0
  46. package/src/help.ts +13 -6
  47. package/src/host-admin-token-validation.ts +6 -2
  48. package/src/hub-db.ts +26 -1
  49. package/src/hub-server.ts +849 -70
  50. package/src/invites.ts +91 -2
  51. package/src/jwt-sign.ts +47 -1
  52. package/src/module-manifest.ts +536 -23
  53. package/src/origin-check.ts +109 -0
  54. package/src/proxy-error-ui.ts +1 -1
  55. package/src/service-spec.ts +132 -41
  56. package/src/services-manifest.ts +97 -0
  57. package/src/setup-wizard.ts +68 -6
  58. package/src/users.ts +11 -0
  59. package/src/vault-name.ts +27 -7
  60. package/src/well-known.ts +41 -33
  61. package/src/ws-bridge.ts +256 -0
  62. package/src/ws-connection-caps.ts +170 -0
  63. package/web/ui/dist/assets/index-Cxtod68O.js +61 -0
  64. package/web/ui/dist/assets/index-E_9wqjEm.css +1 -0
  65. package/web/ui/dist/index.html +2 -2
  66. package/src/__tests__/api-modules-config.test.ts +0 -882
  67. package/src/api-modules-config.ts +0 -421
  68. package/web/ui/dist/assets/index-BYYUeLGA.css +0 -1
  69. package/web/ui/dist/assets/index-D3cDUOOj.js +0 -61
@@ -0,0 +1,752 @@
1
+ /**
2
+ * Tests for the per-UI audience gate (H3, surface-runtime design §12 —
3
+ * fixes parachute-surface#88).
4
+ *
5
+ * The full matrix: each audience value × (anonymous, hub-user session,
6
+ * Bearer, first-admin session), plus the legacy boolean `public` mapping,
7
+ * fail-closed handling of malformed metadata, the document-vs-API deny
8
+ * shapes, the not-a-UI-path pass-through, and the gate running BEFORE a
9
+ * WebSocket upgrade.
10
+ */
11
+ import type { Database } from "bun:sqlite";
12
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
13
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
14
+ import { tmpdir } from "node:os";
15
+ import { join } from "node:path";
16
+ import { resolveUiMount, scopeMatchesPattern, scopesSatisfyRequirement } from "../audience-gate.ts";
17
+ import { hubDbPath, openHubDb } from "../hub-db.ts";
18
+ import { hubFetch } from "../hub-server.ts";
19
+ import { signAccessToken } from "../jwt-sign.ts";
20
+ import { type ServiceEntry, writeManifest } from "../services-manifest.ts";
21
+ import { SESSION_TTL_MS, buildSessionCookie, createSession } from "../sessions.ts";
22
+ import { rotateSigningKey } from "../signing-keys.ts";
23
+ import { createUser } from "../users.ts";
24
+
25
+ // The request origin the test fetch fn sees — minted Bearers carry it as iss.
26
+ const REQ_ORIGIN = "http://127.0.0.1";
27
+
28
+ interface Harness {
29
+ dir: string;
30
+ manifestPath: string;
31
+ db: Database;
32
+ cleanup: () => void;
33
+ }
34
+
35
+ function makeHarness(): Harness {
36
+ const dir = mkdtempSync(join(tmpdir(), "pcli-audience-gate-"));
37
+ const db = openHubDb(hubDbPath(dir));
38
+ rotateSigningKey(db);
39
+ return {
40
+ dir,
41
+ manifestPath: join(dir, "services.json"),
42
+ db,
43
+ cleanup: () => {
44
+ db.close();
45
+ rmSync(dir, { recursive: true, force: true });
46
+ },
47
+ };
48
+ }
49
+
50
+ let h: Harness;
51
+ beforeEach(() => {
52
+ h = makeHarness();
53
+ });
54
+ afterEach(() => {
55
+ h.cleanup();
56
+ });
57
+
58
+ function req(path: string, init?: RequestInit): Request {
59
+ return new Request(`${REQ_ORIGIN}${path}`, init);
60
+ }
61
+
62
+ const fakeServer = (address: string) => ({ requestIP: () => ({ address }) });
63
+
64
+ async function adminSession(): Promise<string> {
65
+ const user = await createUser(h.db, "operator", "hunter2");
66
+ const session = createSession(h.db, { userId: user.id });
67
+ return buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000));
68
+ }
69
+
70
+ /** A non-admin (friend) session — requires the admin to exist first. */
71
+ async function friendSession(): Promise<{ adminCookie: string; friendCookie: string }> {
72
+ const admin = await createUser(h.db, "operator", "hunter2");
73
+ const adminSess = createSession(h.db, { userId: admin.id });
74
+ const friend = await createUser(h.db, "alice", "alice-passphrase", { allowMulti: true });
75
+ const friendSess = createSession(h.db, { userId: friend.id });
76
+ return {
77
+ adminCookie: buildSessionCookie(adminSess.id, Math.floor(SESSION_TTL_MS / 1000)),
78
+ friendCookie: buildSessionCookie(friendSess.id, Math.floor(SESSION_TTL_MS / 1000)),
79
+ };
80
+ }
81
+
82
+ async function mintBearer(scopes: string[]): Promise<string> {
83
+ const signed = await signAccessToken(h.db, {
84
+ sub: "pwa-user",
85
+ scopes,
86
+ audience: "vault.default",
87
+ clientId: "test-pwa",
88
+ issuer: REQ_ORIGIN,
89
+ });
90
+ return signed.token;
91
+ }
92
+
93
+ function startEchoUpstream(): { port: number; stop: () => void } {
94
+ const server = Bun.serve({
95
+ port: 0,
96
+ hostname: "127.0.0.1",
97
+ fetch: (r) =>
98
+ new Response(JSON.stringify({ reached: true, path: new URL(r.url).pathname }), {
99
+ status: 200,
100
+ headers: { "content-type": "application/json" },
101
+ }),
102
+ });
103
+ return { port: server.port as number, stop: () => server.stop(true) };
104
+ }
105
+
106
+ function surfaceEntry(
107
+ port: number,
108
+ uiOverrides: Record<string, unknown> = {},
109
+ ): Record<string, unknown> {
110
+ return {
111
+ name: "parachute-surface",
112
+ port,
113
+ paths: ["/surface"],
114
+ health: "/surface/healthz",
115
+ version: "0.3.0",
116
+ uis: {
117
+ notes: {
118
+ displayName: "Notes",
119
+ path: "/surface/notes",
120
+ scopes_required: ["vault:*:read"],
121
+ ...uiOverrides,
122
+ },
123
+ },
124
+ };
125
+ }
126
+
127
+ function writeServices(entry: Record<string, unknown>): void {
128
+ // Raw write (not writeManifest) so malformed-metadata tests can plant
129
+ // values the validator would reject.
130
+ writeFileSync(h.manifestPath, `${JSON.stringify({ services: [entry] }, null, 2)}\n`);
131
+ }
132
+
133
+ function fetcher() {
134
+ return hubFetch(h.dir, {
135
+ getDb: () => h.db,
136
+ manifestPath: h.manifestPath,
137
+ // Deterministic issuer: never read the developer box's real
138
+ // ~/.parachute/expose-state.json — the per-request origin (REQ_ORIGIN)
139
+ // is then both the resolved issuer and the minted Bearers' iss.
140
+ loadExposeHubOrigin: () => undefined,
141
+ });
142
+ }
143
+
144
+ // ===========================================================================
145
+ // The audience × caller matrix
146
+ // ===========================================================================
147
+
148
+ describe("audience gate matrix (H3)", () => {
149
+ test("audience: public — anon, hub-user, Bearer, first-admin ALL pass", async () => {
150
+ const upstream = startEchoUpstream();
151
+ try {
152
+ writeServices(surfaceEntry(upstream.port, { audience: "public" }));
153
+ const f = fetcher();
154
+ const { adminCookie, friendCookie } = await friendSession();
155
+ const bearer = await mintBearer(["vault:default:read"]);
156
+
157
+ const callers: Record<string, string>[] = [
158
+ {},
159
+ { cookie: friendCookie },
160
+ { authorization: `Bearer ${bearer}` },
161
+ { cookie: adminCookie },
162
+ ];
163
+ for (const headers of callers) {
164
+ const res = await f(req("/surface/notes/index.html", { headers }), fakeServer("127.0.0.1"));
165
+ expect(res?.status).toBe(200);
166
+ }
167
+ } finally {
168
+ upstream.stop();
169
+ }
170
+ });
171
+
172
+ test("audience: hub-users — anon 401 JSON (API) / 302 login (document); session + Bearer + admin pass", async () => {
173
+ const upstream = startEchoUpstream();
174
+ try {
175
+ writeServices(surfaceEntry(upstream.port, { audience: "hub-users" }));
176
+ const f = fetcher();
177
+ const { adminCookie, friendCookie } = await friendSession();
178
+ const bearer = await mintBearer(["vault:default:read", "vault:default:write"]);
179
+
180
+ // anon, API-shaped → 401 JSON.
181
+ const anonApi = await f(req("/surface/notes/api-ish"), fakeServer("127.0.0.1"));
182
+ expect(anonApi?.status).toBe(401);
183
+ expect(((await anonApi?.json()) as { error: string }).error).toBe("unauthenticated");
184
+
185
+ // anon, document → 302 to /login with next=.
186
+ const anonDoc = await f(
187
+ req("/surface/notes/", { headers: { accept: "text/html" } }),
188
+ fakeServer("127.0.0.1"),
189
+ );
190
+ expect(anonDoc?.status).toBe(302);
191
+ expect(anonDoc?.headers.get("location")).toBe(
192
+ `/login?next=${encodeURIComponent("/surface/notes/")}`,
193
+ );
194
+
195
+ // hub-user session passes.
196
+ const asFriend = await f(
197
+ req("/surface/notes/x", { headers: { cookie: friendCookie } }),
198
+ fakeServer("127.0.0.1"),
199
+ );
200
+ expect(asFriend?.status).toBe(200);
201
+
202
+ // Bearer with satisfying scopes passes (the PWA path).
203
+ const asPwa = await f(
204
+ req("/surface/notes/x", { headers: { authorization: `Bearer ${bearer}` } }),
205
+ fakeServer("127.0.0.1"),
206
+ );
207
+ expect(asPwa?.status).toBe(200);
208
+
209
+ // First-admin session passes (an admin is a hub user).
210
+ const asAdmin = await f(
211
+ req("/surface/notes/x", { headers: { cookie: adminCookie } }),
212
+ fakeServer("127.0.0.1"),
213
+ );
214
+ expect(asAdmin?.status).toBe(200);
215
+ } finally {
216
+ upstream.stop();
217
+ }
218
+ });
219
+
220
+ test("audience: hub-users — Bearer with NON-satisfying scopes → 403; garbage Bearer → 401", async () => {
221
+ const upstream = startEchoUpstream();
222
+ try {
223
+ writeServices(surfaceEntry(upstream.port, { audience: "hub-users" }));
224
+ const f = fetcher();
225
+ await adminSession(); // a user must exist for sessions, not used here
226
+
227
+ const wrongScope = await mintBearer(["scribe:admin"]);
228
+ const denied = await f(
229
+ req("/surface/notes/x", { headers: { authorization: `Bearer ${wrongScope}` } }),
230
+ fakeServer("127.0.0.1"),
231
+ );
232
+ expect(denied?.status).toBe(403);
233
+ expect(((await denied?.json()) as { error: string }).error).toBe("insufficient_scope");
234
+
235
+ const garbage = await f(
236
+ req("/surface/notes/x", { headers: { authorization: "Bearer not-a-jwt" } }),
237
+ fakeServer("127.0.0.1"),
238
+ );
239
+ expect(garbage?.status).toBe(401);
240
+ } finally {
241
+ upstream.stop();
242
+ }
243
+ });
244
+
245
+ test("audience: operator — only the first-admin session passes; friend 403; Bearer 401; anon 302/401", async () => {
246
+ const upstream = startEchoUpstream();
247
+ try {
248
+ writeServices(surfaceEntry(upstream.port, { audience: "operator" }));
249
+ const f = fetcher();
250
+ const { adminCookie, friendCookie } = await friendSession();
251
+ // Even a powerful bearer doesn't open an operator surface — the tier is
252
+ // about the operator's interactive presence, not token authority.
253
+ const bearer = await mintBearer(["vault:default:read"]);
254
+
255
+ const asAdmin = await f(
256
+ req("/surface/notes/x", { headers: { cookie: adminCookie } }),
257
+ fakeServer("127.0.0.1"),
258
+ );
259
+ expect(asAdmin?.status).toBe(200);
260
+
261
+ const asFriend = await f(
262
+ req("/surface/notes/x", { headers: { cookie: friendCookie } }),
263
+ fakeServer("127.0.0.1"),
264
+ );
265
+ expect(asFriend?.status).toBe(403);
266
+ expect(((await asFriend?.json()) as { error: string }).error).toBe("not_admin");
267
+
268
+ const asBearer = await f(
269
+ req("/surface/notes/x", { headers: { authorization: `Bearer ${bearer}` } }),
270
+ fakeServer("127.0.0.1"),
271
+ );
272
+ expect(asBearer?.status).toBe(401);
273
+
274
+ const anonDoc = await f(
275
+ req("/surface/notes/", { headers: { accept: "text/html" } }),
276
+ fakeServer("127.0.0.1"),
277
+ );
278
+ expect(anonDoc?.status).toBe(302);
279
+
280
+ const anonApi = await f(req("/surface/notes/x"), fakeServer("127.0.0.1"));
281
+ expect(anonApi?.status).toBe(401);
282
+ } finally {
283
+ upstream.stop();
284
+ }
285
+ });
286
+
287
+ test("audience: surface — anon passes through on document, API, and WS-upgrade shapes (the surface self-auths)", async () => {
288
+ const upstream = startEchoUpstream();
289
+ try {
290
+ writeServices({
291
+ ...surfaceEntry(upstream.port, { audience: "surface" }),
292
+ websocket: true,
293
+ });
294
+ const f = fetcher();
295
+
296
+ // Anonymous document request — NO 302 to /login (this was the concrete
297
+ // blocker: capability-link invitees aren't hub users by design).
298
+ const anonDoc = await f(
299
+ req("/surface/notes/", { headers: { accept: "text/html" } }),
300
+ fakeServer("127.0.0.1"),
301
+ );
302
+ expect(anonDoc?.status).toBe(200);
303
+
304
+ // Anonymous API-shaped request — no 401; the surface decides.
305
+ const anonApi = await f(
306
+ req("/surface/notes/api/docs", { method: "POST" }),
307
+ fakeServer("127.0.0.1"),
308
+ );
309
+ expect(anonApi?.status).toBe(200);
310
+
311
+ // Anonymous WebSocket upgrade — passes the gate and upgrades (the
312
+ // docs editor's collab WS rides this; the surface auths the socket).
313
+ let upgradeCalls = 0;
314
+ const spy = {
315
+ requestIP: () => ({ address: "127.0.0.1" }),
316
+ upgrade: () => {
317
+ upgradeCalls++;
318
+ return true;
319
+ },
320
+ };
321
+ const ws = await f(
322
+ req("/surface/notes/ws", {
323
+ headers: { upgrade: "websocket", connection: "Upgrade" },
324
+ }),
325
+ spy,
326
+ );
327
+ expect(ws).toBeUndefined();
328
+ expect(upgradeCalls).toBe(1);
329
+ } finally {
330
+ upstream.stop();
331
+ }
332
+ });
333
+
334
+ test("audience: surface — passes on an ABSENT DB (stateless boot), like public", async () => {
335
+ // The surface self-auths every request — a hub-side deny on a missing
336
+ // identity store would break the surface for zero security gain (the
337
+ // hub's DB plays no part in the surface's admission decision). `public`
338
+ // passes on absent DB for the same structural reason; pin the parity.
339
+ const upstream = startEchoUpstream();
340
+ try {
341
+ writeServices(surfaceEntry(upstream.port, { audience: "surface" }));
342
+ const statelessFetcher = hubFetch(h.dir, {
343
+ // no getDb — the hub booted without a DB
344
+ manifestPath: h.manifestPath,
345
+ loadExposeHubOrigin: () => undefined,
346
+ });
347
+ const res = await statelessFetcher(req("/surface/notes/x"), fakeServer("127.0.0.1"));
348
+ expect(res?.status).toBe(200);
349
+
350
+ // Contrast: hub-users on the same stateless boot still fails closed.
351
+ writeServices(surfaceEntry(upstream.port, { audience: "hub-users" }));
352
+ const denied = await statelessFetcher(req("/surface/notes/x"), fakeServer("127.0.0.1"));
353
+ expect(denied?.status).toBe(401);
354
+ } finally {
355
+ upstream.stop();
356
+ }
357
+ });
358
+
359
+ test("audience: surface — the loopback cloak still wins (exposure is orthogonal to audience)", async () => {
360
+ const upstream = startEchoUpstream();
361
+ try {
362
+ writeServices({
363
+ ...surfaceEntry(upstream.port, { audience: "surface" }),
364
+ publicExposure: "loopback",
365
+ });
366
+ // Public-layer caller: a surface-audience mount on a loopback-only row
367
+ // is unreachable from funnel — same as public; the audience never
368
+ // bypasses the exposure layer.
369
+ const res = await fetcher()(
370
+ req("/surface/notes/x", { headers: { "cf-ray": "1" } }),
371
+ fakeServer("127.0.0.1"),
372
+ );
373
+ expect(res?.status).toBe(404);
374
+ } finally {
375
+ upstream.stop();
376
+ }
377
+ });
378
+
379
+ test("absent audience defaults to hub-users (anon denied)", async () => {
380
+ const upstream = startEchoUpstream();
381
+ try {
382
+ writeServices(surfaceEntry(upstream.port)); // no audience field
383
+ const res = await fetcher()(req("/surface/notes/x"), fakeServer("127.0.0.1"));
384
+ expect(res?.status).toBe(401);
385
+ } finally {
386
+ upstream.stop();
387
+ }
388
+ });
389
+
390
+ test("legacy boolean public: true maps to 'public' (anon passes); public: false maps to the default (anon denied)", async () => {
391
+ const upstream = startEchoUpstream();
392
+ try {
393
+ writeServices(surfaceEntry(upstream.port, { public: true }));
394
+ const open = await fetcher()(req("/surface/notes/x"), fakeServer("127.0.0.1"));
395
+ expect(open?.status).toBe(200);
396
+
397
+ writeServices(surfaceEntry(upstream.port, { public: false }));
398
+ const closed = await fetcher()(req("/surface/notes/x"), fakeServer("127.0.0.1"));
399
+ expect(closed?.status).toBe(401);
400
+ } finally {
401
+ upstream.stop();
402
+ }
403
+ });
404
+
405
+ test("fail-closed: malformed audience value drops the row (404 — never accidentally public)", async () => {
406
+ const upstream = startEchoUpstream();
407
+ try {
408
+ writeServices(surfaceEntry(upstream.port, { audience: "everyone" }));
409
+ // The validator rejects the row; the lenient read drops the whole
410
+ // service entry, so the mount doesn't exist — 404, not an open door.
411
+ const res = await fetcher()(req("/surface/notes/x"), fakeServer("127.0.0.1"));
412
+ expect(res?.status).toBe(404);
413
+ } finally {
414
+ upstream.stop();
415
+ }
416
+ });
417
+
418
+ test("module paths OUTSIDE any uis entry are NOT gated (module APIs keep their own auth)", async () => {
419
+ const upstream = startEchoUpstream();
420
+ try {
421
+ writeServices(surfaceEntry(upstream.port, { audience: "operator" }));
422
+ // /surface/healthz is on the module mount but under no uis path.
423
+ const res = await fetcher()(req("/surface/healthz"), fakeServer("127.0.0.1"));
424
+ expect(res?.status).toBe(200);
425
+ } finally {
426
+ upstream.stop();
427
+ }
428
+ });
429
+
430
+ test("loopback-cloaked row: 404 cloak wins over the gate (no 401 route-existence leak)", async () => {
431
+ const upstream = startEchoUpstream();
432
+ try {
433
+ writeServices({
434
+ ...surfaceEntry(upstream.port, { audience: "hub-users" }),
435
+ publicExposure: "loopback",
436
+ });
437
+ // Public-layer caller: the cloak must answer 404, not the gate's 401.
438
+ const res = await fetcher()(
439
+ req("/surface/notes/x", { headers: { "cf-ray": "1" } }),
440
+ fakeServer("127.0.0.1"),
441
+ );
442
+ expect(res?.status).toBe(404);
443
+ } finally {
444
+ upstream.stop();
445
+ }
446
+ });
447
+
448
+ test("gate runs BEFORE a WebSocket upgrade (anon upgrade on a hub-users surface → 401, no socket)", async () => {
449
+ const upstream = startEchoUpstream();
450
+ try {
451
+ writeServices({
452
+ ...surfaceEntry(upstream.port, { audience: "hub-users" }),
453
+ websocket: true,
454
+ });
455
+ let upgradeCalls = 0;
456
+ const spy = {
457
+ requestIP: () => ({ address: "127.0.0.1" }),
458
+ upgrade: () => {
459
+ upgradeCalls++;
460
+ return true;
461
+ },
462
+ };
463
+ const res = await fetcher()(
464
+ req("/surface/notes/ws", {
465
+ headers: { upgrade: "websocket", connection: "Upgrade" },
466
+ }),
467
+ spy,
468
+ );
469
+ expect(res?.status).toBe(401);
470
+ expect(upgradeCalls).toBe(0);
471
+
472
+ // …and a session-holding caller upgrades.
473
+ const cookie = await adminSession();
474
+ const ok = await fetcher()(
475
+ req("/surface/notes/ws", {
476
+ headers: { upgrade: "websocket", connection: "Upgrade", cookie },
477
+ }),
478
+ spy,
479
+ );
480
+ expect(ok).toBeUndefined();
481
+ expect(upgradeCalls).toBe(1);
482
+ } finally {
483
+ upstream.stop();
484
+ }
485
+ });
486
+ });
487
+
488
+ // ===========================================================================
489
+ // Units — scope matching + mount resolution
490
+ // ===========================================================================
491
+
492
+ describe("scopeMatchesPattern / scopesSatisfyRequirement", () => {
493
+ test("wildcard segment matches any vault name", () => {
494
+ expect(scopeMatchesPattern("vault:*:read", "vault:default:read")).toBe(true);
495
+ expect(scopeMatchesPattern("vault:*:read", "vault:work:read")).toBe(true);
496
+ expect(scopeMatchesPattern("vault:*:read", "vault:default:write")).toBe(false);
497
+ expect(scopeMatchesPattern("vault:*:read", "scribe:default:read")).toBe(false);
498
+ });
499
+
500
+ test("broad unnamed form satisfies the wildcard pattern (wider-than-required)", () => {
501
+ expect(scopeMatchesPattern("vault:*:read", "vault:read")).toBe(true);
502
+ expect(scopeMatchesPattern("vault:*:write", "vault:read")).toBe(false);
503
+ });
504
+
505
+ test("exact patterns require exact scopes", () => {
506
+ expect(scopeMatchesPattern("surface:admin", "surface:admin")).toBe(true);
507
+ expect(scopeMatchesPattern("surface:admin", "surface:read")).toBe(false);
508
+ });
509
+
510
+ test("EVERY required pattern must be satisfied; empty requirement admits any bearer", () => {
511
+ expect(
512
+ scopesSatisfyRequirement(
513
+ ["vault:*:read", "vault:*:write"],
514
+ ["vault:default:read", "vault:default:write"],
515
+ ),
516
+ ).toBe(true);
517
+ expect(
518
+ scopesSatisfyRequirement(["vault:*:read", "vault:*:write"], ["vault:default:read"]),
519
+ ).toBe(false);
520
+ expect(scopesSatisfyRequirement([], ["anything:at-all"])).toBe(true);
521
+ expect(scopesSatisfyRequirement(undefined, [])).toBe(true);
522
+ });
523
+ });
524
+
525
+ describe("resolveUiMount", () => {
526
+ const entries: ServiceEntry[] = [
527
+ {
528
+ name: "parachute-surface",
529
+ port: 1946,
530
+ paths: ["/surface"],
531
+ health: "/surface/healthz",
532
+ version: "0.3.0",
533
+ uis: {
534
+ notes: { displayName: "Notes", path: "/surface/notes", audience: "hub-users" },
535
+ blog: { displayName: "Blog", path: "/surface/blog", audience: "public" },
536
+ // Nested mount — longest prefix must win.
537
+ "blog-admin": {
538
+ displayName: "Blog Admin",
539
+ path: "/surface/blog/admin",
540
+ audience: "operator",
541
+ },
542
+ },
543
+ },
544
+ ];
545
+
546
+ test("resolves the sub-unit by longest prefix; default audience applied", () => {
547
+ expect(resolveUiMount(entries, "/surface/notes/sw.js")?.uiKey).toBe("notes");
548
+ expect(resolveUiMount(entries, "/surface/blog/post-1")?.audience).toBe("public");
549
+ expect(resolveUiMount(entries, "/surface/blog/admin/settings")?.uiKey).toBe("blog-admin");
550
+ expect(resolveUiMount(entries, "/surface/blog/admin/settings")?.audience).toBe("operator");
551
+ });
552
+
553
+ test("exact mount path matches; sibling paths do not", () => {
554
+ expect(resolveUiMount(entries, "/surface/notes")?.uiKey).toBe("notes");
555
+ expect(resolveUiMount(entries, "/surface/notesy")).toBeUndefined();
556
+ expect(resolveUiMount(entries, "/surface/healthz")).toBeUndefined();
557
+ expect(resolveUiMount(entries, "/elsewhere")).toBeUndefined();
558
+ });
559
+
560
+ // writeManifest round-trip: the validator preserves audience +
561
+ // scopes_required (it used to drop unknown uis fields on rewrite).
562
+ test("validated round-trip preserves audience + scopes_required", () => {
563
+ const dir = mkdtempSync(join(tmpdir(), "pcli-uis-roundtrip-"));
564
+ const p = join(dir, "services.json");
565
+ try {
566
+ writeManifest(
567
+ {
568
+ services: [
569
+ {
570
+ name: "parachute-surface",
571
+ port: 1946,
572
+ paths: ["/surface"],
573
+ health: "/surface/healthz",
574
+ version: "0.3.0",
575
+ uis: {
576
+ blog: {
577
+ displayName: "Blog",
578
+ path: "/surface/blog",
579
+ audience: "public",
580
+ scopes_required: ["vault:*:read"],
581
+ },
582
+ },
583
+ },
584
+ ],
585
+ },
586
+ p,
587
+ );
588
+ const raw = JSON.parse(require("node:fs").readFileSync(p, "utf8") as string) as {
589
+ services: { uis: Record<string, { audience?: string; scopes_required?: string[] }> }[];
590
+ };
591
+ expect(raw.services[0]?.uis.blog?.audience).toBe("public");
592
+ expect(raw.services[0]?.uis.blog?.scopes_required).toEqual(["vault:*:read"]);
593
+ } finally {
594
+ rmSync(dir, { recursive: true, force: true });
595
+ }
596
+ });
597
+
598
+ // The fourth tier is a valid manifest value: validation accepts it and the
599
+ // validated write round-trips it (a backed surface registering
600
+ // `audience: "surface"` must survive a hub-side manifest rewrite).
601
+ test("manifest validation accepts audience: 'surface' and round-trips it", () => {
602
+ const dir = mkdtempSync(join(tmpdir(), "pcli-uis-surface-roundtrip-"));
603
+ const p = join(dir, "services.json");
604
+ try {
605
+ writeManifest(
606
+ {
607
+ services: [
608
+ {
609
+ name: "parachute-surface",
610
+ port: 1946,
611
+ paths: ["/surface"],
612
+ health: "/surface/healthz",
613
+ version: "0.3.0",
614
+ uis: {
615
+ "docs-editor": {
616
+ displayName: "Docs Editor",
617
+ path: "/surface/docs",
618
+ audience: "surface",
619
+ },
620
+ },
621
+ },
622
+ ],
623
+ },
624
+ p,
625
+ );
626
+ const raw = JSON.parse(require("node:fs").readFileSync(p, "utf8") as string) as {
627
+ services: { uis: Record<string, { audience?: string }> }[];
628
+ };
629
+ expect(raw.services[0]?.uis["docs-editor"]?.audience).toBe("surface");
630
+ } finally {
631
+ rmSync(dir, { recursive: true, force: true });
632
+ }
633
+ });
634
+ });
635
+
636
+ // ===========================================================================
637
+ // H5 — chrome-strip rides the gate
638
+ // ===========================================================================
639
+
640
+ describe("chrome strip × audience (H5)", () => {
641
+ function startHtmlUpstream(): { port: number; stop: () => void } {
642
+ const server = Bun.serve({
643
+ port: 0,
644
+ hostname: "127.0.0.1",
645
+ fetch: () =>
646
+ new Response("<html><head></head><body><h1>surface page</h1></body></html>", {
647
+ status: 200,
648
+ headers: { "content-type": "text/html; charset=utf-8" },
649
+ }),
650
+ });
651
+ return { port: server.port as number, stop: () => server.stop(true) };
652
+ }
653
+
654
+ test("audience: public → NO injected chrome on the HTML response", async () => {
655
+ const upstream = startHtmlUpstream();
656
+ try {
657
+ writeServices(surfaceEntry(upstream.port, { audience: "public" }));
658
+ const res = await fetcher()(
659
+ req("/surface/notes/", { headers: { accept: "text/html" } }),
660
+ fakeServer("127.0.0.1"),
661
+ );
662
+ expect(res?.status).toBe(200);
663
+ const html = await res?.text();
664
+ expect(html).toContain("surface page"); // upstream body intact
665
+ expect(html).not.toContain("pc-chrome"); // identity chrome absent
666
+ } finally {
667
+ upstream.stop();
668
+ }
669
+ });
670
+
671
+ test("audience: hub-users (session) → chrome injected as before", async () => {
672
+ const upstream = startHtmlUpstream();
673
+ try {
674
+ // Mount OUTSIDE the static /surface/notes/ opt-out so the audience
675
+ // mechanism (not the legacy hardcoded prefix) is what's exercised.
676
+ writeServices({
677
+ name: "parachute-surface",
678
+ port: upstream.port,
679
+ paths: ["/surface"],
680
+ health: "/surface/healthz",
681
+ version: "0.3.0",
682
+ uis: {
683
+ tasks: { displayName: "Tasks", path: "/surface/tasks", audience: "hub-users" },
684
+ },
685
+ });
686
+ const cookie = await adminSession();
687
+ const res = await fetcher()(
688
+ req("/surface/tasks/", { headers: { accept: "text/html", cookie } }),
689
+ fakeServer("127.0.0.1"),
690
+ );
691
+ expect(res?.status).toBe(200);
692
+ const html = await res?.text();
693
+ expect(html).toContain("pc-chrome"); // identity chrome present
694
+ expect(html).toContain("surface page");
695
+ } finally {
696
+ upstream.stop();
697
+ }
698
+ });
699
+
700
+ test("audience: surface → NO injected chrome (capability invitees aren't hub users — follows the public precedent)", async () => {
701
+ const upstream = startHtmlUpstream();
702
+ try {
703
+ // Mount OUTSIDE the static /surface/notes/ opt-out so the audience
704
+ // mechanism (not the legacy hardcoded prefix) is what's exercised.
705
+ writeServices({
706
+ name: "parachute-surface",
707
+ port: upstream.port,
708
+ paths: ["/surface"],
709
+ health: "/surface/healthz",
710
+ version: "0.3.0",
711
+ uis: {
712
+ docs: { displayName: "Docs Editor", path: "/surface/docs", audience: "surface" },
713
+ },
714
+ });
715
+ const res = await fetcher()(
716
+ req("/surface/docs/some-doc", { headers: { accept: "text/html" } }),
717
+ fakeServer("127.0.0.1"),
718
+ );
719
+ expect(res?.status).toBe(200);
720
+ const html = await res?.text();
721
+ expect(html).toContain("surface page"); // upstream body intact
722
+ expect(html).not.toContain("pc-chrome"); // identity chrome absent
723
+ } finally {
724
+ upstream.stop();
725
+ }
726
+ });
727
+
728
+ test("a PUBLIC mount outside the static opt-out list also strips chrome (the generalization)", async () => {
729
+ const upstream = startHtmlUpstream();
730
+ try {
731
+ writeServices({
732
+ name: "parachute-surface",
733
+ port: upstream.port,
734
+ paths: ["/surface"],
735
+ health: "/surface/healthz",
736
+ version: "0.3.0",
737
+ uis: {
738
+ blog: { displayName: "Blog", path: "/surface/blog", audience: "public" },
739
+ },
740
+ });
741
+ const res = await fetcher()(
742
+ req("/surface/blog/post-1", { headers: { accept: "text/html" } }),
743
+ fakeServer("127.0.0.1"),
744
+ );
745
+ expect(res?.status).toBe(200);
746
+ const html = await res?.text();
747
+ expect(html).not.toContain("pc-chrome");
748
+ } finally {
749
+ upstream.stop();
750
+ }
751
+ });
752
+ });