@openparachute/hub 0.5.13 → 0.5.14-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 (101) hide show
  1. package/README.md +109 -15
  2. package/package.json +2 -2
  3. package/src/__tests__/account-home-ui.test.ts +205 -0
  4. package/src/__tests__/admin-handlers.test.ts +74 -0
  5. package/src/__tests__/admin-host-admin-token.test.ts +62 -0
  6. package/src/__tests__/admin-vault-admin-token.test.ts +44 -0
  7. package/src/__tests__/admin-vaults.test.ts +70 -4
  8. package/src/__tests__/api-account.test.ts +191 -1
  9. package/src/__tests__/api-mint-token.test.ts +682 -3
  10. package/src/__tests__/api-modules-config.test.ts +16 -10
  11. package/src/__tests__/api-modules-ops.test.ts +97 -0
  12. package/src/__tests__/api-modules.test.ts +100 -83
  13. package/src/__tests__/api-ready.test.ts +135 -0
  14. package/src/__tests__/api-revoke-token.test.ts +384 -0
  15. package/src/__tests__/api-users.test.ts +390 -13
  16. package/src/__tests__/chrome-strip.test.ts +15 -15
  17. package/src/__tests__/cli.test.ts +7 -5
  18. package/src/__tests__/cloudflare-detect.test.ts +60 -5
  19. package/src/__tests__/expose-auth-preflight.test.ts +58 -50
  20. package/src/__tests__/expose-cloudflare.test.ts +114 -3
  21. package/src/__tests__/expose-interactive.test.ts +10 -4
  22. package/src/__tests__/expose-public-auto.test.ts +5 -1
  23. package/src/__tests__/expose.test.ts +49 -1
  24. package/src/__tests__/hub-db.test.ts +194 -29
  25. package/src/__tests__/hub-server.test.ts +322 -33
  26. package/src/__tests__/hub.test.ts +11 -0
  27. package/src/__tests__/init.test.ts +827 -0
  28. package/src/__tests__/lifecycle.test.ts +33 -1
  29. package/src/__tests__/migrate.test.ts +433 -51
  30. package/src/__tests__/notes-redirect.test.ts +20 -20
  31. package/src/__tests__/oauth-handlers.test.ts +1060 -29
  32. package/src/__tests__/oauth-ui.test.ts +12 -1
  33. package/src/__tests__/proxy-error-ui.test.ts +212 -0
  34. package/src/__tests__/proxy-state.test.ts +192 -0
  35. package/src/__tests__/resource-binding.test.ts +97 -0
  36. package/src/__tests__/scope-explanations.test.ts +36 -0
  37. package/src/__tests__/serve.test.ts +9 -9
  38. package/src/__tests__/services-manifest.test.ts +40 -40
  39. package/src/__tests__/setup-wizard.test.ts +1114 -66
  40. package/src/__tests__/setup.test.ts +1 -1
  41. package/src/__tests__/status.test.ts +39 -0
  42. package/src/__tests__/users.test.ts +396 -9
  43. package/src/__tests__/vault-auth-status.test.ts +271 -11
  44. package/src/__tests__/vault-hub-origin-env.test.ts +126 -0
  45. package/src/__tests__/well-known.test.ts +9 -9
  46. package/src/__tests__/wizard.test.ts +372 -0
  47. package/src/account-home-ui.ts +547 -0
  48. package/src/admin-handlers.ts +49 -17
  49. package/src/admin-host-admin-token.ts +25 -0
  50. package/src/admin-login-ui.ts +4 -4
  51. package/src/admin-vault-admin-token.ts +17 -0
  52. package/src/admin-vaults.ts +48 -15
  53. package/src/api-account.ts +72 -6
  54. package/src/api-mint-token.ts +132 -24
  55. package/src/api-modules-ops.ts +52 -16
  56. package/src/api-modules.ts +31 -14
  57. package/src/api-ready.ts +102 -0
  58. package/src/api-revoke-token.ts +107 -21
  59. package/src/api-users.ts +497 -58
  60. package/src/bun-link.ts +55 -0
  61. package/src/chrome-strip.ts +6 -6
  62. package/src/cli.ts +93 -24
  63. package/src/cloudflare/config.ts +10 -4
  64. package/src/cloudflare/detect.ts +73 -6
  65. package/src/commands/expose-auth-preflight.ts +55 -63
  66. package/src/commands/expose-cloudflare.ts +114 -10
  67. package/src/commands/expose-interactive.ts +10 -11
  68. package/src/commands/expose-public-auto.ts +6 -4
  69. package/src/commands/expose.ts +8 -0
  70. package/src/commands/init.ts +563 -0
  71. package/src/commands/install.ts +41 -23
  72. package/src/commands/lifecycle.ts +12 -0
  73. package/src/commands/migrate.ts +293 -41
  74. package/src/commands/status.ts +10 -1
  75. package/src/commands/wizard.ts +843 -0
  76. package/src/env-file.ts +10 -0
  77. package/src/help.ts +157 -17
  78. package/src/hub-db.ts +42 -0
  79. package/src/hub-server.ts +136 -23
  80. package/src/hub-settings.ts +13 -2
  81. package/src/hub.ts +16 -9
  82. package/src/notes-redirect.ts +5 -5
  83. package/src/oauth-handlers.ts +342 -173
  84. package/src/oauth-ui.ts +28 -2
  85. package/src/proxy-error-ui.ts +506 -0
  86. package/src/proxy-state.ts +131 -0
  87. package/src/resource-binding.ts +134 -0
  88. package/src/scope-attenuation.ts +85 -0
  89. package/src/scope-explanations.ts +94 -5
  90. package/src/service-spec.ts +39 -18
  91. package/src/setup-wizard.ts +1173 -117
  92. package/src/users.ts +307 -29
  93. package/src/vault/auth-status.ts +152 -25
  94. package/src/vault-hub-origin-env.ts +100 -0
  95. package/web/ui/dist/assets/index-2SSK7JbM.js +61 -0
  96. package/web/ui/dist/assets/index-B28SdMSz.css +1 -0
  97. package/web/ui/dist/index.html +2 -2
  98. package/src/__tests__/vault-tokens-create-interactive.test.ts +0 -183
  99. package/src/commands/vault-tokens-create-interactive.ts +0 -143
  100. package/web/ui/dist/assets/index-7DtAXz7y.css +0 -1
  101. 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
 
@@ -425,7 +462,7 @@ describe("handleDeleteUser", () => {
425
462
  expect(list.users.map((u) => u.id)).toContain(userId);
426
463
  });
427
464
 
428
- test("204 deletes a non-first user and revokes their tokens", async () => {
465
+ test("200 + revocation_lag_seconds deletes a non-first user and revokes their tokens", async () => {
429
466
  const { bearer } = await makeAdminBearer();
430
467
  // Create a second user (non-first) + mint a token on their behalf.
431
468
  const second = await createUser(harness.db, "alice", "alice-strong-passphrase", {
@@ -460,7 +497,12 @@ describe("handleDeleteUser", () => {
460
497
  second.id,
461
498
  deps(),
462
499
  );
463
- expect(res.status).toBe(204);
500
+ // 200 + body (was a bare 204) so the SPA can warn about revocation lag —
501
+ // consistency with the reset-password path.
502
+ expect(res.status).toBe(200);
503
+ const body = (await res.json()) as { ok: boolean; revocation_lag_seconds: number };
504
+ expect(body.ok).toBe(true);
505
+ expect(body.revocation_lag_seconds).toBe(60);
464
506
 
465
507
  // User row is gone.
466
508
  const listRes = await handleListUsers(withBearer("/api/users", bearer), deps());
@@ -477,6 +519,214 @@ describe("handleDeleteUser", () => {
477
519
  });
478
520
  });
479
521
 
522
+ // ---------------------------------------------------------------------------
523
+ // POST /api/users/:id/reset-password — admin-initiated password reset
524
+ // (multi-user Phase 2 PR 1)
525
+ // ---------------------------------------------------------------------------
526
+
527
+ describe("handleResetUserPassword", () => {
528
+ async function post(
529
+ bearer: string | null,
530
+ id: string,
531
+ body: Record<string, unknown> | string | null,
532
+ headers: Record<string, string> = {},
533
+ ): Promise<Response> {
534
+ const init: RequestInit = {
535
+ method: "POST",
536
+ headers: {
537
+ "content-type": "application/json",
538
+ ...(bearer ? { authorization: `Bearer ${bearer}` } : {}),
539
+ ...headers,
540
+ },
541
+ body: body === null ? undefined : typeof body === "string" ? body : JSON.stringify(body),
542
+ };
543
+ return await handleResetUserPassword(req(`/api/users/${id}/reset-password`, init), id, deps());
544
+ }
545
+
546
+ test("401 with no Authorization header", async () => {
547
+ const res = await post(null, "some-id", { new_password: "twelvechars1" });
548
+ expect(res.status).toBe(401);
549
+ });
550
+
551
+ test("403 when bearer lacks parachute:host:admin", async () => {
552
+ const { bearer } = await makeAdminBearer(["other:scope"]);
553
+ const res = await post(bearer, "some-id", { new_password: "twelvechars1" });
554
+ expect(res.status).toBe(403);
555
+ });
556
+
557
+ test("405 on GET", async () => {
558
+ const { bearer } = await makeAdminBearer();
559
+ const res = await handleResetUserPassword(
560
+ withBearer("/api/users/some-id/reset-password", bearer, { method: "GET" }),
561
+ "some-id",
562
+ deps(),
563
+ );
564
+ expect(res.status).toBe(405);
565
+ });
566
+
567
+ test("404 when target user does not exist", async () => {
568
+ const { bearer } = await makeAdminBearer();
569
+ const res = await post(bearer, "no-such-id", { new_password: "twelvechars1" });
570
+ expect(res.status).toBe(404);
571
+ const body = (await res.json()) as { error: string };
572
+ expect(body.error).toBe("not_found");
573
+ });
574
+
575
+ test("403 cannot_reset_first_admin when targeting the first admin", async () => {
576
+ const { bearer, userId } = await makeAdminBearer();
577
+ const res = await post(bearer, userId, { new_password: "twelvechars1" });
578
+ expect(res.status).toBe(403);
579
+ const body = (await res.json()) as { error: string };
580
+ expect(body.error).toBe("cannot_reset_first_admin");
581
+ // First-admin row's hash + password_changed bit untouched.
582
+ const fresh = getUserById(harness.db, userId);
583
+ expect(fresh?.passwordChanged).toBe(true);
584
+ });
585
+
586
+ test("400 invalid_password when new_password is too short (< 12 chars)", async () => {
587
+ const { bearer } = await makeAdminBearer();
588
+ const friend = await createUser(harness.db, "alice", "alice-strong-passphrase", {
589
+ allowMulti: true,
590
+ passwordChanged: true,
591
+ });
592
+ const res = await post(bearer, friend.id, { new_password: "short" });
593
+ expect(res.status).toBe(400);
594
+ const body = (await res.json()) as { error: string; error_description: string };
595
+ expect(body.error).toBe("invalid_password");
596
+ expect(body.error_description).toMatch(/12 characters/);
597
+ });
598
+
599
+ test("413 password_too_long when new_password > 256 chars (before argon2id touches it)", async () => {
600
+ const { bearer } = await makeAdminBearer();
601
+ const friend = await createUser(harness.db, "alice", "alice-strong-passphrase", {
602
+ allowMulti: true,
603
+ passwordChanged: true,
604
+ });
605
+ const huge = "a".repeat(300);
606
+ const t0 = Date.now();
607
+ const res = await post(bearer, friend.id, { new_password: huge });
608
+ const elapsed = Date.now() - t0;
609
+ expect(res.status).toBe(413);
610
+ const body = (await res.json()) as { error: string };
611
+ expect(body.error).toBe("password_too_long");
612
+ // Same liveness check as the create-user path: 300-char argon2id is
613
+ // hundreds of ms; cap-and-reject should complete in <200ms.
614
+ expect(elapsed).toBeLessThan(200);
615
+ });
616
+
617
+ test("400 invalid_request when content-type is not application/json", async () => {
618
+ const { bearer } = await makeAdminBearer();
619
+ const friend = await createUser(harness.db, "alice", "alice-strong-passphrase", {
620
+ allowMulti: true,
621
+ passwordChanged: true,
622
+ });
623
+ const res = await post(bearer, friend.id, "new_password=twelvechars1", {
624
+ "content-type": "application/x-www-form-urlencoded",
625
+ });
626
+ expect(res.status).toBe(400);
627
+ });
628
+
629
+ test("400 invalid_request when new_password missing", async () => {
630
+ const { bearer } = await makeAdminBearer();
631
+ const friend = await createUser(harness.db, "alice", "alice-strong-passphrase", {
632
+ allowMulti: true,
633
+ passwordChanged: true,
634
+ });
635
+ const res = await post(bearer, friend.id, {});
636
+ expect(res.status).toBe(400);
637
+ const body = (await res.json()) as { error: string };
638
+ expect(body.error).toBe("invalid_request");
639
+ });
640
+
641
+ test("happy path — rotates hash, flips password_changed=false, returns user shape", async () => {
642
+ const { bearer } = await makeAdminBearer();
643
+ const friend = await createUser(harness.db, "alice", "alice-strong-passphrase", {
644
+ allowMulti: true,
645
+ passwordChanged: true,
646
+ });
647
+ const oldHash = friend.passwordHash;
648
+ const res = await post(bearer, friend.id, { new_password: "new-temp-passphrase" });
649
+ expect(res.status).toBe(200);
650
+ const body = (await res.json()) as {
651
+ ok: boolean;
652
+ user: { id: string; password_changed: boolean; username: string };
653
+ };
654
+ expect(body.ok).toBe(true);
655
+ expect(body.user.id).toBe(friend.id);
656
+ expect(body.user.username).toBe("alice");
657
+ expect(body.user.password_changed).toBe(false);
658
+ // Echo body never carries the hash (or the new password).
659
+ expect(body.user).not.toHaveProperty("password_hash");
660
+ expect(body).not.toHaveProperty("new_password");
661
+ // Round-trip on the user row: new password works, old does not, hash
662
+ // moved, password_changed is now false (force-redirect on next login).
663
+ const fresh = getUserById(harness.db, friend.id);
664
+ expect(fresh).not.toBeNull();
665
+ expect(fresh?.passwordHash).not.toBe(oldHash);
666
+ expect(fresh?.passwordChanged).toBe(false);
667
+ expect(await verifyPassword(fresh!, "new-temp-passphrase")).toBe(true);
668
+ expect(await verifyPassword(fresh!, "alice-strong-passphrase")).toBe(false);
669
+ });
670
+
671
+ test("response body includes revocation_lag_seconds = 60 so clients can surface the propagation lag (smoke 2026-05-27 finding 3)", async () => {
672
+ // Resource servers cache the revocation list via scope-guard's
673
+ // REVOCATION_CACHE_TTL_MS = 60_000 — so even though hub marks the
674
+ // user's tokens revoked immediately and publishes them on the
675
+ // revocation list, vault/scribe/etc. may keep accepting the
676
+ // revoked token for up to 60 seconds. For the stolen-device
677
+ // recovery threat model that's a meaningful exposure window
678
+ // the admin needs to know about. Surface the lag in the
679
+ // response body so the SPA's success banner can warn operators.
680
+ const { bearer } = await makeAdminBearer();
681
+ const friend = await createUser(harness.db, "alice", "alice-strong-passphrase", {
682
+ allowMulti: true,
683
+ passwordChanged: true,
684
+ });
685
+ const res = await post(bearer, friend.id, { new_password: "new-temp-passphrase" });
686
+ expect(res.status).toBe(200);
687
+ const body = (await res.json()) as {
688
+ ok: boolean;
689
+ user: unknown;
690
+ revocation_lag_seconds: number;
691
+ };
692
+ expect(body.revocation_lag_seconds).toBe(60);
693
+ });
694
+
695
+ test("revokes the friend's existing tokens (pre-reset token row has revoked_at after)", async () => {
696
+ const { bearer } = await makeAdminBearer();
697
+ const friend = await createUser(harness.db, "alice", "alice-strong-passphrase", {
698
+ allowMulti: true,
699
+ passwordChanged: true,
700
+ });
701
+ const minted = await signAccessToken(harness.db, {
702
+ sub: friend.id,
703
+ scopes: ["vault:home:read"],
704
+ audience: "vault",
705
+ clientId: "notes-client",
706
+ issuer: ISSUER,
707
+ ttlSeconds: 600,
708
+ });
709
+ recordTokenMint(harness.db, {
710
+ jti: minted.jti,
711
+ createdVia: "operator_mint",
712
+ subject: friend.username,
713
+ userId: friend.id,
714
+ clientId: "notes-client",
715
+ scopes: ["vault:home:read"],
716
+ expiresAt: minted.expiresAt,
717
+ });
718
+ expect(findTokenRowByJti(harness.db, minted.jti)?.revokedAt).toBeNull();
719
+
720
+ const res = await post(bearer, friend.id, { new_password: "new-temp-passphrase" });
721
+ expect(res.status).toBe(200);
722
+
723
+ const row = findTokenRowByJti(harness.db, minted.jti);
724
+ expect(row?.revokedAt).not.toBeNull();
725
+ // User row sticks around (unlike delete), so user_id is NOT NULLed.
726
+ expect(row?.userId).toBe(friend.id);
727
+ });
728
+ });
729
+
480
730
  // ---------------------------------------------------------------------------
481
731
  // GET /api/users/vaults — vault-name list for the assigned-vault dropdown
482
732
  // ---------------------------------------------------------------------------
@@ -520,3 +770,130 @@ describe("handleListVaults", () => {
520
770
  expect(body.vaults).toEqual(["home", "scratch", "work"]);
521
771
  });
522
772
  });
773
+
774
+ // ---------------------------------------------------------------------------
775
+ // PATCH /api/users/:id/vaults — edit vault assignments (Phase 2 PR 2)
776
+ // ---------------------------------------------------------------------------
777
+
778
+ describe("handleUpdateUserVaults", () => {
779
+ async function patch(
780
+ bearer: string,
781
+ userId: string,
782
+ body: Record<string, unknown> | string,
783
+ headers: Record<string, string> = {},
784
+ ): Promise<Response> {
785
+ const init: RequestInit = {
786
+ method: "PATCH",
787
+ headers: {
788
+ "content-type": "application/json",
789
+ authorization: `Bearer ${bearer}`,
790
+ ...headers,
791
+ },
792
+ body: typeof body === "string" ? body : JSON.stringify(body),
793
+ };
794
+ return await handleUpdateUserVaults(req(`/api/users/${userId}/vaults`, init), userId, deps());
795
+ }
796
+
797
+ test("401 with no Authorization header", async () => {
798
+ const res = await handleUpdateUserVaults(
799
+ req("/api/users/some-id/vaults", {
800
+ method: "PATCH",
801
+ headers: { "content-type": "application/json" },
802
+ body: JSON.stringify({ assigned_vaults: [] }),
803
+ }),
804
+ "some-id",
805
+ deps(),
806
+ );
807
+ expect(res.status).toBe(401);
808
+ });
809
+
810
+ test("405 on non-PATCH", async () => {
811
+ const { bearer } = await makeAdminBearer();
812
+ const res = await handleUpdateUserVaults(
813
+ withBearer("/api/users/x/vaults", bearer, { method: "POST" }),
814
+ "x",
815
+ deps(),
816
+ );
817
+ expect(res.status).toBe(405);
818
+ });
819
+
820
+ test("404 when target user does not exist", async () => {
821
+ const { bearer } = await makeAdminBearer();
822
+ const res = await patch(bearer, "no-such-id", { assigned_vaults: [] });
823
+ expect(res.status).toBe(404);
824
+ });
825
+
826
+ test("403 cannot_edit_first_admin_vaults for the first admin", async () => {
827
+ // The bearer-minting helper creates the first admin already; mint
828
+ // again returns the same admin's id.
829
+ const { bearer, userId } = await makeAdminBearer();
830
+ const res = await patch(bearer, userId, { assigned_vaults: ["home"] });
831
+ expect(res.status).toBe(403);
832
+ const body = (await res.json()) as { error: string };
833
+ expect(body.error).toBe("cannot_edit_first_admin_vaults");
834
+ });
835
+
836
+ test("400 when assigned_vaults is missing", async () => {
837
+ const { bearer } = await makeAdminBearer();
838
+ const friend = await createUser(harness.db, "alice", "alice-strong-passphrase", {
839
+ allowMulti: true,
840
+ });
841
+ const res = await patch(bearer, friend.id, {});
842
+ expect(res.status).toBe(400);
843
+ });
844
+
845
+ test("400 when assigned_vaults entry is not a string", async () => {
846
+ const { bearer } = await makeAdminBearer();
847
+ const friend = await createUser(harness.db, "alice", "alice-strong-passphrase", {
848
+ allowMulti: true,
849
+ });
850
+ const res = await patch(bearer, friend.id, { assigned_vaults: ["ok", 7] });
851
+ expect(res.status).toBe(400);
852
+ });
853
+
854
+ test("400 assigned_vault_not_found when a vault doesn't exist in services.json", async () => {
855
+ harness.cleanup();
856
+ harness = makeHarness(manifestWithVaults("home"));
857
+ const { bearer } = await makeAdminBearer();
858
+ const friend = await createUser(harness.db, "alice", "alice-strong-passphrase", {
859
+ allowMulti: true,
860
+ });
861
+ const res = await patch(bearer, friend.id, { assigned_vaults: ["home", "ghost"] });
862
+ expect(res.status).toBe(400);
863
+ const body = (await res.json()) as { error: string };
864
+ expect(body.error).toBe("assigned_vault_not_found");
865
+ });
866
+
867
+ test("happy path — replaces the user's vault list and bumps updated_at", async () => {
868
+ harness.cleanup();
869
+ harness = makeHarness(manifestWithVaults("home", "work", "family"));
870
+ const { bearer } = await makeAdminBearer();
871
+ const friend = await createUser(harness.db, "alice", "alice-strong-passphrase", {
872
+ allowMulti: true,
873
+ assignedVaults: ["home"],
874
+ });
875
+ const res = await patch(bearer, friend.id, { assigned_vaults: ["work", "family"] });
876
+ expect(res.status).toBe(200);
877
+ const body = (await res.json()) as {
878
+ ok: boolean;
879
+ user: { id: string; assigned_vaults: string[]; updated_at: string };
880
+ };
881
+ expect(body.ok).toBe(true);
882
+ expect(new Set(body.user.assigned_vaults)).toEqual(new Set(["work", "family"]));
883
+ expect(body.user.assigned_vaults).not.toContain("home");
884
+ });
885
+
886
+ test("empty array clears every assignment", async () => {
887
+ harness.cleanup();
888
+ harness = makeHarness(manifestWithVaults("home", "work"));
889
+ const { bearer } = await makeAdminBearer();
890
+ const friend = await createUser(harness.db, "alice", "alice-strong-passphrase", {
891
+ allowMulti: true,
892
+ assignedVaults: ["home", "work"],
893
+ });
894
+ const res = await patch(bearer, friend.id, { assigned_vaults: [] });
895
+ expect(res.status).toBe(200);
896
+ const fresh = getUserById(harness.db, friend.id);
897
+ expect(fresh?.assignedVaults).toEqual([]);
898
+ });
899
+ });
@@ -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
  });
@@ -217,11 +217,13 @@ describe("cli per-subcommand help", () => {
217
217
  expect(stderr).toMatch(/parachute install vault/);
218
218
  });
219
219
 
220
- test("vault tokens create in non-TTY passes through without prompting", async () => {
221
- // Spawned-subprocess stdio is piped, so isTtyInteractive() returns false
222
- // and the command falls through to the passthrough. Clearing PATH forces
223
- // ENOENT same probe as the `vault no-args` test. If we regressed into
224
- // prompting, this subprocess would hang on stdin instead of exiting 127.
220
+ test("vault tokens create forwards verbatim to parachute-vault", async () => {
221
+ // The guided interactive wrapper was removed with the pvt_* DROP (vault
222
+ // #412 / hub#466) `vault tokens create` now always forwards transparently
223
+ // to parachute-vault (which exits 1 with migration guidance on a real box).
224
+ // Clearing PATH forces ENOENT same probe as the `vault no-args` test.
225
+ // If we ever re-introduced a hub-side prompt, this subprocess would hang on
226
+ // stdin instead of exiting 127.
225
227
  const proc = Bun.spawn([process.execPath, CLI, "vault", "tokens", "create"], {
226
228
  stdout: "pipe",
227
229
  stderr: "pipe",
@@ -59,10 +59,65 @@ describe("cloudflare detect", () => {
59
59
  }
60
60
  });
61
61
 
62
- test("install hint names brew on darwin and a URL elsewhere", () => {
63
- expect(cloudflaredInstallHint("darwin")).toContain("brew install cloudflared");
64
- expect(cloudflaredInstallHint("linux")).toContain(
65
- "developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads",
66
- );
62
+ describe("cloudflaredInstallHint", () => {
63
+ test("darwin: names brew + points at GitHub releases as fallback", () => {
64
+ const hint = cloudflaredInstallHint("darwin", "arm64");
65
+ expect(hint).toContain("brew install cloudflared");
66
+ expect(hint).toContain("https://github.com/cloudflare/cloudflared/releases/latest");
67
+ });
68
+
69
+ test("linux x64: writes the curl line with the amd64 artifact suffix", () => {
70
+ // Refresh of stale URLs (2026-05-27). Aaron hit this on a fresh
71
+ // Amazon Linux 2023 install — `sudo dnf install cloudflared`
72
+ // returned 'No match for argument: cloudflared', and the hub's
73
+ // hint pointed at developers.cloudflare.com paths that 404. The
74
+ // GitHub release is the reliable cross-distro path.
75
+ const hint = cloudflaredInstallHint("linux", "x64");
76
+ expect(hint).toContain("curl -L -o /usr/local/bin/cloudflared");
77
+ expect(hint).toContain(
78
+ "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64",
79
+ );
80
+ expect(hint).toContain("sudo chmod +x /usr/local/bin/cloudflared");
81
+ });
82
+
83
+ test("linux arm64: writes the arm64 artifact suffix", () => {
84
+ const hint = cloudflaredInstallHint("linux", "arm64");
85
+ expect(hint).toContain("cloudflared-linux-arm64");
86
+ });
87
+
88
+ test("linux arm (32-bit): writes the arm artifact suffix", () => {
89
+ const hint = cloudflaredInstallHint("linux", "arm");
90
+ expect(hint).toContain("cloudflared-linux-arm");
91
+ });
92
+
93
+ test("linux exotic arch: falls back to a generic GitHub releases pointer", () => {
94
+ // riscv64 / ppc64 / mips64 — no cloudflared artifact published, so
95
+ // we don't fabricate a 404-bound download URL; we point the user at
96
+ // the releases page and surface what their arch is so they can pick.
97
+ const hint = cloudflaredInstallHint("linux", "riscv64");
98
+ expect(hint).toContain("https://github.com/cloudflare/cloudflared/releases/latest");
99
+ expect(hint).toContain("riscv64");
100
+ expect(hint).not.toContain("curl -L -o /usr/local/bin/cloudflared");
101
+ });
102
+
103
+ test("no stale developers.cloudflare.com or pkg.cloudflare.com paths anywhere", () => {
104
+ // Aaron caught both URL shapes returning HTML/404 on 2026-05-27 —
105
+ // they had been the hub's installer instructions for months.
106
+ // Hard-assert they're gone so they don't regress.
107
+ for (const platform of ["darwin", "linux"] as const) {
108
+ for (const arch of ["x64", "arm64"] as const) {
109
+ const hint = cloudflaredInstallHint(platform, arch);
110
+ expect(hint).not.toContain("developers.cloudflare.com");
111
+ expect(hint).not.toContain("pkg.cloudflare.com");
112
+ }
113
+ }
114
+ });
115
+
116
+ test("non-Linux, non-darwin platform: GitHub releases pointer with no curl line", () => {
117
+ const hint = cloudflaredInstallHint("win32", "x64");
118
+ expect(hint).toContain("https://github.com/cloudflare/cloudflared/releases/latest");
119
+ expect(hint).not.toContain("brew install");
120
+ expect(hint).not.toContain("curl -L -o");
121
+ });
67
122
  });
68
123
  });