@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
@@ -68,6 +68,24 @@ function deps() {
68
68
  return { db: harness.db, issuer: ISSUER, manifestPath: harness.manifestPath };
69
69
  }
70
70
 
71
+ /** Register an existing vault in the harness manifest (for shared-vault invites). */
72
+ function seedVaultInManifest(name: string) {
73
+ writeFileSync(
74
+ harness.manifestPath,
75
+ JSON.stringify({
76
+ services: [
77
+ {
78
+ name: "parachute-vault",
79
+ port: 4101,
80
+ paths: [`/vault/${name}`],
81
+ health: "/health",
82
+ version: "0.0.0-test",
83
+ },
84
+ ],
85
+ }),
86
+ );
87
+ }
88
+
71
89
  function withBearer(path: string, bearer: string, init: RequestInit = {}): Request {
72
90
  const headers = new Headers(init.headers ?? {});
73
91
  headers.set("authorization", `Bearer ${bearer}`);
@@ -121,23 +139,64 @@ describe("POST /api/invites", () => {
121
139
  expect(res.status).toBe(400);
122
140
  });
123
141
 
124
- test("400 rejects a shared-vault invite (provision_vault=false + vault_name)", async () => {
125
- // Defense in depth (FIX-1): assigning a redeemer to a PRE-EXISTING vault
126
- // as owner-admin is a cross-tenant breach; shared-vault invites aren't
127
- // supported yet, so the create handler refuses this combination outright.
142
+ test("shared-vault invite (provision_vault=false + EXISTING vault_name) → 201 at the given role", async () => {
143
+ // The Adam/Jonathan shape: read-only access to a vault the admin already
144
+ // built. Issuing is host:admin-gated the same authority that can assign
145
+ // any user to any vault via POST /api/users.
146
+ seedVaultInManifest("jonathan-vault");
128
147
  const bearer = await makeAdminBearer();
129
148
  const res = await handleCreateInvite(
130
149
  withBearer("/api/invites", bearer, {
131
150
  method: "POST",
132
151
  headers: { "content-type": "application/json" },
133
- body: JSON.stringify({ provision_vault: false, vault_name: "someoneelse" }),
152
+ body: JSON.stringify({
153
+ provision_vault: false,
154
+ vault_name: "jonathan-vault",
155
+ role: "read",
156
+ }),
157
+ }),
158
+ deps(),
159
+ );
160
+ expect(res.status).toBe(201);
161
+ const body = (await res.json()) as {
162
+ invite: { vault_name: string | null; role: string; provision_vault: boolean };
163
+ };
164
+ expect(body.invite.vault_name).toBe("jonathan-vault");
165
+ expect(body.invite.role).toBe("read");
166
+ expect(body.invite.provision_vault).toBe(false);
167
+ });
168
+
169
+ test("400 vault_not_found when the shared vault doesn't exist on this hub", async () => {
170
+ // A pinned-but-nonexistent name is a typo, not a future reservation —
171
+ // fail at mint, not at the invitee's redeem.
172
+ const bearer = await makeAdminBearer();
173
+ const res = await handleCreateInvite(
174
+ withBearer("/api/invites", bearer, {
175
+ method: "POST",
176
+ headers: { "content-type": "application/json" },
177
+ body: JSON.stringify({ provision_vault: false, vault_name: "nosuchvault", role: "read" }),
178
+ }),
179
+ deps(),
180
+ );
181
+ expect(res.status).toBe(400);
182
+ const body = (await res.json()) as { error: string };
183
+ expect(body.error).toBe("vault_not_found");
184
+ });
185
+
186
+ test("400 rejects role=read with provision_vault=true (sole user of a new vault must hold write)", async () => {
187
+ const bearer = await makeAdminBearer();
188
+ const res = await handleCreateInvite(
189
+ withBearer("/api/invites", bearer, {
190
+ method: "POST",
191
+ headers: { "content-type": "application/json" },
192
+ body: JSON.stringify({ provision_vault: true, role: "read" }),
134
193
  }),
135
194
  deps(),
136
195
  );
137
196
  expect(res.status).toBe(400);
138
197
  const body = (await res.json()) as { error: string; error_description: string };
139
198
  expect(body.error).toBe("invalid_request");
140
- expect(body.error_description).toContain("shared-vault");
199
+ expect(body.error_description).toContain("write");
141
200
  });
142
201
 
143
202
  test("account-only invite (provision_vault=false, NO vault_name) is still allowed", async () => {
@@ -159,6 +218,107 @@ describe("POST /api/invites", () => {
159
218
  });
160
219
  });
161
220
 
221
+ describe("POST /api/invites — pre-named username", () => {
222
+ test("201 carries the username in the wire shape", async () => {
223
+ seedVaultInManifest("jonathan-vault");
224
+ const bearer = await makeAdminBearer();
225
+ const res = await handleCreateInvite(
226
+ withBearer("/api/invites", bearer, {
227
+ method: "POST",
228
+ headers: { "content-type": "application/json" },
229
+ body: JSON.stringify({
230
+ username: "jonathan",
231
+ provision_vault: false,
232
+ vault_name: "jonathan-vault",
233
+ role: "read",
234
+ }),
235
+ }),
236
+ deps(),
237
+ );
238
+ expect(res.status).toBe(201);
239
+ const body = (await res.json()) as { invite: { username: string | null } };
240
+ expect(body.invite.username).toBe("jonathan");
241
+ });
242
+
243
+ test("400 invalid_username on a name outside the /api/users vocabulary", async () => {
244
+ const bearer = await makeAdminBearer();
245
+ for (const bad of ["A", "Has Spaces", "admin"]) {
246
+ const res = await handleCreateInvite(
247
+ withBearer("/api/invites", bearer, {
248
+ method: "POST",
249
+ headers: { "content-type": "application/json" },
250
+ body: JSON.stringify({ username: bad }),
251
+ }),
252
+ deps(),
253
+ );
254
+ expect(res.status).toBe(400);
255
+ const body = (await res.json()) as { error: string };
256
+ expect(body.error).toBe("invalid_username");
257
+ }
258
+ });
259
+
260
+ test("409 username_taken when an existing user holds the name (case-insensitive)", async () => {
261
+ const bearer = await makeAdminBearer(); // creates user "operator"
262
+ const res = await handleCreateInvite(
263
+ withBearer("/api/invites", bearer, {
264
+ method: "POST",
265
+ headers: { "content-type": "application/json" },
266
+ body: JSON.stringify({ username: "operator" }),
267
+ }),
268
+ deps(),
269
+ );
270
+ expect(res.status).toBe(409);
271
+ const body = (await res.json()) as { error: string };
272
+ expect(body.error).toBe("username_taken");
273
+ });
274
+
275
+ test("409 username_reserved when another PENDING invite pre-names the same username", async () => {
276
+ const bearer = await makeAdminBearer();
277
+ const make = () =>
278
+ handleCreateInvite(
279
+ withBearer("/api/invites", bearer, {
280
+ method: "POST",
281
+ headers: { "content-type": "application/json" },
282
+ body: JSON.stringify({ username: "jonathan" }),
283
+ }),
284
+ deps(),
285
+ );
286
+ const first = await make();
287
+ expect(first.status).toBe(201);
288
+ const second = await make();
289
+ expect(second.status).toBe(409);
290
+ const body = (await second.json()) as { error: string };
291
+ expect(body.error).toBe("username_reserved");
292
+ });
293
+
294
+ test("revoking the pending invite frees the reserved username", async () => {
295
+ const bearer = await makeAdminBearer();
296
+ const first = await handleCreateInvite(
297
+ withBearer("/api/invites", bearer, {
298
+ method: "POST",
299
+ headers: { "content-type": "application/json" },
300
+ body: JSON.stringify({ username: "jonathan" }),
301
+ }),
302
+ deps(),
303
+ );
304
+ const { invite } = (await first.json()) as { invite: { id: string } };
305
+ await handleRevokeInvite(
306
+ withBearer(`/api/invites/${invite.id}`, bearer, { method: "DELETE" }),
307
+ invite.id,
308
+ deps(),
309
+ );
310
+ const second = await handleCreateInvite(
311
+ withBearer("/api/invites", bearer, {
312
+ method: "POST",
313
+ headers: { "content-type": "application/json" },
314
+ body: JSON.stringify({ username: "jonathan" }),
315
+ }),
316
+ deps(),
317
+ );
318
+ expect(second.status).toBe(201);
319
+ });
320
+ });
321
+
162
322
  describe("GET /api/invites", () => {
163
323
  test("lists invites; raw token NEVER present in the wire shape", async () => {
164
324
  const bearer = await makeAdminBearer();
@@ -208,11 +208,27 @@ describe("parseModulesPath", () => {
208
208
  });
209
209
  });
210
210
 
211
- test("rejects non-curated shorts (no marketplace yet)", () => {
212
- // Channel exists in FIRST_PARTY_FALLBACKS but is exploration, not
213
- // in CURATED_MODULES the v0.6 surface refuses to drive it via
214
- // /api/modules.
215
- expect(parseModulesPath("/api/modules/channel/install")).toBeUndefined();
211
+ test("accepts any KNOWN module short — channel install now resolves (was the bug)", () => {
212
+ // Post-2026-06-09 (modular-UI architecture, P2) the install-path gate is
213
+ // `isKnownModuleShort` (KNOWN_MODULES FIRST_PARTY_FALLBACKS), NOT the old
214
+ // CURATED_MODULES whitelist. channel is in FIRST_PARTY_FALLBACKS, so its
215
+ // install path now resolves — fixing the running-but-uninstallable bug.
216
+ expect(parseModulesPath("/api/modules/channel/install")).toEqual({
217
+ short: "channel",
218
+ rest: "install",
219
+ });
220
+ // Other known modules (runner / surface) resolve too.
221
+ expect(parseModulesPath("/api/modules/runner/install")).toEqual({
222
+ short: "runner",
223
+ rest: "install",
224
+ });
225
+ });
226
+
227
+ test("rejects unknown shorts (the hub itself + genuinely third-party rows)", () => {
228
+ // `hub` isn't a supervised module (no registry entry) and a random short
229
+ // has no install package — both still fall through to undefined so the
230
+ // module-ops switch never acts on them.
231
+ expect(parseModulesPath("/api/modules/hub/install")).toBeUndefined();
216
232
  expect(parseModulesPath("/api/modules/random/install")).toBeUndefined();
217
233
  });
218
234
 
@@ -996,6 +1012,55 @@ describe("POST /api/modules/:short/start", () => {
996
1012
  expect(spawns).toEqual([]);
997
1013
  expect(calls).toEqual([]);
998
1014
  });
1015
+
1016
+ test("channel#41: start reconciles a drifted services.json port back to canonical (API path)", async () => {
1017
+ // The live signature: channel's row carried 19415 instead of canonical 1941.
1018
+ // The API start path (admin SPA / `parachute start channel`) must apply the
1019
+ // SAME reconcile the boot path does — otherwise an operator-triggered start
1020
+ // re-strands the module on the dead port.
1021
+ writeManifest(h.manifestPath, [
1022
+ {
1023
+ name: "parachute-channel",
1024
+ port: 19415,
1025
+ paths: ["/channel"],
1026
+ health: "/health",
1027
+ version: "0.0.0-linked",
1028
+ },
1029
+ ]);
1030
+ const { supervisor, spawns } = makeIdleSupervisor();
1031
+ const logs: string[] = [];
1032
+ const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
1033
+
1034
+ const res = await handleStart(
1035
+ postReq("/api/modules/channel/start", { authorization: `Bearer ${bearer}` }),
1036
+ "channel",
1037
+ {
1038
+ db: h.db,
1039
+ issuer: ISSUER,
1040
+ manifestPath: h.manifestPath,
1041
+ configDir: h.dir,
1042
+ supervisor,
1043
+ log: (l) => logs.push(l),
1044
+ },
1045
+ );
1046
+
1047
+ expect(res.status).toBe(200);
1048
+ // The supervisor child gets PORT=1941 (canonical), not the drifted 19415 —
1049
+ // so it binds + the readiness probe checks the right port.
1050
+ expect(spawns.length).toBe(1);
1051
+ expect(spawns[0]?.short).toBe("channel");
1052
+ expect(spawns[0]?.env?.PORT).toBe("1941");
1053
+ // services.json row is rewritten to 1941 → the reverse-proxy (which reads
1054
+ // services.json) routes /channel/* to the live port.
1055
+ const onDisk = JSON.parse(readFileSync(h.manifestPath, "utf8")) as {
1056
+ services: { name: string; port: number }[];
1057
+ };
1058
+ expect(onDisk.services.find((s) => s.name === "parachute-channel")?.port).toBe(1941);
1059
+ // The reconcile event logged on the API path too (deps.log wired — #41 review).
1060
+ expect(
1061
+ logs.some((l) => l.includes("reconciled") && l.includes("19415") && l.includes("1941")),
1062
+ ).toBe(true);
1063
+ });
999
1064
  });
1000
1065
 
1001
1066
  describe("POST /api/modules/:short/stop", () => {