@openparachute/hub 0.5.13 → 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.
Files changed (47) hide show
  1. package/package.json +2 -2
  2. package/src/__tests__/account-home-ui.test.ts +163 -0
  3. package/src/__tests__/admin-handlers.test.ts +74 -0
  4. package/src/__tests__/admin-host-admin-token.test.ts +62 -0
  5. package/src/__tests__/admin-vault-admin-token.test.ts +44 -0
  6. package/src/__tests__/api-account.test.ts +191 -1
  7. package/src/__tests__/api-modules-ops.test.ts +97 -0
  8. package/src/__tests__/api-modules.test.ts +32 -32
  9. package/src/__tests__/api-users.test.ts +383 -11
  10. package/src/__tests__/chrome-strip.test.ts +15 -15
  11. package/src/__tests__/hub-db.test.ts +194 -29
  12. package/src/__tests__/hub-server.test.ts +23 -23
  13. package/src/__tests__/notes-redirect.test.ts +20 -20
  14. package/src/__tests__/oauth-handlers.test.ts +722 -28
  15. package/src/__tests__/serve.test.ts +9 -9
  16. package/src/__tests__/services-manifest.test.ts +40 -40
  17. package/src/__tests__/setup-wizard.test.ts +493 -25
  18. package/src/__tests__/setup.test.ts +1 -1
  19. package/src/__tests__/status.test.ts +39 -0
  20. package/src/__tests__/users.test.ts +396 -9
  21. package/src/__tests__/well-known.test.ts +9 -9
  22. package/src/account-home-ui.ts +434 -0
  23. package/src/admin-handlers.ts +49 -17
  24. package/src/admin-host-admin-token.ts +25 -0
  25. package/src/admin-vault-admin-token.ts +17 -0
  26. package/src/api-account.ts +72 -6
  27. package/src/api-modules-ops.ts +52 -16
  28. package/src/api-modules.ts +3 -3
  29. package/src/api-users.ts +468 -55
  30. package/src/bun-link.ts +55 -0
  31. package/src/chrome-strip.ts +6 -6
  32. package/src/commands/install.ts +8 -21
  33. package/src/commands/status.ts +10 -1
  34. package/src/help.ts +2 -2
  35. package/src/hub-db.ts +42 -0
  36. package/src/hub-server.ts +69 -10
  37. package/src/hub-settings.ts +2 -2
  38. package/src/hub.ts +6 -6
  39. package/src/notes-redirect.ts +5 -5
  40. package/src/oauth-handlers.ts +278 -173
  41. package/src/oauth-ui.ts +18 -2
  42. package/src/service-spec.ts +39 -18
  43. package/src/setup-wizard.ts +489 -42
  44. package/src/users.ts +307 -29
  45. package/web/ui/dist/assets/index-tRmPbbC7.js +61 -0
  46. package/web/ui/dist/index.html +1 -1
  47. package/web/ui/dist/assets/index-Dzrbe6EP.js +0 -61
@@ -1,5 +1,6 @@
1
1
  /**
2
- * Tests for `/api/users*` (multi-user Phase 1, PR 2). Covers:
2
+ * Tests for `/api/users*` (multi-user Phase 1 PR 2 + Phase 2 PR 1).
3
+ * Covers:
3
4
  *
4
5
  * - Auth boundary: every endpoint requires a bearer carrying
5
6
  * `parachute:host:admin`.
@@ -11,6 +12,10 @@
11
12
  * 400 `assigned_vault_not_found`).
12
13
  * - DELETE happy path with token revocation; first-admin-undeletable
13
14
  * returns 403; 404 on unknown id.
15
+ * - POST /:id/reset-password (Phase 2 PR 1) happy path with token
16
+ * revocation, first-admin protection (403 cannot_reset_first_admin),
17
+ * password-validation branches (too-short 400, too-long 413 before
18
+ * argon2id), missing target (404), auth boundary.
14
19
  * - GET /api/users/vaults returns the same name set the OAuth issuer
15
20
  * would resolve against.
16
21
  * - 405 on wrong methods.
@@ -25,10 +30,12 @@ import {
25
30
  handleDeleteUser,
26
31
  handleListUsers,
27
32
  handleListVaults,
33
+ handleResetUserPassword,
34
+ handleUpdateUserVaults,
28
35
  } from "../api-users.ts";
29
36
  import { hubDbPath, openHubDb } from "../hub-db.ts";
30
37
  import { findTokenRowByJti, recordTokenMint, signAccessToken } from "../jwt-sign.ts";
31
- import { createUser } from "../users.ts";
38
+ import { createUser, getUserById, verifyPassword } from "../users.ts";
32
39
 
33
40
  const ISSUER = "https://hub.test";
34
41
  const HOST_ADMIN_SCOPE = "parachute:host:admin";
@@ -170,7 +177,7 @@ describe("handleListUsers", () => {
170
177
  expect(u).toHaveProperty("id");
171
178
  expect(u).toHaveProperty("username");
172
179
  expect(u).toHaveProperty("password_changed");
173
- expect(u).toHaveProperty("assigned_vault");
180
+ expect(u).toHaveProperty("assigned_vaults");
174
181
  expect(u).toHaveProperty("created_at");
175
182
  }
176
183
  });
@@ -214,13 +221,13 @@ describe("handleCreateUser", () => {
214
221
  const res = await post(bearer, {
215
222
  username: "alice",
216
223
  password: "alice-strong-passphrase",
217
- assignedVault: null,
224
+ assignedVaults: [],
218
225
  });
219
226
  expect(res.status).toBe(201);
220
227
  const body = (await res.json()) as { user: Record<string, unknown> };
221
228
  expect(body.user.username).toBe("alice");
222
229
  expect(body.user.password_changed).toBe(false);
223
- expect(body.user.assigned_vault).toBeNull();
230
+ expect(body.user.assigned_vaults).toEqual([]);
224
231
  expect(body.user).not.toHaveProperty("password_hash");
225
232
  });
226
233
 
@@ -325,9 +332,9 @@ describe("handleCreateUser", () => {
325
332
  const stamp = "2026-05-20T00:00:00.000Z";
326
333
  harness.db
327
334
  .prepare(
328
- "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 (?, ?, ?, ?, ?, ?)",
329
336
  )
330
- .run("legacy-id", "Alice", "$argon2id$fake", stamp, stamp, 1, null);
337
+ .run("legacy-id", "Alice", "$argon2id$fake", stamp, stamp, 1);
331
338
  const { bearer } = await makeAdminBearer();
332
339
  const res = await post(bearer, {
333
340
  username: "alice",
@@ -343,25 +350,55 @@ describe("handleCreateUser", () => {
343
350
  const res = await post(bearer, {
344
351
  username: "alice",
345
352
  password: "alice-strong-passphrase",
346
- assignedVault: "ghost-vault",
353
+ assignedVaults: ["ghost-vault"],
347
354
  });
348
355
  expect(res.status).toBe(400);
349
356
  const body = (await res.json()) as { error: string };
350
357
  expect(body.error).toBe("assigned_vault_not_found");
351
358
  });
352
359
 
353
- 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 () => {
354
361
  harness.cleanup();
355
362
  harness = makeHarness(manifestWithVaults("home"));
356
363
  const { bearer } = await makeAdminBearer();
357
364
  const res = await post(bearer, {
358
365
  username: "alice",
359
366
  password: "alice-strong-passphrase",
360
- assignedVault: "home",
367
+ assignedVaults: ["home"],
361
368
  });
362
369
  expect(res.status).toBe(201);
363
370
  const body = (await res.json()) as { user: Record<string, unknown> };
364
- 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");
365
402
  });
366
403
  });
367
404
 
@@ -477,6 +514,214 @@ describe("handleDeleteUser", () => {
477
514
  });
478
515
  });
479
516
 
517
+ // ---------------------------------------------------------------------------
518
+ // POST /api/users/:id/reset-password — admin-initiated password reset
519
+ // (multi-user Phase 2 PR 1)
520
+ // ---------------------------------------------------------------------------
521
+
522
+ describe("handleResetUserPassword", () => {
523
+ async function post(
524
+ bearer: string | null,
525
+ id: string,
526
+ body: Record<string, unknown> | string | null,
527
+ headers: Record<string, string> = {},
528
+ ): Promise<Response> {
529
+ const init: RequestInit = {
530
+ method: "POST",
531
+ headers: {
532
+ "content-type": "application/json",
533
+ ...(bearer ? { authorization: `Bearer ${bearer}` } : {}),
534
+ ...headers,
535
+ },
536
+ body: body === null ? undefined : typeof body === "string" ? body : JSON.stringify(body),
537
+ };
538
+ return await handleResetUserPassword(req(`/api/users/${id}/reset-password`, init), id, deps());
539
+ }
540
+
541
+ test("401 with no Authorization header", async () => {
542
+ const res = await post(null, "some-id", { new_password: "twelvechars1" });
543
+ expect(res.status).toBe(401);
544
+ });
545
+
546
+ test("403 when bearer lacks parachute:host:admin", async () => {
547
+ const { bearer } = await makeAdminBearer(["other:scope"]);
548
+ const res = await post(bearer, "some-id", { new_password: "twelvechars1" });
549
+ expect(res.status).toBe(403);
550
+ });
551
+
552
+ test("405 on GET", async () => {
553
+ const { bearer } = await makeAdminBearer();
554
+ const res = await handleResetUserPassword(
555
+ withBearer("/api/users/some-id/reset-password", bearer, { method: "GET" }),
556
+ "some-id",
557
+ deps(),
558
+ );
559
+ expect(res.status).toBe(405);
560
+ });
561
+
562
+ test("404 when target user does not exist", async () => {
563
+ const { bearer } = await makeAdminBearer();
564
+ const res = await post(bearer, "no-such-id", { new_password: "twelvechars1" });
565
+ expect(res.status).toBe(404);
566
+ const body = (await res.json()) as { error: string };
567
+ expect(body.error).toBe("not_found");
568
+ });
569
+
570
+ test("403 cannot_reset_first_admin when targeting the first admin", async () => {
571
+ const { bearer, userId } = await makeAdminBearer();
572
+ const res = await post(bearer, userId, { new_password: "twelvechars1" });
573
+ expect(res.status).toBe(403);
574
+ const body = (await res.json()) as { error: string };
575
+ expect(body.error).toBe("cannot_reset_first_admin");
576
+ // First-admin row's hash + password_changed bit untouched.
577
+ const fresh = getUserById(harness.db, userId);
578
+ expect(fresh?.passwordChanged).toBe(true);
579
+ });
580
+
581
+ test("400 invalid_password when new_password is too short (< 12 chars)", async () => {
582
+ const { bearer } = await makeAdminBearer();
583
+ const friend = await createUser(harness.db, "alice", "alice-strong-passphrase", {
584
+ allowMulti: true,
585
+ passwordChanged: true,
586
+ });
587
+ const res = await post(bearer, friend.id, { new_password: "short" });
588
+ expect(res.status).toBe(400);
589
+ const body = (await res.json()) as { error: string; error_description: string };
590
+ expect(body.error).toBe("invalid_password");
591
+ expect(body.error_description).toMatch(/12 characters/);
592
+ });
593
+
594
+ test("413 password_too_long when new_password > 256 chars (before argon2id touches it)", async () => {
595
+ const { bearer } = await makeAdminBearer();
596
+ const friend = await createUser(harness.db, "alice", "alice-strong-passphrase", {
597
+ allowMulti: true,
598
+ passwordChanged: true,
599
+ });
600
+ const huge = "a".repeat(300);
601
+ const t0 = Date.now();
602
+ const res = await post(bearer, friend.id, { new_password: huge });
603
+ const elapsed = Date.now() - t0;
604
+ expect(res.status).toBe(413);
605
+ const body = (await res.json()) as { error: string };
606
+ expect(body.error).toBe("password_too_long");
607
+ // Same liveness check as the create-user path: 300-char argon2id is
608
+ // hundreds of ms; cap-and-reject should complete in <200ms.
609
+ expect(elapsed).toBeLessThan(200);
610
+ });
611
+
612
+ test("400 invalid_request when content-type is not application/json", async () => {
613
+ const { bearer } = await makeAdminBearer();
614
+ const friend = await createUser(harness.db, "alice", "alice-strong-passphrase", {
615
+ allowMulti: true,
616
+ passwordChanged: true,
617
+ });
618
+ const res = await post(bearer, friend.id, "new_password=twelvechars1", {
619
+ "content-type": "application/x-www-form-urlencoded",
620
+ });
621
+ expect(res.status).toBe(400);
622
+ });
623
+
624
+ test("400 invalid_request when new_password missing", async () => {
625
+ const { bearer } = await makeAdminBearer();
626
+ const friend = await createUser(harness.db, "alice", "alice-strong-passphrase", {
627
+ allowMulti: true,
628
+ passwordChanged: true,
629
+ });
630
+ const res = await post(bearer, friend.id, {});
631
+ expect(res.status).toBe(400);
632
+ const body = (await res.json()) as { error: string };
633
+ expect(body.error).toBe("invalid_request");
634
+ });
635
+
636
+ test("happy path — rotates hash, flips password_changed=false, returns user shape", async () => {
637
+ const { bearer } = await makeAdminBearer();
638
+ const friend = await createUser(harness.db, "alice", "alice-strong-passphrase", {
639
+ allowMulti: true,
640
+ passwordChanged: true,
641
+ });
642
+ const oldHash = friend.passwordHash;
643
+ const res = await post(bearer, friend.id, { new_password: "new-temp-passphrase" });
644
+ expect(res.status).toBe(200);
645
+ const body = (await res.json()) as {
646
+ ok: boolean;
647
+ user: { id: string; password_changed: boolean; username: string };
648
+ };
649
+ expect(body.ok).toBe(true);
650
+ expect(body.user.id).toBe(friend.id);
651
+ expect(body.user.username).toBe("alice");
652
+ expect(body.user.password_changed).toBe(false);
653
+ // Echo body never carries the hash (or the new password).
654
+ expect(body.user).not.toHaveProperty("password_hash");
655
+ expect(body).not.toHaveProperty("new_password");
656
+ // Round-trip on the user row: new password works, old does not, hash
657
+ // moved, password_changed is now false (force-redirect on next login).
658
+ const fresh = getUserById(harness.db, friend.id);
659
+ expect(fresh).not.toBeNull();
660
+ expect(fresh?.passwordHash).not.toBe(oldHash);
661
+ expect(fresh?.passwordChanged).toBe(false);
662
+ expect(await verifyPassword(fresh!, "new-temp-passphrase")).toBe(true);
663
+ expect(await verifyPassword(fresh!, "alice-strong-passphrase")).toBe(false);
664
+ });
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
+
690
+ test("revokes the friend's existing tokens (pre-reset token row has revoked_at after)", async () => {
691
+ const { bearer } = await makeAdminBearer();
692
+ const friend = await createUser(harness.db, "alice", "alice-strong-passphrase", {
693
+ allowMulti: true,
694
+ passwordChanged: true,
695
+ });
696
+ const minted = await signAccessToken(harness.db, {
697
+ sub: friend.id,
698
+ scopes: ["vault:home:read"],
699
+ audience: "vault",
700
+ clientId: "notes-client",
701
+ issuer: ISSUER,
702
+ ttlSeconds: 600,
703
+ });
704
+ recordTokenMint(harness.db, {
705
+ jti: minted.jti,
706
+ createdVia: "operator_mint",
707
+ subject: friend.username,
708
+ userId: friend.id,
709
+ clientId: "notes-client",
710
+ scopes: ["vault:home:read"],
711
+ expiresAt: minted.expiresAt,
712
+ });
713
+ expect(findTokenRowByJti(harness.db, minted.jti)?.revokedAt).toBeNull();
714
+
715
+ const res = await post(bearer, friend.id, { new_password: "new-temp-passphrase" });
716
+ expect(res.status).toBe(200);
717
+
718
+ const row = findTokenRowByJti(harness.db, minted.jti);
719
+ expect(row?.revokedAt).not.toBeNull();
720
+ // User row sticks around (unlike delete), so user_id is NOT NULLed.
721
+ expect(row?.userId).toBe(friend.id);
722
+ });
723
+ });
724
+
480
725
  // ---------------------------------------------------------------------------
481
726
  // GET /api/users/vaults — vault-name list for the assigned-vault dropdown
482
727
  // ---------------------------------------------------------------------------
@@ -520,3 +765,130 @@ describe("handleListVaults", () => {
520
765
  expect(body.vaults).toEqual(["home", "scratch", "work"]);
521
766
  });
522
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
+ });
@@ -82,21 +82,21 @@ describe("shouldInjectChrome", () => {
82
82
  expect(shouldInjectChrome("/admin/vaults")).toBe(true);
83
83
  expect(shouldInjectChrome("/scribe/admin")).toBe(true);
84
84
  expect(shouldInjectChrome("/vault/default/admin/")).toBe(true);
85
- expect(shouldInjectChrome("/app/admin/modules")).toBe(true);
85
+ expect(shouldInjectChrome("/surface/admin/modules")).toBe(true);
86
86
  });
87
87
 
88
- test("default: opts out the Notes PWA at /app/notes/*", () => {
89
- expect(shouldInjectChrome("/app/notes")).toBe(false);
90
- expect(shouldInjectChrome("/app/notes/")).toBe(false);
91
- expect(shouldInjectChrome("/app/notes/index.html")).toBe(false);
92
- expect(shouldInjectChrome("/app/notes/assets/index-XXX.js")).toBe(false);
88
+ test("default: opts out the Notes PWA at /surface/notes/*", () => {
89
+ expect(shouldInjectChrome("/surface/notes")).toBe(false);
90
+ expect(shouldInjectChrome("/surface/notes/")).toBe(false);
91
+ expect(shouldInjectChrome("/surface/notes/index.html")).toBe(false);
92
+ expect(shouldInjectChrome("/surface/notes/assets/index-XXX.js")).toBe(false);
93
93
  });
94
94
 
95
95
  test("opt-out prefix matching does not over-match sibling paths", () => {
96
- // `/app/notesbook` must NOT match `/app/notes/` — startsWith check
96
+ // `/surface/notesbook` must NOT match `/surface/notes/` — startsWith check
97
97
  // requires a trailing slash boundary.
98
- expect(shouldInjectChrome("/app/notesbook")).toBe(true);
99
- expect(shouldInjectChrome("/app/notes-archive/")).toBe(true);
98
+ expect(shouldInjectChrome("/surface/notesbook")).toBe(true);
99
+ expect(shouldInjectChrome("/surface/notes-archive/")).toBe(true);
100
100
  });
101
101
 
102
102
  test("custom opt-out list is honored", () => {
@@ -111,8 +111,8 @@ describe("shouldInjectChrome", () => {
111
111
  expect(shouldInjectChrome("/foo/bar", ["/foo"])).toBe(false);
112
112
  });
113
113
 
114
- test("the canonical opt-out list contains /app/notes/", () => {
115
- expect(CHROME_OPT_OUT_PREFIXES).toContain("/app/notes/");
114
+ test("the canonical opt-out list contains /surface/notes/", () => {
115
+ expect(CHROME_OPT_OUT_PREFIXES).toContain("/surface/notes/");
116
116
  });
117
117
  });
118
118
 
@@ -250,27 +250,27 @@ describe("injectChromeIntoResponse", () => {
250
250
  expect(cssOut).toBe(css);
251
251
  });
252
252
 
253
- test("passes through responses on opt-out paths (/app/notes/) unchanged", async () => {
253
+ test("passes through responses on opt-out paths (/surface/notes/) unchanged", async () => {
254
254
  const res = new Response("<html><body>notes</body></html>", {
255
255
  status: 200,
256
256
  headers: { "content-type": "text/html" },
257
257
  });
258
258
  const out = await injectChromeIntoResponse(res, {
259
259
  chromeHtml: chrome,
260
- pathname: "/app/notes/",
260
+ pathname: "/surface/notes/",
261
261
  });
262
262
  expect(out).toBe(res);
263
263
  expect(await out.text()).toBe("<html><body>notes</body></html>");
264
264
  });
265
265
 
266
- test("passes through responses on opt-out sub-paths (/app/notes/assets/x.js)", async () => {
266
+ test("passes through responses on opt-out sub-paths (/surface/notes/assets/x.js)", async () => {
267
267
  const res = new Response("<html><body>notes</body></html>", {
268
268
  status: 200,
269
269
  headers: { "content-type": "text/html" },
270
270
  });
271
271
  const out = await injectChromeIntoResponse(res, {
272
272
  chromeHtml: chrome,
273
- pathname: "/app/notes/index.html",
273
+ pathname: "/surface/notes/index.html",
274
274
  });
275
275
  expect(out).toBe(res);
276
276
  });