@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.
- package/LICENSE +661 -0
- package/README.md +284 -0
- package/package.json +31 -0
- package/src/__tests__/auth.test.ts +101 -0
- package/src/__tests__/auto-wire.test.ts +283 -0
- package/src/__tests__/cli.test.ts +192 -0
- package/src/__tests__/cloudflare-config.test.ts +54 -0
- package/src/__tests__/cloudflare-detect.test.ts +68 -0
- package/src/__tests__/cloudflare-state.test.ts +92 -0
- package/src/__tests__/cloudflare-tunnel.test.ts +207 -0
- package/src/__tests__/config.test.ts +18 -0
- package/src/__tests__/env-file.test.ts +125 -0
- package/src/__tests__/expose-auth-preflight.test.ts +201 -0
- package/src/__tests__/expose-cloudflare.test.ts +484 -0
- package/src/__tests__/expose-interactive.test.ts +703 -0
- package/src/__tests__/expose-last-provider.test.ts +113 -0
- package/src/__tests__/expose-off-auto.test.ts +269 -0
- package/src/__tests__/expose-state.test.ts +101 -0
- package/src/__tests__/expose.test.ts +1581 -0
- package/src/__tests__/hub-control.test.ts +346 -0
- package/src/__tests__/hub-server.test.ts +157 -0
- package/src/__tests__/hub.test.ts +116 -0
- package/src/__tests__/install.test.ts +1145 -0
- package/src/__tests__/lifecycle.test.ts +608 -0
- package/src/__tests__/migrate.test.ts +422 -0
- package/src/__tests__/notes-serve.test.ts +135 -0
- package/src/__tests__/port-assign.test.ts +178 -0
- package/src/__tests__/process-state.test.ts +140 -0
- package/src/__tests__/scribe-config.test.ts +193 -0
- package/src/__tests__/scribe-provider-interactive.test.ts +361 -0
- package/src/__tests__/services-manifest.test.ts +177 -0
- package/src/__tests__/status.test.ts +347 -0
- package/src/__tests__/tailscale-commands.test.ts +111 -0
- package/src/__tests__/tailscale-detect.test.ts +64 -0
- package/src/__tests__/vault-auth-status.test.ts +164 -0
- package/src/__tests__/vault-tokens-create-interactive.test.ts +183 -0
- package/src/__tests__/well-known.test.ts +214 -0
- package/src/auto-wire.ts +184 -0
- package/src/cli.ts +482 -0
- package/src/cloudflare/config.ts +58 -0
- package/src/cloudflare/detect.ts +58 -0
- package/src/cloudflare/state.ts +96 -0
- package/src/cloudflare/tunnel.ts +135 -0
- package/src/commands/auth.ts +69 -0
- package/src/commands/expose-auth-preflight.ts +217 -0
- package/src/commands/expose-cloudflare.ts +329 -0
- package/src/commands/expose-interactive.ts +428 -0
- package/src/commands/expose-off-auto.ts +199 -0
- package/src/commands/expose.ts +522 -0
- package/src/commands/install.ts +422 -0
- package/src/commands/lifecycle.ts +324 -0
- package/src/commands/migrate.ts +253 -0
- package/src/commands/scribe-provider-interactive.ts +269 -0
- package/src/commands/status.ts +238 -0
- package/src/commands/vault-tokens-create-interactive.ts +137 -0
- package/src/commands/vault.ts +17 -0
- package/src/config.ts +16 -0
- package/src/env-file.ts +76 -0
- package/src/expose-last-provider.ts +71 -0
- package/src/expose-state.ts +125 -0
- package/src/help.ts +279 -0
- package/src/hub-control.ts +254 -0
- package/src/hub-origin.ts +44 -0
- package/src/hub-server.ts +113 -0
- package/src/hub.ts +674 -0
- package/src/notes-serve.ts +135 -0
- package/src/port-assign.ts +125 -0
- package/src/process-state.ts +111 -0
- package/src/scribe-config.ts +149 -0
- package/src/service-spec.ts +296 -0
- package/src/services-manifest.ts +171 -0
- package/src/tailscale/commands.ts +41 -0
- package/src/tailscale/detect.ts +107 -0
- package/src/tailscale/run.ts +28 -0
- package/src/vault/auth-status.ts +179 -0
- 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
|
+
}
|