@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,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only probe of vault's auth state, for the post-exposure preflight
|
|
3
|
+
* nudge. We don't want to lock the DB or mutate anything — this is a
|
|
4
|
+
* one-shot "should we warn the user their vault is wide open on the public
|
|
5
|
+
* internet?" check.
|
|
6
|
+
*
|
|
7
|
+
* Two sources:
|
|
8
|
+
* 1. ~/.parachute/vault/config.yaml → owner_password_hash + totp_secret
|
|
9
|
+
* 2. ~/.parachute/vault/data/<name>/vault.db (SQLite) → tokens table count
|
|
10
|
+
*
|
|
11
|
+
* The YAML path uses line-anchored regex parsing that matches vault's own
|
|
12
|
+
* `readGlobalConfig()` semantics (parachute-vault src/config.ts): keys are
|
|
13
|
+
* optional, quoted scalars, and empty-string / missing-key both mean "not
|
|
14
|
+
* configured." We mirror that rather than bringing in a YAML dependency.
|
|
15
|
+
*
|
|
16
|
+
* The SQLite path is best-effort: if the DB is missing, locked (vault is
|
|
17
|
+
* writing), or the schema has drifted, `tokenCount` comes back as `null`
|
|
18
|
+
* and the caller surfaces "token status unknown" rather than lying with a
|
|
19
|
+
* false zero. The exposure flow has already succeeded by the time this
|
|
20
|
+
* runs — a probe failure must never block the user's happy path.
|
|
21
|
+
*
|
|
22
|
+
* Schema coupling note: we read the `tokens` table by name with a bare
|
|
23
|
+
* COUNT(*). If vault ever renames that table, that's a breaking change on
|
|
24
|
+
* vault's side and this probe is the least of the fallout. Post-launch,
|
|
25
|
+
* a public `/api/auth/status` endpoint on vault (tracked separately) would
|
|
26
|
+
* let us drop this coupling entirely.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
30
|
+
import { homedir } from "node:os";
|
|
31
|
+
import { join } from "node:path";
|
|
32
|
+
import { configDir } from "../config.ts";
|
|
33
|
+
|
|
34
|
+
export interface VaultAuthStatus {
|
|
35
|
+
hasOwnerPassword: boolean;
|
|
36
|
+
hasTotp: boolean;
|
|
37
|
+
/**
|
|
38
|
+
* `null` means we couldn't read the SQLite DB — distinct from "0 tokens
|
|
39
|
+
* exist." Callers branch the UI on this: `null` → "token status unknown,
|
|
40
|
+
* run `parachute vault tokens list` to check"; `0` → loud "no auth at
|
|
41
|
+
* all!" warning; `>0` → benign.
|
|
42
|
+
*/
|
|
43
|
+
tokenCount: number | null;
|
|
44
|
+
/** Vault instance names discovered under data/. Empty when vault has
|
|
45
|
+
* never been initialized (or the data dir is absent). */
|
|
46
|
+
vaultNames: string[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface AuthStatusOpts {
|
|
50
|
+
/** Override `~/.parachute/vault` for tests. */
|
|
51
|
+
vaultHome?: string;
|
|
52
|
+
/** Read a YAML file; defaults to `readFileSync(path, "utf8")`. Missing
|
|
53
|
+
* file should return `undefined` (not throw) so callers can distinguish
|
|
54
|
+
* "no password configured" from "IO error." */
|
|
55
|
+
readText?: (path: string) => string | undefined;
|
|
56
|
+
/** List vault instance names. Defaults to `readdirSync(dataDir)` filtered
|
|
57
|
+
* to entries that look like vaults (contain `vault.yaml`). */
|
|
58
|
+
listVaultNames?: (dataDir: string) => string[];
|
|
59
|
+
/** Open the given DB path and return `SELECT COUNT(*) FROM tokens`. Any
|
|
60
|
+
* thrown error (missing, locked, schema drift) is caught by the caller
|
|
61
|
+
* and mapped to `tokenCount: null`. */
|
|
62
|
+
countTokens?: (dbPath: string) => number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface Resolved {
|
|
66
|
+
vaultHome: string;
|
|
67
|
+
readText: (path: string) => string | undefined;
|
|
68
|
+
listVaultNames: (dataDir: string) => string[];
|
|
69
|
+
countTokens: (dbPath: string) => number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function defaultVaultHome(): string {
|
|
73
|
+
// Mirrors vault's own resolution: honors $PARACHUTE_HOME via configDir(),
|
|
74
|
+
// then falls back to ~/.parachute. The `vault/` subdir is hard-coded on
|
|
75
|
+
// vault's side too (src/config.ts `vaultHomePath()`), so we match literally.
|
|
76
|
+
const root = configDir();
|
|
77
|
+
return root.length > 0 ? join(root, "vault") : join(homedir(), ".parachute", "vault");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function defaultReadText(path: string): string | undefined {
|
|
81
|
+
try {
|
|
82
|
+
return readFileSync(path, "utf8");
|
|
83
|
+
} catch {
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function defaultListVaultNames(dataDir: string): string[] {
|
|
89
|
+
if (!existsSync(dataDir)) return [];
|
|
90
|
+
try {
|
|
91
|
+
return readdirSync(dataDir, { withFileTypes: true })
|
|
92
|
+
.filter((e) => e.isDirectory())
|
|
93
|
+
.map((e) => e.name)
|
|
94
|
+
.filter((name) => existsSync(join(dataDir, name, "vault.yaml")));
|
|
95
|
+
} catch {
|
|
96
|
+
return [];
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function defaultCountTokens(dbPath: string): number {
|
|
101
|
+
// Imported lazily so the module stays loadable in environments that stub
|
|
102
|
+
// `bun:sqlite` (our own tests inject a fake `countTokens` and never hit
|
|
103
|
+
// this path). `readonly: true` keeps us out of any write lock contention
|
|
104
|
+
// with a live vault process.
|
|
105
|
+
const { Database } = require("bun:sqlite");
|
|
106
|
+
const db = new Database(dbPath, { readonly: true });
|
|
107
|
+
try {
|
|
108
|
+
const row = db.prepare("SELECT COUNT(*) AS n FROM tokens").get() as { n: number } | null;
|
|
109
|
+
return row?.n ?? 0;
|
|
110
|
+
} finally {
|
|
111
|
+
db.close();
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function resolve(opts: AuthStatusOpts): Resolved {
|
|
116
|
+
return {
|
|
117
|
+
vaultHome: opts.vaultHome ?? defaultVaultHome(),
|
|
118
|
+
readText: opts.readText ?? defaultReadText,
|
|
119
|
+
listVaultNames: opts.listVaultNames ?? defaultListVaultNames,
|
|
120
|
+
countTokens: opts.countTokens ?? defaultCountTokens,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Mirrors vault's `readGlobalConfig()` regex on a single key, returning the
|
|
126
|
+
* captured quoted string when present and non-empty, otherwise `undefined`.
|
|
127
|
+
*/
|
|
128
|
+
function matchQuotedKey(yaml: string, key: string): string | undefined {
|
|
129
|
+
const re = new RegExp(`^${key}:\\s*"([^"]*)"`, "m");
|
|
130
|
+
const m = yaml.match(re);
|
|
131
|
+
if (!m) return undefined;
|
|
132
|
+
const captured = m[1];
|
|
133
|
+
if (captured === undefined || captured.length === 0) return undefined;
|
|
134
|
+
return captured;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function readGlobalAuth(r: Resolved): { hasOwnerPassword: boolean; hasTotp: boolean } {
|
|
138
|
+
const yaml = r.readText(join(r.vaultHome, "config.yaml"));
|
|
139
|
+
if (yaml === undefined) return { hasOwnerPassword: false, hasTotp: false };
|
|
140
|
+
return {
|
|
141
|
+
hasOwnerPassword: matchQuotedKey(yaml, "owner_password_hash") !== undefined,
|
|
142
|
+
hasTotp: matchQuotedKey(yaml, "totp_secret") !== undefined,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Sum token counts across every vault instance found under data/. If any
|
|
148
|
+
* probe throws (missing DB, locked, schema drift), the whole result
|
|
149
|
+
* degrades to `null` — partial counts would mislead the caller more than
|
|
150
|
+
* "unknown" does.
|
|
151
|
+
*/
|
|
152
|
+
function readTotalTokenCount(r: Resolved, vaultNames: string[]): number | null {
|
|
153
|
+
if (vaultNames.length === 0) return 0;
|
|
154
|
+
const dataDir = join(r.vaultHome, "data");
|
|
155
|
+
let total = 0;
|
|
156
|
+
for (const name of vaultNames) {
|
|
157
|
+
const dbPath = join(dataDir, name, "vault.db");
|
|
158
|
+
if (!existsSync(dbPath)) {
|
|
159
|
+
// Vault initialized the yaml but hasn't created the DB yet (fresh
|
|
160
|
+
// install). Count as zero for this vault; keep going.
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
try {
|
|
164
|
+
total += r.countTokens(dbPath);
|
|
165
|
+
} catch {
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return total;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function readVaultAuthStatus(opts: AuthStatusOpts = {}): VaultAuthStatus {
|
|
173
|
+
const r = resolve(opts);
|
|
174
|
+
const { hasOwnerPassword, hasTotp } = readGlobalAuth(r);
|
|
175
|
+
const dataDir = join(r.vaultHome, "data");
|
|
176
|
+
const vaultNames = r.listVaultNames(dataDir);
|
|
177
|
+
const tokenCount = readTotalTokenCount(r, vaultNames);
|
|
178
|
+
return { hasOwnerPassword, hasTotp, tokenCount, vaultNames };
|
|
179
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, renameSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { CONFIG_DIR } from "./config.ts";
|
|
4
|
+
import type { ServiceEntry } from "./services-manifest.ts";
|
|
5
|
+
|
|
6
|
+
export interface WellKnownServiceEntry {
|
|
7
|
+
url: string;
|
|
8
|
+
version: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface WellKnownVaultEntry {
|
|
12
|
+
name: string;
|
|
13
|
+
url: string;
|
|
14
|
+
version: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Flat service descriptor — one per installed service, used by the hub page
|
|
19
|
+
* to iterate without having to know every service's shortName ahead of time.
|
|
20
|
+
* `infoUrl` points at the service's `/.parachute/info` endpoint (relative to
|
|
21
|
+
* its mount path) which the hub fetches client-side for displayName/tagline.
|
|
22
|
+
*/
|
|
23
|
+
export interface WellKnownServicesEntry {
|
|
24
|
+
name: string;
|
|
25
|
+
url: string;
|
|
26
|
+
path: string;
|
|
27
|
+
version: string;
|
|
28
|
+
infoUrl: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Canonical `/.well-known/parachute.json` shape.
|
|
33
|
+
*
|
|
34
|
+
* Three parts, all additive so old clients keep working:
|
|
35
|
+
* - `vaults: []` — always an array; vault is the ecosystem's only
|
|
36
|
+
* multi-tenant service.
|
|
37
|
+
* - `services: []` — flat list the hub page iterates. Scales to N frontends
|
|
38
|
+
* without the consumer needing to know every shortName.
|
|
39
|
+
* - Top-level flat keys (`notes`, `scribe`, …) — kept for back-compat with
|
|
40
|
+
* clients that predate `services[]`.
|
|
41
|
+
*/
|
|
42
|
+
export type WellKnownDocument = {
|
|
43
|
+
vaults: WellKnownVaultEntry[];
|
|
44
|
+
services: WellKnownServicesEntry[];
|
|
45
|
+
} & {
|
|
46
|
+
[shortName: string]:
|
|
47
|
+
| WellKnownVaultEntry[]
|
|
48
|
+
| WellKnownServicesEntry[]
|
|
49
|
+
| WellKnownServiceEntry
|
|
50
|
+
| undefined;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export const WELL_KNOWN_DIR = join(CONFIG_DIR, "well-known");
|
|
54
|
+
export const WELL_KNOWN_PATH = join(WELL_KNOWN_DIR, "parachute.json");
|
|
55
|
+
export const WELL_KNOWN_MOUNT = "/.well-known/parachute.json";
|
|
56
|
+
|
|
57
|
+
const VAULT_MANIFEST_PREFIX = "parachute-vault";
|
|
58
|
+
|
|
59
|
+
/** Strip the conventional `parachute-` prefix for the well-known document's keys. */
|
|
60
|
+
export function shortName(manifestName: string): string {
|
|
61
|
+
return manifestName.replace(/^parachute-/, "");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* True when this manifest entry is a vault instance. Any name that starts
|
|
66
|
+
* with `parachute-vault` counts, so post-multi-tenancy names like
|
|
67
|
+
* `parachute-vault-work` also route to the vaults array.
|
|
68
|
+
*/
|
|
69
|
+
export function isVaultEntry(entry: ServiceEntry): boolean {
|
|
70
|
+
return entry.name === VAULT_MANIFEST_PREFIX || entry.name.startsWith(`${VAULT_MANIFEST_PREFIX}-`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Derive a vault instance name. Prefer a `/vault/<name>` path segment; fall
|
|
75
|
+
* back to the manifest-name suffix (`parachute-vault-work` → `work`); last
|
|
76
|
+
* resort is "default".
|
|
77
|
+
*/
|
|
78
|
+
export function vaultInstanceName(entry: ServiceEntry): string {
|
|
79
|
+
const path = entry.paths[0];
|
|
80
|
+
if (path) {
|
|
81
|
+
const match = path.match(/^\/vault\/([^/]+)/);
|
|
82
|
+
if (match?.[1]) return match[1];
|
|
83
|
+
}
|
|
84
|
+
if (entry.name.startsWith(`${VAULT_MANIFEST_PREFIX}-`)) {
|
|
85
|
+
return entry.name.slice(VAULT_MANIFEST_PREFIX.length + 1);
|
|
86
|
+
}
|
|
87
|
+
return "default";
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface BuildWellKnownOpts {
|
|
91
|
+
services: readonly ServiceEntry[];
|
|
92
|
+
canonicalOrigin: string;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Join a base origin and a path without double slashes — "/" stays "/". */
|
|
96
|
+
function joinInfoPath(path: string): string {
|
|
97
|
+
const trimmed = path.replace(/\/$/, "");
|
|
98
|
+
return `${trimmed}/.parachute/info`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function buildWellKnown(opts: BuildWellKnownOpts): WellKnownDocument {
|
|
102
|
+
const base = opts.canonicalOrigin.replace(/\/$/, "");
|
|
103
|
+
const doc: WellKnownDocument = { vaults: [], services: [] };
|
|
104
|
+
for (const s of opts.services) {
|
|
105
|
+
const path = s.paths[0] ?? "/";
|
|
106
|
+
const url = new URL(path, `${base}/`).toString();
|
|
107
|
+
const infoPath = joinInfoPath(path);
|
|
108
|
+
const infoUrl = new URL(infoPath, `${base}/`).toString();
|
|
109
|
+
doc.services.push({ name: s.name, url, path, version: s.version, infoUrl });
|
|
110
|
+
if (isVaultEntry(s)) {
|
|
111
|
+
doc.vaults.push({ name: vaultInstanceName(s), url, version: s.version });
|
|
112
|
+
} else {
|
|
113
|
+
doc[shortName(s.name)] = { url, version: s.version };
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return doc;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function writeWellKnownFile(doc: WellKnownDocument, path: string = WELL_KNOWN_PATH): string {
|
|
120
|
+
if (!existsSync(dirname(path))) {
|
|
121
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
122
|
+
}
|
|
123
|
+
const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
|
|
124
|
+
writeFileSync(tmp, `${JSON.stringify(doc, null, 2)}\n`);
|
|
125
|
+
renameSync(tmp, path);
|
|
126
|
+
return path;
|
|
127
|
+
}
|