@openparachute/hub 0.5.14-rc.2 → 0.5.14-rc.21

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 (106) hide show
  1. package/README.md +109 -15
  2. package/package.json +7 -3
  3. package/src/__tests__/account-home-ui.test.ts +251 -15
  4. package/src/__tests__/account-vault-token.test.ts +355 -0
  5. package/src/__tests__/admin-vaults.test.ts +70 -4
  6. package/src/__tests__/api-mint-token.test.ts +693 -5
  7. package/src/__tests__/api-modules-config.test.ts +16 -10
  8. package/src/__tests__/api-modules-ops.test.ts +45 -0
  9. package/src/__tests__/api-modules.test.ts +92 -75
  10. package/src/__tests__/api-ready.test.ts +135 -0
  11. package/src/__tests__/api-revoke-token.test.ts +384 -0
  12. package/src/__tests__/api-users.test.ts +7 -2
  13. package/src/__tests__/auth.test.ts +157 -30
  14. package/src/__tests__/cli.test.ts +44 -5
  15. package/src/__tests__/cloudflare-detect.test.ts +60 -5
  16. package/src/__tests__/expose-2fa-warning.test.ts +31 -17
  17. package/src/__tests__/expose-auth-preflight.test.ts +71 -72
  18. package/src/__tests__/expose-cloudflare.test.ts +582 -11
  19. package/src/__tests__/expose-interactive.test.ts +10 -4
  20. package/src/__tests__/expose-public-auto.test.ts +5 -1
  21. package/src/__tests__/expose.test.ts +52 -2
  22. package/src/__tests__/hub-server.test.ts +396 -10
  23. package/src/__tests__/hub.test.ts +85 -6
  24. package/src/__tests__/init.test.ts +928 -0
  25. package/src/__tests__/lifecycle.test.ts +464 -2
  26. package/src/__tests__/migrate.test.ts +433 -51
  27. package/src/__tests__/oauth-handlers.test.ts +1252 -83
  28. package/src/__tests__/oauth-ui.test.ts +12 -1
  29. package/src/__tests__/operator-token-issuer-self-heal.test.ts +412 -0
  30. package/src/__tests__/proxy-error-ui.test.ts +212 -0
  31. package/src/__tests__/proxy-state.test.ts +192 -0
  32. package/src/__tests__/resource-binding.test.ts +97 -0
  33. package/src/__tests__/scope-explanations.test.ts +77 -12
  34. package/src/__tests__/services-manifest.test.ts +122 -4
  35. package/src/__tests__/setup-wizard.test.ts +633 -53
  36. package/src/__tests__/status.test.ts +36 -0
  37. package/src/__tests__/two-factor-flow.test.ts +602 -0
  38. package/src/__tests__/two-factor.test.ts +183 -0
  39. package/src/__tests__/upgrade.test.ts +78 -1
  40. package/src/__tests__/users.test.ts +68 -0
  41. package/src/__tests__/vault-auth-status.test.ts +312 -11
  42. package/src/__tests__/vault-hub-origin-env.test.ts +263 -0
  43. package/src/__tests__/wizard.test.ts +372 -0
  44. package/src/account-home-ui.ts +488 -38
  45. package/src/account-vault-token.ts +282 -0
  46. package/src/admin-handlers.ts +159 -4
  47. package/src/admin-login-ui.ts +49 -5
  48. package/src/admin-vaults.ts +48 -15
  49. package/src/api-account.ts +14 -0
  50. package/src/api-mint-token.ts +132 -24
  51. package/src/api-modules-ops.ts +49 -11
  52. package/src/api-modules.ts +29 -12
  53. package/src/api-ready.ts +102 -0
  54. package/src/api-revoke-token.ts +107 -21
  55. package/src/api-users.ts +29 -3
  56. package/src/cli.ts +112 -25
  57. package/src/clients.ts +18 -6
  58. package/src/cloudflare/config.ts +10 -4
  59. package/src/cloudflare/detect.ts +82 -20
  60. package/src/commands/auth.ts +165 -24
  61. package/src/commands/expose-2fa-warning.ts +34 -32
  62. package/src/commands/expose-auth-preflight.ts +89 -78
  63. package/src/commands/expose-cloudflare.ts +471 -16
  64. package/src/commands/expose-interactive.ts +10 -11
  65. package/src/commands/expose-public-auto.ts +6 -4
  66. package/src/commands/expose.ts +8 -0
  67. package/src/commands/init.ts +594 -0
  68. package/src/commands/install.ts +33 -2
  69. package/src/commands/lifecycle.ts +386 -17
  70. package/src/commands/migrate.ts +293 -41
  71. package/src/commands/status.ts +22 -0
  72. package/src/commands/upgrade.ts +55 -11
  73. package/src/commands/wizard.ts +847 -0
  74. package/src/env-file.ts +10 -0
  75. package/src/help.ts +157 -15
  76. package/src/hub-db.ts +39 -1
  77. package/src/hub-server.ts +119 -13
  78. package/src/hub-settings.ts +11 -0
  79. package/src/hub.ts +82 -14
  80. package/src/oauth-handlers.ts +298 -21
  81. package/src/oauth-ui.ts +10 -0
  82. package/src/operator-token.ts +151 -0
  83. package/src/pending-login.ts +116 -0
  84. package/src/proxy-error-ui.ts +506 -0
  85. package/src/proxy-state.ts +131 -0
  86. package/src/rate-limit.ts +51 -0
  87. package/src/resource-binding.ts +134 -0
  88. package/src/scope-attenuation.ts +85 -0
  89. package/src/scope-explanations.ts +131 -14
  90. package/src/services-manifest.ts +112 -0
  91. package/src/setup-wizard.ts +738 -125
  92. package/src/tailscale/run.ts +28 -11
  93. package/src/totp.ts +201 -0
  94. package/src/two-factor-handlers.ts +287 -0
  95. package/src/two-factor-store.ts +181 -0
  96. package/src/two-factor-ui.ts +462 -0
  97. package/src/users.ts +58 -0
  98. package/src/vault/auth-status.ts +200 -25
  99. package/src/vault-hub-origin-env.ts +163 -0
  100. package/web/ui/dist/assets/index-BiBlvEaj.css +1 -0
  101. package/web/ui/dist/assets/index-CIN3mnmf.js +61 -0
  102. package/web/ui/dist/index.html +2 -2
  103. package/src/__tests__/vault-tokens-create-interactive.test.ts +0 -183
  104. package/src/commands/vault-tokens-create-interactive.ts +0 -143
  105. package/web/ui/dist/assets/index-7DtAXz7y.css +0 -1
  106. package/web/ui/dist/assets/index-tRmPbbC7.js +0 -61
@@ -1,38 +1,73 @@
1
1
  /**
2
2
  * `POST /api/auth/revoke-token` — HTTP companion to `parachute auth
3
- * revoke-token <jti>` (hub#221) and the missing piece behind the future
4
- * admin UI's revoke action.
3
+ * revoke-token <jti>` (hub#221) and the backing endpoint for the admin
4
+ * UI's revoke action. Closes hub#220.
5
5
  *
6
- * Same auth shape as `POST /api/auth/mint-token`: bearer-gated on
7
- * `parachute:host:auth` (admin scope-set tokens carry it as a superset;
8
- * narrow `--scope-set auth` operator tokens carry it directly). Closes
9
- * hub#220.
6
+ * Auth capability attenuation, SYMMETRIC to mint-token (hub#452): you may
7
+ * revoke exactly what you could have minted. After validating the bearer
8
+ * (signature / issuer / expiry same as today):
9
+ *
10
+ * 1. If the bearer holds `parachute:host:auth` → it may revoke ANY jti
11
+ * (the original, broadest behavior — preserved unchanged).
12
+ * 2. Otherwise the bearer must clear the entry gate — it must hold at least
13
+ * one minting authority (`parachute:host:auth`, `parachute:host:admin`,
14
+ * or some `vault:<*>:admin`, via `hasMintingAuthority`). A bearer with
15
+ * none (e.g. a read-only token) gets 403 up front — it can revoke
16
+ * nothing, just as it can mint nothing.
17
+ * 3. The per-jti authority check then governs what such a bearer may
18
+ * actually revoke: the target jti is revocable iff EVERY one of its
19
+ * recorded scopes satisfies `canGrant(bearerScopes, scope)` — i.e. the
20
+ * bearer could have minted that exact token. A `vault:work:admin` bearer
21
+ * can revoke a `vault:work:write` or `vault:work:admin` jti, but NOT a
22
+ * `vault:other:*` jti and NOT a `parachute:host:*` jti — the same
23
+ * cross-vault / host-escalation walls mint enforces.
24
+ *
25
+ * Idempotency / no-info-leak: an UNKNOWN jti (no `tokens` row — never minted
26
+ * or already purged) returns the SAME 404 `not_found` the endpoint has always
27
+ * returned, for every caller including host:auth. The per-jti authority check
28
+ * only runs when the row is FOUND. So an attenuated bearer probing a jti it
29
+ * doesn't own cannot distinguish "exists but not yours" from "doesn't exist"
30
+ * by the unknown-jti path — it gets the identical 404 a host:auth bearer
31
+ * would. A jti that EXISTS but is out of the bearer's authority returns 403
32
+ * (and is NOT revoked): the caller already knows the jti string, so "exists
33
+ * but not yours" leaks nothing beyond what it already holds — and returning
34
+ * idempotent-ok there would be a lie (it revoked nothing).
10
35
  *
11
36
  * Body: `{ jti: string }`.
12
37
  *
13
- * Responses (matching the OAuth 2.0 error-shape vocabulary used by
14
- * mint-token and the rest of the hub's bearer-protected admin API):
38
+ * Responses (OAuth 2.0 error-shape vocabulary, matching mint-token):
15
39
  *
16
40
  * - 200 `{ jti, revoked_at }` — success. Idempotent: re-revoking an
17
- * already-revoked jti returns the existing `revoked_at` and 200,
18
- * same as the CLI's exit-0-with-existing-timestamp behavior.
41
+ * already-revoked jti returns the existing `revoked_at` and 200.
19
42
  * - 400 `invalid_request` — missing/malformed body, missing jti.
20
43
  * - 401 `unauthenticated` — missing or invalid bearer.
21
- * - 403 `insufficient_scope` — bearer lacks `parachute:host:auth`.
44
+ * - 403 `insufficient_scope` — bearer holds no minting authority (entry
45
+ * gate), or the target jti carries a scope the bearer couldn't have
46
+ * minted (per-jti authority check).
22
47
  * - 404 `not_found` — no `tokens` row matches the jti.
23
48
  * - 405 `method_not_allowed` — non-POST.
24
49
  *
25
50
  * Identity field in audit-friendly success: not echoed in the response
26
51
  * body (the JSON shape is intentionally minimal — `jti` + `revoked_at`
27
52
  * is all a UI consumer needs); operator-side audit lives in hub logs.
28
- * Mirrors the CLI's design where `identity=` was added for stdout but
29
- * the wire response stays narrow.
30
53
  */
31
54
  import type { Database } from "bun:sqlite";
32
55
  import { findTokenRowByJti, revokeTokenByJti, validateAccessToken } from "./jwt-sign.ts";
56
+ import { MINT_HOST_AUTH_SCOPE, canGrant, hasMintingAuthority } from "./scope-attenuation.ts";
33
57
 
34
- /** Scope required on the bearer token to call this endpoint. */
35
- export const API_REVOKE_TOKEN_REQUIRED_SCOPE = "parachute:host:auth";
58
+ /**
59
+ * Scope that authorises revoking ANY jti unconditionally (rule 1). A bearer
60
+ * without it may still revoke via attenuation (rule 3) if it clears the
61
+ * `hasMintingAuthority` entry gate.
62
+ */
63
+ export const API_REVOKE_TOKEN_REQUIRED_SCOPE = MINT_HOST_AUTH_SCOPE;
64
+
65
+ /**
66
+ * Maximum accepted length of a caller-supplied `jti`. A real jti is a UUID or
67
+ * short opaque token; anything materially longer is malformed input. Capping
68
+ * it keeps the verbatim-echoed value out of structured logs from bloating.
69
+ */
70
+ export const MAX_JTI_LENGTH = 256;
36
71
 
37
72
  export interface ApiRevokeTokenDeps {
38
73
  db: Database;
@@ -80,12 +115,19 @@ export async function handleApiRevokeToken(
80
115
  return jsonError(401, "unauthenticated", `bearer token invalid — ${msg}`);
81
116
  }
82
117
 
83
- // 3. Scope gate.
84
- if (!bearerScopes.includes(API_REVOKE_TOKEN_REQUIRED_SCOPE)) {
118
+ // 3. Entry gate. A `parachute:host:auth` bearer may revoke anything
119
+ // (rule 1) and skips the per-jti authority check below. Any other
120
+ // bearer must hold SOME minting authority (host:admin or a
121
+ // `vault:<*>:admin`) to attempt a revoke at all — a bearer with none
122
+ // can revoke nothing under attenuation, so we 403 it here rather than
123
+ // looking up the jti. Whether such a bearer may revoke a SPECIFIC jti
124
+ // is decided per-jti in step 5 via `canGrant`.
125
+ const bearerHasHostAuth = bearerScopes.includes(API_REVOKE_TOKEN_REQUIRED_SCOPE);
126
+ if (!bearerHasHostAuth && !hasMintingAuthority(bearerScopes)) {
85
127
  return jsonError(
86
128
  403,
87
129
  "insufficient_scope",
88
- `bearer token lacks ${API_REVOKE_TOKEN_REQUIRED_SCOPE}`,
130
+ `bearer token holds no revoke authority (need ${API_REVOKE_TOKEN_REQUIRED_SCOPE}, parachute:host:admin, or vault:<name>:admin)`,
89
131
  );
90
132
  }
91
133
 
@@ -103,15 +145,59 @@ export async function handleApiRevokeToken(
103
145
  if (typeof body.jti !== "string" || body.jti.length === 0) {
104
146
  return jsonError(400, "invalid_request", "jti is required and must be a non-empty string");
105
147
  }
148
+ // Cap the jti length. It's echoed verbatim into `error_description` and
149
+ // structured log lines; a real jti is a UUID/short token (well under 256
150
+ // chars), so a longer value is malformed input — reject it before it can
151
+ // bloat log lines. JSON-encoded responses already neutralize injection;
152
+ // this is a size guard, not an escaping one.
153
+ if (body.jti.length > MAX_JTI_LENGTH) {
154
+ return jsonError(400, "invalid_request", `jti exceeds ${MAX_JTI_LENGTH}-character maximum`);
155
+ }
106
156
  const jti = body.jti;
107
157
 
108
- // 5. Lookup + revoke. Order: row-existence first (404 if missing), then
109
- // attempt revoke. Idempotent: if already revoked, surface the existing
110
- // revoked_at same CLI semantics from hub#221.
158
+ // 5. Lookup + per-jti authority + revoke. Order: row-existence first
159
+ // (404 if missing same response for every caller, no leak), then the
160
+ // attenuation authority check (for non-host:auth bearers), then attempt
161
+ // revoke. Idempotent: if already revoked, surface the existing revoked_at
162
+ // — same CLI semantics from hub#221.
111
163
  const existing = findTokenRowByJti(deps.db, jti);
112
164
  if (!existing) {
113
165
  return jsonError(404, "not_found", `no token with jti ${jti} found in registry`);
114
166
  }
167
+
168
+ // Per-jti authority (rule 3 / symmetric to mint attenuation). A host:auth
169
+ // bearer skips this — it may revoke anything. Any other bearer may revoke
170
+ // this jti only if EVERY one of its recorded scopes is one the bearer could
171
+ // have minted (`canGrant`). One out-of-authority scope (cross-vault, a
172
+ // host:* scope, etc.) blocks the whole revoke with 403 — and the token is
173
+ // left intact. The caller already knows the jti, so "exists but not yours"
174
+ // leaks nothing beyond what it holds; idempotent-ok would falsely imply a
175
+ // revoke happened.
176
+ if (!bearerHasHostAuth) {
177
+ // A scopeless target (recorded `scopes: []`) would otherwise pass the
178
+ // `canGrant` filter vacuously — `[].filter(...)` is empty, so
179
+ // `ungrantable.length === 0`. That's silently permissive: any bearer
180
+ // clearing the entry gate could revoke a zero-scope token. Such tokens
181
+ // shouldn't exist (the CLI/SPA never mint them), but if one does, only a
182
+ // host:auth bearer may revoke it — a non-host:auth bearer has no
183
+ // attenuation authority that "covers" the empty scope set.
184
+ if (existing.scopes.length === 0) {
185
+ return jsonError(
186
+ 403,
187
+ "insufficient_scope",
188
+ `bearer token cannot revoke jti ${jti}: target has no recorded scopes (only ${API_REVOKE_TOKEN_REQUIRED_SCOPE} may revoke a scopeless token)`,
189
+ );
190
+ }
191
+ const ungrantable = existing.scopes.filter((s) => !canGrant(bearerScopes, s));
192
+ if (ungrantable.length > 0) {
193
+ return jsonError(
194
+ 403,
195
+ "insufficient_scope",
196
+ `bearer token cannot revoke jti ${jti}: its scope(s) ${ungrantable.join(", ")} are outside the bearer's authority`,
197
+ );
198
+ }
199
+ }
200
+
115
201
  if (existing.revokedAt) {
116
202
  return ok({ jti, revoked_at: existing.revokedAt });
117
203
  }
package/src/api-users.ts CHANGED
@@ -336,7 +336,16 @@ export async function handleCreateUser(req: Request, deps: ApiUsersDeps): Promis
336
336
  }
337
337
  }
338
338
 
339
- /** DELETE /api/users/:id — hard-delete + token revocation + session/grant cleanup. */
339
+ /**
340
+ * DELETE /api/users/:id — hard-delete + token revocation + session/grant
341
+ * cleanup.
342
+ *
343
+ * Success returns `200 { ok: true, revocation_lag_seconds: 60 }` (was a bare
344
+ * 204 pre-consistency-fix) so the SPA can warn that the deleted user's
345
+ * tokens linger ~60s on resource-server revocation caches — same surface
346
+ * the reset-password path carries. The race-tolerant "row already gone"
347
+ * path stays a bodyless 204 (nothing was revoked here, no lag to report).
348
+ */
340
349
  export async function handleDeleteUser(
341
350
  req: Request,
342
351
  userId: string,
@@ -390,11 +399,28 @@ export async function handleDeleteUser(
390
399
  if (!removed) {
391
400
  // Race: row deleted by a concurrent request. Operator's intent
392
401
  // (no such user) is already satisfied — same shape as the grant-
393
- // revoke race in `admin-grants.ts`.
402
+ // revoke race in `admin-grants.ts`. No tokens were revoked by THIS
403
+ // call, so there's no revocation lag to warn about; keep the bodyless
404
+ // 204 for the race path.
394
405
  return new Response(null, { status: 204 });
395
406
  }
396
407
  console.log(`user deleted: id=${userId} username=${target.username}`);
397
- return new Response(null, { status: 204 });
408
+ // `revocation_lag_seconds`: same consistency fix the reset-password path
409
+ // got (smoke 2026-05-27 finding 3). Deleting a user revokes their tokens
410
+ // in hub's DB immediately, but resource servers (vault, scribe, …) cache
411
+ // the revocation list via scope-guard's `REVOCATION_CACHE_TTL_MS = 60_000`
412
+ // — a deleted user's tokens linger for up to ~60s on those caches. Surface
413
+ // that so the admin isn't surprised when a just-deleted user's client can
414
+ // still read for a minute (relevant in the stolen-device / compromise
415
+ // threat model). 200 + body instead of the old bare 204 so the SPA can
416
+ // render the warning banner.
417
+ return new Response(
418
+ JSON.stringify({ ok: true, revocation_lag_seconds: REVOCATION_LAG_SECONDS }),
419
+ {
420
+ status: 200,
421
+ headers: { "content-type": "application/json", "cache-control": "no-store" },
422
+ },
423
+ );
398
424
  }
399
425
 
400
426
  /**
package/src/cli.ts CHANGED
@@ -6,10 +6,12 @@
6
6
  * Run `parachute --help` or `parachute <subcommand> --help` for usage.
7
7
  */
8
8
 
9
+ import { MissingDependencyError } from "@openparachute/depcheck";
9
10
  import pkg from "../package.json" with { type: "json" };
10
11
  import { CloudflaredStateError } from "./cloudflare/state.ts";
11
12
  import { auth } from "./commands/auth.ts";
12
13
  import { exposePublic, exposeTailnet } from "./commands/expose.ts";
14
+ import { init } from "./commands/init.ts";
13
15
  import { install } from "./commands/install.ts";
14
16
  import { logs, restart, start, stop } from "./commands/lifecycle.ts";
15
17
  import { migrate } from "./commands/migrate.ts";
@@ -18,15 +20,18 @@ import { setup } from "./commands/setup.ts";
18
20
  import { status } from "./commands/status.ts";
19
21
  import { upgrade } from "./commands/upgrade.ts";
20
22
  import { dispatchVault } from "./commands/vault.ts";
23
+ import { runSetupWizardCommand } from "./commands/wizard.ts";
21
24
  import { ExposeStateError } from "./expose-state.ts";
22
25
  import {
23
26
  exposeHelp,
27
+ initHelp,
24
28
  installHelp,
25
29
  logsHelp,
26
30
  migrateHelp,
27
31
  restartHelp,
28
32
  serveHelp,
29
33
  setupHelp,
34
+ setupWizardHelp,
30
35
  startHelp,
31
36
  statusHelp,
32
37
  stopHelp,
@@ -305,6 +310,76 @@ async function main(argv: string[]): Promise<number> {
305
310
  return await setup(setupOpts);
306
311
  }
307
312
 
313
+ case "setup-wizard": {
314
+ // hub#168 Cut 3 — the in-terminal mirror of /admin/setup. Distinct
315
+ // from `parachute setup` (which is the multi-pick install
316
+ // walk-through, not a wizard-handler frontend). Both surfaces stay
317
+ // — `parachute setup` is the historical "install + configure
318
+ // services" entry; `parachute setup-wizard` drives the same
319
+ // handlers the browser wizard uses.
320
+ if (isHelpFlag(rest[0])) {
321
+ console.log(setupWizardHelp());
322
+ return 0;
323
+ }
324
+ return await runSetupWizardCommand(rest);
325
+ }
326
+
327
+ case "init": {
328
+ if (isHelpFlag(rest[0])) {
329
+ console.log(initHelp());
330
+ return 0;
331
+ }
332
+ const exposeExtract = extractNamedFlag(rest, "--expose");
333
+ if (exposeExtract.error) {
334
+ console.error(`parachute init: ${exposeExtract.error}`);
335
+ return 1;
336
+ }
337
+ if (
338
+ exposeExtract.value !== undefined &&
339
+ exposeExtract.value !== "none" &&
340
+ exposeExtract.value !== "tailnet" &&
341
+ exposeExtract.value !== "cloudflare"
342
+ ) {
343
+ console.error(
344
+ `parachute init: --expose must be one of none|tailnet|cloudflare (got "${exposeExtract.value}")`,
345
+ );
346
+ return 1;
347
+ }
348
+ const noBrowser = exposeExtract.rest.includes("--no-browser");
349
+ const noExposePrompt = exposeExtract.rest.includes("--no-expose-prompt");
350
+ const cliWizard = exposeExtract.rest.includes("--cli-wizard");
351
+ const browserWizard = exposeExtract.rest.includes("--browser-wizard");
352
+ const known = new Set([
353
+ "--no-browser",
354
+ "--no-expose-prompt",
355
+ "--cli-wizard",
356
+ "--browser-wizard",
357
+ ]);
358
+ const unknown = exposeExtract.rest.find((a) => !known.has(a));
359
+ if (unknown !== undefined) {
360
+ console.error(`parachute init: unknown argument "${unknown}"`);
361
+ console.error(
362
+ "usage: parachute init [--no-browser] [--no-expose-prompt]\n" +
363
+ " [--expose none|tailnet|cloudflare]\n" +
364
+ " [--cli-wizard | --browser-wizard]",
365
+ );
366
+ return 1;
367
+ }
368
+ if (cliWizard && browserWizard) {
369
+ console.error("parachute init: --cli-wizard and --browser-wizard are mutually exclusive.");
370
+ return 1;
371
+ }
372
+ const initOpts: Parameters<typeof init>[0] = {};
373
+ if (noBrowser) initOpts.noBrowser = true;
374
+ if (noExposePrompt) initOpts.noExposePrompt = true;
375
+ if (exposeExtract.value) {
376
+ initOpts.exposeChoice = exposeExtract.value as "none" | "tailnet" | "cloudflare";
377
+ }
378
+ if (cliWizard) initOpts.wizardChoice = "cli";
379
+ else if (browserWizard) initOpts.wizardChoice = "browser";
380
+ return await init(initOpts);
381
+ }
382
+
308
383
  case "install": {
309
384
  if (isHelpFlag(rest[0])) {
310
385
  console.log(installHelp());
@@ -398,12 +473,21 @@ async function main(argv: string[]): Promise<number> {
398
473
  return 1;
399
474
  }
400
475
  const exposeArgs = flagExtract.rest;
401
- const layer = exposeArgs[0];
476
+ let layer = exposeArgs[0];
402
477
  const mode = exposeArgs[1];
403
478
  if (isHelpFlag(layer)) {
404
479
  console.log(exposeHelp());
405
480
  return 0;
406
481
  }
482
+ // Alias: `parachute expose cloudflare [--domain X] [off]` is shorthand for
483
+ // `parachute expose public --cloudflare …`. Cloudflare is a public-internet
484
+ // provider, so we rewrite the layer to `public` and force the cloudflare
485
+ // flag — the rest of the dispatch (domain prompt, off-path, etc.) is
486
+ // identical to the canonical form.
487
+ if (layer === "cloudflare") {
488
+ layer = "public";
489
+ flagExtract.cloudflare = true;
490
+ }
407
491
  if (layer !== "tailnet" && layer !== "public") {
408
492
  console.error(`parachute expose: unknown layer "${layer ?? ""}"`);
409
493
  console.error("usage: parachute expose tailnet [off]");
@@ -627,14 +711,17 @@ async function main(argv: string[]): Promise<number> {
627
711
  return 0;
628
712
  }
629
713
  const dryRun = rest.includes("--dry-run");
714
+ const list = rest.includes("--list");
630
715
  const yes = rest.includes("--yes") || rest.includes("-y");
631
- const unknown = rest.find((a) => a !== "--dry-run" && a !== "--yes" && a !== "-y");
716
+ const unknown = rest.find(
717
+ (a) => a !== "--dry-run" && a !== "--list" && a !== "--yes" && a !== "-y",
718
+ );
632
719
  if (unknown !== undefined) {
633
720
  console.error(`parachute migrate: unknown argument "${unknown}"`);
634
- console.error("usage: parachute migrate [--dry-run] [--yes]");
721
+ console.error("usage: parachute migrate [--list] [--dry-run] [--yes]");
635
722
  return 1;
636
723
  }
637
- return await migrate({ dryRun, yes });
724
+ return await migrate({ dryRun, list, yes });
638
725
  }
639
726
 
640
727
  case "serve": {
@@ -672,32 +759,24 @@ async function main(argv: string[]): Promise<number> {
672
759
  // after `vault` (including --help) is passed through verbatim.
673
760
  if (rest.length === 0) return await dispatchVault(["--help"]);
674
761
 
675
- // `parachute vault tokens create` in a TTY with no scope-narrowing flag
676
- // guided flow. Any of --scope / --read / --permission means the user
677
- // has already decided, so we stay out of the way. Non-TTY always
678
- // bypasses (no way to answer a prompt). Label is orthogonal the
679
- // guided flow prompts for it only if --label wasn't supplied.
680
- const wantsGuidedTokenCreate =
681
- rest[0] === "tokens" &&
682
- rest[1] === "create" &&
683
- isTtyInteractive() &&
684
- !rest.includes("--scope") &&
685
- !rest.includes("--read") &&
686
- !rest.includes("--permission") &&
687
- !isHelpFlag(rest[2]);
688
- if (wantsGuidedTokenCreate) {
689
- const { runVaultTokensCreateInteractive } = await import(
690
- "./commands/vault-tokens-create-interactive.ts"
691
- );
692
- return await runVaultTokensCreateInteractive({ args: rest.slice(2) });
693
- }
694
-
762
+ // Everything under `vault` forwards transparently to `parachute-vault`.
763
+ // `vault tokens create` used to route through a guided interactive
764
+ // wrapper, but the pvt_* DROP (vault#412 / hub#466) removed that vault
765
+ // subcommand it now exits 1 with migration guidance. Access tokens are
766
+ // hub-issued JWTs; mint them with `parachute auth mint-token` or the
767
+ // admin SPA Connect card. We forward verbatim so the operator sees
768
+ // vault's own migration error rather than a hub-side stub.
695
769
  return await dispatchVault(rest);
696
770
  }
697
771
 
698
772
  default:
699
773
  console.error(`parachute: unknown command "${command}"`);
700
- console.error("run `parachute --help` for usage");
774
+ console.error("");
775
+ console.error("If this is a fresh install, start here:");
776
+ console.error(" parachute init # get the admin wizard going");
777
+ console.error("");
778
+ console.error("Or see all commands:");
779
+ console.error(" parachute --help");
701
780
  return 1;
702
781
  }
703
782
  }
@@ -706,6 +785,14 @@ async function run(argv: string[]): Promise<number> {
706
785
  try {
707
786
  return await main(argv);
708
787
  } catch (err) {
788
+ if (err instanceof MissingDependencyError) {
789
+ // A required external binary wasn't on PATH (git / tailscale / tail /
790
+ // …). Print the friendly install block to stderr. interactive:true so
791
+ // the operator at a terminal sees the "ask your sysadmin" trailer; the
792
+ // message was already formatted at construction, so we just emit it.
793
+ console.error(err.message);
794
+ return 1;
795
+ }
709
796
  if (err instanceof ServicesManifestError) {
710
797
  console.error(`services.json is malformed: ${err.message}`);
711
798
  console.error("Fix or remove the file, then re-run.");
package/src/clients.ts CHANGED
@@ -12,12 +12,24 @@
12
12
  * plaintext exactly once. The token endpoint enforces client_secret per
13
13
  * RFC 6749 §3.2.1 (closes #72).
14
14
  *
15
- * Approval gate (closes #74): every row carries a `status` of `pending` or
16
- * `approved`. New self-registrations default to `pending`; only registrations
17
- * that authenticate with an operator token bearing `hub:admin` (the install-
18
- * time path for first-party modules) land as `approved`. The OAuth flow
19
- * rejects `pending` clients at `/oauth/authorize` and `/oauth/token`. An
20
- * operator promotes a pending client via `parachute auth approve-client`.
15
+ * Status column (`pending` | `approved`): every row carries one. New
16
+ * self-registrations default to `pending`; registrations that authenticate
17
+ * with an operator token bearing `hub:admin` (the install-time path for
18
+ * first-party modules) land as `approved`.
19
+ *
20
+ * Single-consent change (2026-05-29): the separate operator "approve this
21
+ * client" gate was retired. The user's OAuth consent IS the authorization —
22
+ * `handleAuthorizeGet` now session-gates a `pending` client: a request
23
+ * carrying a valid session auto-approves the client (status → `approved`,
24
+ * audit-logged) and FALLS THROUGH to the normal consent screen; a session-
25
+ * less request still renders the unauth "App not yet approved" page whose
26
+ * sign-in CTA round-trips back to authorize (after login the user re-enters
27
+ * with a session → auto-approve → consent). The `status` column, the DCR
28
+ * `pending` default, the `/oauth/token` pending rejection, and the
29
+ * `parachute auth approve-client` / SPA approve surfaces all persist but are
30
+ * near-vestigial — kept for defense-in-depth and back-compat. Motivation:
31
+ * Notes/Claude DCR a fresh `client_id` per instance, so a per-client_id
32
+ * approval gate re-prompted the operator constantly.
21
33
  */
22
34
  import type { Database } from "bun:sqlite";
23
35
  import { createHash, randomBytes, randomUUID } from "node:crypto";
@@ -2,8 +2,6 @@ import { mkdirSync, writeFileSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import { CONFIG_DIR } from "../config.ts";
4
4
 
5
- export const CLOUDFLARED_DIR = join(CONFIG_DIR, "cloudflared");
6
-
7
5
  export const DEFAULT_TUNNEL_NAME = "parachute";
8
6
 
9
7
  /**
@@ -16,12 +14,20 @@ export const DEFAULT_TUNNEL_NAME = "parachute";
16
14
  * location change from pre-#32 (`~/.parachute/cloudflared/config.yml`).
17
15
  * Re-running `parachute expose public --cloudflare` regenerates the file
18
16
  * at the new path; the legacy file is left in place but unused.
17
+ *
18
+ * `configDir` overrides the base (`~/.parachute` by default). Tests pass a
19
+ * tmp dir so per-tunnel-derived paths never resolve against the operator's
20
+ * real `CONFIG_DIR` — otherwise running the suite scribbles fixture
21
+ * config.yml + log files into `~/.parachute/cloudflared/<name>/`.
19
22
  */
20
- export function cloudflaredPathsFor(tunnelName: string): {
23
+ export function cloudflaredPathsFor(
24
+ tunnelName: string,
25
+ configDir: string = CONFIG_DIR,
26
+ ): {
21
27
  configPath: string;
22
28
  logPath: string;
23
29
  } {
24
- const dir = join(CLOUDFLARED_DIR, tunnelName);
30
+ const dir = join(configDir, "cloudflared", tunnelName);
25
31
  return {
26
32
  configPath: join(dir, "config.yml"),
27
33
  logPath: join(dir, "cloudflared.log"),
@@ -1,6 +1,7 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
+ import { isBinaryNotFoundError, lookupDep } from "@openparachute/depcheck";
4
5
  import type { Runner } from "../tailscale/run.ts";
5
6
 
6
7
  export const DEFAULT_CLOUDFLARED_HOME = join(homedir(), ".cloudflared");
@@ -10,30 +11,22 @@ export const DEFAULT_CLOUDFLARED_HOME = join(homedir(), ".cloudflared");
10
11
  * "binary not on PATH" errors — anything else (EACCES from a non-executable
11
12
  * file, corrupted binary, etc.) propagates so we don't silently report
12
13
  * "not installed" when something more specific is wrong.
14
+ *
15
+ * The not-found matcher is `@openparachute/depcheck`'s `isBinaryNotFoundError`
16
+ * — the single source of truth across the ecosystem (this used to be a local
17
+ * copy that drifted from vault's `git-preflight.ts`). Pass the binary name so
18
+ * a not-found message about an unrelated file isn't mis-attributed.
13
19
  */
14
20
  export async function isCloudflaredInstalled(runner: Runner): Promise<boolean> {
15
21
  try {
16
22
  const { code } = await runner(["cloudflared", "--version"]);
17
23
  return code === 0;
18
24
  } catch (err) {
19
- if (isBinaryNotFoundError(err)) return false;
25
+ if (isBinaryNotFoundError(err, "cloudflared")) return false;
20
26
  throw err;
21
27
  }
22
28
  }
23
29
 
24
- function isBinaryNotFoundError(err: unknown): boolean {
25
- if (!err || typeof err !== "object") return false;
26
- const e = err as { code?: unknown; message?: unknown };
27
- if (e.code === "ENOENT") return true;
28
- // Bun.spawn's error shape varies across versions; fall back to message
29
- // string matching so we catch "Executable not found in $PATH" and
30
- // "ENOENT" variants without pinning to one runtime detail.
31
- if (typeof e.message === "string") {
32
- return /ENOENT|not found|No such file/i.test(e.message);
33
- }
34
- return false;
35
- }
36
-
37
30
  /**
38
31
  * `cloudflared tunnel login` drops a cert at `~/.cloudflared/cert.pem` — its
39
32
  * presence is cloudflared's own login marker. Every `cloudflared tunnel
@@ -45,14 +38,83 @@ export function isCloudflaredLoggedIn(cloudflaredHome: string = DEFAULT_CLOUDFLA
45
38
  return existsSync(join(cloudflaredHome, "cert.pem"));
46
39
  }
47
40
 
48
- export function cloudflaredInstallHint(platform: NodeJS.Platform = process.platform): string {
49
- const url =
50
- "https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/";
41
+ /**
42
+ * Cloudflare's "Downloads" page (developers.cloudflare.com/cloudflare-one/
43
+ * connections/connect-networks/downloads/) churns markdown anchors; pkg.cloudflare.com
44
+ * paths the older instructions referenced now serve HTML / 404. Aaron hit
45
+ * the failure mode on a fresh Amazon Linux 2023 EC2 install (2026-05-27):
46
+ * `sudo dnf install cloudflared` returned 'No match for argument:
47
+ * cloudflared'. The reliable cross-distro path is grabbing the static
48
+ * binary from Cloudflare's GitHub releases.
49
+ *
50
+ * Canonical install paths:
51
+ *
52
+ * macOS → `brew install cloudflared` (homebrew is the documented path)
53
+ * Linux → architecture-specific binary from GitHub releases
54
+ * other → the binary-download path is still the best generic answer
55
+ *
56
+ * The `arch` parameter is the architecture string in `process.arch`
57
+ * shape (`x64`, `arm64`, `arm`). The static-binary curl recipe + the arch
58
+ * mapping now live in `@openparachute/depcheck`'s `cloudflared` registry
59
+ * entry (`install.linuxBinaryUrl`) — the single source of truth shared with
60
+ * the structured `MissingDependencyError` UX. This function keeps its own
61
+ * prose (the surrounding "works across distros" framing the expose flow
62
+ * prints) but derives the URL + arch support from the registry so the two
63
+ * can't drift. A `undefined` recipe (arch with no published artifact) is the
64
+ * signal to fall through to the generic releases pointer rather than
65
+ * fabricating a 404-bound URL.
66
+ */
67
+ export function cloudflaredInstallHint(
68
+ platform: NodeJS.Platform = process.platform,
69
+ arch: NodeJS.Architecture = process.arch,
70
+ ): string {
71
+ const releasesUrl = "https://github.com/cloudflare/cloudflared/releases/latest";
51
72
  if (platform === "darwin") {
52
- return `Install cloudflared:\n brew install cloudflared\n(or see ${url})`;
73
+ return [
74
+ "Install cloudflared:",
75
+ " brew install cloudflared",
76
+ "",
77
+ "(or download a static binary from",
78
+ ` ${releasesUrl})`,
79
+ ].join("\n");
53
80
  }
54
81
  if (platform === "linux") {
55
- return `Install cloudflared: ${url}`;
82
+ const downloadUrl = cloudflaredLinuxDownloadUrl(arch);
83
+ if (downloadUrl) {
84
+ return [
85
+ "Install cloudflared (static binary — works across distros):",
86
+ " curl -L -o /usr/local/bin/cloudflared \\",
87
+ ` ${downloadUrl}`,
88
+ " sudo chmod +x /usr/local/bin/cloudflared",
89
+ " cloudflared --version",
90
+ "",
91
+ "(distro packages are unreliable across versions; the GitHub release is the canonical path.)",
92
+ ].join("\n");
93
+ }
94
+ return [
95
+ "Install cloudflared from the official binary release:",
96
+ ` ${releasesUrl}`,
97
+ `(pick the linux-* artifact matching your architecture; your arch is "${arch}")`,
98
+ ].join("\n");
56
99
  }
57
- return `Install cloudflared: ${url}`;
100
+ return ["Install cloudflared from the official binary release:", ` ${releasesUrl}`].join("\n");
101
+ }
102
+
103
+ /**
104
+ * Pull the cloudflared-linux-<suffix> download URL for an arch out of the
105
+ * depcheck registry's static-binary recipe. The registry recipe is a
106
+ * multi-line `curl … / chmod … / version` block; we extract the single
107
+ * `https://…/cloudflared-linux-<suffix>` line so this function's own prose
108
+ * wraps the canonical URL. Returns undefined when the arch has no published
109
+ * artifact (registry recipe is undefined) — the caller then uses the generic
110
+ * pointer. Keeps the arch→suffix mapping in exactly one place (the registry).
111
+ */
112
+ function cloudflaredLinuxDownloadUrl(arch: NodeJS.Architecture): string | undefined {
113
+ const recipe = lookupDep("cloudflared")?.install.linuxBinaryUrl?.(arch);
114
+ if (!recipe) return undefined;
115
+ const urlLine = recipe
116
+ .split("\n")
117
+ .map((l) => l.trim())
118
+ .find((l) => l.startsWith("https://"));
119
+ return urlLine;
58
120
  }