@openparachute/hub 0.7.0 → 0.7.2-rc.1

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 (93) hide show
  1. package/README.md +13 -14
  2. package/package.json +1 -1
  3. package/src/__tests__/account-setup.test.ts +276 -6
  4. package/src/__tests__/admin-agent-grants.test.ts +1547 -0
  5. package/src/__tests__/{admin-channel-token.test.ts → admin-agent-token.test.ts} +32 -32
  6. package/src/__tests__/admin-connections-credentials.test.ts +1324 -0
  7. package/src/__tests__/admin-connections.test.ts +211 -57
  8. package/src/__tests__/admin-csrf-belt.test.ts +7 -7
  9. package/src/__tests__/admin-lock.test.ts +600 -0
  10. package/src/__tests__/admin-module-token.test.ts +36 -8
  11. package/src/__tests__/admin-vaults.test.ts +8 -8
  12. package/src/__tests__/api-invites.test.ts +166 -6
  13. package/src/__tests__/api-modules-ops.test.ts +17 -16
  14. package/src/__tests__/api-modules.test.ts +35 -36
  15. package/src/__tests__/api-ready.test.ts +2 -2
  16. package/src/__tests__/audience-gate.test.ts +752 -0
  17. package/src/__tests__/clients.test.ts +91 -0
  18. package/src/__tests__/grants-store.test.ts +219 -0
  19. package/src/__tests__/hub-db.test.ts +36 -0
  20. package/src/__tests__/hub-server.test.ts +275 -5
  21. package/src/__tests__/invites.test.ts +64 -1
  22. package/src/__tests__/lifecycle.test.ts +238 -3
  23. package/src/__tests__/migrate.test.ts +1 -1
  24. package/src/__tests__/module-manifest.test.ts +11 -11
  25. package/src/__tests__/oauth-client.test.ts +446 -0
  26. package/src/__tests__/oauth-flows-store.test.ts +141 -0
  27. package/src/__tests__/oauth-handlers.test.ts +124 -26
  28. package/src/__tests__/operator-token.test.ts +2 -2
  29. package/src/__tests__/scope-explanations.test.ts +3 -3
  30. package/src/__tests__/serve-boot.test.ts +14 -14
  31. package/src/__tests__/serve.test.ts +26 -0
  32. package/src/__tests__/service-spec-discovery.test.ts +26 -18
  33. package/src/__tests__/services-manifest.test.ts +60 -48
  34. package/src/__tests__/setup-gate.test.ts +52 -3
  35. package/src/__tests__/setup-wizard.test.ts +86 -280
  36. package/src/__tests__/setup.test.ts +1 -1
  37. package/src/__tests__/upgrade.test.ts +276 -0
  38. package/src/__tests__/vault-remove.test.ts +393 -0
  39. package/src/__tests__/ws-bridge.test.ts +573 -0
  40. package/src/__tests__/ws-connection-caps.test.ts +456 -0
  41. package/src/account-setup.ts +94 -23
  42. package/src/admin-agent-grants.ts +1365 -0
  43. package/src/admin-agent-token.ts +147 -0
  44. package/src/admin-connections.ts +980 -61
  45. package/src/admin-host-admin-token.ts +14 -1
  46. package/src/admin-lock.ts +281 -0
  47. package/src/admin-login-ui.ts +64 -15
  48. package/src/admin-module-token.ts +15 -7
  49. package/src/admin-vault-admin-token.ts +8 -1
  50. package/src/admin-vaults.ts +21 -12
  51. package/src/api-admin-lock.ts +335 -0
  52. package/src/api-invites.ts +92 -12
  53. package/src/api-modules-ops.ts +3 -2
  54. package/src/api-modules.ts +9 -9
  55. package/src/audience-gate.ts +268 -0
  56. package/src/chrome-strip.ts +8 -1
  57. package/src/cli.ts +13 -1
  58. package/src/clients.ts +88 -0
  59. package/src/commands/install.ts +7 -0
  60. package/src/commands/lifecycle.ts +187 -47
  61. package/src/commands/serve-boot.ts +5 -4
  62. package/src/commands/serve.ts +45 -19
  63. package/src/commands/setup.ts +4 -3
  64. package/src/commands/upgrade.ts +118 -2
  65. package/src/commands/vault-remove.ts +361 -0
  66. package/src/commands/wizard.ts +4 -4
  67. package/src/connections-store.ts +35 -5
  68. package/src/grants-store.ts +272 -0
  69. package/src/help.ts +17 -7
  70. package/src/host-admin-token-validation.ts +6 -2
  71. package/src/hub-db.ts +26 -1
  72. package/src/hub-server.ts +710 -39
  73. package/src/hub-settings.ts +23 -8
  74. package/src/invites.ts +69 -2
  75. package/src/jwt-sign.ts +11 -1
  76. package/src/module-manifest.ts +109 -2
  77. package/src/oauth-client.ts +497 -0
  78. package/src/oauth-flows-store.ts +163 -0
  79. package/src/oauth-handlers.ts +40 -13
  80. package/src/operator-token.ts +1 -1
  81. package/src/origin-check.ts +13 -5
  82. package/src/resource-binding.ts +4 -4
  83. package/src/scope-explanations.ts +3 -3
  84. package/src/service-spec.ts +56 -43
  85. package/src/services-manifest.ts +97 -0
  86. package/src/setup-wizard.ts +56 -240
  87. package/src/ws-bridge.ts +256 -0
  88. package/src/ws-connection-caps.ts +170 -0
  89. package/web/ui/dist/assets/index-B5AUE359.js +61 -0
  90. package/web/ui/dist/assets/{index-E_9wqjEm.css → index-DR6R8EFf.css} +1 -1
  91. package/web/ui/dist/index.html +2 -2
  92. package/src/admin-channel-token.ts +0 -135
  93. package/web/ui/dist/assets/index-C-XzMVqN.js +0 -61
@@ -28,6 +28,7 @@ import {
28
28
  listInvites,
29
29
  revokeInvite,
30
30
  revokeInvitesForVault,
31
+ usernameReservedByPendingInvite,
31
32
  } from "../invites.ts";
32
33
  import { createUser } from "../users.ts";
33
34
 
@@ -68,7 +69,7 @@ describe("issueInvite", () => {
68
69
  }
69
70
  });
70
71
 
71
- test("defaults: role=write, provision_vault=1, 7-day expiry", async () => {
72
+ test("defaults: role=write, provision_vault=1, 7-day expiry, no pre-named username", async () => {
72
73
  const { db, adminId, cleanup } = await makeDb();
73
74
  try {
74
75
  const now = new Date("2026-06-04T00:00:00Z");
@@ -76,12 +77,74 @@ describe("issueInvite", () => {
76
77
  expect(invite.role).toBe("write");
77
78
  expect(invite.provisionVault).toBe(true);
78
79
  expect(invite.vaultName).toBeNull();
80
+ expect(invite.username).toBeNull();
79
81
  const expiry = new Date(invite.expiresAt).getTime() - now.getTime();
80
82
  expect(Math.round(expiry / 1000)).toBe(DEFAULT_INVITE_TTL_SECONDS);
81
83
  } finally {
82
84
  cleanup();
83
85
  }
84
86
  });
87
+
88
+ test("pre-named username round-trips through the row", async () => {
89
+ const { db, adminId, cleanup } = await makeDb();
90
+ try {
91
+ const { rawToken, invite } = issueInvite(db, {
92
+ createdBy: adminId,
93
+ username: "jonathan",
94
+ vaultName: "shared",
95
+ provisionVault: false,
96
+ role: "read",
97
+ });
98
+ expect(invite.username).toBe("jonathan");
99
+ const found = findInviteByRawToken(db, rawToken);
100
+ expect(found?.username).toBe("jonathan");
101
+ expect(found?.vaultName).toBe("shared");
102
+ expect(found?.provisionVault).toBe(false);
103
+ expect(found?.role).toBe("read");
104
+ } finally {
105
+ cleanup();
106
+ }
107
+ });
108
+ });
109
+
110
+ describe("usernameReservedByPendingInvite", () => {
111
+ test("pending reserves; redeemed / revoked / expired / other-name do not", async () => {
112
+ const { db, adminId, cleanup } = await makeDb();
113
+ try {
114
+ const now = new Date("2026-06-10T00:00:00Z");
115
+ // Pending → reserved.
116
+ issueInvite(db, { createdBy: adminId, username: "pending-name", now: () => now });
117
+ expect(usernameReservedByPendingInvite(db, "pending-name", now)).toBe(true);
118
+ expect(usernameReservedByPendingInvite(db, "other-name", now)).toBe(false);
119
+ // Redeemed → free.
120
+ const redeemed = issueInvite(db, {
121
+ createdBy: adminId,
122
+ username: "used-name",
123
+ now: () => now,
124
+ });
125
+ consumeInvite(db, redeemed.invite.tokenHash, adminId, now);
126
+ expect(usernameReservedByPendingInvite(db, "used-name", now)).toBe(false);
127
+ // Revoked → free.
128
+ const revoked = issueInvite(db, {
129
+ createdBy: adminId,
130
+ username: "gone-name",
131
+ now: () => now,
132
+ });
133
+ revokeInvite(db, revoked.invite.tokenHash, now);
134
+ expect(usernameReservedByPendingInvite(db, "gone-name", now)).toBe(false);
135
+ // Expired → free.
136
+ issueInvite(db, {
137
+ createdBy: adminId,
138
+ username: "old-name",
139
+ expiresInSeconds: 60,
140
+ now: () => now,
141
+ });
142
+ const later = new Date(now.getTime() + 120_000);
143
+ expect(usernameReservedByPendingInvite(db, "old-name", later)).toBe(false);
144
+ } finally {
145
+ cleanup();
146
+ }
147
+ });
85
148
  });
86
149
 
87
150
  describe("findInviteByRawToken", () => {
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { mkdtempSync, openSync, rmSync, writeFileSync } from "node:fs";
2
+ import { mkdtempSync, openSync, rmSync, utimesSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import {
@@ -678,10 +678,37 @@ describe("group-aware kill / liveness (hub#88)", () => {
678
678
  });
679
679
 
680
680
  // ---------------------------------------------------------------------------
681
- // `parachute logs <svc>` unchanged by Phase 5b. Reads the per-service logfile
682
- // keyed by short name (the readers §7.5 keeps). Includes the internal `hub`.
681
+ // `parachute logs <svc>`. Under hub-as-supervisor (Phase 5b) a module's output
682
+ // is multiplexed into the HUB log with a `[<svc>] ` line prefix, so `logs <svc>`
683
+ // reads that stream filtered to the service (hub#652). The per-service logfile
684
+ // keyed by short name (the readers §7.5 keeps) survives as the legacy source
685
+ // when it's fresher than the hub log (pre-supervised installs). `logs hub`
686
+ // reads the hub log unfiltered.
683
687
  // ---------------------------------------------------------------------------
684
688
 
689
+ /** The supervisor's multiplexed hub-log shape (supervisor.ts pipeOutput). */
690
+ const INTERLEAVED_HUB_LOG =
691
+ "[vault] vault boot\n" +
692
+ "[scribe] scribe boot\n" +
693
+ "[vault] GET /vault/default/api/notes 200 7ms\n" +
694
+ "[surface] [app-dcr] client registered\n" +
695
+ "[vaultx] not vault's line\n" +
696
+ "[vault] sync ok\n";
697
+
698
+ const VAULT_LINES_STRIPPED = ["vault boot", "GET /vault/default/api/notes 200 7ms", "sync ok"];
699
+
700
+ function writeHubLog(configDir: string, content: string): string {
701
+ const path = ensureLogPath("hub", configDir);
702
+ writeFileSync(path, content);
703
+ return path;
704
+ }
705
+
706
+ /** Backdate a file's mtime so freshness comparisons are deterministic. */
707
+ function backdate(path: string, secondsAgo: number): void {
708
+ const t = new Date(Date.now() - secondsAgo * 1000);
709
+ utimesSync(path, t, t);
710
+ }
711
+
685
712
  describe("parachute logs", () => {
686
713
  test("hint when no log file exists", async () => {
687
714
  const h = makeHarness();
@@ -829,4 +856,212 @@ describe("parachute logs", () => {
829
856
  h.cleanup();
830
857
  }
831
858
  });
859
+
860
+ // ---- hub#652: supervised modules read the hub log's [svc]-prefixed stream ----
861
+
862
+ test("supervised module: reads the hub log filtered to its prefix, stripped (hub#652)", async () => {
863
+ const h = makeHarness();
864
+ try {
865
+ seedVault(h.manifestPath);
866
+ writeHubLog(h.configDir, INTERLEAVED_HUB_LOG);
867
+ // No per-service vault.log — the supervised steady state.
868
+ const log: string[] = [];
869
+ const code = await logs("vault", {
870
+ configDir: h.configDir,
871
+ manifestPath: h.manifestPath,
872
+ log: (l) => log.push(l),
873
+ });
874
+ expect(code).toBe(0);
875
+ // Exact-prefix match: `[vaultx]` noise excluded; `[vault] ` stripped.
876
+ expect(log).toEqual(VAULT_LINES_STRIPPED);
877
+ } finally {
878
+ h.cleanup();
879
+ }
880
+ });
881
+
882
+ test("stale per-service file + fresher hub log: the hub stream wins (the live hub#652 shape)", async () => {
883
+ const h = makeHarness();
884
+ try {
885
+ seedVault(h.manifestPath);
886
+ const legacy = ensureLogPath("vault", h.configDir);
887
+ writeFileSync(legacy, "stale pre-cutover line\n");
888
+ backdate(legacy, 3600);
889
+ writeHubLog(h.configDir, INTERLEAVED_HUB_LOG);
890
+ const log: string[] = [];
891
+ const code = await logs("vault", {
892
+ configDir: h.configDir,
893
+ manifestPath: h.manifestPath,
894
+ log: (l) => log.push(l),
895
+ });
896
+ expect(code).toBe(0);
897
+ expect(log).toEqual(VAULT_LINES_STRIPPED);
898
+ expect(log.join("\n")).not.toContain("stale pre-cutover line");
899
+ } finally {
900
+ h.cleanup();
901
+ }
902
+ });
903
+
904
+ test("per-service file fresher than the hub log: legacy file wins (pre-supervised install)", async () => {
905
+ const h = makeHarness();
906
+ try {
907
+ seedVault(h.manifestPath);
908
+ const hubLog = writeHubLog(h.configDir, "[vault] old supervised line\n");
909
+ backdate(hubLog, 3600);
910
+ const legacy = ensureLogPath("vault", h.configDir);
911
+ writeFileSync(legacy, "live detached line\n");
912
+ const log: string[] = [];
913
+ const code = await logs("vault", {
914
+ configDir: h.configDir,
915
+ manifestPath: h.manifestPath,
916
+ log: (l) => log.push(l),
917
+ });
918
+ expect(code).toBe(0);
919
+ expect(log).toEqual(["live detached line"]);
920
+ } finally {
921
+ h.cleanup();
922
+ }
923
+ });
924
+
925
+ test("lines cap applies to the FILTERED set, not raw hub-log lines", async () => {
926
+ const h = makeHarness();
927
+ try {
928
+ seedVault(h.manifestPath);
929
+ writeHubLog(h.configDir, INTERLEAVED_HUB_LOG);
930
+ const log: string[] = [];
931
+ const code = await logs("vault", {
932
+ configDir: h.configDir,
933
+ manifestPath: h.manifestPath,
934
+ lines: 2,
935
+ log: (l) => log.push(l),
936
+ });
937
+ expect(code).toBe(0);
938
+ expect(log).toEqual(VAULT_LINES_STRIPPED.slice(-2));
939
+ } finally {
940
+ h.cleanup();
941
+ }
942
+ });
943
+
944
+ test("hub log has no lines for the service + no per-service file: start hint", async () => {
945
+ const h = makeHarness();
946
+ try {
947
+ seedVault(h.manifestPath);
948
+ writeHubLog(h.configDir, "[scribe] scribe boot\n");
949
+ const log: string[] = [];
950
+ const code = await logs("vault", {
951
+ configDir: h.configDir,
952
+ manifestPath: h.manifestPath,
953
+ log: (l) => log.push(l),
954
+ });
955
+ expect(code).toBe(0);
956
+ expect(log.join("\n")).toMatch(/no logs yet for vault/);
957
+ } finally {
958
+ h.cleanup();
959
+ }
960
+ });
961
+
962
+ test("hub log has no lines for the service + per-service file exists: legacy shown with a note", async () => {
963
+ const h = makeHarness();
964
+ try {
965
+ seedVault(h.manifestPath);
966
+ const legacy = ensureLogPath("vault", h.configDir);
967
+ writeFileSync(legacy, "old detached line\n");
968
+ backdate(legacy, 3600);
969
+ writeHubLog(h.configDir, "[scribe] scribe boot\n");
970
+ const log: string[] = [];
971
+ const code = await logs("vault", {
972
+ configDir: h.configDir,
973
+ manifestPath: h.manifestPath,
974
+ log: (l) => log.push(l),
975
+ });
976
+ expect(code).toBe(0);
977
+ // The note distinguishes the stale per-service file from the live
978
+ // stream — the exact "stale logs presented as current" trap in hub#652.
979
+ expect(log[0]).toMatch(/no vault lines in the hub log/);
980
+ expect(log).toContain("old detached line");
981
+ } finally {
982
+ h.cleanup();
983
+ }
984
+ });
985
+
986
+ test("follow mode filters the hub stream and strips the prefix (hub#652)", async () => {
987
+ const h = makeHarness();
988
+ try {
989
+ seedVault(h.manifestPath);
990
+ writeHubLog(h.configDir, INTERLEAVED_HUB_LOG);
991
+ const encoder = new TextEncoder();
992
+ let streamedPath: string | undefined;
993
+ const followStream = (path: string): ReadableStream<Uint8Array> => {
994
+ streamedPath = path;
995
+ return new ReadableStream<Uint8Array>({
996
+ start(controller) {
997
+ controller.enqueue(encoder.encode("[vault] live one\n[scribe] noise\n"));
998
+ // Split a line across chunks to exercise the line buffer.
999
+ controller.enqueue(encoder.encode("[vault] live "));
1000
+ controller.enqueue(encoder.encode("two\n"));
1001
+ controller.close();
1002
+ },
1003
+ });
1004
+ };
1005
+ const log: string[] = [];
1006
+ const code = await logs("vault", {
1007
+ configDir: h.configDir,
1008
+ manifestPath: h.manifestPath,
1009
+ follow: true,
1010
+ followStream,
1011
+ log: (l) => log.push(l),
1012
+ });
1013
+ expect(code).toBe(0);
1014
+ expect(streamedPath).toContain("hub.log");
1015
+ expect(log).toEqual([...VAULT_LINES_STRIPPED, "live one", "live two"]);
1016
+ } finally {
1017
+ h.cleanup();
1018
+ }
1019
+ });
1020
+
1021
+ test("follow mode with a fresher per-service file tails THAT file via tail -f", async () => {
1022
+ const h = makeHarness();
1023
+ try {
1024
+ seedVault(h.manifestPath);
1025
+ const hubLog = writeHubLog(h.configDir, "[vault] old supervised line\n");
1026
+ backdate(hubLog, 3600);
1027
+ const legacy = ensureLogPath("vault", h.configDir);
1028
+ writeFileSync(legacy, "live detached line\n");
1029
+ const spawned: string[][] = [];
1030
+ const code = await logs("vault", {
1031
+ configDir: h.configDir,
1032
+ manifestPath: h.manifestPath,
1033
+ follow: true,
1034
+ tailSpawner: {
1035
+ spawn(cmd) {
1036
+ spawned.push([...cmd]);
1037
+ return 12345;
1038
+ },
1039
+ },
1040
+ log: () => {},
1041
+ });
1042
+ expect(code).toBe(0);
1043
+ expect(spawned).toHaveLength(1);
1044
+ expect(spawned[0]?.[0]).toBe("tail");
1045
+ expect(spawned[0]?.at(-1)).toBe(legacy);
1046
+ } finally {
1047
+ h.cleanup();
1048
+ }
1049
+ });
1050
+
1051
+ test("logs hub: stays unfiltered — module-prefixed lines included", async () => {
1052
+ const h = makeHarness();
1053
+ try {
1054
+ writeHubLog(h.configDir, INTERLEAVED_HUB_LOG);
1055
+ const log: string[] = [];
1056
+ const code = await logs("hub", {
1057
+ configDir: h.configDir,
1058
+ manifestPath: h.manifestPath,
1059
+ log: (l) => log.push(l),
1060
+ });
1061
+ expect(code).toBe(0);
1062
+ expect(log).toEqual(INTERLEAVED_HUB_LOG.replace(/\n$/, "").split("\n"));
1063
+ } finally {
1064
+ h.cleanup();
1065
+ }
1066
+ });
832
1067
  });
@@ -56,7 +56,7 @@ describe("safelistEntries", () => {
56
56
  expect(s.has("vault")).toBe(true);
57
57
  expect(s.has("notes")).toBe(true);
58
58
  expect(s.has("scribe")).toBe(true);
59
- expect(s.has("channel")).toBe(true);
59
+ expect(s.has("agent")).toBe(true);
60
60
  // Internal
61
61
  expect(s.has("hub")).toBe(true);
62
62
  // CLI state
@@ -40,13 +40,13 @@ describe("validateModuleManifest", () => {
40
40
  key: "link-to-vault",
41
41
  title: "Link a channel to a vault",
42
42
  description: "Back a channel with a vault.",
43
- requestedBy: "channel",
43
+ requestedBy: "agent",
44
44
  source: {
45
45
  module: "vault",
46
46
  event: "note.created",
47
- filter: { tags: ["#channel-message/inbound"] },
47
+ filter: { tags: ["#agent-message/inbound"] },
48
48
  },
49
- sink: { module: "channel", action: "message.deliver" },
49
+ sink: { module: "agent", action: "message.deliver" },
50
50
  parameters: [
51
51
  { key: "vault", target: "source.vault", title: "Vault" },
52
52
  { key: "channel", target: "sink.params.channel", example: "eng" },
@@ -61,9 +61,9 @@ describe("validateModuleManifest", () => {
61
61
  expect(t?.source).toEqual({
62
62
  module: "vault",
63
63
  event: "note.created",
64
- filter: { tags: ["#channel-message/inbound"] },
64
+ filter: { tags: ["#agent-message/inbound"] },
65
65
  });
66
- expect(t?.sink).toEqual({ module: "channel", action: "message.deliver" });
66
+ expect(t?.sink).toEqual({ module: "agent", action: "message.deliver" });
67
67
  expect(t?.parameters?.[1]).toEqual({
68
68
  key: "channel",
69
69
  target: "sink.params.channel",
@@ -119,7 +119,7 @@ describe("validateModuleManifest", () => {
119
119
  key: "k",
120
120
  title: "T",
121
121
  source: { module: "vault", event: "note.created" },
122
- sink: { module: "channel", action: "message.deliver" },
122
+ sink: { module: "agent", action: "message.deliver" },
123
123
  parameters: [{ key: "p" }],
124
124
  },
125
125
  ],
@@ -417,24 +417,24 @@ describe("validateModuleManifest", () => {
417
417
  "x",
418
418
  ),
419
419
  ).toThrow(/must be namespaced as "<name>:<verb>"/);
420
- // A matching-namespace scope still validates — the real channel case
421
- // (channel.message.deliver → channel:send).
420
+ // A matching-namespace scope still validates — the real agent case
421
+ // (message.deliver → agent:send).
422
422
  const okm = validateModuleManifest(
423
423
  {
424
424
  ...VALID,
425
- name: "channel",
425
+ name: "agent",
426
426
  actions: [
427
427
  {
428
428
  key: "message.deliver",
429
429
  title: "Deliver",
430
430
  endpoint: "/api/vault/inbound",
431
- scope: "channel:send",
431
+ scope: "agent:send",
432
432
  },
433
433
  ],
434
434
  },
435
435
  "x",
436
436
  );
437
- expect(okm.actions?.[0]?.scope).toBe("channel:send");
437
+ expect(okm.actions?.[0]?.scope).toBe("agent:send");
438
438
  });
439
439
 
440
440
  test("uiUrl accepts a leading-slash path (Phase D)", () => {