@openparachute/hub 0.6.3 → 0.6.4-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 (97) hide show
  1. package/package.json +1 -2
  2. package/src/__tests__/account-home-ui.test.ts +344 -110
  3. package/src/__tests__/account-mirror.test.ts +156 -0
  4. package/src/__tests__/account-setup.test.ts +880 -0
  5. package/src/__tests__/account-usage.test.ts +137 -0
  6. package/src/__tests__/account-vault-admin-token.test.ts +301 -0
  7. package/src/__tests__/account-vault-token.test.ts +53 -1
  8. package/src/__tests__/admin-vault-admin-token.test.ts +17 -0
  9. package/src/__tests__/admin-vaults.test.ts +20 -0
  10. package/src/__tests__/api-account.test.ts +236 -4
  11. package/src/__tests__/api-invites.test.ts +217 -0
  12. package/src/__tests__/api-mint-token.test.ts +259 -10
  13. package/src/__tests__/api-modules-ops.test.ts +195 -3
  14. package/src/__tests__/api-modules.test.ts +40 -4
  15. package/src/__tests__/api-settings-hub-origin.test.ts +13 -8
  16. package/src/__tests__/auto-wire.test.ts +101 -1
  17. package/src/__tests__/cli.test.ts +188 -2
  18. package/src/__tests__/cloudflare-state.test.ts +104 -0
  19. package/src/__tests__/expose-2fa-warning.test.ts +11 -8
  20. package/src/__tests__/expose-cloudflare.test.ts +135 -9
  21. package/src/__tests__/expose-interactive.test.ts +234 -7
  22. package/src/__tests__/expose-supervisor-version.test.ts +104 -0
  23. package/src/__tests__/expose.test.ts +10 -5
  24. package/src/__tests__/grants.test.ts +197 -8
  25. package/src/__tests__/hub-origin-resolution.test.ts +179 -25
  26. package/src/__tests__/hub-server.test.ts +761 -13
  27. package/src/__tests__/hub-unit.test.ts +185 -0
  28. package/src/__tests__/init.test.ts +579 -3
  29. package/src/__tests__/install.test.ts +448 -2
  30. package/src/__tests__/invites.test.ts +220 -0
  31. package/src/__tests__/launchctl-guard.test.ts +185 -0
  32. package/src/__tests__/migrate-cutover.test.ts +33 -0
  33. package/src/__tests__/module-ops-client.test.ts +68 -0
  34. package/src/__tests__/scope-explanations.test.ts +16 -0
  35. package/src/__tests__/serve-boot.test.ts +74 -1
  36. package/src/__tests__/serve.test.ts +121 -7
  37. package/src/__tests__/setup-wizard.test.ts +110 -0
  38. package/src/__tests__/spawn-path.test.ts +191 -0
  39. package/src/__tests__/status.test.ts +64 -0
  40. package/src/__tests__/supervisor.test.ts +374 -0
  41. package/src/__tests__/users.test.ts +66 -0
  42. package/src/__tests__/well-known.test.ts +25 -0
  43. package/src/__tests__/wizard.test.ts +72 -1
  44. package/src/account-home-ui.ts +481 -235
  45. package/src/account-mirror.ts +126 -0
  46. package/src/account-setup.ts +381 -0
  47. package/src/account-usage.ts +118 -0
  48. package/src/account-vault-admin-token.ts +242 -0
  49. package/src/account-vault-token.ts +36 -2
  50. package/src/admin-login-ui.ts +121 -0
  51. package/src/admin-vault-admin-token.ts +8 -2
  52. package/src/admin-vaults.ts +137 -29
  53. package/src/api-account.ts +118 -1
  54. package/src/api-invites.ts +345 -0
  55. package/src/api-mint-token.ts +81 -0
  56. package/src/api-modules-ops.ts +168 -53
  57. package/src/api-modules.ts +36 -0
  58. package/src/auto-wire.ts +87 -0
  59. package/src/cli.ts +128 -34
  60. package/src/cloudflare/detect.ts +1 -1
  61. package/src/cloudflare/state.ts +104 -8
  62. package/src/commands/expose-2fa-warning.ts +17 -13
  63. package/src/commands/expose-cloudflare.ts +103 -36
  64. package/src/commands/expose-interactive.ts +163 -17
  65. package/src/commands/expose-supervisor.ts +45 -0
  66. package/src/commands/init.ts +183 -4
  67. package/src/commands/install.ts +321 -3
  68. package/src/commands/migrate-cutover.ts +12 -5
  69. package/src/commands/serve-boot.ts +33 -3
  70. package/src/commands/serve.ts +158 -37
  71. package/src/commands/status.ts +9 -1
  72. package/src/commands/wizard.ts +36 -2
  73. package/src/grants.ts +113 -0
  74. package/src/help.ts +18 -5
  75. package/src/hub-db.ts +70 -2
  76. package/src/hub-server.ts +438 -41
  77. package/src/hub-settings.ts +3 -3
  78. package/src/hub-unit.ts +259 -9
  79. package/src/invites.ts +291 -0
  80. package/src/launchctl-guard.ts +131 -0
  81. package/src/managed-unit.ts +13 -3
  82. package/src/migrate-offer.ts +15 -6
  83. package/src/module-ops-client.ts +47 -22
  84. package/src/scope-attenuation.ts +19 -0
  85. package/src/scope-explanations.ts +9 -1
  86. package/src/service-spec.ts +17 -4
  87. package/src/setup-wizard.ts +34 -2
  88. package/src/spawn-path.ts +148 -0
  89. package/src/supervisor.ts +232 -7
  90. package/src/users.ts +54 -8
  91. package/src/vault-hub-origin-env.ts +28 -0
  92. package/src/vault-name.ts +13 -1
  93. package/src/well-known.ts +13 -0
  94. package/web/ui/dist/assets/{index-mz8XcVPP.css → index-BYYUeLGA.css} +1 -1
  95. package/web/ui/dist/assets/index-D3cDUOOj.js +61 -0
  96. package/web/ui/dist/index.html +2 -2
  97. package/web/ui/dist/assets/index-D_0TRjeo.js +0 -61
@@ -178,6 +178,203 @@ describe("Supervisor.start + status transitions", () => {
178
178
  });
179
179
  });
180
180
 
181
+ describe("Supervisor port-squatter detection (#580 item 4)", () => {
182
+ test("foreign pid on the module port → port_squatter error, no spawn", async () => {
183
+ const spawner = makeQueueSpawner();
184
+ // Note: NOTHING enqueued — if `start` tried to spawn, the spawner throws.
185
+ const sup = new Supervisor({
186
+ spawnFn: spawner.spawn,
187
+ killFn: noopKill,
188
+ // A rogue pid 1921 holds :1940; it is NOT one of our children.
189
+ pidOnPort: (port) => (port === 1940 ? 1921 : undefined),
190
+ ownerOfPid: (pid) => (pid === 1921 ? "bun /x/vault/src/server.ts" : undefined),
191
+ });
192
+
193
+ const state = await sup.start({
194
+ short: "vault",
195
+ cmd: ["bun", "vault.ts"],
196
+ env: { PORT: "1940" },
197
+ });
198
+
199
+ // No spawn attempted (spawner.calls empty), module is `crashed` with the
200
+ // structured, actionable squatter error.
201
+ expect(spawner.calls).toHaveLength(0);
202
+ expect(state.status).toBe("crashed");
203
+ expect(state.pid).toBeUndefined();
204
+ expect(state.startError?.error_type).toBe("port_squatter");
205
+ expect(state.startError?.error_description).toContain("port 1940 is held by pid 1921");
206
+ expect(state.startError?.error_description).toContain("bun /x/vault/src/server.ts");
207
+ expect(state.startError?.error_description).toContain("kill 1921 && parachute start vault");
208
+ });
209
+
210
+ test("squatter message omits cmdline when ownerOfPid can't read it", async () => {
211
+ const spawner = makeQueueSpawner();
212
+ const sup = new Supervisor({
213
+ spawnFn: spawner.spawn,
214
+ killFn: noopKill,
215
+ pidOnPort: () => 4242,
216
+ ownerOfPid: () => undefined,
217
+ });
218
+
219
+ const state = await sup.start({
220
+ short: "vault",
221
+ cmd: ["bun", "vault.ts"],
222
+ env: { PORT: "1940" },
223
+ });
224
+ expect(state.startError?.error_type).toBe("port_squatter");
225
+ expect(state.startError?.error_description).toContain("port 1940 is held by pid 4242");
226
+ expect(state.startError?.error_description).toContain("kill 4242 && parachute start vault");
227
+ // No parenthetical cmdline.
228
+ expect(state.startError?.error_description).not.toContain("(");
229
+ });
230
+
231
+ test("free port → no squatter error, module spawns normally", async () => {
232
+ const proc = makeFakeProc(500);
233
+ const spawner = makeQueueSpawner();
234
+ spawner.enqueue(proc);
235
+ const sup = new Supervisor({
236
+ spawnFn: spawner.spawn,
237
+ killFn: noopKill,
238
+ pidOnPort: () => undefined, // port free / detection unavailable
239
+ });
240
+
241
+ const state = await sup.start({
242
+ short: "vault",
243
+ cmd: ["bun", "vault.ts"],
244
+ env: { PORT: "1940" },
245
+ });
246
+ expect(spawner.calls).toHaveLength(1);
247
+ expect(state.status).toBe("running");
248
+ expect(state.startError).toBeUndefined();
249
+
250
+ proc.closeStreams();
251
+ sup.stop("vault");
252
+ proc.resolveExit(0);
253
+ });
254
+
255
+ test("port held by one of OUR OWN children is not a squatter", async () => {
256
+ // vault is up on pid 700 holding :1940; starting a sibling (scribe) that
257
+ // somehow reports the same holder pid must NOT be flagged — the holder is
258
+ // a supervised child, not a foreign rogue.
259
+ const vaultProc = makeFakeProc(700);
260
+ const scribeProc = makeFakeProc(701);
261
+ const spawner = makeQueueSpawner();
262
+ spawner.enqueue(vaultProc);
263
+ spawner.enqueue(scribeProc);
264
+ const sup = new Supervisor({
265
+ spawnFn: spawner.spawn,
266
+ killFn: noopKill,
267
+ // vault's port (1940) is free so vault spawns (pid 700). scribe's port
268
+ // (1943) then reports vault's pid 700 as the holder — a supervised child,
269
+ // NOT a foreign rogue, so scribe must still spawn.
270
+ pidOnPort: (port) => (port === 1943 ? 700 : undefined),
271
+ });
272
+
273
+ await sup.start({ short: "vault", cmd: ["bun", "vault.ts"], env: { PORT: "1940" } });
274
+ const scribe = await sup.start({
275
+ short: "scribe",
276
+ cmd: ["bun", "scribe.ts"],
277
+ env: { PORT: "1943" },
278
+ });
279
+ // scribe spawned (no false-positive squatter); both children spawned.
280
+ expect(spawner.calls).toHaveLength(2);
281
+ expect(scribe.status).toBe("running");
282
+ expect(scribe.startError).toBeUndefined();
283
+
284
+ vaultProc.closeStreams();
285
+ scribeProc.closeStreams();
286
+ sup.stop("vault");
287
+ sup.stop("scribe");
288
+ vaultProc.resolveExit(0);
289
+ scribeProc.resolveExit(0);
290
+ });
291
+
292
+ test("a CRASHED child's stale pid does NOT vouch for a port holder (N1 liveness)", async () => {
293
+ // vault spawns (pid 800), then crashes for good (maxRestarts: 1). Its entry
294
+ // keeps `proc.pid === 800` (never cleared on exit) but status is `crashed`.
295
+ // A fresh `start` where pid 800 now holds :1940 must be flagged as a
296
+ // SQUATTER — the stale pid of a dead child must not excuse the holder.
297
+ const first = makeFakeProc(800);
298
+ const spawner = makeQueueSpawner();
299
+ spawner.enqueue(first);
300
+ let portHeld = false;
301
+ const sup = new Supervisor({
302
+ spawnFn: spawner.spawn,
303
+ killFn: noopKill,
304
+ maxRestarts: 1,
305
+ restartDelayMs: 0,
306
+ sleep: () => Promise.resolve(),
307
+ // Free before the crash; pid 800 "holds" :1940 after we flip portHeld.
308
+ pidOnPort: (port) => (portHeld && port === 1940 ? 800 : undefined),
309
+ ownerOfPid: () => "bun /x/vault/src/server.ts",
310
+ });
311
+
312
+ await sup.start({ short: "vault", cmd: ["bun", "vault.ts"], env: { PORT: "1940" } });
313
+ // Crash past the budget → status `crashed`, entry.proc.pid still 800.
314
+ first.closeStreams();
315
+ first.resolveExit(1);
316
+ await tick();
317
+ expect(sup.get("vault")?.status).toBe("crashed");
318
+
319
+ // Now pid 800 holds the port. A re-start must NOT treat 800 as "ours".
320
+ portHeld = true;
321
+ const restarted = await sup.start({
322
+ short: "vault",
323
+ cmd: ["bun", "vault.ts"],
324
+ env: { PORT: "1940" },
325
+ });
326
+ expect(restarted.status).toBe("crashed");
327
+ expect(restarted.startError?.error_type).toBe("port_squatter");
328
+ expect(restarted.startError?.error_description).toContain("port 1940 is held by pid 800");
329
+ // No second spawn — the squatter check aborted before re-spawning.
330
+ expect(spawner.calls).toHaveLength(1);
331
+ });
332
+
333
+ test("no declared PORT → squatter check skipped (request without env.PORT)", async () => {
334
+ const proc = makeFakeProc(900);
335
+ const spawner = makeQueueSpawner();
336
+ spawner.enqueue(proc);
337
+ let probed = false;
338
+ const sup = new Supervisor({
339
+ spawnFn: spawner.spawn,
340
+ killFn: noopKill,
341
+ pidOnPort: () => {
342
+ probed = true;
343
+ return 1;
344
+ },
345
+ });
346
+
347
+ const state = await sup.start({ short: "vault", cmd: ["bun", "vault.ts"] });
348
+ // No PORT in the request → we never probe a port, and the module spawns.
349
+ expect(probed).toBe(false);
350
+ expect(state.status).toBe("running");
351
+
352
+ proc.closeStreams();
353
+ sup.stop("vault");
354
+ proc.resolveExit(0);
355
+ });
356
+
357
+ test("stub-spawner path defaults to no squatter (existing fake-proc tests unaffected)", async () => {
358
+ const proc = makeFakeProc(123);
359
+ const spawner = makeQueueSpawner();
360
+ spawner.enqueue(proc);
361
+ // No pidOnPort injected → on the stub-spawner (test) path it defaults to
362
+ // "no squatter", so a request carrying a PORT still spawns.
363
+ const sup = new Supervisor({ spawnFn: spawner.spawn, killFn: noopKill });
364
+ const state = await sup.start({
365
+ short: "vault",
366
+ cmd: ["bun", "vault.ts"],
367
+ env: { PORT: "1940" },
368
+ });
369
+ expect(spawner.calls).toHaveLength(1);
370
+ expect(state.status).toBe("running");
371
+
372
+ proc.closeStreams();
373
+ sup.stop("vault");
374
+ proc.resolveExit(0);
375
+ });
376
+ });
377
+
181
378
  describe("Supervisor restart-on-crash", () => {
182
379
  test("restarts a crashed module within the budget", async () => {
183
380
  const first = makeFakeProc(101);
@@ -595,6 +792,113 @@ describe("Supervisor.restart", () => {
595
792
  sup.stop("vault");
596
793
  second.resolveExit(0);
597
794
  });
795
+
796
+ test("replays entry.req when no nextReq is supplied", async () => {
797
+ const first = makeFakeProc(101);
798
+ const second = makeFakeProc(102);
799
+ const spawner = makeQueueSpawner();
800
+ spawner.enqueue(first);
801
+ spawner.enqueue(second);
802
+
803
+ const sup = new Supervisor({
804
+ spawnFn: spawner.spawn,
805
+ killFn: noopKill,
806
+ restartDelayMs: 0,
807
+ sleep: () => Promise.resolve(),
808
+ });
809
+ await sup.start({
810
+ short: "vault",
811
+ cmd: ["bun", "vault.ts"],
812
+ env: { PARACHUTE_HUB_ORIGIN: "https://origin.example" },
813
+ });
814
+
815
+ const restartPromise = sup.restart("vault");
816
+ first.closeStreams();
817
+ first.resolveExit(0);
818
+ await restartPromise;
819
+
820
+ // No nextReq → the re-spawn replays the original env (legacy behavior).
821
+ expect(spawner.calls[1]?.env?.PARACHUTE_HUB_ORIGIN).toBe("https://origin.example");
822
+ expect(spawner.calls[1]?.cmd).toEqual(["bun", "vault.ts"]);
823
+
824
+ second.closeStreams();
825
+ sup.stop("vault");
826
+ second.resolveExit(0);
827
+ });
828
+
829
+ test("nextReq re-spawns with the refreshed req AND propagates to crash-restart (hub#532)", async () => {
830
+ const first = makeFakeProc(101);
831
+ const second = makeFakeProc(102); // restart re-spawn
832
+ const third = makeFakeProc(103); // crash-restart respawn
833
+ const spawner = makeQueueSpawner();
834
+ spawner.enqueue(first);
835
+ spawner.enqueue(second);
836
+ spawner.enqueue(third);
837
+
838
+ const sup = new Supervisor({
839
+ spawnFn: spawner.spawn,
840
+ killFn: noopKill,
841
+ restartDelayMs: 0,
842
+ sleep: () => Promise.resolve(),
843
+ });
844
+ // First start with the OLD origin.
845
+ await sup.start({
846
+ short: "vault",
847
+ cmd: ["bun", "vault.ts"],
848
+ env: { PARACHUTE_HUB_ORIGIN: "https://old.example" },
849
+ });
850
+
851
+ // Restart WITH a refreshed req carrying the NEW origin.
852
+ const restartPromise = sup.restart("vault", {
853
+ short: "vault",
854
+ cmd: ["bun", "vault.ts"],
855
+ env: { PARACHUTE_HUB_ORIGIN: "https://new.example" },
856
+ });
857
+ first.closeStreams();
858
+ first.resolveExit(0);
859
+ await restartPromise;
860
+
861
+ // The restart re-spawn carries the NEW origin.
862
+ expect(spawner.calls[1]?.env?.PARACHUTE_HUB_ORIGIN).toBe("https://new.example");
863
+
864
+ // Now crash the restart-spawned child (resolveExit with a non-stop code).
865
+ // handleExit → spawnAndWatch replays entry.req, which `start` stored from
866
+ // the refreshed nextReq — so the crash-restart ALSO carries the new origin.
867
+ second.closeStreams();
868
+ second.resolveExit(1);
869
+ await tick(20);
870
+
871
+ expect(spawner.calls).toHaveLength(3);
872
+ expect(spawner.calls[2]?.env?.PARACHUTE_HUB_ORIGIN).toBe("https://new.example");
873
+
874
+ third.closeStreams();
875
+ sup.stop("vault");
876
+ third.resolveExit(0);
877
+ });
878
+
879
+ test("throws when nextReq.short mismatches the restarted short (state-corruption guard)", async () => {
880
+ const proc = makeFakeProc(101);
881
+ const spawner = makeQueueSpawner();
882
+ spawner.enqueue(proc);
883
+ const sup = new Supervisor({
884
+ spawnFn: spawner.spawn,
885
+ killFn: noopKill,
886
+ restartDelayMs: 0,
887
+ sleep: () => Promise.resolve(),
888
+ });
889
+ await sup.start({ short: "vault", cmd: ["bun", "vault.ts"] });
890
+
891
+ // A nextReq for a DIFFERENT short would re-register under the wrong map key.
892
+ await expect(
893
+ sup.restart("vault", { short: "scribe", cmd: ["bun", "scribe.ts"] }),
894
+ ).rejects.toThrow(/nextReq\.short is "scribe"/);
895
+ // The original entry is untouched — no spurious stop/respawn happened.
896
+ expect(spawner.calls).toHaveLength(1);
897
+
898
+ proc.closeStreams();
899
+ sup.stop("vault");
900
+ proc.resolveExit(0);
901
+ });
598
902
  });
599
903
 
600
904
  describe("Supervisor output multiplexing", () => {
@@ -833,6 +1137,76 @@ describe("Supervisor port-readiness + structured start-error (§6.5)", () => {
833
1137
  proc.resolveExit(0);
834
1138
  });
835
1139
 
1140
+ test("(b2) late bind AFTER the window → the background watch clears the started-but-unbound note", async () => {
1141
+ // Heavy modules (vault — SQLite + git mirror + well-known init) routinely
1142
+ // bind a moment after the readiness window. Pre-fix, the note recorded at
1143
+ // window-elapse stuck for the module's whole lifetime and `parachute
1144
+ // status` showed a perpetual "failed to start" on a healthy module.
1145
+ const proc = makeFakeProc(103);
1146
+ const spawner = makeQueueSpawner();
1147
+ spawner.enqueue(proc);
1148
+ let bound = false;
1149
+ const sup = new Supervisor({
1150
+ spawnFn: spawner.spawn,
1151
+ killFn: noopKill,
1152
+ portListening: async () => bound,
1153
+ startReadyMs: 30,
1154
+ startReadyPollMs: 5,
1155
+ lateBindWatchMs: 2_000,
1156
+ lateBindPollMs: 5,
1157
+ sleep: () => Promise.resolve(),
1158
+ });
1159
+ const state = await sup.start(reqWithPort("vault", 1940));
1160
+ // Window elapsed unbound → note recorded (status stays running)…
1161
+ expect(state.startError?.error_type).toBe("started_but_unbound");
1162
+
1163
+ // …then the port binds late. The watch must clear the note.
1164
+ bound = true;
1165
+ let cleared = false;
1166
+ const deadline = Date.now() + 1_500;
1167
+ while (Date.now() < deadline) {
1168
+ if (sup.list()[0]?.startError === undefined) {
1169
+ cleared = true;
1170
+ break;
1171
+ }
1172
+ await new Promise((r) => setTimeout(r, 10));
1173
+ }
1174
+ expect(cleared).toBe(true);
1175
+ expect(sup.list()[0]?.status).toBe("running");
1176
+
1177
+ proc.closeStreams();
1178
+ sup.stop("vault");
1179
+ proc.resolveExit(0);
1180
+ });
1181
+
1182
+ test("(b3) never binds → the watch gives up at its deadline and the note persists", async () => {
1183
+ // A genuinely-unbound module must KEEP its diagnostic — the watch is a
1184
+ // bounded grace window, not an eraser.
1185
+ const proc = makeFakeProc(104);
1186
+ const spawner = makeQueueSpawner();
1187
+ spawner.enqueue(proc);
1188
+ const sup = new Supervisor({
1189
+ spawnFn: spawner.spawn,
1190
+ killFn: noopKill,
1191
+ portListening: async () => false,
1192
+ startReadyMs: 20,
1193
+ startReadyPollMs: 5,
1194
+ lateBindWatchMs: 40,
1195
+ lateBindPollMs: 5,
1196
+ sleep: () => Promise.resolve(),
1197
+ });
1198
+ const state = await sup.start(reqWithPort("vault", 1940));
1199
+ expect(state.startError?.error_type).toBe("started_but_unbound");
1200
+
1201
+ // Let the 40ms watch budget expire; the note must remain.
1202
+ await new Promise((r) => setTimeout(r, 120));
1203
+ expect(sup.list()[0]?.startError?.error_type).toBe("started_but_unbound");
1204
+
1205
+ proc.closeStreams();
1206
+ sup.stop("vault");
1207
+ proc.resolveExit(0);
1208
+ });
1209
+
836
1210
  test("(c) preflight MissingDependencyError → structured start-error, NO spawn", async () => {
837
1211
  const spawner = makeQueueSpawner();
838
1212
  // No proc enqueued — if start() tried to spawn, the queue spawner throws.
@@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { hubDbPath, openHubDb } from "../hub-db.ts";
6
6
  import { recordTokenMint, signAccessToken } from "../jwt-sign.ts";
7
+ import { createSession, findSession } from "../sessions.ts";
7
8
  import {
8
9
  PASSWORD_MIN_LEN,
9
10
  SingleUserModeError,
@@ -303,6 +304,45 @@ describe("deleteUser", () => {
303
304
  cleanup();
304
305
  }
305
306
  });
307
+
308
+ test("deletes a user holding an auth_codes row (hub#559 — OAuth-authorize FK regression)", async () => {
309
+ // A user who completed an OAuth authorize has an `auth_codes` row whose
310
+ // NOT-NULL, non-cascading FK to users(id) outlives its 60s TTL. Before the
311
+ // fix, that pinned the FK and `DELETE FROM users` threw
312
+ // SQLITE_CONSTRAINT_FOREIGNKEY → a 500 on the admin "delete user" action.
313
+ const { db, cleanup } = makeDb();
314
+ try {
315
+ const u = await createUser(db, "ag", "ag-strong-passphrase");
316
+ // auth_codes.client_id FKs to clients — seed a minimal client first.
317
+ db.prepare(
318
+ "INSERT INTO clients (client_id, redirect_uris, scopes, registered_at) VALUES (?, ?, ?, ?)",
319
+ ).run("client-x", "https://app.example/cb", "vault:default:read", "2026-06-04T00:00:00.000Z");
320
+ db.prepare(
321
+ `INSERT INTO auth_codes
322
+ (code, client_id, user_id, redirect_uri, scopes, code_challenge, code_challenge_method, expires_at, used_at, created_at)
323
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
324
+ ).run(
325
+ "dead-code",
326
+ "client-x",
327
+ u.id,
328
+ "https://app.example/cb",
329
+ "vault:default:read",
330
+ "challenge",
331
+ "S256",
332
+ "2026-06-04T00:00:00.000Z", // long-expired
333
+ "2026-06-04T00:00:00.000Z", // already used
334
+ "2026-06-04T00:00:00.000Z",
335
+ );
336
+ expect(deleteUser(db, u.id)).toBe(true);
337
+ expect(getUserById(db, u.id)).toBeNull();
338
+ // The dead auth_code is gone too (hard-deleted with the user).
339
+ expect(db.query("SELECT COUNT(*) c FROM auth_codes WHERE user_id = ?").get(u.id)).toEqual({
340
+ c: 0,
341
+ });
342
+ } finally {
343
+ cleanup();
344
+ }
345
+ });
306
346
  });
307
347
 
308
348
  describe("validateUsername", () => {
@@ -499,6 +539,32 @@ describe("resetUserPassword", () => {
499
539
  }
500
540
  });
501
541
 
542
+ // Item G — a reset also kills active sessions (not just tokens), so the
543
+ // attacker/holder of a live session cookie must re-authenticate.
544
+ test("deletes the user's active sessions (item G)", async () => {
545
+ const { db, cleanup } = makeDb();
546
+ try {
547
+ const alice = await createUser(db, "alice", "alice-strong-passphrase", {
548
+ passwordChanged: true,
549
+ });
550
+ const bob = await createUser(db, "bob", "bob-strong-passphrase", {
551
+ passwordChanged: true,
552
+ allowMulti: true,
553
+ });
554
+ const aliceSession = createSession(db, { userId: alice.id });
555
+ const bobSession = createSession(db, { userId: bob.id });
556
+ expect(findSession(db, aliceSession.id)).not.toBeNull();
557
+
558
+ expect(await resetUserPassword(db, alice.id, "new-temp-passphrase")).toBe(true);
559
+
560
+ // Alice's session is gone; Bob's (a different user) is untouched.
561
+ expect(findSession(db, aliceSession.id)).toBeNull();
562
+ expect(findSession(db, bobSession.id)).not.toBeNull();
563
+ } finally {
564
+ cleanup();
565
+ }
566
+ });
567
+
502
568
  test("does not re-revoke an already-revoked token", async () => {
503
569
  // Defense-in-depth: a previously-revoked token shouldn't have its
504
570
  // revoked_at timestamp overwritten by a fresh reset. The UPDATE's
@@ -123,6 +123,31 @@ describe("buildWellKnown", () => {
123
123
  ]);
124
124
  });
125
125
 
126
+ test("SEED placeholder vault entry is NOT fabricated into a vault row (hub#577)", () => {
127
+ // `parachute init` installs the vault MODULE without creating an instance,
128
+ // seeding a services.json entry at version "0.0.0-linked" with the
129
+ // canonical /vault/default mount. That must NOT surface as a phantom
130
+ // `default` vault in the management page.
131
+ const seed: ServiceEntry = { ...vault, version: "0.0.0-linked" };
132
+ const doc = buildWellKnown({
133
+ services: [seed],
134
+ canonicalOrigin: "https://x.example",
135
+ });
136
+ // No phantom vault row...
137
+ expect(doc.vaults).toEqual([]);
138
+ // ...but the services entry stays so the SPA knows the module IS installed
139
+ // (offers "New vault", not "Install module").
140
+ expect(doc.services.map((s) => s.name)).toEqual(["parachute-vault"]);
141
+ });
142
+
143
+ test("a REAL (non-seed) vault entry still lands in vaults[] (hub#577 regression guard)", () => {
144
+ const doc = buildWellKnown({
145
+ services: [{ ...vault, version: "0.5.1", paths: ["/vault/techne"] }],
146
+ canonicalOrigin: "https://x.example",
147
+ });
148
+ expect(doc.vaults.map((v) => v.name)).toEqual(["techne"]);
149
+ });
150
+
126
151
  test("multiple installs of the same kind both land in the array (#92)", () => {
127
152
  const work: ServiceEntry = { ...notes, paths: ["/notes-work"], port: 5174 };
128
153
  const doc = buildWellKnown({
@@ -32,6 +32,12 @@ interface FakeHubState {
32
32
  importParams?: { remoteUrl: string; pat?: string; mode: string };
33
33
  exposeMode?: string;
34
34
  posted: Array<{ path: string; body: unknown }>;
35
+ /** hub#576: when set, the fake GET /admin/setup reports requireBootstrapToken=true. */
36
+ requireBootstrapToken?: boolean;
37
+ /** hub#576: when set, the fake GET also returns it (loopback-probe behavior). */
38
+ bootstrapToken?: string;
39
+ /** hub#576: when true, the account POST 401s unless the right token is supplied. */
40
+ enforceBootstrapToken?: boolean;
35
41
  }
36
42
 
37
43
  function makeFakeHub(initialState?: Partial<FakeHubState>): {
@@ -86,8 +92,10 @@ function makeFakeHub(initialState?: Partial<FakeHubState>): {
86
92
  hasAdmin: state.hasAdmin,
87
93
  hasVault: state.hasVault,
88
94
  hasExposeMode: state.hasExposeMode,
89
- requireBootstrapToken: false,
95
+ requireBootstrapToken: state.requireBootstrapToken ?? false,
90
96
  csrfToken: csrf,
97
+ // hub#576: a loopback probe carries the actual token value.
98
+ ...(state.bootstrapToken ? { bootstrapToken: state.bootstrapToken } : {}),
91
99
  });
92
100
  return new Response(respBody, {
93
101
  status: 200,
@@ -101,6 +109,17 @@ function makeFakeHub(initialState?: Partial<FakeHubState>): {
101
109
  // POST /admin/setup/account
102
110
  if (path === "/admin/setup/account" && method === "POST") {
103
111
  state.posted.push({ path, body: bodyJson });
112
+ // hub#576: reject when the gate is enforced and the supplied token is
113
+ // wrong / missing — proves the CLI wizard actually sends it.
114
+ if (state.enforceBootstrapToken) {
115
+ const supplied = (bodyJson as { bootstrap_token?: string })?.bootstrap_token;
116
+ if (supplied !== state.bootstrapToken) {
117
+ return new Response(JSON.stringify({ error: "bad bootstrap token" }), {
118
+ status: 401,
119
+ headers: { "content-type": "application/json; charset=utf-8" },
120
+ });
121
+ }
122
+ }
104
123
  state.hasAdmin = true;
105
124
  return new Response(JSON.stringify({ step: "vault", message: "admin created" }), {
106
125
  status: 200,
@@ -270,6 +289,58 @@ describe("runCliWizard", () => {
270
289
  expect(state.exposeMode).toBe("localhost");
271
290
  });
272
291
 
292
+ test("loopback-probe bootstrap token is sent transparently (no prompt) — hub#576", async () => {
293
+ const { state, fetchImpl } = makeFakeHub({
294
+ requireBootstrapToken: true,
295
+ bootstrapToken: "parachute-bootstrap-LOOPBACK",
296
+ enforceBootstrapToken: true,
297
+ });
298
+ let prompted = false;
299
+ const code = await runCliWizard({
300
+ hubUrl: "http://127.0.0.1:1939",
301
+ log: () => {},
302
+ fetchImpl,
303
+ sleep: async () => {},
304
+ // No --bootstrap-token flag, no env: the value must come from the probe.
305
+ prompt: async () => {
306
+ prompted = true;
307
+ return "";
308
+ },
309
+ accountUsername: "admin",
310
+ accountPassword: "longpassword",
311
+ vaultMode: "skip",
312
+ exposeMode: "localhost",
313
+ });
314
+ expect(code).toBe(0);
315
+ // The account POST carried the probe-supplied token...
316
+ const accountBody = state.posted[0]?.body as Record<string, string>;
317
+ expect(accountBody.bootstrap_token).toBe("parachute-bootstrap-LOOPBACK");
318
+ // ...and the operator was never asked for it.
319
+ expect(prompted).toBe(false);
320
+ });
321
+
322
+ test("explicit --bootstrap-token flag still wins over the probe value — hub#576", async () => {
323
+ const { state, fetchImpl } = makeFakeHub({
324
+ requireBootstrapToken: true,
325
+ bootstrapToken: "parachute-bootstrap-PROBE",
326
+ enforceBootstrapToken: false,
327
+ });
328
+ const code = await runCliWizard({
329
+ hubUrl: "http://127.0.0.1:1939",
330
+ log: () => {},
331
+ fetchImpl,
332
+ sleep: async () => {},
333
+ bootstrapToken: "parachute-bootstrap-EXPLICIT",
334
+ accountUsername: "admin",
335
+ accountPassword: "longpassword",
336
+ vaultMode: "skip",
337
+ exposeMode: "localhost",
338
+ });
339
+ expect(code).toBe(0);
340
+ const accountBody = state.posted[0]?.body as Record<string, string>;
341
+ expect(accountBody.bootstrap_token).toBe("parachute-bootstrap-EXPLICIT");
342
+ });
343
+
273
344
  test("vault import mode threads remote_url + pat + import_mode", async () => {
274
345
  const { state, fetchImpl } = makeFakeHub();
275
346
  const code = await runCliWizard({