@openparachute/hub 0.6.3 → 0.6.4-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 (97) hide show
  1. package/package.json +1 -2
  2. package/src/__tests__/account-home-ui.test.ts +344 -110
  3. package/src/__tests__/account-mirror.test.ts +156 -0
  4. package/src/__tests__/account-setup.test.ts +880 -0
  5. package/src/__tests__/account-usage.test.ts +137 -0
  6. package/src/__tests__/account-vault-admin-token.test.ts +301 -0
  7. package/src/__tests__/account-vault-token.test.ts +53 -1
  8. package/src/__tests__/admin-vault-admin-token.test.ts +17 -0
  9. package/src/__tests__/admin-vaults.test.ts +20 -0
  10. package/src/__tests__/api-account.test.ts +236 -4
  11. package/src/__tests__/api-invites.test.ts +217 -0
  12. package/src/__tests__/api-mint-token.test.ts +259 -10
  13. package/src/__tests__/api-modules-ops.test.ts +195 -3
  14. package/src/__tests__/api-modules.test.ts +40 -4
  15. package/src/__tests__/api-settings-hub-origin.test.ts +13 -8
  16. package/src/__tests__/auto-wire.test.ts +101 -1
  17. package/src/__tests__/cli.test.ts +188 -2
  18. package/src/__tests__/cloudflare-state.test.ts +104 -0
  19. package/src/__tests__/expose-2fa-warning.test.ts +11 -8
  20. package/src/__tests__/expose-cloudflare.test.ts +135 -9
  21. package/src/__tests__/expose-interactive.test.ts +234 -7
  22. package/src/__tests__/expose-supervisor-version.test.ts +104 -0
  23. package/src/__tests__/expose.test.ts +10 -5
  24. package/src/__tests__/grants.test.ts +197 -8
  25. package/src/__tests__/hub-origin-resolution.test.ts +179 -25
  26. package/src/__tests__/hub-server.test.ts +761 -13
  27. package/src/__tests__/hub-unit.test.ts +185 -0
  28. package/src/__tests__/init.test.ts +579 -3
  29. package/src/__tests__/install.test.ts +448 -2
  30. package/src/__tests__/invites.test.ts +220 -0
  31. package/src/__tests__/launchctl-guard.test.ts +185 -0
  32. package/src/__tests__/migrate-cutover.test.ts +33 -0
  33. package/src/__tests__/module-ops-client.test.ts +68 -0
  34. package/src/__tests__/scope-explanations.test.ts +16 -0
  35. package/src/__tests__/serve-boot.test.ts +74 -1
  36. package/src/__tests__/serve.test.ts +121 -7
  37. package/src/__tests__/setup-wizard.test.ts +110 -0
  38. package/src/__tests__/spawn-path.test.ts +191 -0
  39. package/src/__tests__/status.test.ts +64 -0
  40. package/src/__tests__/supervisor.test.ts +374 -0
  41. package/src/__tests__/users.test.ts +66 -0
  42. package/src/__tests__/well-known.test.ts +25 -0
  43. package/src/__tests__/wizard.test.ts +72 -1
  44. package/src/account-home-ui.ts +481 -235
  45. package/src/account-mirror.ts +126 -0
  46. package/src/account-setup.ts +381 -0
  47. package/src/account-usage.ts +118 -0
  48. package/src/account-vault-admin-token.ts +242 -0
  49. package/src/account-vault-token.ts +36 -2
  50. package/src/admin-login-ui.ts +121 -0
  51. package/src/admin-vault-admin-token.ts +8 -2
  52. package/src/admin-vaults.ts +137 -29
  53. package/src/api-account.ts +118 -1
  54. package/src/api-invites.ts +345 -0
  55. package/src/api-mint-token.ts +81 -0
  56. package/src/api-modules-ops.ts +168 -53
  57. package/src/api-modules.ts +36 -0
  58. package/src/auto-wire.ts +87 -0
  59. package/src/cli.ts +128 -34
  60. package/src/cloudflare/detect.ts +1 -1
  61. package/src/cloudflare/state.ts +104 -8
  62. package/src/commands/expose-2fa-warning.ts +17 -13
  63. package/src/commands/expose-cloudflare.ts +103 -36
  64. package/src/commands/expose-interactive.ts +163 -17
  65. package/src/commands/expose-supervisor.ts +45 -0
  66. package/src/commands/init.ts +183 -4
  67. package/src/commands/install.ts +321 -3
  68. package/src/commands/migrate-cutover.ts +12 -5
  69. package/src/commands/serve-boot.ts +33 -3
  70. package/src/commands/serve.ts +158 -37
  71. package/src/commands/status.ts +9 -1
  72. package/src/commands/wizard.ts +36 -2
  73. package/src/grants.ts +113 -0
  74. package/src/help.ts +18 -5
  75. package/src/hub-db.ts +70 -2
  76. package/src/hub-server.ts +438 -41
  77. package/src/hub-settings.ts +3 -3
  78. package/src/hub-unit.ts +259 -9
  79. package/src/invites.ts +291 -0
  80. package/src/launchctl-guard.ts +131 -0
  81. package/src/managed-unit.ts +13 -3
  82. package/src/migrate-offer.ts +15 -6
  83. package/src/module-ops-client.ts +47 -22
  84. package/src/scope-attenuation.ts +19 -0
  85. package/src/scope-explanations.ts +9 -1
  86. package/src/service-spec.ts +17 -4
  87. package/src/setup-wizard.ts +34 -2
  88. package/src/spawn-path.ts +148 -0
  89. package/src/supervisor.ts +232 -7
  90. package/src/users.ts +54 -8
  91. package/src/vault-hub-origin-env.ts +28 -0
  92. package/src/vault-name.ts +13 -1
  93. package/src/well-known.ts +13 -0
  94. package/web/ui/dist/assets/{index-mz8XcVPP.css → index-BYYUeLGA.css} +1 -1
  95. package/web/ui/dist/assets/index-D3cDUOOj.js +61 -0
  96. package/web/ui/dist/index.html +2 -2
  97. 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
+ }
@@ -69,15 +69,22 @@ import {
69
69
  } from "./account-home-ui.ts";
70
70
  import { renderAdminError } from "./admin-login-ui.ts";
71
71
  import { CSRF_FIELD_NAME, ensureCsrfToken, verifyCsrfToken } from "./csrf.ts";
72
+ import { userHasExternalAiGrant } from "./grants.ts";
72
73
  import { inferAudience } from "./jwt-audience.ts";
73
74
  import { recordTokenMint, signAccessToken } from "./jwt-sign.ts";
74
75
  import { vaultTokenMintRateLimiter } from "./rate-limit.ts";
75
76
  import { findActiveSession } from "./sessions.ts";
76
77
  import { isTotpEnrolled } from "./two-factor-store.ts";
77
78
  import { type VaultVerb, getUserById, isFirstAdmin, vaultVerbsForUserVault } from "./users.ts";
79
+ import { VAULT_NAME_CHARSET_RE } from "./vault-name.ts";
78
80
 
79
- /** Matches the manifest vault-name validator + `/admin/vault-admin-token`. */
80
- const VAULT_NAME_RE = /^[a-zA-Z0-9_-]+$/;
81
+ /**
82
+ * Lowercase-only vault-name charset (item I) — single source of truth in
83
+ * vault-name.ts, matching what vault's init enforces. Was `[a-zA-Z0-9_-]`; the
84
+ * uppercase superset let a mint name drift from vault's lowercase URL-derived
85
+ * name, so the minted token's `vault.<Name>` audience wouldn't validate.
86
+ */
87
+ const VAULT_NAME_RE = VAULT_NAME_CHARSET_RE;
81
88
  /** Verbs this surface will ever mint. `admin` is deliberately absent. */
82
89
  const ALLOWED_VERBS: readonly VaultVerb[] = ["read", "write", "admin"];
83
90
  /** client_id stamped on the minted JWT + registry row. */
@@ -183,6 +190,14 @@ export async function handleAccountVaultTokenPost(
183
190
  csrfToken: csrf.token,
184
191
  twoFactorEnabled: isTotpEnrolled(deps.db, user.id),
185
192
  mintableVerbs: buildMintableVerbs(deps.db, user.id, user.assignedVaults),
193
+ // hub#583: "connected" means an EXTERNAL AI/MCP client (Claude, Cursor,
194
+ // …) was wired to a vault — NOT a first-party browser sign-in. Notes /
195
+ // the admin SPA are OAuth clients too and write vault-scoped grants, so
196
+ // the old `userHasVaultGrant` lit "✓ You're connected" the moment the
197
+ // user opened Notes. `userHasExternalAiGrant` filters those out.
198
+ connectedVault: user.assignedVaults.some((v) =>
199
+ userHasExternalAiGrant(deps.db, user.id, v),
200
+ ),
186
201
  ...extras,
187
202
  }),
188
203
  status,
@@ -233,6 +248,25 @@ export async function handleAccountVaultTokenPost(
233
248
  });
234
249
  }
235
250
 
251
+ // Force-change-password gate (item F / hub#469, NARROW). A user the admin
252
+ // created/reset lands with `password_changed: false` and an admin-known temp
253
+ // password. Without this gate an authorized friend could mint a LONG-LIVED
254
+ // vault token here and then keep using (or never rotate) the temp password —
255
+ // the token outlives any later rotation, defeating the "temp password is a
256
+ // one-time handoff" model. So an authorized-but-unrotated friend is sent to
257
+ // the change-password rail BEFORE minting anything. Placed AFTER the
258
+ // authority gates (so an unassigned/garbage request still gets its 403/400,
259
+ // preserving those semantics) and BEFORE the rate-limit + mint. 303 (See
260
+ // Other) so the browser re-issues as GET. Narrow #469 fix — gates
261
+ // token-minting specifically; the broad per-request /account/* wall is
262
+ // deferred to Aaron's design call.
263
+ if (!user.passwordChanged) {
264
+ return new Response(null, {
265
+ status: 303,
266
+ headers: { location: "/account/change-password", "cache-control": "no-store" },
267
+ });
268
+ }
269
+
236
270
  // Rate limit — after CSRF + authority shape, before the mint. Per-user.
237
271
  const rlNow = (deps.now ?? (() => new Date()))();
238
272
  const gate = vaultTokenMintRateLimiter.checkAndRecord(user.id, rlNow);
@@ -152,6 +152,127 @@ 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
+ // OPTIONAL (not `required`): a blank submission defaults the vault name
200
+ // to the chosen username, resolved server-side (this form is no-JS, so
201
+ // the server is the source of truth). The inline script below is a
202
+ // progressive-enhancement pre-fill only.
203
+ vaultRow = `
204
+ <label class="field">
205
+ <span class="field-label">Name your vault</span>
206
+ <input type="text" name="vault_name" id="vault_name" autocomplete="off"
207
+ minlength="2" maxlength="32"
208
+ pattern="[a-z0-9_-]+" title="lowercase letters, digits, _ - (2–32 chars)"
209
+ spellcheck="false" autocapitalize="off"
210
+ placeholder="defaults to your username"${vaultAttr} />
211
+ <span class="field-hint">lowercase letters, digits, <code>_</code>, <code>-</code> (2–32 chars). Leave blank to use your username.</span>
212
+ </label>`;
213
+ }
214
+ }
215
+
216
+ // Progressive enhancement ONLY: mirror the username into the (empty) vault
217
+ // field as the user types, so they see the default before submitting. The
218
+ // server applies the same default with no JS (the field is optional), so
219
+ // this is purely cosmetic — it stops mirroring the moment the user edits the
220
+ // vault field directly. Shown only for the unpinned-provision case.
221
+ const vaultPrefillScript =
222
+ provisionVault && pinnedVaultName === null
223
+ ? `
224
+ <script>
225
+ (function () {
226
+ var u = document.getElementById("username");
227
+ var v = document.getElementById("vault_name");
228
+ if (!u || !v) return;
229
+ var dirty = v.value !== "";
230
+ v.addEventListener("input", function () { dirty = true; });
231
+ u.addEventListener("input", function () {
232
+ if (!dirty) v.value = u.value;
233
+ });
234
+ })();
235
+ </script>`
236
+ : "";
237
+
238
+ const body = `
239
+ <div class="card">
240
+ <div class="card-header">
241
+ ${header()}
242
+ <h1>Claim your invite</h1>
243
+ <p class="subtitle">Pick a username and password to create your Parachute account${
244
+ provisionVault ? " and your own vault" : ""
245
+ }.</p>
246
+ </div>
247
+ ${error}
248
+ <form method="POST" action="/account/setup/${escapeAttr(token)}" class="auth-form">
249
+ ${renderCsrfHiddenInput(csrfToken)}
250
+ <label class="field">
251
+ <span class="field-label">Username</span>
252
+ <input type="text" name="username" id="username" autocomplete="username" autofocus
253
+ required minlength="2" maxlength="32"
254
+ pattern="[a-z0-9_-]+" title="lowercase letters, digits, _ - (2–32 chars)"
255
+ spellcheck="false" autocapitalize="off"${usernameAttr} />
256
+ <span class="field-hint">lowercase letters, digits, <code>_</code>, <code>-</code></span>
257
+ </label>
258
+ <label class="field">
259
+ <span class="field-label">Password</span>
260
+ <input type="password" name="password" autocomplete="new-password"
261
+ required minlength="12" />
262
+ <span class="field-hint">at least 12 characters</span>
263
+ </label>
264
+ <label class="field">
265
+ <span class="field-label">Confirm password</span>
266
+ <input type="password" name="password_confirm" autocomplete="new-password"
267
+ required minlength="12" />
268
+ </label>
269
+ ${vaultRow}
270
+ <button type="submit" class="btn btn-primary">Create my account</button>
271
+ </form>
272
+ </div>${vaultPrefillScript}`;
273
+ return baseDocument("Claim your invite — Parachute", body);
274
+ }
275
+
155
276
  // --- error page ------------------------------------------------------------
156
277
 
157
278
  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;