@openparachute/hub 0.3.0-rc.1 → 0.5.1

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 (91) hide show
  1. package/README.md +19 -17
  2. package/package.json +15 -4
  3. package/src/__tests__/admin-auth.test.ts +197 -0
  4. package/src/__tests__/admin-config.test.ts +281 -0
  5. package/src/__tests__/admin-grants.test.ts +271 -0
  6. package/src/__tests__/admin-handlers.test.ts +530 -0
  7. package/src/__tests__/admin-host-admin-token.test.ts +115 -0
  8. package/src/__tests__/admin-vault-admin-token.test.ts +190 -0
  9. package/src/__tests__/admin-vaults.test.ts +615 -0
  10. package/src/__tests__/auth-codes.test.ts +253 -0
  11. package/src/__tests__/auth.test.ts +1063 -17
  12. package/src/__tests__/cli.test.ts +50 -0
  13. package/src/__tests__/clients.test.ts +264 -0
  14. package/src/__tests__/cloudflare-state.test.ts +167 -7
  15. package/src/__tests__/csrf.test.ts +117 -0
  16. package/src/__tests__/expose-cloudflare.test.ts +232 -37
  17. package/src/__tests__/expose-off-auto.test.ts +15 -9
  18. package/src/__tests__/expose-public-auto.test.ts +153 -0
  19. package/src/__tests__/expose.test.ts +216 -24
  20. package/src/__tests__/grants.test.ts +164 -0
  21. package/src/__tests__/hub-db.test.ts +153 -0
  22. package/src/__tests__/hub-server.test.ts +984 -26
  23. package/src/__tests__/hub.test.ts +56 -49
  24. package/src/__tests__/install.test.ts +327 -3
  25. package/src/__tests__/jwks.test.ts +37 -0
  26. package/src/__tests__/jwt-sign.test.ts +361 -0
  27. package/src/__tests__/lifecycle.test.ts +616 -5
  28. package/src/__tests__/module-manifest.test.ts +183 -0
  29. package/src/__tests__/oauth-handlers.test.ts +3112 -0
  30. package/src/__tests__/oauth-ui.test.ts +253 -0
  31. package/src/__tests__/operator-token.test.ts +140 -0
  32. package/src/__tests__/providers-detect.test.ts +158 -0
  33. package/src/__tests__/scope-explanations.test.ts +108 -0
  34. package/src/__tests__/scope-registry.test.ts +220 -0
  35. package/src/__tests__/services-manifest.test.ts +137 -1
  36. package/src/__tests__/sessions.test.ts +116 -0
  37. package/src/__tests__/setup.test.ts +361 -0
  38. package/src/__tests__/signing-keys.test.ts +153 -0
  39. package/src/__tests__/upgrade.test.ts +541 -0
  40. package/src/__tests__/users.test.ts +154 -0
  41. package/src/__tests__/well-known.test.ts +127 -10
  42. package/src/admin-auth.ts +126 -0
  43. package/src/admin-config-ui.ts +534 -0
  44. package/src/admin-config.ts +226 -0
  45. package/src/admin-grants.ts +160 -0
  46. package/src/admin-handlers.ts +365 -0
  47. package/src/admin-host-admin-token.ts +83 -0
  48. package/src/admin-vault-admin-token.ts +98 -0
  49. package/src/admin-vaults.ts +359 -0
  50. package/src/auth-codes.ts +189 -0
  51. package/src/cli.ts +202 -25
  52. package/src/clients.ts +210 -0
  53. package/src/cloudflare/config.ts +25 -6
  54. package/src/cloudflare/state.ts +108 -28
  55. package/src/commands/auth.ts +851 -19
  56. package/src/commands/expose-cloudflare.ts +85 -45
  57. package/src/commands/expose-interactive.ts +20 -44
  58. package/src/commands/expose-off-auto.ts +27 -11
  59. package/src/commands/expose-public-auto.ts +179 -0
  60. package/src/commands/expose.ts +63 -32
  61. package/src/commands/install.ts +337 -48
  62. package/src/commands/lifecycle.ts +269 -38
  63. package/src/commands/setup.ts +366 -0
  64. package/src/commands/status.ts +4 -1
  65. package/src/commands/upgrade.ts +429 -0
  66. package/src/csrf.ts +101 -0
  67. package/src/grants.ts +142 -0
  68. package/src/help.ts +133 -19
  69. package/src/hub-control.ts +12 -0
  70. package/src/hub-db.ts +164 -0
  71. package/src/hub-server.ts +643 -22
  72. package/src/hub.ts +97 -390
  73. package/src/jwks.ts +41 -0
  74. package/src/jwt-audience.ts +40 -0
  75. package/src/jwt-sign.ts +275 -0
  76. package/src/module-manifest.ts +435 -0
  77. package/src/oauth-handlers.ts +1175 -0
  78. package/src/oauth-ui.ts +582 -0
  79. package/src/operator-token.ts +129 -0
  80. package/src/providers/detect.ts +97 -0
  81. package/src/scope-explanations.ts +137 -0
  82. package/src/scope-registry.ts +158 -0
  83. package/src/service-spec.ts +270 -97
  84. package/src/services-manifest.ts +57 -1
  85. package/src/sessions.ts +115 -0
  86. package/src/signing-keys.ts +120 -0
  87. package/src/users.ts +144 -0
  88. package/src/well-known.ts +62 -26
  89. package/web/ui/dist/assets/index-BKzPDdB0.js +60 -0
  90. package/web/ui/dist/assets/index-Dyk6g7vT.css +1 -0
  91. package/web/ui/dist/index.html +14 -0
@@ -1,4 +1,5 @@
1
1
  import { fileURLToPath } from "node:url";
2
+ import { type ModuleManifest, readModuleManifest } from "./module-manifest.ts";
2
3
  import type { ServiceEntry } from "./services-manifest.ts";
3
4
 
4
5
  /**
@@ -74,6 +75,71 @@ export function isCanonicalPort(port: number): boolean {
74
75
  */
75
76
  export type ServiceKind = "api" | "tool" | "frontend";
76
77
 
78
+ /**
79
+ * Imperative behaviors that don't fit the static `module.json` schema.
80
+ *
81
+ * First-party only. Each first-party fallback declares its own extras
82
+ * alongside its embedded manifest; when the upstream module ships its own
83
+ * `.parachute/module.json`, the corresponding fallback entry — extras and
84
+ * manifest both — gets deleted in one PR per module.
85
+ *
86
+ * Third-party modules don't get extras: anything they need at install time
87
+ * has to fit the manifest contract (or live as a runtime concern at
88
+ * `/.parachute/info`). The boundary is intentional — extras is the seam
89
+ * for transitional behavior, not a permanent escape hatch.
90
+ */
91
+ export interface FirstPartyExtras {
92
+ /** Init command spawned post-install (e.g., `["parachute-vault", "init"]`). */
93
+ readonly init?: readonly string[];
94
+ /**
95
+ * Override startCmd to take the per-install services.json entry. Used by
96
+ * notes (which needs `--port` + `--mount` derived from the entry); plain
97
+ * static-argv `manifest.startCmd` covers everything else.
98
+ */
99
+ readonly startCmd?: (entry: ServiceEntry) => readonly string[] | undefined;
100
+ /** Lines printed at the end of `parachute install <svc>`. */
101
+ readonly postInstallFooter?: () => readonly string[];
102
+ /**
103
+ * Does the service gate its endpoints behind auth today? Drives
104
+ * `effectivePublicExposure`'s default for api/tool services. True for
105
+ * vault/channel; conservatively false for scribe until its auth-gate ships.
106
+ */
107
+ readonly hasAuth?: boolean;
108
+ /**
109
+ * Override the canonical reachable URL for `parachute status`. Most
110
+ * services use `port + paths[0]`; vault appends `/mcp`, scribe is at root.
111
+ */
112
+ readonly urlForEntry?: (entry: ServiceEntry) => string | undefined;
113
+ }
114
+
115
+ /**
116
+ * Vendored fallback for a first-party module.
117
+ *
118
+ * The CLI prefers the installed module's own `.parachute/module.json` when
119
+ * present and falls back to this embedded manifest otherwise. The plan is
120
+ * to delete each fallback as its upstream module starts shipping the real
121
+ * file — see the `// FALLBACK: Delete when ...` markers below for the
122
+ * specific upstream reference per entry.
123
+ *
124
+ * Third-party modules never have a fallback; they ship `module.json` or
125
+ * the install hard-errors.
126
+ */
127
+ export interface FirstPartyFallback {
128
+ /** npm package name for `bun add -g`. */
129
+ readonly package: string;
130
+ /** Embedded module.json — used when the install dir has no `.parachute/module.json`. */
131
+ readonly manifest: ModuleManifest;
132
+ /** Imperative behaviors not expressible in module.json. Optional. */
133
+ readonly extras?: FirstPartyExtras;
134
+ }
135
+
136
+ /**
137
+ * Façade combining a module's manifest with its install-time extras. All
138
+ * consumers (install, lifecycle, status, expose) read this — they don't
139
+ * care whether it came from a vendored fallback or a real
140
+ * `.parachute/module.json`. Non-readonly nothing — every field is read-only
141
+ * from the consumer's perspective.
142
+ */
77
143
  export interface ServiceSpec {
78
144
  readonly package: string;
79
145
  readonly manifestName: string;
@@ -81,55 +147,23 @@ export interface ServiceSpec {
81
147
  /**
82
148
  * Command to spawn for `parachute start <svc>`. Receives the services.json
83
149
  * entry so commands that need per-install data (e.g., the notes static-serve
84
- * shim needs the configured port) can pull it from there.
85
- *
86
- * Returns `undefined` to declare "lifecycle not supported for this service."
87
- * That never applies today but leaves a seam for future services that
88
- * shouldn't be managed by `parachute start`.
150
+ * shim needs the configured port) can pull it from there. Returns
151
+ * `undefined` to declare "lifecycle not supported for this service."
89
152
  */
90
153
  readonly startCmd?: (entry: ServiceEntry) => readonly string[] | undefined;
91
154
  /**
92
155
  * Canonical initial services.json entry used when the service hasn't
93
- * written its own entry yet. Fires post-install only if `findService`
94
- * returns undefined — normal npm installs hit this almost never (the
95
- * service's init or first boot writes the authoritative entry first).
96
- *
97
- * Main use case: `bun link` local-dev installs where the service hasn't
98
- * run yet but `parachute expose` / `parachute start` need an entry to
99
- * plan against. First service boot overwrites the seed with its own
100
- * authoritative version.
156
+ * written its own. Fires post-install only if `findService` returns
157
+ * undefined — normal npm installs hit this almost never (the service's
158
+ * init or first boot writes the authoritative entry first). Main use case:
159
+ * `bun link` local-dev installs where the service hasn't run yet but
160
+ * `parachute expose` / `parachute start` need an entry to plan against.
161
+ * First service boot overwrites the seed with its own authoritative version.
101
162
  */
102
163
  readonly seedEntry?: () => ServiceEntry;
103
- /**
104
- * Declares the service's broad shape. Drives exposure defaults: api/tool
105
- * services without auth fall back to `publicExposure: "auth-required"`
106
- * (treated as loopback at launch); frontends default to "allowed".
107
- */
108
- readonly kind?: ServiceKind;
109
- /**
110
- * Does the service gate its endpoints behind auth today? Used together with
111
- * `kind` to pick a safe default when the services.json entry omits
112
- * `publicExposure`. True for vault/channel (owner-authenticated);
113
- * conservatively false for scribe until its auth-gate ships.
114
- */
164
+ readonly kind: ServiceKind;
115
165
  readonly hasAuth?: boolean;
116
- /**
117
- * Canonical reachable URL for the service given its manifest entry. Drives
118
- * the URL column in `parachute status` and any other place we need to
119
- * render "where do I point a client?". Most services use port + paths[0],
120
- * but some need to append a fixed suffix (vault's MCP endpoint lives at
121
- * `/vault/<name>/mcp`, not the bare mount path).
122
- *
123
- * Returns undefined when the entry doesn't carry enough info — callers
124
- * should fall back to the bare `http://127.0.0.1:<port>` form.
125
- */
126
166
  readonly urlForEntry?: (entry: ServiceEntry) => string | undefined;
127
- /**
128
- * Lines printed at the end of `parachute install <svc>` so the user has a
129
- * clear next step. Vault's footer comes from `parachute-vault init` itself
130
- * (PR #166) — richer because it can read the freshly-minted API token —
131
- * so vault's spec leaves this off.
132
- */
133
167
  readonly postInstallFooter?: () => readonly string[];
134
168
  }
135
169
 
@@ -150,45 +184,118 @@ function pathBasedUrl(entry: ServiceEntry): string {
150
184
  return `http://127.0.0.1:${entry.port}${path}`;
151
185
  }
152
186
 
153
- export const SERVICE_SPECS: Record<string, ServiceSpec> = {
154
- vault: {
155
- package: "@openparachute/vault",
187
+ /**
188
+ * Build a services.json seed row from a module manifest. Pure: doesn't
189
+ * read the filesystem. The `version` is intentionally `0.0.0-linked` to
190
+ * telegraph "stopgap" — the service's own boot overwrites this entry.
191
+ */
192
+ export function seedEntryFromManifest(manifest: ModuleManifest): ServiceEntry {
193
+ const entry: ServiceEntry = {
194
+ name: manifest.manifestName,
195
+ port: manifest.port,
196
+ paths: [...manifest.paths],
197
+ health: manifest.health,
198
+ version: SEED_VERSION,
199
+ };
200
+ if (manifest.displayName !== undefined) entry.displayName = manifest.displayName;
201
+ if (manifest.tagline !== undefined) entry.tagline = manifest.tagline;
202
+ return entry;
203
+ }
204
+
205
+ /**
206
+ * Build the runtime ServiceSpec façade from a manifest + optional extras.
207
+ * Used by both the first-party-fallback path and the
208
+ * read-installed-`module.json` path so both produce identical specs.
209
+ */
210
+ export function composeServiceSpec(opts: {
211
+ packageName: string;
212
+ manifest: ModuleManifest;
213
+ extras?: FirstPartyExtras;
214
+ }): ServiceSpec {
215
+ const { packageName, manifest, extras } = opts;
216
+ const startCmd = extras?.startCmd ?? (manifest.startCmd ? () => manifest.startCmd : undefined);
217
+ const spec: ServiceSpec = {
218
+ package: packageName,
219
+ manifestName: manifest.manifestName,
220
+ seedEntry: () => seedEntryFromManifest(manifest),
221
+ kind: manifest.kind,
222
+ };
223
+ if (extras?.init !== undefined) (spec as { init?: readonly string[] }).init = extras.init;
224
+ if (startCmd !== undefined) {
225
+ (spec as { startCmd?: (e: ServiceEntry) => readonly string[] | undefined }).startCmd = startCmd;
226
+ }
227
+ if (extras?.hasAuth !== undefined) (spec as { hasAuth?: boolean }).hasAuth = extras.hasAuth;
228
+ if (extras?.urlForEntry !== undefined) {
229
+ (
230
+ spec as {
231
+ urlForEntry?: (e: ServiceEntry) => string | undefined;
232
+ }
233
+ ).urlForEntry = extras.urlForEntry;
234
+ }
235
+ if (extras?.postInstallFooter !== undefined) {
236
+ (spec as { postInstallFooter?: () => readonly string[] }).postInstallFooter =
237
+ extras.postInstallFooter;
238
+ }
239
+ return spec;
240
+ }
241
+
242
+ // ---------------------------------------------------------------------------
243
+ // First-party fallbacks
244
+ //
245
+ // Each entry below is a "delete-when-X-ships" marker — when the upstream
246
+ // module starts publishing its own `.parachute/module.json`, the matching
247
+ // FALLBACK comment names the issue that retires the vendored manifest +
248
+ // extras. One cleanup PR per module; the markers make those PRs a one-grep
249
+ // operation (`rg "FALLBACK: Delete when"`).
250
+ // ---------------------------------------------------------------------------
251
+
252
+ // FALLBACK: Delete when @openparachute/vault ships .parachute/module.json
253
+ // (parachute-vault repo: file follow-up after parachute-hub#56 lands).
254
+ const VAULT_FALLBACK: FirstPartyFallback = {
255
+ package: "@openparachute/vault",
256
+ manifest: {
257
+ name: "vault",
156
258
  manifestName: "parachute-vault",
259
+ displayName: "Vault",
260
+ tagline: "Your owner-authenticated MCP knowledge store.",
261
+ kind: "api",
262
+ port: 1940,
263
+ paths: ["/vault/default"],
264
+ health: "/vault/default/health",
265
+ },
266
+ extras: {
157
267
  init: ["parachute-vault", "init"],
158
268
  startCmd: () => ["parachute-vault", "serve"],
159
- kind: "api",
160
269
  hasAuth: true,
161
- seedEntry: () => ({
162
- name: "parachute-vault",
163
- port: 1940,
164
- paths: ["/vault/default"],
165
- health: "/vault/default/health",
166
- version: SEED_VERSION,
167
- }),
168
270
  // Vault's MCP endpoint lives one segment past the mount path. The bare
169
271
  // `/vault/<name>` URL is the discovery shape; clients (claude.ai et al.)
170
272
  // need `/vault/<name>/mcp` to actually open the stream.
171
273
  urlForEntry: (entry) => `${pathBasedUrl(entry)}/mcp`,
172
274
  },
173
- notes: {
174
- // Frontend product name is "Notes". vault's internal `/api/notes` endpoint
275
+ };
276
+
277
+ // FALLBACK: Delete when @openparachute/notes ships .parachute/module.json
278
+ // (parachute-notes repo: file follow-up after parachute-hub#56 lands).
279
+ const NOTES_FALLBACK: FirstPartyFallback = {
280
+ package: "@openparachute/notes",
281
+ manifest: {
282
+ // Frontend product name is "Notes". Vault's internal `/api/notes` endpoint
175
283
  // is unrelated — different concept (vault data primitive vs. PWA brand).
176
- package: "@openparachute/notes",
284
+ name: "notes",
177
285
  manifestName: "parachute-notes",
286
+ displayName: "Notes",
287
+ tagline: "Notes PWA backed by your vault.",
288
+ kind: "frontend",
289
+ port: 1942,
290
+ paths: ["/notes"],
291
+ health: "/notes/health",
292
+ },
293
+ extras: {
178
294
  startCmd: (entry) => {
179
295
  const first = entry.paths[0] ?? "/notes";
180
296
  const mount = first === "/" ? "" : first.replace(/\/+$/, "");
181
297
  return ["bun", NOTES_SERVE_PATH, "--port", String(entry.port), "--mount", mount];
182
298
  },
183
- kind: "frontend",
184
- seedEntry: () => ({
185
- name: "parachute-notes",
186
- port: 1942,
187
- paths: ["/notes"],
188
- health: "/notes/health",
189
- version: SEED_VERSION,
190
- }),
191
- urlForEntry: pathBasedUrl,
192
299
  postInstallFooter: () => [
193
300
  "",
194
301
  "Open your Notes UI at http://localhost:1942/notes — paste the vault URL",
@@ -196,22 +303,28 @@ export const SERVICE_SPECS: Record<string, ServiceSpec> = {
196
303
  "and the API token from your vault install.",
197
304
  ],
198
305
  },
199
- scribe: {
200
- package: "@openparachute/scribe",
306
+ };
307
+
308
+ // FALLBACK: Delete when @openparachute/scribe ships .parachute/module.json
309
+ // (parachute-scribe repo: file follow-up after parachute-hub#56 lands).
310
+ const SCRIBE_FALLBACK: FirstPartyFallback = {
311
+ package: "@openparachute/scribe",
312
+ manifest: {
313
+ name: "scribe",
201
314
  manifestName: "parachute-scribe",
202
- startCmd: () => ["parachute-scribe", "serve"],
203
- // No auth gate today. Scribe's launch PR adds optional SCRIBE_AUTH_TOKEN;
204
- // once it lands and scribe writes `publicExposure: "allowed"` when a token
205
- // is configured, that explicit declaration overrides this default.
315
+ displayName: "Scribe",
316
+ tagline: "Local audio transcription for vault recordings.",
206
317
  kind: "api",
318
+ port: 1943,
319
+ paths: ["/scribe"],
320
+ health: "/scribe/health",
321
+ startCmd: ["parachute-scribe", "serve"],
322
+ },
323
+ extras: {
324
+ // No auth gate today. Scribe's launch PR adds optional SCRIBE_AUTH_TOKEN;
325
+ // once it lands and scribe writes `publicExposure: "allowed"` when a
326
+ // token is configured, that explicit declaration overrides this default.
207
327
  hasAuth: false,
208
- seedEntry: () => ({
209
- name: "parachute-scribe",
210
- port: 1943,
211
- paths: ["/scribe"],
212
- health: "/scribe/health",
213
- version: SEED_VERSION,
214
- }),
215
328
  // Scribe's API is at the root, not under `/scribe`. The path prefix only
216
329
  // shows up in the health endpoint; clients hit the bare port.
217
330
  urlForEntry: (entry) => `http://127.0.0.1:${entry.port}`,
@@ -224,23 +337,42 @@ export const SERVICE_SPECS: Record<string, ServiceSpec> = {
224
337
  "whisper, groq, openai.",
225
338
  ],
226
339
  },
227
- channel: {
228
- package: "@openparachute/channel",
340
+ };
341
+
342
+ // FALLBACK: Delete when @openparachute/channel ships .parachute/module.json
343
+ // (parachute-channel repo: file follow-up after parachute-hub#56 lands;
344
+ // channel is exploration tier — may be retired before module.json ships).
345
+ const CHANNEL_FALLBACK: FirstPartyFallback = {
346
+ package: "@openparachute/channel",
347
+ manifest: {
348
+ name: "channel",
229
349
  manifestName: "parachute-channel",
230
- startCmd: () => ["parachute-channel", "daemon"],
350
+ displayName: "Channel",
351
+ tagline: "Notification fan-out across modules.",
231
352
  kind: "api",
353
+ port: 1941,
354
+ paths: ["/channel"],
355
+ health: "/channel/health",
356
+ startCmd: ["parachute-channel", "daemon"],
357
+ },
358
+ extras: {
232
359
  hasAuth: true,
233
- seedEntry: () => ({
234
- name: "parachute-channel",
235
- port: 1941,
236
- paths: ["/channel"],
237
- health: "/channel/health",
238
- version: SEED_VERSION,
239
- }),
240
- urlForEntry: pathBasedUrl,
241
360
  },
242
361
  };
243
362
 
363
+ /**
364
+ * Vendored manifests + extras for first-party modules. Indexed by short name
365
+ * (the `parachute install <X>` token). Each entry retires when its upstream
366
+ * module starts shipping `.parachute/module.json` — see the per-entry
367
+ * `FALLBACK:` markers above.
368
+ */
369
+ export const FIRST_PARTY_FALLBACKS: Record<string, FirstPartyFallback> = {
370
+ vault: VAULT_FALLBACK,
371
+ notes: NOTES_FALLBACK,
372
+ scribe: SCRIBE_FALLBACK,
373
+ channel: CHANNEL_FALLBACK,
374
+ };
375
+
244
376
  /**
245
377
  * Effective publicExposure for a service, given what's on its services.json
246
378
  * entry. Explicit wins. If absent, derive from the spec: known api/tool
@@ -254,19 +386,60 @@ export function effectivePublicExposure(
254
386
  ): "allowed" | "loopback" | "auth-required" {
255
387
  if (entry.publicExposure !== undefined) return entry.publicExposure;
256
388
  const short = shortNameForManifest(entry.name);
257
- const spec = short !== undefined ? SERVICE_SPECS[short] : undefined;
258
- if (spec && (spec.kind === "api" || spec.kind === "tool") && spec.hasAuth === false) {
389
+ const fb = short !== undefined ? FIRST_PARTY_FALLBACKS[short] : undefined;
390
+ if (
391
+ fb &&
392
+ (fb.manifest.kind === "api" || fb.manifest.kind === "tool") &&
393
+ fb.extras?.hasAuth === false
394
+ ) {
259
395
  return "auth-required";
260
396
  }
261
397
  return "allowed";
262
398
  }
263
399
 
264
400
  export function knownServices(): string[] {
265
- return Object.keys(SERVICE_SPECS);
401
+ return Object.keys(FIRST_PARTY_FALLBACKS);
402
+ }
403
+
404
+ /**
405
+ * Resolve the runtime spec for a known short name. Returns undefined for
406
+ * unknown names; third-party modules installed via `module.json` resolve
407
+ * via {@link getSpecFromInstallDir} instead, since their spec isn't
408
+ * compiled in.
409
+ */
410
+ export function getSpec(short: string): ServiceSpec | undefined {
411
+ const fb = FIRST_PARTY_FALLBACKS[short];
412
+ if (!fb) return undefined;
413
+ return composeServiceSpec({
414
+ packageName: fb.package,
415
+ manifest: fb.manifest,
416
+ extras: fb.extras,
417
+ });
266
418
  }
267
419
 
268
- export function getSpec(service: string): ServiceSpec | undefined {
269
- return SERVICE_SPECS[service];
420
+ /**
421
+ * Resolve a third-party module's runtime spec by reading its
422
+ * `<installDir>/.parachute/module.json` fresh. Re-reading at lifecycle time
423
+ * (rather than baking the spec into services.json at install) means the
424
+ * module can ship `startCmd` updates without a re-install.
425
+ *
426
+ * Returns null when the manifest is missing — caller falls back to the
427
+ * "lifecycle not yet supported" message (same shape as a first-party spec
428
+ * with no startCmd). Throws ModuleManifestError on a malformed manifest;
429
+ * lifecycle catches and surfaces it as a per-service failure rather than
430
+ * crashing the whole sweep.
431
+ *
432
+ * `packageName` is informational only — the spec carries it forward for
433
+ * diagnostics. Lifecycle doesn't care; install passes it through from the
434
+ * services.json row's name.
435
+ */
436
+ export async function getSpecFromInstallDir(
437
+ installDir: string,
438
+ packageName: string,
439
+ ): Promise<ServiceSpec | null> {
440
+ const manifest = await readModuleManifest(installDir);
441
+ if (!manifest) return null;
442
+ return composeServiceSpec({ packageName, manifest });
270
443
  }
271
444
 
272
445
  /**
@@ -286,11 +459,11 @@ const LEGACY_MANIFEST_ALIASES: Record<string, string> = {
286
459
  "parachute-lens": "notes",
287
460
  };
288
461
 
289
- /** Short name (the key into SERVICE_SPECS) for a given manifest name, e.g.
290
- * `parachute-vault` → `vault`. Returns undefined for unknown manifests. */
462
+ /** Short name (the key into FIRST_PARTY_FALLBACKS) for a given manifest name,
463
+ * e.g. `parachute-vault` → `vault`. Returns undefined for unknown manifests. */
291
464
  export function shortNameForManifest(manifestName: string): string | undefined {
292
- for (const [short, spec] of Object.entries(SERVICE_SPECS)) {
293
- if (spec.manifestName === manifestName) return short;
465
+ for (const [short, fb] of Object.entries(FIRST_PARTY_FALLBACKS)) {
466
+ if (fb.manifest.manifestName === manifestName) return short;
294
467
  }
295
468
  return LEGACY_MANIFEST_ALIASES[manifestName];
296
469
  }
@@ -34,6 +34,17 @@ export interface ServiceEntry {
34
34
  tagline?: string;
35
35
  /** Opt-in or opt-out of public-facing expose layers. See PublicExposure. */
36
36
  publicExposure?: PublicExposure;
37
+ /**
38
+ * Absolute path to the installed package directory. Set at install time
39
+ * for both npm-installed (`bunGlobalPrefixes()/<package>`) and local-path
40
+ * installs (`<absPath>`); first-party fallbacks may leave it absent.
41
+ *
42
+ * Lifecycle (`parachute start`) reads `<installDir>/.parachute/module.json`
43
+ * to recover startCmd for third-party modules whose spec isn't in
44
+ * FIRST_PARTY_FALLBACKS, and spawns with `cwd: installDir` so manifests
45
+ * can use clean relative paths in their `startCmd`.
46
+ */
47
+ installDir?: string;
37
48
  }
38
49
 
39
50
  export interface ServicesManifest {
@@ -74,6 +85,7 @@ function validateEntry(raw: unknown, where: string): ServiceEntry {
74
85
  const displayName = e.displayName;
75
86
  const tagline = e.tagline;
76
87
  const publicExposure = e.publicExposure;
88
+ const installDir = e.installDir;
77
89
  if (displayName !== undefined && typeof displayName !== "string") {
78
90
  throw new ServicesManifestError(`${where}: "displayName" must be a string if present`);
79
91
  }
@@ -90,10 +102,14 @@ function validateEntry(raw: unknown, where: string): ServiceEntry {
90
102
  `${where}: "publicExposure" must be "allowed" | "loopback" | "auth-required" if present`,
91
103
  );
92
104
  }
105
+ if (installDir !== undefined && (typeof installDir !== "string" || installDir.length === 0)) {
106
+ throw new ServicesManifestError(`${where}: "installDir" must be a non-empty string if present`);
107
+ }
93
108
  const entry: ServiceEntry = { name, port, paths: paths as string[], health, version };
94
109
  if (displayName !== undefined) entry.displayName = displayName;
95
110
  if (tagline !== undefined) entry.tagline = tagline;
96
111
  if (publicExposure !== undefined) entry.publicExposure = publicExposure as PublicExposure;
112
+ if (installDir !== undefined) entry.installDir = installDir;
97
113
  return entry;
98
114
  }
99
115
 
@@ -120,7 +136,47 @@ export function readManifest(path: string = SERVICES_MANIFEST_PATH): ServicesMan
120
136
  `failed to parse ${path}: ${err instanceof Error ? err.message : String(err)}`,
121
137
  );
122
138
  }
123
- return validateManifest(raw, path);
139
+ const validated = validateManifest(raw, path);
140
+ const migrated = migrateClawToAgent(validated);
141
+ if (migrated.changed) writeManifest(migrated.manifest, path);
142
+ return migrated.manifest;
143
+ }
144
+
145
+ /**
146
+ * Migrate legacy `claw` entries to `agent` in-place. Paraclaw was renamed
147
+ * to parachute-agent across the ecosystem (npm package, mount path, short
148
+ * name); operators who upgraded hub but still have the old paraclaw row
149
+ * in services.json would otherwise see a tile labelled "Claw" and a hub
150
+ * route at `/claw` while their newly-upgraded daemon listens on `/agent`.
151
+ *
152
+ * Idempotent. Only rewrites when both `name === "claw"` AND the first path
153
+ * is `/claw` — narrow enough that a deliberately-named third-party module
154
+ * (e.g. `name: "claw"` on a different mount) is left alone. Health and any
155
+ * `/claw`-rooted paths are rewritten in lockstep.
156
+ */
157
+ function migrateClawToAgent(manifest: ServicesManifest): {
158
+ manifest: ServicesManifest;
159
+ changed: boolean;
160
+ } {
161
+ let changed = false;
162
+ const services = manifest.services.map((entry) => {
163
+ if (entry.name !== "claw" || entry.paths[0] !== "/claw") return entry;
164
+ changed = true;
165
+ const next: ServiceEntry = {
166
+ ...entry,
167
+ name: "agent",
168
+ paths: entry.paths.map((p) => rewriteClawPath(p)),
169
+ health: rewriteClawPath(entry.health),
170
+ };
171
+ return next;
172
+ });
173
+ return { manifest: { services }, changed };
174
+ }
175
+
176
+ function rewriteClawPath(p: string): string {
177
+ if (p === "/claw") return "/agent";
178
+ if (p.startsWith("/claw/")) return `/agent${p.slice("/claw".length)}`;
179
+ return p;
124
180
  }
125
181
 
126
182
  export function writeManifest(
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Browser sessions for the `/oauth/authorize` login + consent flow. The hub
3
+ * sets a session cookie when the user signs in; subsequent authorize requests
4
+ * with that cookie skip the login form and go straight to consent.
5
+ *
6
+ * Stored in `sessions` (one row per active session), so logout / forced
7
+ * revocation is just a delete. Cookies are 24h; sliding extension is a
8
+ * follow-up — for now, a session expires absolutely at `expires_at`.
9
+ *
10
+ * The cookie value is the session id directly. It's a 32-byte base64url
11
+ * random; collision is statistically impossible. No HMAC needed because the
12
+ * value is already opaque to the client and only ever compared to a row in
13
+ * the DB.
14
+ */
15
+ import type { Database } from "bun:sqlite";
16
+ import { randomBytes } from "node:crypto";
17
+
18
+ export const SESSION_COOKIE_NAME = "parachute_hub_session";
19
+ export const SESSION_TTL_MS = 24 * 60 * 60 * 1000;
20
+
21
+ export interface Session {
22
+ id: string;
23
+ userId: string;
24
+ expiresAt: string;
25
+ createdAt: string;
26
+ }
27
+
28
+ interface Row {
29
+ id: string;
30
+ user_id: string;
31
+ expires_at: string;
32
+ created_at: string;
33
+ }
34
+
35
+ function rowToSession(r: Row): Session {
36
+ return {
37
+ id: r.id,
38
+ userId: r.user_id,
39
+ expiresAt: r.expires_at,
40
+ createdAt: r.created_at,
41
+ };
42
+ }
43
+
44
+ export interface CreateSessionOpts {
45
+ userId: string;
46
+ now?: () => Date;
47
+ }
48
+
49
+ export function createSession(db: Database, opts: CreateSessionOpts): Session {
50
+ const id = randomBytes(32).toString("base64url");
51
+ const now = opts.now?.() ?? new Date();
52
+ const createdAt = now.toISOString();
53
+ const expiresAt = new Date(now.getTime() + SESSION_TTL_MS).toISOString();
54
+ db.prepare("INSERT INTO sessions (id, user_id, expires_at, created_at) VALUES (?, ?, ?, ?)").run(
55
+ id,
56
+ opts.userId,
57
+ expiresAt,
58
+ createdAt,
59
+ );
60
+ return { id, userId: opts.userId, expiresAt, createdAt };
61
+ }
62
+
63
+ /**
64
+ * Returns the session row if it exists and isn't expired; otherwise null.
65
+ * Caller is expected to use this to gate the consent screen — no session
66
+ * means show the login form.
67
+ */
68
+ export function findSession(
69
+ db: Database,
70
+ id: string,
71
+ now: () => Date = () => new Date(),
72
+ ): Session | null {
73
+ const row = db.query<Row, [string]>("SELECT * FROM sessions WHERE id = ?").get(id);
74
+ if (!row) return null;
75
+ const session = rowToSession(row);
76
+ if (now().getTime() > new Date(session.expiresAt).getTime()) return null;
77
+ return session;
78
+ }
79
+
80
+ export function deleteSession(db: Database, id: string): void {
81
+ db.prepare("DELETE FROM sessions WHERE id = ?").run(id);
82
+ }
83
+
84
+ /**
85
+ * Build a `Set-Cookie` header value for the given session id. HttpOnly +
86
+ * SameSite=Lax + Secure (we always assume a TLS terminator; localhost dev
87
+ * still sets Secure because Tailscale serves with HTTPS even on the tailnet
88
+ * mount). Path=/ covers the whole hub origin: the operator's session is "logged
89
+ * into this hub", and admin pages outside /oauth/ (config portal, etc.) ride
90
+ * the same session. State-changing admin POSTs require a CSRF token (see
91
+ * src/csrf.ts) since SameSite=Lax alone doesn't prevent same-site CSRF.
92
+ */
93
+ export function buildSessionCookie(sessionId: string, maxAgeSeconds: number): string {
94
+ return [
95
+ `${SESSION_COOKIE_NAME}=${sessionId}`,
96
+ "HttpOnly",
97
+ "Secure",
98
+ "SameSite=Lax",
99
+ "Path=/",
100
+ `Max-Age=${maxAgeSeconds}`,
101
+ ].join("; ");
102
+ }
103
+
104
+ export function buildSessionClearCookie(): string {
105
+ return `${SESSION_COOKIE_NAME}=; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=0`;
106
+ }
107
+
108
+ export function parseSessionCookie(cookieHeader: string | null): string | null {
109
+ if (!cookieHeader) return null;
110
+ for (const part of cookieHeader.split(";")) {
111
+ const [name, ...rest] = part.trim().split("=");
112
+ if (name === SESSION_COOKIE_NAME) return rest.join("=");
113
+ }
114
+ return null;
115
+ }