@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.
- package/package.json +1 -2
- package/src/__tests__/account-home-ui.test.ts +344 -110
- package/src/__tests__/account-mirror.test.ts +156 -0
- package/src/__tests__/account-setup.test.ts +880 -0
- package/src/__tests__/account-usage.test.ts +137 -0
- package/src/__tests__/account-vault-admin-token.test.ts +301 -0
- package/src/__tests__/account-vault-token.test.ts +53 -1
- package/src/__tests__/admin-vault-admin-token.test.ts +17 -0
- package/src/__tests__/admin-vaults.test.ts +20 -0
- package/src/__tests__/api-account.test.ts +236 -4
- package/src/__tests__/api-invites.test.ts +217 -0
- package/src/__tests__/api-mint-token.test.ts +259 -10
- package/src/__tests__/api-modules-ops.test.ts +195 -3
- package/src/__tests__/api-modules.test.ts +40 -4
- package/src/__tests__/api-settings-hub-origin.test.ts +13 -8
- package/src/__tests__/auto-wire.test.ts +101 -1
- package/src/__tests__/cli.test.ts +188 -2
- package/src/__tests__/cloudflare-state.test.ts +104 -0
- package/src/__tests__/expose-2fa-warning.test.ts +11 -8
- package/src/__tests__/expose-cloudflare.test.ts +135 -9
- package/src/__tests__/expose-interactive.test.ts +234 -7
- package/src/__tests__/expose-supervisor-version.test.ts +104 -0
- package/src/__tests__/expose.test.ts +10 -5
- package/src/__tests__/grants.test.ts +197 -8
- package/src/__tests__/hub-origin-resolution.test.ts +179 -25
- package/src/__tests__/hub-server.test.ts +761 -13
- package/src/__tests__/hub-unit.test.ts +185 -0
- package/src/__tests__/init.test.ts +579 -3
- package/src/__tests__/install.test.ts +448 -2
- package/src/__tests__/invites.test.ts +220 -0
- package/src/__tests__/launchctl-guard.test.ts +185 -0
- package/src/__tests__/migrate-cutover.test.ts +33 -0
- package/src/__tests__/module-ops-client.test.ts +68 -0
- package/src/__tests__/scope-explanations.test.ts +16 -0
- package/src/__tests__/serve-boot.test.ts +74 -1
- package/src/__tests__/serve.test.ts +121 -7
- package/src/__tests__/setup-wizard.test.ts +110 -0
- package/src/__tests__/spawn-path.test.ts +191 -0
- package/src/__tests__/status.test.ts +64 -0
- package/src/__tests__/supervisor.test.ts +374 -0
- package/src/__tests__/users.test.ts +66 -0
- package/src/__tests__/well-known.test.ts +25 -0
- package/src/__tests__/wizard.test.ts +72 -1
- package/src/account-home-ui.ts +481 -235
- package/src/account-mirror.ts +126 -0
- package/src/account-setup.ts +381 -0
- package/src/account-usage.ts +118 -0
- package/src/account-vault-admin-token.ts +242 -0
- package/src/account-vault-token.ts +36 -2
- package/src/admin-login-ui.ts +121 -0
- package/src/admin-vault-admin-token.ts +8 -2
- package/src/admin-vaults.ts +137 -29
- package/src/api-account.ts +118 -1
- package/src/api-invites.ts +345 -0
- package/src/api-mint-token.ts +81 -0
- package/src/api-modules-ops.ts +168 -53
- package/src/api-modules.ts +36 -0
- package/src/auto-wire.ts +87 -0
- package/src/cli.ts +128 -34
- package/src/cloudflare/detect.ts +1 -1
- package/src/cloudflare/state.ts +104 -8
- package/src/commands/expose-2fa-warning.ts +17 -13
- package/src/commands/expose-cloudflare.ts +103 -36
- package/src/commands/expose-interactive.ts +163 -17
- package/src/commands/expose-supervisor.ts +45 -0
- package/src/commands/init.ts +183 -4
- package/src/commands/install.ts +321 -3
- package/src/commands/migrate-cutover.ts +12 -5
- package/src/commands/serve-boot.ts +33 -3
- package/src/commands/serve.ts +158 -37
- package/src/commands/status.ts +9 -1
- package/src/commands/wizard.ts +36 -2
- package/src/grants.ts +113 -0
- package/src/help.ts +18 -5
- package/src/hub-db.ts +70 -2
- package/src/hub-server.ts +438 -41
- package/src/hub-settings.ts +3 -3
- package/src/hub-unit.ts +259 -9
- package/src/invites.ts +291 -0
- package/src/launchctl-guard.ts +131 -0
- package/src/managed-unit.ts +13 -3
- package/src/migrate-offer.ts +15 -6
- package/src/module-ops-client.ts +47 -22
- package/src/scope-attenuation.ts +19 -0
- package/src/scope-explanations.ts +9 -1
- package/src/service-spec.ts +17 -4
- package/src/setup-wizard.ts +34 -2
- package/src/spawn-path.ts +148 -0
- package/src/supervisor.ts +232 -7
- package/src/users.ts +54 -8
- package/src/vault-hub-origin-env.ts +28 -0
- package/src/vault-name.ts +13 -1
- package/src/well-known.ts +13 -0
- package/web/ui/dist/assets/{index-mz8XcVPP.css → index-BYYUeLGA.css} +1 -1
- package/web/ui/dist/assets/index-D3cDUOOj.js +61 -0
- package/web/ui/dist/index.html +2 -2
- 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({
|