@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,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
+ }