@openparachute/hub 0.5.7 → 0.5.10-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 (85) 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-modules-ops.test.ts +658 -0
  8. package/src/__tests__/api-modules.test.ts +426 -0
  9. package/src/__tests__/api-revocation-list.test.ts +198 -0
  10. package/src/__tests__/api-revoke-token.test.ts +320 -0
  11. package/src/__tests__/api-tokens.test.ts +629 -0
  12. package/src/__tests__/auth.test.ts +680 -16
  13. package/src/__tests__/csrf.test.ts +40 -1
  14. package/src/__tests__/expose-2fa-warning.test.ts +3 -5
  15. package/src/__tests__/expose-cloudflare.test.ts +1 -1
  16. package/src/__tests__/expose.test.ts +2 -2
  17. package/src/__tests__/hub-server.test.ts +584 -67
  18. package/src/__tests__/hub-settings.test.ts +377 -0
  19. package/src/__tests__/hub.test.ts +123 -53
  20. package/src/__tests__/install-source.test.ts +249 -0
  21. package/src/__tests__/jwt-sign.test.ts +205 -0
  22. package/src/__tests__/module-manifest.test.ts +48 -0
  23. package/src/__tests__/oauth-handlers.test.ts +522 -5
  24. package/src/__tests__/operator-token.test.ts +427 -3
  25. package/src/__tests__/origin-check.test.ts +220 -0
  26. package/src/__tests__/request-protocol.test.ts +54 -0
  27. package/src/__tests__/serve-boot.test.ts +193 -0
  28. package/src/__tests__/serve.test.ts +100 -0
  29. package/src/__tests__/sessions.test.ts +25 -2
  30. package/src/__tests__/setup-gate.test.ts +222 -0
  31. package/src/__tests__/setup-wizard.test.ts +2089 -0
  32. package/src/__tests__/status.test.ts +199 -0
  33. package/src/__tests__/supervisor.test.ts +482 -0
  34. package/src/__tests__/upgrade.test.ts +247 -4
  35. package/src/__tests__/vault-name.test.ts +79 -0
  36. package/src/__tests__/well-known.test.ts +69 -0
  37. package/src/admin-clients.ts +139 -0
  38. package/src/admin-handlers.ts +37 -254
  39. package/src/admin-host-admin-token.ts +25 -10
  40. package/src/admin-login-ui.ts +256 -0
  41. package/src/admin-vault-admin-token.ts +1 -1
  42. package/src/api-me.ts +124 -0
  43. package/src/api-mint-token.ts +239 -0
  44. package/src/api-modules-ops.ts +585 -0
  45. package/src/api-modules.ts +367 -0
  46. package/src/api-revocation-list.ts +59 -0
  47. package/src/api-revoke-token.ts +153 -0
  48. package/src/api-tokens.ts +224 -0
  49. package/src/cli.ts +28 -0
  50. package/src/commands/auth.ts +408 -51
  51. package/src/commands/expose-2fa-warning.ts +6 -6
  52. package/src/commands/serve-boot.ts +133 -0
  53. package/src/commands/serve.ts +214 -0
  54. package/src/commands/status.ts +74 -10
  55. package/src/commands/upgrade.ts +33 -6
  56. package/src/csrf.ts +34 -13
  57. package/src/help.ts +55 -5
  58. package/src/hub-control.ts +1 -0
  59. package/src/hub-db.ts +87 -0
  60. package/src/hub-server.ts +767 -136
  61. package/src/hub-settings.ts +259 -0
  62. package/src/hub.ts +298 -150
  63. package/src/install-source.ts +291 -0
  64. package/src/jwt-sign.ts +265 -5
  65. package/src/module-manifest.ts +48 -10
  66. package/src/oauth-handlers.ts +262 -56
  67. package/src/oauth-ui.ts +23 -2
  68. package/src/operator-token.ts +349 -18
  69. package/src/origin-check.ts +127 -0
  70. package/src/rate-limit.ts +5 -2
  71. package/src/request-protocol.ts +48 -0
  72. package/src/scope-explanations.ts +33 -2
  73. package/src/sessions.ts +30 -18
  74. package/src/setup-wizard.ts +2009 -0
  75. package/src/supervisor.ts +411 -0
  76. package/src/vault-name.ts +71 -0
  77. package/src/well-known.ts +54 -1
  78. package/web/ui/dist/assets/index-BDSEsaBY.css +1 -0
  79. package/web/ui/dist/assets/index-CP07NbdF.js +61 -0
  80. package/web/ui/dist/index.html +2 -2
  81. package/src/__tests__/admin-config.test.ts +0 -281
  82. package/src/admin-config-ui.ts +0 -534
  83. package/src/admin-config.ts +0 -226
  84. package/web/ui/dist/assets/index-BKzPDdB0.js +0 -60
  85. package/web/ui/dist/assets/index-Dyk6g7vT.css +0 -1
@@ -0,0 +1,585 @@
1
+ /**
2
+ * `/api/modules/:short/*` POST endpoints + `/api/modules/operations/:id`
3
+ * — module lifecycle operations driven from the admin SPA.
4
+ *
5
+ * Two operation classes:
6
+ *
7
+ * - **Synchronous** (restart, uninstall): handler runs the work
8
+ * inline and returns the new state in the response body. Fast
9
+ * enough that the UI just shows a spinner for the request
10
+ * round-trip — no operation_id needed.
11
+ *
12
+ * - **Asynchronous** (install, upgrade): handler kicks off work via
13
+ * `Bun.spawn` for `bun add` and returns 202 + `{operation_id}`
14
+ * immediately. The UI polls `GET /api/modules/operations/:id`
15
+ * every ~1s until the operation reaches a terminal state. This
16
+ * decouples the npm download (which can take 10-60s on a slow
17
+ * link) from the request timeout.
18
+ *
19
+ * Operation state lives in an in-memory registry — a singleton Map
20
+ * keyed by uuid. State is transient by design: a hub restart drops
21
+ * pending ops, which is the correct behavior because the underlying
22
+ * `bun add` is no longer running and the supervisor's own state is
23
+ * the source of truth post-restart. The UI re-polls /api/modules to
24
+ * re-derive what's actually installed.
25
+ *
26
+ * Bearer-gated on `parachute:host:admin` (destructive ops). Diverges
27
+ * from the read-only `/api/modules` GET which sits on the broader
28
+ * `:host:auth` scope: reading the catalog is part of the auth
29
+ * surface, mutating it is admin-only. A `:auth`-only automation token
30
+ * gets 403 here; the SPA's host-admin mint
31
+ * (`/admin/host-admin-token`) carries both scopes so the UI path is
32
+ * unaffected.
33
+ */
34
+
35
+ import type { Database } from "bun:sqlite";
36
+ import { randomUUID } from "node:crypto";
37
+ import { CURATED_MODULES, type CuratedModuleShort } from "./api-modules.ts";
38
+ import { getModuleInstallChannel } from "./hub-settings.ts";
39
+ import { validateAccessToken } from "./jwt-sign.ts";
40
+ import { FIRST_PARTY_FALLBACKS, type ServiceSpec, composeServiceSpec } from "./service-spec.ts";
41
+ import { findService, readManifest, removeService } from "./services-manifest.ts";
42
+ import type { ModuleState, SpawnRequest, Supervisor } from "./supervisor.ts";
43
+
44
+ /**
45
+ * Scope required for every POST + operation-poll endpoint here.
46
+ *
47
+ * `:host:admin` (not `:host:auth`) because install / upgrade /
48
+ * uninstall change the running set of system components — destructive
49
+ * by definition. The SPA mints both scopes through
50
+ * `/admin/host-admin-token` so its bearer carries this; an automation
51
+ * caller minted with `--scope-set auth` gets 403 from these endpoints,
52
+ * which is the intended security boundary.
53
+ */
54
+ export const API_MODULES_OPS_REQUIRED_SCOPE = "parachute:host:admin";
55
+
56
+ export type OperationKind = "install" | "upgrade" | "restart" | "uninstall";
57
+ export type OperationStatus = "pending" | "running" | "succeeded" | "failed";
58
+
59
+ export interface Operation {
60
+ id: string;
61
+ kind: OperationKind;
62
+ short: string;
63
+ status: OperationStatus;
64
+ /** Sparse log of progress events surfaced to the UI ("running bun add…", etc). */
65
+ log: string[];
66
+ /** Error message when status is `failed`. Mirrored from the underlying throw. */
67
+ error?: string;
68
+ startedAt: string;
69
+ finishedAt?: string;
70
+ }
71
+
72
+ export interface OperationsRegistry {
73
+ create(kind: OperationKind, short: string): Operation;
74
+ get(id: string): Operation | undefined;
75
+ /** Append a log line + (optionally) advance status. */
76
+ update(id: string, patch: Partial<Pick<Operation, "status" | "error">>, logLine?: string): void;
77
+ }
78
+
79
+ /**
80
+ * Process-local operations registry. One Map for the lifetime of
81
+ * `parachute serve`. Tests opt into a fresh registry per case via
82
+ * `_resetOperationsRegistryForTests`.
83
+ */
84
+ class InMemoryOperationsRegistry implements OperationsRegistry {
85
+ private readonly ops = new Map<string, Operation>();
86
+ private readonly clock: () => Date;
87
+
88
+ constructor(clock: () => Date = () => new Date()) {
89
+ this.clock = clock;
90
+ }
91
+
92
+ create(kind: OperationKind, short: string): Operation {
93
+ const op: Operation = {
94
+ id: randomUUID(),
95
+ kind,
96
+ short,
97
+ status: "pending",
98
+ log: [],
99
+ startedAt: this.clock().toISOString(),
100
+ };
101
+ this.ops.set(op.id, op);
102
+ return op;
103
+ }
104
+
105
+ get(id: string): Operation | undefined {
106
+ return this.ops.get(id);
107
+ }
108
+
109
+ update(id: string, patch: Partial<Pick<Operation, "status" | "error">>, logLine?: string): void {
110
+ const op = this.ops.get(id);
111
+ if (!op) return;
112
+ if (patch.status) op.status = patch.status;
113
+ if (patch.error !== undefined) op.error = patch.error;
114
+ if (logLine) op.log.push(logLine);
115
+ if (patch.status === "succeeded" || patch.status === "failed") {
116
+ op.finishedAt = this.clock().toISOString();
117
+ }
118
+ }
119
+ }
120
+
121
+ const defaultRegistry = new InMemoryOperationsRegistry();
122
+
123
+ /**
124
+ * Access the process-singleton operations registry. Non-API callers
125
+ * (the first-boot wizard, hub#259) hand this to `runInstall` so the
126
+ * resulting op is poll-able through the same
127
+ * `/api/modules/operations/:id` surface the SPA uses — a stale tab
128
+ * watching the wizard's poll-cookie URL can still hand off mid-flight
129
+ * to the admin UI's module-management page after setup completes.
130
+ */
131
+ export function getDefaultOperationsRegistry(): OperationsRegistry {
132
+ return defaultRegistry;
133
+ }
134
+
135
+ /** Reset the singleton operations registry — tests call between cases. */
136
+ export function _resetOperationsRegistryForTests(): void {
137
+ // The Map underneath is private; re-create the singleton by replacing
138
+ // every entry. Cheaper than re-exporting a mutable reference.
139
+ const r = defaultRegistry as unknown as { ops: Map<string, Operation> };
140
+ r.ops.clear();
141
+ }
142
+
143
+ export interface RunOpts {
144
+ /** stdio-inheriting Bun.spawn wrapper for `bun add` / `bun remove`. */
145
+ run?: (cmd: readonly string[]) => Promise<number>;
146
+ }
147
+
148
+ export interface ApiModulesOpsDeps {
149
+ db: Database;
150
+ issuer: string;
151
+ manifestPath: string;
152
+ configDir: string;
153
+ supervisor: Supervisor;
154
+ /**
155
+ * Override the operations registry (test seam). Production uses the
156
+ * process-singleton; tests inject one with a deterministic clock so
157
+ * `startedAt`/`finishedAt` are stable.
158
+ */
159
+ registry?: OperationsRegistry;
160
+ /**
161
+ * Override the shell runner (test seam). Production spawns `bun add`
162
+ * / `bun remove` for real; tests stub to a fast in-memory function
163
+ * that returns a chosen exit code without touching the filesystem.
164
+ */
165
+ run?: (cmd: readonly string[]) => Promise<number>;
166
+ /** Override the cwd for the install dir lookup (BUN_INSTALL-aware). */
167
+ bunInstallDir?: string;
168
+ /**
169
+ * Override `findGlobalInstall`. Production probes bun's globals
170
+ * (BUN_INSTALL-aware via `${BUN_INSTALL}/install/global/...`); tests
171
+ * inject a fake. Returns the path to the installed package.json or
172
+ * null when not found.
173
+ */
174
+ findGlobalInstall?: (pkg: string) => string | null;
175
+ /**
176
+ * Extra env vars merged onto the supervised child at spawn time (hub#267).
177
+ *
178
+ * The first-boot wizard uses this to pass `PARACHUTE_VAULT_NAME=<typed>`
179
+ * through to vault's first-boot path so the operator-typed name flows
180
+ * end-to-end (vault's `server.ts` reads the env var on its first-boot
181
+ * branch and creates the vault under that name instead of the hard-coded
182
+ * `default`). Generic enough that future env-driven config (e.g.
183
+ * `SCRIBE_MODEL`) can ride the same seam without growing a new field.
184
+ *
185
+ * Threaded to the supervisor's `SpawnRequest.env` — the merge happens
186
+ * inside `Bun.spawn` at child spawn time; we don't mutate `process.env`.
187
+ */
188
+ spawnEnv?: Record<string, string>;
189
+ }
190
+
191
+ interface PathMatch {
192
+ short: CuratedModuleShort;
193
+ rest: string;
194
+ }
195
+
196
+ /**
197
+ * Parse `/api/modules/<short>/<rest>` into the canonical short name +
198
+ * the action suffix. Rejects unknown shorts to keep arbitrary
199
+ * services.json names from driving the install pathway (curated-only
200
+ * for v0.6).
201
+ */
202
+ export function parseModulesPath(pathname: string): PathMatch | undefined {
203
+ const prefix = "/api/modules/";
204
+ if (!pathname.startsWith(prefix)) return undefined;
205
+ const tail = pathname.slice(prefix.length);
206
+ const slash = tail.indexOf("/");
207
+ if (slash <= 0) return undefined;
208
+ const short = tail.slice(0, slash);
209
+ const rest = tail.slice(slash + 1);
210
+ if (!CURATED_MODULES.includes(short as CuratedModuleShort)) return undefined;
211
+ return { short: short as CuratedModuleShort, rest };
212
+ }
213
+
214
+ async function authorize(req: Request, deps: ApiModulesOpsDeps): Promise<Response | undefined> {
215
+ const auth = req.headers.get("authorization");
216
+ if (!auth || !auth.startsWith("Bearer ")) {
217
+ return jsonError(401, "unauthenticated", "Authorization: Bearer <token> required");
218
+ }
219
+ const bearer = auth.slice("Bearer ".length).trim();
220
+ if (!bearer) return jsonError(401, "unauthenticated", "empty bearer token");
221
+ try {
222
+ const validated = await validateAccessToken(deps.db, bearer, deps.issuer);
223
+ if (typeof validated.payload.sub !== "string" || validated.payload.sub.length === 0) {
224
+ return jsonError(401, "unauthenticated", "bearer token has no sub claim");
225
+ }
226
+ const scopes =
227
+ typeof validated.payload.scope === "string"
228
+ ? validated.payload.scope.split(/\s+/).filter((s) => s.length > 0)
229
+ : [];
230
+ if (!scopes.includes(API_MODULES_OPS_REQUIRED_SCOPE)) {
231
+ return jsonError(
232
+ 403,
233
+ "insufficient_scope",
234
+ `bearer token lacks ${API_MODULES_OPS_REQUIRED_SCOPE}`,
235
+ );
236
+ }
237
+ } catch (err) {
238
+ const msg = err instanceof Error ? err.message : String(err);
239
+ return jsonError(401, "unauthenticated", `bearer token invalid — ${msg}`);
240
+ }
241
+ return undefined;
242
+ }
243
+
244
+ /**
245
+ * Resolve the canonical `ServiceSpec` for a curated module short — the
246
+ * pair of (package, manifest) the supervisor + install runner act on.
247
+ * Exported so non-API callers (the first-boot wizard, hub#259) can
248
+ * reach the same spec the API handlers use without duplicating the
249
+ * FIRST_PARTY_FALLBACKS lookup.
250
+ */
251
+ export function specFor(short: CuratedModuleShort): ServiceSpec {
252
+ const fb = FIRST_PARTY_FALLBACKS[short];
253
+ // Curated set is a const; every entry has a fallback. The non-null
254
+ // assertion is safe because CURATED_MODULES is a tuple-literal
255
+ // intersected with the FIRST_PARTY_FALLBACKS key set.
256
+ if (!fb) throw new Error(`internal: no fallback for curated ${short}`);
257
+ return composeServiceSpec({
258
+ packageName: fb.package,
259
+ manifest: fb.manifest,
260
+ extras: fb.extras,
261
+ });
262
+ }
263
+
264
+ function defaultRun(cmd: readonly string[]): Promise<number> {
265
+ const proc = Bun.spawn([...cmd], { stdio: ["ignore", "inherit", "inherit"] });
266
+ return proc.exited;
267
+ }
268
+
269
+ /**
270
+ * Spawn the supervised child for `short`, using the spec's startCmd
271
+ * and the current services.json entry (so notes' port-derived
272
+ * startCmd resolves correctly).
273
+ */
274
+ async function spawnSupervised(
275
+ short: CuratedModuleShort,
276
+ spec: ServiceSpec,
277
+ deps: ApiModulesOpsDeps,
278
+ ): Promise<ModuleState | undefined> {
279
+ const manifest = readManifest(deps.manifestPath);
280
+ const entry = manifest.services.find((s) => s.name === spec.manifestName);
281
+ if (!entry) return undefined;
282
+ const cmd = spec.startCmd?.(entry);
283
+ if (!cmd || cmd.length === 0) return undefined;
284
+ const req: SpawnRequest = {
285
+ short,
286
+ cmd,
287
+ ...(entry.installDir ? { cwd: entry.installDir } : {}),
288
+ ...(deps.spawnEnv && Object.keys(deps.spawnEnv).length > 0 ? { env: deps.spawnEnv } : {}),
289
+ };
290
+ return deps.supervisor.start(req);
291
+ }
292
+
293
+ /**
294
+ * POST /api/modules/:short/install — async.
295
+ *
296
+ * Schedules a `bun add @openparachute/<svc>@latest` followed by
297
+ * services.json seed + supervisor spawn. Returns 202 + operation_id
298
+ * immediately; the UI polls /api/modules/operations/:id.
299
+ *
300
+ * Idempotent: if the module is already installed AND its supervisor
301
+ * state is running, the operation completes immediately with status
302
+ * `succeeded` and a "already running" log line. The UI doesn't have
303
+ * to special-case "this was a no-op."
304
+ */
305
+ export async function handleInstall(
306
+ req: Request,
307
+ short: CuratedModuleShort,
308
+ deps: ApiModulesOpsDeps,
309
+ ): Promise<Response> {
310
+ if (req.method !== "POST") return jsonError(405, "method_not_allowed", "use POST");
311
+ const authFail = await authorize(req, deps);
312
+ if (authFail) return authFail;
313
+
314
+ const registry = deps.registry ?? defaultRegistry;
315
+ const op = registry.create("install", short);
316
+
317
+ // Idempotent short-circuit: already installed + running → mark
318
+ // succeeded synchronously so the UI's "operation finished"
319
+ // pathway works the same as a fresh install.
320
+ const spec = specFor(short);
321
+ const existing = findService(spec.manifestName, deps.manifestPath);
322
+ const state = deps.supervisor.get(short);
323
+ if (existing && state?.status === "running") {
324
+ registry.update(op.id, { status: "succeeded" }, `${short} already installed + running`);
325
+ return acceptedOp(op.id);
326
+ }
327
+
328
+ // Kick off the async work. We DON'T await — the response goes back
329
+ // immediately + the work runs in the background. Errors get logged
330
+ // to the operation; nothing throws back to the request handler.
331
+ void runInstall(op.id, short, spec, deps).catch((err) => {
332
+ const msg = err instanceof Error ? err.message : String(err);
333
+ registry.update(op.id, { status: "failed", error: msg }, `install failed: ${msg}`);
334
+ });
335
+
336
+ return acceptedOp(op.id);
337
+ }
338
+
339
+ /**
340
+ * Internal install runner. Exported so non-API callers (the first-boot
341
+ * wizard at `/admin/setup`, hub#259) can drive the same install →
342
+ * services.json-seed → supervisor-spawn sequence without re-fabricating
343
+ * an HTTP request + bearer token just to hit `handleInstall`.
344
+ *
345
+ * The op-id + registry threading is identical to the API path; the
346
+ * wizard creates its own op, awaits this function, and surfaces the
347
+ * resulting state to the operator.
348
+ */
349
+ export async function runInstall(
350
+ opId: string,
351
+ short: CuratedModuleShort,
352
+ spec: ServiceSpec,
353
+ deps: ApiModulesOpsDeps,
354
+ ): Promise<void> {
355
+ const registry = deps.registry ?? defaultRegistry;
356
+ const run = deps.run ?? defaultRun;
357
+ // hub#275: operator-settable channel (`latest` | `rc`). Read on every
358
+ // op so a toggle change applies to the next install without a hub
359
+ // restart. The hub-settings layer seeds from PARACHUTE_MODULE_CHANNEL
360
+ // on first read; after that the row is source of truth.
361
+ const channel = getModuleInstallChannel(deps.db);
362
+ const spec_str = `${spec.package}@${channel}`;
363
+ registry.update(opId, { status: "running" }, `running bun add -g ${spec_str}`);
364
+ const code = await run(["bun", "add", "-g", spec_str]);
365
+ if (code !== 0) {
366
+ // Bun 1.2.x lockfile-recovery noise: probe the global prefix
367
+ // before treating non-zero as fatal. Mirrors the same defense in
368
+ // commands/install.ts.
369
+ const findGlobalInstall = deps.findGlobalInstall;
370
+ const probed = findGlobalInstall?.(spec.package) ?? null;
371
+ if (!probed) {
372
+ registry.update(
373
+ opId,
374
+ { status: "failed", error: `bun add -g exited ${code}` },
375
+ `bun add -g ${spec_str} failed (exit ${code})`,
376
+ );
377
+ return;
378
+ }
379
+ registry.update(opId, {}, `bun add reported exit ${code} but package landed at ${probed}`);
380
+ }
381
+
382
+ // Seed services.json if absent (the install flow does this for the
383
+ // CLI; we replicate the seed-only piece here so the supervisor's
384
+ // boot path can spawn next time).
385
+ if (spec.seedEntry) {
386
+ const existing = findService(spec.manifestName, deps.manifestPath);
387
+ if (!existing) {
388
+ const entry = spec.seedEntry();
389
+ const { upsertService } = await import("./services-manifest.ts");
390
+ upsertService(entry, deps.manifestPath);
391
+ registry.update(opId, {}, `seeded services.json entry for ${short}`);
392
+ }
393
+ }
394
+
395
+ // Spawn the child via the supervisor. Boot-spawn semantics apply.
396
+ const state = await spawnSupervised(short, spec, deps);
397
+ if (!state) {
398
+ registry.update(
399
+ opId,
400
+ { status: "failed", error: "module installed but spawn failed (no startCmd resolved)" },
401
+ `${short}: install succeeded but no startCmd resolvable from services.json`,
402
+ );
403
+ return;
404
+ }
405
+ registry.update(opId, { status: "succeeded" }, `${short} installed + spawned (pid ${state.pid})`);
406
+ }
407
+
408
+ /**
409
+ * POST /api/modules/:short/restart — synchronous.
410
+ *
411
+ * Routes through `supervisor.restart(short)` which does stop → await
412
+ * exit → start with the same SpawnRequest. Returns the new state in
413
+ * the body — the UI's spinner can clear as soon as the response
414
+ * arrives, no operation poll needed.
415
+ */
416
+ export async function handleRestart(
417
+ req: Request,
418
+ short: CuratedModuleShort,
419
+ deps: ApiModulesOpsDeps,
420
+ ): Promise<Response> {
421
+ if (req.method !== "POST") return jsonError(405, "method_not_allowed", "use POST");
422
+ const authFail = await authorize(req, deps);
423
+ if (authFail) return authFail;
424
+
425
+ const state = await deps.supervisor.restart(short);
426
+ if (!state) {
427
+ return jsonError(
428
+ 404,
429
+ "not_supervised",
430
+ `${short} is not currently supervised — install it first`,
431
+ );
432
+ }
433
+ return jsonOk({ short, state });
434
+ }
435
+
436
+ /**
437
+ * POST /api/modules/:short/upgrade — async.
438
+ *
439
+ * Runs `bun add -g @openparachute/<svc>@latest` then restarts the
440
+ * supervised child. Same operation-poll pattern as install.
441
+ */
442
+ export async function handleUpgrade(
443
+ req: Request,
444
+ short: CuratedModuleShort,
445
+ deps: ApiModulesOpsDeps,
446
+ ): Promise<Response> {
447
+ if (req.method !== "POST") return jsonError(405, "method_not_allowed", "use POST");
448
+ const authFail = await authorize(req, deps);
449
+ if (authFail) return authFail;
450
+
451
+ const registry = deps.registry ?? defaultRegistry;
452
+ const op = registry.create("upgrade", short);
453
+ const spec = specFor(short);
454
+
455
+ void runUpgrade(op.id, short, spec, deps).catch((err) => {
456
+ const msg = err instanceof Error ? err.message : String(err);
457
+ registry.update(op.id, { status: "failed", error: msg }, `upgrade failed: ${msg}`);
458
+ });
459
+ return acceptedOp(op.id);
460
+ }
461
+
462
+ async function runUpgrade(
463
+ opId: string,
464
+ short: CuratedModuleShort,
465
+ spec: ServiceSpec,
466
+ deps: ApiModulesOpsDeps,
467
+ ): Promise<void> {
468
+ const registry = deps.registry ?? defaultRegistry;
469
+ const run = deps.run ?? defaultRun;
470
+ const channel = getModuleInstallChannel(deps.db);
471
+ const spec_str = `${spec.package}@${channel}`;
472
+ registry.update(opId, { status: "running" }, `running bun add -g ${spec_str}`);
473
+ const code = await run(["bun", "add", "-g", spec_str]);
474
+ if (code !== 0) {
475
+ const findGlobalInstall = deps.findGlobalInstall;
476
+ const probed = findGlobalInstall?.(spec.package) ?? null;
477
+ if (!probed) {
478
+ registry.update(
479
+ opId,
480
+ { status: "failed", error: `bun add -g exited ${code}` },
481
+ `bun add -g ${spec_str} failed (exit ${code})`,
482
+ );
483
+ return;
484
+ }
485
+ registry.update(opId, {}, `bun add reported exit ${code} but package landed at ${probed}`);
486
+ }
487
+
488
+ const state = await deps.supervisor.restart(short);
489
+ if (!state) {
490
+ registry.update(
491
+ opId,
492
+ { status: "failed", error: "upgrade installed but supervisor restart found no module" },
493
+ `${short}: upgraded but supervisor had no live entry — try install first`,
494
+ );
495
+ return;
496
+ }
497
+ registry.update(
498
+ opId,
499
+ { status: "succeeded" },
500
+ `${short} upgraded + restarted (pid ${state.pid})`,
501
+ );
502
+ }
503
+
504
+ /**
505
+ * POST /api/modules/:short/uninstall — synchronous.
506
+ *
507
+ * Stops the supervised child, removes the services.json row, runs
508
+ * `bun remove -g <pkg>`. Returns the final state for UI confirmation.
509
+ * Idempotent: missing supervisor entry / missing services.json row /
510
+ * missing global install are all handled gracefully (the operation
511
+ * succeeds with a per-step "already gone" log).
512
+ */
513
+ export async function handleUninstall(
514
+ req: Request,
515
+ short: CuratedModuleShort,
516
+ deps: ApiModulesOpsDeps,
517
+ ): Promise<Response> {
518
+ if (req.method !== "POST") return jsonError(405, "method_not_allowed", "use POST");
519
+ const authFail = await authorize(req, deps);
520
+ if (authFail) return authFail;
521
+
522
+ const spec = specFor(short);
523
+ const log: string[] = [];
524
+
525
+ // 1. Stop the supervised child (idempotent — null on missing).
526
+ const stopped = await deps.supervisor.stop(short);
527
+ log.push(stopped ? `${short} supervisor stopped` : `${short} not supervised`);
528
+
529
+ // 2. Drop the services.json row (idempotent — readManifest is empty if missing).
530
+ const before = readManifest(deps.manifestPath);
531
+ if (before.services.some((s) => s.name === spec.manifestName)) {
532
+ removeService(spec.manifestName, deps.manifestPath);
533
+ log.push(`removed ${spec.manifestName} from services.json`);
534
+ } else {
535
+ log.push(`${spec.manifestName} not in services.json`);
536
+ }
537
+
538
+ // 3. bun remove -g (idempotent on missing — bun returns 0).
539
+ const run = deps.run ?? defaultRun;
540
+ const code = await run(["bun", "remove", "-g", spec.package]);
541
+ log.push(`bun remove -g ${spec.package} exited ${code}`);
542
+
543
+ return jsonOk({ short, log });
544
+ }
545
+
546
+ /**
547
+ * GET /api/modules/operations/:id — poll operation status.
548
+ */
549
+ export async function handleOperationGet(
550
+ req: Request,
551
+ opId: string,
552
+ deps: ApiModulesOpsDeps,
553
+ ): Promise<Response> {
554
+ if (req.method !== "GET") return jsonError(405, "method_not_allowed", "use GET");
555
+ const authFail = await authorize(req, deps);
556
+ if (authFail) return authFail;
557
+
558
+ const registry = deps.registry ?? defaultRegistry;
559
+ const op = registry.get(opId);
560
+ if (!op) {
561
+ return jsonError(404, "not_found", `no operation with id ${opId}`);
562
+ }
563
+ return jsonOk(op);
564
+ }
565
+
566
+ function jsonError(status: number, code: string, description: string): Response {
567
+ return new Response(JSON.stringify({ error: code, error_description: description }), {
568
+ status,
569
+ headers: { "content-type": "application/json" },
570
+ });
571
+ }
572
+
573
+ function jsonOk(body: unknown): Response {
574
+ return new Response(JSON.stringify(body), {
575
+ status: 200,
576
+ headers: { "content-type": "application/json" },
577
+ });
578
+ }
579
+
580
+ function acceptedOp(opId: string): Response {
581
+ return new Response(JSON.stringify({ operation_id: opId }), {
582
+ status: 202,
583
+ headers: { "content-type": "application/json" },
584
+ });
585
+ }