@openparachute/hub 0.3.0-rc.1

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 (76) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +284 -0
  3. package/package.json +31 -0
  4. package/src/__tests__/auth.test.ts +101 -0
  5. package/src/__tests__/auto-wire.test.ts +283 -0
  6. package/src/__tests__/cli.test.ts +192 -0
  7. package/src/__tests__/cloudflare-config.test.ts +54 -0
  8. package/src/__tests__/cloudflare-detect.test.ts +68 -0
  9. package/src/__tests__/cloudflare-state.test.ts +92 -0
  10. package/src/__tests__/cloudflare-tunnel.test.ts +207 -0
  11. package/src/__tests__/config.test.ts +18 -0
  12. package/src/__tests__/env-file.test.ts +125 -0
  13. package/src/__tests__/expose-auth-preflight.test.ts +201 -0
  14. package/src/__tests__/expose-cloudflare.test.ts +484 -0
  15. package/src/__tests__/expose-interactive.test.ts +703 -0
  16. package/src/__tests__/expose-last-provider.test.ts +113 -0
  17. package/src/__tests__/expose-off-auto.test.ts +269 -0
  18. package/src/__tests__/expose-state.test.ts +101 -0
  19. package/src/__tests__/expose.test.ts +1581 -0
  20. package/src/__tests__/hub-control.test.ts +346 -0
  21. package/src/__tests__/hub-server.test.ts +157 -0
  22. package/src/__tests__/hub.test.ts +116 -0
  23. package/src/__tests__/install.test.ts +1145 -0
  24. package/src/__tests__/lifecycle.test.ts +608 -0
  25. package/src/__tests__/migrate.test.ts +422 -0
  26. package/src/__tests__/notes-serve.test.ts +135 -0
  27. package/src/__tests__/port-assign.test.ts +178 -0
  28. package/src/__tests__/process-state.test.ts +140 -0
  29. package/src/__tests__/scribe-config.test.ts +193 -0
  30. package/src/__tests__/scribe-provider-interactive.test.ts +361 -0
  31. package/src/__tests__/services-manifest.test.ts +177 -0
  32. package/src/__tests__/status.test.ts +347 -0
  33. package/src/__tests__/tailscale-commands.test.ts +111 -0
  34. package/src/__tests__/tailscale-detect.test.ts +64 -0
  35. package/src/__tests__/vault-auth-status.test.ts +164 -0
  36. package/src/__tests__/vault-tokens-create-interactive.test.ts +183 -0
  37. package/src/__tests__/well-known.test.ts +214 -0
  38. package/src/auto-wire.ts +184 -0
  39. package/src/cli.ts +482 -0
  40. package/src/cloudflare/config.ts +58 -0
  41. package/src/cloudflare/detect.ts +58 -0
  42. package/src/cloudflare/state.ts +96 -0
  43. package/src/cloudflare/tunnel.ts +135 -0
  44. package/src/commands/auth.ts +69 -0
  45. package/src/commands/expose-auth-preflight.ts +217 -0
  46. package/src/commands/expose-cloudflare.ts +329 -0
  47. package/src/commands/expose-interactive.ts +428 -0
  48. package/src/commands/expose-off-auto.ts +199 -0
  49. package/src/commands/expose.ts +522 -0
  50. package/src/commands/install.ts +422 -0
  51. package/src/commands/lifecycle.ts +324 -0
  52. package/src/commands/migrate.ts +253 -0
  53. package/src/commands/scribe-provider-interactive.ts +269 -0
  54. package/src/commands/status.ts +238 -0
  55. package/src/commands/vault-tokens-create-interactive.ts +137 -0
  56. package/src/commands/vault.ts +17 -0
  57. package/src/config.ts +16 -0
  58. package/src/env-file.ts +76 -0
  59. package/src/expose-last-provider.ts +71 -0
  60. package/src/expose-state.ts +125 -0
  61. package/src/help.ts +279 -0
  62. package/src/hub-control.ts +254 -0
  63. package/src/hub-origin.ts +44 -0
  64. package/src/hub-server.ts +113 -0
  65. package/src/hub.ts +674 -0
  66. package/src/notes-serve.ts +135 -0
  67. package/src/port-assign.ts +125 -0
  68. package/src/process-state.ts +111 -0
  69. package/src/scribe-config.ts +149 -0
  70. package/src/service-spec.ts +296 -0
  71. package/src/services-manifest.ts +171 -0
  72. package/src/tailscale/commands.ts +41 -0
  73. package/src/tailscale/detect.ts +107 -0
  74. package/src/tailscale/run.ts +28 -0
  75. package/src/vault/auth-status.ts +179 -0
  76. package/src/well-known.ts +127 -0
@@ -0,0 +1,135 @@
1
+ import { join } from "node:path";
2
+ import type { CommandResult, Runner } from "../tailscale/run.ts";
3
+
4
+ export class CloudflaredError extends Error {
5
+ override name = "CloudflaredError";
6
+ constructor(
7
+ message: string,
8
+ public readonly cmd: readonly string[],
9
+ public readonly result: CommandResult,
10
+ ) {
11
+ super(message);
12
+ }
13
+ }
14
+
15
+ export interface Tunnel {
16
+ id: string;
17
+ name: string;
18
+ createdAt?: string;
19
+ }
20
+
21
+ function combineErrStreams(result: CommandResult): string {
22
+ const e = result.stderr.trim();
23
+ if (e.length > 0) return e;
24
+ return result.stdout.trim();
25
+ }
26
+
27
+ /**
28
+ * Parse `cloudflared tunnel list --output json`. The schema is stable: an
29
+ * array of objects each with `id` (UUID) and `name`. We ignore extra fields.
30
+ * Entries missing either id or name are skipped rather than thrown — keeps
31
+ * us forward-compatible with cloudflared adding new tunnel shapes.
32
+ */
33
+ export async function listTunnels(runner: Runner): Promise<Tunnel[]> {
34
+ const cmd = ["cloudflared", "tunnel", "list", "--output", "json"];
35
+ const result = await runner(cmd);
36
+ if (result.code !== 0) {
37
+ throw new CloudflaredError(
38
+ `cloudflared tunnel list failed: ${combineErrStreams(result)}`,
39
+ cmd,
40
+ result,
41
+ );
42
+ }
43
+ let parsed: unknown;
44
+ try {
45
+ parsed = JSON.parse(result.stdout);
46
+ } catch (err) {
47
+ throw new CloudflaredError(
48
+ `failed to parse cloudflared tunnel list JSON: ${err instanceof Error ? err.message : String(err)}`,
49
+ cmd,
50
+ result,
51
+ );
52
+ }
53
+ if (!Array.isArray(parsed)) {
54
+ throw new CloudflaredError("cloudflared tunnel list did not return a JSON array", cmd, result);
55
+ }
56
+ const tunnels: Tunnel[] = [];
57
+ for (const raw of parsed) {
58
+ if (!raw || typeof raw !== "object") continue;
59
+ const r = raw as Record<string, unknown>;
60
+ const id = typeof r.id === "string" ? r.id : undefined;
61
+ const name = typeof r.name === "string" ? r.name : undefined;
62
+ if (!id || !name) continue;
63
+ const t: Tunnel = { id, name };
64
+ if (typeof r.created_at === "string") t.createdAt = r.created_at;
65
+ tunnels.push(t);
66
+ }
67
+ return tunnels;
68
+ }
69
+
70
+ export async function findTunnelByName(runner: Runner, name: string): Promise<Tunnel | undefined> {
71
+ const tunnels = await listTunnels(runner);
72
+ return tunnels.find((t) => t.name === name);
73
+ }
74
+
75
+ /**
76
+ * `cloudflared tunnel create <name>` writes credentials to
77
+ * `~/.cloudflared/<UUID>.json` and prints a line like
78
+ *
79
+ * Created tunnel parachute with id 2c1a7c7e-…-b3ef7c1d9a2a
80
+ *
81
+ * We parse the UUID from stdout rather than requiring callers to walk the
82
+ * credentials dir afterward — less filesystem coupling, and the UUID format
83
+ * is stable (RFC 4122 lowercase hex).
84
+ */
85
+ export async function createTunnel(runner: Runner, name: string): Promise<Tunnel> {
86
+ const cmd = ["cloudflared", "tunnel", "create", name];
87
+ const result = await runner(cmd);
88
+ if (result.code !== 0) {
89
+ throw new CloudflaredError(
90
+ `cloudflared tunnel create failed: ${combineErrStreams(result)}`,
91
+ cmd,
92
+ result,
93
+ );
94
+ }
95
+ const match = result.stdout.match(
96
+ /([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i,
97
+ );
98
+ if (!match) {
99
+ throw new CloudflaredError(
100
+ `could not parse tunnel UUID from cloudflared output: ${result.stdout.trim()}`,
101
+ cmd,
102
+ result,
103
+ );
104
+ }
105
+ return { id: match[1]!, name };
106
+ }
107
+
108
+ /**
109
+ * `--overwrite-dns` turns the route command into an idempotent UPSERT: without
110
+ * it, cloudflared exits non-zero when the CNAME already exists, which breaks
111
+ * every rerun on the same hostname (and the error surface — "add the domain to
112
+ * Cloudflare" — is actively wrong in that case). The destination is always the
113
+ * caller's tunnel, so overwriting a pre-existing CNAME that points somewhere
114
+ * else is the right move; the user explicitly asked for this hostname to
115
+ * reach this tunnel.
116
+ */
117
+ export async function routeDns(
118
+ runner: Runner,
119
+ tunnelName: string,
120
+ hostname: string,
121
+ ): Promise<void> {
122
+ const cmd = ["cloudflared", "tunnel", "route", "dns", "--overwrite-dns", tunnelName, hostname];
123
+ const result = await runner(cmd);
124
+ if (result.code !== 0) {
125
+ throw new CloudflaredError(
126
+ `cloudflared tunnel route dns failed: ${combineErrStreams(result)}`,
127
+ cmd,
128
+ result,
129
+ );
130
+ }
131
+ }
132
+
133
+ export function credentialsPath(uuid: string, cloudflaredHome: string): string {
134
+ return join(cloudflaredHome, `${uuid}.json`);
135
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * `parachute auth` — ecosystem-level identity commands.
3
+ *
4
+ * Identity (password + 2FA) is an ecosystem concern now that the hub owns
5
+ * OAuth issuance (Phase 0). The *implementation* still lives in
6
+ * parachute-vault — these commands are thin shell-forwards to the vault
7
+ * binary so beta users learn the blessed namespace from day one.
8
+ *
9
+ * Vault keeps its own `set-password` / `2fa` commands for back-compat.
10
+ */
11
+
12
+ export interface Runner {
13
+ run(cmd: readonly string[]): Promise<number>;
14
+ }
15
+
16
+ export const defaultRunner: Runner = {
17
+ async run(cmd) {
18
+ const proc = Bun.spawn([...cmd], { stdio: ["inherit", "inherit", "inherit"] });
19
+ return await proc.exited;
20
+ },
21
+ };
22
+
23
+ const AUTH_SUBCOMMANDS = new Set(["set-password", "2fa"]);
24
+
25
+ export function authHelp(): string {
26
+ return `parachute auth — ecosystem identity commands (password + two-factor authentication)
27
+
28
+ Usage:
29
+ parachute auth set-password Set or change the owner password
30
+ parachute auth set-password --clear Remove the owner password
31
+ parachute auth 2fa status Show 2FA state
32
+ parachute auth 2fa enroll Enable TOTP 2FA (QR + backup codes)
33
+ parachute auth 2fa disable Disable 2FA (requires password)
34
+ parachute auth 2fa backup-codes Regenerate backup codes
35
+
36
+ All subcommands forward to \`parachute-vault\` which implements the storage
37
+ and crypto. If you see "not found on PATH", install vault first:
38
+
39
+ parachute install vault
40
+ `;
41
+ }
42
+
43
+ export async function auth(
44
+ args: readonly string[],
45
+ runner: Runner = defaultRunner,
46
+ ): Promise<number> {
47
+ const sub = args[0];
48
+ if (sub === undefined || sub === "--help" || sub === "-h" || sub === "help") {
49
+ console.log(authHelp());
50
+ return 0;
51
+ }
52
+ if (!AUTH_SUBCOMMANDS.has(sub)) {
53
+ console.error(`parachute auth: unknown subcommand "${sub}"`);
54
+ console.error("run `parachute auth --help` for usage");
55
+ return 1;
56
+ }
57
+ try {
58
+ return await runner.run(["parachute-vault", ...args]);
59
+ } catch (err) {
60
+ const msg = err instanceof Error ? err.message : String(err);
61
+ if (msg.toLowerCase().includes("enoent") || msg.toLowerCase().includes("not found")) {
62
+ console.error("parachute-vault not found on PATH.");
63
+ console.error("Install it with: parachute install vault");
64
+ return 127;
65
+ }
66
+ console.error(`failed to run parachute-vault: ${msg}`);
67
+ return 1;
68
+ }
69
+ }
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Post-exposure auth nudge. Runs after `parachute expose public` successfully
3
+ * brings a tunnel up (TTY only). The tunnel is already live; this is purely
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.
7
+ *
8
+ * Four states we branch on, based on {@link VaultAuthStatus}:
9
+ *
10
+ * - neither password nor tokens: loud warning + offer to set up each.
11
+ * - 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).
16
+ *
17
+ * Defaults are always "skip" — Enter declines every prompt. User can always
18
+ * run `parachute auth …` or `parachute vault tokens create` later.
19
+ */
20
+
21
+ import { createInterface } from "node:readline/promises";
22
+ import { type VaultAuthStatus, readVaultAuthStatus } from "../vault/auth-status.ts";
23
+
24
+ /** `Bun.spawn(..., { stdio: "inherit" })` wrapper. Factored out so tests can
25
+ * assert which commands got invoked without running them. */
26
+ export type InteractiveRunner = (cmd: readonly string[]) => Promise<number>;
27
+
28
+ const defaultInteractiveRunner: InteractiveRunner = async (cmd) => {
29
+ const proc = Bun.spawn([...cmd], { stdio: ["inherit", "inherit", "inherit"] });
30
+ return await proc.exited;
31
+ };
32
+
33
+ async function defaultPrompt(question: string): Promise<string> {
34
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
35
+ try {
36
+ return await rl.question(question);
37
+ } finally {
38
+ rl.close();
39
+ }
40
+ }
41
+
42
+ export interface AuthPreflightOpts {
43
+ /** Supply a pre-computed status to skip the on-disk read (tests). In
44
+ * production, leave unset and we'll call {@link readVaultAuthStatus}. */
45
+ status?: VaultAuthStatus;
46
+ prompt?: (question: string) => Promise<string>;
47
+ interactiveRunner?: InteractiveRunner;
48
+ log?: (line: string) => void;
49
+ /** Forwarded to {@link readVaultAuthStatus} when `status` is not supplied. */
50
+ vaultHome?: string;
51
+ }
52
+
53
+ interface Resolved {
54
+ status: VaultAuthStatus;
55
+ prompt: (question: string) => Promise<string>;
56
+ interactiveRunner: InteractiveRunner;
57
+ log: (line: string) => void;
58
+ }
59
+
60
+ function resolve(opts: AuthPreflightOpts): Resolved {
61
+ return {
62
+ status: opts.status ?? readVaultAuthStatus({ vaultHome: opts.vaultHome }),
63
+ prompt: opts.prompt ?? defaultPrompt,
64
+ interactiveRunner: opts.interactiveRunner ?? defaultInteractiveRunner,
65
+ log: opts.log ?? ((line) => console.log(line)),
66
+ };
67
+ }
68
+
69
+ /**
70
+ * Prompt the user yes/no with Enter defaulting to "no" (skip). Returns true
71
+ * only on an affirmative answer. Anything else — blank, "n", garbage — is a
72
+ * decline; we don't reprompt, because the preflight is explicitly optional
73
+ * and the user has already done the hard part.
74
+ */
75
+ async function yesNo(r: Resolved, question: string): Promise<boolean> {
76
+ const raw = (await r.prompt(`${question} [y/N] `)).trim().toLowerCase();
77
+ return raw === "y" || raw === "yes";
78
+ }
79
+
80
+ async function runCmd(r: Resolved, cmd: readonly string[], friendly: string): Promise<void> {
81
+ r.log("");
82
+ const code = await r.interactiveRunner(cmd);
83
+ if (code !== 0) {
84
+ // Don't blow up the preflight on a sub-command failure — the user can
85
+ // see the error, and the tunnel is still up. Just log a hint and move on.
86
+ r.log(
87
+ `(${friendly} exited ${code} — you can re-run \`${cmd.join(" ")}\` anytime. Continuing.)`,
88
+ );
89
+ }
90
+ }
91
+
92
+ async function offerOwnerPassword(r: Resolved): Promise<void> {
93
+ if (await yesNo(r, "Set the owner password now?")) {
94
+ await runCmd(r, ["parachute", "auth", "set-password"], "parachute auth set-password");
95
+ }
96
+ }
97
+
98
+ async function offerTotp(r: Resolved): Promise<void> {
99
+ if (await yesNo(r, "Enable TOTP 2FA now?")) {
100
+ await runCmd(r, ["parachute", "auth", "2fa", "enroll"], "parachute auth 2fa enroll");
101
+ }
102
+ }
103
+
104
+ async function offerTokenCreate(r: Resolved): Promise<void> {
105
+ if (await yesNo(r, "Create an API token now?")) {
106
+ await runCmd(r, ["parachute", "vault", "tokens", "create"], "parachute vault tokens create");
107
+ }
108
+ }
109
+
110
+ function printDivider(r: Resolved): void {
111
+ r.log("");
112
+ r.log("──────────────────────────────────────────────────────────────");
113
+ }
114
+
115
+ /**
116
+ * `neither password nor tokens`: the exposure is wide open — anyone who
117
+ * finds the URL can talk to the vault. The loudest warning we draw.
118
+ */
119
+ async function handleWideOpen(r: Resolved): Promise<void> {
120
+ printDivider(r);
121
+ r.log("⚠ No owner password and no API tokens are configured.");
122
+ r.log(" The tunnel is reachable from the public internet RIGHT NOW.");
123
+ r.log(" Anyone with the URL can make requests until you set auth up.");
124
+ r.log("");
125
+ r.log("Recommended: set an owner password (enables the browser sign-in flow)");
126
+ r.log("and/or create an API token (for programmatic clients).");
127
+ r.log("");
128
+ await offerOwnerPassword(r);
129
+ // Offer 2FA regardless of the password step outcome: we can't observe it
130
+ // from outside the subprocess, and vault itself will reject a 2fa enroll
131
+ // if there's no password yet, surfacing the real error to the user.
132
+ await offerTotp(r);
133
+ await offerTokenCreate(r);
134
+ printDivider(r);
135
+ }
136
+
137
+ /**
138
+ * `password set, no 2FA`: the common case where the user did the obvious
139
+ * thing but hasn't opted into the stronger factor yet. Short nudge.
140
+ */
141
+ async function handlePasswordNoTotp(r: Resolved): Promise<void> {
142
+ r.log("");
143
+ r.log("✓ Owner password is set.");
144
+ r.log(" Consider also enabling 2FA for defense-in-depth.");
145
+ await offerTotp(r);
146
+ }
147
+
148
+ /**
149
+ * `tokens exist, no password`: vault is authenticated for API clients but
150
+ * nobody can sign in through a browser — the hub's OAuth flow is dead in
151
+ * the water. Offer to fix.
152
+ */
153
+ async function handleTokensNoPassword(r: Resolved): Promise<void> {
154
+ r.log("");
155
+ r.log("ℹ API tokens exist, but no owner password is set.");
156
+ r.log(" Browser sign-in (OAuth) won't work until you add one.");
157
+ await offerOwnerPassword(r);
158
+ }
159
+
160
+ /**
161
+ * `tokenCount === null`: SQLite probe failed (DB missing, locked, schema
162
+ * drift, whatever). Don't guess; don't prompt on token state. Nudge 2FA
163
+ * if we know the password is set, otherwise stay quiet.
164
+ */
165
+ async function handleUnknownTokens(r: Resolved): Promise<void> {
166
+ r.log("");
167
+ r.log("ℹ Couldn't read vault token state (vault may be locked or offline).");
168
+ r.log(" Run `parachute vault tokens list` to check token config yourself.");
169
+ if (r.status.hasOwnerPassword && !r.status.hasTotp) {
170
+ r.log("");
171
+ r.log(" (While you're here: owner password is set, 2FA is not.)");
172
+ await offerTotp(r);
173
+ }
174
+ }
175
+
176
+ /**
177
+ * `all set`: password + 2FA + at least one token. Keep it tight.
178
+ */
179
+ function handleAllGood(r: Resolved): void {
180
+ r.log("");
181
+ r.log("✓ Auth config looks good (password + 2FA + API tokens).");
182
+ }
183
+
184
+ /**
185
+ * Pick the branch. Pure function of the status — keeps test coverage trivial.
186
+ */
187
+ function classify(
188
+ s: VaultAuthStatus,
189
+ ): "wide-open" | "password-no-totp" | "tokens-no-password" | "unknown-tokens" | "all-good" {
190
+ if (s.tokenCount === null) return "unknown-tokens";
191
+ const hasTokens = s.tokenCount > 0;
192
+ if (!s.hasOwnerPassword && !hasTokens) return "wide-open";
193
+ if (!s.hasOwnerPassword && hasTokens) return "tokens-no-password";
194
+ if (s.hasOwnerPassword && !s.hasTotp) return "password-no-totp";
195
+ return "all-good";
196
+ }
197
+
198
+ export async function runAuthPreflight(opts: AuthPreflightOpts = {}): Promise<void> {
199
+ const r = resolve(opts);
200
+ switch (classify(r.status)) {
201
+ case "wide-open":
202
+ await handleWideOpen(r);
203
+ return;
204
+ case "password-no-totp":
205
+ await handlePasswordNoTotp(r);
206
+ return;
207
+ case "tokens-no-password":
208
+ await handleTokensNoPassword(r);
209
+ return;
210
+ case "unknown-tokens":
211
+ await handleUnknownTokens(r);
212
+ return;
213
+ case "all-good":
214
+ handleAllGood(r);
215
+ return;
216
+ }
217
+ }