@openparachute/hub 0.7.2 → 0.7.3-rc.10

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 (66) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/admin-agent-grants.test.ts +113 -0
  3. package/src/__tests__/admin-vaults.test.ts +20 -5
  4. package/src/__tests__/api-modules.test.ts +65 -10
  5. package/src/__tests__/api-vault-caps.test.ts +232 -0
  6. package/src/__tests__/cli.test.ts +20 -0
  7. package/src/__tests__/hub-command.test.ts +136 -0
  8. package/src/__tests__/hub-origins-env-set.test.ts +273 -0
  9. package/src/__tests__/init.test.ts +148 -0
  10. package/src/__tests__/jwt-sign.test.ts +79 -0
  11. package/src/__tests__/module-manifest.test.ts +3 -1
  12. package/src/__tests__/oauth-client.test.ts +75 -0
  13. package/src/__tests__/oauth-handlers.test.ts +413 -5
  14. package/src/__tests__/public-signup.test.ts +619 -0
  15. package/src/__tests__/rate-limit.test.ts +86 -0
  16. package/src/__tests__/scribe-config.test.ts +117 -0
  17. package/src/__tests__/serve-boot.test.ts +45 -0
  18. package/src/__tests__/serve.test.ts +67 -1
  19. package/src/__tests__/service-spec-discovery.test.ts +22 -2
  20. package/src/__tests__/setup-wizard.test.ts +18 -0
  21. package/src/__tests__/setup.test.ts +129 -15
  22. package/src/__tests__/two-factor-flow.test.ts +94 -0
  23. package/src/__tests__/users.test.ts +33 -0
  24. package/src/__tests__/vault-caps.test.ts +89 -0
  25. package/src/__tests__/vault-hub-origin-env.test.ts +38 -4
  26. package/src/__tests__/wizard-transcription.test.ts +334 -0
  27. package/src/__tests__/wizard.test.ts +146 -0
  28. package/src/account-setup.ts +118 -32
  29. package/src/admin-agent-grants.ts +51 -3
  30. package/src/admin-handlers.ts +53 -16
  31. package/src/admin-login-ui.ts +30 -4
  32. package/src/admin-vaults.ts +9 -1
  33. package/src/api-invites.ts +163 -3
  34. package/src/api-modules-ops.ts +12 -0
  35. package/src/api-modules.ts +47 -13
  36. package/src/api-users.ts +3 -0
  37. package/src/api-vault-caps.ts +206 -0
  38. package/src/cli.ts +35 -1
  39. package/src/commands/hub.ts +173 -0
  40. package/src/commands/init.ts +73 -2
  41. package/src/commands/serve-boot.ts +16 -2
  42. package/src/commands/serve.ts +39 -3
  43. package/src/commands/setup.ts +31 -6
  44. package/src/commands/wizard-transcription.ts +296 -0
  45. package/src/commands/wizard.ts +73 -3
  46. package/src/help.ts +8 -0
  47. package/src/hub-db.ts +108 -1
  48. package/src/hub-origin.ts +64 -0
  49. package/src/hub-server.ts +27 -0
  50. package/src/invites.ts +155 -31
  51. package/src/jwt-sign.ts +72 -3
  52. package/src/module-manifest.ts +23 -9
  53. package/src/oauth-client.ts +102 -12
  54. package/src/oauth-flows-store.ts +12 -0
  55. package/src/oauth-handlers.ts +145 -20
  56. package/src/rate-limit.ts +111 -20
  57. package/src/scribe-config.ts +145 -0
  58. package/src/service-spec.ts +31 -12
  59. package/src/setup-wizard.ts +37 -4
  60. package/src/users.ts +62 -3
  61. package/src/vault-caps.ts +109 -0
  62. package/src/vault-hub-origin-env.ts +109 -16
  63. package/web/ui/dist/assets/{index-DR6R8EFf.css → index--728BX3j.css} +1 -1
  64. package/web/ui/dist/assets/index-DZzX_Enf.js +61 -0
  65. package/web/ui/dist/index.html +2 -2
  66. package/web/ui/dist/assets/index-B5AUE359.js +0 -61
@@ -0,0 +1,206 @@
1
+ /**
2
+ * `/api/vault-caps*` — admin visibility + edit for per-vault storage caps
3
+ * (DEMO-PREP-2026-06-25 Workstream B5 / D-slice).
4
+ *
5
+ * PR #686 shipped the `vault_caps` table + `setVaultCap`/`getVaultCapBytes`
6
+ * (provision-time persistence) and a separate Phase-2 PR reads + ENFORCES the
7
+ * cap at upload time. This file is the OPERATOR-FACING seam in between: the
8
+ * admin SPA needs to SEE who has what cap and EDIT a cap live in the demo.
9
+ *
10
+ * Surfaces:
11
+ *
12
+ * GET /api/vault-caps list every vault (from services.json) joined
13
+ * with its persisted cap (host:admin)
14
+ * PUT /api/vault-caps/:name set/update a vault's cap to N bytes (host:admin)
15
+ *
16
+ * The list is the JOIN of "what vaults exist" (services.json, the canonical
17
+ * vault-name source) and "what caps are persisted" (`vault_caps`). A vault
18
+ * with no cap row appears with `cap_bytes: null` (uncapped) — the same
19
+ * "uncapped = no row" contract the Phase-2 enforcement reader relies on.
20
+ *
21
+ * Wire shape is snake_case (matches `/api/users`, `/api/invites`). Auth: same
22
+ * `parachute:host:admin` Bearer gate as every other `/api/*` admin surface;
23
+ * the SPA mints it from the session cookie via `/admin/host-admin-token`.
24
+ *
25
+ * Scope discipline: PUT only sets a cap on a vault that is REGISTERED in
26
+ * services.json (rejects a stale / typo name with 400 `vault_not_found`) so an
27
+ * operator can't seed a cap row for a vault that doesn't exist. Additive only —
28
+ * this never changes the provision-time cap-persistence from #686; it's a
29
+ * read + targeted-edit layer on the same table.
30
+ */
31
+ import type { Database } from "bun:sqlite";
32
+ import { type AdminAuthError, adminAuthErrorResponse, requireScope } from "./admin-auth.ts";
33
+ import { HOST_ADMIN_SCOPE } from "./admin-vaults.ts";
34
+ import { SERVICES_MANIFEST_PATH } from "./config.ts";
35
+ import { getVaultCap, setVaultCap } from "./vault-caps.ts";
36
+ import { listVaultNamesFromPath } from "./vault-names.ts";
37
+
38
+ export interface ApiVaultCapsDeps {
39
+ db: Database;
40
+ /** Hub origin — JWT `iss` validation. */
41
+ issuer: string;
42
+ /** Override services.json path. Defaults to `~/.parachute/services.json`. */
43
+ manifestPath?: string;
44
+ }
45
+
46
+ /**
47
+ * One row in the `GET /api/vault-caps` response: a vault name + its cap. A
48
+ * vault with no persisted cap carries `cap_bytes: null` (uncapped); `created_at`
49
+ * / `updated_at` are null too in that case.
50
+ */
51
+ export interface VaultCapWireShape {
52
+ vault_name: string;
53
+ cap_bytes: number | null;
54
+ created_at: string | null;
55
+ updated_at: string | null;
56
+ }
57
+
58
+ function jsonError(status: number, error: string, description: string): Response {
59
+ return new Response(JSON.stringify({ error, error_description: description }), {
60
+ status,
61
+ headers: { "content-type": "application/json" },
62
+ });
63
+ }
64
+
65
+ /**
66
+ * GET /api/vault-caps — every vault registered in services.json, joined with
67
+ * its persisted cap (null = uncapped). Ordered by vault name.
68
+ */
69
+ export async function handleListVaultCaps(req: Request, deps: ApiVaultCapsDeps): Promise<Response> {
70
+ if (req.method !== "GET") {
71
+ return jsonError(405, "method_not_allowed", "use GET");
72
+ }
73
+ try {
74
+ await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.issuer);
75
+ } catch (err) {
76
+ return adminAuthErrorResponse(err as AdminAuthError);
77
+ }
78
+ const manifestPath = deps.manifestPath ?? SERVICES_MANIFEST_PATH;
79
+ const names = listVaultNamesFromPath(manifestPath);
80
+ const caps: VaultCapWireShape[] = names.map((vaultName) => {
81
+ const cap = getVaultCap(deps.db, vaultName);
82
+ return {
83
+ vault_name: vaultName,
84
+ cap_bytes: cap?.capBytes ?? null,
85
+ created_at: cap?.createdAt ?? null,
86
+ updated_at: cap?.updatedAt ?? null,
87
+ };
88
+ });
89
+ return new Response(JSON.stringify({ vault_caps: caps }), {
90
+ status: 200,
91
+ headers: { "content-type": "application/json", "cache-control": "no-store" },
92
+ });
93
+ }
94
+
95
+ interface SetCapBody {
96
+ cap_bytes: number;
97
+ }
98
+
99
+ interface ParseErr {
100
+ ok: false;
101
+ status: number;
102
+ error: string;
103
+ description: string;
104
+ }
105
+
106
+ async function parseSetCapBody(req: Request): Promise<{ ok: true; body: SetCapBody } | ParseErr> {
107
+ const ctype = req.headers.get("content-type") ?? "";
108
+ if (!ctype.toLowerCase().includes("application/json")) {
109
+ return {
110
+ ok: false,
111
+ status: 400,
112
+ error: "invalid_request",
113
+ description: "Content-Type must be application/json",
114
+ };
115
+ }
116
+ let raw: unknown;
117
+ try {
118
+ raw = await req.json();
119
+ } catch (err) {
120
+ const msg = err instanceof Error ? err.message : String(err);
121
+ return {
122
+ ok: false,
123
+ status: 400,
124
+ error: "invalid_request",
125
+ description: `invalid JSON body: ${msg}`,
126
+ };
127
+ }
128
+ if (!raw || typeof raw !== "object") {
129
+ return {
130
+ ok: false,
131
+ status: 400,
132
+ error: "invalid_request",
133
+ description: "request body must be a JSON object",
134
+ };
135
+ }
136
+ const obj = raw as Record<string, unknown>;
137
+ const capBytes = obj.cap_bytes;
138
+ // Positive integer only — the table's CHECK (cap_bytes > 0) is the at-rest
139
+ // backstop; this is the edge validation so the operator gets a clean 400
140
+ // instead of a sqlite constraint error. A fractional byte count (which a byte
141
+ // count can never be) is rejected rather than silently floored downstream.
142
+ if (typeof capBytes !== "number" || !Number.isInteger(capBytes) || capBytes <= 0) {
143
+ return {
144
+ ok: false,
145
+ status: 400,
146
+ error: "invalid_request",
147
+ description: '"cap_bytes" must be a positive integer number of bytes',
148
+ };
149
+ }
150
+ return { ok: true, body: { cap_bytes: capBytes } };
151
+ }
152
+
153
+ /**
154
+ * PUT /api/vault-caps/:name — set or update a vault's storage cap.
155
+ *
156
+ * Order of checks (mirrors the /api/users handlers):
157
+ *
158
+ * 1. Method gate (405 on non-PUT).
159
+ * 2. Bearer carries `parachute:host:admin` (401 / 403 via `requireScope`).
160
+ * 3. Parse + validate body (400 on shape / non-positive cap).
161
+ * 4. Vault is registered in services.json (400 `vault_not_found`) — refuse
162
+ * to seed a cap for a vault that doesn't exist.
163
+ * 5. `setVaultCap` (upsert) — overwrites the cap, bumps `updated_at`,
164
+ * preserves the original `created_at`.
165
+ *
166
+ * Response on success: `200 { vault_cap: <wire shape> }`.
167
+ */
168
+ export async function handleSetVaultCap(
169
+ req: Request,
170
+ vaultName: string,
171
+ deps: ApiVaultCapsDeps,
172
+ ): Promise<Response> {
173
+ if (req.method !== "PUT") {
174
+ return jsonError(405, "method_not_allowed", "use PUT");
175
+ }
176
+ try {
177
+ await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.issuer);
178
+ } catch (err) {
179
+ return adminAuthErrorResponse(err as AdminAuthError);
180
+ }
181
+ const parsed = await parseSetCapBody(req);
182
+ if (!parsed.ok) {
183
+ return jsonError(parsed.status, parsed.error, parsed.description);
184
+ }
185
+ const manifestPath = deps.manifestPath ?? SERVICES_MANIFEST_PATH;
186
+ const known = new Set(listVaultNamesFromPath(manifestPath));
187
+ if (!known.has(vaultName)) {
188
+ return jsonError(
189
+ 400,
190
+ "vault_not_found",
191
+ `vault "${vaultName}" is not registered in services.json`,
192
+ );
193
+ }
194
+ const cap = setVaultCap(deps.db, vaultName, parsed.body.cap_bytes);
195
+ console.log(`vault cap set: vault=${vaultName} cap_bytes=${cap.capBytes}`);
196
+ const wire: VaultCapWireShape = {
197
+ vault_name: cap.vaultName,
198
+ cap_bytes: cap.capBytes,
199
+ created_at: cap.createdAt,
200
+ updated_at: cap.updatedAt,
201
+ };
202
+ return new Response(JSON.stringify({ vault_cap: wire }), {
203
+ status: 200,
204
+ headers: { "content-type": "application/json", "cache-control": "no-store" },
205
+ });
206
+ }
package/src/cli.ts CHANGED
@@ -8,6 +8,7 @@
8
8
 
9
9
  import { MissingDependencyError } from "@openparachute/depcheck";
10
10
  import pkg from "../package.json" with { type: "json" };
11
+ import { validateHubOrigin } from "./api-settings-hub-origin.ts";
11
12
  import { CloudflaredStateError } from "./cloudflare/state.ts";
12
13
  // Command-implementation modules are loaded LAZILY inside their switch arms (see
13
14
  // `loadCommand` + each `case`), so a module that throws at eval-time is isolated
@@ -360,7 +361,32 @@ async function main(argv: string[]): Promise<number> {
360
361
  console.log(initHelp());
361
362
  return 0;
362
363
  }
363
- const exposeExtract = extractNamedFlag(rest, "--expose");
364
+ const originExtract = extractHubOrigin(rest);
365
+ if (originExtract.error) {
366
+ console.error(`parachute init: ${originExtract.error}`);
367
+ return 1;
368
+ }
369
+ // Validate --hub-origin to the SAME shape `hub set-origin` enforces — the
370
+ // value is persisted to hub_settings.hub_origin and stamps the OAuth `iss`
371
+ // claim on every minted token, so a malformed path/scheme/credential must
372
+ // never reach the DB. Strip a trailing slash first (browser copy-paste
373
+ // ergonomics), then validate; pass the normalized form downstream.
374
+ let validatedHubOrigin: string | undefined;
375
+ if (originExtract.hubOrigin !== undefined) {
376
+ const result = validateHubOrigin(originExtract.hubOrigin.replace(/\/+$/, ""));
377
+ if (!result.ok) {
378
+ console.error(`parachute init: invalid --hub-origin: ${result.description}`);
379
+ return 1;
380
+ }
381
+ if (result.normalized === null) {
382
+ console.error(
383
+ "parachute init: invalid --hub-origin: an empty value is not a valid public origin.",
384
+ );
385
+ return 1;
386
+ }
387
+ validatedHubOrigin = result.normalized;
388
+ }
389
+ const exposeExtract = extractNamedFlag(originExtract.rest, "--expose");
364
390
  if (exposeExtract.error) {
365
391
  console.error(`parachute init: ${exposeExtract.error}`);
366
392
  return 1;
@@ -392,6 +418,7 @@ async function main(argv: string[]): Promise<number> {
392
418
  console.error(
393
419
  "usage: parachute init [--no-browser] [--no-expose-prompt]\n" +
394
420
  " [--expose none|tailnet|cloudflare]\n" +
421
+ " [--hub-origin <url>]\n" +
395
422
  " [--cli-wizard | --browser-wizard]",
396
423
  );
397
424
  return 1;
@@ -403,6 +430,7 @@ async function main(argv: string[]): Promise<number> {
403
430
  const initOpts: Parameters<typeof init>[0] = {};
404
431
  if (noBrowser) initOpts.noBrowser = true;
405
432
  if (noExposePrompt) initOpts.noExposePrompt = true;
433
+ if (validatedHubOrigin) initOpts.hubOrigin = validatedHubOrigin;
406
434
  if (exposeExtract.value) {
407
435
  initOpts.exposeChoice = exposeExtract.value as "none" | "tailnet" | "cloudflare";
408
436
  }
@@ -931,6 +959,12 @@ async function main(argv: string[]): Promise<number> {
931
959
  return await mod.auth(rest);
932
960
  }
933
961
 
962
+ case "hub": {
963
+ const mod = await loadCommand("hub", () => import("./commands/hub.ts"));
964
+ if (!mod) return 1;
965
+ return await mod.hub(rest);
966
+ }
967
+
934
968
  case "vault": {
935
969
  const mod = await loadCommand("vault", () => import("./commands/vault.ts"));
936
970
  if (!mod) return 1;
@@ -0,0 +1,173 @@
1
+ /**
2
+ * `parachute hub <subcommand>` — hub-local configuration verbs that write
3
+ * `~/.parachute/hub.db`.
4
+ *
5
+ * Subcommands:
6
+ * - `set-origin <url>` — persist the operator's canonical public hub origin
7
+ * into `hub_settings.hub_origin`. That row is tier-1 in `resolveIssuer`
8
+ * (hub-server.ts) — the OAuth `iss` claim hub stamps into every JWT — and,
9
+ * since the onboarding-streamline 2026-06-25 boot-issuer fix, also seeds the
10
+ * `PARACHUTE_HUB_ORIGINS` set hub injects into supervised modules
11
+ * (vault/scribe) so they accept tokens minted under the public origin.
12
+ *
13
+ * The headline use case is the zero-SSH Caddy-direct DigitalOcean path: a bare
14
+ * droplet runs Caddy (Let's Encrypt TLS terminator + reverse proxy to the
15
+ * loopback hub), and the hub never does TLS. The hub binds loopback and reads
16
+ * its public origin from the DB. Before this verb the only way to set
17
+ * `hub_origin` was the admin SPA's `PUT /api/settings/hub-origin` — which needs
18
+ * a browser session you don't have on a freshly-provisioned headless box. The
19
+ * CLI verb closes that gap so a cloud-init script (or an operator over the DO
20
+ * console) can record the public origin without SSHing into the admin UI.
21
+ *
22
+ * Validation reuses `validateHubOrigin` (api-settings-hub-origin.ts) so the CLI
23
+ * and the SPA enforce the EXACT same shape: http(s) scheme, a hostname, no
24
+ * trailing slash / path / query / fragment / credentials. A loopback value is
25
+ * allowed (a dev/test box may legitimately set one) but warned about, since a
26
+ * loopback `hub_origin` would advertise a non-public issuer.
27
+ */
28
+
29
+ import type { Database } from "bun:sqlite";
30
+ import { validateHubOrigin } from "../api-settings-hub-origin.ts";
31
+ import { CONFIG_DIR } from "../config.ts";
32
+ import { hubDbPath, openHubDb } from "../hub-db.ts";
33
+ import { setHubOrigin } from "../hub-settings.ts";
34
+ import { isLoopbackOrigin } from "../vault-hub-origin-env.ts";
35
+
36
+ export interface HubCommandDeps {
37
+ configDir?: string;
38
+ log?: (line: string) => void;
39
+ /**
40
+ * Test seam: open the hub DB. Production opens `hub.db` under the resolved
41
+ * `configDir` (running migrations). Tests pass an in-memory / temp DB so the
42
+ * write is exercised without touching the operator's live `~/.parachute`.
43
+ */
44
+ openDb?: (configDir: string) => Database;
45
+ }
46
+
47
+ /**
48
+ * `parachute hub set-origin <url>`. Validates + canonicalizes the URL, persists
49
+ * it to `hub_settings.hub_origin`, and prints the restart note (already-running
50
+ * supervised modules pick up the widened `PARACHUTE_HUB_ORIGINS` set only on
51
+ * their next `parachute restart`, because that set is injected at child-spawn
52
+ * time). Returns 0 on success, 1 on a validation / write failure.
53
+ */
54
+ export async function hubSetOrigin(
55
+ args: readonly string[],
56
+ deps: HubCommandDeps = {},
57
+ ): Promise<number> {
58
+ const configDir = deps.configDir ?? CONFIG_DIR;
59
+ const log = deps.log ?? ((line) => console.log(line));
60
+ const err = (line: string) => console.error(line);
61
+ const openDb = deps.openDb ?? ((dir: string) => openHubDb(hubDbPath(dir)));
62
+
63
+ const positional = args.filter((a) => !a.startsWith("-"));
64
+ const raw = positional[0];
65
+ if (raw === undefined) {
66
+ err("usage: parachute hub set-origin <url>");
67
+ err("example: parachute hub set-origin https://box.sslip.io");
68
+ return 1;
69
+ }
70
+ if (positional.length > 1) {
71
+ err(`parachute hub set-origin: unexpected argument "${positional[1]}"`);
72
+ err("usage: parachute hub set-origin <url>");
73
+ return 1;
74
+ }
75
+
76
+ // Strip a trailing slash up front so the convenient `https://host/` form is
77
+ // accepted (the operator copy-pastes a browser URL). `validateHubOrigin`
78
+ // itself REJECTS a trailing slash (it's the SPA's PUT body validator, where a
79
+ // strict shape is wanted) — we canonicalize here, then validate the rest of
80
+ // the shape (scheme / hostname / no-path / no-credentials) through the shared
81
+ // validator so the CLI and SPA agree on everything else.
82
+ const trimmed = raw.replace(/\/+$/, "");
83
+ const result = validateHubOrigin(trimmed);
84
+ if (!result.ok) {
85
+ err(`parachute hub set-origin: ${result.description}`);
86
+ return 1;
87
+ }
88
+ // `validateHubOrigin` maps empty / null to `normalized: null` (clear the row).
89
+ // `set-origin` with an explicit non-empty arg should never land there — a
90
+ // present positional that normalizes to null means the operator passed an
91
+ // empty string, which is not a valid public origin for this verb.
92
+ if (result.normalized === null) {
93
+ err("parachute hub set-origin: a non-empty origin URL is required");
94
+ err("usage: parachute hub set-origin <url>");
95
+ return 1;
96
+ }
97
+ const origin = result.normalized;
98
+
99
+ // Loopback is allowed (a dev/test box may set one deliberately) but warned —
100
+ // a loopback hub_origin advertises a non-public issuer, so remote devices
101
+ // and supervised resource servers reached over a public URL would reject it.
102
+ if (isLoopbackOrigin(origin)) {
103
+ log(`⚠ ${origin} is a loopback origin — it won't be reachable from other devices.`);
104
+ log(" Set the box's PUBLIC URL (e.g. https://<droplet-ip>.sslip.io) for a real deploy.");
105
+ }
106
+
107
+ const db = openDb(configDir);
108
+ try {
109
+ setHubOrigin(db, origin);
110
+ } finally {
111
+ db.close();
112
+ }
113
+
114
+ log(`✓ Canonical hub origin set to ${origin}.`);
115
+ log(" Stored in hub_settings — the OAuth issuer (`iss`) hub stamps on every token,");
116
+ log(" and the origin set injected into supervised modules so they accept it.");
117
+ log("");
118
+ log("Already-running modules (vault, scribe) pick up the widened origin set only on");
119
+ log("their next restart (it's injected at spawn time). Restart them to apply now:");
120
+ log(" parachute restart vault");
121
+ log(" parachute restart scribe # if installed");
122
+ return 0;
123
+ }
124
+
125
+ /**
126
+ * `parachute hub <subcommand>` dispatcher. Mirrors `auth`'s shape (a thin
127
+ * router over subcommand handlers, each catching its own errors).
128
+ */
129
+ export async function hub(args: readonly string[], deps: HubCommandDeps = {}): Promise<number> {
130
+ const sub = args[0];
131
+ if (sub === undefined || sub === "--help" || sub === "-h" || sub === "help") {
132
+ console.log(hubHelp());
133
+ return 0;
134
+ }
135
+ if (sub === "set-origin") {
136
+ try {
137
+ return await hubSetOrigin(args.slice(1), deps);
138
+ } catch (err) {
139
+ console.error(
140
+ `parachute hub set-origin: ${err instanceof Error ? err.message : String(err)}`,
141
+ );
142
+ return 1;
143
+ }
144
+ }
145
+ console.error(`parachute hub: unknown subcommand "${sub}"`);
146
+ console.error("");
147
+ console.error(hubHelp());
148
+ return 1;
149
+ }
150
+
151
+ export function hubHelp(): string {
152
+ return `parachute hub — hub-local configuration
153
+
154
+ Usage:
155
+ parachute hub set-origin <url> set the canonical public hub origin
156
+
157
+ Subcommands:
158
+ set-origin <url> Persist the canonical public origin (OAuth issuer) to the
159
+ hub DB. This is the URL the hub stamps as the \`iss\` claim
160
+ on every token AND the origin it tells supervised modules
161
+ (vault, scribe) to accept. Use it on a reverse-proxy /
162
+ Caddy-direct box where the hub binds loopback but is reached
163
+ over a public HTTPS URL — no admin browser session needed.
164
+
165
+ The URL must be http(s), with a hostname and no trailing
166
+ slash / path / query. After it's set, restart already-running
167
+ modules so they pick up the widened origin set.
168
+
169
+ Examples:
170
+ parachute hub set-origin https://box.sslip.io
171
+ parachute hub set-origin https://parachute.example.com
172
+ `;
173
+ }
@@ -41,6 +41,7 @@ import { type ExposeState, readExposeState } from "../expose-state.ts";
41
41
  import { type EnsureHubOpts, HUB_DEFAULT_PORT, HUB_SVC, readHubPort } from "../hub-control.ts";
42
42
  import { hubDbPath, openHubDb } from "../hub-db.ts";
43
43
  import { deriveHubOrigin } from "../hub-origin.ts";
44
+ import { setHubOrigin } from "../hub-settings.ts";
44
45
  import {
45
46
  type EnsureHubVersionMatchesResult,
46
47
  ensureHubUnit,
@@ -172,7 +173,11 @@ export interface InitOpts {
172
173
  * Test seam: shim for the CLI wizard chain (hub#168 Cut 3). Production
173
174
  * lazy-imports `runCliWizard` from `./wizard.ts`. Tests pass a stub.
174
175
  */
175
- runCliWizardImpl?: (opts: { hubUrl: string; log: (l: string) => void }) => Promise<number>;
176
+ runCliWizardImpl?: (opts: {
177
+ hubUrl: string;
178
+ log: (l: string) => void;
179
+ configDir?: string;
180
+ }) => Promise<number>;
176
181
  /**
177
182
  * Skip the "browser or CLI?" wizard-choice prompt (hub#168 Cut 4). Used
178
183
  * by pre-Cut-4 tests that don't expect the new prompt + by the
@@ -180,6 +185,27 @@ export interface InitOpts {
180
185
  * already known so there's no question to ask).
181
186
  */
182
187
  noWizardPrompt?: boolean;
188
+ /**
189
+ * Canonical public hub origin (the OAuth issuer / `iss` claim). Persisted to
190
+ * `hub_settings.hub_origin` BEFORE the hub unit starts + modules spawn, so
191
+ * supervised vault/scribe come up with the public origin already in their
192
+ * `PARACHUTE_HUB_ORIGINS` set in ONE pass — no restart needed. The headline
193
+ * use case is the zero-SSH Caddy-direct path: a cloud-init script passes
194
+ * `--hub-origin https://<droplet-ip>.sslip.io` so a reverse-proxied box
195
+ * records its public origin without an admin browser session. Validated +
196
+ * canonicalized by the CLI before it reaches here; persisted via the
197
+ * `setHubOriginImpl` seam. Loopback / non-http(s) values never reach this far
198
+ * (the CLI rejects them via `validateHubOrigin`). Absent → no-op (the laptop /
199
+ * loopback path is unchanged).
200
+ */
201
+ hubOrigin?: string;
202
+ /**
203
+ * Test seam: persist the canonical hub origin to `hub_settings.hub_origin`.
204
+ * Production opens `hub.db` (running migrations) and calls `setHubOrigin`.
205
+ * Tests pass a stub to assert the write happens BEFORE `ensureHub` without
206
+ * touching the operator's live DB. Receives the resolved configDir + origin.
207
+ */
208
+ setHubOriginImpl?: (configDir: string, origin: string) => void;
183
209
  /**
184
210
  * Test seam: probe the running hub for its first-claim bootstrap token
185
211
  * (hub#576). Production hits `GET http://127.0.0.1:<port>/admin/setup` with
@@ -387,6 +413,25 @@ async function defaultEnsureHubViaUnit(opts: EnsureHubOpts): Promise<{
387
413
  throw new Error(result.messages.join("\n") || `hub unit bringup failed (${result.outcome}).`);
388
414
  }
389
415
 
416
+ /**
417
+ * Default impl for the `--hub-origin` persistence step. Opens `hub.db` (running
418
+ * migrations on a fresh box) and writes `hub_settings.hub_origin`. Runs BEFORE
419
+ * the hub unit starts so the boot-time issuer resolution + child-spawn env
420
+ * (`PARACHUTE_HUB_ORIGINS`) pick up the public origin in one pass. The DB is an
421
+ * on-disk SQLite file shared with the (not-yet-started) serve process, so a
422
+ * write here is visible to the unit when it boots. The CLI has already
423
+ * validated + canonicalized the URL via `validateHubOrigin`, so this trusts the
424
+ * input (mirrors `setHubOrigin`'s typed-callsite contract).
425
+ */
426
+ function defaultSetHubOrigin(configDir: string, origin: string): void {
427
+ const db = openHubDb(hubDbPath(configDir));
428
+ try {
429
+ setHubOrigin(db, origin);
430
+ } finally {
431
+ db.close();
432
+ }
433
+ }
434
+
390
435
  /**
391
436
  * Resolve the issuer to mint the operator token under. At init time the hub is
392
437
  * reachable on loopback (just installed); prefer the live expose-state origin
@@ -485,6 +530,7 @@ async function defaultInstallVaultModule(configDir: string, manifestPath: string
485
530
  async function defaultRunCliWizard(opts: {
486
531
  hubUrl: string;
487
532
  log: (l: string) => void;
533
+ configDir?: string;
488
534
  }): Promise<number> {
489
535
  const { runCliWizard } = await import("./wizard.ts");
490
536
  return await runCliWizard(opts);
@@ -624,10 +670,32 @@ export async function init(opts: InitOpts = {}): Promise<number> {
624
670
  const installVaultModuleImpl = opts.installVaultModuleImpl ?? defaultInstallVaultModule;
625
671
  const runCliWizardImpl = opts.runCliWizardImpl ?? defaultRunCliWizard;
626
672
  const fetchBootstrapTokenImpl = opts.fetchBootstrapTokenImpl ?? defaultFetchBootstrapToken;
673
+ const setHubOriginImpl = opts.setHubOriginImpl ?? defaultSetHubOrigin;
627
674
 
628
675
  log("Parachute init — getting your hub set up.");
629
676
  log("");
630
677
 
678
+ // Step 0: persist `--hub-origin` BEFORE the hub unit starts (below). The
679
+ // boot-time issuer resolution + child-spawn env (`PARACHUTE_HUB_ORIGINS`)
680
+ // both read `hub_settings.hub_origin`, so writing it now means vault/scribe
681
+ // come up with the public origin already in their accepted-`iss` set in ONE
682
+ // pass — no restart needed (the Caddy-direct zero-SSH path). A failure here
683
+ // is non-fatal: warn + continue (the operator can re-run `parachute hub
684
+ // set-origin` later), since init's contract is hub up → wizard regardless.
685
+ if (opts.hubOrigin) {
686
+ try {
687
+ setHubOriginImpl(configDir, opts.hubOrigin);
688
+ log(`✓ Canonical hub origin set to ${opts.hubOrigin} (OAuth issuer).`);
689
+ log("");
690
+ } catch (err) {
691
+ const detail = err instanceof Error ? err.message : String(err);
692
+ log(
693
+ `⚠ Couldn't persist the hub origin (${detail}); set it later with \`parachute hub set-origin <url>\`.`,
694
+ );
695
+ log("");
696
+ }
697
+ }
698
+
631
699
  // Step 1: hub running?
632
700
  // NB: under the Phase 3a unit-managed hub there is no pidfile, so
633
701
  // `processState(HUB_SVC)` reports not-running on EVERY init re-run even when
@@ -895,7 +963,10 @@ export async function init(opts: InitOpts = {}): Promise<number> {
895
963
  // (the loopback-gated GET /admin/setup probe) — the operator never has to
896
964
  // copy the token out of the startup logs.
897
965
  const cliWizardUrl = `http://127.0.0.1:${hubPort}`;
898
- return await runCliWizardImpl({ hubUrl: cliWizardUrl, log });
966
+ // Pass the resolved configDir so the CLI wizard's transcription step can
967
+ // write scribe's config (onboarding-streamline hub PR1). Without it the
968
+ // step is skipped.
969
+ return await runCliWizardImpl({ hubUrl: cliWizardUrl, log, configDir });
899
970
  }
900
971
 
901
972
  // Step 5: offer to open the browser. Skip in non-TTY shells (CI),
@@ -18,7 +18,7 @@
18
18
 
19
19
  import { join } from "node:path";
20
20
  import { readEnvFileValues } from "../env-file.ts";
21
- import { HUB_ORIGIN_ENV } from "../hub-origin.ts";
21
+ import { HUB_ORIGINS_ENV, HUB_ORIGIN_ENV } from "../hub-origin.ts";
22
22
  import { ModuleManifestError } from "../module-manifest.ts";
23
23
  import {
24
24
  type ServiceSpec,
@@ -30,6 +30,7 @@ import {
30
30
  import { type ServiceEntry, readManifestLenient, upsertService } from "../services-manifest.ts";
31
31
  import { enrichedPath } from "../spawn-path.ts";
32
32
  import type { Supervisor } from "../supervisor.ts";
33
+ import { buildHubOriginsEnvValue } from "../vault-hub-origin-env.ts";
33
34
 
34
35
  export interface BootOpts {
35
36
  /** Path to services.json. */
@@ -197,7 +198,20 @@ export function buildModuleSpawnRequest(
197
198
  PORT: String(entry.port),
198
199
  ...fileEnvSansPort,
199
200
  };
200
- if (opts.hubOrigin) env[HUB_ORIGIN_ENV] = opts.hubOrigin;
201
+ if (opts.hubOrigin) {
202
+ env[HUB_ORIGIN_ENV] = opts.hubOrigin;
203
+ // Multi-origin iss-set (onboarding-streamline 2026-06-25): alongside the
204
+ // single canonical origin, inject the SET of origins this hub legitimately
205
+ // answers on (issuer ∪ loopback aliases ∪ expose-state ∪ platform). A
206
+ // resource server on scope-guard ≥0.5.0 widens its accepted-`iss` check to
207
+ // this set, so a token minted under one URL of a multi-URL box validates
208
+ // when the resource is reached via another URL of the SAME box. SECURITY:
209
+ // the set is hub-controlled config/disk state only, NEVER a request Host —
210
+ // see `buildHubOriginsEnvValue`. The single `PARACHUTE_HUB_ORIGIN` above
211
+ // stays for back-compat with older scope-guard.
212
+ const originsValue = buildHubOriginsEnvValue(opts.configDir, opts.hubOrigin);
213
+ if (originsValue) env[HUB_ORIGINS_ENV] = originsValue;
214
+ }
201
215
  if (opts.extraEnv) Object.assign(env, opts.extraEnv);
202
216
 
203
217
  const req: SpawnReqShape = { short, cmd };