@openparachute/hub 0.5.14-rc.2 → 0.5.14-rc.21

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 (106) hide show
  1. package/README.md +109 -15
  2. package/package.json +7 -3
  3. package/src/__tests__/account-home-ui.test.ts +251 -15
  4. package/src/__tests__/account-vault-token.test.ts +355 -0
  5. package/src/__tests__/admin-vaults.test.ts +70 -4
  6. package/src/__tests__/api-mint-token.test.ts +693 -5
  7. package/src/__tests__/api-modules-config.test.ts +16 -10
  8. package/src/__tests__/api-modules-ops.test.ts +45 -0
  9. package/src/__tests__/api-modules.test.ts +92 -75
  10. package/src/__tests__/api-ready.test.ts +135 -0
  11. package/src/__tests__/api-revoke-token.test.ts +384 -0
  12. package/src/__tests__/api-users.test.ts +7 -2
  13. package/src/__tests__/auth.test.ts +157 -30
  14. package/src/__tests__/cli.test.ts +44 -5
  15. package/src/__tests__/cloudflare-detect.test.ts +60 -5
  16. package/src/__tests__/expose-2fa-warning.test.ts +31 -17
  17. package/src/__tests__/expose-auth-preflight.test.ts +71 -72
  18. package/src/__tests__/expose-cloudflare.test.ts +582 -11
  19. package/src/__tests__/expose-interactive.test.ts +10 -4
  20. package/src/__tests__/expose-public-auto.test.ts +5 -1
  21. package/src/__tests__/expose.test.ts +52 -2
  22. package/src/__tests__/hub-server.test.ts +396 -10
  23. package/src/__tests__/hub.test.ts +85 -6
  24. package/src/__tests__/init.test.ts +928 -0
  25. package/src/__tests__/lifecycle.test.ts +464 -2
  26. package/src/__tests__/migrate.test.ts +433 -51
  27. package/src/__tests__/oauth-handlers.test.ts +1252 -83
  28. package/src/__tests__/oauth-ui.test.ts +12 -1
  29. package/src/__tests__/operator-token-issuer-self-heal.test.ts +412 -0
  30. package/src/__tests__/proxy-error-ui.test.ts +212 -0
  31. package/src/__tests__/proxy-state.test.ts +192 -0
  32. package/src/__tests__/resource-binding.test.ts +97 -0
  33. package/src/__tests__/scope-explanations.test.ts +77 -12
  34. package/src/__tests__/services-manifest.test.ts +122 -4
  35. package/src/__tests__/setup-wizard.test.ts +633 -53
  36. package/src/__tests__/status.test.ts +36 -0
  37. package/src/__tests__/two-factor-flow.test.ts +602 -0
  38. package/src/__tests__/two-factor.test.ts +183 -0
  39. package/src/__tests__/upgrade.test.ts +78 -1
  40. package/src/__tests__/users.test.ts +68 -0
  41. package/src/__tests__/vault-auth-status.test.ts +312 -11
  42. package/src/__tests__/vault-hub-origin-env.test.ts +263 -0
  43. package/src/__tests__/wizard.test.ts +372 -0
  44. package/src/account-home-ui.ts +488 -38
  45. package/src/account-vault-token.ts +282 -0
  46. package/src/admin-handlers.ts +159 -4
  47. package/src/admin-login-ui.ts +49 -5
  48. package/src/admin-vaults.ts +48 -15
  49. package/src/api-account.ts +14 -0
  50. package/src/api-mint-token.ts +132 -24
  51. package/src/api-modules-ops.ts +49 -11
  52. package/src/api-modules.ts +29 -12
  53. package/src/api-ready.ts +102 -0
  54. package/src/api-revoke-token.ts +107 -21
  55. package/src/api-users.ts +29 -3
  56. package/src/cli.ts +112 -25
  57. package/src/clients.ts +18 -6
  58. package/src/cloudflare/config.ts +10 -4
  59. package/src/cloudflare/detect.ts +82 -20
  60. package/src/commands/auth.ts +165 -24
  61. package/src/commands/expose-2fa-warning.ts +34 -32
  62. package/src/commands/expose-auth-preflight.ts +89 -78
  63. package/src/commands/expose-cloudflare.ts +471 -16
  64. package/src/commands/expose-interactive.ts +10 -11
  65. package/src/commands/expose-public-auto.ts +6 -4
  66. package/src/commands/expose.ts +8 -0
  67. package/src/commands/init.ts +594 -0
  68. package/src/commands/install.ts +33 -2
  69. package/src/commands/lifecycle.ts +386 -17
  70. package/src/commands/migrate.ts +293 -41
  71. package/src/commands/status.ts +22 -0
  72. package/src/commands/upgrade.ts +55 -11
  73. package/src/commands/wizard.ts +847 -0
  74. package/src/env-file.ts +10 -0
  75. package/src/help.ts +157 -15
  76. package/src/hub-db.ts +39 -1
  77. package/src/hub-server.ts +119 -13
  78. package/src/hub-settings.ts +11 -0
  79. package/src/hub.ts +82 -14
  80. package/src/oauth-handlers.ts +298 -21
  81. package/src/oauth-ui.ts +10 -0
  82. package/src/operator-token.ts +151 -0
  83. package/src/pending-login.ts +116 -0
  84. package/src/proxy-error-ui.ts +506 -0
  85. package/src/proxy-state.ts +131 -0
  86. package/src/rate-limit.ts +51 -0
  87. package/src/resource-binding.ts +134 -0
  88. package/src/scope-attenuation.ts +85 -0
  89. package/src/scope-explanations.ts +131 -14
  90. package/src/services-manifest.ts +112 -0
  91. package/src/setup-wizard.ts +738 -125
  92. package/src/tailscale/run.ts +28 -11
  93. package/src/totp.ts +201 -0
  94. package/src/two-factor-handlers.ts +287 -0
  95. package/src/two-factor-store.ts +181 -0
  96. package/src/two-factor-ui.ts +462 -0
  97. package/src/users.ts +58 -0
  98. package/src/vault/auth-status.ts +200 -25
  99. package/src/vault-hub-origin-env.ts +163 -0
  100. package/web/ui/dist/assets/index-BiBlvEaj.css +1 -0
  101. package/web/ui/dist/assets/index-CIN3mnmf.js +61 -0
  102. package/web/ui/dist/index.html +2 -2
  103. package/src/__tests__/vault-tokens-create-interactive.test.ts +0 -183
  104. package/src/commands/vault-tokens-create-interactive.ts +0 -143
  105. package/web/ui/dist/assets/index-7DtAXz7y.css +0 -1
  106. package/web/ui/dist/assets/index-tRmPbbC7.js +0 -61
@@ -0,0 +1,282 @@
1
+ /**
2
+ * `POST /account/vault-token/<name>` — friend-facing scoped vault token mint.
3
+ *
4
+ * A non-admin friend with a hub session is ALREADY authorized for their
5
+ * assigned vault(s): the OAuth issuer mints `vault:<assigned>:read|write` for
6
+ * them on the no-token connect path. This endpoint lets the same friend mint
7
+ * that same authority *in token form* — a long-lived bearer for a script or
8
+ * headless client that can't open a browser to do interactive OAuth. It is
9
+ * the same authority, just materialized; no escalation.
10
+ *
11
+ * Authorization — the security spine of this surface. The mint is capped to
12
+ * the caller's own authority by three gates, in order:
13
+ *
14
+ * 1. **Session.** A valid, unexpired hub session cookie is required (the
15
+ * friend's). No session → 401. (Resolved via `findActiveSession`, the
16
+ * same gate `/account/change-password` uses.)
17
+ * 2. **Assignment.** The requested `<name>` MUST be one of the session
18
+ * user's `user_vaults` assignments. A vault the user is not assigned to
19
+ * → 403. This is what blocks "mint for a vault I'm not assigned" and
20
+ * cross-vault minting. Read directly from `user_vaults` via
21
+ * `vaultVerbsForUserVault` (which returns `null` for an unassigned
22
+ * vault), NOT from the verb-blind `assignedVaults` array.
23
+ * 3. **Scope cap.** The requested verb MUST be one the user's assignment
24
+ * role permits, and may only ever be `read` or `write` — NEVER `admin`,
25
+ * never a broader scope than the user holds. `vaultVerbsForUserVault`
26
+ * returns the role's verb set (today always `["read", "write"]` since
27
+ * every assignment is `role = 'write'`); a verb outside that set → 403.
28
+ * `admin` is not in the form's vocabulary at all and is rejected at the
29
+ * parse step as an invalid verb.
30
+ *
31
+ * The first admin (unrestricted, empty `assignedVaults`) has no `user_vaults`
32
+ * rows, so gate 2 returns `null` for every vault and the admin gets a 403
33
+ * here too — by design. Admins mint vault tokens through the admin SPA's
34
+ * tokens page (`/admin/vault-admin-token/<name>` → `/api/auth/mint-token`),
35
+ * not this friend surface. This endpoint is exclusively the friend path.
36
+ *
37
+ * CSRF: double-submit cookie, same `__csrf` field + `verifyCsrfToken` as
38
+ * `/account/change-password` and `/logout`. A cross-site POST without the
39
+ * matching cookie/form token → 400.
40
+ *
41
+ * Rate limit: `vaultTokenMintRateLimiter`, per-user (10 / 10 min). Fires
42
+ * after CSRF (so a junk cross-site POST doesn't burn the victim's bucket)
43
+ * and before the mint. A floor against a stolen-cookie mint flood, not the
44
+ * primary defense.
45
+ *
46
+ * Mint: `signAccessToken` (the same machinery the OAuth issuer + admin paths
47
+ * use — no hand-rolled JWT signing) with:
48
+ * - `scopes: ["vault:<name>:<verb>"]`
49
+ * - `audience: "vault.<name>"` (via `inferAudience`; vault validates this
50
+ * against its URL-derived name — identical to the OAuth + admin mints)
51
+ * - `iss`: the hub origin
52
+ * - `sub`: the friend's user id
53
+ * - `vaultScope: [<name>]` — pins the token to that one vault (defense in
54
+ * depth, mirrors the admin vault-token + the rule-2/3 mints in
55
+ * `api-mint-token.ts`)
56
+ * - `ttlSeconds`: 90 days (`ACCOUNT_VAULT_TOKEN_TTL_SECONDS`, matching the
57
+ * CLI/api-mint default)
58
+ * and a `tokens` registry row via `recordTokenMint` (`created_via='cli_mint'`,
59
+ * `userId` = the friend) so the token shows up in the revocation list and the
60
+ * operator's token registry.
61
+ *
62
+ * Response: a re-render of `/account/` (server-rendered, no-JS posture) with
63
+ * the token shown ONCE in a banner. The hub keeps no plaintext copy, so this
64
+ * is the only moment the token string is visible.
65
+ */
66
+ import type { Database } from "bun:sqlite";
67
+ import {
68
+ ACCOUNT_VAULT_TOKEN_TTL_SECONDS,
69
+ type MintedTokenView,
70
+ renderAccountHome,
71
+ } from "./account-home-ui.ts";
72
+ import { renderAdminError } from "./admin-login-ui.ts";
73
+ import { CSRF_FIELD_NAME, ensureCsrfToken, verifyCsrfToken } from "./csrf.ts";
74
+ import { inferAudience } from "./jwt-audience.ts";
75
+ import { recordTokenMint, signAccessToken } from "./jwt-sign.ts";
76
+ import { vaultTokenMintRateLimiter } from "./rate-limit.ts";
77
+ import { findActiveSession } from "./sessions.ts";
78
+ import { isTotpEnrolled } from "./two-factor-store.ts";
79
+ import { type VaultVerb, getUserById, isFirstAdmin, vaultVerbsForUserVault } from "./users.ts";
80
+
81
+ /** Matches the manifest vault-name validator + `/admin/vault-admin-token`. */
82
+ const VAULT_NAME_RE = /^[a-zA-Z0-9_-]+$/;
83
+ /** Verbs this surface will ever mint. `admin` is deliberately absent. */
84
+ const ALLOWED_VERBS: readonly VaultVerb[] = ["read", "write"];
85
+ /** client_id stamped on the minted JWT + registry row. */
86
+ const ACCOUNT_VAULT_TOKEN_CLIENT_ID = "parachute-account";
87
+
88
+ export interface AccountVaultTokenDeps {
89
+ db: Database;
90
+ /** Hub origin for this request — `iss` of the minted token. */
91
+ hubOrigin: string;
92
+ /** Test seam for the clock (rate limiter + mint). */
93
+ now?: () => Date;
94
+ }
95
+
96
+ function htmlResponse(body: string, status = 200, extra: Record<string, string> = {}): Response {
97
+ return new Response(body, {
98
+ status,
99
+ headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", ...extra },
100
+ });
101
+ }
102
+
103
+ /**
104
+ * Build the `mintableVerbs` map the account-home renderer needs: each of the
105
+ * user's assigned vaults → the verbs its role permits. Used both on the
106
+ * success re-render (so every tile keeps its mint affordance) and the error
107
+ * re-render. Mirrors the GET handler's construction.
108
+ */
109
+ function buildMintableVerbs(
110
+ db: Database,
111
+ userId: string,
112
+ assignedVaults: readonly string[],
113
+ ): Record<string, VaultVerb[]> {
114
+ const map: Record<string, VaultVerb[]> = {};
115
+ for (const v of assignedVaults) {
116
+ const verbs = vaultVerbsForUserVault(db, userId, v);
117
+ if (verbs && verbs.length > 0) map[v] = verbs;
118
+ }
119
+ return map;
120
+ }
121
+
122
+ export async function handleAccountVaultTokenPost(
123
+ req: Request,
124
+ vaultName: string,
125
+ deps: AccountVaultTokenDeps,
126
+ ): Promise<Response> {
127
+ if (req.method !== "POST") {
128
+ return htmlResponse("method not allowed", 405);
129
+ }
130
+
131
+ // Gate 1 — session. No identity, no mint.
132
+ const session = findActiveSession(deps.db, req);
133
+ if (!session) {
134
+ return htmlResponse(
135
+ renderAdminError({
136
+ title: "Not signed in",
137
+ message: "Please sign in before minting an access token.",
138
+ }),
139
+ 401,
140
+ );
141
+ }
142
+ const user = getUserById(deps.db, session.userId);
143
+ if (!user) {
144
+ return htmlResponse(
145
+ renderAdminError({
146
+ title: "Account not found",
147
+ message: "The signed-in account no longer exists. Please sign in again.",
148
+ }),
149
+ 401,
150
+ );
151
+ }
152
+
153
+ // CSRF — verify before any state change / rate-limit bucket touch, same
154
+ // shape + 400 status as `/account/change-password`.
155
+ const form = await req.formData();
156
+ const formCsrf = form.get(CSRF_FIELD_NAME);
157
+ if (!verifyCsrfToken(req, typeof formCsrf === "string" ? formCsrf : null)) {
158
+ return htmlResponse(
159
+ renderAdminError({
160
+ title: "Invalid form submission",
161
+ message: "The form's CSRF token did not match. Reload the page and try again.",
162
+ }),
163
+ 400,
164
+ );
165
+ }
166
+
167
+ // Helper to re-render the account home (success banner or inline error).
168
+ // Re-resolves the CSRF token + 2FA state so the page stays fully usable.
169
+ const renderHome = (
170
+ status: number,
171
+ extras: { mintedToken?: MintedTokenView; mintError?: string },
172
+ ): Response => {
173
+ const csrf = ensureCsrfToken(req);
174
+ const setCookie: Record<string, string> = csrf.setCookie
175
+ ? { "set-cookie": csrf.setCookie }
176
+ : {};
177
+ const adminFlag = isFirstAdmin(deps.db, user.id);
178
+ return htmlResponse(
179
+ renderAccountHome({
180
+ username: user.username,
181
+ assignedVaults: user.assignedVaults,
182
+ passwordChanged: user.passwordChanged,
183
+ hubOrigin: deps.hubOrigin,
184
+ isFirstAdmin: adminFlag,
185
+ csrfToken: csrf.token,
186
+ twoFactorEnabled: isTotpEnrolled(deps.db, user.id),
187
+ mintableVerbs: buildMintableVerbs(deps.db, user.id, user.assignedVaults),
188
+ ...extras,
189
+ }),
190
+ status,
191
+ setCookie,
192
+ );
193
+ };
194
+
195
+ // Vault-name shape guard — reject anything that can't be a services.json
196
+ // key before any DB / authority work (router can hand us arbitrary path
197
+ // segments). Same posture as `/admin/vault-admin-token`.
198
+ if (!VAULT_NAME_RE.test(vaultName)) {
199
+ return renderHome(400, { mintError: `"${vaultName}" is not a valid vault name.` });
200
+ }
201
+
202
+ // Verb parse — must be exactly one of read/write. `admin` (or anything
203
+ // else) is not in this surface's vocabulary and is rejected here, well
204
+ // before authority is even consulted.
205
+ const verbRaw = form.get("verb");
206
+ const verb = typeof verbRaw === "string" ? verbRaw : "";
207
+ if (!ALLOWED_VERBS.includes(verb as VaultVerb)) {
208
+ return renderHome(400, {
209
+ mintError: "Pick an access level (read or write).",
210
+ });
211
+ }
212
+ const requestedVerb = verb as VaultVerb;
213
+
214
+ // Gate 2 + 3 — assignment + scope cap. `vaultVerbsForUserVault` returns:
215
+ // - null → the user has NO assignment for this vault (gate 2 fail): 403.
216
+ // - [] → assignment exists but its role grants no mintable verb
217
+ // (fail-closed for an unknown role): 403.
218
+ // - [...] → the verbs the assignment role permits. The requested verb
219
+ // must be in this set (gate 3): else 403.
220
+ // This is the cap to the user's actual authority — it blocks minting for
221
+ // an unassigned vault, a broader verb than the role grants, and (since the
222
+ // set never contains `admin`) any admin escalation.
223
+ const allowedForUser = vaultVerbsForUserVault(deps.db, user.id, vaultName);
224
+ if (allowedForUser === null) {
225
+ return renderHome(403, {
226
+ mintError: `You're not assigned to a vault named "${vaultName}", so you can't mint a token for it. Ask the hub operator if you think this is wrong.`,
227
+ });
228
+ }
229
+ if (!allowedForUser.includes(requestedVerb)) {
230
+ return renderHome(403, {
231
+ mintError: `Your access to "${vaultName}" doesn't allow minting a ${requestedVerb} token.`,
232
+ });
233
+ }
234
+
235
+ // Rate limit — after CSRF + authority shape, before the mint. Per-user.
236
+ const rlNow = (deps.now ?? (() => new Date()))();
237
+ const gate = vaultTokenMintRateLimiter.checkAndRecord(user.id, rlNow);
238
+ if (!gate.allowed) {
239
+ return renderHome(429, {
240
+ mintError: `Too many token-mint attempts. Try again in ${gate.retryAfterSeconds ?? 1} seconds.`,
241
+ });
242
+ }
243
+
244
+ // Mint — the same machinery the OAuth issuer + admin paths use. The scope
245
+ // is exactly the capped `vault:<name>:<verb>`; the audience binds the token
246
+ // to that one vault; `vaultScope` pins it as defense in depth.
247
+ const scope = `vault:${vaultName}:${requestedVerb}`;
248
+ const audience = inferAudience([scope]); // → `vault.<name>`
249
+ const minted = await signAccessToken(deps.db, {
250
+ sub: user.id,
251
+ scopes: [scope],
252
+ audience,
253
+ clientId: ACCOUNT_VAULT_TOKEN_CLIENT_ID,
254
+ issuer: deps.hubOrigin,
255
+ ttlSeconds: ACCOUNT_VAULT_TOKEN_TTL_SECONDS,
256
+ vaultScope: [vaultName],
257
+ ...(deps.now !== undefined ? { now: deps.now } : {}),
258
+ });
259
+
260
+ recordTokenMint(deps.db, {
261
+ jti: minted.jti,
262
+ createdVia: "cli_mint",
263
+ subject: user.id,
264
+ // Anchor the registry row to the friend's user id so the operator's
265
+ // token registry + the revocation list attribute it correctly, and a
266
+ // future per-user revoke surface can find it.
267
+ userId: user.id,
268
+ clientId: ACCOUNT_VAULT_TOKEN_CLIENT_ID,
269
+ scopes: [scope],
270
+ expiresAt: minted.expiresAt,
271
+ ...(deps.now !== undefined ? { now: deps.now } : {}),
272
+ });
273
+
274
+ return renderHome(200, {
275
+ mintedToken: {
276
+ vaultName,
277
+ verb: requestedVerb,
278
+ token: minted.token,
279
+ expiresInDays: Math.round(ACCOUNT_VAULT_TOKEN_TTL_SECONDS / (24 * 60 * 60)),
280
+ },
281
+ });
282
+ }
@@ -11,9 +11,17 @@
11
11
  * (`parachute_hub_csrf` cookie + `__csrf` form field, constant-time compare).
12
12
  */
13
13
  import type { Database } from "bun:sqlite";
14
- import { renderAdminError, renderAdminLogin } from "./admin-login-ui.ts";
14
+ import { renderAdminError, renderAdminLogin, renderTotpChallenge } from "./admin-login-ui.ts";
15
15
  import { CSRF_FIELD_NAME, ensureCsrfToken, verifyCsrfToken } from "./csrf.ts";
16
- import { checkAndRecord, clientIpFromRequest } from "./rate-limit.ts";
16
+ import {
17
+ buildPendingLoginClearCookie,
18
+ buildPendingLoginCookie,
19
+ consumePendingLogin,
20
+ createPendingLogin,
21
+ getPendingLogin,
22
+ parsePendingLoginCookie,
23
+ } from "./pending-login.ts";
24
+ import { checkAndRecord, clientIpFromRequest, totpRateLimiter } from "./rate-limit.ts";
17
25
  import { isHttpsRequest } from "./request-protocol.ts";
18
26
  import {
19
27
  SESSION_TTL_MS,
@@ -23,9 +31,11 @@ import {
23
31
  deleteSession,
24
32
  parseSessionCookie,
25
33
  } from "./sessions.ts";
34
+ import { isTotpEnrolled, verifySecondFactor } from "./two-factor-store.ts";
26
35
  import {
27
36
  PASSWORD_MAX_LEN,
28
37
  type User,
38
+ getUserById,
29
39
  getUserByUsername,
30
40
  isFirstAdmin,
31
41
  verifyPassword,
@@ -214,8 +224,42 @@ export async function handleAdminLoginPost(
214
224
  401,
215
225
  );
216
226
  }
227
+
228
+ // 2FA gate (hub#473). If the user has TOTP enrolled, the password is only
229
+ // the *first* factor — do NOT mint a session yet. Stash a short-lived
230
+ // pending-login (server-side, keyed by an opaque cookie token) and render
231
+ // the "enter your code" page. The session is minted in
232
+ // `handleAdminLoginTotpPost` only after the second factor verifies.
233
+ // Users WITHOUT 2FA fall through to the existing password-only path
234
+ // unchanged — existing operators keep signing in exactly as before.
235
+ if (isTotpEnrolled(db, user.id)) {
236
+ const pendingToken = createPendingLogin(user.id, next);
237
+ // Reuse the same CSRF token (cookie unchanged) for the challenge form.
238
+ return htmlResponse(renderTotpChallenge({ next, csrfToken }), 200, {
239
+ "set-cookie": buildPendingLoginCookie(pendingToken, req),
240
+ });
241
+ }
242
+
243
+ return mintSessionAndRedirect(db, req, user, next);
244
+ }
245
+
246
+ /**
247
+ * Mint a session for an authenticated user and 302 to the resolved target.
248
+ * Shared by the password-only login path and the post-2FA path so both apply
249
+ * the identical force-change-password / friend-rewrite redirect logic.
250
+ *
251
+ * `extraCookies` lets the 2FA path also clear the pending-login cookie in the
252
+ * same response.
253
+ */
254
+ function mintSessionAndRedirect(
255
+ db: Database,
256
+ req: Request,
257
+ user: User,
258
+ next: string,
259
+ extraCookies: string[] = [],
260
+ ): Response {
217
261
  const session = createSession(db, { userId: user.id });
218
- const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000), {
262
+ const sessionCookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000), {
219
263
  secure: isHttpsRequest(req),
220
264
  });
221
265
  // Multi-user Phase 1 PR 3 — `password_changed === false` (admin-created
@@ -226,7 +270,118 @@ export async function handleAdminLoginPost(
226
270
  // `next` rides along on the change-password URL so the post-change
227
271
  // POST can land them at their intended destination.
228
272
  const target = loginRedirectTarget(db, user, next);
229
- return redirect(target, { "set-cookie": cookie });
273
+ const headers = new Headers({ location: target });
274
+ headers.append("set-cookie", sessionCookie);
275
+ for (const c of extraCookies) headers.append("set-cookie", c);
276
+ return new Response(null, { status: 302, headers });
277
+ }
278
+
279
+ /**
280
+ * POST `/login/2fa` — the second-factor step (hub#473).
281
+ *
282
+ * Reached only after a correct password POST for a 2FA-enrolled user handed
283
+ * back a pending-login cookie. Order of checks:
284
+ * 1. CSRF (else 400 — same shape as `/login`).
285
+ * 2. Per-IP rate-limit (5 / 15 min) BEFORE the factor check, so backup-code
286
+ * / TOTP grinding by a password-holder is bounded.
287
+ * 3. Pending-login cookie resolves to a live half-login (else 401 — the
288
+ * pending login expired or was never created; restart the password step).
289
+ * 4. The user row still exists + still has 2FA enrolled (defensive).
290
+ * 5. Verify the submitted code as TOTP (±1 window) OR a backup code (single-
291
+ * use, consumed on match). On success: consume the pending login, mint
292
+ * the session, 302 to the resolved target + clear the pending cookie.
293
+ * On failure: re-render the challenge with an error (the pending login
294
+ * stays valid so the user can retry without re-entering the password).
295
+ */
296
+ export async function handleAdminLoginTotpPost(
297
+ db: Database,
298
+ req: Request,
299
+ deps: AdminLoginDeps = {},
300
+ ): Promise<Response> {
301
+ const form = await req.formData();
302
+ const formCsrf = form.get(CSRF_FIELD_NAME);
303
+ const csrfToken = typeof formCsrf === "string" ? formCsrf : "";
304
+ if (!verifyCsrfToken(req, csrfToken || null)) {
305
+ return htmlResponse(
306
+ renderAdminError({
307
+ title: "Invalid form submission",
308
+ message: "The form's CSRF token did not match. Reload the page and try again.",
309
+ }),
310
+ 400,
311
+ );
312
+ }
313
+
314
+ const next = safeNext(String(form.get("next") ?? ""));
315
+ const code = String(form.get("code") ?? "");
316
+
317
+ // Rate-limit BEFORE resolving the pending login or verifying the factor.
318
+ const clientIp = clientIpFromRequest(req);
319
+ const now = deps.now ? deps.now() : new Date();
320
+ const gate = totpRateLimiter.checkAndRecord(clientIp, now);
321
+ if (!gate.allowed) {
322
+ return htmlResponse(
323
+ renderAdminError({
324
+ title: "Too many attempts",
325
+ message: `Too many verification attempts from this IP. Try again in ${gate.retryAfterSeconds ?? 1} seconds.`,
326
+ }),
327
+ 429,
328
+ { "retry-after": String(gate.retryAfterSeconds ?? 1) },
329
+ );
330
+ }
331
+
332
+ const pendingToken = parsePendingLoginCookie(req.headers.get("cookie"));
333
+ const pending = getPendingLogin(pendingToken, () => now);
334
+ if (!pending) {
335
+ // No live pending login — expired, missing, or tampered. Send the user
336
+ // back to the start; clear any stale pending cookie.
337
+ return htmlResponse(
338
+ renderAdminError({
339
+ title: "Session expired",
340
+ message: "Your sign-in attempt expired. Please sign in again.",
341
+ }),
342
+ 401,
343
+ { "set-cookie": buildPendingLoginClearCookie(req) },
344
+ );
345
+ }
346
+
347
+ const user = getUserById(db, pending.userId);
348
+ if (!user || !isTotpEnrolled(db, user.id)) {
349
+ consumePendingLogin(pendingToken);
350
+ return htmlResponse(
351
+ renderAdminError({
352
+ title: "Sign-in unavailable",
353
+ message: "Please sign in again.",
354
+ }),
355
+ 401,
356
+ { "set-cookie": buildPendingLoginClearCookie(req) },
357
+ );
358
+ }
359
+
360
+ if (!code) {
361
+ return htmlResponse(
362
+ renderTotpChallenge({ next, csrfToken, errorMessage: "Enter your authentication code." }),
363
+ 400,
364
+ );
365
+ }
366
+
367
+ const result = await verifySecondFactor(db, user.id, code);
368
+ if (!result.ok) {
369
+ return htmlResponse(
370
+ renderTotpChallenge({
371
+ next,
372
+ csrfToken,
373
+ errorMessage: "That code is incorrect or expired. Try again.",
374
+ }),
375
+ 401,
376
+ );
377
+ }
378
+
379
+ // Second factor good. Consume the pending login (single use), mint the
380
+ // session, and clear the pending cookie in the same response. Use the
381
+ // pending login's stored `next` if the form's was tampered to the default.
382
+ consumePendingLogin(pendingToken);
383
+ const target = pending.next && pending.next !== POST_LOGIN_DEFAULT ? pending.next : next;
384
+ return mintSessionAndRedirect(db, req, user, target, [buildPendingLoginClearCookie(req)]);
230
385
  }
231
386
 
232
387
  /**
@@ -12,7 +12,7 @@
12
12
  *
13
13
  * Pure functions — DB, sessions live in `admin-handlers.ts`.
14
14
  */
15
- import { brandMarkSvg, WORDMARK_TEXT } from "./brand.ts";
15
+ import { WORDMARK_TEXT, brandMarkSvg } from "./brand.ts";
16
16
  import { renderCsrfHiddenInput } from "./csrf.ts";
17
17
  import { escapeHtml } from "./oauth-ui.ts";
18
18
 
@@ -67,7 +67,7 @@ function header(): string {
67
67
  <div class="brand">
68
68
  <span class="brand-mark" aria-hidden="true">${brandMarkSvg(20, "admin-login")}</span>
69
69
  <span class="brand-name">${WORDMARK_TEXT}</span>
70
- <span class="brand-tag">admin</span>
70
+ <span class="brand-tag">sign in</span>
71
71
  </div>`;
72
72
  }
73
73
 
@@ -87,8 +87,8 @@ export function renderAdminLogin(props: AdminLoginProps): string {
87
87
  <div class="card">
88
88
  <div class="card-header">
89
89
  ${header()}
90
- <h1>Sign in</h1>
91
- <p class="subtitle">to administer this hub</p>
90
+ <h1>Sign in to your Parachute account</h1>
91
+ <p class="subtitle">Hub operators and invited members sign in here.</p>
92
92
  </div>
93
93
  ${error}
94
94
  <form method="POST" action="/login" class="auth-form">
@@ -105,7 +105,51 @@ export function renderAdminLogin(props: AdminLoginProps): string {
105
105
  <button type="submit" class="btn btn-primary">Sign in</button>
106
106
  </form>
107
107
  </div>`;
108
- return baseDocument("Sign in to Parachute Hub admin", body);
108
+ return baseDocument("Sign in Parachute", body);
109
+ }
110
+
111
+ // --- /login/2fa (second-factor step) --------------------------------------
112
+
113
+ export interface TotpChallengeProps {
114
+ /** Continuation path after a successful second factor — hidden field. */
115
+ next: string;
116
+ csrfToken: string;
117
+ errorMessage?: string;
118
+ }
119
+
120
+ /**
121
+ * Server-rendered "enter your code" page shown after a correct password when
122
+ * the user has 2FA enrolled (hub#473). Posts to `/login/2fa` along with the
123
+ * pending-login cookie minted by the password step. Accepts either a 6-digit
124
+ * TOTP code or a backup code in the same field — the handler tries TOTP first,
125
+ * then backup codes.
126
+ *
127
+ * No JS, stands alone like `/login`. `inputmode`/`autocomplete` hint mobile
128
+ * keyboards toward a numeric pad + the platform's one-time-code autofill.
129
+ */
130
+ export function renderTotpChallenge(props: TotpChallengeProps): string {
131
+ const { next, csrfToken, errorMessage } = props;
132
+ const error = errorMessage ? `<p class="error-banner">${escapeHtml(errorMessage)}</p>` : "";
133
+ const body = `
134
+ <div class="card">
135
+ <div class="card-header">
136
+ ${header()}
137
+ <h1>Two-factor authentication</h1>
138
+ <p class="subtitle">Enter the 6-digit code from your authenticator app, or a backup code.</p>
139
+ </div>
140
+ ${error}
141
+ <form method="POST" action="/login/2fa" class="auth-form">
142
+ ${renderCsrfHiddenInput(csrfToken)}
143
+ <input type="hidden" name="next" value="${escapeAttr(next)}" />
144
+ <label class="field">
145
+ <span class="field-label">Authentication code</span>
146
+ <input type="text" name="code" inputmode="numeric" autocomplete="one-time-code"
147
+ autofocus required placeholder="123456" />
148
+ </label>
149
+ <button type="submit" class="btn btn-primary">Verify</button>
150
+ </form>
151
+ </div>`;
152
+ return baseDocument("Two-factor authentication — Parachute", body);
109
153
  }
110
154
 
111
155
  // --- error page ------------------------------------------------------------
@@ -12,16 +12,24 @@
12
12
  * Content-Type: application/json
13
13
  * { "name": "<vault-name>" }
14
14
  *
15
- * 201 → { name, url, version, token?, paths? }
16
- * // vault freshly created. `token` (single-emit `pvt_*`) and
17
- * // filesystem `paths` are present when the create path took the
18
- * // `parachute-vault create --json` branch — that's the only time
19
- * // the just-emitted token is captured. The first-vault-on-host
20
- * // bootstrap (`parachute install vault`) doesn't emit JSON yet,
21
- * // so a fresh-box response carries name/url/version only.
15
+ * 201 → { name, url, version, token?, token_guidance?, paths? }
16
+ * // vault freshly created. `token` is a hub-issued ACCESS token
17
+ * // (a JWT scoped `vault:<name>:admin`) captured from the
18
+ * // `parachute-vault create --json` branch — NOT a `pvt_*` vault
19
+ * // token (those were dropped). Post-DROP `token` may be the
20
+ * // empty string `""` when the bootstrap mint was unavailable
21
+ * // (e.g. a loopback origin the hub can't mint against); in that
22
+ * // case `token_guidance` carries the vault's human-readable
23
+ * // reason, forwarded verbatim so the SPA can explain the gap.
24
+ * // `paths` is the new vault's filesystem layout. The
25
+ * // first-vault-on-host bootstrap (`parachute install vault`)
26
+ * // doesn't emit JSON yet, so a fresh-box response carries
27
+ * // name/url/version only.
22
28
  * 200 → { name, url, version }
23
29
  * // idempotent re-POST: existing vault. Never includes `token` —
24
- * // tokens are single-emit at create time, not retrievable later.
30
+ * // the create-time access token isn't retrievable later. The
31
+ * // caller branches on HTTP status (201 vs 200), not on `token`
32
+ * // truthiness, so an empty-token 201 isn't confused with a 200.
25
33
  * 400 → { error: "invalid_request", error_description: ... }
26
34
  * 401/403 → bearer-auth failure
27
35
  * 500 → orchestration failure
@@ -50,7 +58,7 @@
50
58
  import type { Database } from "bun:sqlite";
51
59
  import { type AdminAuthError, adminAuthErrorResponse, requireScope } from "./admin-auth.ts";
52
60
  import { SERVICES_MANIFEST_PATH } from "./config.ts";
53
- import { findService, readManifest, readManifestLenient } from "./services-manifest.ts";
61
+ import { findService, type readManifest, readManifestLenient } from "./services-manifest.ts";
54
62
  import { type WellKnownVaultEntry, isVaultEntry, vaultInstanceNameFor } from "./well-known.ts";
55
63
 
56
64
  /** Scope required to call POST /vaults. */
@@ -74,7 +82,20 @@ export interface CreateVaultRequest {
74
82
  /** Output shape of `parachute-vault create --json` (vault PR #184). */
75
83
  export interface VaultCreateJson {
76
84
  name: string;
85
+ /**
86
+ * Hub-issued access token (a JWT scoped `vault:<name>:admin`) the vault
87
+ * minted at create time. Post the pvt_* DROP this is the empty string
88
+ * `""` when no hub origin was reachable to mint against (e.g. a loopback
89
+ * create) — the field is always present but may be empty.
90
+ */
77
91
  token: string;
92
+ /**
93
+ * Vault-supplied human-readable reason no token was minted, present only
94
+ * when `token` is empty (e.g. "no hub origin reachable to mint against").
95
+ * Optional — older vaults that always minted don't emit it. Forwarded
96
+ * verbatim to the caller so the SPA can explain the empty-token state.
97
+ */
98
+ token_guidance?: string;
78
99
  paths: {
79
100
  vault_dir: string;
80
101
  vault_db: string;
@@ -239,8 +260,9 @@ interface OrchestrateError {
239
260
  * Run the orchestration step. Picks `parachute install` (bootstrap) vs
240
261
  * `parachute-vault create --json` (subsequent) based on whether vault is
241
262
  * already registered in services.json. The create branch parses stdout for
242
- * the just-emitted `pvt_*` token + filesystem paths so the caller can talk
243
- * to the new vault those creds are single-emit.
263
+ * the just-minted hub access token (a `vault:<name>:admin` JWT, possibly
264
+ * empty post-DROP), the optional `token_guidance`, and filesystem paths so
265
+ * the caller can talk to the new vault — the access token is single-emit.
244
266
  */
245
267
  async function orchestrate(
246
268
  manifestPath: string,
@@ -348,14 +370,25 @@ export async function handleCreateVault(req: Request, deps: CreateVaultDeps): Pr
348
370
  }
349
371
 
350
372
  const entry = buildEntry(name, created.path, created.version, deps.issuer);
351
- // Token + filesystem paths are single-emit at create time. We surface them
352
- // here so the caller can immediately bootstrap a connection to the new
353
- // vault. Idempotent re-POSTs intentionally never include them.
373
+ // Access token (a `vault:<name>:admin` JWT, possibly empty post-DROP) +
374
+ // filesystem paths are single-emit at create time. We surface them here so
375
+ // the caller can immediately bootstrap a connection to the new vault.
376
+ // `token_guidance` (when the vault couldn't mint) is forwarded verbatim so
377
+ // the SPA can explain the empty-token state rather than rendering a blank.
378
+ // Idempotent re-POSTs intentionally never include any of these.
354
379
  const body: WellKnownVaultEntry & {
355
380
  token?: string;
381
+ token_guidance?: string;
356
382
  paths?: VaultCreateJson["paths"];
357
383
  } = result.createJson
358
- ? { ...entry, token: result.createJson.token, paths: result.createJson.paths }
384
+ ? {
385
+ ...entry,
386
+ token: result.createJson.token,
387
+ ...(result.createJson.token_guidance
388
+ ? { token_guidance: result.createJson.token_guidance }
389
+ : {}),
390
+ paths: result.createJson.paths,
391
+ }
359
392
  : entry;
360
393
 
361
394
  return new Response(JSON.stringify(body), {