@openparachute/hub 0.7.3-rc.1 → 0.7.3-rc.11

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 (65) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/admin-agent-grants.test.ts +113 -0
  3. package/src/__tests__/admin-vaults.test.ts +20 -5
  4. package/src/__tests__/api-modules.test.ts +65 -10
  5. package/src/__tests__/api-vault-caps.test.ts +232 -0
  6. package/src/__tests__/cli.test.ts +20 -0
  7. package/src/__tests__/hub-command.test.ts +136 -0
  8. package/src/__tests__/hub-origins-env-set.test.ts +273 -0
  9. package/src/__tests__/hub-server.test.ts +240 -0
  10. package/src/__tests__/init.test.ts +148 -0
  11. package/src/__tests__/jwt-sign.test.ts +79 -0
  12. package/src/__tests__/module-manifest.test.ts +3 -1
  13. package/src/__tests__/oauth-handlers.test.ts +413 -5
  14. package/src/__tests__/public-signup.test.ts +619 -0
  15. package/src/__tests__/rate-limit.test.ts +86 -0
  16. package/src/__tests__/scribe-config.test.ts +117 -0
  17. package/src/__tests__/serve-boot.test.ts +45 -0
  18. package/src/__tests__/serve.test.ts +67 -1
  19. package/src/__tests__/service-spec-discovery.test.ts +22 -2
  20. package/src/__tests__/setup-wizard.test.ts +18 -0
  21. package/src/__tests__/setup.test.ts +129 -15
  22. package/src/__tests__/two-factor-flow.test.ts +94 -0
  23. package/src/__tests__/users.test.ts +33 -0
  24. package/src/__tests__/vault-caps.test.ts +89 -0
  25. package/src/__tests__/vault-hub-origin-env.test.ts +38 -4
  26. package/src/__tests__/wizard-transcription.test.ts +334 -0
  27. package/src/__tests__/wizard.test.ts +146 -0
  28. package/src/account-setup.ts +118 -32
  29. package/src/admin-agent-grants.ts +51 -3
  30. package/src/admin-handlers.ts +53 -16
  31. package/src/admin-login-ui.ts +30 -4
  32. package/src/admin-vaults.ts +9 -1
  33. package/src/api-invites.ts +163 -3
  34. package/src/api-modules-ops.ts +12 -0
  35. package/src/api-modules.ts +47 -13
  36. package/src/api-users.ts +3 -0
  37. package/src/api-vault-caps.ts +206 -0
  38. package/src/cli.ts +35 -1
  39. package/src/commands/hub.ts +173 -0
  40. package/src/commands/init.ts +73 -2
  41. package/src/commands/serve-boot.ts +16 -2
  42. package/src/commands/serve.ts +39 -3
  43. package/src/commands/setup.ts +31 -6
  44. package/src/commands/wizard-transcription.ts +296 -0
  45. package/src/commands/wizard.ts +73 -3
  46. package/src/help.ts +8 -0
  47. package/src/hub-db.ts +108 -1
  48. package/src/hub-origin.ts +64 -0
  49. package/src/hub-server.ts +73 -0
  50. package/src/invites.ts +155 -31
  51. package/src/jwt-sign.ts +72 -3
  52. package/src/module-manifest.ts +23 -9
  53. package/src/oauth-flows-store.ts +12 -0
  54. package/src/oauth-handlers.ts +145 -20
  55. package/src/rate-limit.ts +111 -20
  56. package/src/scribe-config.ts +145 -0
  57. package/src/service-spec.ts +31 -12
  58. package/src/setup-wizard.ts +37 -4
  59. package/src/users.ts +62 -3
  60. package/src/vault-caps.ts +109 -0
  61. package/src/vault-hub-origin-env.ts +109 -16
  62. package/web/ui/dist/assets/{index-DR6R8EFf.css → index--728BX3j.css} +1 -1
  63. package/web/ui/dist/assets/index-DZzX_Enf.js +61 -0
  64. package/web/ui/dist/index.html +2 -2
  65. package/web/ui/dist/assets/index-B5AUE359.js +0 -61
@@ -960,6 +960,154 @@ describe("hasNoDisplay heuristic (Fix 2 — headless browser-open guard)", () =>
960
960
  });
961
961
  });
962
962
 
963
+ describe("init --hub-origin (Caddy-direct zero-SSH boot, onboarding-streamline 2026-06-25)", () => {
964
+ test("persists the origin BEFORE ensureHub so modules spawn with it in one pass", async () => {
965
+ const h = makeHarness();
966
+ try {
967
+ const order: string[] = [];
968
+ const code = await init({
969
+ configDir: h.configDir,
970
+ ensureHubVersion: async () => ({
971
+ outcome: "match" as const,
972
+ installedVersion: "test",
973
+ messages: [],
974
+ }),
975
+ manifestPath: h.manifestPath,
976
+ log: () => {},
977
+ alive: () => false,
978
+ hubOrigin: "https://box.sslip.io",
979
+ // The load-bearing ordering assertion: the origin write MUST happen
980
+ // before the hub unit starts (which boots + spawns vault/scribe), so
981
+ // the boot-time issuer + child env pick it up without a restart.
982
+ setHubOriginImpl: (_dir, origin) => {
983
+ order.push(`set-origin:${origin}`);
984
+ },
985
+ ensureHub: async () => {
986
+ order.push("ensureHub");
987
+ writeHubPort(1939, h.configDir);
988
+ return { pid: 5555, port: 1939, started: true };
989
+ },
990
+ readExposeStateFn: () => undefined,
991
+ isTty: false,
992
+ platform: "linux",
993
+ installVaultModuleImpl: noopVaultInstall,
994
+ });
995
+ expect(code).toBe(0);
996
+ expect(order).toEqual(["set-origin:https://box.sslip.io", "ensureHub"]);
997
+ } finally {
998
+ h.cleanup();
999
+ }
1000
+ });
1001
+
1002
+ test("default impl writes hub_settings.hub_origin to the real DB", async () => {
1003
+ const h = makeHarness();
1004
+ try {
1005
+ const code = await init({
1006
+ configDir: h.configDir,
1007
+ ensureHubVersion: async () => ({
1008
+ outcome: "match" as const,
1009
+ installedVersion: "test",
1010
+ messages: [],
1011
+ }),
1012
+ manifestPath: h.manifestPath,
1013
+ log: () => {},
1014
+ alive: () => false,
1015
+ hubOrigin: "https://box.sslip.io",
1016
+ // No setHubOriginImpl override → exercise the production default
1017
+ // (openHubDb + setHubOrigin) against the harness's temp configDir.
1018
+ ensureHub: async () => {
1019
+ writeHubPort(1939, h.configDir);
1020
+ return { pid: 5555, port: 1939, started: true };
1021
+ },
1022
+ readExposeStateFn: () => undefined,
1023
+ isTty: false,
1024
+ platform: "linux",
1025
+ installVaultModuleImpl: noopVaultInstall,
1026
+ });
1027
+ expect(code).toBe(0);
1028
+ const { hubDbPath, openHubDb } = await import("../hub-db.ts");
1029
+ const { getHubOrigin } = await import("../hub-settings.ts");
1030
+ const db = openHubDb(hubDbPath(h.configDir));
1031
+ try {
1032
+ expect(getHubOrigin(db)).toBe("https://box.sslip.io");
1033
+ } finally {
1034
+ db.close();
1035
+ }
1036
+ } finally {
1037
+ h.cleanup();
1038
+ }
1039
+ });
1040
+
1041
+ test("no --hub-origin → no persistence call (laptop/loopback path unchanged)", async () => {
1042
+ const h = makeHarness();
1043
+ try {
1044
+ let setCalls = 0;
1045
+ const code = await init({
1046
+ configDir: h.configDir,
1047
+ ensureHubVersion: async () => ({
1048
+ outcome: "match" as const,
1049
+ installedVersion: "test",
1050
+ messages: [],
1051
+ }),
1052
+ manifestPath: h.manifestPath,
1053
+ log: () => {},
1054
+ alive: () => false,
1055
+ setHubOriginImpl: () => {
1056
+ setCalls++;
1057
+ },
1058
+ ensureHub: async () => {
1059
+ writeHubPort(1939, h.configDir);
1060
+ return { pid: 5555, port: 1939, started: true };
1061
+ },
1062
+ readExposeStateFn: () => undefined,
1063
+ isTty: false,
1064
+ platform: "linux",
1065
+ installVaultModuleImpl: noopVaultInstall,
1066
+ });
1067
+ expect(code).toBe(0);
1068
+ expect(setCalls).toBe(0);
1069
+ } finally {
1070
+ h.cleanup();
1071
+ }
1072
+ });
1073
+
1074
+ test("a persistence failure is non-fatal — init still reaches the wizard", async () => {
1075
+ const h = makeHarness();
1076
+ try {
1077
+ const logs: string[] = [];
1078
+ const code = await init({
1079
+ configDir: h.configDir,
1080
+ ensureHubVersion: async () => ({
1081
+ outcome: "match" as const,
1082
+ installedVersion: "test",
1083
+ messages: [],
1084
+ }),
1085
+ manifestPath: h.manifestPath,
1086
+ log: (l) => logs.push(l),
1087
+ alive: () => false,
1088
+ hubOrigin: "https://box.sslip.io",
1089
+ setHubOriginImpl: () => {
1090
+ throw new Error("disk full");
1091
+ },
1092
+ ensureHub: async () => {
1093
+ writeHubPort(1939, h.configDir);
1094
+ return { pid: 5555, port: 1939, started: true };
1095
+ },
1096
+ readExposeStateFn: () => undefined,
1097
+ isTty: false,
1098
+ platform: "linux",
1099
+ installVaultModuleImpl: noopVaultInstall,
1100
+ });
1101
+ expect(code).toBe(0);
1102
+ const joined = logs.join("\n");
1103
+ expect(joined).toContain("Couldn't persist the hub origin");
1104
+ expect(joined).toContain("http://127.0.0.1:1939/admin/");
1105
+ } finally {
1106
+ h.cleanup();
1107
+ }
1108
+ });
1109
+ });
1110
+
963
1111
  describe("init exposure chain", () => {
964
1112
  test("TTY + no exposure + no flags → prompt is shown", async () => {
965
1113
  const h = makeHarness();
@@ -10,7 +10,9 @@ import {
10
10
  RefreshTokenInsertError,
11
11
  findRefreshToken,
12
12
  findTokenRowByJti,
13
+ linkRotation,
13
14
  listActiveRevocations,
15
+ liveFamilyRefreshRows,
14
16
  recordTokenMint,
15
17
  revokeTokenByJti,
16
18
  signAccessToken,
@@ -623,3 +625,80 @@ describe("token registry (hub#212 Phase 1)", () => {
623
625
  }
624
626
  });
625
627
  });
628
+
629
+ // hub#685 — rotation-grace primitives. The grace decision in
630
+ // `handleTokenRefresh` is built on these two helpers; unit-cover them
631
+ // directly so the security-load-bearing predecessor check has a tight test.
632
+ describe("rotation grace primitives (hub#685)", () => {
633
+ test("rotatedTo is null on a freshly minted row, set by linkRotation", async () => {
634
+ const { db, cleanup } = makeDb();
635
+ try {
636
+ rotateSigningKey(db);
637
+ const u = await createUser(db, "owner", "pw");
638
+ signRefreshToken(db, {
639
+ jti: "pred",
640
+ userId: u.id,
641
+ clientId: "parachute-hub",
642
+ scopes: ["vault:read"],
643
+ familyId: "fam-1",
644
+ });
645
+ expect(findTokenRowByJti(db, "pred")?.rotatedTo).toBeNull();
646
+
647
+ signRefreshToken(db, {
648
+ jti: "succ",
649
+ userId: u.id,
650
+ clientId: "parachute-hub",
651
+ scopes: ["vault:read"],
652
+ familyId: "fam-1",
653
+ });
654
+ linkRotation(db, "pred", "succ");
655
+ expect(findTokenRowByJti(db, "pred")?.rotatedTo).toBe("succ");
656
+ // Successor itself has not rotated yet.
657
+ expect(findTokenRowByJti(db, "succ")?.rotatedTo).toBeNull();
658
+ } finally {
659
+ cleanup();
660
+ }
661
+ });
662
+
663
+ test("liveFamilyRefreshRows returns only un-revoked, un-expired refresh rows in the family", async () => {
664
+ const { db, cleanup } = makeDb();
665
+ try {
666
+ rotateSigningKey(db);
667
+ const u = await createUser(db, "owner", "pw");
668
+ const t0 = new Date("2026-06-24T00:00:00Z");
669
+ for (const jti of ["a", "b", "c"]) {
670
+ signRefreshToken(db, {
671
+ jti,
672
+ userId: u.id,
673
+ clientId: "parachute-hub",
674
+ scopes: ["vault:read"],
675
+ familyId: "fam-x",
676
+ now: () => t0,
677
+ });
678
+ }
679
+ // A different family — must never leak in.
680
+ signRefreshToken(db, {
681
+ jti: "other",
682
+ userId: u.id,
683
+ clientId: "parachute-hub",
684
+ scopes: ["vault:read"],
685
+ familyId: "fam-y",
686
+ now: () => t0,
687
+ });
688
+ // Revoke one of the three.
689
+ revokeTokenByJti(db, "b", t0);
690
+
691
+ const live = liveFamilyRefreshRows(db, "fam-x", t0)
692
+ .map((r) => r.jti)
693
+ .sort();
694
+ expect(live).toEqual(["a", "c"]);
695
+
696
+ // Evaluated PAST the 30-day TTL: every row in the family is expired,
697
+ // so none count as a live tip — the grace path can't rotate into one.
698
+ const afterExpiry = new Date(t0.getTime() + REFRESH_TOKEN_TTL_MS + 1);
699
+ expect(liveFamilyRefreshRows(db, "fam-x", afterExpiry)).toEqual([]);
700
+ } finally {
701
+ cleanup();
702
+ }
703
+ });
704
+ });
@@ -277,11 +277,13 @@ describe("validateModuleManifest", () => {
277
277
  // --- 2026-06-09 modular-UI architecture P1 fields: focus / configUiUrl /
278
278
  // adminCapabilities / events / actions. All optional + additive. ---
279
279
 
280
- test("focus accepts core / experimental and rejects anything else", () => {
280
+ test("focus accepts core / experimental / deprecated and rejects anything else", () => {
281
281
  expect(validateModuleManifest({ ...VALID, focus: "core" }, "x").focus).toBe("core");
282
282
  expect(validateModuleManifest({ ...VALID, focus: "experimental" }, "x").focus).toBe(
283
283
  "experimental",
284
284
  );
285
+ // `deprecated` tier (2026-06-25) — a module can self-declare it.
286
+ expect(validateModuleManifest({ ...VALID, focus: "deprecated" }, "x").focus).toBe("deprecated");
285
287
  // Absent stays absent (hub falls back to its default map downstream).
286
288
  expect(validateModuleManifest(VALID, "x").focus).toBeUndefined();
287
289
  expect(() => validateModuleManifest({ ...VALID, focus: "headline" }, "x")).toThrow(/focus/);