@openparachute/hub 0.5.7 → 0.5.10-rc.2

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__/admin-clients.test.ts +275 -0
  3. package/src/__tests__/admin-handlers.test.ts +70 -323
  4. package/src/__tests__/admin-host-admin-token.test.ts +52 -4
  5. package/src/__tests__/api-me.test.ts +149 -0
  6. package/src/__tests__/api-mint-token.test.ts +381 -0
  7. package/src/__tests__/api-revocation-list.test.ts +198 -0
  8. package/src/__tests__/api-revoke-token.test.ts +320 -0
  9. package/src/__tests__/api-tokens.test.ts +629 -0
  10. package/src/__tests__/auth.test.ts +680 -16
  11. package/src/__tests__/expose-2fa-warning.test.ts +3 -5
  12. package/src/__tests__/expose-cloudflare.test.ts +1 -1
  13. package/src/__tests__/expose.test.ts +2 -2
  14. package/src/__tests__/hub-server.test.ts +526 -67
  15. package/src/__tests__/hub.test.ts +108 -55
  16. package/src/__tests__/install-source.test.ts +249 -0
  17. package/src/__tests__/jwt-sign.test.ts +205 -0
  18. package/src/__tests__/module-manifest.test.ts +48 -0
  19. package/src/__tests__/oauth-handlers.test.ts +375 -5
  20. package/src/__tests__/operator-token.test.ts +427 -3
  21. package/src/__tests__/origin-check.test.ts +220 -0
  22. package/src/__tests__/serve.test.ts +100 -0
  23. package/src/__tests__/setup-gate.test.ts +196 -0
  24. package/src/__tests__/status.test.ts +199 -0
  25. package/src/__tests__/supervisor.test.ts +408 -0
  26. package/src/__tests__/upgrade.test.ts +247 -4
  27. package/src/__tests__/well-known.test.ts +69 -0
  28. package/src/admin-clients.ts +139 -0
  29. package/src/admin-handlers.ts +32 -254
  30. package/src/admin-host-admin-token.ts +25 -10
  31. package/src/admin-login-ui.ts +256 -0
  32. package/src/admin-vault-admin-token.ts +1 -1
  33. package/src/api-me.ts +124 -0
  34. package/src/api-mint-token.ts +239 -0
  35. package/src/api-revocation-list.ts +59 -0
  36. package/src/api-revoke-token.ts +153 -0
  37. package/src/api-tokens.ts +224 -0
  38. package/src/cli.ts +28 -0
  39. package/src/commands/auth.ts +408 -51
  40. package/src/commands/expose-2fa-warning.ts +6 -6
  41. package/src/commands/serve.ts +157 -0
  42. package/src/commands/status.ts +74 -10
  43. package/src/commands/upgrade.ts +33 -6
  44. package/src/csrf.ts +6 -3
  45. package/src/help.ts +54 -5
  46. package/src/hub-control.ts +1 -0
  47. package/src/hub-db.ts +63 -0
  48. package/src/hub-server.ts +630 -135
  49. package/src/hub.ts +272 -149
  50. package/src/install-source.ts +291 -0
  51. package/src/jwt-sign.ts +265 -5
  52. package/src/module-manifest.ts +48 -10
  53. package/src/oauth-handlers.ts +238 -54
  54. package/src/oauth-ui.ts +23 -2
  55. package/src/operator-token.ts +349 -18
  56. package/src/origin-check.ts +127 -0
  57. package/src/rate-limit.ts +5 -2
  58. package/src/scope-explanations.ts +33 -2
  59. package/src/sessions.ts +1 -1
  60. package/src/supervisor.ts +359 -0
  61. package/src/well-known.ts +54 -1
  62. package/web/ui/dist/assets/index-Bv6Bq_wx.js +60 -0
  63. package/web/ui/dist/assets/index-D54otIhv.css +1 -0
  64. package/web/ui/dist/index.html +2 -2
  65. package/src/__tests__/admin-config.test.ts +0 -281
  66. package/src/admin-config-ui.ts +0 -534
  67. package/src/admin-config.ts +0 -226
  68. package/web/ui/dist/assets/index-BKzPDdB0.js +0 -60
  69. package/web/ui/dist/assets/index-Dyk6g7vT.css +0 -1
@@ -0,0 +1,408 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { type SpawnRequest, type SupervisedProc, Supervisor } from "../supervisor.ts";
3
+
4
+ /**
5
+ * Fake subprocess with controllable exited promise + injectable stdout
6
+ * / stderr. The test drives `resolveExit(code)` to simulate the child
7
+ * exiting (clean or crash) and `emit(stream, bytes)` to push log
8
+ * output through the line-buffered tap.
9
+ */
10
+ interface FakeProc extends SupervisedProc {
11
+ resolveExit(code: number | null): void;
12
+ emitStdout(chunk: string): void;
13
+ emitStderr(chunk: string): void;
14
+ closeStreams(): void;
15
+ killed: boolean;
16
+ killSignal?: NodeJS.Signals | number;
17
+ }
18
+
19
+ function makeFakeProc(pid: number): FakeProc {
20
+ let resolveExit!: (code: number | null) => void;
21
+ const exited = new Promise<number | null>((resolve) => {
22
+ resolveExit = resolve;
23
+ });
24
+
25
+ const stdoutController = makeStreamController();
26
+ const stderrController = makeStreamController();
27
+
28
+ return {
29
+ pid,
30
+ exited,
31
+ stdout: stdoutController.stream,
32
+ stderr: stderrController.stream,
33
+ kill(signal) {
34
+ this.killed = true;
35
+ this.killSignal = signal;
36
+ },
37
+ killed: false,
38
+ resolveExit: (code) => resolveExit(code),
39
+ emitStdout: (chunk) => stdoutController.push(chunk),
40
+ emitStderr: (chunk) => stderrController.push(chunk),
41
+ closeStreams: () => {
42
+ stdoutController.close();
43
+ stderrController.close();
44
+ },
45
+ };
46
+ }
47
+
48
+ function makeStreamController(): {
49
+ stream: ReadableStream<Uint8Array>;
50
+ push: (s: string) => void;
51
+ close: () => void;
52
+ } {
53
+ let controller: ReadableStreamDefaultController<Uint8Array>;
54
+ const stream = new ReadableStream<Uint8Array>({
55
+ start(c) {
56
+ controller = c;
57
+ },
58
+ });
59
+ const enc = new TextEncoder();
60
+ return {
61
+ stream,
62
+ push: (s) => controller.enqueue(enc.encode(s)),
63
+ close: () => {
64
+ try {
65
+ controller.close();
66
+ } catch {
67
+ // already closed
68
+ }
69
+ },
70
+ };
71
+ }
72
+
73
+ /**
74
+ * Programmable spawner. Hand it a queue of FakeProc instances, one per
75
+ * expected spawn call (or one shared if every spawn uses the same
76
+ * fake). Tests drive lifecycle by calling resolveExit on the returned
77
+ * proc.
78
+ */
79
+ function makeQueueSpawner(): {
80
+ spawn: (req: SpawnRequest) => SupervisedProc;
81
+ enqueue: (proc: FakeProc) => void;
82
+ calls: SpawnRequest[];
83
+ } {
84
+ const queue: FakeProc[] = [];
85
+ const calls: SpawnRequest[] = [];
86
+ return {
87
+ enqueue: (proc) => queue.push(proc),
88
+ calls,
89
+ spawn: (req) => {
90
+ calls.push(req);
91
+ const next = queue.shift();
92
+ if (!next) throw new Error(`unexpected spawn for ${req.short}`);
93
+ return next;
94
+ },
95
+ };
96
+ }
97
+
98
+ function tick(ms = 10): Promise<void> {
99
+ return new Promise((r) => setTimeout(r, ms));
100
+ }
101
+
102
+ describe("Supervisor.start + status transitions", () => {
103
+ test("transitions starting → running after spawn", async () => {
104
+ const proc = makeFakeProc(123);
105
+ const spawner = makeQueueSpawner();
106
+ spawner.enqueue(proc);
107
+ const sup = new Supervisor({ spawnFn: spawner.spawn });
108
+
109
+ const state = await sup.start({ short: "vault", cmd: ["bun", "vault.ts"] });
110
+ expect(state.status).toBe("running");
111
+ expect(state.pid).toBe(123);
112
+ expect(state.startedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
113
+ expect(spawner.calls).toHaveLength(1);
114
+ expect(spawner.calls[0]?.short).toBe("vault");
115
+
116
+ // Cleanup: resolve the exited promise so the watcher doesn't dangle.
117
+ proc.closeStreams();
118
+ sup.stop("vault");
119
+ proc.resolveExit(0);
120
+ });
121
+
122
+ test("is idempotent on a running module — second start returns existing state", async () => {
123
+ const proc = makeFakeProc(123);
124
+ const spawner = makeQueueSpawner();
125
+ spawner.enqueue(proc);
126
+ const sup = new Supervisor({ spawnFn: spawner.spawn });
127
+
128
+ await sup.start({ short: "vault", cmd: ["bun", "vault.ts"] });
129
+ await sup.start({ short: "vault", cmd: ["bun", "vault.ts"] });
130
+ // Only one spawn — the second start short-circuited.
131
+ expect(spawner.calls).toHaveLength(1);
132
+
133
+ proc.closeStreams();
134
+ sup.stop("vault");
135
+ proc.resolveExit(0);
136
+ });
137
+ });
138
+
139
+ describe("Supervisor restart-on-crash", () => {
140
+ test("restarts a crashed module within the budget", async () => {
141
+ const first = makeFakeProc(101);
142
+ const second = makeFakeProc(102);
143
+ const spawner = makeQueueSpawner();
144
+ spawner.enqueue(first);
145
+ spawner.enqueue(second);
146
+
147
+ const sup = new Supervisor({
148
+ spawnFn: spawner.spawn,
149
+ restartDelayMs: 0,
150
+ sleep: () => Promise.resolve(),
151
+ });
152
+ await sup.start({ short: "vault", cmd: ["bun", "vault.ts"] });
153
+
154
+ // First child crashes.
155
+ first.closeStreams();
156
+ first.resolveExit(1);
157
+ // Let the handleExit microtask run + the spawn happen.
158
+ await tick();
159
+
160
+ expect(spawner.calls).toHaveLength(2);
161
+ const state = sup.get("vault");
162
+ expect(state?.status).toBe("running");
163
+ expect(state?.pid).toBe(102);
164
+ expect(state?.restartsInWindow).toBe(1);
165
+ expect(state?.lastExitCode).toBe(1);
166
+
167
+ second.closeStreams();
168
+ sup.stop("vault");
169
+ second.resolveExit(0);
170
+ });
171
+
172
+ test("gives up after maxRestarts crashes in window, marks crashed", async () => {
173
+ const procs = Array.from({ length: 3 }, (_, i) => makeFakeProc(200 + i));
174
+ const spawner = makeQueueSpawner();
175
+ for (const p of procs) spawner.enqueue(p);
176
+
177
+ const outputs: string[] = [];
178
+ const sup = new Supervisor({
179
+ spawnFn: spawner.spawn,
180
+ maxRestarts: 3,
181
+ restartDelayMs: 0,
182
+ sleep: () => Promise.resolve(),
183
+ output: (line) => outputs.push(line),
184
+ });
185
+ await sup.start({ short: "vault", cmd: ["bun", "vault.ts"] });
186
+
187
+ // Crash 3 times in quick succession.
188
+ for (const p of procs) {
189
+ p.closeStreams();
190
+ p.resolveExit(2);
191
+ await tick();
192
+ }
193
+
194
+ const state = sup.get("vault");
195
+ expect(state?.status).toBe("crashed");
196
+ expect(state?.restartsInWindow).toBe(3);
197
+ expect(spawner.calls).toHaveLength(3); // initial + 2 restarts; 3rd crash trips the budget.
198
+ expect(outputs.some((l) => l.includes("giving up"))).toBe(true);
199
+ });
200
+
201
+ test("crashes outside the window drop off and budget resets", async () => {
202
+ const procs = Array.from({ length: 3 }, (_, i) => makeFakeProc(300 + i));
203
+ const spawner = makeQueueSpawner();
204
+ for (const p of procs) spawner.enqueue(p);
205
+
206
+ let nowVal = 1_000_000;
207
+ const sup = new Supervisor({
208
+ spawnFn: spawner.spawn,
209
+ maxRestarts: 2,
210
+ restartWindowMs: 5_000,
211
+ restartDelayMs: 0,
212
+ sleep: () => Promise.resolve(),
213
+ now: () => nowVal,
214
+ });
215
+ await sup.start({ short: "vault", cmd: ["bun", "vault.ts"] });
216
+
217
+ // First crash at t=0.
218
+ procs[0]?.closeStreams();
219
+ procs[0]?.resolveExit(1);
220
+ await tick();
221
+ expect(sup.get("vault")?.restartsInWindow).toBe(1);
222
+
223
+ // Advance past the window; the first crash falls out before the
224
+ // second is counted.
225
+ nowVal += 10_000;
226
+ procs[1]?.closeStreams();
227
+ procs[1]?.resolveExit(1);
228
+ await tick();
229
+ // Budget reset — this crash is alone in its window.
230
+ expect(sup.get("vault")?.restartsInWindow).toBe(1);
231
+ expect(sup.get("vault")?.status).toBe("running");
232
+
233
+ procs[2]?.closeStreams();
234
+ sup.stop("vault");
235
+ procs[2]?.resolveExit(0);
236
+ });
237
+ });
238
+
239
+ describe("Supervisor.stop", () => {
240
+ test("operator stop is not a crash — does not restart", async () => {
241
+ const proc = makeFakeProc(101);
242
+ const spawner = makeQueueSpawner();
243
+ spawner.enqueue(proc);
244
+ const sup = new Supervisor({
245
+ spawnFn: spawner.spawn,
246
+ restartDelayMs: 0,
247
+ sleep: () => Promise.resolve(),
248
+ });
249
+ await sup.start({ short: "vault", cmd: ["bun", "vault.ts"] });
250
+
251
+ await sup.stop("vault");
252
+ expect(proc.killed).toBe(true);
253
+ expect(proc.killSignal).toBe("SIGTERM");
254
+
255
+ proc.closeStreams();
256
+ proc.resolveExit(0);
257
+ await tick();
258
+
259
+ // No second spawn — stop is an intentional exit.
260
+ expect(spawner.calls).toHaveLength(1);
261
+ expect(sup.get("vault")?.status).toBe("stopped");
262
+ });
263
+ });
264
+
265
+ describe("Supervisor.restart", () => {
266
+ test("stops the current process and spawns fresh", async () => {
267
+ const first = makeFakeProc(101);
268
+ const second = makeFakeProc(102);
269
+ const spawner = makeQueueSpawner();
270
+ spawner.enqueue(first);
271
+ spawner.enqueue(second);
272
+
273
+ const sup = new Supervisor({
274
+ spawnFn: spawner.spawn,
275
+ restartDelayMs: 0,
276
+ sleep: () => Promise.resolve(),
277
+ });
278
+ await sup.start({ short: "vault", cmd: ["bun", "vault.ts"] });
279
+
280
+ // Kick off restart; we need to resolve the first proc's exited so
281
+ // the awaitable in restart() doesn't hang.
282
+ const restartPromise = sup.restart("vault");
283
+ first.closeStreams();
284
+ first.resolveExit(0);
285
+ const state = await restartPromise;
286
+
287
+ expect(state?.status).toBe("running");
288
+ expect(state?.pid).toBe(102);
289
+ expect(spawner.calls).toHaveLength(2);
290
+
291
+ second.closeStreams();
292
+ sup.stop("vault");
293
+ second.resolveExit(0);
294
+ });
295
+ });
296
+
297
+ describe("Supervisor output multiplexing", () => {
298
+ test("prefixes child stdout lines with [short]", async () => {
299
+ const proc = makeFakeProc(101);
300
+ const spawner = makeQueueSpawner();
301
+ spawner.enqueue(proc);
302
+ const outputs: string[] = [];
303
+ const sup = new Supervisor({
304
+ spawnFn: spawner.spawn,
305
+ output: (line) => outputs.push(line),
306
+ });
307
+ await sup.start({ short: "vault", cmd: ["bun", "vault.ts"] });
308
+
309
+ proc.emitStdout("listening on 1940\n");
310
+ proc.emitStdout("ready\n");
311
+ // Allow the async stream reader to flush.
312
+ await tick(20);
313
+
314
+ expect(outputs).toContain("[vault] listening on 1940\n");
315
+ expect(outputs).toContain("[vault] ready\n");
316
+
317
+ proc.closeStreams();
318
+ sup.stop("vault");
319
+ proc.resolveExit(0);
320
+ });
321
+
322
+ test("line-buffers split chunks so partial lines don't break the prefix", async () => {
323
+ const proc = makeFakeProc(101);
324
+ const spawner = makeQueueSpawner();
325
+ spawner.enqueue(proc);
326
+ const outputs: string[] = [];
327
+ const sup = new Supervisor({
328
+ spawnFn: spawner.spawn,
329
+ output: (line) => outputs.push(line),
330
+ });
331
+ await sup.start({ short: "scribe", cmd: ["bun", "scribe.ts"] });
332
+
333
+ // Single line arriving in two chunks should still be one prefixed
334
+ // line — not "[scribe] listening" + "[scribe] on 3200\n".
335
+ proc.emitStdout("listening");
336
+ await tick(10);
337
+ proc.emitStdout(" on 3200\n");
338
+ await tick(20);
339
+
340
+ expect(outputs).toContain("[scribe] listening on 3200\n");
341
+
342
+ proc.closeStreams();
343
+ sup.stop("scribe");
344
+ proc.resolveExit(0);
345
+ });
346
+
347
+ test("multiple children interleave without prefix collisions", async () => {
348
+ const vault = makeFakeProc(101);
349
+ const scribe = makeFakeProc(102);
350
+ const spawner = makeQueueSpawner();
351
+ spawner.enqueue(vault);
352
+ spawner.enqueue(scribe);
353
+ const outputs: string[] = [];
354
+ const sup = new Supervisor({
355
+ spawnFn: spawner.spawn,
356
+ output: (line) => outputs.push(line),
357
+ });
358
+ await sup.start({ short: "vault", cmd: ["bun", "vault.ts"] });
359
+ await sup.start({ short: "scribe", cmd: ["bun", "scribe.ts"] });
360
+
361
+ vault.emitStdout("vault-line-1\n");
362
+ scribe.emitStdout("scribe-line-1\n");
363
+ vault.emitStderr("vault-err\n");
364
+ await tick(20);
365
+
366
+ expect(outputs).toContain("[vault] vault-line-1\n");
367
+ expect(outputs).toContain("[scribe] scribe-line-1\n");
368
+ expect(outputs).toContain("[vault] vault-err\n");
369
+
370
+ vault.closeStreams();
371
+ scribe.closeStreams();
372
+ sup.stop("vault");
373
+ sup.stop("scribe");
374
+ vault.resolveExit(0);
375
+ scribe.resolveExit(0);
376
+ });
377
+ });
378
+
379
+ describe("Supervisor.list + get", () => {
380
+ test("list returns snapshot of all supervised modules", async () => {
381
+ const vault = makeFakeProc(101);
382
+ const scribe = makeFakeProc(102);
383
+ const spawner = makeQueueSpawner();
384
+ spawner.enqueue(vault);
385
+ spawner.enqueue(scribe);
386
+ const sup = new Supervisor({ spawnFn: spawner.spawn });
387
+
388
+ await sup.start({ short: "vault", cmd: ["bun", "vault.ts"] });
389
+ await sup.start({ short: "scribe", cmd: ["bun", "scribe.ts"] });
390
+
391
+ const states = sup.list();
392
+ expect(states).toHaveLength(2);
393
+ const shorts = states.map((s) => s.short).sort();
394
+ expect(shorts).toEqual(["scribe", "vault"]);
395
+
396
+ vault.closeStreams();
397
+ scribe.closeStreams();
398
+ sup.stop("vault");
399
+ sup.stop("scribe");
400
+ vault.resolveExit(0);
401
+ scribe.resolveExit(0);
402
+ });
403
+
404
+ test("get returns undefined for an unknown module", () => {
405
+ const sup = new Supervisor({ spawnFn: () => makeFakeProc(0) });
406
+ expect(sup.get("nothing")).toBeUndefined();
407
+ });
408
+ });