@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.
- package/package.json +1 -1
- package/src/__tests__/admin-agent-grants.test.ts +113 -0
- package/src/__tests__/admin-vaults.test.ts +20 -5
- package/src/__tests__/api-modules.test.ts +65 -10
- package/src/__tests__/api-vault-caps.test.ts +232 -0
- package/src/__tests__/cli.test.ts +20 -0
- package/src/__tests__/hub-command.test.ts +136 -0
- package/src/__tests__/hub-origins-env-set.test.ts +273 -0
- package/src/__tests__/init.test.ts +148 -0
- package/src/__tests__/jwt-sign.test.ts +79 -0
- package/src/__tests__/module-manifest.test.ts +3 -1
- package/src/__tests__/oauth-client.test.ts +75 -0
- package/src/__tests__/oauth-handlers.test.ts +413 -5
- package/src/__tests__/public-signup.test.ts +619 -0
- package/src/__tests__/rate-limit.test.ts +86 -0
- package/src/__tests__/scribe-config.test.ts +117 -0
- package/src/__tests__/serve-boot.test.ts +45 -0
- package/src/__tests__/serve.test.ts +67 -1
- package/src/__tests__/service-spec-discovery.test.ts +22 -2
- package/src/__tests__/setup-wizard.test.ts +18 -0
- package/src/__tests__/setup.test.ts +129 -15
- package/src/__tests__/two-factor-flow.test.ts +94 -0
- package/src/__tests__/users.test.ts +33 -0
- package/src/__tests__/vault-caps.test.ts +89 -0
- package/src/__tests__/vault-hub-origin-env.test.ts +38 -4
- package/src/__tests__/wizard-transcription.test.ts +334 -0
- package/src/__tests__/wizard.test.ts +146 -0
- package/src/account-setup.ts +118 -32
- package/src/admin-agent-grants.ts +51 -3
- package/src/admin-handlers.ts +53 -16
- package/src/admin-login-ui.ts +30 -4
- package/src/admin-vaults.ts +9 -1
- package/src/api-invites.ts +163 -3
- package/src/api-modules-ops.ts +12 -0
- package/src/api-modules.ts +47 -13
- package/src/api-users.ts +3 -0
- package/src/api-vault-caps.ts +206 -0
- package/src/cli.ts +35 -1
- package/src/commands/hub.ts +173 -0
- package/src/commands/init.ts +73 -2
- package/src/commands/serve-boot.ts +16 -2
- package/src/commands/serve.ts +39 -3
- package/src/commands/setup.ts +31 -6
- package/src/commands/wizard-transcription.ts +296 -0
- package/src/commands/wizard.ts +73 -3
- package/src/help.ts +8 -0
- package/src/hub-db.ts +108 -1
- package/src/hub-origin.ts +64 -0
- package/src/hub-server.ts +27 -0
- package/src/invites.ts +155 -31
- package/src/jwt-sign.ts +72 -3
- package/src/module-manifest.ts +23 -9
- package/src/oauth-client.ts +102 -12
- package/src/oauth-flows-store.ts +12 -0
- package/src/oauth-handlers.ts +145 -20
- package/src/rate-limit.ts +111 -20
- package/src/scribe-config.ts +145 -0
- package/src/service-spec.ts +31 -12
- package/src/setup-wizard.ts +37 -4
- package/src/users.ts +62 -3
- package/src/vault-caps.ts +109 -0
- package/src/vault-hub-origin-env.ts +109 -16
- package/web/ui/dist/assets/{index-DR6R8EFf.css → index--728BX3j.css} +1 -1
- package/web/ui/dist/assets/index-DZzX_Enf.js +61 -0
- package/web/ui/dist/index.html +2 -2
- 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
|
|
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
|
+
}
|
package/src/commands/init.ts
CHANGED
|
@@ -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: {
|
|
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
|
-
|
|
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)
|
|
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 };
|