@openparachute/hub 0.6.4-rc.1 → 0.6.4-rc.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 (48) hide show
  1. package/package.json +1 -2
  2. package/src/__tests__/account-home-ui.test.ts +344 -110
  3. package/src/__tests__/account-mirror.test.ts +156 -0
  4. package/src/__tests__/account-setup.test.ts +273 -2
  5. package/src/__tests__/api-account.test.ts +112 -1
  6. package/src/__tests__/api-invites.test.ts +37 -0
  7. package/src/__tests__/api-modules-ops.test.ts +8 -2
  8. package/src/__tests__/cloudflare-state.test.ts +104 -0
  9. package/src/__tests__/expose-cloudflare.test.ts +130 -5
  10. package/src/__tests__/expose-interactive.test.ts +234 -7
  11. package/src/__tests__/expose-supervisor-version.test.ts +104 -0
  12. package/src/__tests__/grants.test.ts +197 -8
  13. package/src/__tests__/hub-server.test.ts +133 -0
  14. package/src/__tests__/hub-unit.test.ts +181 -0
  15. package/src/__tests__/init.test.ts +579 -3
  16. package/src/__tests__/install.test.ts +448 -2
  17. package/src/__tests__/migrate-cutover.test.ts +1 -0
  18. package/src/__tests__/setup-wizard.test.ts +110 -0
  19. package/src/__tests__/supervisor.test.ts +197 -0
  20. package/src/__tests__/users.test.ts +39 -0
  21. package/src/__tests__/well-known.test.ts +25 -0
  22. package/src/__tests__/wizard.test.ts +72 -1
  23. package/src/account-home-ui.ts +430 -257
  24. package/src/account-mirror.ts +126 -0
  25. package/src/account-setup.ts +46 -7
  26. package/src/account-vault-token.ts +9 -0
  27. package/src/admin-login-ui.ts +33 -6
  28. package/src/api-account.ts +64 -0
  29. package/src/api-invites.ts +12 -14
  30. package/src/cli.ts +6 -2
  31. package/src/cloudflare/detect.ts +1 -1
  32. package/src/cloudflare/state.ts +104 -8
  33. package/src/commands/expose-cloudflare.ts +103 -36
  34. package/src/commands/expose-interactive.ts +163 -17
  35. package/src/commands/expose-supervisor.ts +45 -0
  36. package/src/commands/init.ts +183 -4
  37. package/src/commands/install.ts +321 -3
  38. package/src/commands/wizard.ts +36 -2
  39. package/src/grants.ts +113 -0
  40. package/src/help.ts +18 -5
  41. package/src/hub-server.ts +39 -0
  42. package/src/hub-settings.ts +3 -3
  43. package/src/hub-unit.ts +255 -0
  44. package/src/service-spec.ts +9 -1
  45. package/src/setup-wizard.ts +34 -2
  46. package/src/supervisor.ts +148 -0
  47. package/src/users.ts +12 -4
  48. package/src/well-known.ts +13 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.6.4-rc.1",
3
+ "version": "0.6.4-rc.10",
4
4
  "description": "parachute — the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
5
5
  "license": "AGPL-3.0",
6
6
  "publishConfig": {
@@ -32,7 +32,6 @@
32
32
  "format": "biome format --write .",
33
33
  "typecheck": "tsc --noEmit",
34
34
  "build:spa": "cd web/ui && bun install --frozen-lockfile && bun run build",
35
- "postinstall": "if [ -d web/ui ]; then bun run build:spa; fi",
36
35
  "prepack": "bun run build:spa"
37
36
  },
38
37
  "devDependencies": {
@@ -4,9 +4,11 @@
4
4
  * tests pin the load-bearing shape:
5
5
  *
6
6
  * - Assigned-vault branch: Notes CTA href encodes the hub+vault URL,
7
- * vault name shows in the body, AND a per-tile MCP connect block
8
- * surfaces the endpoint + `claude mcp add` command (OAuth, no token)
9
- * with copy buttons the multi-user friend-connect surface.
7
+ * vault name shows in the body, the backup-state line surfaces, and a
8
+ * "build your own UI" hint links the surface-build starter. The connect
9
+ * instructions live ONCE in the onboarding checklist (not duplicated in
10
+ * the vault card); token-minting is gone from /account (OAuth-first);
11
+ * a single "Advanced vault settings ↗" deep-link covers advanced needs.
10
12
  * - Admin (no assigned vault) branch: link to /admin/ visible.
11
13
  * - Defensive third branch (non-admin + no vault): "ask the operator"
12
14
  * copy renders.
@@ -44,35 +46,9 @@ describe("renderAccountHome", () => {
44
46
  expect(html).toContain(`https://notes.parachute.computer/add?url=${encodedVaultUrl}`);
45
47
  expect(html).toContain('target="_blank"');
46
48
  expect(html).toContain('rel="noopener"');
47
- // The friend-connect surface: MCP endpoint + `claude mcp add` command,
48
- // each with a copy button. OAuth path (no token in the command).
49
- expect(html).toContain(`${HUB_ORIGIN}/vault/alice/mcp`);
50
- expect(html).toContain(
51
- `claude mcp add --transport http parachute-alice ${HUB_ORIGIN}/vault/alice/mcp`,
52
- );
53
- expect(html).toContain('data-testid="copy-mcp-endpoint"');
54
- expect(html).toContain('data-testid="copy-mcp-add-command"');
55
- // The connect command must NOT embed a token — the OAuth path needs none.
56
- expect(html).not.toContain("--header");
57
- expect(html).not.toContain("Authorization: Bearer");
58
49
  // Copy-button progressive-enhancement script is present.
59
50
  expect(html).toContain("navigator.clipboard");
60
- // Friendlier framing: the block leads with "connect your AI assistant"
61
- // rather than MCP jargon up top.
62
- expect(html).toContain('data-testid="connect-ai-heading"');
63
- expect(html).toContain("Connect your AI");
64
- // BOTH connect methods render as distinct, labelled blocks.
65
- expect(html).toContain('data-testid="connect-method-claude-code"');
66
- expect(html).toContain("Claude Code");
67
- expect(html).toContain('data-testid="connect-method-claude-ai"');
68
- expect(html).toContain("Claude.ai");
69
- // The Claude.ai path mirrors the install.njk canonical phrasing
70
- // (Settings → Connectors → Add custom connector, paste the endpoint).
71
- expect(html).toContain("Connectors");
72
- expect(html).toContain("Add custom connector");
73
- // A brief "any other MCP client" line is present (no bloat — just one).
74
- expect(html).toContain('data-testid="connect-any-client-hint"');
75
- // Notes CTA still present, now framed as the browser-UI option.
51
+ // Notes CTA present, framed as the browser-UI option.
76
52
  expect(html).toContain('data-testid="open-notes-cta"');
77
53
  // Import-notes CTA deep-links to the Notes-UI /import route for the same
78
54
  // vault, mirroring the Open-Notes target-resolution (same hosted origin,
@@ -81,6 +57,78 @@ describe("renderAccountHome", () => {
81
57
  expect(html).toContain('data-testid="import-notes-cta"');
82
58
  });
83
59
 
60
+ test("dedup — the full connect block lives ONCE (in the checklist), NOT in the vault card", () => {
61
+ const html = renderAccountHome({
62
+ username: "alice",
63
+ assignedVaults: ["alice"],
64
+ passwordChanged: true,
65
+ hubOrigin: HUB_ORIGIN,
66
+ isFirstAdmin: false,
67
+ csrfToken: CSRF,
68
+ twoFactorEnabled: false,
69
+ connectedVault: false,
70
+ });
71
+ // The checklist owns "Connect your AI" — the endpoint + the `claude mcp add`
72
+ // command (OAuth, no token) appear there.
73
+ expect(html).toContain('data-testid="onboarding-mcp-endpoint"');
74
+ expect(html).toContain('data-testid="onboarding-mcp-add-command"');
75
+ expect(html).toContain(`${HUB_ORIGIN}/vault/alice/mcp`);
76
+ // The vault card must NOT repeat the connect instructions. None of the
77
+ // old mcp-connect block markers may appear — no duplicated connect surface.
78
+ expect(html).not.toContain('data-testid="mcp-connect"');
79
+ expect(html).not.toContain('data-testid="connect-ai-heading"');
80
+ expect(html).not.toContain('data-testid="connect-method-claude-code"');
81
+ expect(html).not.toContain('data-testid="connect-method-claude-ai"');
82
+ expect(html).not.toContain('data-testid="connect-any-client-hint"');
83
+ // The connect instructions live only in the checklist, never duplicated in
84
+ // the vault card: split the page at the vault card and assert the endpoint
85
+ // doesn't reappear in that slice.
86
+ const cardIdx = html.indexOf('data-testid="vault-card"');
87
+ expect(cardIdx).toBeGreaterThan(-1);
88
+ const vaultCardSlice = html.slice(cardIdx);
89
+ expect(vaultCardSlice).not.toContain(`${HUB_ORIGIN}/vault/alice/mcp`);
90
+ expect(vaultCardSlice).not.toContain("claude mcp add");
91
+ });
92
+
93
+ test("token-mint — the mint affordance is gone from /account (OAuth-first)", () => {
94
+ const html = renderAccountHome({
95
+ username: "alice",
96
+ assignedVaults: ["alice"],
97
+ passwordChanged: true,
98
+ hubOrigin: HUB_ORIGIN,
99
+ isFirstAdmin: false,
100
+ csrfToken: CSRF,
101
+ twoFactorEnabled: false,
102
+ mintableVerbs: { alice: ["read", "write", "admin"] },
103
+ });
104
+ // No token-mint <details> block, no mint form, no verb radios — minting a
105
+ // header-auth token is a script/advanced concern that lives in the SPA.
106
+ expect(html).not.toContain('data-testid="token-mint"');
107
+ expect(html).not.toContain('data-testid="mint-form"');
108
+ expect(html).not.toContain('data-testid="mint-verb-read"');
109
+ expect(html).not.toContain('data-testid="mint-verb-admin"');
110
+ expect(html).not.toContain("Mint an access token");
111
+ // The single advanced entry point covers the advanced needs.
112
+ expect(html).toContain('data-testid="vault-admin-button"');
113
+ expect(html).toContain("Advanced vault settings");
114
+ });
115
+
116
+ test("build-your-own-UI — Notes is one way; the hint links the surface-build starter", () => {
117
+ const html = renderAccountHome({
118
+ username: "alice",
119
+ assignedVaults: ["alice"],
120
+ passwordChanged: true,
121
+ hubOrigin: HUB_ORIGIN,
122
+ isFirstAdmin: false,
123
+ csrfToken: CSRF,
124
+ twoFactorEnabled: false,
125
+ });
126
+ expect(html).toContain('data-testid="build-your-own-ui"');
127
+ expect(html).toContain('data-testid="build-your-own-ui-link"');
128
+ expect(html).toContain("https://parachute.computer/onboarding/surface-build/");
129
+ expect(html).toContain("build you a custom UI");
130
+ });
131
+
84
132
  test("assigned-vault branch — import-notes CTA gated alongside open-notes (no dead link)", () => {
85
133
  // Both CTAs render together for an assigned vault and are absent together
86
134
  // in the no-vault branches — so we never surface an Import link that points
@@ -175,7 +223,7 @@ describe("renderAccountHome", () => {
175
223
  expect(html).not.toContain('data-testid="mcp-connect"');
176
224
  });
177
225
 
178
- test("get-started card — links to the two onboarding prompts, placed before the vault card", () => {
226
+ test("get-started card — links to the two onboarding prompts, placed AFTER the vault card", () => {
179
227
  const html = renderAccountHome({
180
228
  username: "alice",
181
229
  assignedVaults: ["alice"],
@@ -195,8 +243,10 @@ describe("renderAccountHome", () => {
195
243
  expect(html).toContain('data-testid="starter-surface-build"');
196
244
  // External links open safely.
197
245
  expect(html).toContain('rel="noopener"');
198
- // Placed prominently before the vault card in document order.
199
- expect(html.indexOf('data-testid="get-started-card"')).toBeLessThan(
246
+ // Connect-before-prompts: the prompts are only useful once connected, so
247
+ // they now sit AFTER the vault card in document order (and after the
248
+ // onboarding checklist, which leads the page).
249
+ expect(html.indexOf('data-testid="get-started-card"')).toBeGreaterThan(
200
250
  html.indexOf('data-testid="vault-card"'),
201
251
  );
202
252
  });
@@ -231,8 +281,30 @@ describe("renderAccountHome", () => {
231
281
  expect(noVault).toContain('data-testid="no-vault-card"');
232
282
  });
233
283
 
234
- test("connect-any-client hint bridges MCP ChatGPT 'connector' terminology", () => {
235
- const html = renderAccountHome({
284
+ test("backup state renders the backup line + omits when no mirror entry", () => {
285
+ const backedUp = renderAccountHome({
286
+ username: "alice",
287
+ assignedVaults: ["alice"],
288
+ passwordChanged: true,
289
+ hubOrigin: HUB_ORIGIN,
290
+ isFirstAdmin: false,
291
+ csrfToken: CSRF,
292
+ twoFactorEnabled: false,
293
+ mintableVerbs: { alice: ["read", "write", "admin"] },
294
+ mirrorLines: { alice: "Backed up — full version history" },
295
+ });
296
+ // The warm, plain-language backup line surfaces on the tile.
297
+ expect(backedUp).toContain('data-testid="backup-state-line"');
298
+ expect(backedUp).toContain("Backed up — full version history");
299
+ // Not pushing yet → a "Back up to GitHub ↗" action (reuses the
300
+ // vault-admin-token deep-link, gated on admin).
301
+ expect(backedUp).toContain('data-testid="backup-github-button"');
302
+ expect(backedUp).toContain("Back up to GitHub");
303
+ expect(backedUp).toContain("/account/vault-admin-token/alice");
304
+
305
+ // GitHub variant: when pushing, the line says so and the action is dropped.
306
+ // Suppression is gated on the `mirrorPushing` boolean (NOT the line string).
307
+ const pushing = renderAccountHome({
236
308
  username: "alice",
237
309
  assignedVaults: ["alice"],
238
310
  passwordChanged: true,
@@ -240,14 +312,59 @@ describe("renderAccountHome", () => {
240
312
  isFirstAdmin: false,
241
313
  csrfToken: CSRF,
242
314
  twoFactorEnabled: false,
315
+ mintableVerbs: { alice: ["read", "write", "admin"] },
316
+ mirrorLines: { alice: "Backed up — version history + GitHub" },
317
+ mirrorPushing: { alice: true },
243
318
  });
244
- // The "any other client" hint now names the ChatGPT "connector" term so
245
- // a friend who only knows that word can find the right place to paste.
246
- // Assert the NEW hint string specifically — a bare toContain("connector")
247
- // was already satisfied pre-PR by the Claude.ai "Connectors" block, so it
248
- // wouldn't catch a regression that drops this bridging copy.
249
- expect(html).toContain('data-testid="connect-any-client-hint"');
250
- expect(html).toContain('call these "connectors."');
319
+ expect(pushing).toContain("version history + GitHub");
320
+ expect(pushing).not.toContain('data-testid="backup-github-button"');
321
+
322
+ // Omitted silently when the mirror fetch returned nothing (no entry).
323
+ const noMirror = renderAccountHome({
324
+ username: "alice",
325
+ assignedVaults: ["alice"],
326
+ passwordChanged: true,
327
+ hubOrigin: HUB_ORIGIN,
328
+ isFirstAdmin: false,
329
+ csrfToken: CSRF,
330
+ twoFactorEnabled: false,
331
+ mintableVerbs: { alice: ["read", "write", "admin"] },
332
+ });
333
+ expect(noMirror).not.toContain('data-testid="backup-state-line"');
334
+ expect(noMirror).not.toContain('data-testid="vault-backup"');
335
+ });
336
+
337
+ test("backup action — gated on the mirrorPushing boolean, not the line string", () => {
338
+ // Regression guard: the "Back up to GitHub ↗" suppression must follow the
339
+ // proper `mirrorPushing` boolean, NOT substring-match the display line.
340
+ // (a) line mentions "GitHub" but mirrorPushing is false → action STILL shows.
341
+ const lineLies = renderAccountHome({
342
+ username: "alice",
343
+ assignedVaults: ["alice"],
344
+ passwordChanged: true,
345
+ hubOrigin: HUB_ORIGIN,
346
+ isFirstAdmin: false,
347
+ csrfToken: CSRF,
348
+ twoFactorEnabled: false,
349
+ mintableVerbs: { alice: ["read", "write", "admin"] },
350
+ mirrorLines: { alice: "Backed up — full version history (GitHub disabled)" },
351
+ mirrorPushing: { alice: false },
352
+ });
353
+ expect(lineLies).toContain('data-testid="backup-github-button"');
354
+ // (b) mirrorPushing true but the line lacks "GitHub" → action suppressed.
355
+ const boolWins = renderAccountHome({
356
+ username: "alice",
357
+ assignedVaults: ["alice"],
358
+ passwordChanged: true,
359
+ hubOrigin: HUB_ORIGIN,
360
+ isFirstAdmin: false,
361
+ csrfToken: CSRF,
362
+ twoFactorEnabled: false,
363
+ mintableVerbs: { alice: ["read", "write", "admin"] },
364
+ mirrorLines: { alice: "Backed up — full version history" },
365
+ mirrorPushing: { alice: true },
366
+ });
367
+ expect(boolWins).not.toContain('data-testid="backup-github-button"');
251
368
  });
252
369
 
253
370
  test("account card — security actions collapse into a secondary <details>", () => {
@@ -345,18 +462,13 @@ describe("renderAccountHome", () => {
345
462
  const familyEncoded = encodeURIComponent(`${HUB_ORIGIN}/vault/family`);
346
463
  expect(html).toContain(`https://notes.parachute.computer/add?url=${personalEncoded}`);
347
464
  expect(html).toContain(`https://notes.parachute.computer/add?url=${familyEncoded}`);
348
- // One per-vault MCP connect block per tile (endpoint + command).
349
- expect(html).toContain(`${HUB_ORIGIN}/vault/personal/mcp`);
350
- expect(html).toContain(`${HUB_ORIGIN}/vault/family/mcp`);
351
- expect(html).toContain(
352
- `claude mcp add --transport http parachute-personal ${HUB_ORIGIN}/vault/personal/mcp`,
353
- );
354
- expect(html).toContain(
355
- `claude mcp add --transport http parachute-family ${HUB_ORIGIN}/vault/family/mcp`,
356
- );
357
- // Two tiles → two copy-endpoint buttons.
358
- expect(html.split('data-testid="copy-mcp-endpoint"').length - 1).toBe(2);
359
- // The copy script is emitted once at the section level, not per-tile.
465
+ // Two tiles two Open-Notes CTAs.
466
+ expect(html.split('data-testid="open-notes-cta"').length - 1).toBe(2);
467
+ // The vault card does NOT repeat the connect block — that lives in the
468
+ // checklist (which uses the first/primary vault only).
469
+ expect(html).not.toContain('data-testid="mcp-connect"');
470
+ expect(html).not.toContain(`${HUB_ORIGIN}/vault/family/mcp`);
471
+ // The copy script is emitted once at the page level, not per-tile.
360
472
  expect(html.split("<script>").length - 1).toBe(1);
361
473
  });
362
474
 
@@ -394,9 +506,9 @@ describe("renderAccountHome", () => {
394
506
  expect(html).toContain("parachute-&lt;vault&gt;");
395
507
  });
396
508
 
397
- // --- friend vault-token mint affordance (the new surface) ----------------
509
+ // --- single advanced entry point (gated on the admin verb) ---------------
398
510
 
399
- test("mint affordanceread+write tile offers both verbs, POSTs to the right path", () => {
511
+ test("advanced settings link present + gated on the admin verb", () => {
400
512
  const html = renderAccountHome({
401
513
  username: "alice",
402
514
  assignedVaults: ["work"],
@@ -405,45 +517,22 @@ describe("renderAccountHome", () => {
405
517
  isFirstAdmin: false,
406
518
  csrfToken: CSRF,
407
519
  twoFactorEnabled: false,
408
- mintableVerbs: { work: ["read", "write"] },
520
+ mintableVerbs: { work: ["read", "write", "admin"] },
409
521
  });
410
- // The collapsible mint block is present, framed as secondary (headless).
411
- expect(html).toContain('data-testid="token-mint"');
412
- expect(html).toContain("Mint an access token");
413
- expect(html).toContain("for scripts / headless clients");
414
- // Both verb radios render.
415
- expect(html).toContain('data-testid="mint-verb-read"');
416
- expect(html).toContain('data-testid="mint-verb-write"');
417
- // Form POSTs to the per-vault endpoint with the CSRF token embedded.
418
- expect(html).toContain('action="/account/vault-token/work"');
522
+ // One clearly-labelled "Advanced vault settings ↗" link the SPA deep-link.
523
+ expect(html).toContain('data-testid="vault-admin-form"');
524
+ expect(html).toContain('data-testid="vault-admin-button"');
525
+ expect(html).toContain("Advanced vault settings");
526
+ expect(html).toContain('action="/account/vault-admin-token/work"');
419
527
  expect(html).toContain('method="POST"');
420
- expect(html).toContain('data-testid="mint-form"');
421
528
  expect(html).toContain(CSRF);
422
- // Recommends the no-token path as default.
423
- expect(html).toContain("no-token");
424
- });
425
-
426
- test("mint affordance — a read-only role offers ONLY the read verb", () => {
427
- // Today every assignment is write-role, but the renderer is verb-blind to
428
- // the role: it shows exactly the verbs it's handed. A read-only cap must
429
- // never surface a write radio (the server would reject it anyway).
430
- const html = renderAccountHome({
431
- username: "alice",
432
- assignedVaults: ["work"],
433
- passwordChanged: true,
434
- hubOrigin: HUB_ORIGIN,
435
- isFirstAdmin: false,
436
- csrfToken: CSRF,
437
- twoFactorEnabled: false,
438
- mintableVerbs: { work: ["read"] },
439
- });
440
- expect(html).toContain('data-testid="mint-verb-read"');
441
- expect(html).not.toContain('data-testid="mint-verb-write"');
529
+ // The old dual-purpose "Configure / back up this vault" label is gone.
530
+ expect(html).not.toContain("Configure / back up this vault");
442
531
  });
443
532
 
444
- test("mint affordanceoffers the admin verb when the user holds it", () => {
445
- // 2026-05-30: assigned users hold read/write/admin on their vault, so the
446
- // mint form offers admin (the live `vaultVerbsForUserVault` returns it).
533
+ test("advanced settings link absent when the user lacks the admin verb", () => {
534
+ // A read/write-only assignment must not surface the admin deep-link (the
535
+ // POST handler would 403 the admin token mint).
447
536
  const html = renderAccountHome({
448
537
  username: "alice",
449
538
  assignedVaults: ["work"],
@@ -452,14 +541,10 @@ describe("renderAccountHome", () => {
452
541
  isFirstAdmin: false,
453
542
  csrfToken: CSRF,
454
543
  twoFactorEnabled: false,
455
- mintableVerbs: { work: ["read", "write", "admin"] },
544
+ mintableVerbs: { work: ["read", "write"] },
456
545
  });
457
- expect(html).toContain('value="admin"');
458
- expect(html).toContain('data-testid="mint-verb-admin"');
459
- });
460
-
461
- test("mint affordance — absent when no mintable verbs (admin / no-vault / unmapped role)", () => {
462
- // Admin branch: no tiles at all, so no mint block.
546
+ expect(html).not.toContain('data-testid="vault-admin-button"');
547
+ // Admin branch: no tiles at all, so no advanced link.
463
548
  const admin = renderAccountHome({
464
549
  username: "admin",
465
550
  assignedVaults: [],
@@ -469,19 +554,7 @@ describe("renderAccountHome", () => {
469
554
  csrfToken: CSRF,
470
555
  twoFactorEnabled: false,
471
556
  });
472
- expect(admin).not.toContain('data-testid="token-mint"');
473
- // Assigned vault but empty verb list (fail-closed unknown role) → no block.
474
- const empty = renderAccountHome({
475
- username: "alice",
476
- assignedVaults: ["work"],
477
- passwordChanged: true,
478
- hubOrigin: HUB_ORIGIN,
479
- isFirstAdmin: false,
480
- csrfToken: CSRF,
481
- twoFactorEnabled: false,
482
- mintableVerbs: { work: [] },
483
- });
484
- expect(empty).not.toContain('data-testid="token-mint"');
557
+ expect(admin).not.toContain('data-testid="vault-admin-button"');
485
558
  });
486
559
 
487
560
  test("minted-token banner — shows the token once with a save-it warning, no revoke claim", () => {
@@ -529,4 +602,165 @@ describe("renderAccountHome", () => {
529
602
  // Error render must NOT also show a token.
530
603
  expect(html).not.toContain('data-testid="minted-token-banner"');
531
604
  });
605
+
606
+ // --- first-run onboarding checklist --------------------------------------
607
+
608
+ test("onboarding checklist — renders 3 steps with the correct /mcp endpoint (not connected)", () => {
609
+ const html = renderAccountHome({
610
+ username: "alice",
611
+ assignedVaults: ["alice"],
612
+ passwordChanged: true,
613
+ hubOrigin: HUB_ORIGIN,
614
+ isFirstAdmin: false,
615
+ csrfToken: CSRF,
616
+ twoFactorEnabled: false,
617
+ connectedVault: false,
618
+ });
619
+ expect(html).toContain('data-testid="onboarding-checklist"');
620
+ expect(html).toContain('data-connected="false"');
621
+ // All three numbered steps render.
622
+ expect(html).toContain('data-testid="onboarding-step-1"');
623
+ expect(html).toContain('data-testid="onboarding-step-2"');
624
+ expect(html).toContain('data-testid="onboarding-step-3"');
625
+ expect(html).toContain("Your account is ready");
626
+ expect(html).toContain("Connect your AI");
627
+ expect(html).toContain("Set up your vault");
628
+ // Step ② shows the canonical /vault/<name>/mcp endpoint inline — the /mcp
629
+ // suffix is load-bearing (only it returns the WWW-Authenticate header).
630
+ expect(html).toContain(`${HUB_ORIGIN}/vault/alice/mcp`);
631
+ expect(html).toMatch(/data-testid="onboarding-mcp-endpoint">[^<]*\/vault\/alice\/mcp</);
632
+ // Both connect methods are inline in step ②.
633
+ expect(html).toContain('data-testid="onboarding-mcp-add-command"');
634
+ expect(html).toContain("Add custom connector");
635
+ expect(html).toContain("claude mcp add");
636
+ // Step ③ links the vault-setup starter prompt.
637
+ expect(html).toContain('data-testid="onboarding-vault-setup-link"');
638
+ expect(html).toContain("https://parachute.computer/onboarding/vault-setup/");
639
+ });
640
+
641
+ test("onboarding checklist — condenses to 'you're connected' when a grant exists", () => {
642
+ const html = renderAccountHome({
643
+ username: "alice",
644
+ assignedVaults: ["alice"],
645
+ passwordChanged: true,
646
+ hubOrigin: HUB_ORIGIN,
647
+ isFirstAdmin: false,
648
+ csrfToken: CSRF,
649
+ twoFactorEnabled: false,
650
+ connectedVault: true,
651
+ });
652
+ // Still the same section, but in its condensed done-state.
653
+ expect(html).toContain('data-testid="onboarding-checklist"');
654
+ expect(html).toContain('data-connected="true"');
655
+ expect(html).toContain('data-testid="onboarding-done-line"');
656
+ expect(html).toContain("You're connected");
657
+ // The full 3-step list is gone (no nagging) — but the vault card below
658
+ // remains the working surface.
659
+ expect(html).not.toContain('data-testid="onboarding-step-2"');
660
+ expect(html).toContain('data-testid="vault-card"');
661
+ });
662
+
663
+ test("onboarding condensed state keeps a 'Connect another AI' expander with the full instructions (hub#583)", () => {
664
+ const html = renderAccountHome({
665
+ username: "alice",
666
+ assignedVaults: ["alice"],
667
+ passwordChanged: true,
668
+ hubOrigin: HUB_ORIGIN,
669
+ isFirstAdmin: false,
670
+ csrfToken: CSRF,
671
+ twoFactorEnabled: false,
672
+ connectedVault: true,
673
+ });
674
+ // The expander itself...
675
+ expect(html).toContain('data-testid="onboarding-connect-another"');
676
+ expect(html).toContain('data-testid="onboarding-connect-another-summary"');
677
+ expect(html).toContain("Connect another AI");
678
+ // ...re-reveals the endpoint + BOTH connect methods that the condensed
679
+ // line used to delete entirely (the hub#583 defect).
680
+ expect(html).toContain('data-testid="onboarding-mcp-endpoint"');
681
+ expect(html).toContain('data-testid="onboarding-mcp-add-command"');
682
+ expect(html).toContain("Claude.ai (web)");
683
+ expect(html).toContain("Claude Code (terminal)");
684
+ });
685
+
686
+ test("onboarding NON-condensed (not connected) state has no 'Connect another AI' expander (hub#583)", () => {
687
+ const html = renderAccountHome({
688
+ username: "alice",
689
+ assignedVaults: ["alice"],
690
+ passwordChanged: true,
691
+ hubOrigin: HUB_ORIGIN,
692
+ isFirstAdmin: false,
693
+ csrfToken: CSRF,
694
+ twoFactorEnabled: false,
695
+ connectedVault: false,
696
+ });
697
+ // Full checklist already shows the inline instructions in step 2, so the
698
+ // expander is condensed-state-only.
699
+ expect(html).not.toContain('data-testid="onboarding-connect-another"');
700
+ expect(html).toContain('data-testid="onboarding-step-2"');
701
+ expect(html).toContain('data-testid="onboarding-mcp-endpoint"');
702
+ });
703
+
704
+ test("onboarding checklist — leads the page: BEFORE the vault card and the starter prompts", () => {
705
+ const html = renderAccountHome({
706
+ username: "alice",
707
+ assignedVaults: ["alice"],
708
+ passwordChanged: true,
709
+ hubOrigin: HUB_ORIGIN,
710
+ isFirstAdmin: false,
711
+ csrfToken: CSRF,
712
+ twoFactorEnabled: false,
713
+ connectedVault: false,
714
+ });
715
+ const checklistIdx = html.indexOf('data-testid="onboarding-checklist"');
716
+ const vaultIdx = html.indexOf('data-testid="vault-card"');
717
+ const promptsIdx = html.indexOf('data-testid="get-started-card"');
718
+ // Net first-run order: checklist (connect) → vault details → prompts.
719
+ expect(checklistIdx).toBeGreaterThanOrEqual(0);
720
+ expect(checklistIdx).toBeLessThan(vaultIdx);
721
+ expect(vaultIdx).toBeLessThan(promptsIdx);
722
+ });
723
+
724
+ test("onboarding checklist — absent on the admin and no-vault branches", () => {
725
+ const admin = renderAccountHome({
726
+ username: "admin",
727
+ assignedVaults: [],
728
+ passwordChanged: true,
729
+ hubOrigin: HUB_ORIGIN,
730
+ isFirstAdmin: true,
731
+ csrfToken: CSRF,
732
+ twoFactorEnabled: false,
733
+ });
734
+ expect(admin).not.toContain('data-testid="onboarding-checklist"');
735
+
736
+ const noVault = renderAccountHome({
737
+ username: "ghost",
738
+ assignedVaults: [],
739
+ passwordChanged: true,
740
+ hubOrigin: HUB_ORIGIN,
741
+ isFirstAdmin: false,
742
+ csrfToken: CSRF,
743
+ twoFactorEnabled: false,
744
+ });
745
+ expect(noVault).not.toContain('data-testid="onboarding-checklist"');
746
+ });
747
+
748
+ test("onboarding checklist — multi-vault uses the first vault for the connect step", () => {
749
+ const html = renderAccountHome({
750
+ username: "alice",
751
+ assignedVaults: ["personal", "family"],
752
+ passwordChanged: true,
753
+ hubOrigin: HUB_ORIGIN,
754
+ isFirstAdmin: false,
755
+ csrfToken: CSRF,
756
+ twoFactorEnabled: false,
757
+ connectedVault: false,
758
+ });
759
+ // The checklist's connect step references the first/primary vault; the
760
+ // per-vault tiles below still list every vault (by name + Notes CTA), but
761
+ // no longer repeat the connect endpoint (dedup — connect lives only here).
762
+ expect(html).toMatch(/data-testid="onboarding-mcp-endpoint">[^<]*\/vault\/personal\/mcp</);
763
+ expect(html).toContain("<strong>family</strong>"); // second vault still has a tile
764
+ expect(html).not.toContain(`${HUB_ORIGIN}/vault/family/mcp`); // no duplicated connect
765
+ });
532
766
  });