@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,359 @@
1
+ /**
2
+ * Per-module child supervisor for container-mode hub.
3
+ *
4
+ * The on-box flow (`parachute start <svc>`) spawns module daemons
5
+ * detached + unref'd, writes a pidfile, and walks away — process
6
+ * lifecycle becomes the operator's problem (launchd, systemd, or a
7
+ * follow-up `parachute restart`). That shape doesn't work in a
8
+ * container:
9
+ *
10
+ * - There's no external supervisor watching the children. If vault
11
+ * crashes, nothing brings it back.
12
+ * - Render's log viewer only surfaces hub's stdout. A detached child
13
+ * whose stdout goes to `~/.parachute/<svc>/logs/<svc>.log` is
14
+ * invisible to the operator clicking through the dashboard.
15
+ *
16
+ * This supervisor solves both. It spawns each module attached (no
17
+ * `detached: true`, no `unref()`), pipes their stdout/stderr through a
18
+ * line-prefixing tap into hub's own stdout (`[vault] …`,
19
+ * `[scribe] …`), watches `proc.exited`, and restarts crashed children
20
+ * up to a small budget before giving up + marking the module
21
+ * `crashed`. The budget keeps a wedged-on-boot module from chewing
22
+ * forever; once it's exhausted the operator sees the crash via /api/modules
23
+ * (or, post-1B, the per-module log view).
24
+ *
25
+ * Out of scope for this module: spawning the hub HTTP server itself
26
+ * (that's `Bun.serve` in the same process), driving the on-box
27
+ * `parachute start <svc>` path (still uses `commands/lifecycle.ts`),
28
+ * persisting child state to disk (transient — re-derived from
29
+ * services.json on boot).
30
+ */
31
+
32
+ export type ModuleStatus = "starting" | "running" | "stopped" | "crashed" | "restarting";
33
+
34
+ export interface ModuleState {
35
+ /** Short name (vault / notes / scribe / …). */
36
+ readonly short: string;
37
+ /** Last-observed lifecycle phase. */
38
+ readonly status: ModuleStatus;
39
+ /** PID of the current Bun.spawn child, if any. */
40
+ readonly pid?: number;
41
+ /** ISO timestamp of the most recent spawn. */
42
+ readonly startedAt?: string;
43
+ /** Crash count within the current restart window. Resets after the window passes without a crash. */
44
+ readonly restartsInWindow: number;
45
+ /** ISO timestamp of the most recent crash, or undefined if never crashed. */
46
+ readonly lastCrashAt?: string;
47
+ /** Exit code of the most recent crash. */
48
+ readonly lastExitCode?: number | null;
49
+ }
50
+
51
+ export interface SpawnRequest {
52
+ /** Short name — used as the log prefix and the supervisor map key. */
53
+ readonly short: string;
54
+ /** argv passed to `Bun.spawn`. */
55
+ readonly cmd: readonly string[];
56
+ /** Optional cwd for the child. */
57
+ readonly cwd?: string;
58
+ /**
59
+ * Optional env merged on top of `process.env`. The supervisor doesn't
60
+ * mutate `process.env`; the merge happens at spawn time.
61
+ */
62
+ readonly env?: Record<string, string>;
63
+ }
64
+
65
+ export interface SupervisorOpts {
66
+ /**
67
+ * Max crashes within `restartWindowMs` before the supervisor gives up
68
+ * and marks the module `crashed`. Default 3 — enough to ride out a
69
+ * transient race (DB lock, port still releasing), few enough to stop
70
+ * a wedged-on-boot module from looping forever.
71
+ */
72
+ readonly maxRestarts?: number;
73
+ /**
74
+ * Sliding window for the restart budget, in ms. A crash older than
75
+ * this falls out of the count. Default 60_000 (1 minute).
76
+ */
77
+ readonly restartWindowMs?: number;
78
+ /**
79
+ * Delay between a crash being observed and the restart spawn, in ms.
80
+ * Default 500 — gives sockets time to release on EADDRINUSE.
81
+ */
82
+ readonly restartDelayMs?: number;
83
+ /**
84
+ * Where prefixed child output goes. Default `process.stdout.write`.
85
+ * Tests inject a collector so they can assert on the multiplexed
86
+ * stream without spelunking stdout.
87
+ */
88
+ readonly output?: (line: string) => void;
89
+ /**
90
+ * Test seam over `Bun.spawn`. Returns a Subprocess-shaped handle.
91
+ */
92
+ readonly spawnFn?: SpawnFn;
93
+ /**
94
+ * Test seam over wall-clock. Production passes `Date.now`.
95
+ */
96
+ readonly now?: () => number;
97
+ /**
98
+ * Test seam over `setTimeout`. Production resolves a real Promise
99
+ * with `setTimeout`. Tests stub to advance time deterministically.
100
+ */
101
+ readonly sleep?: (ms: number) => Promise<void>;
102
+ }
103
+
104
+ /**
105
+ * Subprocess-shaped seam. Production passes through to `Bun.spawn`;
106
+ * tests construct a fake that exposes a controllable `exited` Promise
107
+ * and pipe-able stdout/stderr.
108
+ */
109
+ export type SpawnFn = (req: SpawnRequest) => SupervisedProc;
110
+
111
+ /**
112
+ * The minimal Subprocess shape the supervisor depends on. Bun's real
113
+ * `Subprocess` matches this; the test fake mirrors it.
114
+ */
115
+ export interface SupervisedProc {
116
+ readonly pid: number;
117
+ readonly exited: Promise<number | null>;
118
+ readonly stdout: ReadableStream<Uint8Array> | null;
119
+ readonly stderr: ReadableStream<Uint8Array> | null;
120
+ kill(signal?: NodeJS.Signals | number): void;
121
+ }
122
+
123
+ const DEFAULT_MAX_RESTARTS = 3;
124
+ const DEFAULT_RESTART_WINDOW_MS = 60_000;
125
+ const DEFAULT_RESTART_DELAY_MS = 500;
126
+
127
+ /**
128
+ * Per-module supervisor. Owns the spawn → watch → restart loop.
129
+ *
130
+ * Single-process semantics: instances of `Supervisor` aren't safe to
131
+ * share across processes (the underlying Subprocess handles are
132
+ * process-local). Hub creates one Supervisor per `parachute serve`
133
+ * boot and threads it into the API handlers.
134
+ */
135
+ export class Supervisor {
136
+ private readonly opts: Required<Omit<SupervisorOpts, "spawnFn">> & {
137
+ readonly spawnFn: SpawnFn;
138
+ };
139
+ private readonly modules = new Map<string, ModuleEntry>();
140
+
141
+ constructor(opts: SupervisorOpts = {}) {
142
+ this.opts = {
143
+ maxRestarts: opts.maxRestarts ?? DEFAULT_MAX_RESTARTS,
144
+ restartWindowMs: opts.restartWindowMs ?? DEFAULT_RESTART_WINDOW_MS,
145
+ restartDelayMs: opts.restartDelayMs ?? DEFAULT_RESTART_DELAY_MS,
146
+ output: opts.output ?? ((line) => process.stdout.write(line)),
147
+ spawnFn: opts.spawnFn ?? defaultSpawnFn,
148
+ now: opts.now ?? Date.now,
149
+ sleep: opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms))),
150
+ };
151
+ }
152
+
153
+ /**
154
+ * Spawn a module under supervision. Idempotent: re-spawning an
155
+ * already-running module is a no-op (returns the existing state).
156
+ * Re-spawning a previously-crashed module clears the crash budget
157
+ * and starts fresh.
158
+ */
159
+ async start(req: SpawnRequest): Promise<ModuleState> {
160
+ const existing = this.modules.get(req.short);
161
+ if (existing && (existing.state.status === "running" || existing.state.status === "starting")) {
162
+ return existing.state;
163
+ }
164
+ // Crashed → operator intent is "try again." Wipe the budget.
165
+ const entry: ModuleEntry = {
166
+ req,
167
+ state: {
168
+ short: req.short,
169
+ status: "starting",
170
+ restartsInWindow: 0,
171
+ },
172
+ crashStamps: [],
173
+ };
174
+ this.modules.set(req.short, entry);
175
+ this.spawnAndWatch(entry);
176
+ return entry.state;
177
+ }
178
+
179
+ /**
180
+ * Stop a supervised module. Sends SIGTERM, marks the state
181
+ * `stopped`, and detaches the exit watcher so a normal termination
182
+ * isn't seen as a crash. Idempotent on already-stopped modules.
183
+ */
184
+ async stop(short: string): Promise<ModuleState | undefined> {
185
+ const entry = this.modules.get(short);
186
+ if (!entry) return undefined;
187
+ entry.stopRequested = true;
188
+ if (entry.proc) {
189
+ try {
190
+ entry.proc.kill("SIGTERM");
191
+ } catch {
192
+ // Process may already be dead — fall through.
193
+ }
194
+ }
195
+ entry.state = { ...entry.state, status: "stopped" };
196
+ return entry.state;
197
+ }
198
+
199
+ /** Snapshot of every supervised module's current state. */
200
+ list(): ModuleState[] {
201
+ return Array.from(this.modules.values(), (e) => e.state);
202
+ }
203
+
204
+ /** Snapshot of a single module's state, or undefined if not supervised. */
205
+ get(short: string): ModuleState | undefined {
206
+ return this.modules.get(short)?.state;
207
+ }
208
+
209
+ /**
210
+ * Restart a supervised module: stop, wait for exit, start with the
211
+ * same SpawnRequest. Used by the `/api/modules/:name/restart`
212
+ * handler. The on-box `parachute restart <svc>` path stays on
213
+ * `commands/lifecycle.ts` — different surface, different ownership.
214
+ */
215
+ async restart(short: string): Promise<ModuleState | undefined> {
216
+ const entry = this.modules.get(short);
217
+ if (!entry) return undefined;
218
+ const req = entry.req;
219
+ entry.state = { ...entry.state, status: "restarting" };
220
+ await this.stop(short);
221
+ // Wait for the prior process to actually exit so the new spawn
222
+ // doesn't race on EADDRINUSE.
223
+ if (entry.proc) {
224
+ try {
225
+ await entry.proc.exited;
226
+ } catch {
227
+ // exited promise rejection is non-fatal — we're stopping anyway.
228
+ }
229
+ }
230
+ // Drop the entry so `start` treats this as a clean spawn.
231
+ this.modules.delete(short);
232
+ return this.start(req);
233
+ }
234
+
235
+ private spawnAndWatch(entry: ModuleEntry): void {
236
+ const proc = this.opts.spawnFn(entry.req);
237
+ entry.proc = proc;
238
+ entry.state = {
239
+ ...entry.state,
240
+ status: "running",
241
+ pid: proc.pid,
242
+ startedAt: new Date(this.opts.now()).toISOString(),
243
+ };
244
+ this.pipeOutput(entry.req.short, proc);
245
+ void proc.exited.then((exitCode) => this.handleExit(entry, exitCode));
246
+ }
247
+
248
+ private async handleExit(entry: ModuleEntry, exitCode: number | null): Promise<void> {
249
+ // Operator-driven stop: not a crash, don't restart.
250
+ if (entry.stopRequested) {
251
+ entry.state = {
252
+ ...entry.state,
253
+ status: "stopped",
254
+ pid: undefined,
255
+ lastExitCode: exitCode,
256
+ };
257
+ return;
258
+ }
259
+
260
+ const now = this.opts.now();
261
+ // Drop crashes older than the window before counting.
262
+ const cutoff = now - this.opts.restartWindowMs;
263
+ entry.crashStamps = entry.crashStamps.filter((t) => t >= cutoff);
264
+ entry.crashStamps.push(now);
265
+
266
+ if (entry.crashStamps.length >= this.opts.maxRestarts) {
267
+ this.opts.output(
268
+ `[supervisor] ${entry.req.short} crashed ${entry.crashStamps.length}x within ${this.opts.restartWindowMs}ms — giving up.\n`,
269
+ );
270
+ entry.state = {
271
+ ...entry.state,
272
+ status: "crashed",
273
+ pid: undefined,
274
+ lastCrashAt: new Date(now).toISOString(),
275
+ lastExitCode: exitCode,
276
+ restartsInWindow: entry.crashStamps.length,
277
+ };
278
+ return;
279
+ }
280
+
281
+ entry.state = {
282
+ ...entry.state,
283
+ status: "restarting",
284
+ pid: undefined,
285
+ lastCrashAt: new Date(now).toISOString(),
286
+ lastExitCode: exitCode,
287
+ restartsInWindow: entry.crashStamps.length,
288
+ };
289
+ this.opts.output(
290
+ `[supervisor] ${entry.req.short} exited (code=${exitCode ?? "?"}); restart ${entry.crashStamps.length}/${this.opts.maxRestarts} in window.\n`,
291
+ );
292
+ await this.opts.sleep(this.opts.restartDelayMs);
293
+ // Operator may have called stop() during the sleep — re-check.
294
+ if (entry.stopRequested) return;
295
+ this.spawnAndWatch(entry);
296
+ }
297
+
298
+ /**
299
+ * Tap a child's stdout + stderr into the supervisor's `output`
300
+ * callback (hub's stdout by default), prefixing each line with the
301
+ * module's short name. Line-buffered: partial chunks accumulate
302
+ * until a newline arrives so multi-byte log lines don't get
303
+ * scrambled across modules.
304
+ */
305
+ private pipeOutput(short: string, proc: SupervisedProc): void {
306
+ const prefix = `[${short}] `;
307
+ if (proc.stdout) void pumpLines(proc.stdout, prefix, this.opts.output);
308
+ if (proc.stderr) void pumpLines(proc.stderr, prefix, this.opts.output);
309
+ }
310
+ }
311
+
312
+ interface ModuleEntry {
313
+ req: SpawnRequest;
314
+ state: ModuleState;
315
+ proc?: SupervisedProc;
316
+ crashStamps: number[];
317
+ stopRequested?: boolean;
318
+ }
319
+
320
+ async function pumpLines(
321
+ stream: ReadableStream<Uint8Array>,
322
+ prefix: string,
323
+ output: (line: string) => void,
324
+ ): Promise<void> {
325
+ const reader = stream.getReader();
326
+ const decoder = new TextDecoder();
327
+ let buf = "";
328
+ try {
329
+ while (true) {
330
+ const { done, value } = await reader.read();
331
+ if (done) break;
332
+ buf += decoder.decode(value, { stream: true });
333
+ // Flush every complete line; keep the partial tail buffered.
334
+ let nl = buf.indexOf("\n");
335
+ while (nl !== -1) {
336
+ const line = buf.slice(0, nl + 1);
337
+ output(prefix + line);
338
+ buf = buf.slice(nl + 1);
339
+ nl = buf.indexOf("\n");
340
+ }
341
+ }
342
+ // Flush any trailing partial line so we don't drop a module's
343
+ // last log message (it's likely the most important one — the
344
+ // exit cause).
345
+ if (buf.length > 0) output(`${prefix + buf}\n`);
346
+ } finally {
347
+ reader.releaseLock();
348
+ }
349
+ }
350
+
351
+ const defaultSpawnFn: SpawnFn = (req) => {
352
+ const spawnOpts: Parameters<typeof Bun.spawn>[1] = {
353
+ stdio: ["ignore", "pipe", "pipe"],
354
+ };
355
+ if (req.cwd) spawnOpts.cwd = req.cwd;
356
+ if (req.env) spawnOpts.env = { ...process.env, ...req.env };
357
+ const proc = Bun.spawn([...req.cmd], spawnOpts);
358
+ return proc as unknown as SupervisedProc;
359
+ };
package/src/well-known.ts CHANGED
@@ -26,6 +26,14 @@ export interface WellKnownVaultEntry {
26
26
  * to iterate without having to know every service's shortName ahead of time.
27
27
  * `infoUrl` points at the service's `/.parachute/info` endpoint (relative to
28
28
  * its mount path) which the hub fetches client-side for displayName/tagline.
29
+ *
30
+ * `displayName` and `uiUrl` are both optional — the discovery page renders
31
+ * a Services tile when `uiUrl` is present, falling back to the manifest
32
+ * short name when `displayName` is absent. Both are sourced via hub-server's
33
+ * `loadUiUrls`/`loadManagementUrls`-style readers from the module's
34
+ * `installDir/.parachute/module.json`, NOT from services.json (which gets
35
+ * overwritten on service boot per the "services own the write side"
36
+ * contract — see hub#238 commit message for the C-not-B trace).
29
37
  */
30
38
  export interface WellKnownServicesEntry {
31
39
  name: string;
@@ -33,6 +41,16 @@ export interface WellKnownServicesEntry {
33
41
  path: string;
34
42
  version: string;
35
43
  infoUrl: string;
44
+ /**
45
+ * Human-readable label for the discovery page. Sourced from
46
+ * `module.json:displayName` when available; falls back to
47
+ * `services.json:displayName` written at install time.
48
+ */
49
+ displayName?: string;
50
+ /** Where the service's primary user-facing UI lives, sourced from `module.json:uiUrl`. */
51
+ uiUrl?: string;
52
+ /** One-line subtitle for the discovery tile, sourced from `services.json:tagline`. */
53
+ tagline?: string;
36
54
  }
37
55
 
38
56
  /**
@@ -107,6 +125,19 @@ export interface BuildWellKnownOpts {
107
125
  * in. Returning `undefined` means "no admin SPA" and hub renders no link.
108
126
  */
109
127
  managementUrlFor?: (entry: ServiceEntry) => string | undefined;
128
+ /**
129
+ * Optional resolver mapping a `ServiceEntry` to its `module.json:uiUrl`,
130
+ * if any. Same shape as `managementUrlFor`. Returning `undefined` means
131
+ * "no user-facing UI" and discovery omits the Services tile (e.g. vault
132
+ * has no `uiUrl` — its content browses through Notes).
133
+ */
134
+ uiUrlFor?: (entry: ServiceEntry) => string | undefined;
135
+ /**
136
+ * Optional resolver mapping a `ServiceEntry` to its `module.json:displayName`.
137
+ * Hub-server reads this at request time; falls back to the entry's own
138
+ * `displayName` (from services.json) when absent.
139
+ */
140
+ displayNameFor?: (entry: ServiceEntry) => string | undefined;
110
141
  }
111
142
 
112
143
  /** Join a base origin and a path without double slashes — "/" stays "/". */
@@ -131,7 +162,29 @@ export function buildWellKnown(opts: BuildWellKnownOpts): WellKnownDocument {
131
162
  for (const path of pathsToEmit) {
132
163
  const url = new URL(path, `${base}/`).toString();
133
164
  const infoUrl = new URL(joinInfoPath(path), `${base}/`).toString();
134
- doc.services.push({ name: s.name, url, path, version: s.version, infoUrl });
165
+ const entry: WellKnownServicesEntry = {
166
+ name: s.name,
167
+ url,
168
+ path,
169
+ version: s.version,
170
+ infoUrl,
171
+ };
172
+ const displayName = opts.displayNameFor?.(s) ?? s.displayName;
173
+ if (displayName !== undefined) entry.displayName = displayName;
174
+ // Tagline rides on services.json (set by service-spec at install or
175
+ // by the service's own boot-time upsert). Read directly from the
176
+ // entry — no installDir round-trip needed since it's already
177
+ // persisted server-side and reasonably stable across reboots.
178
+ if (s.tagline !== undefined) entry.tagline = s.tagline;
179
+ // Resolve uiUrl: relative path → absolute URL against `base`; full
180
+ // http(s) URL → verbatim. Same rule managementUrl uses.
181
+ const uiUrlRaw = opts.uiUrlFor?.(s);
182
+ if (uiUrlRaw !== undefined) {
183
+ entry.uiUrl = /^https?:\/\//i.test(uiUrlRaw)
184
+ ? uiUrlRaw
185
+ : new URL(uiUrlRaw, `${base}/`).toString();
186
+ }
187
+ doc.services.push(entry);
135
188
  if (isVault) {
136
189
  const managementUrl = opts.managementUrlFor?.(s);
137
190
  const entry: WellKnownVaultEntry = {