@openparachute/hub 0.7.1 → 0.7.2-rc.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 (73) hide show
  1. package/README.md +13 -14
  2. package/package.json +1 -1
  3. package/src/__tests__/admin-agent-grants.test.ts +1547 -0
  4. package/src/__tests__/{admin-channel-token.test.ts → admin-agent-token.test.ts} +32 -32
  5. package/src/__tests__/admin-connections-credentials.test.ts +8 -4
  6. package/src/__tests__/admin-connections.test.ts +211 -57
  7. package/src/__tests__/admin-csrf-belt.test.ts +7 -7
  8. package/src/__tests__/admin-lock.test.ts +600 -0
  9. package/src/__tests__/admin-module-token.test.ts +36 -8
  10. package/src/__tests__/admin-vaults.test.ts +8 -8
  11. package/src/__tests__/api-modules-ops.test.ts +17 -16
  12. package/src/__tests__/api-modules.test.ts +35 -36
  13. package/src/__tests__/api-ready.test.ts +2 -2
  14. package/src/__tests__/clients.test.ts +91 -0
  15. package/src/__tests__/grants-store.test.ts +219 -0
  16. package/src/__tests__/hub-server.test.ts +9 -5
  17. package/src/__tests__/migrate.test.ts +1 -1
  18. package/src/__tests__/module-manifest.test.ts +11 -11
  19. package/src/__tests__/oauth-client.test.ts +446 -0
  20. package/src/__tests__/oauth-flows-store.test.ts +141 -0
  21. package/src/__tests__/oauth-handlers.test.ts +124 -26
  22. package/src/__tests__/operator-token.test.ts +2 -2
  23. package/src/__tests__/scope-explanations.test.ts +3 -3
  24. package/src/__tests__/serve-boot.test.ts +14 -14
  25. package/src/__tests__/serve.test.ts +26 -0
  26. package/src/__tests__/service-spec-discovery.test.ts +26 -18
  27. package/src/__tests__/services-manifest.test.ts +60 -48
  28. package/src/__tests__/setup-gate.test.ts +52 -3
  29. package/src/__tests__/setup-wizard.test.ts +86 -280
  30. package/src/__tests__/setup.test.ts +1 -1
  31. package/src/__tests__/upgrade.test.ts +276 -0
  32. package/src/__tests__/vault-remove.test.ts +393 -0
  33. package/src/admin-agent-grants.ts +1365 -0
  34. package/src/admin-agent-token.ts +147 -0
  35. package/src/admin-connections.ts +67 -50
  36. package/src/admin-host-admin-token.ts +14 -1
  37. package/src/admin-lock.ts +281 -0
  38. package/src/admin-module-token.ts +15 -7
  39. package/src/admin-vault-admin-token.ts +8 -1
  40. package/src/admin-vaults.ts +12 -12
  41. package/src/api-admin-lock.ts +335 -0
  42. package/src/api-modules-ops.ts +3 -2
  43. package/src/api-modules.ts +9 -9
  44. package/src/cli.ts +13 -1
  45. package/src/clients.ts +88 -0
  46. package/src/commands/install.ts +7 -0
  47. package/src/commands/serve-boot.ts +5 -4
  48. package/src/commands/serve.ts +45 -19
  49. package/src/commands/setup.ts +4 -3
  50. package/src/commands/upgrade.ts +118 -2
  51. package/src/commands/vault-remove.ts +361 -0
  52. package/src/commands/wizard.ts +4 -4
  53. package/src/connections-store.ts +3 -3
  54. package/src/grants-store.ts +272 -0
  55. package/src/help.ts +4 -1
  56. package/src/hub-server.ts +209 -27
  57. package/src/hub-settings.ts +23 -8
  58. package/src/jwt-sign.ts +5 -1
  59. package/src/module-manifest.ts +2 -2
  60. package/src/oauth-client.ts +497 -0
  61. package/src/oauth-flows-store.ts +163 -0
  62. package/src/oauth-handlers.ts +40 -13
  63. package/src/operator-token.ts +1 -1
  64. package/src/origin-check.ts +7 -2
  65. package/src/resource-binding.ts +4 -4
  66. package/src/scope-explanations.ts +3 -3
  67. package/src/service-spec.ts +56 -43
  68. package/src/setup-wizard.ts +56 -240
  69. package/web/ui/dist/assets/index-B5AUE359.js +61 -0
  70. package/web/ui/dist/assets/{index-E_9wqjEm.css → index-DR6R8EFf.css} +1 -1
  71. package/web/ui/dist/index.html +2 -2
  72. package/src/admin-channel-token.ts +0 -135
  73. package/web/ui/dist/assets/index-Cxtod68O.js +0 -61
@@ -2151,9 +2151,19 @@ describe("handleSetupExposePost", () => {
2151
2151
  });
2152
2152
  });
2153
2153
 
2154
- // --- hub#272 Item A: auto-mint operator token + MCP command rendering ---
2155
-
2156
- describe("done screen auto-minted token (hub#272 Item A)", () => {
2154
+ // --- hub#272 Item A → OAuth-default MCP command (no auto-mint) ---
2155
+ //
2156
+ // History: hub#272 Item A auto-minted a full-scope operator token in the
2157
+ // expose POST + pre-filled the done-screen MCP command with a
2158
+ // `--header "Authorization: Bearer <token>"` flag (plus a masked-reveal /
2159
+ // Copy widget). Removed 2026-06-23 after Austen reported the header-auth
2160
+ // command broke his connect: vault/init is OAuth-default now (parachute-
2161
+ // vault #491), so the bare `claude mcp add` command — which triggers
2162
+ // browser OAuth on first use — is the correct UX. These tests pin the new
2163
+ // contract: no token minted/stored by default, and the done screen never
2164
+ // emits a Bearer header.
2165
+
2166
+ describe("done screen MCP command — OAuth-default, no auto-mint", () => {
2157
2167
  let h: Harness;
2158
2168
  beforeEach(() => {
2159
2169
  h = makeHarness();
@@ -2191,7 +2201,7 @@ describe("done screen auto-minted token (hub#272 Item A)", () => {
2191
2201
  return { user, session, csrf };
2192
2202
  }
2193
2203
 
2194
- test("expose POST mints + stores an operator token in hub_settings (setup_minted_token)", async () => {
2204
+ test("expose POST does NOT mint or store an operator token (setup_minted_token stays unset)", async () => {
2195
2205
  const db = openHubDb(hubDbPath(h.dir));
2196
2206
  try {
2197
2207
  const { session, csrf } = await bringWizardToExposeStep(db);
@@ -2218,42 +2228,26 @@ describe("done screen auto-minted token (hub#272 Item A)", () => {
2218
2228
  },
2219
2229
  );
2220
2230
  expect(res.status).toBe(303);
2221
- // Token is a JWT (three base64url segments). We don't assert the
2222
- // exact value the load-bearing surface is "a non-empty token
2223
- // exists" so the done-step renderer has something to inject.
2224
- const stored = getSetting(db, "setup_minted_token");
2225
- expect(stored).toBeDefined();
2226
- expect(stored?.split(".").length).toBe(3);
2231
+ // The auto-mint was removed (Austen's report) vault is OAuth-
2232
+ // default, so nothing should land in hub_settings.
2233
+ expect(getSetting(db, "setup_minted_token")).toBeUndefined();
2227
2234
  } finally {
2228
2235
  db.close();
2229
2236
  }
2230
2237
  });
2231
2238
 
2232
- test("done screen renders the MCP command with a Bearer header when a minted token exists", async () => {
2239
+ test("expose POST JSON response carries no minted_token", async () => {
2233
2240
  const db = openHubDb(hubDbPath(h.dir));
2234
2241
  try {
2235
- const user = await createUser(db, "owner", "pw");
2236
- writeManifest(
2237
- {
2238
- services: [
2239
- {
2240
- name: "parachute-vault",
2241
- version: "0.1.0",
2242
- port: 1940,
2243
- paths: ["/vault/default"],
2244
- health: "/health",
2245
- },
2246
- ],
2247
- },
2248
- h.manifestPath,
2249
- );
2250
- setSetting(db, "setup_expose_mode", "localhost");
2251
- setSetting(db, "setup_minted_token", "test-jwt-token-abc");
2252
- const { createSession } = await import("../sessions.ts");
2253
- const session = createSession(db, { userId: user.id });
2254
- const res = handleSetupGet(
2255
- req("/admin/setup?just_finished=1", {
2256
- headers: { cookie: `${SESSION_COOKIE_NAME}=${session.id}` },
2242
+ const { session, csrf } = await bringWizardToExposeStep(db);
2243
+ const res = await handleSetupExposePost(
2244
+ req("/admin/setup/expose", {
2245
+ method: "POST",
2246
+ body: JSON.stringify({ expose_mode: "localhost", [CSRF_FIELD_NAME]: csrf }),
2247
+ headers: {
2248
+ "content-type": "application/json",
2249
+ cookie: `${CSRF_COOKIE_NAME}=${csrf}; ${SESSION_COOKIE_NAME}=${session.id}`,
2250
+ },
2257
2251
  }),
2258
2252
  {
2259
2253
  db,
@@ -2265,38 +2259,16 @@ describe("done screen auto-minted token (hub#272 Item A)", () => {
2265
2259
  },
2266
2260
  );
2267
2261
  expect(res.status).toBe(200);
2268
- const html = await res.text();
2269
- // Real token rides in the hidden script-tag stash as JSON-encoded
2270
- // text — script element content is raw-text per the HTML spec
2271
- // (entities aren't parsed), so JSON encoding round-trips through
2272
- // textContent + JSON.parse without `&quot;` polluting the copied
2273
- // command. Verify the JSON-encoded form appears in the document.
2274
- expect(html).toContain(
2275
- '"claude mcp add --transport http parachute-default https://hub.example/vault/default/mcp --header \\"Authorization: Bearer test-jwt-token-abc\\""',
2276
- );
2277
- expect(html).toContain('id="mcp-cmd"');
2278
- expect(html).toContain('id="mcp-cmd-real"');
2279
- // The hidden stash is `<script type="application/json">` so the
2280
- // browser doesn't execute it but textContent is still readable.
2281
- expect(html).toContain('<script type="application/json" id="mcp-cmd-real">');
2282
- // The visible default state is masked: the <pre> body is wrapped
2283
- // with data-state="masked" and renders • placeholder characters
2284
- // rather than the live token. Verified by the masked Bearer
2285
- // header substring (• repeated).
2286
- expect(html).toContain('data-state="masked"');
2287
- expect(html).toMatch(/Bearer •+/);
2288
- // Show button + Copy button both present.
2289
- expect(html).toContain('id="mcp-cmd-show"');
2290
- expect(html).toContain('id="mcp-cmd-copy"');
2291
- expect(html).toContain("/admin/tokens");
2292
- // The token is single-use — consumed on first render.
2262
+ const json = (await res.json()) as Record<string, unknown>;
2263
+ expect(json.step).toBe("done");
2264
+ expect(json).not.toHaveProperty("minted_token");
2293
2265
  expect(getSetting(db, "setup_minted_token")).toBeUndefined();
2294
2266
  } finally {
2295
2267
  db.close();
2296
2268
  }
2297
2269
  });
2298
2270
 
2299
- test("done screen falls back to bare MCP command + admin/tokens hint when no minted token", async () => {
2271
+ test("done screen renders the bare OAuth MCP command with NO Bearer header by default", async () => {
2300
2272
  const db = openHubDb(hubDbPath(h.dir));
2301
2273
  try {
2302
2274
  const user = await createUser(db, "owner", "pw");
@@ -2330,140 +2302,57 @@ describe("done screen auto-minted token (hub#272 Item A)", () => {
2330
2302
  registry: getDefaultOperationsRegistry(),
2331
2303
  },
2332
2304
  );
2305
+ expect(res.status).toBe(200);
2333
2306
  const html = await res.text();
2334
- expect(html).toContain("claude mcp add --transport http parachute-default");
2335
- // The fallback explanatory text leads with the OAuth path (no token
2336
- // needed) and, for headless clients, references a hub JWT placeholder
2337
- // — NOT the retired `pvt_*` format (gap #4). The `--header` flag must
2338
- // also NOT be appended to the command line itself.
2307
+ // The bare OAuth command is the rendered command.
2308
+ expect(html).toContain(
2309
+ "claude mcp add --transport http parachute-default https://hub.example/vault/default/mcp",
2310
+ );
2311
+ // Load-bearing regression (Austen's report): the rendered COMMAND
2312
+ // (the MCP tile's <pre> block) must NOT carry a `--header
2313
+ // "Authorization: Bearer ..."` flag. This is the assertion that
2314
+ // would have caught the bug — the prior build baked the header into
2315
+ // the command. The headless-client fine print legitimately mentions
2316
+ // the header form with an escaped `<token>` placeholder, so we
2317
+ // scope the negative assertion to the command block, not the whole
2318
+ // document. Anchor to the MCP-tile <h2> so the regex can't drift
2319
+ // onto another tile's <pre> (e.g. the tailnet/public reachable
2320
+ // tile's bare Tailscale command) under a different expose mode.
2321
+ const cmdPre = html.match(/<h2>Connect Claude Code \(MCP\)<\/h2>[\s\S]*?<pre>([^<]*)<\/pre>/);
2322
+ expect(cmdPre).not.toBeNull();
2323
+ const cmdText = cmdPre?.[1] ?? "";
2324
+ expect(cmdText).not.toContain("--header");
2325
+ expect(cmdText).not.toContain("Authorization");
2326
+ expect(cmdText).not.toContain("Bearer");
2327
+ // The document must NOT carry a real or masked Bearer token — the
2328
+ // only legitimate `Bearer` mention is the escaped placeholder in
2329
+ // the guidance.
2330
+ expect(html).not.toMatch(/Bearer •/);
2331
+ expect(html).not.toContain("Authorization: Bearer test");
2332
+ // Explanatory text leads with the OAuth path; the only mention of
2333
+ // a Bearer header is the escaped `<token>` placeholder in the
2334
+ // headless-client guidance.
2339
2335
  expect(html).toContain("browser OAuth");
2340
2336
  expect(html).toContain("Bearer &lt;token&gt;");
2341
2337
  expect(html).not.toContain("pvt_");
2342
2338
  expect(html).toContain("parachute auth mint-token");
2343
2339
  expect(html).toContain("/admin/tokens");
2344
- // Specifically no Copy button that's a token-present surface.
2340
+ // None of the masked-token / Copy-widget DOM survives — the
2341
+ // token-present branch was removed.
2345
2342
  expect(html).not.toContain('id="mcp-cmd"');
2343
+ expect(html).not.toContain('id="mcp-cmd-real"');
2344
+ expect(html).not.toContain('id="mcp-cmd-copy"');
2345
+ expect(html).not.toContain('data-state="masked"');
2346
2346
  } finally {
2347
2347
  db.close();
2348
2348
  }
2349
2349
  });
2350
2350
 
2351
- test("minted token is consumed after first render refresh shows the fallback shape", async () => {
2352
- const db = openHubDb(hubDbPath(h.dir));
2353
- try {
2354
- const user = await createUser(db, "owner", "pw");
2355
- writeManifest(
2356
- {
2357
- services: [
2358
- {
2359
- name: "parachute-vault",
2360
- version: "0.1.0",
2361
- port: 1940,
2362
- paths: ["/vault/default"],
2363
- health: "/health",
2364
- },
2365
- ],
2366
- },
2367
- h.manifestPath,
2368
- );
2369
- setSetting(db, "setup_expose_mode", "localhost");
2370
- setSetting(db, "setup_minted_token", "test-token-xyz");
2371
- const { createSession } = await import("../sessions.ts");
2372
- const session = createSession(db, { userId: user.id });
2373
- const deps = {
2374
- db,
2375
- manifestPath: h.manifestPath,
2376
- configDir: h.dir,
2377
- readExposeStateFn: h.readExposeStateFn,
2378
- issuer: "https://hub.example",
2379
- registry: getDefaultOperationsRegistry(),
2380
- };
2381
- const sessionedReq = () =>
2382
- req("/admin/setup?just_finished=1", {
2383
- headers: { cookie: `${SESSION_COOKIE_NAME}=${session.id}` },
2384
- });
2385
- const first = handleSetupGet(sessionedReq(), deps);
2386
- const firstHtml = await first.text();
2387
- expect(firstHtml).toContain("test-token-xyz");
2388
- const second = handleSetupGet(sessionedReq(), deps);
2389
- const secondHtml = await second.text();
2390
- expect(secondHtml).not.toContain("test-token-xyz");
2391
- // The MCP command tile has no Copy button on the fallback shape.
2392
- expect(secondHtml).not.toContain('id="mcp-cmd"');
2393
- } finally {
2394
- db.close();
2395
- }
2396
- });
2397
-
2398
- // rc.11 — token visible by default on the done screen was a
2399
- // shoulder-surf hazard. The fix: render the visible command with
2400
- // a masked Bearer token, stash the real command in a
2401
- // hidden script tag, and surface a Show button + Copy button. Copy
2402
- // ALWAYS pulls the real command from the script tag so the
2403
- // operator's terminal paste never breaks regardless of mask state.
2404
- test("done screen masks the Bearer token in the visible <pre> by default", async () => {
2405
- const db = openHubDb(hubDbPath(h.dir));
2406
- try {
2407
- const user = await createUser(db, "owner", "pw");
2408
- writeManifest(
2409
- {
2410
- services: [
2411
- {
2412
- name: "parachute-vault",
2413
- version: "0.1.0",
2414
- port: 1940,
2415
- paths: ["/vault/default"],
2416
- health: "/health",
2417
- },
2418
- ],
2419
- },
2420
- h.manifestPath,
2421
- );
2422
- setSetting(db, "setup_expose_mode", "localhost");
2423
- setSetting(db, "setup_minted_token", "pvt_super_secret_token_payload");
2424
- const { createSession } = await import("../sessions.ts");
2425
- const session = createSession(db, { userId: user.id });
2426
- const res = handleSetupGet(
2427
- req("/admin/setup?just_finished=1", {
2428
- headers: { cookie: `${SESSION_COOKIE_NAME}=${session.id}` },
2429
- }),
2430
- {
2431
- db,
2432
- manifestPath: h.manifestPath,
2433
- configDir: h.dir,
2434
- readExposeStateFn: h.readExposeStateFn,
2435
- issuer: "https://hub.example",
2436
- registry: getDefaultOperationsRegistry(),
2437
- },
2438
- );
2439
- const html = await res.text();
2440
- // Extract the visible <pre id="mcp-cmd"> text only — the masked
2441
- // shape must live there, with no occurrence of the literal token
2442
- // string. The real token still appears elsewhere (the hidden
2443
- // script tag) so a plain `toContain` would miss the leak.
2444
- const preMatch = html.match(/<pre id="mcp-cmd">([^<]*)<\/pre>/);
2445
- expect(preMatch).not.toBeNull();
2446
- const preBody = preMatch?.[1] ?? "";
2447
- expect(preBody).not.toContain("pvt_super_secret_token_payload");
2448
- // Masked Bearer header is present in the <pre> text.
2449
- expect(preBody).toMatch(/Bearer •+/);
2450
- // Real command still in the document (hidden JSON stash) so the
2451
- // Copy handler can read it.
2452
- expect(html).toContain('<script type="application/json" id="mcp-cmd-real">');
2453
- expect(html).toContain("pvt_super_secret_token_payload");
2454
- // Default state is masked.
2455
- expect(html).toContain('data-state="masked"');
2456
- } finally {
2457
- db.close();
2458
- }
2459
- });
2460
-
2461
- test("done screen JSON-encodes the stashed command so `</script>` in a token can't break out", async () => {
2462
- // Defense-in-depth: an attacker-shaped token containing `</script>`
2463
- // would prematurely close the stash tag if we just dropped it into
2464
- // the HTML. The renderer JSON-encodes the command AND replaces
2465
- // `</` with `<\/` inside the encoded string so the sequence can't
2466
- // appear in the document. Decode round-trips via JSON.parse.
2351
+ test("done screen never renders a stale setup_minted_token row + clears it defensively", async () => {
2352
+ // Upgrade path: a pre-upgrade hub may have left a stale
2353
+ // setup_minted_token row behind. The done-step GET must never paint
2354
+ // it (no token surface anymore) and should clear it so it can't
2355
+ // linger.
2467
2356
  const db = openHubDb(hubDbPath(h.dir));
2468
2357
  try {
2469
2358
  const user = await createUser(db, "owner", "pw");
@@ -2482,11 +2371,8 @@ describe("done screen auto-minted token (hub#272 Item A)", () => {
2482
2371
  h.manifestPath,
2483
2372
  );
2484
2373
  setSetting(db, "setup_expose_mode", "localhost");
2485
- // Token contains characters that would be load-bearing in the
2486
- // HTML/JS layer if mis-encoded: a quote (would close the JSON
2487
- // string) and `</script>` (would close the stash tag).
2488
- const hostileToken = `weird-token-with-"-and-</script>-inside`;
2489
- setSetting(db, "setup_minted_token", hostileToken);
2374
+ // Plant a stale row as if a pre-upgrade hub had minted one.
2375
+ setSetting(db, "setup_minted_token", "stale-token-from-old-hub");
2490
2376
  const { createSession } = await import("../sessions.ts");
2491
2377
  const session = createSession(db, { userId: user.id });
2492
2378
  const res = handleSetupGet(
@@ -2503,95 +2389,24 @@ describe("done screen auto-minted token (hub#272 Item A)", () => {
2503
2389
  },
2504
2390
  );
2505
2391
  const html = await res.text();
2506
- // `</script>` must NOT appear inside the stash element. We
2507
- // verify by extracting the stash text via the literal HTML
2508
- // boundaries and asserting no close-tag escape escaped the
2509
- // encoder.
2510
- const stashMatch = html.match(
2511
- /<script type="application\/json" id="mcp-cmd-real">([\s\S]*?)<\/script>/,
2512
- );
2513
- expect(stashMatch).not.toBeNull();
2514
- const stashBody = stashMatch?.[1] ?? "";
2515
- // The encoder replaces `</` with `<\/` inside the JSON, so the
2516
- // raw bytes between the opening and the first `</script>` should
2517
- // not contain `</`.
2518
- expect(stashBody).not.toContain("</");
2519
- // Round-trips: `<\/` decodes back to `</` after JSON.parse +
2520
- // the script-end-sequence escape — the operator's clipboard
2521
- // gets the original bytes.
2522
- const decoded = JSON.parse(stashBody) as string;
2523
- expect(decoded).toContain(hostileToken);
2524
- } finally {
2525
- db.close();
2526
- }
2527
- });
2528
-
2529
- test("done screen wires Show + Copy buttons that read from the hidden real-command stash", async () => {
2530
- const db = openHubDb(hubDbPath(h.dir));
2531
- try {
2532
- const user = await createUser(db, "owner", "pw");
2533
- writeManifest(
2534
- {
2535
- services: [
2536
- {
2537
- name: "parachute-vault",
2538
- version: "0.1.0",
2539
- port: 1940,
2540
- paths: ["/vault/default"],
2541
- health: "/health",
2542
- },
2543
- ],
2544
- },
2545
- h.manifestPath,
2546
- );
2547
- setSetting(db, "setup_expose_mode", "localhost");
2548
- setSetting(db, "setup_minted_token", "live-token-AAA");
2549
- const { createSession } = await import("../sessions.ts");
2550
- const session = createSession(db, { userId: user.id });
2551
- const res = handleSetupGet(
2552
- req("/admin/setup?just_finished=1", {
2553
- headers: { cookie: `${SESSION_COOKIE_NAME}=${session.id}` },
2554
- }),
2555
- {
2556
- db,
2557
- manifestPath: h.manifestPath,
2558
- configDir: h.dir,
2559
- readExposeStateFn: h.readExposeStateFn,
2560
- issuer: "https://hub.example",
2561
- registry: getDefaultOperationsRegistry(),
2562
- },
2563
- );
2564
- const html = await res.text();
2565
- // Both buttons present, both wired via addEventListener (no
2566
- // inline onclick — the script runs in a single IIFE).
2567
- expect(html).toContain('id="mcp-cmd-show"');
2568
- expect(html).toContain('id="mcp-cmd-copy"');
2569
- expect(html).toContain("'click'");
2570
- // The Copy handler reads from the hidden script tag, not from
2571
- // the visible <pre>. Regression: this was the load-bearing
2572
- // contract Aaron called out ("Copy still works without reveal").
2573
- expect(html).toContain("getElementById('mcp-cmd-real')");
2574
- // The stash holds JSON-encoded text and the handler decodes via
2575
- // JSON.parse so the clipboard receives the exact byte sequence of
2576
- // the command — `&quot;`-style HTML entities can't survive into
2577
- // the operator's shell because script-element content is raw text
2578
- // (the HTML parser doesn't decode entities inside <script>).
2579
- expect(html).toContain("JSON.parse(real.textContent");
2580
- // Auto-hide timer present so a stray reveal doesn't leak into a
2581
- // subsequent screencast capture.
2582
- expect(html).toContain("setTimeout(setMasked, 10000)");
2392
+ // The stale token is NOT painted anywhere in the document, and the
2393
+ // rendered command carries no Bearer header (the only `Bearer`
2394
+ // mention is the escaped `<token>` placeholder in the guidance).
2395
+ expect(html).not.toContain("stale-token-from-old-hub");
2396
+ expect(html).not.toContain("Authorization: Bearer stale-token-from-old-hub");
2397
+ const cmdPre = html.match(/<h2>Connect Claude Code \(MCP\)<\/h2>[\s\S]*?<pre>([^<]*)<\/pre>/);
2398
+ expect(cmdPre?.[1] ?? "").not.toContain("Authorization");
2399
+ // And the row is cleared so it can't linger across renders.
2400
+ expect(getSetting(db, "setup_minted_token")).toBeUndefined();
2583
2401
  } finally {
2584
2402
  db.close();
2585
2403
  }
2586
2404
  });
2587
2405
 
2588
- test("GET /admin/setup?just_finished=1 without a session does NOT consume the minted token (hub#274 security fold)", async () => {
2589
- // Regression without the session gate, any HTTP client racing the
2590
- // operator's browser between the expose POST (which mints + stores)
2591
- // and the done GET (which reads + consumes) walks off with a
2592
- // full-scope operator JWT. The gate sends sessionless GETs to
2593
- // /login + leaves the row in place so the operator's subsequent
2594
- // legitimate GET still surfaces the token.
2406
+ test("GET /admin/setup?just_finished=1 without a session redirects to /login (done screen is post-setup admin)", async () => {
2407
+ // The done screen is a post-setup admin surface and shouldn't render
2408
+ // to a drive-by unauthenticated GET. (This gate originally protected
2409
+ // the minted-token read; the mint is gone, but the gate stays.)
2595
2410
  const db = openHubDb(hubDbPath(h.dir));
2596
2411
  try {
2597
2412
  await createUser(db, "owner", "pw");
@@ -2610,10 +2425,7 @@ describe("done screen auto-minted token (hub#272 Item A)", () => {
2610
2425
  h.manifestPath,
2611
2426
  );
2612
2427
  setSetting(db, "setup_expose_mode", "localhost");
2613
- setSetting(db, "setup_minted_token", "test-secret-token-must-not-leak");
2614
- // No session cookie on this request — simulating a drive-by GET
2615
- // from an attacker or a stale bookmark in a different browser
2616
- // tab that doesn't carry the wizard's session.
2428
+ // No session cookie — a drive-by GET.
2617
2429
  const res = handleSetupGet(req("/admin/setup?just_finished=1"), {
2618
2430
  db,
2619
2431
  manifestPath: h.manifestPath,
@@ -2622,14 +2434,8 @@ describe("done screen auto-minted token (hub#272 Item A)", () => {
2622
2434
  issuer: "https://hub.example",
2623
2435
  registry: getDefaultOperationsRegistry(),
2624
2436
  });
2625
- // The gate redirects to /login (302) rather than rendering the
2626
- // done screen. Body must NOT contain the token.
2627
2437
  expect(res.status).toBe(302);
2628
2438
  expect(res.headers.get("location")).toBe("/login");
2629
- // The setup_minted_token row is STILL present — the unauthed GET
2630
- // didn't consume it, so the legitimate operator's session-bearing
2631
- // GET will still see the token on the done screen.
2632
- expect(getSetting(db, "setup_minted_token")).toBe("test-secret-token-must-not-leak");
2633
2439
  } finally {
2634
2440
  db.close();
2635
2441
  }
@@ -121,7 +121,7 @@ describe("setup", () => {
121
121
  { name: "parachute-vault", port: 1940 },
122
122
  { name: "parachute-notes", port: 1942 },
123
123
  { name: "parachute-scribe", port: 1943 },
124
- { name: "parachute-channel", port: 1941 },
124
+ { name: "parachute-agent", port: 1941 },
125
125
  { name: "parachute-runner", port: 1945 },
126
126
  { name: "parachute-surface", port: 1946 },
127
127
  ];