@openparachute/hub 0.6.4-rc.4 → 0.6.4-rc.5

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.6.4-rc.4",
3
+ "version": "0.6.4-rc.5",
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": {
@@ -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
@@ -233,8 +281,46 @@ describe("renderAccountHome", () => {
233
281
  expect(noVault).toContain('data-testid="no-vault-card"');
234
282
  });
235
283
 
236
- test("connect-any-client hint bridges MCP ChatGPT 'connector' terminology", () => {
237
- 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({
308
+ username: "alice",
309
+ assignedVaults: ["alice"],
310
+ passwordChanged: true,
311
+ hubOrigin: HUB_ORIGIN,
312
+ isFirstAdmin: false,
313
+ csrfToken: CSRF,
314
+ twoFactorEnabled: false,
315
+ mintableVerbs: { alice: ["read", "write", "admin"] },
316
+ mirrorLines: { alice: "Backed up — version history + GitHub" },
317
+ mirrorPushing: { alice: true },
318
+ });
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({
238
324
  username: "alice",
239
325
  assignedVaults: ["alice"],
240
326
  passwordChanged: true,
@@ -242,14 +328,43 @@ describe("renderAccountHome", () => {
242
328
  isFirstAdmin: false,
243
329
  csrfToken: CSRF,
244
330
  twoFactorEnabled: false,
331
+ mintableVerbs: { alice: ["read", "write", "admin"] },
245
332
  });
246
- // The "any other client" hint now names the ChatGPT "connector" term so
247
- // a friend who only knows that word can find the right place to paste.
248
- // Assert the NEW hint string specifically — a bare toContain("connector")
249
- // was already satisfied pre-PR by the Claude.ai "Connectors" block, so it
250
- // wouldn't catch a regression that drops this bridging copy.
251
- expect(html).toContain('data-testid="connect-any-client-hint"');
252
- expect(html).toContain('call these "connectors."');
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"');
253
368
  });
254
369
 
255
370
  test("account card — security actions collapse into a secondary <details>", () => {
@@ -347,18 +462,13 @@ describe("renderAccountHome", () => {
347
462
  const familyEncoded = encodeURIComponent(`${HUB_ORIGIN}/vault/family`);
348
463
  expect(html).toContain(`https://notes.parachute.computer/add?url=${personalEncoded}`);
349
464
  expect(html).toContain(`https://notes.parachute.computer/add?url=${familyEncoded}`);
350
- // One per-vault MCP connect block per tile (endpoint + command).
351
- expect(html).toContain(`${HUB_ORIGIN}/vault/personal/mcp`);
352
- expect(html).toContain(`${HUB_ORIGIN}/vault/family/mcp`);
353
- expect(html).toContain(
354
- `claude mcp add --transport http parachute-personal ${HUB_ORIGIN}/vault/personal/mcp`,
355
- );
356
- expect(html).toContain(
357
- `claude mcp add --transport http parachute-family ${HUB_ORIGIN}/vault/family/mcp`,
358
- );
359
- // Two tiles → two copy-endpoint buttons.
360
- expect(html.split('data-testid="copy-mcp-endpoint"').length - 1).toBe(2);
361
- // 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.
362
472
  expect(html.split("<script>").length - 1).toBe(1);
363
473
  });
364
474
 
@@ -396,9 +506,9 @@ describe("renderAccountHome", () => {
396
506
  expect(html).toContain("parachute-&lt;vault&gt;");
397
507
  });
398
508
 
399
- // --- friend vault-token mint affordance (the new surface) ----------------
509
+ // --- single advanced entry point (gated on the admin verb) ---------------
400
510
 
401
- test("mint affordanceread+write tile offers both verbs, POSTs to the right path", () => {
511
+ test("advanced settings link present + gated on the admin verb", () => {
402
512
  const html = renderAccountHome({
403
513
  username: "alice",
404
514
  assignedVaults: ["work"],
@@ -407,45 +517,22 @@ describe("renderAccountHome", () => {
407
517
  isFirstAdmin: false,
408
518
  csrfToken: CSRF,
409
519
  twoFactorEnabled: false,
410
- mintableVerbs: { work: ["read", "write"] },
520
+ mintableVerbs: { work: ["read", "write", "admin"] },
411
521
  });
412
- // The collapsible mint block is present, framed as secondary (headless).
413
- expect(html).toContain('data-testid="token-mint"');
414
- expect(html).toContain("Mint an access token");
415
- expect(html).toContain("for scripts / headless clients");
416
- // Both verb radios render.
417
- expect(html).toContain('data-testid="mint-verb-read"');
418
- expect(html).toContain('data-testid="mint-verb-write"');
419
- // Form POSTs to the per-vault endpoint with the CSRF token embedded.
420
- 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"');
421
527
  expect(html).toContain('method="POST"');
422
- expect(html).toContain('data-testid="mint-form"');
423
528
  expect(html).toContain(CSRF);
424
- // Recommends the no-token path as default.
425
- expect(html).toContain("no-token");
426
- });
427
-
428
- test("mint affordance — a read-only role offers ONLY the read verb", () => {
429
- // Today every assignment is write-role, but the renderer is verb-blind to
430
- // the role: it shows exactly the verbs it's handed. A read-only cap must
431
- // never surface a write radio (the server would reject it anyway).
432
- const html = renderAccountHome({
433
- username: "alice",
434
- assignedVaults: ["work"],
435
- passwordChanged: true,
436
- hubOrigin: HUB_ORIGIN,
437
- isFirstAdmin: false,
438
- csrfToken: CSRF,
439
- twoFactorEnabled: false,
440
- mintableVerbs: { work: ["read"] },
441
- });
442
- expect(html).toContain('data-testid="mint-verb-read"');
443
- 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");
444
531
  });
445
532
 
446
- test("mint affordanceoffers the admin verb when the user holds it", () => {
447
- // 2026-05-30: assigned users hold read/write/admin on their vault, so the
448
- // 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).
449
536
  const html = renderAccountHome({
450
537
  username: "alice",
451
538
  assignedVaults: ["work"],
@@ -454,14 +541,10 @@ describe("renderAccountHome", () => {
454
541
  isFirstAdmin: false,
455
542
  csrfToken: CSRF,
456
543
  twoFactorEnabled: false,
457
- mintableVerbs: { work: ["read", "write", "admin"] },
544
+ mintableVerbs: { work: ["read", "write"] },
458
545
  });
459
- expect(html).toContain('value="admin"');
460
- expect(html).toContain('data-testid="mint-verb-admin"');
461
- });
462
-
463
- test("mint affordance — absent when no mintable verbs (admin / no-vault / unmapped role)", () => {
464
- // 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.
465
548
  const admin = renderAccountHome({
466
549
  username: "admin",
467
550
  assignedVaults: [],
@@ -471,19 +554,7 @@ describe("renderAccountHome", () => {
471
554
  csrfToken: CSRF,
472
555
  twoFactorEnabled: false,
473
556
  });
474
- expect(admin).not.toContain('data-testid="token-mint"');
475
- // Assigned vault but empty verb list (fail-closed unknown role) → no block.
476
- const empty = renderAccountHome({
477
- username: "alice",
478
- assignedVaults: ["work"],
479
- passwordChanged: true,
480
- hubOrigin: HUB_ORIGIN,
481
- isFirstAdmin: false,
482
- csrfToken: CSRF,
483
- twoFactorEnabled: false,
484
- mintableVerbs: { work: [] },
485
- });
486
- expect(empty).not.toContain('data-testid="token-mint"');
557
+ expect(admin).not.toContain('data-testid="vault-admin-button"');
487
558
  });
488
559
 
489
560
  test("minted-token banner — shows the token once with a save-it warning, no revoke claim", () => {
@@ -645,8 +716,10 @@ describe("renderAccountHome", () => {
645
716
  connectedVault: false,
646
717
  });
647
718
  // The checklist's connect step references the first/primary vault; the
648
- // per-vault tiles below still list every vault.
719
+ // per-vault tiles below still list every vault (by name + Notes CTA), but
720
+ // no longer repeat the connect endpoint (dedup — connect lives only here).
649
721
  expect(html).toMatch(/data-testid="onboarding-mcp-endpoint">[^<]*\/vault\/personal\/mcp</);
650
- expect(html).toContain(`${HUB_ORIGIN}/vault/family/mcp`); // still present in the vault tiles
722
+ expect(html).toContain("<strong>family</strong>"); // second vault still has a tile
723
+ expect(html).not.toContain(`${HUB_ORIGIN}/vault/family/mcp`); // no duplicated connect
651
724
  });
652
725
  });
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Unit tests for the `/account/` per-vault backup (mirror) status fetch +
3
+ * formatting (`account-mirror.ts`). The fetch mints an admin-scoped token + hits
4
+ * the vault's loopback `/.parachute/mirror` endpoint; it must be fault-tolerant
5
+ * (any failure → null) and shape-strict (a malformed body → null, not a render
6
+ * of `undefined`). Mirrors `account-usage.test.ts`'s posture.
7
+ */
8
+ import type { Database } from "bun:sqlite";
9
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
10
+ import { mkdtempSync, rmSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+ import {
14
+ type VaultMirrorStat,
15
+ fetchVaultMirrorStatus,
16
+ formatMirrorLine,
17
+ } from "../account-mirror.ts";
18
+ import { hubDbPath, openHubDb } from "../hub-db.ts";
19
+
20
+ interface Harness {
21
+ db: Database;
22
+ cleanup: () => void;
23
+ }
24
+
25
+ function makeHarness(): Harness {
26
+ const dir = mkdtempSync(join(tmpdir(), "phub-account-mirror-"));
27
+ const db = openHubDb(hubDbPath(dir));
28
+ return {
29
+ db,
30
+ cleanup: () => {
31
+ db.close();
32
+ rmSync(dir, { recursive: true, force: true });
33
+ },
34
+ };
35
+ }
36
+
37
+ let harness: Harness;
38
+ beforeEach(() => {
39
+ harness = makeHarness();
40
+ });
41
+ afterEach(() => {
42
+ harness.cleanup();
43
+ });
44
+
45
+ /** A stub signer — no real key needed; the fetch only carries the token string. */
46
+ const stubSign = async () => ({
47
+ token: "stub.jwt.token",
48
+ jti: "jti-1",
49
+ expiresAt: new Date(Date.now() + 60000).toISOString(),
50
+ });
51
+
52
+ function baseDeps(fetchImpl: typeof fetch) {
53
+ return {
54
+ db: harness.db,
55
+ hubOrigin: "https://hub.test",
56
+ vaultPort: 1940,
57
+ userId: "user-1",
58
+ fetchImpl,
59
+ signToken: stubSign as never,
60
+ };
61
+ }
62
+
63
+ describe("fetchVaultMirrorStatus", () => {
64
+ test("returns enabled+not-pushing on a backed-up, local-only config", async () => {
65
+ const fetchImpl = (async () =>
66
+ new Response(
67
+ JSON.stringify({
68
+ config: { enabled: true, location: "internal", auto_push: false },
69
+ status: { enabled: true, last_commit_sha: "abc", last_error: null },
70
+ }),
71
+ { status: 200, headers: { "content-type": "application/json" } },
72
+ )) as unknown as typeof fetch;
73
+ const stat = await fetchVaultMirrorStatus("work", baseDeps(fetchImpl));
74
+ expect(stat).toEqual({ enabled: true, backedUpToRemote: false });
75
+ });
76
+
77
+ test("flags pushing when auto_push is configured", async () => {
78
+ const fetchImpl = (async () =>
79
+ new Response(
80
+ JSON.stringify({ config: { enabled: true, location: "internal", auto_push: true } }),
81
+ { status: 200 },
82
+ )) as unknown as typeof fetch;
83
+ const stat = await fetchVaultMirrorStatus("work", baseDeps(fetchImpl));
84
+ expect(stat).toEqual({ enabled: true, backedUpToRemote: true });
85
+ });
86
+
87
+ test("returns enabled:false when backup is off", async () => {
88
+ const fetchImpl = (async () =>
89
+ new Response(JSON.stringify({ config: { enabled: false, auto_push: false } }), {
90
+ status: 200,
91
+ })) as unknown as typeof fetch;
92
+ const stat = await fetchVaultMirrorStatus("work", baseDeps(fetchImpl));
93
+ expect(stat).toEqual({ enabled: false, backedUpToRemote: false });
94
+ });
95
+
96
+ test("mints an ADMIN-scoped Bearer + hits the vault's loopback mirror endpoint", async () => {
97
+ let seenUrl = "";
98
+ let seenAuth = "";
99
+ let seenScope: string[] = [];
100
+ const captureSign = (async (_db: unknown, opts: { scopes: string[] }) => {
101
+ seenScope = opts.scopes;
102
+ return { token: "stub.jwt.token", jti: "j", expiresAt: new Date().toISOString() };
103
+ }) as never;
104
+ const fetchImpl = (async (url: string, init?: RequestInit) => {
105
+ seenUrl = url;
106
+ seenAuth = (init?.headers as Record<string, string>)?.authorization ?? "";
107
+ return new Response(JSON.stringify({ config: { enabled: true, auto_push: false } }), {
108
+ status: 200,
109
+ });
110
+ }) as unknown as typeof fetch;
111
+ await fetchVaultMirrorStatus("work", { ...baseDeps(fetchImpl), signToken: captureSign });
112
+ expect(seenUrl).toBe("http://127.0.0.1:1940/vault/work/.parachute/mirror");
113
+ expect(seenAuth).toBe("Bearer stub.jwt.token");
114
+ expect(seenScope).toEqual(["vault:work:admin"]);
115
+ });
116
+
117
+ test("returns null on a non-2xx response (vault down / 403 / 404)", async () => {
118
+ for (const status of [403, 404, 500]) {
119
+ const fetchImpl = (async () => new Response("nope", { status })) as unknown as typeof fetch;
120
+ const stat = await fetchVaultMirrorStatus("work", baseDeps(fetchImpl));
121
+ expect(stat).toBeNull();
122
+ }
123
+ });
124
+
125
+ test("returns null when the body is malformed (missing config.enabled)", async () => {
126
+ const fetchImpl = (async () =>
127
+ new Response(JSON.stringify({ config: {} }), { status: 200 })) as unknown as typeof fetch;
128
+ const stat = await fetchVaultMirrorStatus("work", baseDeps(fetchImpl));
129
+ expect(stat).toBeNull();
130
+ });
131
+
132
+ test("returns null when fetch throws (network error)", async () => {
133
+ const fetchImpl = (async () => {
134
+ throw new Error("ECONNREFUSED");
135
+ }) as unknown as typeof fetch;
136
+ const stat = await fetchVaultMirrorStatus("work", baseDeps(fetchImpl));
137
+ expect(stat).toBeNull();
138
+ });
139
+ });
140
+
141
+ describe("formatMirrorLine", () => {
142
+ test("warm plain-language line; GitHub variant when pushing", () => {
143
+ expect(formatMirrorLine({ enabled: true, backedUpToRemote: false } as VaultMirrorStat)).toBe(
144
+ "Backed up — full version history",
145
+ );
146
+ expect(formatMirrorLine({ enabled: true, backedUpToRemote: true } as VaultMirrorStat)).toBe(
147
+ "Backed up — version history + GitHub",
148
+ );
149
+ });
150
+
151
+ test("returns null when backup is off (the tile omits the line, never nags)", () => {
152
+ expect(
153
+ formatMirrorLine({ enabled: false, backedUpToRemote: false } as VaultMirrorStat),
154
+ ).toBeNull();
155
+ });
156
+ });
@@ -922,7 +922,7 @@ describe("handleAccountHomeGet", () => {
922
922
  expect(html).not.toContain('data-testid="vault-usage"');
923
923
  });
924
924
 
925
- test("renders the 'Configure / back up this vault ↗' deep-link button for an assigned vault", async () => {
925
+ test("renders the 'Advanced vault settings ↗' deep-link button for an assigned vault", async () => {
926
926
  await createUser(harness.db, "admin", "admin-passphrase", { passwordChanged: true });
927
927
  const friend = await createUser(harness.db, "alice", "alice-passphrase", {
928
928
  allowMulti: true,
@@ -936,6 +936,56 @@ describe("handleAccountHomeGet", () => {
936
936
  expect(res.status).toBe(200);
937
937
  const html = await res.text();
938
938
  expect(html).toContain('data-testid="vault-admin-button"');
939
+ expect(html).toContain("Advanced vault settings");
939
940
  expect(html).toContain('action="/account/vault-admin-token/alice"');
940
941
  });
942
+
943
+ test("renders the backup-state line when the mirror status resolves enabled", async () => {
944
+ await createUser(harness.db, "admin", "admin-passphrase", { passwordChanged: true });
945
+ const friend = await createUser(harness.db, "alice", "alice-passphrase", {
946
+ allowMulti: true,
947
+ passwordChanged: true,
948
+ assignedVaults: ["alice"],
949
+ });
950
+ const session = createSession(harness.db, { userId: friend.id });
951
+ const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000));
952
+ const req = new Request(`${HUB_ORIGIN}/account/`, { headers: { cookie } });
953
+ const res = await handleAccountHomeGet(req, {
954
+ db: harness.db,
955
+ hubOrigin: HUB_ORIGIN,
956
+ resolveVaultPort: () => 1940,
957
+ // Stub the mirror fetch: resolves to a backed-up, GitHub-pushing config.
958
+ fetchMirror: async () => ({ enabled: true, backedUpToRemote: true }),
959
+ });
960
+ expect(res.status).toBe(200);
961
+ const html = await res.text();
962
+ expect(html).toContain('data-testid="backup-state-line"');
963
+ // Already pushing → the handler threads mirrorPushing=true, so the
964
+ // "Back up to GitHub ↗" action is suppressed.
965
+ expect(html).not.toContain('data-testid="backup-github-button"');
966
+ expect(html).toContain("version history + GitHub");
967
+ });
968
+
969
+ test("omits the backup line gracefully when the mirror fetch fails (null)", async () => {
970
+ await createUser(harness.db, "admin", "admin-passphrase", { passwordChanged: true });
971
+ const friend = await createUser(harness.db, "alice", "alice-passphrase", {
972
+ allowMulti: true,
973
+ passwordChanged: true,
974
+ assignedVaults: ["alice"],
975
+ });
976
+ const session = createSession(harness.db, { userId: friend.id });
977
+ const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000));
978
+ const req = new Request(`${HUB_ORIGIN}/account/`, { headers: { cookie } });
979
+ const res = await handleAccountHomeGet(req, {
980
+ db: harness.db,
981
+ hubOrigin: HUB_ORIGIN,
982
+ resolveVaultPort: () => 1940,
983
+ fetchMirror: async () => null,
984
+ });
985
+ expect(res.status).toBe(200);
986
+ const html = await res.text();
987
+ // Tile still renders; just no backup line.
988
+ expect(html).toContain("<strong>alice</strong>");
989
+ expect(html).not.toContain('data-testid="backup-state-line"');
990
+ });
941
991
  });