@openparachute/hub 0.5.13 → 0.5.14-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 (101) hide show
  1. package/README.md +109 -15
  2. package/package.json +2 -2
  3. package/src/__tests__/account-home-ui.test.ts +205 -0
  4. package/src/__tests__/admin-handlers.test.ts +74 -0
  5. package/src/__tests__/admin-host-admin-token.test.ts +62 -0
  6. package/src/__tests__/admin-vault-admin-token.test.ts +44 -0
  7. package/src/__tests__/admin-vaults.test.ts +70 -4
  8. package/src/__tests__/api-account.test.ts +191 -1
  9. package/src/__tests__/api-mint-token.test.ts +682 -3
  10. package/src/__tests__/api-modules-config.test.ts +16 -10
  11. package/src/__tests__/api-modules-ops.test.ts +97 -0
  12. package/src/__tests__/api-modules.test.ts +100 -83
  13. package/src/__tests__/api-ready.test.ts +135 -0
  14. package/src/__tests__/api-revoke-token.test.ts +384 -0
  15. package/src/__tests__/api-users.test.ts +390 -13
  16. package/src/__tests__/chrome-strip.test.ts +15 -15
  17. package/src/__tests__/cli.test.ts +7 -5
  18. package/src/__tests__/cloudflare-detect.test.ts +60 -5
  19. package/src/__tests__/expose-auth-preflight.test.ts +58 -50
  20. package/src/__tests__/expose-cloudflare.test.ts +114 -3
  21. package/src/__tests__/expose-interactive.test.ts +10 -4
  22. package/src/__tests__/expose-public-auto.test.ts +5 -1
  23. package/src/__tests__/expose.test.ts +49 -1
  24. package/src/__tests__/hub-db.test.ts +194 -29
  25. package/src/__tests__/hub-server.test.ts +322 -33
  26. package/src/__tests__/hub.test.ts +11 -0
  27. package/src/__tests__/init.test.ts +827 -0
  28. package/src/__tests__/lifecycle.test.ts +33 -1
  29. package/src/__tests__/migrate.test.ts +433 -51
  30. package/src/__tests__/notes-redirect.test.ts +20 -20
  31. package/src/__tests__/oauth-handlers.test.ts +1060 -29
  32. package/src/__tests__/oauth-ui.test.ts +12 -1
  33. package/src/__tests__/proxy-error-ui.test.ts +212 -0
  34. package/src/__tests__/proxy-state.test.ts +192 -0
  35. package/src/__tests__/resource-binding.test.ts +97 -0
  36. package/src/__tests__/scope-explanations.test.ts +36 -0
  37. package/src/__tests__/serve.test.ts +9 -9
  38. package/src/__tests__/services-manifest.test.ts +40 -40
  39. package/src/__tests__/setup-wizard.test.ts +1114 -66
  40. package/src/__tests__/setup.test.ts +1 -1
  41. package/src/__tests__/status.test.ts +39 -0
  42. package/src/__tests__/users.test.ts +396 -9
  43. package/src/__tests__/vault-auth-status.test.ts +271 -11
  44. package/src/__tests__/vault-hub-origin-env.test.ts +126 -0
  45. package/src/__tests__/well-known.test.ts +9 -9
  46. package/src/__tests__/wizard.test.ts +372 -0
  47. package/src/account-home-ui.ts +547 -0
  48. package/src/admin-handlers.ts +49 -17
  49. package/src/admin-host-admin-token.ts +25 -0
  50. package/src/admin-login-ui.ts +4 -4
  51. package/src/admin-vault-admin-token.ts +17 -0
  52. package/src/admin-vaults.ts +48 -15
  53. package/src/api-account.ts +72 -6
  54. package/src/api-mint-token.ts +132 -24
  55. package/src/api-modules-ops.ts +52 -16
  56. package/src/api-modules.ts +31 -14
  57. package/src/api-ready.ts +102 -0
  58. package/src/api-revoke-token.ts +107 -21
  59. package/src/api-users.ts +497 -58
  60. package/src/bun-link.ts +55 -0
  61. package/src/chrome-strip.ts +6 -6
  62. package/src/cli.ts +93 -24
  63. package/src/cloudflare/config.ts +10 -4
  64. package/src/cloudflare/detect.ts +73 -6
  65. package/src/commands/expose-auth-preflight.ts +55 -63
  66. package/src/commands/expose-cloudflare.ts +114 -10
  67. package/src/commands/expose-interactive.ts +10 -11
  68. package/src/commands/expose-public-auto.ts +6 -4
  69. package/src/commands/expose.ts +8 -0
  70. package/src/commands/init.ts +563 -0
  71. package/src/commands/install.ts +41 -23
  72. package/src/commands/lifecycle.ts +12 -0
  73. package/src/commands/migrate.ts +293 -41
  74. package/src/commands/status.ts +10 -1
  75. package/src/commands/wizard.ts +843 -0
  76. package/src/env-file.ts +10 -0
  77. package/src/help.ts +157 -17
  78. package/src/hub-db.ts +42 -0
  79. package/src/hub-server.ts +136 -23
  80. package/src/hub-settings.ts +13 -2
  81. package/src/hub.ts +16 -9
  82. package/src/notes-redirect.ts +5 -5
  83. package/src/oauth-handlers.ts +342 -173
  84. package/src/oauth-ui.ts +28 -2
  85. package/src/proxy-error-ui.ts +506 -0
  86. package/src/proxy-state.ts +131 -0
  87. package/src/resource-binding.ts +134 -0
  88. package/src/scope-attenuation.ts +85 -0
  89. package/src/scope-explanations.ts +94 -5
  90. package/src/service-spec.ts +39 -18
  91. package/src/setup-wizard.ts +1173 -117
  92. package/src/users.ts +307 -29
  93. package/src/vault/auth-status.ts +152 -25
  94. package/src/vault-hub-origin-env.ts +100 -0
  95. package/web/ui/dist/assets/index-2SSK7JbM.js +61 -0
  96. package/web/ui/dist/assets/index-B28SdMSz.css +1 -0
  97. package/web/ui/dist/index.html +2 -2
  98. package/src/__tests__/vault-tokens-create-interactive.test.ts +0 -183
  99. package/src/commands/vault-tokens-create-interactive.ts +0 -143
  100. package/web/ui/dist/assets/index-7DtAXz7y.css +0 -1
  101. package/web/ui/dist/assets/index-Dzrbe6EP.js +0 -61
@@ -0,0 +1,55 @@
1
+ /**
2
+ * bun-link detection — shared helper used by both the CLI install path
3
+ * (`commands/install.ts`) and the API/wizard install path (`api-modules-ops.ts`).
4
+ *
5
+ * "Linked" means a global symlink shape under `~/.bun/install/global/node_modules/<pkg>`
6
+ * created by `bun link` (from a local checkout). When the package is already linked,
7
+ * `bun add -g <pkg>` is at best a wasted npm round-trip (~3s) and at worst a hard
8
+ * failure when the global bun.lock has unrelated noise — neither outcome is desirable
9
+ * given the linked checkout already provides the binary on PATH.
10
+ *
11
+ * Both install paths gate the `bun add -g` call on `isLinked(pkg) === false`.
12
+ * Centralizing the detection here keeps the CLI and wizard in lockstep — diverging
13
+ * (as the wizard did pre-hub#433) is the bug class this module exists to prevent.
14
+ */
15
+
16
+ import { lstatSync } from "node:fs";
17
+ import { homedir } from "node:os";
18
+ import { join } from "node:path";
19
+
20
+ /**
21
+ * The set of bun global-prefix locations to probe for a `<pkg>` symlink.
22
+ * Honors `BUN_INSTALL` (the canonical override) before falling back to the
23
+ * default `~/.bun` layout. Order matters — env-set prefix wins on a custom
24
+ * bun layout (containers, CI).
25
+ */
26
+ export function bunGlobalPrefixes(): string[] {
27
+ const prefixes: string[] = [];
28
+ const fromEnv = process.env.BUN_INSTALL;
29
+ if (fromEnv) prefixes.push(join(fromEnv, "install", "global", "node_modules"));
30
+ prefixes.push(join(homedir(), ".bun", "install", "global", "node_modules"));
31
+ return prefixes;
32
+ }
33
+
34
+ /**
35
+ * True iff `<pkg>` resolves to a symlink under any bun global prefix —
36
+ * i.e. the package was installed via `bun link` from a local checkout
37
+ * rather than `bun add -g` from npm. Used to short-circuit `bun add -g`
38
+ * in both the CLI and the wizard install paths.
39
+ *
40
+ * Scoped packages (`@openparachute/vault`) are split on `/` so the probe
41
+ * lands at `<prefix>/@openparachute/vault`. Non-symlink resolutions
42
+ * (real dir from `bun add -g`) return false — we only want to skip the
43
+ * `bun add -g` when the symlink-shape is in place.
44
+ */
45
+ export function isLinked(pkg: string): boolean {
46
+ for (const prefix of bunGlobalPrefixes()) {
47
+ const path = join(prefix, ...pkg.split("/"));
48
+ try {
49
+ if (lstatSync(path).isSymbolicLink()) return true;
50
+ } catch {
51
+ // Not present at this prefix; try the next.
52
+ }
53
+ }
54
+ return false;
55
+ }
@@ -13,7 +13,7 @@
13
13
  * (above that threshold the response is almost certainly not an HTML shell
14
14
  * anyway — SPA index.html files are < 16 KB in this ecosystem).
15
15
  *
16
- * Opt-out: hub-side path-prefix deny list. The Notes PWA at `/app/notes/*`
16
+ * Opt-out: hub-side path-prefix deny list. The Notes PWA at `/surface/notes/*`
17
17
  * is the canonical opt-out — it owns its own chrome (see design-system §7
18
18
  * "Where NOT to inject" + AUDIT §4: "Notes is the proof this can work: own
19
19
  * application, looks distinctively Notes, reads as Parachute because the
@@ -22,7 +22,7 @@
22
22
  * Why path-based and not module-declared:
23
23
  * - Notes is a `uis[]` sub-unit of parachute-app, not its own module —
24
24
  * adding `chrome: "off"` to parachute-app's module.json would suppress
25
- * chrome on `/app/admin/*` too (wrong: that surface SHOULD get chrome).
25
+ * chrome on `/surface/admin/*` too (wrong: that surface SHOULD get chrome).
26
26
  * - The per-uis well-known fan-out (workstream C/4) is in flight but the
27
27
  * hub side doesn't yet thread per-uis metadata into proxy dispatch.
28
28
  * - HTML meta-tag peeking adds parsing overhead on every response.
@@ -46,10 +46,10 @@ import { CSRF_FIELD_NAME, ensureCsrfToken } from "./csrf.ts";
46
46
  * prefix" or "pathname startsWith prefix" — the same shape as
47
47
  * `findServiceUpstream`'s mount comparison.
48
48
  *
49
- * `/app/notes/` covers the Notes PWA bundled by parachute-app. Notes is a
49
+ * `/surface/notes/` covers the Notes PWA bundled by parachute-app. Notes is a
50
50
  * destination, not chrome; it owns its own header (see design-system.md §7).
51
51
  */
52
- export const CHROME_OPT_OUT_PREFIXES: readonly string[] = ["/app/notes/"];
52
+ export const CHROME_OPT_OUT_PREFIXES: readonly string[] = ["/surface/notes/"];
53
53
 
54
54
  /**
55
55
  * Buffer size cap. Responses larger than this are passed through unchanged.
@@ -208,8 +208,8 @@ function renderSignedOutCluster(nextPath: string): string {
208
208
  * when any opt-out prefix matches (`pathname === prefix` or
209
209
  * `pathname startsWith prefix`).
210
210
  *
211
- * Match shape mirrors `findServiceUpstream` so an opt-out for `"/app/notes/"`
212
- * suppresses chrome for `/app/notes`, `/app/notes/`, and every sub-path.
211
+ * Match shape mirrors `findServiceUpstream` so an opt-out for `"/surface/notes/"`
212
+ * suppresses chrome for `/surface/notes`, `/surface/notes/`, and every sub-path.
213
213
  */
214
214
  export function shouldInjectChrome(
215
215
  pathname: string,
package/src/cli.ts CHANGED
@@ -10,6 +10,7 @@ import pkg from "../package.json" with { type: "json" };
10
10
  import { CloudflaredStateError } from "./cloudflare/state.ts";
11
11
  import { auth } from "./commands/auth.ts";
12
12
  import { exposePublic, exposeTailnet } from "./commands/expose.ts";
13
+ import { init } from "./commands/init.ts";
13
14
  import { install } from "./commands/install.ts";
14
15
  import { logs, restart, start, stop } from "./commands/lifecycle.ts";
15
16
  import { migrate } from "./commands/migrate.ts";
@@ -18,15 +19,18 @@ import { setup } from "./commands/setup.ts";
18
19
  import { status } from "./commands/status.ts";
19
20
  import { upgrade } from "./commands/upgrade.ts";
20
21
  import { dispatchVault } from "./commands/vault.ts";
22
+ import { runSetupWizardCommand } from "./commands/wizard.ts";
21
23
  import { ExposeStateError } from "./expose-state.ts";
22
24
  import {
23
25
  exposeHelp,
26
+ initHelp,
24
27
  installHelp,
25
28
  logsHelp,
26
29
  migrateHelp,
27
30
  restartHelp,
28
31
  serveHelp,
29
32
  setupHelp,
33
+ setupWizardHelp,
30
34
  startHelp,
31
35
  statusHelp,
32
36
  stopHelp,
@@ -305,6 +309,76 @@ async function main(argv: string[]): Promise<number> {
305
309
  return await setup(setupOpts);
306
310
  }
307
311
 
312
+ case "setup-wizard": {
313
+ // hub#168 Cut 3 — the in-terminal mirror of /admin/setup. Distinct
314
+ // from `parachute setup` (which is the multi-pick install
315
+ // walk-through, not a wizard-handler frontend). Both surfaces stay
316
+ // — `parachute setup` is the historical "install + configure
317
+ // services" entry; `parachute setup-wizard` drives the same
318
+ // handlers the browser wizard uses.
319
+ if (isHelpFlag(rest[0])) {
320
+ console.log(setupWizardHelp());
321
+ return 0;
322
+ }
323
+ return await runSetupWizardCommand(rest);
324
+ }
325
+
326
+ case "init": {
327
+ if (isHelpFlag(rest[0])) {
328
+ console.log(initHelp());
329
+ return 0;
330
+ }
331
+ const exposeExtract = extractNamedFlag(rest, "--expose");
332
+ if (exposeExtract.error) {
333
+ console.error(`parachute init: ${exposeExtract.error}`);
334
+ return 1;
335
+ }
336
+ if (
337
+ exposeExtract.value !== undefined &&
338
+ exposeExtract.value !== "none" &&
339
+ exposeExtract.value !== "tailnet" &&
340
+ exposeExtract.value !== "cloudflare"
341
+ ) {
342
+ console.error(
343
+ `parachute init: --expose must be one of none|tailnet|cloudflare (got "${exposeExtract.value}")`,
344
+ );
345
+ return 1;
346
+ }
347
+ const noBrowser = exposeExtract.rest.includes("--no-browser");
348
+ const noExposePrompt = exposeExtract.rest.includes("--no-expose-prompt");
349
+ const cliWizard = exposeExtract.rest.includes("--cli-wizard");
350
+ const browserWizard = exposeExtract.rest.includes("--browser-wizard");
351
+ const known = new Set([
352
+ "--no-browser",
353
+ "--no-expose-prompt",
354
+ "--cli-wizard",
355
+ "--browser-wizard",
356
+ ]);
357
+ const unknown = exposeExtract.rest.find((a) => !known.has(a));
358
+ if (unknown !== undefined) {
359
+ console.error(`parachute init: unknown argument "${unknown}"`);
360
+ console.error(
361
+ "usage: parachute init [--no-browser] [--no-expose-prompt]\n" +
362
+ " [--expose none|tailnet|cloudflare]\n" +
363
+ " [--cli-wizard | --browser-wizard]",
364
+ );
365
+ return 1;
366
+ }
367
+ if (cliWizard && browserWizard) {
368
+ console.error("parachute init: --cli-wizard and --browser-wizard are mutually exclusive.");
369
+ return 1;
370
+ }
371
+ const initOpts: Parameters<typeof init>[0] = {};
372
+ if (noBrowser) initOpts.noBrowser = true;
373
+ if (noExposePrompt) initOpts.noExposePrompt = true;
374
+ if (exposeExtract.value) {
375
+ initOpts.exposeChoice = exposeExtract.value as "none" | "tailnet" | "cloudflare";
376
+ }
377
+ if (cliWizard) initOpts.wizardChoice = "cli";
378
+ else if (browserWizard) initOpts.wizardChoice = "browser";
379
+ return await init(initOpts);
380
+ }
381
+
308
382
  case "install": {
309
383
  if (isHelpFlag(rest[0])) {
310
384
  console.log(installHelp());
@@ -627,14 +701,17 @@ async function main(argv: string[]): Promise<number> {
627
701
  return 0;
628
702
  }
629
703
  const dryRun = rest.includes("--dry-run");
704
+ const list = rest.includes("--list");
630
705
  const yes = rest.includes("--yes") || rest.includes("-y");
631
- const unknown = rest.find((a) => a !== "--dry-run" && a !== "--yes" && a !== "-y");
706
+ const unknown = rest.find(
707
+ (a) => a !== "--dry-run" && a !== "--list" && a !== "--yes" && a !== "-y",
708
+ );
632
709
  if (unknown !== undefined) {
633
710
  console.error(`parachute migrate: unknown argument "${unknown}"`);
634
- console.error("usage: parachute migrate [--dry-run] [--yes]");
711
+ console.error("usage: parachute migrate [--list] [--dry-run] [--yes]");
635
712
  return 1;
636
713
  }
637
- return await migrate({ dryRun, yes });
714
+ return await migrate({ dryRun, list, yes });
638
715
  }
639
716
 
640
717
  case "serve": {
@@ -672,32 +749,24 @@ async function main(argv: string[]): Promise<number> {
672
749
  // after `vault` (including --help) is passed through verbatim.
673
750
  if (rest.length === 0) return await dispatchVault(["--help"]);
674
751
 
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
-
752
+ // Everything under `vault` forwards transparently to `parachute-vault`.
753
+ // `vault tokens create` used to route through a guided interactive
754
+ // wrapper, but the pvt_* DROP (vault#412 / hub#466) removed that vault
755
+ // subcommand it now exits 1 with migration guidance. Access tokens are
756
+ // hub-issued JWTs; mint them with `parachute auth mint-token` or the
757
+ // admin SPA Connect card. We forward verbatim so the operator sees
758
+ // vault's own migration error rather than a hub-side stub.
695
759
  return await dispatchVault(rest);
696
760
  }
697
761
 
698
762
  default:
699
763
  console.error(`parachute: unknown command "${command}"`);
700
- console.error("run `parachute --help` for usage");
764
+ console.error("");
765
+ console.error("If this is a fresh install, start here:");
766
+ console.error(" parachute init # get the admin wizard going");
767
+ console.error("");
768
+ console.error("Or see all commands:");
769
+ console.error(" parachute --help");
701
770
  return 1;
702
771
  }
703
772
  }
@@ -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"),
@@ -45,14 +45,81 @@ export function isCloudflaredLoggedIn(cloudflaredHome: string = DEFAULT_CLOUDFLA
45
45
  return existsSync(join(cloudflaredHome, "cert.pem"));
46
46
  }
47
47
 
48
- export function cloudflaredInstallHint(platform: NodeJS.Platform = process.platform): string {
49
- const url =
50
- "https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/";
48
+ /**
49
+ * Cloudflare's "Downloads" page (developers.cloudflare.com/cloudflare-one/
50
+ * connections/connect-networks/downloads/) churns markdown anchors; pkg.cloudflare.com
51
+ * paths the older instructions referenced now serve HTML / 404. Aaron hit
52
+ * the failure mode on a fresh Amazon Linux 2023 EC2 install (2026-05-27):
53
+ * `sudo dnf install cloudflared` returned 'No match for argument:
54
+ * cloudflared'. The reliable cross-distro path is grabbing the static
55
+ * binary from Cloudflare's GitHub releases.
56
+ *
57
+ * Canonical install paths:
58
+ *
59
+ * macOS → `brew install cloudflared` (homebrew is the documented path)
60
+ * Linux → architecture-specific binary from GitHub releases
61
+ * other → the binary-download path is still the best generic answer
62
+ *
63
+ * The `arch` parameter is the architecture string in `process.arch`
64
+ * shape (`x64`, `arm64`, `arm`). Mapped to the suffix cloudflared uses
65
+ * in its release artifacts (`amd64`, `arm64`, `arm`). Unknown arches
66
+ * fall through to a generic pointer at the releases page.
67
+ */
68
+ export function cloudflaredInstallHint(
69
+ platform: NodeJS.Platform = process.platform,
70
+ arch: NodeJS.Architecture = process.arch,
71
+ ): string {
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
+ " https://github.com/cloudflare/cloudflared/releases/latest)",
79
+ ].join("\n");
53
80
  }
54
81
  if (platform === "linux") {
55
- return `Install cloudflared: ${url}`;
82
+ const suffix = linuxArtifactSuffix(arch);
83
+ if (suffix) {
84
+ return [
85
+ "Install cloudflared (static binary — works across distros):",
86
+ ` curl -L -o /usr/local/bin/cloudflared \\`,
87
+ ` https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-${suffix}`,
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
+ " https://github.com/cloudflare/cloudflared/releases/latest",
97
+ `(pick the linux-* artifact matching your architecture; your arch is "${arch}")`,
98
+ ].join("\n");
99
+ }
100
+ return [
101
+ "Install cloudflared from the official binary release:",
102
+ " https://github.com/cloudflare/cloudflared/releases/latest",
103
+ ].join("\n");
104
+ }
105
+
106
+ /**
107
+ * Map a Node `process.arch` to the suffix Cloudflare uses for its
108
+ * cloudflared-linux-* release artifacts. Returns undefined for arches
109
+ * that don't have a published artifact (we surface a generic pointer
110
+ * in that case instead of fabricating a download URL that 404s).
111
+ */
112
+ function linuxArtifactSuffix(arch: NodeJS.Architecture): string | undefined {
113
+ switch (arch) {
114
+ case "x64":
115
+ return "amd64";
116
+ case "arm64":
117
+ return "arm64";
118
+ case "arm":
119
+ return "arm";
120
+ case "ia32":
121
+ return "386";
122
+ default:
123
+ return undefined;
56
124
  }
57
- return `Install cloudflared: ${url}`;
58
125
  }
@@ -2,20 +2,28 @@
2
2
  * Post-exposure auth nudge. Runs after `parachute expose public` successfully
3
3
  * brings a tunnel up (TTY only). The tunnel is already live; this is purely
4
4
  * advisory — we never error the exposure flow regardless of what the user
5
- * chooses. The goal is to catch the "fresh vault, just went public, no
6
- * password or tokens set" trap before someone else finds it first.
5
+ * chooses. The goal is to catch the "fresh vault, just went public, no auth
6
+ * configured" trap before someone else finds it first.
7
7
  *
8
- * Four states we branch on, based on {@link VaultAuthStatus}:
8
+ * The load-bearing signal is the **owner password**. Post-pvt_*-DROP (vault
9
+ * #412 / hub#466), the vault `tokens` table holds only vestigial pvt_* rows;
10
+ * a non-zero count no longer means "API auth is configured." Access is now
11
+ * hub-issued JWTs, minted against the operator's identity — and minting that
12
+ * identity requires the owner password (browser OAuth) or the operator token
13
+ * that `set-password` seeds. So "has an owner password" is the single gate
14
+ * that tells us whether *any* authenticated access is reachable. We branch
15
+ * purely on password + 2FA; we no longer count vault-DB rows for the auth
16
+ * decision.
9
17
  *
10
- * - neither password nor tokens: loud warning + offer to set up each.
18
+ * Three states we branch on, based on {@link VaultAuthStatus}:
19
+ *
20
+ * - no owner password: loud warning — the exposure is wide open. Offer to
21
+ * set a password (+ 2FA), and point at the hub-JWT mint path for clients.
11
22
  * - password, no 2FA: shorter "recommend 2FA" nudge.
12
- * - tokens but no password: OAuth isn't set up; offer to add a password.
13
- * - `tokenCount === null`: couldn't read the DB; advisory only, no prompts
14
- * that depend on token state.
15
- * - all set: one-line "looks good" (the quiet path).
23
+ * - password + 2FA: one-line "looks good" (the quiet path).
16
24
  *
17
25
  * Defaults are always "skip" — Enter declines every prompt. User can always
18
- * run `parachute auth …` or `parachute vault tokens create` later.
26
+ * run `parachute auth set-password` / `parachute auth mint-token …` later.
19
27
  */
20
28
 
21
29
  import { createInterface } from "node:readline/promises";
@@ -106,10 +114,25 @@ async function offerTotp(r: Resolved): Promise<void> {
106
114
  }
107
115
  }
108
116
 
109
- async function offerTokenCreate(r: Resolved): Promise<void> {
110
- if (await yesNo(r, "Create an API token now?")) {
111
- await runCmd(r, ["parachute", "vault", "tokens", "create"], "parachute vault tokens create");
112
- }
117
+ /**
118
+ * Programmatic / headless clients don't use a password — they carry a
119
+ * hub-issued JWT. We don't auto-mint one here (it needs a scope, and the
120
+ * operator should choose read vs write per client), so this is guidance,
121
+ * not a prompt. Mint paths, in order of how most operators reach them:
122
+ *
123
+ * - Admin SPA → Vaults → "Connect" card (mints + shows the header command).
124
+ * - `parachute auth mint-token --scope vault:<name>:<verb>` (pipeable JWT).
125
+ *
126
+ * The old affordance ran `parachute vault tokens create`, which exits 1
127
+ * post-DROP (vault no longer mints pvt_* tokens) — we never offer it.
128
+ */
129
+ function printTokenGuidance(r: Resolved): void {
130
+ const name = r.status.vaultNames[0] ?? "<name>";
131
+ r.log("");
132
+ r.log("For programmatic / headless clients (scripts, CI), mint a hub token:");
133
+ r.log(" • Admin → Vaults → Connect (mints a scope-narrow token + copy-paste header)");
134
+ r.log(` • parachute auth mint-token --scope vault:${name}:read # or :write`);
135
+ r.log(" → attach the printed JWT as Authorization: Bearer <hub-jwt>");
113
136
  }
114
137
 
115
138
  function printDivider(r: Resolved): void {
@@ -118,24 +141,27 @@ function printDivider(r: Resolved): void {
118
141
  }
119
142
 
120
143
  /**
121
- * `neither password nor tokens`: the exposure is wide open — anyone who
122
- * finds the URL can talk to the vault. The loudest warning we draw.
144
+ * `no owner password`: the exposure is wide open — without a password,
145
+ * nobody can sign in and no hub JWT can be minted, so there's no auth gate
146
+ * at all. The loudest warning we draw.
123
147
  */
124
148
  async function handleWideOpen(r: Resolved): Promise<void> {
125
149
  printDivider(r);
126
- r.log("⚠ No owner password and no API tokens are configured.");
150
+ r.log("⚠ No owner password is configured.");
127
151
  r.log(" The tunnel is reachable from the public internet RIGHT NOW.");
128
152
  r.log(" Anyone with the URL can make requests until you set auth up.");
129
153
  r.log("");
130
- r.log("Recommended: set an owner password (enables the browser sign-in flow)");
131
- r.log("and/or create an API token (for programmatic clients).");
154
+ r.log("Recommended: set an owner password it's the gate for both browser");
155
+ r.log("sign-in (OAuth) and minting hub tokens for programmatic clients.");
132
156
  r.log("");
133
157
  await offerOwnerPassword(r);
134
158
  // Offer 2FA regardless of the password step outcome: we can't observe it
135
159
  // from outside the subprocess, and vault itself will reject a 2fa enroll
136
160
  // if there's no password yet, surfacing the real error to the user.
137
161
  await offerTotp(r);
138
- await offerTokenCreate(r);
162
+ // Programmatic-client guidance is informational (no auto-mint) — print it
163
+ // so the operator knows the headless path exists, not the dead pvt_* one.
164
+ printTokenGuidance(r);
139
165
  printDivider(r);
140
166
  }
141
167
 
@@ -151,52 +177,24 @@ async function handlePasswordNoTotp(r: Resolved): Promise<void> {
151
177
  }
152
178
 
153
179
  /**
154
- * `tokens exist, no password`: vault is authenticated for API clients but
155
- * nobody can sign in through a browser the hub's OAuth flow is dead in
156
- * the water. Offer to fix.
157
- */
158
- async function handleTokensNoPassword(r: Resolved): Promise<void> {
159
- r.log("");
160
- r.log("ℹ API tokens exist, but no owner password is set.");
161
- r.log(" Browser sign-in (OAuth) won't work until you add one.");
162
- await offerOwnerPassword(r);
163
- }
164
-
165
- /**
166
- * `tokenCount === null`: SQLite probe failed (DB missing, locked, schema
167
- * drift, whatever). Don't guess; don't prompt on token state. Nudge 2FA
168
- * if we know the password is set, otherwise stay quiet.
169
- */
170
- async function handleUnknownTokens(r: Resolved): Promise<void> {
171
- r.log("");
172
- r.log("ℹ Couldn't read vault token state (vault may be locked or offline).");
173
- r.log(" Run `parachute vault tokens list` to check token config yourself.");
174
- if (r.status.hasOwnerPassword && !r.status.hasTotp) {
175
- r.log("");
176
- r.log(" (While you're here: owner password is set, 2FA is not.)");
177
- await offerTotp(r);
178
- }
179
- }
180
-
181
- /**
182
- * `all set`: password + 2FA + at least one token. Keep it tight.
180
+ * `all set`: owner password + 2FA. Keep it tight. (We don't assert on
181
+ * tokens a hub JWT is minted on demand, not a standing prerequisite.)
183
182
  */
184
183
  function handleAllGood(r: Resolved): void {
185
184
  r.log("");
186
- r.log("✓ Auth config looks good (password + 2FA + API tokens).");
185
+ r.log("✓ Auth config looks good (owner password + 2FA).");
187
186
  }
188
187
 
189
188
  /**
190
189
  * Pick the branch. Pure function of the status — keeps test coverage trivial.
190
+ *
191
+ * Owner-password-centric since the pvt_* DROP (hub#466): `tokenCount` is no
192
+ * longer consulted — those rows are vestigial and minting access now flows
193
+ * through the owner password, not a standing vault token. Three states.
191
194
  */
192
- function classify(
193
- s: VaultAuthStatus,
194
- ): "wide-open" | "password-no-totp" | "tokens-no-password" | "unknown-tokens" | "all-good" {
195
- if (s.tokenCount === null) return "unknown-tokens";
196
- const hasTokens = s.tokenCount > 0;
197
- if (!s.hasOwnerPassword && !hasTokens) return "wide-open";
198
- if (!s.hasOwnerPassword && hasTokens) return "tokens-no-password";
199
- if (s.hasOwnerPassword && !s.hasTotp) return "password-no-totp";
195
+ function classify(s: VaultAuthStatus): "wide-open" | "password-no-totp" | "all-good" {
196
+ if (!s.hasOwnerPassword) return "wide-open";
197
+ if (!s.hasTotp) return "password-no-totp";
200
198
  return "all-good";
201
199
  }
202
200
 
@@ -209,12 +207,6 @@ export async function runAuthPreflight(opts: AuthPreflightOpts = {}): Promise<vo
209
207
  case "password-no-totp":
210
208
  await handlePasswordNoTotp(r);
211
209
  return;
212
- case "tokens-no-password":
213
- await handleTokensNoPassword(r);
214
- return;
215
- case "unknown-tokens":
216
- await handleUnknownTokens(r);
217
- return;
218
210
  case "all-good":
219
211
  handleAllGood(r);
220
212
  return;