@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,157 @@
1
+ /**
2
+ * `parachute serve` — long-running hub HTTP server, foregrounded.
3
+ *
4
+ * The on-box CLI flow (`parachute expose`) spawns hub-server detached and
5
+ * tracks it via pidfile. Container hosts (Docker, Render) need the inverse
6
+ * shape: the hub process IS PID 1 of the container, lives in the
7
+ * foreground, and exits with the container.
8
+ *
9
+ * This subcommand wires that path:
10
+ *
11
+ * - Reads `PORT` (default 1939) and `PARACHUTE_HUB_ORIGIN` (the canonical
12
+ * public origin Render exposes via custom domain) from env.
13
+ * - Auto-writes `hub.html` into `~/.parachute/well-known/` so `/` serves a
14
+ * real discovery page on a fresh disk without the operator having to
15
+ * run `parachute expose` first.
16
+ * - Seeds an initial admin from `PARACHUTE_INITIAL_ADMIN_USERNAME` +
17
+ * `PARACHUTE_INITIAL_ADMIN_PASSWORD` on first boot when no admin
18
+ * exists, so the wizard isn't a hard precondition.
19
+ * - Starts the hub-server fetch loop bound to `0.0.0.0` (container hosts
20
+ * need to accept the platform's HTTP forwarder, not just localhost).
21
+ *
22
+ * Stays out of pidfile/log-rotation logic — those are for the detached
23
+ * `parachute start hub` flow. A container supervisor (Docker, systemd,
24
+ * Render) owns process lifecycle; this command only owns the fetch loop.
25
+ */
26
+
27
+ import { existsSync, mkdirSync } from "node:fs";
28
+ import { join } from "node:path";
29
+ // NOTE: CONFIG_DIR/WELL_KNOWN_DIR are evaluated at import time from process.env.PARACHUTE_HOME.
30
+ // The `env` parameter on `serve()` cannot reroute them — set PARACHUTE_HOME before importing for path isolation.
31
+ import { CONFIG_DIR } from "../config.ts";
32
+ import { hubDbPath, openHubDb } from "../hub-db.ts";
33
+ import { hubFetch } from "../hub-server.ts";
34
+ import { writeHubFile } from "../hub.ts";
35
+ import { createUser, userCount } from "../users.ts";
36
+ import { WELL_KNOWN_DIR } from "../well-known.ts";
37
+
38
+ export interface ServeOpts {
39
+ /** Override PORT (test-only). Real callers thread env via process.env. */
40
+ port?: number;
41
+ /** Override PARACHUTE_HUB_ORIGIN (test-only). */
42
+ issuer?: string;
43
+ /** Override the env source (test-only). */
44
+ env?: NodeJS.ProcessEnv;
45
+ /** Logger seam (test-only). */
46
+ log?: (line: string) => void;
47
+ }
48
+
49
+ export interface ServeResult {
50
+ port: number;
51
+ issuer?: string;
52
+ /**
53
+ * "seeded" — initial admin created from env vars.
54
+ * "exists" — admin row already present, env vars ignored.
55
+ * "needs-setup" — no admin and no env-var seed; wizard mode (the
56
+ * setup-placeholder redirect in hub-server.ts takes over).
57
+ */
58
+ adminBootstrap: "seeded" | "exists" | "needs-setup";
59
+ }
60
+
61
+ const DEFAULT_PORT = 1939;
62
+
63
+ function parsePort(raw: string | undefined): number | undefined {
64
+ if (raw === undefined || raw === "") return undefined;
65
+ const n = Number.parseInt(raw, 10);
66
+ if (!Number.isInteger(n) || n <= 0 || n > 65535) {
67
+ throw new Error(`PORT must be 1..65535, got "${raw}"`);
68
+ }
69
+ return n;
70
+ }
71
+
72
+ /**
73
+ * Seed the initial admin from env vars when no admin exists. Returns the
74
+ * bootstrap state so the caller can log it for operator visibility.
75
+ *
76
+ * Boot-time idempotent: if an admin already exists, we leave it alone —
77
+ * `PARACHUTE_INITIAL_ADMIN_*` is a first-boot seed, not a reset switch.
78
+ * That keeps a container restart with the env vars still set from
79
+ * clobbering an admin who has since rotated their password.
80
+ */
81
+ export async function seedInitialAdminIfNeeded(
82
+ db: ReturnType<typeof openHubDb>,
83
+ env: NodeJS.ProcessEnv,
84
+ log: (line: string) => void = () => {},
85
+ ): Promise<"seeded" | "exists" | "needs-setup"> {
86
+ if (userCount(db) > 0) return "exists";
87
+ const username = env.PARACHUTE_INITIAL_ADMIN_USERNAME?.trim();
88
+ const password = env.PARACHUTE_INITIAL_ADMIN_PASSWORD;
89
+ if (!username || !password) return "needs-setup";
90
+ await createUser(db, username, password);
91
+ log(`parachute serve: seeded initial admin "${username}" from PARACHUTE_INITIAL_ADMIN_*`);
92
+ return "seeded";
93
+ }
94
+
95
+ /**
96
+ * Run the hub fetch loop in the foreground. Resolves when `Bun.serve` is
97
+ * bound; the returned `stop()` shuts the server down for tests.
98
+ *
99
+ * The CLI dispatcher calls this without awaiting completion — `Bun.serve`
100
+ * runs the listener and the runtime keeps the process alive until a
101
+ * signal. Tests pass `port: 0` so the kernel picks an ephemeral port.
102
+ */
103
+ export async function serve(opts: ServeOpts = {}): Promise<{
104
+ result: ServeResult;
105
+ stop: () => Promise<void>;
106
+ }> {
107
+ const env = opts.env ?? process.env;
108
+ const log = opts.log ?? ((line) => console.log(line));
109
+
110
+ const envPort = parsePort(env.PORT);
111
+ const port = opts.port ?? envPort ?? DEFAULT_PORT;
112
+ const issuer = (opts.issuer ?? env.PARACHUTE_HUB_ORIGIN)?.replace(/\/+$/, "") || undefined;
113
+ // Containers default to 0.0.0.0 so the platform's HTTP forwarder can
114
+ // reach us; the `--hostname` flag / PARACHUTE_BIND_HOST is the escape
115
+ // hatch for setups that want loopback-only inside a sidecar.
116
+ const hostname = env.PARACHUTE_BIND_HOST || "0.0.0.0";
117
+
118
+ // Ensure the well-known dir exists, and seed a static hub.html so `/`
119
+ // serves something coherent on a fresh disk (the dynamic path through
120
+ // `hubFetch` takes over once a DB row exists; the disk file is the
121
+ // signed-out fallback).
122
+ if (!existsSync(WELL_KNOWN_DIR)) mkdirSync(WELL_KNOWN_DIR, { recursive: true });
123
+ const hubHtmlPath = join(WELL_KNOWN_DIR, "hub.html");
124
+ if (!existsSync(hubHtmlPath)) writeHubFile(hubHtmlPath);
125
+
126
+ const dbPath = hubDbPath();
127
+ const db = openHubDb(dbPath);
128
+ const adminBootstrap = await seedInitialAdminIfNeeded(db, env, log);
129
+
130
+ if (adminBootstrap === "needs-setup") {
131
+ log(
132
+ "parachute serve: no admin account configured. Set PARACHUTE_INITIAL_ADMIN_USERNAME + PARACHUTE_INITIAL_ADMIN_PASSWORD, or visit /admin/setup once the hub is reachable.",
133
+ );
134
+ }
135
+
136
+ const server = Bun.serve({
137
+ port,
138
+ hostname,
139
+ fetch: hubFetch(WELL_KNOWN_DIR, {
140
+ getDb: () => db,
141
+ issuer,
142
+ loopbackPort: port,
143
+ }),
144
+ });
145
+
146
+ log(
147
+ `parachute serve: listening on http://${hostname}:${port} (PARACHUTE_HOME=${CONFIG_DIR}, db=${dbPath}, issuer=${issuer ?? "<request-origin>"}, admin=${adminBootstrap})`,
148
+ );
149
+
150
+ return {
151
+ result: { port, ...(issuer !== undefined ? { issuer } : {}), adminBootstrap },
152
+ stop: async () => {
153
+ await server.stop();
154
+ db.close();
155
+ },
156
+ };
157
+ }
@@ -1,5 +1,12 @@
1
1
  import { CONFIG_DIR, SERVICES_MANIFEST_PATH } from "../config.ts";
2
2
  import { HUB_SVC, readHubPort } from "../hub-control.ts";
3
+ import {
4
+ type DetectInstallSourceDeps,
5
+ detectHubInstallSource,
6
+ detectInstallSource,
7
+ formatInstallSourceLabel,
8
+ isStale,
9
+ } from "../install-source.ts";
3
10
  import { type AliveFn, defaultAlive, formatUptime, processState } from "../process-state.ts";
4
11
  import { canonicalPortForManifest, getSpec, shortNameForManifest } from "../service-spec.ts";
5
12
  import { type ServiceEntry, readManifest } from "../services-manifest.ts";
@@ -14,6 +21,19 @@ export interface StatusOpts {
14
21
  configDir?: string;
15
22
  alive?: AliveFn;
16
23
  now?: () => Date;
24
+ /**
25
+ * Test seam for install-source detection. Production reads the filesystem
26
+ * + shells out to git; tests inject stubs so each case (npm / bun-linked /
27
+ * unknown / stale) is exercised deterministically without depending on
28
+ * the operator's actual bun globals.
29
+ */
30
+ installSourceDeps?: DetectInstallSourceDeps;
31
+ /**
32
+ * Directory containing the running hub source. Defaults to `import.meta.dir`
33
+ * (the directory of this file). Tests override so the hub row's install
34
+ * source classification doesn't depend on the test runner's location.
35
+ */
36
+ hubSrcDir?: string;
17
37
  }
18
38
 
19
39
  export interface ProbeResult {
@@ -71,6 +91,7 @@ interface StatusRow {
71
91
  uptimeLabel: string;
72
92
  healthLabel: string;
73
93
  latencyLabel: string;
94
+ sourceLabel: string;
74
95
  url: string | undefined;
75
96
  healthy: boolean;
76
97
  skipped: boolean;
@@ -82,6 +103,13 @@ interface StatusRow {
82
103
  * hard-erroring on a deliberate operator port change.
83
104
  */
84
105
  driftWarning?: string;
106
+ /**
107
+ * Version-drift indicator (hub#243). Set when a bun-linked service's
108
+ * `services.json.version` lags the live `package.json` version at its
109
+ * checkout. Surfaced as a continuation line so operators can spot a
110
+ * stale-after-rebuild row without comparing columns by eye.
111
+ */
112
+ staleNote?: string;
85
113
  }
86
114
 
87
115
  /**
@@ -98,7 +126,13 @@ function urlForEntry(entry: ServiceEntry, short: string | undefined): string | u
98
126
  return `http://127.0.0.1:${entry.port}${first}`;
99
127
  }
100
128
 
101
- function hubRow(configDir: string, alive: AliveFn, nowDate: Date): StatusRow | undefined {
129
+ function hubRow(
130
+ configDir: string,
131
+ alive: AliveFn,
132
+ nowDate: Date,
133
+ hubSrcDir: string,
134
+ installSourceDeps: DetectInstallSourceDeps,
135
+ ): StatusRow | undefined {
102
136
  const proc = processState(HUB_SVC, configDir, alive);
103
137
  if (proc.status === "unknown") return undefined;
104
138
  const port = readHubPort(configDir);
@@ -107,15 +141,17 @@ function hubRow(configDir: string, alive: AliveFn, nowDate: Date): StatusRow | u
107
141
  const pidLabel = proc.status === "running" && proc.pid !== undefined ? String(proc.pid) : "-";
108
142
  const uptimeLabel =
109
143
  proc.status === "running" && proc.startedAt ? formatUptime(proc.startedAt, nowDate) : "-";
144
+ const source = detectHubInstallSource(hubSrcDir, installSourceDeps);
110
145
  return {
111
146
  service: "parachute-hub (internal)",
112
147
  port: portLabel,
113
- version: "-",
148
+ version: source.livePackageVersion ?? "-",
114
149
  processLabel,
115
150
  pidLabel,
116
151
  uptimeLabel,
117
152
  healthLabel: "-",
118
153
  latencyLabel: "-",
154
+ sourceLabel: formatInstallSourceLabel(source),
119
155
  url: port !== undefined ? `http://127.0.0.1:${port}` : undefined,
120
156
  healthy: true,
121
157
  skipped: true,
@@ -130,6 +166,8 @@ export async function status(opts: StatusOpts = {}): Promise<number> {
130
166
  const configDir = opts.configDir ?? CONFIG_DIR;
131
167
  const alive = opts.alive ?? defaultAlive;
132
168
  const now = opts.now ?? (() => new Date());
169
+ const installSourceDeps = opts.installSourceDeps ?? {};
170
+ const hubSrcDir = opts.hubSrcDir ?? import.meta.dir;
133
171
 
134
172
  const manifest = readManifest(manifestPath);
135
173
  if (manifest.services.length === 0) {
@@ -178,6 +216,17 @@ export async function status(opts: StatusOpts = {}): Promise<number> {
178
216
  ? `canonical port is ${canonical}`
179
217
  : undefined;
180
218
 
219
+ // Install-source detection (hub#243). One filesystem walk + maybe one
220
+ // `git rev-parse` per row. Failures degrade silently to `unknown` —
221
+ // status output should never error out on a missing checkout dir.
222
+ const detectArgs: { entryName: string; installDir?: string } = { entryName: entry.name };
223
+ if (entry.installDir !== undefined) detectArgs.installDir = entry.installDir;
224
+ const source = detectInstallSource(detectArgs, installSourceDeps);
225
+ const sourceLabel = formatInstallSourceLabel(source);
226
+ const staleNote = isStale(entry.version, source)
227
+ ? `STALE: services.json cached ${entry.version}; live package.json ${source.livePackageVersion}`
228
+ : undefined;
229
+
181
230
  // Only skip probe when we know the process is dead (PID file was
182
231
  // present but kill(pid, 0) failed). "unknown" status (no PID file)
183
232
  // still probes — externally-managed services should report health.
@@ -191,10 +240,12 @@ export async function status(opts: StatusOpts = {}): Promise<number> {
191
240
  uptimeLabel,
192
241
  healthLabel: "-",
193
242
  latencyLabel: "-",
243
+ sourceLabel,
194
244
  url,
195
245
  healthy: false,
196
246
  skipped: true,
197
247
  driftWarning,
248
+ staleNote,
198
249
  };
199
250
  }
200
251
 
@@ -213,20 +264,32 @@ export async function status(opts: StatusOpts = {}): Promise<number> {
213
264
  uptimeLabel,
214
265
  healthLabel,
215
266
  latencyLabel: `${p.latencyMs}ms`,
267
+ sourceLabel,
216
268
  url,
217
269
  healthy: p.healthy,
218
270
  skipped: false,
219
271
  driftWarning,
272
+ staleNote,
220
273
  };
221
274
  }),
222
275
  );
223
276
 
224
277
  // Hub is an internal service — not in services.json, but users notice
225
278
  // when it's dead. Only show it if we've seen it run.
226
- const hub = hubRow(configDir, alive, nowDate);
279
+ const hub = hubRow(configDir, alive, nowDate, hubSrcDir, installSourceDeps);
227
280
  if (hub) rows.push(hub);
228
281
 
229
- const header = ["SERVICE", "PORT", "VERSION", "PROCESS", "PID", "UPTIME", "HEALTH", "LATENCY"];
282
+ const header = [
283
+ "SERVICE",
284
+ "PORT",
285
+ "VERSION",
286
+ "PROCESS",
287
+ "PID",
288
+ "UPTIME",
289
+ "HEALTH",
290
+ "LATENCY",
291
+ "SOURCE",
292
+ ];
230
293
  const textRows = rows.map((r) => [
231
294
  r.service,
232
295
  r.port,
@@ -236,14 +299,17 @@ export async function status(opts: StatusOpts = {}): Promise<number> {
236
299
  r.uptimeLabel,
237
300
  r.healthLabel,
238
301
  r.latencyLabel,
302
+ r.sourceLabel,
239
303
  ]);
240
304
  const widths = header.map((_, i) =>
241
305
  Math.max(header[i]?.length ?? 0, ...textRows.map((r) => r[i]?.length ?? 0)),
242
306
  );
243
307
  print(formatRow(header, widths));
244
- // URL stays on a continuation line rather than a column. URLs are long
245
- // (vault's MCP path runs ~40 chars), and a ninth column would push the
246
- // table past 80 cols on every install. The " → " prefix groups visually
308
+ // URL, drift, and stale notes stay on continuation lines rather than
309
+ // columns. URLs are long (vault's MCP path runs ~40 chars); SOURCE labels
310
+ // can be long for bun-linked rows. Spreading them across columns would
311
+ // push the table well past 80 cols on every install — continuation lines
312
+ // keep the table scannable. The " → " / " ! " prefixes group visually
247
313
  // with the row above without misleading the table widths.
248
314
  for (let i = 0; i < textRows.length; i++) {
249
315
  const cells = textRows[i];
@@ -251,10 +317,8 @@ export async function status(opts: StatusOpts = {}): Promise<number> {
251
317
  if (!cells || !row) continue;
252
318
  print(formatRow(cells, widths));
253
319
  if (row.url) print(` → ${row.url}`);
254
- // Drift warning rides as its own continuation line. Plain ASCII (no
255
- // emoji / unicode glyphs) for terminal compatibility — the same
256
- // surface that prints to scripts piping `parachute status`.
257
320
  if (row.driftWarning) print(` ! ${row.driftWarning}`);
321
+ if (row.staleNote) print(` ! ${row.staleNote}`);
258
322
  }
259
323
 
260
324
  /**
@@ -38,6 +38,7 @@ import { existsSync, readFileSync, realpathSync } from "node:fs";
38
38
  import { homedir } from "node:os";
39
39
  import { dirname, join } from "node:path";
40
40
  import { CONFIG_DIR, SERVICES_MANIFEST_PATH } from "../config.ts";
41
+ import { HUB_PACKAGE, HUB_SVC } from "../hub-control.ts";
41
42
  import { ModuleManifestError } from "../module-manifest.ts";
42
43
  import {
43
44
  type ServiceSpec,
@@ -149,16 +150,38 @@ function resolve(opts: UpgradeOpts): Resolved {
149
150
  };
150
151
  }
151
152
 
153
+ /**
154
+ * Synthetic services.json row for the hub. The hub isn't in services.json
155
+ * (it's an implementation detail of `parachute expose`, not a user-facing
156
+ * service), so callers passing `hub` as the upgrade target need a fabricated
157
+ * `ResolvedTarget`. Only `installDir` is read downstream — left undefined so
158
+ * `findGlobalInstall("@openparachute/hub")` is the sole locate path, which
159
+ * works the same for npm installs and `bun link` checkouts.
160
+ */
161
+ function hubTarget(): ResolvedTarget {
162
+ const entry: ServiceEntry = {
163
+ name: HUB_PACKAGE,
164
+ port: 0,
165
+ paths: [],
166
+ health: "",
167
+ version: "",
168
+ };
169
+ return { short: HUB_SVC, entry, spec: undefined, packageName: HUB_PACKAGE };
170
+ }
171
+
152
172
  async function resolveTargets(
153
173
  svc: string | undefined,
154
174
  manifestPath: string,
155
175
  ): Promise<{ targets: ResolvedTarget[] } | { error: string }> {
156
176
  const manifest = readManifest(manifestPath);
157
- if (manifest.services.length === 0) {
158
- return { error: "No services installed yet. Try: parachute install <service>" };
159
- }
160
177
 
161
178
  if (svc !== undefined) {
179
+ if (svc === HUB_SVC) return { targets: [hubTarget()] };
180
+
181
+ if (manifest.services.length === 0) {
182
+ return { error: "No services installed yet. Try: parachute install <service>" };
183
+ }
184
+
162
185
  const firstPartySpec = getSpec(svc);
163
186
  if (firstPartySpec) {
164
187
  const entry = manifest.services.find((s) => s.name === firstPartySpec.manifestName);
@@ -183,10 +206,15 @@ async function resolveTargets(
183
206
  throw err;
184
207
  }
185
208
  }
186
- return { error: `unknown service "${svc}". known: ${knownServices().join(", ")}` };
209
+ return {
210
+ error: `unknown service "${svc}". known: ${[HUB_SVC, ...knownServices()].join(", ")}`,
211
+ };
187
212
  }
188
213
 
189
- const targets: ResolvedTarget[] = [];
214
+ // Sweep mode: hub first, then everything in services.json. Hub-first means a
215
+ // dispatcher upgrade can't be undermined mid-sweep by a service upgrade that
216
+ // restarts hub for reasons unrelated to its own code change.
217
+ const targets: ResolvedTarget[] = [hubTarget()];
190
218
  for (const entry of manifest.services) {
191
219
  const short = shortNameForManifest(entry.name);
192
220
  if (short) {
@@ -209,7 +237,6 @@ async function resolveTargets(
209
237
  }
210
238
  }
211
239
  }
212
- if (targets.length === 0) return { error: "No upgradeable services in services.json." };
213
240
  return { targets };
214
241
  }
215
242
 
package/src/csrf.ts CHANGED
@@ -19,9 +19,12 @@
19
19
  * pre-login and post-login forms, and it works no matter how many tabs the
20
20
  * operator has open.
21
21
  *
22
- * The cookie is HttpOnly (the form doesn't need JS to read it; the server
23
- * embeds the value at render time), SameSite=Lax (matches the session
24
- * cookie), Secure, and Path=/ (covers every admin form, OAuth or otherwise).
22
+ * The cookie is HttpOnly: consumers receive the token value via either the
23
+ * server-rendered HTML form (cookie + embedded value, classic double-submit)
24
+ * or via the JSON body of `/api/me` (cookie alongside body same pattern,
25
+ * just JSON instead of HTML). Neither path needs JS to read the cookie
26
+ * directly. SameSite=Lax (matches the session cookie), Secure, and Path=/
27
+ * (covers every admin form, OAuth flow, and `/api/me` consumer).
25
28
  *
26
29
  * Token entropy: 32 random bytes, base64url-encoded — same shape as session
27
30
  * IDs. No HMAC needed: the value is opaque to the client and only ever
package/src/help.ts CHANGED
@@ -17,6 +17,7 @@ Usage:
17
17
  parachute logs <service> [-f] print service logs; -f to tail
18
18
  parachute expose tailnet [off] HTTPS across your tailnet (supported)
19
19
  parachute expose public [off] HTTPS on the public internet (exploratory)
20
+ parachute serve run hub HTTP server foregrounded (for containers)
20
21
  parachute migrate [--dry-run] archive legacy files at ecosystem root
21
22
  parachute auth <cmd> identity (set password, manage 2FA)
22
23
  parachute vault <args...> vault-specific ops (tokens, 2fa, config, init,
@@ -124,7 +125,7 @@ Examples:
124
125
  }
125
126
 
126
127
  export function statusHelp(): string {
127
- return `parachute status — show installed services, process state, and health
128
+ return `parachute status — show installed services, process state, health, install source
128
129
 
129
130
  Usage:
130
131
  parachute status
@@ -133,22 +134,28 @@ What it does:
133
134
  Reads ~/.parachute/services.json. For each registered service:
134
135
  - checks PID file at ~/.parachute/<svc>/run/<svc>.pid → running/stopped
135
136
  - probes http://localhost:<port><health> (skipped for known-stopped processes)
137
+ - classifies the install source as bun-linked (local checkout) or npm
136
138
 
137
139
  Stopped services show "-" for health and don't count toward the exit
138
140
  code — they're an expected state after fresh install before \`parachute
139
141
  start\`. Running or externally-managed services that fail health checks
140
142
  do exit 1.
141
143
 
144
+ A "STALE: services.json cached … live package.json …" continuation line
145
+ appears under a row when a bun-linked service has been rebuilt but the
146
+ manifest's cached version hasn't caught up — re-install (\`parachute
147
+ install <pkg>\`) refreshes the row.
148
+
142
149
  Exit codes:
143
150
  0 all probed services healthy (or none running)
144
151
  1 one or more probed services unhealthy
145
152
 
146
153
  Example:
147
154
  $ parachute status
148
- SERVICE PORT VERSION PROCESS PID UPTIME HEALTH LATENCY
149
- parachute-vault 1940 0.2.4 running 12345 2h 13m ok 2ms
155
+ SERVICE PORT VERSION PROCESS PID UPTIME HEALTH LATENCY SOURCE
156
+ parachute-vault 1940 0.2.4 running 12345 2h 13m ok 2ms bun-linked → parachute-vault @ 8aa167b
150
157
  → http://127.0.0.1:1940/vault/default/mcp
151
- parachute-notes 1942 0.0.1 stopped - - - -
158
+ parachute-notes 1942 0.0.1 stopped - - - - npm (0.3.15-rc.1)
152
159
  → http://127.0.0.1:1942/notes
153
160
  `;
154
161
  }
@@ -342,8 +349,9 @@ What it does:
342
349
  Re-running on an up-to-date install is a fast no-op.
343
350
 
344
351
  Examples:
345
- parachute upgrade sweep every installed service
352
+ parachute upgrade sweep hub + every installed service
346
353
  parachute upgrade vault just vault
354
+ parachute upgrade hub upgrade the dispatcher itself (closes #251)
347
355
  parachute upgrade vault --tag rc pin the rc dist-tag (npm path only)
348
356
  `;
349
357
  }
@@ -363,6 +371,47 @@ If no log file exists yet, prints a hint to \`parachute start <service>\`.
363
371
  `;
364
372
  }
365
373
 
374
+ export function serveHelp(): string {
375
+ return `parachute serve — run the hub HTTP server foregrounded
376
+
377
+ Usage:
378
+ parachute serve
379
+
380
+ The container shape. The on-box CLI flow (\`parachute expose\`) spawns the
381
+ hub-server detached and tracks it via pidfile; \`parachute serve\` is the
382
+ inverse — the hub IS the foreground process, lives as long as its
383
+ supervisor wants it to, and exits on signal. Built for Docker / Render /
384
+ systemd, but works fine for a foregrounded local debug too.
385
+
386
+ Environment:
387
+ PORT bind port (default 1939). Render injects
388
+ this; honor it so the platform's HTTP
389
+ forwarder lands on the right socket.
390
+ PARACHUTE_HOME config root (default ~/.parachute).
391
+ Point at a persistent disk in containers.
392
+ PARACHUTE_HUB_ORIGIN canonical https://… origin baked into
393
+ OAuth issuer + token aud claims. Set to
394
+ the public hostname Render / Cloudflare
395
+ serves.
396
+ PARACHUTE_INITIAL_ADMIN_USERNAME on first boot when no admin row exists,
397
+ PARACHUTE_INITIAL_ADMIN_PASSWORD seed an admin from these. Boot-time
398
+ idempotent — ignored once an admin
399
+ exists, so leaving them set across
400
+ restarts is safe.
401
+
402
+ If no admin exists and the seed env vars aren't set, the hub still comes
403
+ up — visit \`/admin/setup\` to bootstrap via the placeholder wizard.
404
+
405
+ Examples:
406
+ parachute serve # foreground, defaults
407
+ PORT=8080 PARACHUTE_HOME=/parachute parachute serve
408
+ docker run -e PARACHUTE_INITIAL_ADMIN_USERNAME=ops \\
409
+ -e PARACHUTE_INITIAL_ADMIN_PASSWORD=… \\
410
+ -v parachute-data:/parachute \\
411
+ parachute-hub:0.5.10 serve
412
+ `;
413
+ }
414
+
366
415
  export function migrateHelp(): string {
367
416
  return `parachute migrate — archive legacy files at the ecosystem root
368
417
 
@@ -27,6 +27,7 @@ import { WELL_KNOWN_DIR } from "./well-known.ts";
27
27
  */
28
28
 
29
29
  export const HUB_SVC = "hub";
30
+ export const HUB_PACKAGE = "@openparachute/hub";
30
31
  export const HUB_DEFAULT_PORT = 1939;
31
32
  /**
32
33
  * Default fallback range is 1 — the hub binds 1939 or fails. Walking up would
package/src/hub-db.ts CHANGED
@@ -130,6 +130,69 @@ const MIGRATIONS: readonly Migration[] = [
130
130
  CREATE INDEX tokens_family ON tokens (family_id) WHERE family_id IS NOT NULL;
131
131
  `,
132
132
  },
133
+ {
134
+ version: 6,
135
+ sql: `
136
+ -- Token registry generalization (closes hub#212 Phase 1). Until v6 the
137
+ -- tokens table only held OAuth refresh tokens; v6 generalizes it to a
138
+ -- single registry across every issued JWT class (refresh, access,
139
+ -- operator, mint-token). Three structural changes:
140
+ --
141
+ -- 1. user_id becomes NULLABLE. OAuth-issued rows still set it to the
142
+ -- caller's user (canonical identity field). CLI-minted /
143
+ -- operator-minted rows leave user_id NULL and put the operator/
144
+ -- service name in the new \`subject\` column.
145
+ -- 2. Three new columns: \`permissions\` (JSON, custom claim per
146
+ -- auth-architecture-shape.md §11.3), \`created_via\` (provenance
147
+ -- tag: oauth_refresh / cli_mint / operator_mint), \`subject\`
148
+ -- (non-user identity for service / operator mints).
149
+ -- 3. Existing rows backfill \`created_via='oauth_refresh'\` because
150
+ -- the table was OAuth-refresh-only before v6.
151
+ --
152
+ -- SQLite has no ALTER COLUMN to drop NOT NULL, so we use the
153
+ -- recreate-and-rename pattern. Inside the migration transaction the
154
+ -- whole swap is atomic; concurrent reads (there are none — hub is
155
+ -- single-writer) wouldn't see a half-state. FKs from tokens → users
156
+ -- stay enforced for non-NULL user_id values; nothing references
157
+ -- tokens, so the drop is safe.
158
+ CREATE TABLE tokens_new (
159
+ jti TEXT PRIMARY KEY,
160
+ user_id TEXT REFERENCES users(id),
161
+ client_id TEXT NOT NULL,
162
+ scopes TEXT NOT NULL,
163
+ refresh_token_hash TEXT,
164
+ family_id TEXT,
165
+ expires_at TEXT NOT NULL,
166
+ revoked_at TEXT,
167
+ created_at TEXT NOT NULL,
168
+ permissions TEXT,
169
+ created_via TEXT NOT NULL DEFAULT 'oauth_refresh',
170
+ subject TEXT
171
+ );
172
+ INSERT INTO tokens_new (
173
+ jti, user_id, client_id, scopes, refresh_token_hash, family_id,
174
+ expires_at, revoked_at, created_at,
175
+ permissions, created_via, subject
176
+ )
177
+ SELECT
178
+ jti, user_id, client_id, scopes, refresh_token_hash, family_id,
179
+ expires_at, revoked_at, created_at,
180
+ NULL, 'oauth_refresh', NULL
181
+ FROM tokens;
182
+ DROP TABLE tokens;
183
+ ALTER TABLE tokens_new RENAME TO tokens;
184
+ -- Recreate indexes (DROP TABLE took them with it).
185
+ CREATE INDEX tokens_user ON tokens (user_id) WHERE user_id IS NOT NULL;
186
+ CREATE INDEX tokens_active_refresh ON tokens (refresh_token_hash)
187
+ WHERE refresh_token_hash IS NOT NULL AND revoked_at IS NULL;
188
+ CREATE INDEX tokens_family ON tokens (family_id) WHERE family_id IS NOT NULL;
189
+ -- New: revocation list endpoint queries on (revoked_at, expires_at).
190
+ CREATE INDEX tokens_revoked ON tokens (revoked_at)
191
+ WHERE revoked_at IS NOT NULL;
192
+ -- Subject lookup for non-user mints (operator name, service name).
193
+ CREATE INDEX tokens_subject ON tokens (subject) WHERE subject IS NOT NULL;
194
+ `,
195
+ },
133
196
  ];
134
197
 
135
198
  export function openHubDb(path: string = hubDbPath()): Database {