@openparachute/hub 0.5.14-rc.1 → 0.5.14-rc.2

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.5.14-rc.1",
3
+ "version": "0.5.14-rc.2",
4
4
  "description": "parachute \u2014 the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
5
5
  "license": "AGPL-3.0",
6
6
  "publishConfig": {
@@ -22,7 +22,7 @@ describe("renderAccountHome", () => {
22
22
  test("assigned-vault branch — Notes CTA carries the encoded hub+vault URL", () => {
23
23
  const html = renderAccountHome({
24
24
  username: "alice",
25
- assignedVault: "alice",
25
+ assignedVaults: ["alice"],
26
26
  passwordChanged: true,
27
27
  hubOrigin: HUB_ORIGIN,
28
28
  isFirstAdmin: false,
@@ -49,7 +49,7 @@ describe("renderAccountHome", () => {
49
49
  // renderer must produce a clean `/vault/<name>` join either way.
50
50
  const html = renderAccountHome({
51
51
  username: "alice",
52
- assignedVault: "alice",
52
+ assignedVaults: ["alice"],
53
53
  passwordChanged: true,
54
54
  hubOrigin: `${HUB_ORIGIN}/`,
55
55
  isFirstAdmin: false,
@@ -65,7 +65,7 @@ describe("renderAccountHome", () => {
65
65
  test("admin branch — null assignedVault + isFirstAdmin renders an /admin/ link", () => {
66
66
  const html = renderAccountHome({
67
67
  username: "admin",
68
- assignedVault: null,
68
+ assignedVaults: [],
69
69
  passwordChanged: true,
70
70
  hubOrigin: HUB_ORIGIN,
71
71
  isFirstAdmin: true,
@@ -85,7 +85,7 @@ describe("renderAccountHome", () => {
85
85
  // state via hand-edit or migration race.
86
86
  const html = renderAccountHome({
87
87
  username: "ghost",
88
- assignedVault: null,
88
+ assignedVaults: [],
89
89
  passwordChanged: true,
90
90
  hubOrigin: HUB_ORIGIN,
91
91
  isFirstAdmin: false,
@@ -102,7 +102,7 @@ describe("renderAccountHome", () => {
102
102
  test("account card — change-password link and sign-out form are present", () => {
103
103
  const html = renderAccountHome({
104
104
  username: "alice",
105
- assignedVault: "alice",
105
+ assignedVaults: ["alice"],
106
106
  passwordChanged: true,
107
107
  hubOrigin: HUB_ORIGIN,
108
108
  isFirstAdmin: false,
@@ -120,6 +120,29 @@ describe("renderAccountHome", () => {
120
120
  expect(html).toContain("<code>alice</code>");
121
121
  });
122
122
 
123
+ test("multi-vault branch — renders one tile per assigned vault (Phase 2 PR 2)", () => {
124
+ const html = renderAccountHome({
125
+ username: "alice",
126
+ assignedVaults: ["personal", "family"],
127
+ passwordChanged: true,
128
+ hubOrigin: HUB_ORIGIN,
129
+ isFirstAdmin: false,
130
+ csrfToken: CSRF,
131
+ });
132
+ // Plural heading.
133
+ expect(html).toContain("Your vaults");
134
+ // Each vault name appears.
135
+ expect(html).toContain("<strong>personal</strong>");
136
+ expect(html).toContain("<strong>family</strong>");
137
+ // One CTA per vault with the right encoded URL.
138
+ const personalEncoded = encodeURIComponent(`${HUB_ORIGIN}/vault/personal`);
139
+ const familyEncoded = encodeURIComponent(`${HUB_ORIGIN}/vault/family`);
140
+ expect(html).toContain(`https://notes.parachute.computer/add?url=${personalEncoded}`);
141
+ expect(html).toContain(`https://notes.parachute.computer/add?url=${familyEncoded}`);
142
+ // Hub origin block appears once at the section level, not per tile.
143
+ expect(html.split(`<code>${HUB_ORIGIN}</code>`).length - 1).toBe(1);
144
+ });
145
+
123
146
  test("escapes hostile content in username and vault name", () => {
124
147
  // Defense-in-depth: usernames pass validateUsername (lowercase alnum
125
148
  // + `_-`), so HTML metacharacters won't normally make it through. But
@@ -127,7 +150,7 @@ describe("renderAccountHome", () => {
127
150
  // escape is load-bearing if the validator ever loosens.
128
151
  const html = renderAccountHome({
129
152
  username: "<script>",
130
- assignedVault: "<vault>",
153
+ assignedVaults: ["<vault>"],
131
154
  passwordChanged: true,
132
155
  hubOrigin: HUB_ORIGIN,
133
156
  isFirstAdmin: false,
@@ -160,7 +160,7 @@ describe("handleVaultAdminToken", () => {
160
160
  // whole reason this gate exists.
161
161
  await createUser(harness.db, "operator", "hunter2-admin");
162
162
  const friend = await createUser(harness.db, "friend", "hunter2-friend", {
163
- assignedVault: "work",
163
+ assignedVaults: ["work"],
164
164
  allowMulti: true,
165
165
  });
166
166
  const session = createSession(harness.db, { userId: friend.id });
@@ -182,7 +182,7 @@ describe("handleVaultAdminToken", () => {
182
182
  // happy path when there are friends in the DB.
183
183
  const admin = await createUser(harness.db, "operator", "hunter2-admin");
184
184
  await createUser(harness.db, "friend", "hunter2-friend", {
185
- assignedVault: "work",
185
+ assignedVaults: ["work"],
186
186
  allowMulti: true,
187
187
  });
188
188
  const session = createSession(harness.db, { userId: admin.id });
@@ -757,7 +757,7 @@ describe("handleAccountHomeGet", () => {
757
757
  const friend = await createUser(harness.db, "alice", "alice-passphrase", {
758
758
  allowMulti: true,
759
759
  passwordChanged: true,
760
- assignedVault: "alice",
760
+ assignedVaults: ["alice"],
761
761
  });
762
762
  const session = createSession(harness.db, { userId: friend.id });
763
763
  const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000));
@@ -774,7 +774,7 @@ describe("handleAccountHomeGet", () => {
774
774
  expect(html).toContain(`https://notes.parachute.computer/add?url=${encoded}`);
775
775
  });
776
776
 
777
- test("200 + admin branch when the first-admin signs in (assignedVault=null)", async () => {
777
+ test("200 + admin branch when the first-admin signs in (no vault assignments)", async () => {
778
778
  // The first-created user with no vault pin is the admin posture.
779
779
  const admin = await createUser(harness.db, "admin", "admin-passphrase", {
780
780
  passwordChanged: true,
@@ -126,6 +126,19 @@ function alwaysOkRun(): {
126
126
  };
127
127
  }
128
128
 
129
+ /**
130
+ * Test default for `isLinked`: assume the package is NOT bun-linked so
131
+ * `runInstall` exercises the `bun add -g` path (which the stubbed runner
132
+ * captures into `calls`). The production default at
133
+ * `src/bun-link.ts` reads the contributor's real `~/.bun/install/global/`
134
+ * symlinks; on Aaron's machine vault/scribe/notes/hub are all linked
135
+ * (the canonical local-dev shape — smoke 2026-05-27 finding 1 caps the
136
+ * fix). Tests asserting "bun add WAS called" must opt out of that leakage
137
+ * by passing this stub. Tests specifically exercising the skip path use
138
+ * an inline `isLinked: () => true` or a per-pkg discriminator.
139
+ */
140
+ const TEST_DEFAULT_NOT_LINKED = (_pkg: string): boolean => false;
141
+
129
142
  describe("parseModulesPath", () => {
130
143
  test("recognizes curated short + action", () => {
131
144
  expect(parseModulesPath("/api/modules/vault/install")).toEqual({
@@ -231,6 +244,7 @@ describe("POST /api/modules/:short/install", () => {
231
244
  configDir: h.dir,
232
245
  supervisor,
233
246
  run,
247
+ isLinked: TEST_DEFAULT_NOT_LINKED,
234
248
  },
235
249
  );
236
250
  expect(res.status).toBe(202);
@@ -280,6 +294,7 @@ describe("POST /api/modules/:short/install", () => {
280
294
  configDir: h.dir,
281
295
  supervisor,
282
296
  run,
297
+ isLinked: TEST_DEFAULT_NOT_LINKED,
283
298
  },
284
299
  );
285
300
  expect(res.status).toBe(202);
@@ -301,6 +316,7 @@ describe("POST /api/modules/:short/install", () => {
301
316
  configDir: h.dir,
302
317
  supervisor,
303
318
  run,
319
+ isLinked: TEST_DEFAULT_NOT_LINKED,
304
320
  },
305
321
  );
306
322
  const op = (await opRes.json()) as { status: string };
@@ -324,6 +340,7 @@ describe("POST /api/modules/:short/install", () => {
324
340
  configDir: h.dir,
325
341
  supervisor,
326
342
  run,
343
+ isLinked: TEST_DEFAULT_NOT_LINKED,
327
344
  },
328
345
  );
329
346
  expect(res.status).toBe(202);
@@ -348,6 +365,7 @@ describe("POST /api/modules/:short/install", () => {
348
365
  configDir: h.dir,
349
366
  supervisor,
350
367
  run,
368
+ isLinked: TEST_DEFAULT_NOT_LINKED,
351
369
  },
352
370
  );
353
371
  await new Promise((r) => setTimeout(r, 10));
@@ -379,6 +397,7 @@ describe("POST /api/modules/:short/install", () => {
379
397
  configDir: h.dir,
380
398
  supervisor,
381
399
  run,
400
+ isLinked: TEST_DEFAULT_NOT_LINKED,
382
401
  },
383
402
  );
384
403
  expect(res.status).toBe(202);
@@ -406,6 +425,7 @@ describe("POST /api/modules/:short/install", () => {
406
425
  configDir: h.dir,
407
426
  supervisor,
408
427
  run,
428
+ isLinked: TEST_DEFAULT_NOT_LINKED,
409
429
  },
410
430
  );
411
431
  await new Promise((r) => setTimeout(r, 10));
@@ -433,6 +453,7 @@ describe("POST /api/modules/:short/install", () => {
433
453
  configDir: h.dir,
434
454
  supervisor,
435
455
  run,
456
+ isLinked: TEST_DEFAULT_NOT_LINKED,
436
457
  },
437
458
  );
438
459
  expect(res.status).toBe(400);
@@ -458,6 +479,7 @@ describe("POST /api/modules/:short/install", () => {
458
479
  configDir: h.dir,
459
480
  supervisor,
460
481
  run,
482
+ isLinked: TEST_DEFAULT_NOT_LINKED,
461
483
  },
462
484
  );
463
485
  await new Promise((r) => setTimeout(r, 10));
@@ -487,6 +509,7 @@ describe("POST /api/modules/:short/install", () => {
487
509
  configDir: h.dir,
488
510
  supervisor,
489
511
  run,
512
+ isLinked: TEST_DEFAULT_NOT_LINKED,
490
513
  },
491
514
  );
492
515
  await new Promise((r) => setTimeout(r, 10));
@@ -524,6 +547,7 @@ describe("POST /api/modules/:short/install", () => {
524
547
  configDir: h.dir,
525
548
  supervisor,
526
549
  run,
550
+ isLinked: TEST_DEFAULT_NOT_LINKED,
527
551
  },
528
552
  );
529
553
  await new Promise((r) => setTimeout(r, 10));
@@ -557,6 +581,7 @@ describe("POST /api/modules/:short/install", () => {
557
581
  configDir: h.dir,
558
582
  supervisor,
559
583
  run,
584
+ isLinked: TEST_DEFAULT_NOT_LINKED,
560
585
  },
561
586
  );
562
587
  expect(res.status).toBe(202);
@@ -583,6 +608,7 @@ describe("POST /api/modules/:short/install", () => {
583
608
  supervisor,
584
609
  run: async () => 1,
585
610
  findGlobalInstall: () => null,
611
+ isLinked: TEST_DEFAULT_NOT_LINKED,
586
612
  };
587
613
  const res = await handleInstall(
588
614
  postReq("/api/modules/vault/install", { authorization: `Bearer ${bearer}` }),
@@ -602,6 +628,71 @@ describe("POST /api/modules/:short/install", () => {
602
628
  expect(op.status).toBe("failed");
603
629
  expect(op.error).toMatch(/bun add -g exited 1/);
604
630
  });
631
+
632
+ test("skips bun add -g when package is already bun-linked (smoke 2026-05-27 finding 1)", async () => {
633
+ // Smoke finding 1: the wizard's parallel install path was unconditionally
634
+ // invoking `bun add -g <pkg>` even when the package was already linked
635
+ // via `bun link <abspath>` (the standard local-dev shape). At best a
636
+ // wasted ~3s npm round-trip per install; at worst the global bun.lock
637
+ // had unrelated noise and the install failed outright, taking the
638
+ // wizard's vault step with it. Fix: mirror the CLI install path's
639
+ // `isLinked` short-circuit. Regression guard.
640
+ const { supervisor, spawns } = makeIdleSupervisor();
641
+ const { run, calls } = alwaysOkRun();
642
+ const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
643
+ const res = await handleInstall(
644
+ postReq("/api/modules/vault/install", { authorization: `Bearer ${bearer}` }),
645
+ "vault",
646
+ {
647
+ db: h.db,
648
+ issuer: ISSUER,
649
+ manifestPath: h.manifestPath,
650
+ configDir: h.dir,
651
+ supervisor,
652
+ run,
653
+ // The bug shape: package IS linked locally. Without the
654
+ // short-circuit, runInstall would still call bun add -g.
655
+ isLinked: (pkg) => pkg === "@openparachute/vault",
656
+ },
657
+ );
658
+ expect(res.status).toBe(202);
659
+ await new Promise((r) => setTimeout(r, 10));
660
+
661
+ // The fix: bun add -g was NOT invoked for the linked package.
662
+ expect(calls).not.toContainEqual(["bun", "add", "-g", "@openparachute/vault@latest"]);
663
+ // Downstream of the skip, the seed + spawn still happen — the install
664
+ // op completes successfully against the locally-linked checkout.
665
+ const manifest = JSON.parse(readFileSync(h.manifestPath, "utf8")) as {
666
+ services: Array<{ name: string }>;
667
+ };
668
+ expect(manifest.services.some((s) => s.name === "parachute-vault")).toBe(true);
669
+ expect(spawns.find((s) => s.short === "vault")?.cmd).toEqual(["parachute-vault", "serve"]);
670
+ });
671
+
672
+ test("still runs bun add -g when package is NOT bun-linked", async () => {
673
+ // Companion to the above — confirms the short-circuit doesn't
674
+ // unconditionally skip. On a friend's fresh machine (no bun link),
675
+ // bun add -g IS what installs the package from npm. `isLinked: () => false`
676
+ // is the production default behavior for a non-linked package.
677
+ const { supervisor } = makeIdleSupervisor();
678
+ const { run, calls } = alwaysOkRun();
679
+ const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
680
+ await handleInstall(
681
+ postReq("/api/modules/vault/install", { authorization: `Bearer ${bearer}` }),
682
+ "vault",
683
+ {
684
+ db: h.db,
685
+ issuer: ISSUER,
686
+ manifestPath: h.manifestPath,
687
+ configDir: h.dir,
688
+ supervisor,
689
+ run,
690
+ isLinked: () => false,
691
+ },
692
+ );
693
+ await new Promise((r) => setTimeout(r, 10));
694
+ expect(calls).toContainEqual(["bun", "add", "-g", "@openparachute/vault@latest"]);
695
+ });
605
696
  });
606
697
 
607
698
  describe("POST /api/modules/:short/restart", () => {
@@ -682,6 +773,7 @@ describe("POST /api/modules/:short/upgrade", () => {
682
773
  configDir: h.dir,
683
774
  supervisor,
684
775
  run,
776
+ isLinked: TEST_DEFAULT_NOT_LINKED,
685
777
  },
686
778
  );
687
779
  expect(res.status).toBe(202);
@@ -706,6 +798,7 @@ describe("POST /api/modules/:short/upgrade", () => {
706
798
  configDir: h.dir,
707
799
  supervisor,
708
800
  run,
801
+ isLinked: TEST_DEFAULT_NOT_LINKED,
709
802
  },
710
803
  );
711
804
  expect(res.status).toBe(202);
@@ -736,6 +829,7 @@ describe("POST /api/modules/:short/upgrade", () => {
736
829
  configDir: h.dir,
737
830
  supervisor,
738
831
  run,
832
+ isLinked: TEST_DEFAULT_NOT_LINKED,
739
833
  },
740
834
  );
741
835
  await new Promise((r) => setTimeout(r, 10));
@@ -846,6 +940,7 @@ describe("POST /api/modules/:short/uninstall", () => {
846
940
  configDir: h.dir,
847
941
  supervisor,
848
942
  run,
943
+ isLinked: TEST_DEFAULT_NOT_LINKED,
849
944
  },
850
945
  );
851
946
  expect(res.status).toBe(200);
@@ -878,6 +973,7 @@ describe("POST /api/modules/:short/uninstall", () => {
878
973
  configDir: h.dir,
879
974
  supervisor,
880
975
  run,
976
+ isLinked: TEST_DEFAULT_NOT_LINKED,
881
977
  },
882
978
  );
883
979
  expect(res.status).toBe(200);
@@ -1099,6 +1195,7 @@ describe("well-known regen after module ops", () => {
1099
1195
  supervisor,
1100
1196
  run: async () => 1,
1101
1197
  findGlobalInstall: () => null,
1198
+ isLinked: TEST_DEFAULT_NOT_LINKED,
1102
1199
  wellKnownPath: wkPath,
1103
1200
  };
1104
1201
  const res = await handleInstall(
@@ -31,6 +31,7 @@ import {
31
31
  handleListUsers,
32
32
  handleListVaults,
33
33
  handleResetUserPassword,
34
+ handleUpdateUserVaults,
34
35
  } from "../api-users.ts";
35
36
  import { hubDbPath, openHubDb } from "../hub-db.ts";
36
37
  import { findTokenRowByJti, recordTokenMint, signAccessToken } from "../jwt-sign.ts";
@@ -176,7 +177,7 @@ describe("handleListUsers", () => {
176
177
  expect(u).toHaveProperty("id");
177
178
  expect(u).toHaveProperty("username");
178
179
  expect(u).toHaveProperty("password_changed");
179
- expect(u).toHaveProperty("assigned_vault");
180
+ expect(u).toHaveProperty("assigned_vaults");
180
181
  expect(u).toHaveProperty("created_at");
181
182
  }
182
183
  });
@@ -220,13 +221,13 @@ describe("handleCreateUser", () => {
220
221
  const res = await post(bearer, {
221
222
  username: "alice",
222
223
  password: "alice-strong-passphrase",
223
- assignedVault: null,
224
+ assignedVaults: [],
224
225
  });
225
226
  expect(res.status).toBe(201);
226
227
  const body = (await res.json()) as { user: Record<string, unknown> };
227
228
  expect(body.user.username).toBe("alice");
228
229
  expect(body.user.password_changed).toBe(false);
229
- expect(body.user.assigned_vault).toBeNull();
230
+ expect(body.user.assigned_vaults).toEqual([]);
230
231
  expect(body.user).not.toHaveProperty("password_hash");
231
232
  });
232
233
 
@@ -331,9 +332,9 @@ describe("handleCreateUser", () => {
331
332
  const stamp = "2026-05-20T00:00:00.000Z";
332
333
  harness.db
333
334
  .prepare(
334
- "INSERT INTO users (id, username, password_hash, created_at, updated_at, password_changed, assigned_vault) VALUES (?, ?, ?, ?, ?, ?, ?)",
335
+ "INSERT INTO users (id, username, password_hash, created_at, updated_at, password_changed) VALUES (?, ?, ?, ?, ?, ?)",
335
336
  )
336
- .run("legacy-id", "Alice", "$argon2id$fake", stamp, stamp, 1, null);
337
+ .run("legacy-id", "Alice", "$argon2id$fake", stamp, stamp, 1);
337
338
  const { bearer } = await makeAdminBearer();
338
339
  const res = await post(bearer, {
339
340
  username: "alice",
@@ -349,25 +350,55 @@ describe("handleCreateUser", () => {
349
350
  const res = await post(bearer, {
350
351
  username: "alice",
351
352
  password: "alice-strong-passphrase",
352
- assignedVault: "ghost-vault",
353
+ assignedVaults: ["ghost-vault"],
353
354
  });
354
355
  expect(res.status).toBe(400);
355
356
  const body = (await res.json()) as { error: string };
356
357
  expect(body.error).toBe("assigned_vault_not_found");
357
358
  });
358
359
 
359
- test("happy path with assigned_vault that exists in services.json", async () => {
360
+ test("happy path with single assigned_vaults that exists in services.json", async () => {
360
361
  harness.cleanup();
361
362
  harness = makeHarness(manifestWithVaults("home"));
362
363
  const { bearer } = await makeAdminBearer();
363
364
  const res = await post(bearer, {
364
365
  username: "alice",
365
366
  password: "alice-strong-passphrase",
366
- assignedVault: "home",
367
+ assignedVaults: ["home"],
367
368
  });
368
369
  expect(res.status).toBe(201);
369
370
  const body = (await res.json()) as { user: Record<string, unknown> };
370
- expect(body.user.assigned_vault).toBe("home");
371
+ expect(body.user.assigned_vaults).toEqual(["home"]);
372
+ });
373
+
374
+ test("happy path with multiple assigned_vaults (Phase 2 PR 2)", async () => {
375
+ harness.cleanup();
376
+ harness = makeHarness(manifestWithVaults("personal", "family"));
377
+ const { bearer } = await makeAdminBearer();
378
+ const res = await post(bearer, {
379
+ username: "alice",
380
+ password: "alice-strong-passphrase",
381
+ assignedVaults: ["personal", "family"],
382
+ });
383
+ expect(res.status).toBe(201);
384
+ const body = (await res.json()) as { user: Record<string, unknown> };
385
+ const list = body.user.assigned_vaults as string[];
386
+ expect(new Set(list)).toEqual(new Set(["personal", "family"]));
387
+ });
388
+
389
+ test("400 assigned_vault_not_found when ANY vault in the array is unknown", async () => {
390
+ harness.cleanup();
391
+ harness = makeHarness(manifestWithVaults("home"));
392
+ const { bearer } = await makeAdminBearer();
393
+ const res = await post(bearer, {
394
+ username: "alice",
395
+ password: "alice-strong-passphrase",
396
+ assignedVaults: ["home", "ghost"],
397
+ });
398
+ expect(res.status).toBe(400);
399
+ const body = (await res.json()) as { error: string; error_description: string };
400
+ expect(body.error).toBe("assigned_vault_not_found");
401
+ expect(body.error_description).toContain("ghost");
371
402
  });
372
403
  });
373
404
 
@@ -632,6 +663,30 @@ describe("handleResetUserPassword", () => {
632
663
  expect(await verifyPassword(fresh!, "alice-strong-passphrase")).toBe(false);
633
664
  });
634
665
 
666
+ test("response body includes revocation_lag_seconds = 60 so clients can surface the propagation lag (smoke 2026-05-27 finding 3)", async () => {
667
+ // Resource servers cache the revocation list via scope-guard's
668
+ // REVOCATION_CACHE_TTL_MS = 60_000 — so even though hub marks the
669
+ // user's tokens revoked immediately and publishes them on the
670
+ // revocation list, vault/scribe/etc. may keep accepting the
671
+ // revoked token for up to 60 seconds. For the stolen-device
672
+ // recovery threat model that's a meaningful exposure window
673
+ // the admin needs to know about. Surface the lag in the
674
+ // response body so the SPA's success banner can warn operators.
675
+ const { bearer } = await makeAdminBearer();
676
+ const friend = await createUser(harness.db, "alice", "alice-strong-passphrase", {
677
+ allowMulti: true,
678
+ passwordChanged: true,
679
+ });
680
+ const res = await post(bearer, friend.id, { new_password: "new-temp-passphrase" });
681
+ expect(res.status).toBe(200);
682
+ const body = (await res.json()) as {
683
+ ok: boolean;
684
+ user: unknown;
685
+ revocation_lag_seconds: number;
686
+ };
687
+ expect(body.revocation_lag_seconds).toBe(60);
688
+ });
689
+
635
690
  test("revokes the friend's existing tokens (pre-reset token row has revoked_at after)", async () => {
636
691
  const { bearer } = await makeAdminBearer();
637
692
  const friend = await createUser(harness.db, "alice", "alice-strong-passphrase", {
@@ -710,3 +765,130 @@ describe("handleListVaults", () => {
710
765
  expect(body.vaults).toEqual(["home", "scratch", "work"]);
711
766
  });
712
767
  });
768
+
769
+ // ---------------------------------------------------------------------------
770
+ // PATCH /api/users/:id/vaults — edit vault assignments (Phase 2 PR 2)
771
+ // ---------------------------------------------------------------------------
772
+
773
+ describe("handleUpdateUserVaults", () => {
774
+ async function patch(
775
+ bearer: string,
776
+ userId: string,
777
+ body: Record<string, unknown> | string,
778
+ headers: Record<string, string> = {},
779
+ ): Promise<Response> {
780
+ const init: RequestInit = {
781
+ method: "PATCH",
782
+ headers: {
783
+ "content-type": "application/json",
784
+ authorization: `Bearer ${bearer}`,
785
+ ...headers,
786
+ },
787
+ body: typeof body === "string" ? body : JSON.stringify(body),
788
+ };
789
+ return await handleUpdateUserVaults(req(`/api/users/${userId}/vaults`, init), userId, deps());
790
+ }
791
+
792
+ test("401 with no Authorization header", async () => {
793
+ const res = await handleUpdateUserVaults(
794
+ req("/api/users/some-id/vaults", {
795
+ method: "PATCH",
796
+ headers: { "content-type": "application/json" },
797
+ body: JSON.stringify({ assigned_vaults: [] }),
798
+ }),
799
+ "some-id",
800
+ deps(),
801
+ );
802
+ expect(res.status).toBe(401);
803
+ });
804
+
805
+ test("405 on non-PATCH", async () => {
806
+ const { bearer } = await makeAdminBearer();
807
+ const res = await handleUpdateUserVaults(
808
+ withBearer("/api/users/x/vaults", bearer, { method: "POST" }),
809
+ "x",
810
+ deps(),
811
+ );
812
+ expect(res.status).toBe(405);
813
+ });
814
+
815
+ test("404 when target user does not exist", async () => {
816
+ const { bearer } = await makeAdminBearer();
817
+ const res = await patch(bearer, "no-such-id", { assigned_vaults: [] });
818
+ expect(res.status).toBe(404);
819
+ });
820
+
821
+ test("403 cannot_edit_first_admin_vaults for the first admin", async () => {
822
+ // The bearer-minting helper creates the first admin already; mint
823
+ // again returns the same admin's id.
824
+ const { bearer, userId } = await makeAdminBearer();
825
+ const res = await patch(bearer, userId, { assigned_vaults: ["home"] });
826
+ expect(res.status).toBe(403);
827
+ const body = (await res.json()) as { error: string };
828
+ expect(body.error).toBe("cannot_edit_first_admin_vaults");
829
+ });
830
+
831
+ test("400 when assigned_vaults is missing", async () => {
832
+ const { bearer } = await makeAdminBearer();
833
+ const friend = await createUser(harness.db, "alice", "alice-strong-passphrase", {
834
+ allowMulti: true,
835
+ });
836
+ const res = await patch(bearer, friend.id, {});
837
+ expect(res.status).toBe(400);
838
+ });
839
+
840
+ test("400 when assigned_vaults entry is not a string", async () => {
841
+ const { bearer } = await makeAdminBearer();
842
+ const friend = await createUser(harness.db, "alice", "alice-strong-passphrase", {
843
+ allowMulti: true,
844
+ });
845
+ const res = await patch(bearer, friend.id, { assigned_vaults: ["ok", 7] });
846
+ expect(res.status).toBe(400);
847
+ });
848
+
849
+ test("400 assigned_vault_not_found when a vault doesn't exist in services.json", async () => {
850
+ harness.cleanup();
851
+ harness = makeHarness(manifestWithVaults("home"));
852
+ const { bearer } = await makeAdminBearer();
853
+ const friend = await createUser(harness.db, "alice", "alice-strong-passphrase", {
854
+ allowMulti: true,
855
+ });
856
+ const res = await patch(bearer, friend.id, { assigned_vaults: ["home", "ghost"] });
857
+ expect(res.status).toBe(400);
858
+ const body = (await res.json()) as { error: string };
859
+ expect(body.error).toBe("assigned_vault_not_found");
860
+ });
861
+
862
+ test("happy path — replaces the user's vault list and bumps updated_at", async () => {
863
+ harness.cleanup();
864
+ harness = makeHarness(manifestWithVaults("home", "work", "family"));
865
+ const { bearer } = await makeAdminBearer();
866
+ const friend = await createUser(harness.db, "alice", "alice-strong-passphrase", {
867
+ allowMulti: true,
868
+ assignedVaults: ["home"],
869
+ });
870
+ const res = await patch(bearer, friend.id, { assigned_vaults: ["work", "family"] });
871
+ expect(res.status).toBe(200);
872
+ const body = (await res.json()) as {
873
+ ok: boolean;
874
+ user: { id: string; assigned_vaults: string[]; updated_at: string };
875
+ };
876
+ expect(body.ok).toBe(true);
877
+ expect(new Set(body.user.assigned_vaults)).toEqual(new Set(["work", "family"]));
878
+ expect(body.user.assigned_vaults).not.toContain("home");
879
+ });
880
+
881
+ test("empty array clears every assignment", async () => {
882
+ harness.cleanup();
883
+ harness = makeHarness(manifestWithVaults("home", "work"));
884
+ const { bearer } = await makeAdminBearer();
885
+ const friend = await createUser(harness.db, "alice", "alice-strong-passphrase", {
886
+ allowMulti: true,
887
+ assignedVaults: ["home", "work"],
888
+ });
889
+ const res = await patch(bearer, friend.id, { assigned_vaults: [] });
890
+ expect(res.status).toBe(200);
891
+ const fresh = getUserById(harness.db, friend.id);
892
+ expect(fresh?.assignedVaults).toEqual([]);
893
+ });
894
+ });