@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
@@ -8,11 +8,21 @@ import { signAccessToken } from "../jwt-sign.ts";
8
8
  import { upsertService, writeManifest } from "../services-manifest.ts";
9
9
  import { rotateSigningKey } from "../signing-keys.ts";
10
10
 
11
- /** Build the JSON shape parachute-vault create --json emits (PR #184). */
12
- function vaultCreateJson(name: string, token = `pvt_${name}_token`): string {
11
+ /**
12
+ * Build the JSON shape `parachute-vault create --json` emits (PR #184).
13
+ * Post the pvt_* DROP the `token` is a hub-issued access JWT (scoped
14
+ * `vault:<name>:admin`), and may be the empty string when the vault
15
+ * couldn't mint — in which case `token_guidance` carries the reason.
16
+ */
17
+ function vaultCreateJson(
18
+ name: string,
19
+ token = `hubjwt.${name}.access`,
20
+ tokenGuidance?: string,
21
+ ): string {
13
22
  return JSON.stringify({
14
23
  name,
15
24
  token,
25
+ ...(tokenGuidance ? { token_guidance: tokenGuidance } : {}),
16
26
  paths: {
17
27
  vault_dir: `/home/test/.parachute/vault/${name}`,
18
28
  vault_db: `/home/test/.parachute/vault/${name}/vault.db`,
@@ -404,7 +414,7 @@ describe("POST /vaults — orchestration", () => {
404
414
  );
405
415
  return {
406
416
  exitCode: 0,
407
- stdout: vaultCreateJson("work", "pvt_supersecret"),
417
+ stdout: vaultCreateJson("work", "hubjwt.work.access"),
408
418
  stderr: "",
409
419
  };
410
420
  };
@@ -420,7 +430,7 @@ describe("POST /vaults — orchestration", () => {
420
430
  token?: string;
421
431
  paths?: { vault_dir: string; vault_db: string; vault_config: string };
422
432
  };
423
- expect(body.token).toBe("pvt_supersecret");
433
+ expect(body.token).toBe("hubjwt.work.access");
424
434
  expect(body.paths).toEqual({
425
435
  vault_dir: "/home/test/.parachute/vault/work",
426
436
  vault_db: "/home/test/.parachute/vault/work/vault.db",
@@ -434,6 +444,62 @@ describe("POST /vaults — orchestration", () => {
434
444
  }
435
445
  });
436
446
 
447
+ test("201 forwards an empty token + token_guidance when the vault couldn't mint (post-DROP)", async () => {
448
+ // The vault emits `token: ""` + a `token_guidance` reason when no hub
449
+ // origin was reachable to mint against (e.g. loopback create). The hub
450
+ // must forward both verbatim so the SPA can render the
451
+ // created-but-no-token state instead of confusing it with a re-POST.
452
+ const h = makeHarness();
453
+ try {
454
+ const db = openHubDb(hubDbPath(h.dir));
455
+ try {
456
+ rotateSigningKey(db);
457
+ upsertService(
458
+ {
459
+ name: "parachute-vault",
460
+ port: 1940,
461
+ paths: ["/vault/default"],
462
+ health: "/health",
463
+ version: "0.3.5",
464
+ },
465
+ h.manifestPath,
466
+ );
467
+ const runCommand = async (_cmd: readonly string[]): Promise<RunResult> => {
468
+ upsertService(
469
+ {
470
+ name: "parachute-vault",
471
+ port: 1940,
472
+ paths: ["/vault/default", "/vault/work"],
473
+ health: "/health",
474
+ version: "0.3.5",
475
+ },
476
+ h.manifestPath,
477
+ );
478
+ return {
479
+ exitCode: 0,
480
+ stdout: vaultCreateJson("work", "", "no hub origin reachable to mint against"),
481
+ stderr: "",
482
+ };
483
+ };
484
+ const res = await call({
485
+ db,
486
+ manifestPath: h.manifestPath,
487
+ body: { name: "work" },
488
+ runCommand,
489
+ });
490
+ // Still a fresh create — HTTP 201, NOT 200.
491
+ expect(res.status).toBe(201);
492
+ const body = (await res.json()) as { token?: string; token_guidance?: string };
493
+ expect(body.token).toBe("");
494
+ expect(body.token_guidance).toBe("no hub origin reachable to mint against");
495
+ } finally {
496
+ db.close();
497
+ }
498
+ } finally {
499
+ h.cleanup();
500
+ }
501
+ });
502
+
437
503
  test("500 when `parachute-vault create --json` exits 0 but stdout is unparseable", async () => {
438
504
  const h = makeHarness();
439
505
  try {
@@ -25,6 +25,7 @@ import { join } from "node:path";
25
25
  import {
26
26
  handleAccountChangePasswordGet,
27
27
  handleAccountChangePasswordPost,
28
+ handleAccountHomeGet,
28
29
  markPasswordChanged,
29
30
  } from "../api-account.ts";
30
31
  import { CSRF_COOKIE_NAME, CSRF_FIELD_NAME } from "../csrf.ts";
@@ -256,7 +257,110 @@ describe("POST /account/change-password", () => {
256
257
  }
257
258
  });
258
259
 
259
- test("missing next defaults to /admin/vaults", async () => {
260
+ test("non-admin user with no next defaults to /account/ (no admin-shell flash)", async () => {
261
+ // Without this rewrite, a friend's change-password POST would 302 to
262
+ // /admin/vaults, the SPA would load, the 403 from
263
+ // /admin/host-admin-token would bounce them to /account/ — a visible
264
+ // two-hop flash. Mirror the login-redirect rewrite in admin-handlers.ts.
265
+ await createUser(harness.db, "operator", "operator-strong-passphrase");
266
+ const { cookie } = await sessionCookieFor(harness.db, "friend", "old-default-pw", {
267
+ passwordChanged: false,
268
+ allowMulti: true,
269
+ });
270
+ const { body, headers } = formBody({
271
+ [CSRF_FIELD_NAME]: TEST_CSRF,
272
+ current_password: "old-default-pw",
273
+ new_password: "user-chosen-strong-passphrase",
274
+ new_password_confirm: "user-chosen-strong-passphrase",
275
+ });
276
+ const req = new Request("http://hub.test/account/change-password", {
277
+ method: "POST",
278
+ headers: { ...headers, cookie },
279
+ body,
280
+ });
281
+ const res = await handleAccountChangePasswordPost(req, { db: harness.db });
282
+ expect(res.status).toBe(302);
283
+ expect(res.headers.get("location")).toBe("/account/");
284
+ });
285
+
286
+ test("non-admin user with next=/admin/users gets rewritten to /account/", async () => {
287
+ await createUser(harness.db, "operator", "operator-strong-passphrase");
288
+ const { cookie } = await sessionCookieFor(harness.db, "friend", "old-default-pw", {
289
+ passwordChanged: false,
290
+ allowMulti: true,
291
+ });
292
+ const { body, headers } = formBody({
293
+ [CSRF_FIELD_NAME]: TEST_CSRF,
294
+ current_password: "old-default-pw",
295
+ new_password: "user-chosen-strong-passphrase",
296
+ new_password_confirm: "user-chosen-strong-passphrase",
297
+ next: "/admin/users",
298
+ });
299
+ const req = new Request("http://hub.test/account/change-password", {
300
+ method: "POST",
301
+ headers: { ...headers, cookie },
302
+ body,
303
+ });
304
+ const res = await handleAccountChangePasswordPost(req, { db: harness.db });
305
+ expect(res.status).toBe(302);
306
+ expect(res.headers.get("location")).toBe("/account/");
307
+ });
308
+
309
+ test("non-admin user with non-admin next= passes through unchanged", async () => {
310
+ // Friends with a legitimate non-admin destination (e.g. /oauth/authorize
311
+ // mid-flow) should land where they intended — the rewrite only catches
312
+ // /admin/* targets.
313
+ await createUser(harness.db, "operator", "operator-strong-passphrase");
314
+ const { cookie } = await sessionCookieFor(harness.db, "friend", "old-default-pw", {
315
+ passwordChanged: false,
316
+ allowMulti: true,
317
+ });
318
+ const { body, headers } = formBody({
319
+ [CSRF_FIELD_NAME]: TEST_CSRF,
320
+ current_password: "old-default-pw",
321
+ new_password: "user-chosen-strong-passphrase",
322
+ new_password_confirm: "user-chosen-strong-passphrase",
323
+ next: "/oauth/authorize?client_id=abc",
324
+ });
325
+ const req = new Request("http://hub.test/account/change-password", {
326
+ method: "POST",
327
+ headers: { ...headers, cookie },
328
+ body,
329
+ });
330
+ const res = await handleAccountChangePasswordPost(req, { db: harness.db });
331
+ expect(res.status).toBe(302);
332
+ expect(res.headers.get("location")).toBe("/oauth/authorize?client_id=abc");
333
+ });
334
+
335
+ test("non-admin with exact next=/admin (no trailing slash) rewrites to /account/", async () => {
336
+ // Pins the exact-match arm of the prefix gate. Tests in #426 cover
337
+ // /admin/users (prefix match) and the no-next case (POST_CHANGE_DEFAULT
338
+ // → rewrite). This is the third arm.
339
+ await createUser(harness.db, "operator", "operator-strong-passphrase");
340
+ const { cookie } = await sessionCookieFor(harness.db, "friend", "old-default-pw", {
341
+ passwordChanged: false,
342
+ allowMulti: true,
343
+ });
344
+ const { body, headers } = formBody({
345
+ [CSRF_FIELD_NAME]: TEST_CSRF,
346
+ current_password: "old-default-pw",
347
+ new_password: "user-chosen-strong-passphrase",
348
+ new_password_confirm: "user-chosen-strong-passphrase",
349
+ next: "/admin",
350
+ });
351
+ const req = new Request("http://hub.test/account/change-password", {
352
+ method: "POST",
353
+ headers: { ...headers, cookie },
354
+ body,
355
+ });
356
+ const res = await handleAccountChangePasswordPost(req, { db: harness.db });
357
+ expect(res.status).toBe(302);
358
+ expect(res.headers.get("location")).toBe("/account/");
359
+ });
360
+
361
+ test("first admin with no next still defaults to /admin/vaults", async () => {
362
+ // Existing behavior — preserved by the non-admin gate. The first user
363
+ // is the admin under Phase 1; admin SPA is the intended landing.
260
364
  const { cookie } = await sessionCookieFor(harness.db, "newbie", "old-default-pw", {
261
365
  passwordChanged: false,
262
366
  });
@@ -628,3 +732,89 @@ describe("markPasswordChanged", () => {
628
732
  expect(after?.passwordChanged).toBe(true);
629
733
  });
630
734
  });
735
+
736
+ describe("handleAccountHomeGet", () => {
737
+ // Integration smoke for `GET /account/` — verifies session gating
738
+ // (302 → /login when missing) and the happy path (200 + rendered HTML
739
+ // with the user's vault). The pure-renderer assertions live in
740
+ // `account-home-ui.test.ts`; this suite pins handler-level wiring.
741
+
742
+ const HUB_ORIGIN = "https://hub.test";
743
+
744
+ test("302 → /login when no session cookie is present", async () => {
745
+ const req = new Request(`${HUB_ORIGIN}/account/`);
746
+ const res = handleAccountHomeGet(req, { db: harness.db, hubOrigin: HUB_ORIGIN });
747
+ expect(res.status).toBe(302);
748
+ const location = res.headers.get("location") ?? "";
749
+ // Round-trip /account/ as the `next` param so post-login lands back.
750
+ expect(location).toBe(`/login?next=${encodeURIComponent("/account/")}`);
751
+ });
752
+
753
+ test("200 + HTML for a signed-in friend with an assigned vault", async () => {
754
+ // Create the admin first (so the friend is NOT the first admin),
755
+ // then a friend with an assigned vault.
756
+ await createUser(harness.db, "admin", "admin-passphrase", { passwordChanged: true });
757
+ const friend = await createUser(harness.db, "alice", "alice-passphrase", {
758
+ allowMulti: true,
759
+ passwordChanged: true,
760
+ assignedVaults: ["alice"],
761
+ });
762
+ const session = createSession(harness.db, { userId: friend.id });
763
+ const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000));
764
+ const req = new Request(`${HUB_ORIGIN}/account/`, { headers: { cookie } });
765
+ const res = handleAccountHomeGet(req, { db: harness.db, hubOrigin: HUB_ORIGIN });
766
+ expect(res.status).toBe(200);
767
+ expect(res.headers.get("content-type")).toContain("text/html");
768
+ const html = await res.text();
769
+ expect(html).toContain("Welcome, alice");
770
+ // Vault name visible.
771
+ expect(html).toContain("<strong>alice</strong>");
772
+ // Notes CTA carries the hub-origin-encoded vault URL.
773
+ const encoded = encodeURIComponent(`${HUB_ORIGIN}/vault/alice`);
774
+ expect(html).toContain(`https://notes.parachute.computer/add?url=${encoded}`);
775
+ });
776
+
777
+ test("200 + admin branch when the first-admin signs in (no vault assignments)", async () => {
778
+ // The first-created user with no vault pin is the admin posture.
779
+ const admin = await createUser(harness.db, "admin", "admin-passphrase", {
780
+ passwordChanged: true,
781
+ });
782
+ const session = createSession(harness.db, { userId: admin.id });
783
+ const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000));
784
+ const req = new Request(`${HUB_ORIGIN}/account/`, { headers: { cookie } });
785
+ const res = handleAccountHomeGet(req, { db: harness.db, hubOrigin: HUB_ORIGIN });
786
+ expect(res.status).toBe(200);
787
+ const html = await res.text();
788
+ expect(html).toContain("Welcome, admin");
789
+ expect(html).toContain("hub administrator");
790
+ expect(html).toContain('href="/admin/"');
791
+ });
792
+
793
+ test("302 → /login when the session points at a deleted user", async () => {
794
+ // Stale-session shape: a session row outlives its user. The handler
795
+ // hands back to /login rather than rendering against null.
796
+ //
797
+ // Construction note: `deleteUser` drops the session as part of its
798
+ // cleanup transaction, and the sessions.user_id FK is RESTRICT, so
799
+ // we briefly drop FK enforcement to fabricate the orphan-session
800
+ // shape. The handler's job is robustness against an externally-
801
+ // induced orphan (e.g. a race between session-read and user-delete
802
+ // on a different connection); the test exercises that defensive
803
+ // branch directly.
804
+ const user = await createUser(harness.db, "ghost", "ghost-passphrase", {
805
+ passwordChanged: true,
806
+ });
807
+ const session = createSession(harness.db, { userId: user.id });
808
+ const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000));
809
+ harness.db.exec("PRAGMA foreign_keys = OFF");
810
+ try {
811
+ harness.db.prepare("DELETE FROM users WHERE id = ?").run(user.id);
812
+ } finally {
813
+ harness.db.exec("PRAGMA foreign_keys = ON");
814
+ }
815
+ const req = new Request(`${HUB_ORIGIN}/account/`, { headers: { cookie } });
816
+ const res = handleAccountHomeGet(req, { db: harness.db, hubOrigin: HUB_ORIGIN });
817
+ expect(res.status).toBe(302);
818
+ expect(res.headers.get("location")).toBe("/login");
819
+ });
820
+ });