@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.
- package/package.json +1 -1
- package/src/__tests__/admin-clients.test.ts +275 -0
- package/src/__tests__/admin-handlers.test.ts +70 -323
- package/src/__tests__/admin-host-admin-token.test.ts +52 -4
- package/src/__tests__/api-me.test.ts +149 -0
- package/src/__tests__/api-mint-token.test.ts +381 -0
- package/src/__tests__/api-modules-ops.test.ts +658 -0
- package/src/__tests__/api-modules.test.ts +426 -0
- package/src/__tests__/api-revocation-list.test.ts +198 -0
- package/src/__tests__/api-revoke-token.test.ts +320 -0
- package/src/__tests__/api-tokens.test.ts +629 -0
- package/src/__tests__/auth.test.ts +680 -16
- package/src/__tests__/csrf.test.ts +40 -1
- package/src/__tests__/expose-2fa-warning.test.ts +3 -5
- package/src/__tests__/expose-cloudflare.test.ts +1 -1
- package/src/__tests__/expose.test.ts +2 -2
- package/src/__tests__/hub-server.test.ts +584 -67
- package/src/__tests__/hub-settings.test.ts +377 -0
- package/src/__tests__/hub.test.ts +123 -53
- package/src/__tests__/install-source.test.ts +249 -0
- package/src/__tests__/jwt-sign.test.ts +205 -0
- package/src/__tests__/module-manifest.test.ts +48 -0
- package/src/__tests__/oauth-handlers.test.ts +522 -5
- package/src/__tests__/operator-token.test.ts +427 -3
- package/src/__tests__/origin-check.test.ts +220 -0
- package/src/__tests__/request-protocol.test.ts +54 -0
- package/src/__tests__/serve-boot.test.ts +193 -0
- package/src/__tests__/serve.test.ts +100 -0
- package/src/__tests__/sessions.test.ts +25 -2
- package/src/__tests__/setup-gate.test.ts +222 -0
- package/src/__tests__/setup-wizard.test.ts +2089 -0
- package/src/__tests__/status.test.ts +199 -0
- package/src/__tests__/supervisor.test.ts +482 -0
- package/src/__tests__/upgrade.test.ts +247 -4
- package/src/__tests__/vault-name.test.ts +79 -0
- package/src/__tests__/well-known.test.ts +69 -0
- package/src/admin-clients.ts +139 -0
- package/src/admin-handlers.ts +37 -254
- package/src/admin-host-admin-token.ts +25 -10
- package/src/admin-login-ui.ts +256 -0
- package/src/admin-vault-admin-token.ts +1 -1
- package/src/api-me.ts +124 -0
- package/src/api-mint-token.ts +239 -0
- package/src/api-modules-ops.ts +585 -0
- package/src/api-modules.ts +367 -0
- package/src/api-revocation-list.ts +59 -0
- package/src/api-revoke-token.ts +153 -0
- package/src/api-tokens.ts +224 -0
- package/src/cli.ts +28 -0
- package/src/commands/auth.ts +408 -51
- package/src/commands/expose-2fa-warning.ts +6 -6
- package/src/commands/serve-boot.ts +133 -0
- package/src/commands/serve.ts +214 -0
- package/src/commands/status.ts +74 -10
- package/src/commands/upgrade.ts +33 -6
- package/src/csrf.ts +34 -13
- package/src/help.ts +55 -5
- package/src/hub-control.ts +1 -0
- package/src/hub-db.ts +87 -0
- package/src/hub-server.ts +767 -136
- package/src/hub-settings.ts +259 -0
- package/src/hub.ts +298 -150
- package/src/install-source.ts +291 -0
- package/src/jwt-sign.ts +265 -5
- package/src/module-manifest.ts +48 -10
- package/src/oauth-handlers.ts +262 -56
- package/src/oauth-ui.ts +23 -2
- package/src/operator-token.ts +349 -18
- package/src/origin-check.ts +127 -0
- package/src/rate-limit.ts +5 -2
- package/src/request-protocol.ts +48 -0
- package/src/scope-explanations.ts +33 -2
- package/src/sessions.ts +30 -18
- package/src/setup-wizard.ts +2009 -0
- package/src/supervisor.ts +411 -0
- package/src/vault-name.ts +71 -0
- package/src/well-known.ts +54 -1
- package/web/ui/dist/assets/index-BDSEsaBY.css +1 -0
- package/web/ui/dist/assets/index-CP07NbdF.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/src/__tests__/admin-config.test.ts +0 -281
- package/src/admin-config-ui.ts +0 -534
- package/src/admin-config.ts +0 -226
- package/web/ui/dist/assets/index-BKzPDdB0.js +0 -60
- 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
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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,
|
|
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
|
|
package/src/hub-control.ts
CHANGED
|
@@ -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 {
|