@openparachute/hub 0.6.5-rc.8 → 0.7.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 (69) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/account-setup.test.ts +310 -6
  3. package/src/__tests__/account-vault-admin-token.test.ts +35 -3
  4. package/src/__tests__/admin-channel-token.test.ts +173 -0
  5. package/src/__tests__/admin-connections-credentials.test.ts +1320 -0
  6. package/src/__tests__/admin-connections.test.ts +1154 -0
  7. package/src/__tests__/admin-csrf-belt.test.ts +346 -0
  8. package/src/__tests__/admin-module-token.test.ts +311 -0
  9. package/src/__tests__/admin-vaults.test.ts +590 -0
  10. package/src/__tests__/api-invites.test.ts +166 -6
  11. package/src/__tests__/api-modules-ops.test.ts +70 -5
  12. package/src/__tests__/api-modules.test.ts +262 -79
  13. package/src/__tests__/audience-gate.test.ts +752 -0
  14. package/src/__tests__/hub-db.test.ts +36 -0
  15. package/src/__tests__/hub-server.test.ts +585 -21
  16. package/src/__tests__/invites.test.ts +91 -1
  17. package/src/__tests__/lifecycle.test.ts +238 -3
  18. package/src/__tests__/module-manifest.test.ts +305 -8
  19. package/src/__tests__/serve-boot.test.ts +133 -2
  20. package/src/__tests__/service-spec-discovery.test.ts +109 -0
  21. package/src/__tests__/setup-gate.test.ts +13 -7
  22. package/src/__tests__/setup-wizard.test.ts +228 -1
  23. package/src/__tests__/vault-name.test.ts +20 -5
  24. package/src/__tests__/well-known.test.ts +44 -8
  25. package/src/__tests__/ws-bridge.test.ts +573 -0
  26. package/src/__tests__/ws-connection-caps.test.ts +456 -0
  27. package/src/account-setup.ts +94 -23
  28. package/src/account-vault-admin-token.ts +43 -14
  29. package/src/admin-channel-token.ts +135 -0
  30. package/src/admin-connections.ts +1882 -0
  31. package/src/admin-login-ui.ts +64 -15
  32. package/src/admin-module-token.ts +197 -0
  33. package/src/admin-vaults.ts +399 -12
  34. package/src/api-hub-upgrade.ts +4 -3
  35. package/src/api-invites.ts +92 -12
  36. package/src/api-modules-ops.ts +41 -16
  37. package/src/api-modules.ts +238 -116
  38. package/src/api-tokens.ts +8 -5
  39. package/src/audience-gate.ts +268 -0
  40. package/src/chrome-strip.ts +8 -1
  41. package/src/commands/lifecycle.ts +187 -47
  42. package/src/commands/serve-boot.ts +80 -3
  43. package/src/commands/setup.ts +4 -4
  44. package/src/connections-store.ts +191 -0
  45. package/src/grants.ts +50 -0
  46. package/src/help.ts +13 -6
  47. package/src/host-admin-token-validation.ts +6 -2
  48. package/src/hub-db.ts +26 -1
  49. package/src/hub-server.ts +849 -70
  50. package/src/invites.ts +91 -2
  51. package/src/jwt-sign.ts +47 -1
  52. package/src/module-manifest.ts +536 -23
  53. package/src/origin-check.ts +109 -0
  54. package/src/proxy-error-ui.ts +1 -1
  55. package/src/service-spec.ts +132 -41
  56. package/src/services-manifest.ts +97 -0
  57. package/src/setup-wizard.ts +68 -6
  58. package/src/users.ts +11 -0
  59. package/src/vault-name.ts +27 -7
  60. package/src/well-known.ts +41 -33
  61. package/src/ws-bridge.ts +256 -0
  62. package/src/ws-connection-caps.ts +170 -0
  63. package/web/ui/dist/assets/index-Cxtod68O.js +61 -0
  64. package/web/ui/dist/assets/index-E_9wqjEm.css +1 -0
  65. package/web/ui/dist/index.html +2 -2
  66. package/src/__tests__/api-modules-config.test.ts +0 -882
  67. package/src/api-modules-config.ts +0 -421
  68. package/web/ui/dist/assets/index-BYYUeLGA.css +0 -1
  69. package/web/ui/dist/assets/index-D3cDUOOj.js +0 -61
@@ -27,6 +27,8 @@ import {
27
27
  issueInvite,
28
28
  listInvites,
29
29
  revokeInvite,
30
+ revokeInvitesForVault,
31
+ usernameReservedByPendingInvite,
30
32
  } from "../invites.ts";
31
33
  import { createUser } from "../users.ts";
32
34
 
@@ -67,7 +69,7 @@ describe("issueInvite", () => {
67
69
  }
68
70
  });
69
71
 
70
- 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 () => {
71
73
  const { db, adminId, cleanup } = await makeDb();
72
74
  try {
73
75
  const now = new Date("2026-06-04T00:00:00Z");
@@ -75,12 +77,74 @@ describe("issueInvite", () => {
75
77
  expect(invite.role).toBe("write");
76
78
  expect(invite.provisionVault).toBe(true);
77
79
  expect(invite.vaultName).toBeNull();
80
+ expect(invite.username).toBeNull();
78
81
  const expiry = new Date(invite.expiresAt).getTime() - now.getTime();
79
82
  expect(Math.round(expiry / 1000)).toBe(DEFAULT_INVITE_TTL_SECONDS);
80
83
  } finally {
81
84
  cleanup();
82
85
  }
83
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
+ });
84
148
  });
85
149
 
86
150
  describe("findInviteByRawToken", () => {
@@ -183,6 +247,32 @@ describe("revokeInvite", () => {
183
247
  });
184
248
  });
185
249
 
250
+ describe("revokeInvitesForVault (B1 cascade step)", () => {
251
+ test("a NULL-vault_name invite (redeemer-named flow) is NOT revoked by any vault's cascade", async () => {
252
+ // The cascade invalidates invites PINNED to the deleted vault — an
253
+ // unpinned invite (vault_name NULL: the redeemer names their own vault)
254
+ // can't resurrect a specific name, so it must survive every vault's
255
+ // delete. SQL `vault_name = ?` never matches NULL; pin that boundary.
256
+ const { db, adminId, cleanup } = await makeDb();
257
+ try {
258
+ const unpinned = issueInvite(db, { createdBy: adminId }); // vault_name NULL
259
+ const pinned = issueInvite(db, { createdBy: adminId, vaultName: "work" });
260
+
261
+ expect(revokeInvitesForVault(db, "work")).toBe(1);
262
+ // The pinned invite is revoked; the unpinned one rides on untouched.
263
+ expect(findInviteByRawToken(db, pinned.rawToken)?.revokedAt).not.toBeNull();
264
+ expect(findInviteByRawToken(db, unpinned.rawToken)?.revokedAt).toBeNull();
265
+
266
+ // A second sweep (or another vault's sweep) finds nothing more.
267
+ expect(revokeInvitesForVault(db, "work")).toBe(0);
268
+ expect(revokeInvitesForVault(db, "other")).toBe(0);
269
+ expect(findInviteByRawToken(db, unpinned.rawToken)?.revokedAt).toBeNull();
270
+ } finally {
271
+ cleanup();
272
+ }
273
+ });
274
+ });
275
+
186
276
  describe("inviteStatus / listInvites", () => {
187
277
  test("derives pending / redeemed / expired / revoked", async () => {
188
278
  const { db, adminId, cleanup } = await makeDb();
@@ -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
  });