@openparachute/hub 0.6.3 → 0.6.4-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 (72) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/account-setup.test.ts +609 -0
  3. package/src/__tests__/account-usage.test.ts +137 -0
  4. package/src/__tests__/account-vault-admin-token.test.ts +301 -0
  5. package/src/__tests__/account-vault-token.test.ts +53 -1
  6. package/src/__tests__/admin-vault-admin-token.test.ts +17 -0
  7. package/src/__tests__/admin-vaults.test.ts +20 -0
  8. package/src/__tests__/api-account.test.ts +125 -4
  9. package/src/__tests__/api-invites.test.ts +180 -0
  10. package/src/__tests__/api-mint-token.test.ts +259 -10
  11. package/src/__tests__/api-modules-ops.test.ts +187 -1
  12. package/src/__tests__/api-modules.test.ts +40 -4
  13. package/src/__tests__/api-settings-hub-origin.test.ts +13 -8
  14. package/src/__tests__/auto-wire.test.ts +101 -1
  15. package/src/__tests__/cli.test.ts +188 -2
  16. package/src/__tests__/expose-2fa-warning.test.ts +11 -8
  17. package/src/__tests__/expose-cloudflare.test.ts +5 -4
  18. package/src/__tests__/expose.test.ts +10 -5
  19. package/src/__tests__/hub-origin-resolution.test.ts +179 -25
  20. package/src/__tests__/hub-server.test.ts +628 -13
  21. package/src/__tests__/hub-unit.test.ts +4 -0
  22. package/src/__tests__/invites.test.ts +220 -0
  23. package/src/__tests__/launchctl-guard.test.ts +185 -0
  24. package/src/__tests__/migrate-cutover.test.ts +32 -0
  25. package/src/__tests__/module-ops-client.test.ts +68 -0
  26. package/src/__tests__/scope-explanations.test.ts +16 -0
  27. package/src/__tests__/serve-boot.test.ts +74 -1
  28. package/src/__tests__/serve.test.ts +121 -7
  29. package/src/__tests__/spawn-path.test.ts +191 -0
  30. package/src/__tests__/status.test.ts +64 -0
  31. package/src/__tests__/supervisor.test.ts +177 -0
  32. package/src/__tests__/users.test.ts +27 -0
  33. package/src/account-home-ui.ts +82 -9
  34. package/src/account-setup.ts +342 -0
  35. package/src/account-usage.ts +118 -0
  36. package/src/account-vault-admin-token.ts +242 -0
  37. package/src/account-vault-token.ts +27 -2
  38. package/src/admin-login-ui.ts +94 -0
  39. package/src/admin-vault-admin-token.ts +8 -2
  40. package/src/admin-vaults.ts +137 -29
  41. package/src/api-account.ts +54 -1
  42. package/src/api-invites.ts +347 -0
  43. package/src/api-mint-token.ts +81 -0
  44. package/src/api-modules-ops.ts +168 -53
  45. package/src/api-modules.ts +36 -0
  46. package/src/auto-wire.ts +87 -0
  47. package/src/cli.ts +122 -32
  48. package/src/commands/expose-2fa-warning.ts +17 -13
  49. package/src/commands/migrate-cutover.ts +12 -5
  50. package/src/commands/serve-boot.ts +33 -3
  51. package/src/commands/serve.ts +158 -37
  52. package/src/commands/status.ts +9 -1
  53. package/src/hub-db.ts +70 -2
  54. package/src/hub-server.ts +399 -41
  55. package/src/hub-unit.ts +4 -9
  56. package/src/invites.ts +291 -0
  57. package/src/launchctl-guard.ts +131 -0
  58. package/src/managed-unit.ts +13 -3
  59. package/src/migrate-offer.ts +15 -6
  60. package/src/module-ops-client.ts +47 -22
  61. package/src/scope-attenuation.ts +19 -0
  62. package/src/scope-explanations.ts +9 -1
  63. package/src/service-spec.ts +8 -3
  64. package/src/spawn-path.ts +148 -0
  65. package/src/supervisor.ts +84 -7
  66. package/src/users.ts +42 -4
  67. package/src/vault-hub-origin-env.ts +28 -0
  68. package/src/vault-name.ts +13 -1
  69. package/web/ui/dist/assets/{index-mz8XcVPP.css → index-BYYUeLGA.css} +1 -1
  70. package/web/ui/dist/assets/index-D3cDUOOj.js +61 -0
  71. package/web/ui/dist/index.html +2 -2
  72. package/web/ui/dist/assets/index-D_0TRjeo.js +0 -61
@@ -0,0 +1,242 @@
1
+ /**
2
+ * `POST /account/vault-admin-token/<name>` — friend-facing vault ADMIN deep-link.
3
+ *
4
+ * The non-admin sibling of `GET /admin/vault-admin-token/<name>`
5
+ * (`admin-vault-admin-token.ts`). Both mint a `vault:<name>:admin` JWT and
6
+ * hand it to the vault's own admin SPA via a `#token=<jwt>` URL fragment — the
7
+ * SPA reads `location.hash` on bootstrap, then strips it. The admin sibling is
8
+ * gated `isFirstAdmin` and returns JSON for the hub SPA's fetch; THIS surface
9
+ * is gated on ASSIGNMENT (an individual user holds `admin` on a vault they're
10
+ * assigned to) and is a server-rendered POST → 303 redirect, matching the
11
+ * no-JS posture of the rest of `/account/*`.
12
+ *
13
+ * Why this exists: an assigned user is ALREADY authorized for `vault:<name>:admin`
14
+ * (`vaultVerbsForUserVault` returns `["read","write","admin"]` for their vault,
15
+ * 2026-05-30; vault gates mirror/backup config + token rotation on that scope).
16
+ * The authority existed; the only missing piece was a friend-reachable HTTP
17
+ * unlock + a button. With this, an individual user can open their vault's admin
18
+ * SPA — token mint/revoke, **and the Git-backup / mirror config** — without
19
+ * host-admin. The deep-link lands on the vault admin home (`managementUrl`,
20
+ * `/admin/` by default), whose "Git backup →" link reaches `VaultMirror`.
21
+ *
22
+ * Authorization — the same three-gate spine as `/account/vault-token/<name>`
23
+ * (`account-vault-token.ts`), here pinned to the single `admin` verb:
24
+ *
25
+ * 1. **Session.** A valid hub session cookie (the user's). No session → 401.
26
+ * 2. **Assignment.** `<name>` MUST be one of the user's `user_vaults`
27
+ * assignments AND that assignment's role must grant `admin`. Read via
28
+ * `vaultVerbsForUserVault` (`null` for an unassigned vault → 403; a role
29
+ * that doesn't grant `admin` → 403). The first admin (empty
30
+ * `assignedVaults`, no `user_vaults` rows) gets `null` here too — by
31
+ * design, the same as `/account/vault-token`: admins use the SPA's
32
+ * `/admin/vault-admin-token` path, not this friend surface.
33
+ *
34
+ * CSRF: double-submit cookie, same `__csrf` field + `verifyCsrfToken` as
35
+ * `/account/vault-token`, `/account/change-password`, and `/logout`. A
36
+ * cross-site POST without the matching cookie/form token → 400.
37
+ *
38
+ * Force-change-password gate (item F / hub#469): if `!user.passwordChanged`,
39
+ * 303 → `/account/change-password` BEFORE minting — identical to the gate
40
+ * `/account/vault-token` applies post-#550. An admin-created/reset user lands
41
+ * with a temp password and `password_changed: false`; without this gate they
42
+ * could mint a `vault:<name>:admin` deep-link token (10-min TTL, but still) and
43
+ * keep using the temp password instead of rotating it. Placed AFTER the
44
+ * authority gates (so an unassigned/garbage request still gets its 403/400) and
45
+ * BEFORE the mint. This does NOT reintroduce the bypass #469 closed.
46
+ *
47
+ * Mint: `signAccessToken` (the same machinery the OAuth issuer + admin paths
48
+ * use) with `scopes: ["vault:<name>:admin"]`, `audience: "vault.<name>"`,
49
+ * `iss`: the hub origin, `sub`: the user id, `vaultScope: [<name>]`, and the
50
+ * SAME short 10-min TTL as the admin sibling (`VAULT_ADMIN_TOKEN_TTL_SECONDS`):
51
+ * this is a deep-link bootstrap token, not a long-lived headless credential
52
+ * (that's `/account/vault-token`). A `tokens` registry row records it
53
+ * (`created_via='cli_mint'`, `userId` = the user) so it's revocable.
54
+ *
55
+ * Response: 303 → `<vault-url><managementUrl>#token=<jwt>`. 303 (See Other) so
56
+ * the browser re-issues the navigation as GET. The token rides the URL
57
+ * fragment, which is never sent to the server — same contract the hub SPA's
58
+ * `VaultsList` "Manage" button uses (vault PR #219).
59
+ */
60
+ import type { Database } from "bun:sqlite";
61
+ import { renderAdminError } from "./admin-login-ui.ts";
62
+ import { VAULT_ADMIN_TOKEN_TTL_SECONDS } from "./admin-vault-admin-token.ts";
63
+ import { CSRF_FIELD_NAME, verifyCsrfToken } from "./csrf.ts";
64
+ import { recordTokenMint, signAccessToken } from "./jwt-sign.ts";
65
+ import { findActiveSession } from "./sessions.ts";
66
+ import { getUserById, vaultVerbsForUserVault } from "./users.ts";
67
+ import { VAULT_NAME_CHARSET_RE } from "./vault-name.ts";
68
+
69
+ /** Lowercase-only vault-name charset — single source of truth in vault-name.ts. */
70
+ const VAULT_NAME_RE = VAULT_NAME_CHARSET_RE;
71
+ /** client_id stamped on the minted JWT + registry row. Matches the admin sibling. */
72
+ const ACCOUNT_VAULT_ADMIN_CLIENT_ID = "parachute-hub-spa";
73
+
74
+ export interface AccountVaultAdminTokenDeps {
75
+ db: Database;
76
+ /** Hub origin for this request — `iss` of the minted token + base of the redirect URL. */
77
+ hubOrigin: string;
78
+ /**
79
+ * The vault's declared `managementUrl` (from its `.parachute/module.json`),
80
+ * resolved by the route handler at request time. Either an absolute URL or a
81
+ * path relative to the vault's mounted URL. Defaults to `/admin/` (vault's
82
+ * canonical value) when the handler can't resolve one — that's where the
83
+ * admin sibling's deep-link lands too.
84
+ */
85
+ managementUrl?: string;
86
+ /** Test seam for the clock (mint). */
87
+ now?: () => Date;
88
+ }
89
+
90
+ function htmlResponse(body: string, status = 200, extra: Record<string, string> = {}): Response {
91
+ return new Response(body, {
92
+ status,
93
+ headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", ...extra },
94
+ });
95
+ }
96
+
97
+ /**
98
+ * Resolve a vault's `managementUrl` against the vault's hub-mounted URL.
99
+ * Absolute URL → returned verbatim; path → joined onto the vault URL after
100
+ * trimming a trailing slash. Mirrors `resolveManagementUrl` in the SPA's
101
+ * `web/ui/src/lib/api.ts` so hub-server and SPA deep-links agree.
102
+ */
103
+ function resolveManagementUrl(vaultUrl: string, managementUrl: string): string {
104
+ if (/^https?:\/\//i.test(managementUrl)) return managementUrl;
105
+ const base = vaultUrl.replace(/\/+$/, "");
106
+ const tail = managementUrl.startsWith("/") ? managementUrl : `/${managementUrl}`;
107
+ return `${base}${tail}`;
108
+ }
109
+
110
+ export async function handleAccountVaultAdminTokenPost(
111
+ req: Request,
112
+ vaultName: string,
113
+ deps: AccountVaultAdminTokenDeps,
114
+ ): Promise<Response> {
115
+ if (req.method !== "POST") {
116
+ return htmlResponse("method not allowed", 405);
117
+ }
118
+
119
+ // Gate 1 — session. No identity, no mint.
120
+ const session = findActiveSession(deps.db, req);
121
+ if (!session) {
122
+ return htmlResponse(
123
+ renderAdminError({
124
+ title: "Not signed in",
125
+ message: "Please sign in before opening your vault's admin tools.",
126
+ }),
127
+ 401,
128
+ );
129
+ }
130
+ const user = getUserById(deps.db, session.userId);
131
+ if (!user) {
132
+ return htmlResponse(
133
+ renderAdminError({
134
+ title: "Account not found",
135
+ message: "The signed-in account no longer exists. Please sign in again.",
136
+ }),
137
+ 401,
138
+ );
139
+ }
140
+
141
+ // CSRF — verify before any state change, same shape + 400 as the other
142
+ // `/account/*` POSTs.
143
+ const form = await req.formData();
144
+ const formCsrf = form.get(CSRF_FIELD_NAME);
145
+ if (!verifyCsrfToken(req, typeof formCsrf === "string" ? formCsrf : null)) {
146
+ return htmlResponse(
147
+ renderAdminError({
148
+ title: "Invalid form submission",
149
+ message: "The form's CSRF token did not match. Reload the page and try again.",
150
+ }),
151
+ 400,
152
+ );
153
+ }
154
+
155
+ // Vault-name shape guard — reject anything that can't be a services.json key
156
+ // before any DB / authority work. Same posture as the other vault-token mints.
157
+ if (!VAULT_NAME_RE.test(vaultName)) {
158
+ return htmlResponse(
159
+ renderAdminError({
160
+ title: "Invalid vault name",
161
+ message: `"${vaultName}" is not a valid vault name.`,
162
+ }),
163
+ 400,
164
+ );
165
+ }
166
+
167
+ // Gate 2 — assignment + admin-verb cap. `vaultVerbsForUserVault` returns:
168
+ // - null → no assignment for this vault → 403.
169
+ // - [...] → the verbs the assignment role permits; must include `admin`.
170
+ // Assigned users hold read/write/admin on their vault (2026-05-30); the first
171
+ // admin has no `user_vaults` rows so gets `null` here and a 403 — by design,
172
+ // admins use the SPA path. This is the cap to the user's actual authority.
173
+ const allowed = vaultVerbsForUserVault(deps.db, user.id, vaultName);
174
+ if (allowed === null || !allowed.includes("admin")) {
175
+ return htmlResponse(
176
+ renderAdminError({
177
+ title: "Not your vault to manage",
178
+ message: `You don't have admin access to a vault named "${vaultName}". Ask the hub operator if you think this is wrong.`,
179
+ }),
180
+ 403,
181
+ );
182
+ }
183
+
184
+ // Force-change-password gate (item F / hub#469). An admin-created/reset user
185
+ // with `password_changed: false` is sent to the change-password rail BEFORE
186
+ // minting — same gate `/account/vault-token` applies, so a temp-password
187
+ // handoff can't be parlayed into a vault-admin deep-link. Placed AFTER the
188
+ // authority gates (an unassigned/garbage request still gets its 403/400) and
189
+ // BEFORE the mint. 303 so the browser re-issues as GET. Does NOT reintroduce
190
+ // the bypass #469 closed.
191
+ if (!user.passwordChanged) {
192
+ return new Response(null, {
193
+ status: 303,
194
+ headers: { location: "/account/change-password", "cache-control": "no-store" },
195
+ });
196
+ }
197
+
198
+ // Mint the vault-admin deep-link token. Short TTL (10 min, matching the admin
199
+ // sibling) — it's a bootstrap token the vault SPA trades for its session, not
200
+ // a long-lived headless credential (that's `/account/vault-token`).
201
+ const scope = `vault:${vaultName}:admin`;
202
+ const audience = `vault.${vaultName}`;
203
+ const minted = await signAccessToken(deps.db, {
204
+ sub: user.id,
205
+ scopes: [scope],
206
+ audience,
207
+ clientId: ACCOUNT_VAULT_ADMIN_CLIENT_ID,
208
+ issuer: deps.hubOrigin,
209
+ ttlSeconds: VAULT_ADMIN_TOKEN_TTL_SECONDS,
210
+ vaultScope: [vaultName],
211
+ ...(deps.now !== undefined ? { now: deps.now } : {}),
212
+ });
213
+
214
+ recordTokenMint(deps.db, {
215
+ jti: minted.jti,
216
+ createdVia: "cli_mint",
217
+ subject: user.id,
218
+ // Anchor the registry row to the user's id so the operator's token registry
219
+ // + the revocation list attribute it correctly.
220
+ userId: user.id,
221
+ clientId: ACCOUNT_VAULT_ADMIN_CLIENT_ID,
222
+ scopes: [scope],
223
+ expiresAt: minted.expiresAt,
224
+ ...(deps.now !== undefined ? { now: deps.now } : {}),
225
+ });
226
+
227
+ // Build the redirect target: <vault-url><managementUrl>#token=<jwt>. The
228
+ // vault URL is the hub-mounted path (`<hubOrigin>/vault/<name>`); the
229
+ // managementUrl (default `/admin/`) is the vault admin SPA entry point. The
230
+ // JWT rides the URL fragment — never sent to the server — exactly as the hub
231
+ // SPA's "Manage" button does (vault PR #219).
232
+ const trimmedOrigin = deps.hubOrigin.replace(/\/+$/, "");
233
+ const vaultUrl = `${trimmedOrigin}/vault/${vaultName}`;
234
+ const target = resolveManagementUrl(vaultUrl, deps.managementUrl ?? "/admin/");
235
+ const sep = target.includes("#") ? "&" : "#";
236
+ const location = `${target}${sep}token=${minted.token}`;
237
+
238
+ return new Response(null, {
239
+ status: 303,
240
+ headers: { location, "cache-control": "no-store" },
241
+ });
242
+ }
@@ -75,9 +75,15 @@ import { vaultTokenMintRateLimiter } from "./rate-limit.ts";
75
75
  import { findActiveSession } from "./sessions.ts";
76
76
  import { isTotpEnrolled } from "./two-factor-store.ts";
77
77
  import { type VaultVerb, getUserById, isFirstAdmin, vaultVerbsForUserVault } from "./users.ts";
78
+ import { VAULT_NAME_CHARSET_RE } from "./vault-name.ts";
78
79
 
79
- /** Matches the manifest vault-name validator + `/admin/vault-admin-token`. */
80
- const VAULT_NAME_RE = /^[a-zA-Z0-9_-]+$/;
80
+ /**
81
+ * Lowercase-only vault-name charset (item I) — single source of truth in
82
+ * vault-name.ts, matching what vault's init enforces. Was `[a-zA-Z0-9_-]`; the
83
+ * uppercase superset let a mint name drift from vault's lowercase URL-derived
84
+ * name, so the minted token's `vault.<Name>` audience wouldn't validate.
85
+ */
86
+ const VAULT_NAME_RE = VAULT_NAME_CHARSET_RE;
81
87
  /** Verbs this surface will ever mint. `admin` is deliberately absent. */
82
88
  const ALLOWED_VERBS: readonly VaultVerb[] = ["read", "write", "admin"];
83
89
  /** client_id stamped on the minted JWT + registry row. */
@@ -233,6 +239,25 @@ export async function handleAccountVaultTokenPost(
233
239
  });
234
240
  }
235
241
 
242
+ // Force-change-password gate (item F / hub#469, NARROW). A user the admin
243
+ // created/reset lands with `password_changed: false` and an admin-known temp
244
+ // password. Without this gate an authorized friend could mint a LONG-LIVED
245
+ // vault token here and then keep using (or never rotate) the temp password —
246
+ // the token outlives any later rotation, defeating the "temp password is a
247
+ // one-time handoff" model. So an authorized-but-unrotated friend is sent to
248
+ // the change-password rail BEFORE minting anything. Placed AFTER the
249
+ // authority gates (so an unassigned/garbage request still gets its 403/400,
250
+ // preserving those semantics) and BEFORE the rate-limit + mint. 303 (See
251
+ // Other) so the browser re-issues as GET. Narrow #469 fix — gates
252
+ // token-minting specifically; the broad per-request /account/* wall is
253
+ // deferred to Aaron's design call.
254
+ if (!user.passwordChanged) {
255
+ return new Response(null, {
256
+ status: 303,
257
+ headers: { location: "/account/change-password", "cache-control": "no-store" },
258
+ });
259
+ }
260
+
236
261
  // Rate limit — after CSRF + authority shape, before the mint. Per-user.
237
262
  const rlNow = (deps.now ?? (() => new Date()))();
238
263
  const gate = vaultTokenMintRateLimiter.checkAndRecord(user.id, rlNow);
@@ -152,6 +152,100 @@ export function renderTotpChallenge(props: TotpChallengeProps): string {
152
152
  return baseDocument("Two-factor authentication — Parachute", body);
153
153
  }
154
154
 
155
+ // --- invite redemption ( /account/setup/<token> ) --------------------------
156
+
157
+ export interface InviteSetupProps {
158
+ /** The raw token from the URL — POSTs back to the same path. */
159
+ token: string;
160
+ csrfToken: string;
161
+ /**
162
+ * When the invite pins a vault name the redeemer can't choose one — we show
163
+ * it read-only. When null the redeemer names their own vault (a text field).
164
+ */
165
+ pinnedVaultName: string | null;
166
+ /** Whether redemption provisions a vault at all (shows the vault row iff true). */
167
+ provisionVault: boolean;
168
+ username?: string;
169
+ vaultName?: string;
170
+ errorMessage?: string;
171
+ }
172
+
173
+ /**
174
+ * Server-rendered "claim your invite" form for `/account/setup/<token>`.
175
+ * Stands alone without the SPA (same posture as `/login` + the setup wizard):
176
+ * a brand-new invitee hits this with no session and no JS. Posts back to the
177
+ * same `/account/setup/<token>` path. Reuses the shared login chrome.
178
+ */
179
+ export function renderInviteSetup(props: InviteSetupProps): string {
180
+ const { token, csrfToken, pinnedVaultName, provisionVault, username, vaultName, errorMessage } =
181
+ props;
182
+ const error = errorMessage ? `<p class="error-banner">${escapeHtml(errorMessage)}</p>` : "";
183
+ const usernameAttr = username ? ` value="${escapeAttr(username)}"` : "";
184
+
185
+ // Vault row: shown only when the invite provisions a vault. Pinned → a
186
+ // read-only display of the name the admin chose (redeemer can't squat names).
187
+ // Unpinned → a text field the redeemer fills.
188
+ let vaultRow = "";
189
+ if (provisionVault) {
190
+ if (pinnedVaultName !== null) {
191
+ vaultRow = `
192
+ <label class="field">
193
+ <span class="field-label">Your vault</span>
194
+ <input type="text" value="${escapeAttr(pinnedVaultName)}" readonly disabled />
195
+ <span class="field-hint">Your hub operator named this vault for you.</span>
196
+ </label>`;
197
+ } else {
198
+ const vaultAttr = vaultName ? ` value="${escapeAttr(vaultName)}"` : "";
199
+ vaultRow = `
200
+ <label class="field">
201
+ <span class="field-label">Name your vault</span>
202
+ <input type="text" name="vault_name" autocomplete="off"
203
+ required minlength="2" maxlength="32"
204
+ pattern="[a-z0-9_-]+" title="lowercase letters, digits, _ - (2–32 chars)"
205
+ spellcheck="false" autocapitalize="off"${vaultAttr} />
206
+ <span class="field-hint">lowercase letters, digits, <code>_</code>, <code>-</code> (2–32 chars)</span>
207
+ </label>`;
208
+ }
209
+ }
210
+
211
+ const body = `
212
+ <div class="card">
213
+ <div class="card-header">
214
+ ${header()}
215
+ <h1>Claim your invite</h1>
216
+ <p class="subtitle">Pick a username and password to create your Parachute account${
217
+ provisionVault ? " and your own vault" : ""
218
+ }.</p>
219
+ </div>
220
+ ${error}
221
+ <form method="POST" action="/account/setup/${escapeAttr(token)}" class="auth-form">
222
+ ${renderCsrfHiddenInput(csrfToken)}
223
+ <label class="field">
224
+ <span class="field-label">Username</span>
225
+ <input type="text" name="username" autocomplete="username" autofocus
226
+ required minlength="2" maxlength="32"
227
+ pattern="[a-z0-9_-]+" title="lowercase letters, digits, _ - (2–32 chars)"
228
+ spellcheck="false" autocapitalize="off"${usernameAttr} />
229
+ <span class="field-hint">lowercase letters, digits, <code>_</code>, <code>-</code></span>
230
+ </label>
231
+ <label class="field">
232
+ <span class="field-label">Password</span>
233
+ <input type="password" name="password" autocomplete="new-password"
234
+ required minlength="12" />
235
+ <span class="field-hint">at least 12 characters</span>
236
+ </label>
237
+ <label class="field">
238
+ <span class="field-label">Confirm password</span>
239
+ <input type="password" name="password_confirm" autocomplete="new-password"
240
+ required minlength="12" />
241
+ </label>
242
+ ${vaultRow}
243
+ <button type="submit" class="btn btn-primary">Create my account</button>
244
+ </form>
245
+ </div>`;
246
+ return baseDocument("Claim your invite — Parachute", body);
247
+ }
248
+
155
249
  // --- error page ------------------------------------------------------------
156
250
 
157
251
  export function renderAdminError(props: { title: string; message: string }): string {
@@ -28,13 +28,19 @@ import type { Database } from "bun:sqlite";
28
28
  import { signAccessToken } from "./jwt-sign.ts";
29
29
  import { findSession, parseSessionCookie } from "./sessions.ts";
30
30
  import { isFirstAdmin } from "./users.ts";
31
+ import { VAULT_NAME_CHARSET_RE } from "./vault-name.ts";
31
32
 
32
33
  /** Short TTL — matches host-admin-token. SPA re-fetches on near-expiry. */
33
34
  export const VAULT_ADMIN_TOKEN_TTL_SECONDS = 10 * 60;
34
35
  const VAULT_ADMIN_CLIENT_ID = "parachute-hub-spa";
35
36
 
36
- /** Same shape as the manifest name validator — keeps URL-injection out. */
37
- const VAULT_NAME_RE = /^[a-zA-Z0-9_-]+$/;
37
+ /**
38
+ * Lowercase-only vault-name charset (item I) — single source of truth in
39
+ * vault-name.ts, matching vault's init. Keeps URL-injection out AND closes the
40
+ * case-drift class (an uppercased name minting `vault.<Name>` audience that
41
+ * vault's lowercase URL-derived name would never validate).
42
+ */
43
+ const VAULT_NAME_RE = VAULT_NAME_CHARSET_RE;
38
44
 
39
45
  export interface MintVaultAdminTokenDeps {
40
46
  db: Database;
@@ -59,6 +59,8 @@ import type { Database } from "bun:sqlite";
59
59
  import { type AdminAuthError, adminAuthErrorResponse, requireScope } from "./admin-auth.ts";
60
60
  import { SERVICES_MANIFEST_PATH } from "./config.ts";
61
61
  import { findService, type readManifest, readManifestLenient } from "./services-manifest.ts";
62
+ import { enrichedPath } from "./spawn-path.ts";
63
+ import { VAULT_NAME_CHARSET_RE } from "./vault-name.ts";
62
64
  import { type WellKnownVaultEntry, isVaultEntry, vaultInstanceNameFor } from "./well-known.ts";
63
65
 
64
66
  /** Scope required to call POST /vaults. */
@@ -72,7 +74,12 @@ export const HOST_ADMIN_SCOPE = "parachute:host:admin";
72
74
  * the hub rejects them at the API edge before a vault under those names
73
75
  * can register and capture the proxy path.
74
76
  */
75
- const VAULT_NAME_PATTERN = /^[a-zA-Z0-9_-]+$/;
77
+ // Lowercase-only (item I) — single source of truth in vault-name.ts. Vault's
78
+ // init enforces `[a-z0-9_-]`; a hub-side `[a-zA-Z0-9_-]` superset let an
79
+ // uppercased name through that vault would then lowercase or reject, drifting
80
+ // the hub's idea of the vault from vault's. The hub-only reservations (`new`,
81
+ // `assets`) shadow SPA routes and stay on top of vault's `list`.
82
+ const VAULT_NAME_PATTERN = VAULT_NAME_CHARSET_RE;
76
83
  const RESERVED_VAULT_NAMES = new Set(["list", "new", "assets"]);
77
84
 
78
85
  export interface CreateVaultRequest {
@@ -164,7 +171,7 @@ async function parseBody(req: Request): Promise<ParseResult | ParseError> {
164
171
  return {
165
172
  ok: false,
166
173
  status: 400,
167
- message: "vault name must contain only letters, numbers, hyphens, and underscores",
174
+ message: "vault name must contain only lowercase letters, numbers, hyphens, and underscores",
168
175
  };
169
176
  }
170
177
  if (RESERVED_VAULT_NAMES.has(name)) {
@@ -230,9 +237,13 @@ function buildEntry(
230
237
  async function defaultRunCommand(cmd: readonly string[]): Promise<RunResult> {
231
238
  // Inherit env so the child sees PATH, HOME, BUN_INSTALL, etc. Bun.spawn
232
239
  // defaults to empty env — see api-modules-ops.ts:defaultRun for the rationale.
240
+ // PATH enrichment (hub launchd-PATH regression): a launchd-managed hub bakes
241
+ // a minimal PATH, so this shell-out (vault ops) inherits that thin PATH too;
242
+ // enrich it with operator-tool dirs (`$HOME/.local/bin`, brew bin). See
243
+ // `spawn-path.ts`.
233
244
  const proc = Bun.spawn([...cmd], {
234
245
  stdio: ["ignore", "pipe", "pipe"],
235
- env: process.env,
246
+ env: { ...process.env, PATH: enrichedPath() },
236
247
  });
237
248
  // Drain both pipes in parallel — leaving stderr unread can deadlock long
238
249
  // installs once the OS pipe buffer fills (#97). Captured stderr is folded
@@ -268,10 +279,17 @@ async function orchestrate(
268
279
  manifestPath: string,
269
280
  name: string,
270
281
  runCommand: (cmd: readonly string[]) => Promise<RunResult>,
282
+ opts: { noMirror?: boolean } = {},
271
283
  ): Promise<OrchestrateOk | OrchestrateError> {
272
284
  const vaultRegistered = findService("parachute-vault", manifestPath) !== undefined;
285
+ // `--no-mirror` opts this create out of the default internal live mirror
286
+ // (§3 default_mirror knob). Only meaningful on the `create` branch — the
287
+ // bootstrap `install` path provisions the default vault and follows the
288
+ // server-wide knob.
273
289
  const cmd = vaultRegistered
274
- ? ["parachute-vault", "create", name, "--json"]
290
+ ? opts.noMirror
291
+ ? ["parachute-vault", "create", name, "--json", "--no-mirror"]
292
+ : ["parachute-vault", "create", name, "--json"]
275
293
  : ["parachute", "install", "vault"];
276
294
  let result: RunResult;
277
295
  try {
@@ -320,6 +338,102 @@ async function orchestrate(
320
338
  return { ok: true, createJson };
321
339
  }
322
340
 
341
+ /**
342
+ * Result of {@link provisionVault} — the programmatic vault-provisioning
343
+ * core shared by the authed HTTP handler and the invite-redeem path.
344
+ *
345
+ * - `created: true` — a fresh vault was provisioned this call. `entry`
346
+ * describes it; `createJson` (when present) carries the single-emit
347
+ * bootstrap creds.
348
+ * - `created: false` — the vault already existed (idempotent). `entry`
349
+ * describes the existing vault; no `createJson`.
350
+ */
351
+ export type ProvisionVaultResult =
352
+ | {
353
+ ok: true;
354
+ created: boolean;
355
+ entry: WellKnownVaultEntry;
356
+ createJson: VaultCreateJson | null;
357
+ }
358
+ | { ok: false; status: number; message: string };
359
+
360
+ /**
361
+ * Provision (or no-op if it exists) a vault by name — the auth-free core
362
+ * lifted out of {@link handleCreateVault} so the invite-redeem flow can
363
+ * provision a vault for a freshly-created account WITHOUT a host:admin
364
+ * bearer (the redeemer holds no admin authority; the invite IS the
365
+ * authorization). The HTTP handler keeps the host:admin gate in front of
366
+ * this; the invite path calls it directly after validating the invite.
367
+ *
368
+ * Idempotent-safe: if the vault already exists this returns
369
+ * `created:false` with the existing entry rather than re-running the CLI —
370
+ * a redeem retry (or a name collision) lands the user on the existing
371
+ * vault instead of erroring.
372
+ *
373
+ * Name validation matches `parachute-vault create`'s rules. Callers that
374
+ * accept an untrusted name (the invite redeemer naming their own vault)
375
+ * MUST pass it through here so the same regex + reserved-name gate the
376
+ * `/vaults` API edge enforces applies.
377
+ */
378
+ export async function provisionVault(
379
+ name: string,
380
+ deps: {
381
+ issuer: string;
382
+ manifestPath?: string;
383
+ runCommand?: CreateVaultDeps["runCommand"];
384
+ /** `'off'` appends `--no-mirror`; anything else follows the server knob. */
385
+ defaultMirror?: string;
386
+ },
387
+ ): Promise<ProvisionVaultResult> {
388
+ const manifestPath = deps.manifestPath ?? SERVICES_MANIFEST_PATH;
389
+ const runCommand = deps.runCommand ?? defaultRunCommand;
390
+
391
+ if (!VAULT_NAME_PATTERN.test(name)) {
392
+ return {
393
+ ok: false,
394
+ status: 400,
395
+ message: "vault name must contain only lowercase letters, numbers, hyphens, and underscores",
396
+ };
397
+ }
398
+ if (RESERVED_VAULT_NAMES.has(name)) {
399
+ return { ok: false, status: 400, message: `"${name}" is a reserved vault name` };
400
+ }
401
+
402
+ // Idempotency: if the vault already exists, return the existing entry.
403
+ const existing = findExistingVault(manifestPath, name);
404
+ if (existing) {
405
+ return {
406
+ ok: true,
407
+ created: false,
408
+ entry: buildEntry(name, existing.path, existing.version, deps.issuer),
409
+ createJson: null,
410
+ };
411
+ }
412
+
413
+ const result = await orchestrate(manifestPath, name, runCommand, {
414
+ noMirror: deps.defaultMirror === "off",
415
+ });
416
+ if (!result.ok) {
417
+ return { ok: false, status: result.status, message: result.message };
418
+ }
419
+
420
+ // Re-read services.json: the CLI just wrote it.
421
+ const created = findExistingVault(manifestPath, name);
422
+ if (!created) {
423
+ return {
424
+ ok: false,
425
+ status: 500,
426
+ message: `vault "${name}" was provisioned but is not in services.json — manual recovery required`,
427
+ };
428
+ }
429
+ return {
430
+ ok: true,
431
+ created: true,
432
+ entry: buildEntry(name, created.path, created.version, deps.issuer),
433
+ createJson: result.createJson,
434
+ };
435
+ }
436
+
323
437
  export async function handleCreateVault(req: Request, deps: CreateVaultDeps): Promise<Response> {
324
438
  if (req.method !== "POST") {
325
439
  return new Response("method not allowed", { status: 405 });
@@ -341,35 +455,29 @@ export async function handleCreateVault(req: Request, deps: CreateVaultDeps): Pr
341
455
  }
342
456
  const { name } = parsed.body;
343
457
 
344
- // Idempotency: if the vault already exists, return 200 + existing entry.
345
- // Skip the CLI shell-out — re-POST is usually a UI retry.
346
- const existing = findExistingVault(manifestPath, name);
347
- if (existing) {
348
- return new Response(
349
- JSON.stringify(buildEntry(name, existing.path, existing.version, deps.issuer)),
350
- {
351
- status: 200,
352
- headers: { "content-type": "application/json" },
353
- },
354
- );
355
- }
356
-
357
- const result = await orchestrate(manifestPath, name, runCommand);
358
- if (!result.ok) {
359
- return jsonError(result.status, "server_error", result.message);
458
+ const provisioned = await provisionVault(name, {
459
+ issuer: deps.issuer,
460
+ manifestPath,
461
+ runCommand,
462
+ });
463
+ if (!provisioned.ok) {
464
+ // parseBody already enforced the name regex/reserved gate, so a
465
+ // provisionVault 400 here would be a redundant re-check; map any
466
+ // non-ok to its status. invalid_request for 400, server_error otherwise.
467
+ const error = provisioned.status === 400 ? "invalid_request" : "server_error";
468
+ return jsonError(provisioned.status, error, provisioned.message);
360
469
  }
361
470
 
362
- // Re-read services.json: the CLI just wrote it.
363
- const created = findExistingVault(manifestPath, name);
364
- if (!created) {
365
- return jsonError(
366
- 500,
367
- "server_error",
368
- `vault "${name}" was provisioned but is not in services.json — manual recovery required`,
369
- );
471
+ // Idempotent re-POST: existing vault 200, no single-emit creds.
472
+ if (!provisioned.created) {
473
+ return new Response(JSON.stringify(provisioned.entry), {
474
+ status: 200,
475
+ headers: { "content-type": "application/json" },
476
+ });
370
477
  }
371
478
 
372
- const entry = buildEntry(name, created.path, created.version, deps.issuer);
479
+ const entry = provisioned.entry;
480
+ const result = { createJson: provisioned.createJson };
373
481
  // Access token (a `vault:<name>:admin` JWT, possibly empty post-DROP) +
374
482
  // filesystem paths are single-emit at create time. We surface them here so
375
483
  // the caller can immediately bootstrap a connection to the new vault.