@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
package/src/csrf.ts CHANGED
@@ -19,15 +19,28 @@
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 conditional
27
+ * on the request protocol (see below), and Path=/ (covers every admin
28
+ * form, OAuth flow, and `/api/me` consumer).
29
+ *
30
+ * `Secure` is set when the request arrived over HTTPS (direct or behind a
31
+ * reverse proxy that set `X-Forwarded-Proto: https`) — `isHttpsRequest` in
32
+ * `request-protocol.ts` is the single source of truth. On
33
+ * `http://localhost:1939` the attribute is omitted so the browser actually
34
+ * keeps the cookie; setting `Secure` unconditionally silently drops it on
35
+ * HTTP and breaks the double-submit handshake on the very next POST
36
+ * ("Invalid form submission" page on the wizard, etc.).
25
37
  *
26
38
  * Token entropy: 32 random bytes, base64url-encoded — same shape as session
27
39
  * IDs. No HMAC needed: the value is opaque to the client and only ever
28
40
  * compared to itself across the cookie/form boundary.
29
41
  */
30
42
  import { randomBytes, timingSafeEqual } from "node:crypto";
43
+ import { isHttpsRequest } from "./request-protocol.ts";
31
44
 
32
45
  export const CSRF_COOKIE_NAME = "parachute_hub_csrf";
33
46
  export const CSRF_FIELD_NAME = "__csrf";
@@ -39,15 +52,18 @@ export function generateCsrfToken(): string {
39
52
  return randomBytes(32).toString("base64url");
40
53
  }
41
54
 
42
- export function buildCsrfCookie(token: string): string {
43
- return [
44
- `${CSRF_COOKIE_NAME}=${token}`,
45
- "HttpOnly",
46
- "Secure",
47
- "SameSite=Lax",
48
- "Path=/",
49
- `Max-Age=${CSRF_TTL_SECONDS}`,
50
- ].join("; ");
55
+ /**
56
+ * Build a Set-Cookie header value for a CSRF token. `secure` defaults to
57
+ * true (the production posture behind a TLS terminator); callers that
58
+ * mint the cookie for a known-HTTP request — `ensureCsrfToken` does
59
+ * this via `isHttpsRequest` — pass `secure: false` to omit the
60
+ * attribute so the browser keeps the cookie on plain HTTP.
61
+ */
62
+ export function buildCsrfCookie(token: string, opts: { secure?: boolean } = {}): string {
63
+ const parts = [`${CSRF_COOKIE_NAME}=${token}`, "HttpOnly"];
64
+ if (opts.secure !== false) parts.push("Secure");
65
+ parts.push("SameSite=Lax", "Path=/", `Max-Age=${CSRF_TTL_SECONDS}`);
66
+ return parts.join("; ");
51
67
  }
52
68
 
53
69
  export function parseCsrfCookie(cookieHeader: string | null): string | null {
@@ -69,12 +85,17 @@ export interface EnsuredCsrf {
69
85
  * Ensure the request carries a CSRF token cookie; mint and return one if not.
70
86
  * Callers embed `result.token` in the rendered form and attach
71
87
  * `result.setCookie` (if defined) to the response.
88
+ *
89
+ * Protocol-aware: when the request is plain HTTP (`http://localhost:1939`
90
+ * during local dev / on-box CLI), the minted cookie omits the `Secure`
91
+ * attribute so the browser keeps it. When the request is HTTPS (or
92
+ * forwarded via `X-Forwarded-Proto: https`), `Secure` is set.
72
93
  */
73
94
  export function ensureCsrfToken(req: Request): EnsuredCsrf {
74
95
  const existing = parseCsrfCookie(req.headers.get("cookie"));
75
96
  if (existing && existing.length > 0) return { token: existing };
76
97
  const token = generateCsrfToken();
77
- return { token, setCookie: buildCsrfCookie(token) };
98
+ return { token, setCookie: buildCsrfCookie(token, { secure: isHttpsRequest(req) }) };
78
99
  }
79
100
 
80
101
  /**
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,48 @@ 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 first-boot web wizard
404
+ (admin account + first vault, all from the browser).
405
+
406
+ Examples:
407
+ parachute serve # foreground, defaults
408
+ PORT=8080 PARACHUTE_HOME=/parachute parachute serve
409
+ docker run -e PARACHUTE_INITIAL_ADMIN_USERNAME=ops \\
410
+ -e PARACHUTE_INITIAL_ADMIN_PASSWORD=… \\
411
+ -v parachute-data:/parachute \\
412
+ parachute-hub:0.5.10 serve
413
+ `;
414
+ }
415
+
366
416
  export function migrateHelp(): string {
367
417
  return `parachute migrate — archive legacy files at the ecosystem root
368
418
 
@@ -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,93 @@ 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
+ },
196
+ {
197
+ version: 7,
198
+ sql: `
199
+ -- Hub-level key/value settings (hub#268). Used by:
200
+ -- * setup_expose_mode — operator's "how will this hub be reached?"
201
+ -- choice from the first-boot wizard expose step. Values:
202
+ -- 'localhost' | 'tailnet' | 'public'.
203
+ -- * pending_first_client_auto_approve_until — ISO-8601 timestamp
204
+ -- set when the wizard finishes; first OAuth client registration
205
+ -- within the window is auto-approved + the row cleared (single
206
+ -- use). Absent / past-due means the standard pending-approval
207
+ -- flow applies.
208
+ --
209
+ -- Single-row-per-key schema. updated_at lets us age out stale
210
+ -- entries if a future pattern needs it; nothing currently relies on
211
+ -- it. Bare KV — no audit log, no history — these are hub-local
212
+ -- operator preferences, not user-facing data.
213
+ CREATE TABLE hub_settings (
214
+ key TEXT PRIMARY KEY,
215
+ value TEXT NOT NULL,
216
+ updated_at TEXT NOT NULL
217
+ );
218
+ `,
219
+ },
133
220
  ];
134
221
 
135
222
  export function openHubDb(path: string = hubDbPath()): Database {