@openparachute/hub 0.7.3 → 0.7.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 (39) hide show
  1. package/package.json +4 -11
  2. package/src/__tests__/admin-vaults.test.ts +164 -1
  3. package/src/__tests__/api-account-2fa.test.ts +381 -0
  4. package/src/__tests__/api-hub-upgrade.test.ts +59 -3
  5. package/src/__tests__/cli.test.ts +22 -0
  6. package/src/__tests__/clients.test.ts +28 -8
  7. package/src/__tests__/cloudflare-connector-service.test.ts +3 -1
  8. package/src/__tests__/hub-server.test.ts +127 -5
  9. package/src/__tests__/init.test.ts +507 -1
  10. package/src/__tests__/managed-unit.test.ts +62 -0
  11. package/src/__tests__/oauth-handlers.test.ts +488 -0
  12. package/src/__tests__/oauth-ui.test.ts +55 -1
  13. package/src/__tests__/scope-explanations.test.ts +19 -0
  14. package/src/__tests__/setup-wizard.test.ts +124 -7
  15. package/src/__tests__/status-supervisor.test.ts +152 -3
  16. package/src/__tests__/supervisor.test.ts +25 -0
  17. package/src/__tests__/vault-names.test.ts +32 -3
  18. package/src/__tests__/well-known.test.ts +37 -2
  19. package/src/admin-vaults.ts +7 -12
  20. package/src/api-account-2fa.ts +373 -0
  21. package/src/api-hub-upgrade.ts +38 -3
  22. package/src/api-me.ts +11 -2
  23. package/src/cli.ts +49 -5
  24. package/src/clients.ts +14 -0
  25. package/src/commands/init.ts +224 -37
  26. package/src/commands/status.ts +108 -5
  27. package/src/help.ts +17 -0
  28. package/src/hub-server.ts +72 -7
  29. package/src/managed-unit.ts +30 -1
  30. package/src/oauth-handlers.ts +98 -6
  31. package/src/oauth-ui.ts +123 -0
  32. package/src/scope-explanations.ts +2 -1
  33. package/src/setup-wizard.ts +40 -21
  34. package/src/supervisor.ts +46 -2
  35. package/src/vault-names.ts +15 -4
  36. package/src/well-known.ts +10 -1
  37. package/web/ui/dist/assets/{index--728BX3j.css → index-BcC4U5gM.css} +1 -1
  38. package/web/ui/dist/assets/{index-DZzX_Enf.js → index-DygKux-C.js} +13 -13
  39. package/web/ui/dist/index.html +2 -2
@@ -0,0 +1,373 @@
1
+ /**
2
+ * `/api/account/*` — JSON self-service account surfaces for the admin SPA
3
+ * (hub#85). The server-rendered `/account/2fa` + `/account/change-password`
4
+ * pages stay (they work without JS, the friend-facing path); these are the
5
+ * JSON twins the in-`/admin` SPA "My account" page drives.
6
+ *
7
+ * POST /api/account/2fa/start → mint a fresh secret + QR + otpauth URL
8
+ * (NOT persisted — confirm seals it)
9
+ * POST /api/account/2fa/confirm → verify a live code vs the in-flight
10
+ * secret, persist enrollment, return the
11
+ * backup codes ONCE
12
+ * POST /api/account/2fa/disable → verify current password, clear 2FA
13
+ * POST /api/account/password → verify current, set new (+ revoke the
14
+ * user's still-active tokens)
15
+ *
16
+ * Auth posture: every endpoint is **self-service** — it acts on the
17
+ * SIGNED-IN user's OWN account (`session.userId`), never a client-supplied
18
+ * user id. ANY authenticated user reaches them (the owner / first-admin is
19
+ * NOT special — same path, no privilege bypass). This is deliberately the
20
+ * `/api/admin-lock` cookie+CSRF posture, NOT the host-admin Bearer posture:
21
+ * a user managing their own credentials shouldn't need (or have) the
22
+ * `parachute:host:admin` scope. Order on every POST:
23
+ *
24
+ * 1. Session cookie (else 401).
25
+ * 2. CSRF double-submit `__csrf` in the JSON body (else 403). Same-origin
26
+ * belt is applied by the hub-server dispatcher before this runs.
27
+ * 3. Per-action validation.
28
+ *
29
+ * The crypto + persistence is REUSED, never duplicated: secret generation +
30
+ * code verification live in `totp.ts`; enrollment storage lives in
31
+ * `two-factor-store.ts`; password validation + hashing live in `users.ts`.
32
+ * This file is the JSON wire layer only.
33
+ *
34
+ * In-flight-secret model (mirrors the server-rendered flow): `start` returns
35
+ * the secret, the SPA holds it client-side, and `confirm` sends it back with
36
+ * the live code. Nothing is persisted until `confirm` verifies — an abandoned
37
+ * setup leaves zero state.
38
+ */
39
+ import type { Database } from "bun:sqlite";
40
+ import { hash as argonHash } from "@node-rs/argon2";
41
+ import QRCode from "qrcode";
42
+ import { verifyCsrfToken } from "./csrf.ts";
43
+ import { changePasswordRateLimiter } from "./rate-limit.ts";
44
+ import { findActiveSession } from "./sessions.ts";
45
+ import { generateTotpSecret, otpauthUrlFor, verifyTotpCode } from "./totp.ts";
46
+ import {
47
+ clearEnrollment,
48
+ getTotpState,
49
+ isTotpEnrolled,
50
+ persistEnrollment,
51
+ } from "./two-factor-store.ts";
52
+ import {
53
+ PASSWORD_MAX_LEN,
54
+ type User,
55
+ UserNotFoundError,
56
+ getUserById,
57
+ validatePassword,
58
+ verifyPassword,
59
+ } from "./users.ts";
60
+
61
+ export interface ApiAccount2faDeps {
62
+ db: Database;
63
+ /** Test seam — defaults to the real clock. */
64
+ now?: () => Date;
65
+ }
66
+
67
+ function json(status: number, body: unknown, extra: Record<string, string> = {}): Response {
68
+ return new Response(JSON.stringify(body), {
69
+ status,
70
+ headers: { "content-type": "application/json", "cache-control": "no-store", ...extra },
71
+ });
72
+ }
73
+
74
+ function jsonError(status: number, error: string, description: string): Response {
75
+ return json(status, { error, error_description: description });
76
+ }
77
+
78
+ /** Resolve the signed-in user, or an error Response (401). Self-only — no id from the client. */
79
+ function requireUser(
80
+ db: Database,
81
+ req: Request,
82
+ ): { ok: true; user: User } | { ok: false; res: Response } {
83
+ const session = findActiveSession(db, req);
84
+ if (!session) {
85
+ return {
86
+ ok: false,
87
+ res: jsonError(401, "unauthenticated", "no session — sign in at /login first"),
88
+ };
89
+ }
90
+ const user = getUserById(db, session.userId);
91
+ if (!user) {
92
+ return {
93
+ ok: false,
94
+ res: jsonError(401, "unauthenticated", "signed-in account no longer exists"),
95
+ };
96
+ }
97
+ return { ok: true, user };
98
+ }
99
+
100
+ async function readJsonBody(req: Request): Promise<Record<string, unknown>> {
101
+ try {
102
+ const body = (await req.json()) as unknown;
103
+ return body && typeof body === "object" ? (body as Record<string, unknown>) : {};
104
+ } catch {
105
+ return {};
106
+ }
107
+ }
108
+
109
+ function checkCsrf(req: Request, body: Record<string, unknown>): boolean {
110
+ const token = typeof body.__csrf === "string" ? body.__csrf : null;
111
+ return verifyCsrfToken(req, token);
112
+ }
113
+
114
+ /**
115
+ * Gate the password-verifying endpoints (`/password`, `/2fa/disable`) before the
116
+ * argon2id `verifyPassword` call — a session-hijack attacker shouldn't get an
117
+ * unbounded grind window against the hash. Keyed by `user.id` (identity is
118
+ * already established by the session) and shares the `changePasswordRateLimiter`
119
+ * bucket (3 attempts / 5 min) with the server-rendered change-password POST, so
120
+ * a single user's argon2id budget is uniform across both surfaces. Returns a 429
121
+ * Response when the bucket is exhausted, else null. Fires AFTER CSRF so a junk
122
+ * cross-site POST can't burn the victim's bucket slot.
123
+ */
124
+ function passwordRateLimit(userId: string, now: () => Date): Response | null {
125
+ const gate = changePasswordRateLimiter.checkAndRecord(userId, now());
126
+ if (gate.allowed) return null;
127
+ const retryAfter = gate.retryAfterSeconds ?? 1;
128
+ return json(
129
+ 429,
130
+ {
131
+ error: "too_many_attempts",
132
+ error_description: `Too many attempts. Try again in ${retryAfter} seconds.`,
133
+ },
134
+ { "retry-after": String(retryAfter) },
135
+ );
136
+ }
137
+
138
+ /**
139
+ * Router for `/api/account/*`. `subpath` is the path AFTER `/api/account`
140
+ * (e.g. "/2fa/start", "/password"). The hub-server dispatcher slices it.
141
+ *
142
+ * Every route here is a POST (state-changing); the read-side 2FA status the
143
+ * SPA renders comes from `/api/me`'s `two_factor_enabled` field, so there's
144
+ * no GET on this surface.
145
+ */
146
+ export async function handleApiAccount(
147
+ req: Request,
148
+ subpath: string,
149
+ deps: ApiAccount2faDeps,
150
+ ): Promise<Response> {
151
+ if (req.method !== "POST") return jsonError(405, "method_not_allowed", "use POST");
152
+
153
+ const gate = requireUser(deps.db, req);
154
+ if (!gate.ok) return gate.res;
155
+ const user = gate.user;
156
+
157
+ const body = await readJsonBody(req);
158
+ if (!checkCsrf(req, body)) {
159
+ return jsonError(403, "csrf_failed", "missing or invalid CSRF token");
160
+ }
161
+
162
+ switch (subpath) {
163
+ case "/2fa/start":
164
+ return handleStart(deps.db, user);
165
+ case "/2fa/confirm":
166
+ return handleConfirm(deps, user, body);
167
+ case "/2fa/disable":
168
+ return handleDisable(deps, user, body);
169
+ case "/password":
170
+ return handlePassword(deps, user, body);
171
+ default:
172
+ return jsonError(404, "not_found", `no account route at /api/account${subpath}`);
173
+ }
174
+ }
175
+
176
+ /**
177
+ * POST /api/account/2fa/start — mint a fresh secret + provisioning artifacts.
178
+ * Refuses if already enrolled (disable first to re-enroll) — same guard as
179
+ * the server-rendered `start`. The secret is NOT persisted; the SPA holds it
180
+ * and round-trips it back on confirm.
181
+ */
182
+ async function handleStart(db: Database, user: User): Promise<Response> {
183
+ if (isTotpEnrolled(db, user.id)) {
184
+ return jsonError(
185
+ 409,
186
+ "already_enrolled",
187
+ "Two-factor is already enabled. Turn it off first to re-enroll.",
188
+ );
189
+ }
190
+ const { secret, otpauthUrl } = generateTotpSecret(user.username);
191
+ // PNG data-URL QR (margin:1 for scanner-friendly quiet zone). The repo
192
+ // already depends on `qrcode`; returning a data-URL lets the SPA render a
193
+ // plain <img> with no new client dependency, and the otpauth URL is
194
+ // returned alongside for manual-entry / copy affordances.
195
+ const qrDataUrl = await QRCode.toDataURL(otpauthUrl, { margin: 1, errorCorrectionLevel: "M" });
196
+ return json(200, { secret, otpauth_url: otpauthUrl, qr_data_url: qrDataUrl });
197
+ }
198
+
199
+ /** base32 alphabet (A–Z, 2–7) + optional `=` padding, ≥16 chars. Same N1 guard as the HTML flow. */
200
+ function isPlausibleBase32Secret(secret: string): boolean {
201
+ return /^[A-Z2-7]+=*$/i.test(secret) && secret.length >= 16;
202
+ }
203
+
204
+ /**
205
+ * POST /api/account/2fa/confirm {secret, code} — verify the live code vs the
206
+ * in-flight secret, persist enrollment, return the backup codes ONCE.
207
+ */
208
+ async function handleConfirm(
209
+ deps: ApiAccount2faDeps,
210
+ user: User,
211
+ body: Record<string, unknown>,
212
+ ): Promise<Response> {
213
+ const secret = typeof body.secret === "string" ? body.secret : "";
214
+ const code = typeof body.code === "string" ? body.code : "";
215
+
216
+ if (!secret || !isPlausibleBase32Secret(secret)) {
217
+ return jsonError(400, "setup_expired", "Setup expired or malformed. Start again.");
218
+ }
219
+ // Defensive — a confirm POST against an already-enrolled account.
220
+ if (isTotpEnrolled(deps.db, user.id)) {
221
+ return jsonError(409, "already_enrolled", "Two-factor is already enabled.");
222
+ }
223
+ if (!verifyTotpCode(secret, code)) {
224
+ return jsonError(
225
+ 400,
226
+ "invalid_code",
227
+ "That code didn't match. Check your device clock and try the current code.",
228
+ );
229
+ }
230
+ const result = await persistEnrollment(deps.db, user.id, secret, deps.now ?? (() => new Date()));
231
+ // Backup codes are shown ONCE — no-store so the response is never cached.
232
+ return json(200, {
233
+ enrolled: true,
234
+ enrolled_at: result.enrolledAt,
235
+ backup_codes: result.backupCodes,
236
+ });
237
+ }
238
+
239
+ /**
240
+ * POST /api/account/2fa/disable {password} — verify the current password,
241
+ * clear 2FA. Password-gated (same safety as the HTML flow): disabling a
242
+ * second factor with only a session cookie would let a hijacked session
243
+ * strip the very protection that defends the account.
244
+ */
245
+ async function handleDisable(
246
+ deps: ApiAccount2faDeps,
247
+ user: User,
248
+ body: Record<string, unknown>,
249
+ ): Promise<Response> {
250
+ const db = deps.db;
251
+ if (!isTotpEnrolled(db, user.id)) {
252
+ // Idempotent — already off.
253
+ return json(200, { enrolled: false });
254
+ }
255
+ const password = typeof body.password === "string" ? body.password : "";
256
+ if (!password) {
257
+ return jsonError(
258
+ 400,
259
+ "password_required",
260
+ "Enter your current password to turn off two-factor.",
261
+ );
262
+ }
263
+ // Cap before argon2id verify (CPU-DoS guard — same posture as /login).
264
+ if (password.length > PASSWORD_MAX_LEN) {
265
+ return jsonError(
266
+ 413,
267
+ "password_too_long",
268
+ `Password must be ≤ ${PASSWORD_MAX_LEN} characters.`,
269
+ );
270
+ }
271
+ // Rate-limit before the argon2id verify (a stolen session shouldn't grind).
272
+ const limited = passwordRateLimit(user.id, deps.now ?? (() => new Date()));
273
+ if (limited) return limited;
274
+ const ok = await verifyPassword(user, password);
275
+ if (!ok) {
276
+ return jsonError(401, "invalid_credentials", "That password is incorrect.");
277
+ }
278
+ clearEnrollment(db, user.id);
279
+ return json(200, { enrolled: false });
280
+ }
281
+
282
+ /**
283
+ * POST /api/account/password {current_password, new_password} — JSON twin of
284
+ * the server-rendered `/account/change-password` POST. Same validation +
285
+ * atomic hash-write-and-revoke-tokens as `api-account.ts`, reusing the same
286
+ * `users.ts` validators. Self-only (the signed-in user's own hash).
287
+ *
288
+ * Check order mirrors the HTML handler:
289
+ * 1. fields present (400)
290
+ * 2. current too long → 413 (before argon2id verify)
291
+ * 3. new too long → 413 (before argon2id hash)
292
+ * 4. validatePassword(new) → 400
293
+ * 5. rate-limit (429, before the argon2id verify — same as the HTML twin)
294
+ * 6. verifyPassword(current) → 401
295
+ * 7. new === current → 400 (after verify — see api-account.ts rationale)
296
+ * 8. hash new + UPDATE + revoke tokens (one tx)
297
+ */
298
+ async function handlePassword(
299
+ deps: ApiAccount2faDeps,
300
+ user: User,
301
+ body: Record<string, unknown>,
302
+ ): Promise<Response> {
303
+ const currentPassword = typeof body.current_password === "string" ? body.current_password : "";
304
+ const newPassword = typeof body.new_password === "string" ? body.new_password : "";
305
+
306
+ if (!currentPassword || !newPassword) {
307
+ return jsonError(400, "missing_fields", "current_password and new_password are required.");
308
+ }
309
+ if (currentPassword.length > PASSWORD_MAX_LEN) {
310
+ return jsonError(
311
+ 413,
312
+ "password_too_long",
313
+ `Current password must be ≤ ${PASSWORD_MAX_LEN} characters.`,
314
+ );
315
+ }
316
+ if (newPassword.length > PASSWORD_MAX_LEN) {
317
+ return jsonError(
318
+ 413,
319
+ "password_too_long",
320
+ `New password must be ≤ ${PASSWORD_MAX_LEN} characters.`,
321
+ );
322
+ }
323
+ if (!validatePassword(newPassword).valid) {
324
+ return jsonError(
325
+ 400,
326
+ "invalid_password",
327
+ "New password must be at least 12 characters (a passphrase is fine).",
328
+ );
329
+ }
330
+ // Rate-limit before the argon2id verify (a stolen session shouldn't grind
331
+ // the current-password check). Shares the bucket with the HTML twin + the
332
+ // disable endpoint — uniform per-user argon2id budget.
333
+ const limited = passwordRateLimit(user.id, deps.now ?? (() => new Date()));
334
+ if (limited) return limited;
335
+ const currentOk = await verifyPassword(user, currentPassword);
336
+ if (!currentOk) {
337
+ return jsonError(401, "invalid_credentials", "Current password is incorrect.");
338
+ }
339
+ if (newPassword === currentPassword) {
340
+ return jsonError(
341
+ 400,
342
+ "password_unchanged",
343
+ "New password must differ from your current password.",
344
+ );
345
+ }
346
+
347
+ // Hash OUTSIDE the transaction — argon2id is async and bun:sqlite's
348
+ // `db.transaction()` is sync; an async closure silently breaks atomicity
349
+ // (same constraint api-account.ts documents). Then write the hash, flip
350
+ // `password_changed`, and revoke the user's still-active tokens in one tx.
351
+ const now = deps.now ?? (() => new Date());
352
+ const passwordHash = await argonHash(newPassword);
353
+ const stamp = now().toISOString();
354
+ try {
355
+ deps.db.transaction(() => {
356
+ const result = deps.db
357
+ .prepare(
358
+ "UPDATE users SET password_hash = ?, password_changed = 1, updated_at = ? WHERE id = ?",
359
+ )
360
+ .run(passwordHash, stamp, user.id);
361
+ if (result.changes === 0) throw new UserNotFoundError(user.id);
362
+ deps.db
363
+ .prepare("UPDATE tokens SET revoked_at = ? WHERE user_id = ? AND revoked_at IS NULL")
364
+ .run(stamp, user.id);
365
+ })();
366
+ } catch (err) {
367
+ if (err instanceof UserNotFoundError) {
368
+ return jsonError(401, "unauthenticated", "The signed-in account no longer exists.");
369
+ }
370
+ throw err;
371
+ }
372
+ return json(200, { ok: true });
373
+ }
@@ -67,6 +67,34 @@ export const HUB_UPGRADE_REQUIRED_SCOPE = "parachute:host:admin";
67
67
  */
68
68
  const IN_FLIGHT_PHASES = new Set<HubUpgradeStatus["phase"]>(["pending", "running", "restarting"]);
69
69
 
70
+ /**
71
+ * #506: TTL for the 409 in-flight guard. The status file is single-slot, and a
72
+ * helper that CRASHES (OOM, killed mid-rewrite, host reboot) never reaches a
73
+ * terminal phase — leaving the slot stuck in `pending`/`running`/`restarting`
74
+ * FOREVER and 409-deadlocking every future upgrade. So: an in-flight slot whose
75
+ * `started_at` is older than this bound is treated as ABANDONED and the new
76
+ * request proceeds (overwriting the stale slot).
77
+ *
78
+ * 15 minutes — comfortably past the longest expected in-place upgrade (an
79
+ * `npm view` + `bun add -g` rewrite + restart is seconds-to-low-minutes even on
80
+ * a slow box / cold cache). A live upgrade finishing under the bound is never
81
+ * mistaken for abandoned; a crashed one frees the slot within 15 min instead of
82
+ * never. (A missing/garbage `started_at` is treated as stale → not 409, so a
83
+ * malformed file can't deadlock either.)
84
+ */
85
+ const IN_FLIGHT_TTL_MS = 15 * 60 * 1000;
86
+
87
+ /**
88
+ * Is an in-flight slot still FRESH (within the TTL), so a second POST must be
89
+ * rejected 409? An unparseable / missing `started_at` is treated as stale
90
+ * (not fresh) so a malformed file frees the slot rather than deadlocking it.
91
+ */
92
+ function isInFlightFresh(existing: HubUpgradeStatus, now: Date): boolean {
93
+ const startedMs = Date.parse(existing.started_at);
94
+ if (Number.isNaN(startedMs)) return false;
95
+ return now.getTime() - startedMs < IN_FLIGHT_TTL_MS;
96
+ }
97
+
70
98
  export interface SpawnHelperArgs {
71
99
  operationId: string;
72
100
  channel: "rc" | "latest";
@@ -213,7 +241,9 @@ export async function handleHubUpgrade(req: Request, deps: ApiHubUpgradeDeps): P
213
241
  const parsed = await parseBody(req);
214
242
  if (parsed instanceof Response) return parsed;
215
243
 
216
- // ── 409 in-flight guard ────────────────────────────────────────────────────
244
+ const now = (deps.now ?? (() => new Date()))();
245
+
246
+ // ── 409 in-flight guard (TTL-bounded) ──────────────────────────────────────
217
247
  // The status file is single-slot (one hub, one upgrade). If a prior upgrade
218
248
  // is still in a non-terminal phase (pending/running/restarting), starting a
219
249
  // SECOND would overwrite its operation_id — and a still-running first helper
@@ -222,9 +252,15 @@ export async function handleHubUpgrade(req: Request, deps: ApiHubUpgradeDeps): P
222
252
  // server-side too (a second tab, a stale page, a scripted POST). Reject with
223
253
  // 409 unless the slot is free (no file) or the prior op reached a terminal
224
254
  // phase (failed / redeploy-required / succeeded).
255
+ //
256
+ // #506: BUT a non-terminal slot is only a real block while it's FRESH. A
257
+ // helper that crashed (OOM / killed / host reboot) leaves the slot stuck
258
+ // in-flight forever and would 409-deadlock every future upgrade. So an
259
+ // in-flight slot older than IN_FLIGHT_TTL_MS is treated as ABANDONED and the
260
+ // request proceeds (the seeded status below overwrites the stale slot).
225
261
  const readStatus = deps.readStatus ?? readHubUpgradeStatus;
226
262
  const existing = readStatus(deps.configDir);
227
- if (existing && IN_FLIGHT_PHASES.has(existing.phase)) {
263
+ if (existing && IN_FLIGHT_PHASES.has(existing.phase) && isInFlightFresh(existing, now)) {
228
264
  return jsonError(
229
265
  409,
230
266
  "upgrade_in_flight",
@@ -234,7 +270,6 @@ export async function handleHubUpgrade(req: Request, deps: ApiHubUpgradeDeps): P
234
270
 
235
271
  const hubSrcDir = deps.hubSrcDir ?? dirname(fileURLToPath(import.meta.url));
236
272
  const env = deps.env ?? process.env;
237
- const now = (deps.now ?? (() => new Date()))();
238
273
 
239
274
  const currentVersion = (deps.currentVersion ?? (() => defaultCurrentVersion(hubSrcDir)))();
240
275
  // Auto-detect the channel from the current version when not explicitly set —
package/src/api-me.ts CHANGED
@@ -14,7 +14,12 @@
14
14
  * Response shape:
15
15
  *
16
16
  * { hasSession: false }
17
- * { hasSession: true, user: { id, displayName }, csrf: "<token>" }
17
+ * { hasSession: true, user: { id, displayName }, csrf: "<token>",
18
+ * two_factor_enabled: boolean }
19
+ *
20
+ * `two_factor_enabled` (hub#85) lets the SPA's "My account" page render the
21
+ * 2FA status without a separate read. It reflects `users.totp_secret` being
22
+ * set for the signed-in user.
18
23
  *
19
24
  * `displayName` is the user's `username` today — there's no separate
20
25
  * display-name field on the User shape. Surfaced under a different key
@@ -39,6 +44,7 @@
39
44
  import type { Database } from "bun:sqlite";
40
45
  import { ensureCsrfToken } from "./csrf.ts";
41
46
  import { findActiveSession } from "./sessions.ts";
47
+ import { isTotpEnrolled } from "./two-factor-store.ts";
42
48
  import { getUserById } from "./users.ts";
43
49
 
44
50
  export interface ApiMeDeps {
@@ -59,7 +65,9 @@ interface SignedInUser {
59
65
  * that mixes states — e.g. `{ hasSession: false, user: staleUser }`
60
66
  * fails at the type-check, not just at code-review.
61
67
  */
62
- type ApiMeResponse = { hasSession: false } | { hasSession: true; user: SignedInUser; csrf: string };
68
+ type ApiMeResponse =
69
+ | { hasSession: false }
70
+ | { hasSession: true; user: SignedInUser; csrf: string; two_factor_enabled: boolean };
63
71
 
64
72
  export function handleApiMe(req: Request, deps: ApiMeDeps): Response {
65
73
  if (req.method !== "GET") {
@@ -99,6 +107,7 @@ export function handleApiMe(req: Request, deps: ApiMeDeps): Response {
99
107
  displayName: user.username,
100
108
  },
101
109
  csrf: csrf.token,
110
+ two_factor_enabled: isTotpEnrolled(deps.db, user.id),
102
111
  };
103
112
  return new Response(JSON.stringify(body), { status: 200, headers });
104
113
  }
package/src/cli.ts CHANGED
@@ -402,23 +402,63 @@ async function main(argv: string[]): Promise<number> {
402
402
  );
403
403
  return 1;
404
404
  }
405
- const noBrowser = exposeExtract.rest.includes("--no-browser");
406
- const noExposePrompt = exposeExtract.rest.includes("--no-expose-prompt");
407
- const cliWizard = exposeExtract.rest.includes("--cli-wizard");
408
- const browserWizard = exposeExtract.rest.includes("--browser-wizard");
405
+ // hub#694 bug 2: `--channel rc|latest` picks the dist-tag init installs the
406
+ // vault module from. Without it, init always resolved @latest — DOWNGRADING
407
+ // vault below an rc-tracking hub. Mirrors `parachute install --channel`.
408
+ const channelExtract = extractNamedFlag(exposeExtract.rest, "--channel");
409
+ if (channelExtract.error) {
410
+ console.error(`parachute init: ${channelExtract.error}`);
411
+ return 1;
412
+ }
413
+ if (
414
+ channelExtract.value !== undefined &&
415
+ channelExtract.value !== "rc" &&
416
+ channelExtract.value !== "latest"
417
+ ) {
418
+ console.error(
419
+ `parachute init: --channel must be "rc" or "latest" (got "${channelExtract.value}")`,
420
+ );
421
+ return 1;
422
+ }
423
+ // #478 Part 2: --vault-name <name> creates the first vault in one shot.
424
+ const vaultNameExtract = extractNamedFlag(channelExtract.rest, "--vault-name");
425
+ if (vaultNameExtract.error) {
426
+ console.error(`parachute init: ${vaultNameExtract.error}`);
427
+ return 1;
428
+ }
429
+ let validatedVaultName: string | undefined;
430
+ if (vaultNameExtract.value !== undefined) {
431
+ if (vaultNameExtract.value.trim() === "") {
432
+ console.error("parachute init: --vault-name must not be empty.");
433
+ return 1;
434
+ }
435
+ const { validateVaultName: vvn } = await import("./vault-name.ts");
436
+ const vr = vvn(vaultNameExtract.value);
437
+ if (!vr.ok) {
438
+ console.error(`parachute init: invalid --vault-name: ${vr.error}`);
439
+ return 1;
440
+ }
441
+ validatedVaultName = vr.name;
442
+ }
443
+ const noBrowser = vaultNameExtract.rest.includes("--no-browser");
444
+ const noExposePrompt = vaultNameExtract.rest.includes("--no-expose-prompt");
445
+ const cliWizard = vaultNameExtract.rest.includes("--cli-wizard");
446
+ const browserWizard = vaultNameExtract.rest.includes("--browser-wizard");
409
447
  const known = new Set([
410
448
  "--no-browser",
411
449
  "--no-expose-prompt",
412
450
  "--cli-wizard",
413
451
  "--browser-wizard",
414
452
  ]);
415
- const unknown = exposeExtract.rest.find((a) => !known.has(a));
453
+ const unknown = vaultNameExtract.rest.find((a) => !known.has(a));
416
454
  if (unknown !== undefined) {
417
455
  console.error(`parachute init: unknown argument "${unknown}"`);
418
456
  console.error(
419
457
  "usage: parachute init [--no-browser] [--no-expose-prompt]\n" +
420
458
  " [--expose none|tailnet|cloudflare]\n" +
459
+ " [--channel rc|latest]\n" +
421
460
  " [--hub-origin <url>]\n" +
461
+ " [--vault-name <name>]\n" +
422
462
  " [--cli-wizard | --browser-wizard]",
423
463
  );
424
464
  return 1;
@@ -434,6 +474,10 @@ async function main(argv: string[]): Promise<number> {
434
474
  if (exposeExtract.value) {
435
475
  initOpts.exposeChoice = exposeExtract.value as "none" | "tailnet" | "cloudflare";
436
476
  }
477
+ if (channelExtract.value === "rc" || channelExtract.value === "latest") {
478
+ initOpts.channel = channelExtract.value;
479
+ }
480
+ if (validatedVaultName !== undefined) initOpts.vaultName = validatedVaultName;
437
481
  if (cliWizard) initOpts.wizardChoice = "cli";
438
482
  else if (browserWizard) initOpts.wizardChoice = "browser";
439
483
  const mod = await loadCommand("init", () => import("./commands/init.ts"));
package/src/clients.ts CHANGED
@@ -323,9 +323,23 @@ function timingSafeEqualHex(a: string, b: string): boolean {
323
323
  * URIs). Doesn't try to match a registered URI; that's `requireRegisteredRedirectUri`.
324
324
  */
325
325
  export function isValidRedirectUri(uri: string): boolean {
326
+ // hub#663: reject control chars (C0 0x00-0x1f + DEL 0x7f) in the RAW input
327
+ // BEFORE URL parsing normalizes/strips them. A `\r`/`\n`/NUL smuggled into a
328
+ // redirect_uri is a header/log-injection vector even though our exact-match +
329
+ // verbatim foreign-storage neutralize it downstream — spec-forbidden hygiene.
330
+ // (Charcode scan rather than a control-char regex literal, which biome's
331
+ // noControlCharactersInRegex rightly flags as an easy footgun.)
332
+ for (let i = 0; i < uri.length; i++) {
333
+ const c = uri.charCodeAt(i);
334
+ if (c <= 0x1f || c === 0x7f) return false;
335
+ }
326
336
  try {
327
337
  const u = new URL(uri);
328
338
  if (u.protocol === "javascript:" || u.protocol === "data:") return false;
339
+ // hub#663: reject userinfo (`https://x@evil.com/cb`). A redirect target
340
+ // carrying credentials is spec-forbidden and an open-redirect / phishing
341
+ // shape; the protocol allowlist alone let it through.
342
+ if (u.username !== "" || u.password !== "") return false;
329
343
  return u.protocol === "http:" || u.protocol === "https:";
330
344
  } catch {
331
345
  return false;