@openparachute/hub 0.5.10-rc.9 → 0.5.10

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 (44) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/admin-handlers.test.ts +141 -6
  3. package/src/__tests__/api-account.test.ts +463 -0
  4. package/src/__tests__/api-modules-ops.test.ts +74 -0
  5. package/src/__tests__/api-modules.test.ts +134 -0
  6. package/src/__tests__/api-users.test.ts +522 -0
  7. package/src/__tests__/cors.test.ts +587 -0
  8. package/src/__tests__/hub-db.test.ts +126 -1
  9. package/src/__tests__/hub-settings.test.ts +152 -0
  10. package/src/__tests__/jwt-sign.test.ts +59 -0
  11. package/src/__tests__/oauth-handlers.test.ts +912 -10
  12. package/src/__tests__/oauth-ui.test.ts +210 -0
  13. package/src/__tests__/scope-explanations.test.ts +23 -0
  14. package/src/__tests__/serve.test.ts +8 -1
  15. package/src/__tests__/setup-wizard.test.ts +216 -3
  16. package/src/__tests__/users.test.ts +196 -0
  17. package/src/__tests__/vault-names.test.ts +172 -0
  18. package/src/account-change-password-ui.ts +379 -0
  19. package/src/admin-handlers.ts +68 -2
  20. package/src/admin-host-admin-token.ts +5 -0
  21. package/src/admin-vault-admin-token.ts +7 -0
  22. package/src/api-account.ts +443 -0
  23. package/src/api-mint-token.ts +6 -0
  24. package/src/api-modules-ops.ts +15 -6
  25. package/src/api-modules.ts +101 -0
  26. package/src/api-users.ts +393 -0
  27. package/src/commands/auth.ts +10 -1
  28. package/src/commands/serve.ts +5 -1
  29. package/src/cors.ts +263 -0
  30. package/src/hub-db.ts +30 -0
  31. package/src/hub-server.ts +138 -18
  32. package/src/hub-settings.ts +98 -1
  33. package/src/jwt-sign.ts +17 -1
  34. package/src/oauth-handlers.ts +237 -29
  35. package/src/oauth-ui.ts +451 -38
  36. package/src/operator-token.ts +4 -0
  37. package/src/scope-explanations.ts +26 -1
  38. package/src/setup-wizard.ts +134 -16
  39. package/src/users.ts +210 -3
  40. package/src/vault-names.ts +57 -0
  41. package/web/ui/dist/assets/index-XhxYXDT5.js +61 -0
  42. package/web/ui/dist/assets/{index-D54otIhv.css → index-p6DkOcsk.css} +1 -1
  43. package/web/ui/dist/index.html +2 -2
  44. package/web/ui/dist/assets/index-AX_UHJ5e.js +0 -61
@@ -2,10 +2,13 @@ import { describe, expect, test } from "bun:test";
2
2
  import {
3
3
  type AuthorizeFormParams,
4
4
  escapeHtml,
5
+ renderApprovePending,
5
6
  renderConsent,
6
7
  renderError,
7
8
  renderHiddenInputs,
8
9
  renderLogin,
10
+ renderUnknownClient,
11
+ substituteVaultDisplay,
9
12
  } from "../oauth-ui.ts";
10
13
 
11
14
  const PARAMS: AuthorizeFormParams = {
@@ -232,6 +235,213 @@ describe("renderError", () => {
232
235
  });
233
236
  });
234
237
 
238
+ describe("renderUnknownClient", () => {
239
+ test("escapes the client_id into the page", () => {
240
+ const html = renderUnknownClient({
241
+ clientId: "<img src=x>",
242
+ selfOriginRedirectPath: null,
243
+ });
244
+ expect(html).toContain("&lt;img src=x&gt;");
245
+ expect(html).not.toContain("<img src=x>");
246
+ });
247
+
248
+ test("renders the recovery button when selfOriginRedirectPath is set", () => {
249
+ const html = renderUnknownClient({
250
+ clientId: "stale-id",
251
+ selfOriginRedirectPath: "/notes/oauth/callback",
252
+ });
253
+ expect(html).toContain('id="unknown-client-reset"');
254
+ expect(html).toContain('data-target="/notes/oauth/callback"');
255
+ // The inline JS clears the Notes-side DCR cache prefix.
256
+ expect(html).toContain("'lens:dcr:'");
257
+ });
258
+
259
+ test("escapes selfOriginRedirectPath into the data-target attribute", () => {
260
+ const html = renderUnknownClient({
261
+ clientId: "id",
262
+ selfOriginRedirectPath: '/x"><script>alert(1)</script>',
263
+ });
264
+ expect(html).not.toContain("><script>alert(1)</script>");
265
+ expect(html).toContain("&quot;");
266
+ });
267
+
268
+ test("omits the recovery button when selfOriginRedirectPath is null", () => {
269
+ const html = renderUnknownClient({
270
+ clientId: "stale-id",
271
+ selfOriginRedirectPath: null,
272
+ });
273
+ expect(html).not.toContain('id="unknown-client-reset"');
274
+ expect(html).not.toContain("'lens:dcr:'");
275
+ // Static fallback help text still surfaces.
276
+ expect(html).toContain("close this window");
277
+ });
278
+ });
279
+
280
+ describe("substituteVaultDisplay", () => {
281
+ test("undefined → leaves the scope untouched", () => {
282
+ expect(substituteVaultDisplay("vault:read", undefined)).toBe("vault:read");
283
+ });
284
+
285
+ test("named vault → substitutes vault:<name>:<verb>", () => {
286
+ expect(substituteVaultDisplay("vault:read", "work")).toBe("vault:work:read");
287
+ expect(substituteVaultDisplay("vault:write", "default")).toBe("vault:default:write");
288
+ });
289
+
290
+ test("null → renders a <TBD> placeholder for the consent picker", () => {
291
+ expect(substituteVaultDisplay("vault:read", null)).toBe("vault:<TBD>:read");
292
+ });
293
+
294
+ test("non-vault scope passes through regardless of displayVault", () => {
295
+ expect(substituteVaultDisplay("scribe:transcribe", "work")).toBe("scribe:transcribe");
296
+ expect(substituteVaultDisplay("channel:send", null)).toBe("channel:send");
297
+ });
298
+
299
+ test("already-named vault scope passes through (caller specified the vault)", () => {
300
+ expect(substituteVaultDisplay("vault:other:read", "work")).toBe("vault:other:read");
301
+ });
302
+
303
+ test("vault admin (vault:admin) doesn't get narrowed — admin verb stays unnamed", () => {
304
+ // vault:admin is a full-vault scope; we don't narrow it the same way
305
+ // because per-vault admin is `vault:<name>:admin` (non-requestable) and
306
+ // the unnamed vault:admin form is the legacy full-vault grant. Keep the
307
+ // displayed shape as-is so the operator sees the scope they're
308
+ // consenting to literally.
309
+ expect(substituteVaultDisplay("vault:admin", "work")).toBe("vault:admin");
310
+ });
311
+ });
312
+
313
+ describe("renderConsent displayVault substitution", () => {
314
+ test("non-admin user (lockedVault) → consent shows vault:<assigned>:<verb>", () => {
315
+ const html = renderConsent({
316
+ params: PARAMS,
317
+ csrfToken: CSRF,
318
+ clientId: "c",
319
+ clientName: "App",
320
+ scopes: ["vault:read", "vault:write"],
321
+ vaultPicker: {
322
+ unnamedVerbs: ["read", "write"],
323
+ availableVaults: ["my-vault"],
324
+ lockedVault: "my-vault",
325
+ },
326
+ displayVault: "my-vault",
327
+ });
328
+ expect(html).toContain("vault:my-vault:read");
329
+ expect(html).toContain("vault:my-vault:write");
330
+ // Raw unnamed form (the thing this PR fixes) must NOT appear in the
331
+ // rendered scope-row code blocks. Scope name shows up inside
332
+ // `<code class="scope-name">…</code>` so check the row substring.
333
+ expect(html).not.toMatch(/<code class="scope-name">vault:read<\/code>/);
334
+ expect(html).not.toMatch(/<code class="scope-name">vault:write<\/code>/);
335
+ });
336
+
337
+ test("admin user, single-vault hub → picker pre-checks the only vault, consent shows it", () => {
338
+ const html = renderConsent({
339
+ params: PARAMS,
340
+ csrfToken: CSRF,
341
+ clientId: "c",
342
+ clientName: "App",
343
+ scopes: ["vault:read"],
344
+ vaultPicker: { unnamedVerbs: ["read"], availableVaults: ["default"] },
345
+ displayVault: "default",
346
+ });
347
+ expect(html).toContain("vault:default:read");
348
+ expect(html).not.toMatch(/<code class="scope-name">vault:read<\/code>/);
349
+ });
350
+
351
+ test("admin user, multi-vault hub → displayVault=null renders <TBD> + picker hint", () => {
352
+ const html = renderConsent({
353
+ params: PARAMS,
354
+ csrfToken: CSRF,
355
+ clientId: "c",
356
+ clientName: "App",
357
+ scopes: ["vault:read"],
358
+ vaultPicker: { unnamedVerbs: ["read"], availableVaults: ["work", "personal"] },
359
+ displayVault: null,
360
+ });
361
+ expect(html).toContain("vault:&lt;TBD&gt;:read");
362
+ expect(html).toContain("scope-pending-note");
363
+ expect(html).toContain("A specific vault is picked below");
364
+ // explainScope label still resolves via the verb-form lookup.
365
+ expect(html).toContain("Read your notes");
366
+ });
367
+
368
+ test("displayVault undefined preserves the legacy raw form (no substitution)", () => {
369
+ // The existing oauth-ui.test.ts cases call renderConsent without
370
+ // displayVault — confirming the back-compat shape stays.
371
+ const html = renderConsent({
372
+ params: PARAMS,
373
+ csrfToken: CSRF,
374
+ clientId: "c",
375
+ clientName: "App",
376
+ scopes: ["vault:read"],
377
+ });
378
+ expect(html).toContain("vault:read");
379
+ });
380
+ });
381
+
382
+ describe("renderApprovePending unauthenticated CTAs", () => {
383
+ const COMMON = {
384
+ clientName: "MyApp",
385
+ clientId: "client-xyz",
386
+ redirectUris: ["https://app.example/cb"],
387
+ requestedScopes: ["vault:read"],
388
+ hubOrigin: "https://hub.example.com",
389
+ };
390
+
391
+ test("renders Sign in CTA wired to /login?next=/admin/approve-client/<id>", () => {
392
+ const html = renderApprovePending(COMMON);
393
+ expect(html).toContain("Sign in as admin to approve");
394
+ const expectedHref = `/login?next=${encodeURIComponent("/admin/approve-client/client-xyz")}`;
395
+ expect(html).toContain(`href="${expectedHref}"`);
396
+ });
397
+
398
+ test("renders fully-qualified shareable deep link + Copy button + clipboard JS", () => {
399
+ const html = renderApprovePending(COMMON);
400
+ expect(html).toContain("https://hub.example.com/admin/approve-client/client-xyz");
401
+ expect(html).toContain('id="approve-share-copy"');
402
+ expect(html).toContain('data-link="https://hub.example.com/admin/approve-client/client-xyz"');
403
+ // Inline JS uses navigator.clipboard.writeText with visual feedback.
404
+ expect(html).toContain("navigator.clipboard");
405
+ expect(html).toContain("writeText");
406
+ expect(html).toContain("Copied!");
407
+ });
408
+
409
+ test("trims a trailing slash on hubOrigin so the deep link doesn't double-slash", () => {
410
+ const html = renderApprovePending({ ...COMMON, hubOrigin: "https://hub.example.com/" });
411
+ expect(html).toContain("https://hub.example.com/admin/approve-client/client-xyz");
412
+ expect(html).not.toContain("https://hub.example.com//admin/");
413
+ });
414
+
415
+ test("retired CLI hint does NOT appear in the unauthenticated branch", () => {
416
+ const html = renderApprovePending(COMMON);
417
+ expect(html).not.toContain("parachute auth approve-client");
418
+ expect(html).not.toContain("from a terminal");
419
+ });
420
+
421
+ test("escapes hostile client_id into href, data-link, and the visible deep link", () => {
422
+ const html = renderApprovePending({
423
+ ...COMMON,
424
+ clientId: "<img src=x onerror=alert(1)>",
425
+ });
426
+ expect(html).not.toContain("<img src=x");
427
+ // encodeURIComponent in both the href and the deep-link URL produces
428
+ // %3Cimg…; that lives inside escapeHtml-wrapped attribute values.
429
+ expect(html).toContain("%3Cimg");
430
+ });
431
+
432
+ test("admin-authenticated branch hides the unauth CTAs and renders the inline approve form", () => {
433
+ const html = renderApprovePending({
434
+ ...COMMON,
435
+ approveForm: { csrfToken: CSRF, returnTo: "/oauth/authorize?client_id=client-xyz" },
436
+ });
437
+ expect(html).toContain('action="/oauth/authorize/approve"');
438
+ expect(html).toContain("Approve and continue");
439
+ expect(html).not.toContain("Sign in as admin to approve");
440
+ expect(html).not.toContain("Or send this link to your hub admin");
441
+ expect(html).not.toContain("parachute auth approve-client");
442
+ });
443
+ });
444
+
235
445
  describe("CSS / styling guarantees", () => {
236
446
  test("does not load fonts from a third-party CDN (privacy)", () => {
237
447
  const html = renderLogin({ params: PARAMS, csrfToken: CSRF });
@@ -41,6 +41,29 @@ describe("explainScope", () => {
41
41
  test("returns null for an unknown scope", () => {
42
42
  expect(explainScope("notes:weird-thing")).toBeNull();
43
43
  });
44
+
45
+ // Approval-UX rc.19: the consent screen renders the *resolved* scope
46
+ // shape (`vault:<name>:read`) rather than the raw OAuth request
47
+ // (`vault:read`) so the operator sees the form the token will carry.
48
+ // explainScope falls back to the unnamed verb's entry for both the
49
+ // narrowed (`vault:work:read`) and wildcard-display (`vault:*:read`)
50
+ // forms so the consent UI keeps the same label + level styling.
51
+ test("named vault scopes (vault:<name>:<verb>) reuse the unnamed-verb explanation", () => {
52
+ expect(explainScope("vault:work:read")?.label).toBe(SCOPE_EXPLANATIONS["vault:read"]?.label);
53
+ expect(explainScope("vault:work:read")?.level).toBe("read");
54
+ expect(explainScope("vault:my-techne_2:write")?.level).toBe("write");
55
+ });
56
+
57
+ test("wildcard vault display form (vault:*:<verb>) explains via the unnamed verb", () => {
58
+ expect(explainScope("vault:*:read")?.level).toBe("read");
59
+ expect(explainScope("vault:*:write")?.level).toBe("write");
60
+ });
61
+
62
+ test("doesn't promote a per-vault admin (vault:<name>:admin) into an explained scope", () => {
63
+ // vault:<name>:admin is NON_REQUESTABLE — never appears on the consent
64
+ // screen. Explicitly not in the verb-pattern, so explainScope returns null.
65
+ expect(explainScope("vault:default:admin")).toBeNull();
66
+ });
44
67
  });
45
68
 
46
69
  describe("scopeIsAdmin", () => {
@@ -4,7 +4,7 @@ import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { seedInitialAdminIfNeeded } from "../commands/serve.ts";
6
6
  import { openHubDb } from "../hub-db.ts";
7
- import { userCount } from "../users.ts";
7
+ import { getUserByUsername, userCount } from "../users.ts";
8
8
 
9
9
  describe("seedInitialAdminIfNeeded", () => {
10
10
  let dir: string;
@@ -44,6 +44,13 @@ describe("seedInitialAdminIfNeeded", () => {
44
44
  expect(log).toHaveBeenCalledTimes(1);
45
45
  expect(log.mock.calls[0]?.[0] ?? "").toContain("seeded initial admin");
46
46
  expect(log.mock.calls[0]?.[0] ?? "").toContain("ops");
47
+ // Multi-user Phase 1 (design 2026-05-20-multi-user-phase-1.md §wizard
48
+ // interaction): env-seeded admin chose their password via env vars, so
49
+ // skip the force-change-password redirect. `assignedVault` stays null
50
+ // — admin posture.
51
+ const seeded = getUserByUsername(db, "ops");
52
+ expect(seeded?.passwordChanged).toBe(true);
53
+ expect(seeded?.assignedVault).toBeNull();
47
54
  });
48
55
 
49
56
  test("returns 'exists' when an admin already exists, even with env vars set", async () => {
@@ -37,7 +37,7 @@ import {
37
37
  handleSetupVaultPost,
38
38
  } from "../setup-wizard.ts";
39
39
  import { Supervisor } from "../supervisor.ts";
40
- import { createUser, userCount } from "../users.ts";
40
+ import { createUser, getUserByUsername, userCount } from "../users.ts";
41
41
 
42
42
  interface Harness {
43
43
  dir: string;
@@ -577,6 +577,13 @@ describe("handleSetupAccountPost", () => {
577
577
  const sessionCookie = setCookie(post, SESSION_COOKIE_NAME);
578
578
  expect(sessionCookie).toBeDefined();
579
579
  expect(userCount(db)).toBe(1);
580
+ // Multi-user Phase 1: the wizard's first admin chose their password
581
+ // via this very form, so skip the force-change-password redirect on
582
+ // first sign-in (`password_changed=1`). `assigned_vault` stays NULL
583
+ // — admin posture (no per-vault restriction).
584
+ const created = getUserByUsername(db, "ops");
585
+ expect(created?.passwordChanged).toBe(true);
586
+ expect(created?.assignedVault).toBeNull();
580
587
  } finally {
581
588
  db.close();
582
589
  }
@@ -1335,9 +1342,28 @@ describe("done screen auto-minted token (hub#272 Item A)", () => {
1335
1342
  );
1336
1343
  expect(res.status).toBe(200);
1337
1344
  const html = await res.text();
1338
- expect(html).toContain("--header &quot;Authorization: Bearer test-jwt-token-abc&quot;");
1339
- expect(html).toContain('data-target="mcp-cmd"');
1345
+ // Real token rides in the hidden script-tag stash as JSON-encoded
1346
+ // text — script element content is raw-text per the HTML spec
1347
+ // (entities aren't parsed), so JSON encoding round-trips through
1348
+ // textContent + JSON.parse without `&quot;` polluting the copied
1349
+ // command. Verify the JSON-encoded form appears in the document.
1350
+ expect(html).toContain(
1351
+ '"claude mcp add --transport http parachute-default https://hub.example/vault/default/mcp --header \\"Authorization: Bearer test-jwt-token-abc\\""',
1352
+ );
1340
1353
  expect(html).toContain('id="mcp-cmd"');
1354
+ expect(html).toContain('id="mcp-cmd-real"');
1355
+ // The hidden stash is `<script type="application/json">` so the
1356
+ // browser doesn't execute it but textContent is still readable.
1357
+ expect(html).toContain('<script type="application/json" id="mcp-cmd-real">');
1358
+ // The visible default state is masked: the <pre> body is wrapped
1359
+ // with data-state="masked" and renders • placeholder characters
1360
+ // rather than the live token. Verified by the masked Bearer
1361
+ // header substring (• repeated).
1362
+ expect(html).toContain('data-state="masked"');
1363
+ expect(html).toMatch(/Bearer •+/);
1364
+ // Show button + Copy button both present.
1365
+ expect(html).toContain('id="mcp-cmd-show"');
1366
+ expect(html).toContain('id="mcp-cmd-copy"');
1341
1367
  expect(html).toContain("/admin/tokens");
1342
1368
  // The token is single-use — consumed on first render.
1343
1369
  expect(getSetting(db, "setup_minted_token")).toBeUndefined();
@@ -1439,6 +1465,193 @@ describe("done screen auto-minted token (hub#272 Item A)", () => {
1439
1465
  }
1440
1466
  });
1441
1467
 
1468
+ // rc.11 — token visible by default on the done screen was a
1469
+ // shoulder-surf hazard. The fix: render the visible command with
1470
+ // a masked Bearer token, stash the real command in a
1471
+ // hidden script tag, and surface a Show button + Copy button. Copy
1472
+ // ALWAYS pulls the real command from the script tag so the
1473
+ // operator's terminal paste never breaks regardless of mask state.
1474
+ test("done screen masks the Bearer token in the visible <pre> by default", async () => {
1475
+ const db = openHubDb(hubDbPath(h.dir));
1476
+ try {
1477
+ const user = await createUser(db, "owner", "pw");
1478
+ writeManifest(
1479
+ {
1480
+ services: [
1481
+ {
1482
+ name: "parachute-vault",
1483
+ version: "0.1.0",
1484
+ port: 1940,
1485
+ paths: ["/vault/default"],
1486
+ health: "/health",
1487
+ },
1488
+ ],
1489
+ },
1490
+ h.manifestPath,
1491
+ );
1492
+ setSetting(db, "setup_expose_mode", "localhost");
1493
+ setSetting(db, "setup_minted_token", "pvt_super_secret_token_payload");
1494
+ const { createSession } = await import("../sessions.ts");
1495
+ const session = createSession(db, { userId: user.id });
1496
+ const res = handleSetupGet(
1497
+ req("/admin/setup?just_finished=1", {
1498
+ headers: { cookie: `${SESSION_COOKIE_NAME}=${session.id}` },
1499
+ }),
1500
+ {
1501
+ db,
1502
+ manifestPath: h.manifestPath,
1503
+ configDir: h.dir,
1504
+ issuer: "https://hub.example",
1505
+ registry: getDefaultOperationsRegistry(),
1506
+ },
1507
+ );
1508
+ const html = await res.text();
1509
+ // Extract the visible <pre id="mcp-cmd"> text only — the masked
1510
+ // shape must live there, with no occurrence of the literal token
1511
+ // string. The real token still appears elsewhere (the hidden
1512
+ // script tag) so a plain `toContain` would miss the leak.
1513
+ const preMatch = html.match(/<pre id="mcp-cmd">([^<]*)<\/pre>/);
1514
+ expect(preMatch).not.toBeNull();
1515
+ const preBody = preMatch?.[1] ?? "";
1516
+ expect(preBody).not.toContain("pvt_super_secret_token_payload");
1517
+ // Masked Bearer header is present in the <pre> text.
1518
+ expect(preBody).toMatch(/Bearer •+/);
1519
+ // Real command still in the document (hidden JSON stash) so the
1520
+ // Copy handler can read it.
1521
+ expect(html).toContain('<script type="application/json" id="mcp-cmd-real">');
1522
+ expect(html).toContain("pvt_super_secret_token_payload");
1523
+ // Default state is masked.
1524
+ expect(html).toContain('data-state="masked"');
1525
+ } finally {
1526
+ db.close();
1527
+ }
1528
+ });
1529
+
1530
+ test("done screen JSON-encodes the stashed command so `</script>` in a token can't break out", async () => {
1531
+ // Defense-in-depth: an attacker-shaped token containing `</script>`
1532
+ // would prematurely close the stash tag if we just dropped it into
1533
+ // the HTML. The renderer JSON-encodes the command AND replaces
1534
+ // `</` with `<\/` inside the encoded string so the sequence can't
1535
+ // appear in the document. Decode round-trips via JSON.parse.
1536
+ const db = openHubDb(hubDbPath(h.dir));
1537
+ try {
1538
+ const user = await createUser(db, "owner", "pw");
1539
+ writeManifest(
1540
+ {
1541
+ services: [
1542
+ {
1543
+ name: "parachute-vault",
1544
+ version: "0.1.0",
1545
+ port: 1940,
1546
+ paths: ["/vault/default"],
1547
+ health: "/health",
1548
+ },
1549
+ ],
1550
+ },
1551
+ h.manifestPath,
1552
+ );
1553
+ setSetting(db, "setup_expose_mode", "localhost");
1554
+ // Token contains characters that would be load-bearing in the
1555
+ // HTML/JS layer if mis-encoded: a quote (would close the JSON
1556
+ // string) and `</script>` (would close the stash tag).
1557
+ const hostileToken = `weird-token-with-"-and-</script>-inside`;
1558
+ setSetting(db, "setup_minted_token", hostileToken);
1559
+ const { createSession } = await import("../sessions.ts");
1560
+ const session = createSession(db, { userId: user.id });
1561
+ const res = handleSetupGet(
1562
+ req("/admin/setup?just_finished=1", {
1563
+ headers: { cookie: `${SESSION_COOKIE_NAME}=${session.id}` },
1564
+ }),
1565
+ {
1566
+ db,
1567
+ manifestPath: h.manifestPath,
1568
+ configDir: h.dir,
1569
+ issuer: "https://hub.example",
1570
+ registry: getDefaultOperationsRegistry(),
1571
+ },
1572
+ );
1573
+ const html = await res.text();
1574
+ // `</script>` must NOT appear inside the stash element. We
1575
+ // verify by extracting the stash text via the literal HTML
1576
+ // boundaries and asserting no close-tag escape escaped the
1577
+ // encoder.
1578
+ const stashMatch = html.match(
1579
+ /<script type="application\/json" id="mcp-cmd-real">([\s\S]*?)<\/script>/,
1580
+ );
1581
+ expect(stashMatch).not.toBeNull();
1582
+ const stashBody = stashMatch?.[1] ?? "";
1583
+ // The encoder replaces `</` with `<\/` inside the JSON, so the
1584
+ // raw bytes between the opening and the first `</script>` should
1585
+ // not contain `</`.
1586
+ expect(stashBody).not.toContain("</");
1587
+ // Round-trips: `<\/` decodes back to `</` after JSON.parse +
1588
+ // the script-end-sequence escape — the operator's clipboard
1589
+ // gets the original bytes.
1590
+ const decoded = JSON.parse(stashBody) as string;
1591
+ expect(decoded).toContain(hostileToken);
1592
+ } finally {
1593
+ db.close();
1594
+ }
1595
+ });
1596
+
1597
+ test("done screen wires Show + Copy buttons that read from the hidden real-command stash", async () => {
1598
+ const db = openHubDb(hubDbPath(h.dir));
1599
+ try {
1600
+ const user = await createUser(db, "owner", "pw");
1601
+ writeManifest(
1602
+ {
1603
+ services: [
1604
+ {
1605
+ name: "parachute-vault",
1606
+ version: "0.1.0",
1607
+ port: 1940,
1608
+ paths: ["/vault/default"],
1609
+ health: "/health",
1610
+ },
1611
+ ],
1612
+ },
1613
+ h.manifestPath,
1614
+ );
1615
+ setSetting(db, "setup_expose_mode", "localhost");
1616
+ setSetting(db, "setup_minted_token", "live-token-AAA");
1617
+ const { createSession } = await import("../sessions.ts");
1618
+ const session = createSession(db, { userId: user.id });
1619
+ const res = handleSetupGet(
1620
+ req("/admin/setup?just_finished=1", {
1621
+ headers: { cookie: `${SESSION_COOKIE_NAME}=${session.id}` },
1622
+ }),
1623
+ {
1624
+ db,
1625
+ manifestPath: h.manifestPath,
1626
+ configDir: h.dir,
1627
+ issuer: "https://hub.example",
1628
+ registry: getDefaultOperationsRegistry(),
1629
+ },
1630
+ );
1631
+ const html = await res.text();
1632
+ // Both buttons present, both wired via addEventListener (no
1633
+ // inline onclick — the script runs in a single IIFE).
1634
+ expect(html).toContain('id="mcp-cmd-show"');
1635
+ expect(html).toContain('id="mcp-cmd-copy"');
1636
+ expect(html).toContain("'click'");
1637
+ // The Copy handler reads from the hidden script tag, not from
1638
+ // the visible <pre>. Regression: this was the load-bearing
1639
+ // contract Aaron called out ("Copy still works without reveal").
1640
+ expect(html).toContain("getElementById('mcp-cmd-real')");
1641
+ // The stash holds JSON-encoded text and the handler decodes via
1642
+ // JSON.parse so the clipboard receives the exact byte sequence of
1643
+ // the command — `&quot;`-style HTML entities can't survive into
1644
+ // the operator's shell because script-element content is raw text
1645
+ // (the HTML parser doesn't decode entities inside <script>).
1646
+ expect(html).toContain("JSON.parse(real.textContent");
1647
+ // Auto-hide timer present so a stray reveal doesn't leak into a
1648
+ // subsequent screencast capture.
1649
+ expect(html).toContain("setTimeout(setMasked, 10000)");
1650
+ } finally {
1651
+ db.close();
1652
+ }
1653
+ });
1654
+
1442
1655
  test("GET /admin/setup?just_finished=1 without a session does NOT consume the minted token (hub#274 security fold)", async () => {
1443
1656
  // Regression — without the session gate, any HTTP client racing the
1444
1657
  // operator's browser between the expose POST (which mints + stores)