@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,254 @@
1
+ import { existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { createServer } from "node:net";
3
+ import { dirname, join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { CONFIG_DIR } from "./config.ts";
6
+ import {
7
+ type AliveFn,
8
+ clearPid,
9
+ defaultAlive,
10
+ ensureLogPath,
11
+ readPid,
12
+ runDir,
13
+ writePid,
14
+ } from "./process-state.ts";
15
+ import { WELL_KNOWN_DIR } from "./well-known.ts";
16
+
17
+ /**
18
+ * Lifecycle for the internal hub HTTP server. The hub is *not* a user-facing
19
+ * service (not in services.json) — it's an implementation detail of
20
+ * `parachute expose`, spawned implicitly on bringup and torn down on the
21
+ * final teardown.
22
+ *
23
+ * The hub lives under `svc = "hub"` in the process-state world, so its PID,
24
+ * logs, and runtime files land at `~/.parachute/hub/{run,logs}/…` alongside
25
+ * every other managed service.
26
+ */
27
+
28
+ export const HUB_SVC = "hub";
29
+ export const HUB_DEFAULT_PORT = 1939;
30
+ /**
31
+ * Default fallback range is 1 — the hub binds 1939 or fails. Walking up would
32
+ * steal another Parachute service's slot from the canonical 1939–1949 range.
33
+ * Tests and debug tooling can pass a larger `fallbackRange` explicitly.
34
+ */
35
+ export const HUB_PORT_FALLBACK_RANGE = 1;
36
+
37
+ const HUB_SERVER_PATH = fileURLToPath(new URL("./hub-server.ts", import.meta.url));
38
+
39
+ export function hubPortPath(configDir: string = CONFIG_DIR): string {
40
+ return join(runDir(HUB_SVC, configDir), `${HUB_SVC}.port`);
41
+ }
42
+
43
+ export function readHubPort(configDir: string = CONFIG_DIR): number | undefined {
44
+ const p = hubPortPath(configDir);
45
+ if (!existsSync(p)) return undefined;
46
+ const raw = readFileSync(p, "utf8").trim();
47
+ const n = Number.parseInt(raw, 10);
48
+ return Number.isInteger(n) && n > 0 && n <= 65535 ? n : undefined;
49
+ }
50
+
51
+ export function writeHubPort(port: number, configDir: string = CONFIG_DIR): void {
52
+ const p = hubPortPath(configDir);
53
+ mkdirSync(dirname(p), { recursive: true });
54
+ writeFileSync(p, `${port}\n`);
55
+ }
56
+
57
+ export function clearHubPort(configDir: string = CONFIG_DIR): void {
58
+ const p = hubPortPath(configDir);
59
+ if (existsSync(p)) rmSync(p, { force: true });
60
+ }
61
+
62
+ /**
63
+ * Seam over `Bun.spawn`, mirroring the lifecycle Spawner — tests never want
64
+ * to actually fork a process. The real implementation opens the log file,
65
+ * pipes stdout+stderr into it, and detaches.
66
+ */
67
+ export interface HubSpawner {
68
+ spawn(cmd: readonly string[], logFile: string): number;
69
+ }
70
+
71
+ export const defaultHubSpawner: HubSpawner = {
72
+ spawn(cmd, logFile) {
73
+ const fd = openSync(logFile, "a");
74
+ const proc = Bun.spawn([...cmd], { stdio: ["ignore", fd, fd] });
75
+ proc.unref();
76
+ return proc.pid;
77
+ },
78
+ };
79
+
80
+ export type HubPortProbe = (port: number) => Promise<boolean>;
81
+ export type KillFn = (pid: number, signal: NodeJS.Signals | number) => void;
82
+ export type SleepFn = (ms: number) => Promise<void>;
83
+
84
+ export const defaultKill: KillFn = (pid, signal) => {
85
+ process.kill(pid, signal);
86
+ };
87
+
88
+ export const defaultSleep: SleepFn = (ms) => new Promise((r) => setTimeout(r, ms));
89
+
90
+ /**
91
+ * True if `port` accepts a listen() on 127.0.0.1. We bind-then-close to
92
+ * avoid racing: the common failure is "Aaron already has something on 1939",
93
+ * and a listen probe catches both EADDRINUSE and EACCES without parsing
94
+ * anything.
95
+ */
96
+ export const defaultPortProbe: HubPortProbe = (port) =>
97
+ new Promise((resolve) => {
98
+ const server = createServer();
99
+ server.once("error", () => resolve(false));
100
+ server.once("listening", () => {
101
+ server.close(() => resolve(true));
102
+ });
103
+ server.listen(port, "127.0.0.1");
104
+ });
105
+
106
+ export interface EnsureHubOpts {
107
+ configDir?: string;
108
+ wellKnownDir?: string;
109
+ spawner?: HubSpawner;
110
+ alive?: AliveFn;
111
+ probe?: HubPortProbe;
112
+ sleep?: SleepFn;
113
+ /** Starting port (default 1939). First port that probe()s true wins. */
114
+ startPort?: number;
115
+ /** How many ports to try before giving up (default 20). */
116
+ fallbackRange?: number;
117
+ /**
118
+ * Ports to skip during fallback — typically service ports from services.json
119
+ * so the hub doesn't steal a port a registered service will bind later.
120
+ * Probed ports that happen to be listening still fail the probe on their own;
121
+ * this guards the case where the service isn't running yet.
122
+ */
123
+ reservedPorts?: Iterable<number>;
124
+ /** How long to wait after spawn before claiming readiness. Short — tests set to 0. */
125
+ readyWaitMs?: number;
126
+ log?: (line: string) => void;
127
+ }
128
+
129
+ export interface EnsureHubResult {
130
+ pid: number;
131
+ port: number;
132
+ /** True when this call spawned the hub; false when it was already running. */
133
+ started: boolean;
134
+ }
135
+
136
+ export async function ensureHubRunning(opts: EnsureHubOpts = {}): Promise<EnsureHubResult> {
137
+ const configDir = opts.configDir ?? CONFIG_DIR;
138
+ const wellKnownDir = opts.wellKnownDir ?? WELL_KNOWN_DIR;
139
+ const spawner = opts.spawner ?? defaultHubSpawner;
140
+ const alive = opts.alive ?? defaultAlive;
141
+ const probe = opts.probe ?? defaultPortProbe;
142
+ const sleep = opts.sleep ?? defaultSleep;
143
+ const startPort = opts.startPort ?? HUB_DEFAULT_PORT;
144
+ const fallbackRange = opts.fallbackRange ?? HUB_PORT_FALLBACK_RANGE;
145
+ const reservedPorts = new Set(opts.reservedPorts ?? []);
146
+ const readyWaitMs = opts.readyWaitMs ?? 150;
147
+ const log = opts.log ?? (() => {});
148
+
149
+ const existingPid = readPid(HUB_SVC, configDir);
150
+ const existingPort = readHubPort(configDir);
151
+ if (existingPid !== undefined && alive(existingPid) && existingPort !== undefined) {
152
+ return { pid: existingPid, port: existingPort, started: false };
153
+ }
154
+ // Any stale state (pid without live process, port without pid) — wipe.
155
+ if (existingPid !== undefined) clearPid(HUB_SVC, configDir);
156
+ clearHubPort(configDir);
157
+
158
+ let chosenPort: number | undefined;
159
+ for (let i = 0; i < fallbackRange; i++) {
160
+ const candidate = startPort + i;
161
+ if (reservedPorts.has(candidate)) continue;
162
+ if (await probe(candidate)) {
163
+ chosenPort = candidate;
164
+ break;
165
+ }
166
+ }
167
+ if (chosenPort === undefined) {
168
+ const range =
169
+ fallbackRange === 1 ? `${startPort}` : `${startPort}..${startPort + fallbackRange - 1}`;
170
+ throw new Error(
171
+ `hub: port ${range} unavailable. Run \`lsof -iTCP:${startPort}\` to find what's using it, or pass --hub-port to override.`,
172
+ );
173
+ }
174
+
175
+ const logFile = ensureLogPath(HUB_SVC, configDir);
176
+ const cmd = [
177
+ "bun",
178
+ HUB_SERVER_PATH,
179
+ "--port",
180
+ String(chosenPort),
181
+ "--well-known-dir",
182
+ wellKnownDir,
183
+ ];
184
+ const pid = spawner.spawn(cmd, logFile);
185
+ writePid(HUB_SVC, pid, configDir);
186
+ writeHubPort(chosenPort, configDir);
187
+
188
+ // A tiny grace period so the subsequent `tailscale serve` proxy target
189
+ // isn't pointed at a not-yet-listening socket.
190
+ if (readyWaitMs > 0) await sleep(readyWaitMs);
191
+
192
+ log(`hub listening on 127.0.0.1:${chosenPort} (pid ${pid}); logs: ${logFile}`);
193
+ return { pid, port: chosenPort, started: true };
194
+ }
195
+
196
+ export interface StopHubOpts {
197
+ configDir?: string;
198
+ kill?: KillFn;
199
+ alive?: AliveFn;
200
+ sleep?: SleepFn;
201
+ now?: () => number;
202
+ /** How long SIGTERM gets before SIGKILL. */
203
+ killWaitMs?: number;
204
+ pollIntervalMs?: number;
205
+ log?: (line: string) => void;
206
+ }
207
+
208
+ export async function stopHub(opts: StopHubOpts = {}): Promise<boolean> {
209
+ const configDir = opts.configDir ?? CONFIG_DIR;
210
+ const kill = opts.kill ?? defaultKill;
211
+ const alive = opts.alive ?? defaultAlive;
212
+ const sleep = opts.sleep ?? defaultSleep;
213
+ const now = opts.now ?? Date.now;
214
+ const killWaitMs = opts.killWaitMs ?? 5_000;
215
+ const pollIntervalMs = opts.pollIntervalMs ?? 100;
216
+ const log = opts.log ?? (() => {});
217
+
218
+ const pid = readPid(HUB_SVC, configDir);
219
+ if (pid === undefined) {
220
+ clearHubPort(configDir);
221
+ return false;
222
+ }
223
+ if (!alive(pid)) {
224
+ clearPid(HUB_SVC, configDir);
225
+ clearHubPort(configDir);
226
+ return false;
227
+ }
228
+
229
+ try {
230
+ kill(pid, "SIGTERM");
231
+ } catch {
232
+ // PID gone between alive() and kill(); treat as stopped.
233
+ clearPid(HUB_SVC, configDir);
234
+ clearHubPort(configDir);
235
+ return true;
236
+ }
237
+
238
+ const deadline = now() + killWaitMs;
239
+ while (now() < deadline && alive(pid)) {
240
+ await sleep(pollIntervalMs);
241
+ }
242
+ if (alive(pid)) {
243
+ log(`hub didn't exit after ${killWaitMs}ms; sending SIGKILL.`);
244
+ try {
245
+ kill(pid, "SIGKILL");
246
+ } catch {
247
+ // Swallowed — racing against a just-exited process.
248
+ }
249
+ }
250
+
251
+ clearPid(HUB_SVC, configDir);
252
+ clearHubPort(configDir);
253
+ return true;
254
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * The Parachute hub is the ecosystem's OAuth issuer (Phase 0 of the hub-as-
3
+ * portal design at DESIGN-2026-04-20-hub-as-portal-oauth-and-service-catalog.md).
4
+ * Every service that participates in OAuth (today just vault; scribe + channel
5
+ * later) needs to know what URL clients will use to discover and reach the
6
+ * issuer — and that URL has to match what tailscale actually serves.
7
+ *
8
+ * exposed (tailnet or public) → `https://<fqdn>`
9
+ * not exposed (local dev) → `http://127.0.0.1:<hub-port>`
10
+ * user override → whatever --hub-origin was passed
11
+ *
12
+ * One source of truth — expose/start both route through `deriveHubOrigin`.
13
+ */
14
+
15
+ export const HUB_ORIGIN_ENV = "PARACHUTE_HUB_ORIGIN";
16
+
17
+ export interface DeriveHubOriginOpts {
18
+ /** Explicit user override (e.g., `--hub-origin`). Wins over everything else. */
19
+ override?: string;
20
+ /**
21
+ * Tailnet FQDN from a live exposure. Present when `expose-state.json`
22
+ * carries a canonicalFqdn; absent for unexposed local dev.
23
+ */
24
+ exposeFqdn?: string;
25
+ /**
26
+ * Bound hub port for the localhost fallback. When no exposure and no hub
27
+ * port exists, we pass through `undefined` and callers decide what to do
28
+ * (typically: skip setting the env so vault advertises its own issuer).
29
+ */
30
+ hubPort?: number;
31
+ }
32
+
33
+ /**
34
+ * Resolve the canonical hub origin. Returns `undefined` only when no source
35
+ * of truth is available (no override, no exposure, no hub port). Callers that
36
+ * set `PARACHUTE_HUB_ORIGIN` on a child process should skip the env var
37
+ * entirely in that case so the service falls back to its own defaults.
38
+ */
39
+ export function deriveHubOrigin(opts: DeriveHubOriginOpts): string | undefined {
40
+ if (opts.override) return opts.override.replace(/\/+$/, "");
41
+ if (opts.exposeFqdn) return `https://${opts.exposeFqdn}`;
42
+ if (opts.hubPort !== undefined) return `http://127.0.0.1:${opts.hubPort}`;
43
+ return undefined;
44
+ }
@@ -0,0 +1,113 @@
1
+ #!/usr/bin/env bun
2
+
3
+ /**
4
+ * Localhost HTTP backing for the hub page.
5
+ *
6
+ * macOS `tailscaled` runs sandboxed and cannot read files under arbitrary
7
+ * user paths — `tailscale serve … --set-path=/ <file>` returns "an error
8
+ * occurred reading the file or directory". The reliable shape is HTTP proxy:
9
+ * `tailscale serve … --set-path=/ http://127.0.0.1:<port>`. This shim is
10
+ * that localhost backing.
11
+ *
12
+ * Routes (all bound to 127.0.0.1):
13
+ * / → hub.html (text/html)
14
+ * /hub.html → hub.html (text/html)
15
+ * /.well-known/parachute.json → parachute.json (application/json)
16
+ * anything else → 404
17
+ *
18
+ * Invoked as:
19
+ * bun <this-file> --port <n> --well-known-dir <path>
20
+ *
21
+ * `--well-known-dir` is the directory containing both `hub.html` and
22
+ * `parachute.json` (both written by `parachute expose`). Kept as one flag so
23
+ * the lifecycle side doesn't have to care how the hub server lays out files.
24
+ */
25
+
26
+ import { existsSync } from "node:fs";
27
+ import { join, resolve } from "node:path";
28
+
29
+ interface Args {
30
+ port: number;
31
+ wellKnownDir: string;
32
+ }
33
+
34
+ function parseArgs(argv: string[]): Args {
35
+ let port: number | undefined;
36
+ let wellKnownDir: string | undefined;
37
+ for (let i = 0; i < argv.length; i++) {
38
+ const a = argv[i];
39
+ if (a === "--port") {
40
+ const v = argv[++i];
41
+ if (!v) throw new Error("--port requires a value");
42
+ const n = Number.parseInt(v, 10);
43
+ if (!Number.isInteger(n) || n <= 0 || n > 65535) {
44
+ throw new Error(`--port must be 1..65535, got "${v}"`);
45
+ }
46
+ port = n;
47
+ } else if (a === "--well-known-dir") {
48
+ const v = argv[++i];
49
+ if (!v) throw new Error("--well-known-dir requires a value");
50
+ wellKnownDir = resolve(v);
51
+ } else {
52
+ throw new Error(`unknown argument: ${a}`);
53
+ }
54
+ }
55
+ if (port === undefined) throw new Error("--port is required");
56
+ if (wellKnownDir === undefined) throw new Error("--well-known-dir is required");
57
+ return { port, wellKnownDir };
58
+ }
59
+
60
+ export function hubFetch(wellKnownDir: string): (req: Request) => Response {
61
+ const hubHtmlPath = join(wellKnownDir, "hub.html");
62
+ const parachuteJsonPath = join(wellKnownDir, "parachute.json");
63
+
64
+ return (req) => {
65
+ const url = new URL(req.url);
66
+ const pathname = url.pathname;
67
+
68
+ if (pathname === "/" || pathname === "/hub.html") {
69
+ if (!existsSync(hubHtmlPath)) {
70
+ return new Response("hub.html not found", { status: 404 });
71
+ }
72
+ return new Response(Bun.file(hubHtmlPath), {
73
+ headers: { "content-type": "text/html; charset=utf-8" },
74
+ });
75
+ }
76
+
77
+ if (pathname === "/.well-known/parachute.json") {
78
+ // The well-known doc is a public service-discovery manifest (no
79
+ // secrets, no PII), and Notes / future browser clients fetch it
80
+ // cross-origin from their own loopback port. Wildcard CORS is the
81
+ // shape it needs. Browsers send an OPTIONS preflight when the request
82
+ // adds non-simple headers; answer it with 204 + the same allow-list.
83
+ const corsHeaders = {
84
+ "access-control-allow-origin": "*",
85
+ "access-control-allow-methods": "GET, OPTIONS",
86
+ };
87
+ if (req.method === "OPTIONS") {
88
+ return new Response(null, { status: 204, headers: corsHeaders });
89
+ }
90
+ if (!existsSync(parachuteJsonPath)) {
91
+ return new Response("parachute.json not found", {
92
+ status: 404,
93
+ headers: corsHeaders,
94
+ });
95
+ }
96
+ return new Response(Bun.file(parachuteJsonPath), {
97
+ headers: { "content-type": "application/json", ...corsHeaders },
98
+ });
99
+ }
100
+
101
+ return new Response("not found", { status: 404 });
102
+ };
103
+ }
104
+
105
+ if (import.meta.main) {
106
+ const { port, wellKnownDir } = parseArgs(process.argv.slice(2));
107
+ Bun.serve({
108
+ port,
109
+ hostname: "127.0.0.1",
110
+ fetch: hubFetch(wellKnownDir),
111
+ });
112
+ console.log(`parachute-hub listening on http://127.0.0.1:${port} (dir=${wellKnownDir})`);
113
+ }