@openparachute/vault 0.1.0

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 (103) hide show
  1. package/.claude/settings.local.json +31 -0
  2. package/.dockerignore +8 -0
  3. package/.env.example +9 -0
  4. package/.playwright-mcp/console-2026-04-14T04-17-25-395Z.log +2 -0
  5. package/.playwright-mcp/console-2026-04-14T04-18-11-767Z.log +1 -0
  6. package/.playwright-mcp/console-2026-04-14T04-19-07-733Z.log +2 -0
  7. package/.playwright-mcp/console-2026-04-14T04-20-45-440Z.log +2 -0
  8. package/.playwright-mcp/page-2026-04-14T04-17-25-536Z.yml +1 -0
  9. package/.playwright-mcp/page-2026-04-14T04-18-11-816Z.yml +1 -0
  10. package/.playwright-mcp/page-2026-04-14T04-18-31-674Z.yml +211 -0
  11. package/.playwright-mcp/page-2026-04-14T04-19-07-795Z.yml +59 -0
  12. package/.playwright-mcp/page-2026-04-14T04-19-36-239Z.yml +232 -0
  13. package/.playwright-mcp/page-2026-04-14T04-19-58-327Z.yml +182 -0
  14. package/.playwright-mcp/page-2026-04-14T04-20-10-517Z.yml +91 -0
  15. package/.playwright-mcp/page-2026-04-14T04-20-14-796Z.yml +70 -0
  16. package/.playwright-mcp/page-2026-04-14T04-20-45-509Z.yml +59 -0
  17. package/CLAUDE.md +115 -0
  18. package/Caddyfile +3 -0
  19. package/Dockerfile +22 -0
  20. package/LICENSE +661 -0
  21. package/README.md +356 -0
  22. package/bun.lock +219 -0
  23. package/bunfig.toml +2 -0
  24. package/core/package.json +7 -0
  25. package/core/src/core.test.ts +940 -0
  26. package/core/src/hooks.test.ts +361 -0
  27. package/core/src/hooks.ts +234 -0
  28. package/core/src/links.ts +352 -0
  29. package/core/src/mcp.ts +672 -0
  30. package/core/src/notes.ts +520 -0
  31. package/core/src/obsidian.test.ts +380 -0
  32. package/core/src/obsidian.ts +322 -0
  33. package/core/src/paths.test.ts +197 -0
  34. package/core/src/paths.ts +53 -0
  35. package/core/src/schema.ts +331 -0
  36. package/core/src/store.ts +303 -0
  37. package/core/src/tag-schemas.ts +104 -0
  38. package/core/src/test-preload.ts +8 -0
  39. package/core/src/types.ts +140 -0
  40. package/core/src/wikilinks.test.ts +277 -0
  41. package/core/src/wikilinks.ts +402 -0
  42. package/deploy/parachute-vault.service +20 -0
  43. package/docker-compose.yml +50 -0
  44. package/docs/HTTP_API.md +328 -0
  45. package/fly.toml +24 -0
  46. package/package.json +32 -0
  47. package/railway.json +14 -0
  48. package/religions-abrahamic-filter.png +0 -0
  49. package/religions-buddhism-v2.png +0 -0
  50. package/religions-buddhism.png +0 -0
  51. package/religions-final.png +0 -0
  52. package/religions-v1.png +0 -0
  53. package/religions-v2.png +0 -0
  54. package/religions-zen.png +0 -0
  55. package/scripts/migrate-audio-to-opus.test.ts +237 -0
  56. package/scripts/migrate-audio-to-opus.ts +499 -0
  57. package/src/auth.ts +170 -0
  58. package/src/cli.ts +1131 -0
  59. package/src/config-triggers.test.ts +83 -0
  60. package/src/config.test.ts +125 -0
  61. package/src/config.ts +716 -0
  62. package/src/db.ts +14 -0
  63. package/src/launchd.ts +109 -0
  64. package/src/mcp-http.ts +113 -0
  65. package/src/mcp-tools.ts +155 -0
  66. package/src/oauth.test.ts +1242 -0
  67. package/src/oauth.ts +729 -0
  68. package/src/owner-auth.ts +159 -0
  69. package/src/prompt.ts +141 -0
  70. package/src/published.test.ts +214 -0
  71. package/src/qrcode-terminal.d.ts +9 -0
  72. package/src/routes.ts +822 -0
  73. package/src/server.ts +450 -0
  74. package/src/systemd.ts +84 -0
  75. package/src/token-store.test.ts +174 -0
  76. package/src/token-store.ts +241 -0
  77. package/src/triggers.test.ts +397 -0
  78. package/src/triggers.ts +412 -0
  79. package/src/two-factor.test.ts +246 -0
  80. package/src/two-factor.ts +222 -0
  81. package/src/vault-store.ts +47 -0
  82. package/src/vault.test.ts +1309 -0
  83. package/tsconfig.json +29 -0
  84. package/web/README.md +73 -0
  85. package/web/bun.lock +827 -0
  86. package/web/eslint.config.js +23 -0
  87. package/web/index.html +15 -0
  88. package/web/package.json +36 -0
  89. package/web/public/favicon.svg +1 -0
  90. package/web/public/icons.svg +24 -0
  91. package/web/src/App.tsx +149 -0
  92. package/web/src/Graph.tsx +200 -0
  93. package/web/src/NoteView.tsx +155 -0
  94. package/web/src/Sidebar.tsx +186 -0
  95. package/web/src/api.ts +21 -0
  96. package/web/src/index.css +50 -0
  97. package/web/src/main.tsx +10 -0
  98. package/web/src/types.ts +37 -0
  99. package/web/src/utils.ts +107 -0
  100. package/web/tsconfig.app.json +25 -0
  101. package/web/tsconfig.json +7 -0
  102. package/web/tsconfig.node.json +24 -0
  103. package/web/vite.config.ts +15 -0
package/src/oauth.ts ADDED
@@ -0,0 +1,729 @@
1
+ /**
2
+ * OAuth 2.1 provider for Parachute Vault.
3
+ *
4
+ * Implements the subset of OAuth 2.1 needed for MCP clients (Claude Web,
5
+ * Claude Desktop, etc.) to connect via the standard browser-based flow:
6
+ *
7
+ * 1. Dynamic Client Registration (RFC 7591) — POST /oauth/register
8
+ * 2. Authorization endpoint (PKCE required) — GET/POST /oauth/authorize
9
+ * 3. Token endpoint (code exchange) — POST /oauth/token
10
+ * 4. Discovery endpoints — GET /.well-known/*
11
+ *
12
+ * The flow produces a standard `pvt_` token stored in the vault's tokens table.
13
+ * After the OAuth handshake, all requests use the same Bearer token auth path.
14
+ */
15
+
16
+ import crypto from "node:crypto";
17
+ import type { Database } from "bun:sqlite";
18
+ import { generateToken, createToken, resolveToken } from "./token-store.ts";
19
+ import type { TokenPermission } from "./token-store.ts";
20
+ import { verifyOwnerPassword, authorizeRateLimit, type RateLimiter } from "./owner-auth.ts";
21
+ import { verifyTotpCode, verifyAndConsumeBackupCode } from "./two-factor.ts";
22
+
23
+ /** Options for handleAuthorizePost. */
24
+ export interface AuthorizePostOptions {
25
+ vaultName?: string;
26
+ /** Client IP address (from Bun server.requestIP). If provided, rate limiting is applied. */
27
+ clientIp?: string;
28
+ /**
29
+ * Bcrypt hash of the owner password. When set, the consent form requires a
30
+ * `password` field. When null/undefined, falls back to legacy `owner_token`
31
+ * auth (vault token in the consent form).
32
+ */
33
+ ownerPasswordHash?: string | null;
34
+ /**
35
+ * Base32-encoded TOTP secret. When set, consent additionally requires a
36
+ * `totp_code` (6-digit) or `backup_code` form field.
37
+ */
38
+ totpSecret?: string | null;
39
+ /** Override for testing; defaults to the module singleton. */
40
+ rateLimiter?: RateLimiter;
41
+ }
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Helpers
45
+ // ---------------------------------------------------------------------------
46
+
47
+ function getBaseUrl(req: Request): string {
48
+ const forwardedHost = req.headers.get("x-forwarded-host");
49
+ const forwardedProto = req.headers.get("x-forwarded-proto");
50
+ if (forwardedHost) {
51
+ return `${forwardedProto || "https"}://${forwardedHost}`;
52
+ }
53
+ // Fall back to the request URL's origin
54
+ const url = new URL(req.url);
55
+ return url.origin;
56
+ }
57
+
58
+ function escapeHtml(s: string): string {
59
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
60
+ }
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // Discovery endpoints
64
+ // ---------------------------------------------------------------------------
65
+
66
+ export function handleProtectedResource(req: Request, mcpPath = "/mcp"): Response {
67
+ const base = getBaseUrl(req);
68
+ return Response.json({
69
+ resource: `${base}${mcpPath}`,
70
+ authorization_servers: [base],
71
+ scopes_supported: ["full", "read"],
72
+ bearer_methods_supported: ["header"],
73
+ });
74
+ }
75
+
76
+ export function handleAuthorizationServer(req: Request): Response {
77
+ const base = getBaseUrl(req);
78
+ return Response.json({
79
+ issuer: base,
80
+ authorization_endpoint: `${base}/oauth/authorize`,
81
+ token_endpoint: `${base}/oauth/token`,
82
+ registration_endpoint: `${base}/oauth/register`,
83
+ response_types_supported: ["code"],
84
+ code_challenge_methods_supported: ["S256"],
85
+ grant_types_supported: ["authorization_code"],
86
+ token_endpoint_auth_methods_supported: ["none"],
87
+ scopes_supported: ["full", "read"],
88
+ });
89
+ }
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // Dynamic Client Registration (RFC 7591)
93
+ // ---------------------------------------------------------------------------
94
+
95
+ export async function handleRegister(req: Request, db: Database): Promise<Response> {
96
+ if (req.method !== "POST") {
97
+ return Response.json({ error: "method_not_allowed" }, { status: 405 });
98
+ }
99
+
100
+ let body: any;
101
+ try {
102
+ body = await req.json();
103
+ } catch {
104
+ return Response.json({ error: "invalid_request", error_description: "Invalid JSON body" }, { status: 400 });
105
+ }
106
+
107
+ const redirectUris = body.redirect_uris;
108
+ if (!Array.isArray(redirectUris) || redirectUris.length === 0) {
109
+ return Response.json(
110
+ { error: "invalid_client_metadata", error_description: "redirect_uris is required" },
111
+ { status: 400 },
112
+ );
113
+ }
114
+
115
+ const clientId = crypto.randomUUID();
116
+ const clientName = body.client_name || "Unknown Client";
117
+ const now = new Date().toISOString();
118
+
119
+ db.prepare(`
120
+ INSERT INTO oauth_clients (client_id, client_name, redirect_uris, created_at)
121
+ VALUES (?, ?, ?, ?)
122
+ `).run(clientId, clientName, JSON.stringify(redirectUris), now);
123
+
124
+ return Response.json({
125
+ client_id: clientId,
126
+ client_name: clientName,
127
+ redirect_uris: redirectUris,
128
+ grant_types: ["authorization_code"],
129
+ response_types: ["code"],
130
+ token_endpoint_auth_method: "none",
131
+ }, { status: 201 });
132
+ }
133
+
134
+ // ---------------------------------------------------------------------------
135
+ // Authorization endpoint
136
+ // ---------------------------------------------------------------------------
137
+
138
+ export function handleAuthorizeGet(
139
+ req: Request,
140
+ db: Database,
141
+ vaultName: string,
142
+ ownerPasswordHash?: string | null,
143
+ totpEnrolled = false,
144
+ ): Response {
145
+ const url = new URL(req.url);
146
+ const clientId = url.searchParams.get("client_id");
147
+ const redirectUri = url.searchParams.get("redirect_uri");
148
+ const codeChallenge = url.searchParams.get("code_challenge");
149
+ const codeChallengeMethod = url.searchParams.get("code_challenge_method") || "S256";
150
+ const responseType = url.searchParams.get("response_type");
151
+ const scope = url.searchParams.get("scope") || "full";
152
+ const state = url.searchParams.get("state") || "";
153
+
154
+ // Validate required params
155
+ if (!clientId || !redirectUri || !codeChallenge || responseType !== "code") {
156
+ return new Response(renderErrorPage("Missing or invalid parameters. Required: client_id, redirect_uri, code_challenge, response_type=code"), {
157
+ status: 400,
158
+ headers: { "Content-Type": "text/html; charset=utf-8" },
159
+ });
160
+ }
161
+
162
+ if (codeChallengeMethod !== "S256") {
163
+ return new Response(renderErrorPage("Only S256 code challenge method is supported."), {
164
+ status: 400,
165
+ headers: { "Content-Type": "text/html; charset=utf-8" },
166
+ });
167
+ }
168
+
169
+ // Validate client
170
+ const client = db.prepare("SELECT client_id, client_name, redirect_uris FROM oauth_clients WHERE client_id = ?")
171
+ .get(clientId) as { client_id: string; client_name: string; redirect_uris: string } | null;
172
+
173
+ if (!client) {
174
+ return new Response(renderErrorPage("Unknown client."), {
175
+ status: 400,
176
+ headers: { "Content-Type": "text/html; charset=utf-8" },
177
+ });
178
+ }
179
+
180
+ // Validate redirect_uri matches registration
181
+ const registeredUris: string[] = JSON.parse(client.redirect_uris);
182
+ if (!registeredUris.includes(redirectUri)) {
183
+ return new Response(renderErrorPage("Redirect URI does not match registered client."), {
184
+ status: 400,
185
+ headers: { "Content-Type": "text/html; charset=utf-8" },
186
+ });
187
+ }
188
+
189
+ // Normalize requested scope. The user can change it via the radio buttons.
190
+ const requestedScope: TokenPermission = scope === "read" ? "read" : "full";
191
+
192
+ // Render consent page
193
+ const html = renderConsentPage({
194
+ vaultName,
195
+ clientName: client.client_name,
196
+ requestedScope,
197
+ selectedScope: requestedScope,
198
+ clientId,
199
+ redirectUri,
200
+ codeChallenge,
201
+ codeChallengeMethod,
202
+ state,
203
+ passwordMode: typeof ownerPasswordHash === "string" && ownerPasswordHash.length > 0,
204
+ totpEnrolled,
205
+ });
206
+
207
+ return new Response(html, {
208
+ status: 200,
209
+ headers: { "Content-Type": "text/html; charset=utf-8" },
210
+ });
211
+ }
212
+
213
+ export async function handleAuthorizePost(
214
+ req: Request,
215
+ db: Database,
216
+ opts: AuthorizePostOptions = {},
217
+ ): Promise<Response> {
218
+ const { vaultName, clientIp, ownerPasswordHash, totpSecret, rateLimiter = authorizeRateLimit } = opts;
219
+ const totpEnrolled = typeof totpSecret === "string" && totpSecret.length > 0;
220
+
221
+ let form: FormData;
222
+ try {
223
+ form = await req.formData();
224
+ } catch {
225
+ return Response.json({ error: "invalid_request" }, { status: 400 });
226
+ }
227
+
228
+ const action = form.get("action") as string;
229
+ const clientId = form.get("client_id") as string;
230
+ const redirectUri = form.get("redirect_uri") as string;
231
+ const codeChallenge = form.get("code_challenge") as string;
232
+ const codeChallengeMethod = form.get("code_challenge_method") as string || "S256";
233
+ // Requested scope (from hidden field, carried from GET) and selected scope
234
+ // (from radio button on the consent page). Default selected to requested.
235
+ const requestedScope = form.get("scope") as string || "full";
236
+ const selectedScopeRaw = form.get("selected_scope") as string | null;
237
+ const selectedScope = selectedScopeRaw === "read" || selectedScopeRaw === "full"
238
+ ? selectedScopeRaw
239
+ : (requestedScope === "read" ? "read" : "full");
240
+ const state = form.get("state") as string || "";
241
+
242
+ if (!clientId || !redirectUri || !codeChallenge) {
243
+ return Response.json({ error: "invalid_request" }, { status: 400 });
244
+ }
245
+
246
+ // Validate client and redirect_uri BEFORE constructing any redirect.
247
+ // This prevents open-redirect attacks via crafted redirect_uri values.
248
+ const client = db.prepare("SELECT redirect_uris FROM oauth_clients WHERE client_id = ?")
249
+ .get(clientId) as { redirect_uris: string } | null;
250
+
251
+ if (!client) {
252
+ return Response.json({ error: "invalid_request", error_description: "Unknown client" }, { status: 400 });
253
+ }
254
+
255
+ const registeredUris: string[] = JSON.parse(client.redirect_uris);
256
+ if (!registeredUris.includes(redirectUri)) {
257
+ return Response.json({ error: "invalid_request", error_description: "redirect_uri mismatch" }, { status: 400 });
258
+ }
259
+
260
+ // Only S256 is supported
261
+ if (codeChallengeMethod !== "S256") {
262
+ return Response.json({ error: "invalid_request", error_description: "Only S256 code challenge method is supported" }, { status: 400 });
263
+ }
264
+
265
+ const redirect = new URL(redirectUri);
266
+ if (state) redirect.searchParams.set("state", state);
267
+
268
+ // User denied
269
+ if (action === "deny") {
270
+ redirect.searchParams.set("error", "access_denied");
271
+ return Response.redirect(redirect.toString(), 302);
272
+ }
273
+
274
+ // Rate-limit the owner-auth step. Applied before any credential check so
275
+ // brute-force attempts are capped regardless of which path (password or
276
+ // legacy token) is being used.
277
+ if (clientIp) {
278
+ const gate = rateLimiter.check(clientIp);
279
+ if (!gate.allowed) {
280
+ return new Response(renderErrorPage(
281
+ `Too many failed attempts. Try again in ${Math.ceil(gate.retryAfterSec / 60)} minute(s).`,
282
+ ), {
283
+ status: 429,
284
+ headers: {
285
+ "Content-Type": "text/html; charset=utf-8",
286
+ "Retry-After": String(gate.retryAfterSec),
287
+ },
288
+ });
289
+ }
290
+ }
291
+
292
+ // Verify owner identity — password if configured, else legacy vault token.
293
+ const passwordMode = typeof ownerPasswordHash === "string" && ownerPasswordHash.length > 0;
294
+ let ownerOk = false;
295
+ let errorMsg = "";
296
+
297
+ if (passwordMode) {
298
+ const password = form.get("password") as string;
299
+ if (!password) {
300
+ errorMsg = "Password is required.";
301
+ } else {
302
+ ownerOk = await verifyOwnerPassword(password, ownerPasswordHash!);
303
+ // Keep failure messages uniform across password / TOTP / backup-code so
304
+ // an attacker can't tell which factor was wrong.
305
+ if (!ownerOk) errorMsg = "Invalid credentials.";
306
+ }
307
+ } else {
308
+ const ownerToken = form.get("owner_token") as string;
309
+ if (!ownerToken) {
310
+ errorMsg = "Vault token is required.";
311
+ } else {
312
+ ownerOk = resolveToken(db, ownerToken) !== null;
313
+ if (!ownerOk) errorMsg = "Invalid vault token.";
314
+ }
315
+ }
316
+
317
+ if (!ownerOk) {
318
+ if (clientIp) rateLimiter.recordFailure(clientIp);
319
+ return renderConsentWithError(db, vaultName || "vault", {
320
+ clientId, redirectUri, codeChallenge, codeChallengeMethod,
321
+ requestedScope, selectedScope, state, passwordMode, totpEnrolled,
322
+ error: errorMsg,
323
+ });
324
+ }
325
+
326
+ // 2FA check — password passed, now verify TOTP or backup code.
327
+ if (totpEnrolled) {
328
+ const totpCode = ((form.get("totp_code") as string | null) ?? "").trim();
329
+ const backupCode = ((form.get("backup_code") as string | null) ?? "").trim();
330
+ let twoFaOk = false;
331
+ let twoFaError = "";
332
+ if (totpCode) {
333
+ twoFaOk = verifyTotpCode(totpSecret!, totpCode);
334
+ if (!twoFaOk) twoFaError = "Invalid credentials.";
335
+ } else if (backupCode) {
336
+ twoFaOk = await verifyAndConsumeBackupCode(backupCode);
337
+ if (!twoFaOk) twoFaError = "Invalid credentials.";
338
+ } else {
339
+ twoFaError = "Enter a 6-digit code from your authenticator app, or a backup code.";
340
+ }
341
+ if (!twoFaOk) {
342
+ if (clientIp) rateLimiter.recordFailure(clientIp);
343
+ return renderConsentWithError(db, vaultName || "vault", {
344
+ clientId, redirectUri, codeChallenge, codeChallengeMethod,
345
+ requestedScope, selectedScope, state, passwordMode, totpEnrolled,
346
+ error: twoFaError,
347
+ });
348
+ }
349
+ }
350
+
351
+ if (clientIp) rateLimiter.recordSuccess(clientIp);
352
+
353
+ // Generate auth code — persist the user-selected scope (not the requested one)
354
+ const code = crypto.randomBytes(32).toString("base64url");
355
+ const expiresAt = new Date(Date.now() + 10 * 60 * 1000).toISOString(); // 10 minutes
356
+
357
+ db.prepare(`
358
+ INSERT INTO oauth_codes (code, client_id, code_challenge, code_challenge_method, scope, redirect_uri, expires_at, created_at)
359
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
360
+ `).run(code, clientId, codeChallenge, codeChallengeMethod, selectedScope, redirectUri, expiresAt, new Date().toISOString());
361
+
362
+ redirect.searchParams.set("code", code);
363
+ return Response.redirect(redirect.toString(), 302);
364
+ }
365
+
366
+ // ---------------------------------------------------------------------------
367
+ // Token endpoint
368
+ // ---------------------------------------------------------------------------
369
+
370
+ export async function handleToken(req: Request, db: Database): Promise<Response> {
371
+ if (req.method !== "POST") {
372
+ return Response.json({ error: "method_not_allowed" }, { status: 405 });
373
+ }
374
+
375
+ let params: URLSearchParams;
376
+ const contentType = req.headers.get("content-type") || "";
377
+ if (contentType.includes("application/x-www-form-urlencoded")) {
378
+ params = new URLSearchParams(await req.text());
379
+ } else if (contentType.includes("application/json")) {
380
+ try {
381
+ const body = await req.json();
382
+ params = new URLSearchParams(body as Record<string, string>);
383
+ } catch {
384
+ return Response.json({ error: "invalid_request" }, { status: 400 });
385
+ }
386
+ } else {
387
+ params = new URLSearchParams(await req.text());
388
+ }
389
+
390
+ const grantType = params.get("grant_type");
391
+
392
+ if (grantType !== "authorization_code") {
393
+ return Response.json({ error: "unsupported_grant_type" }, { status: 400 });
394
+ }
395
+
396
+ const code = params.get("code");
397
+ const codeVerifier = params.get("code_verifier");
398
+ const clientId = params.get("client_id");
399
+ const redirectUri = params.get("redirect_uri");
400
+
401
+ if (!code || !codeVerifier || !clientId || !redirectUri) {
402
+ return Response.json({ error: "invalid_request", error_description: "Missing required parameters" }, { status: 400 });
403
+ }
404
+
405
+ // Look up the auth code
406
+ const authCode = db.prepare(`
407
+ SELECT code, client_id, code_challenge, code_challenge_method, scope, redirect_uri, expires_at, used
408
+ FROM oauth_codes WHERE code = ?
409
+ `).get(code) as {
410
+ code: string;
411
+ client_id: string;
412
+ code_challenge: string;
413
+ code_challenge_method: string;
414
+ scope: string;
415
+ redirect_uri: string;
416
+ expires_at: string;
417
+ used: number;
418
+ } | null;
419
+
420
+ if (!authCode) {
421
+ return Response.json({ error: "invalid_grant", error_description: "Invalid authorization code" }, { status: 400 });
422
+ }
423
+
424
+ // Check single-use
425
+ if (authCode.used) {
426
+ return Response.json({ error: "invalid_grant", error_description: "Authorization code already used" }, { status: 400 });
427
+ }
428
+
429
+ // Check expiry
430
+ if (new Date(authCode.expires_at) < new Date()) {
431
+ return Response.json({ error: "invalid_grant", error_description: "Authorization code expired" }, { status: 400 });
432
+ }
433
+
434
+ // Validate client_id matches
435
+ if (authCode.client_id !== clientId) {
436
+ return Response.json({ error: "invalid_grant", error_description: "client_id mismatch" }, { status: 400 });
437
+ }
438
+
439
+ // Validate redirect_uri matches
440
+ if (authCode.redirect_uri !== redirectUri) {
441
+ return Response.json({ error: "invalid_grant", error_description: "redirect_uri mismatch" }, { status: 400 });
442
+ }
443
+
444
+ // PKCE verification: SHA256(code_verifier) must match stored code_challenge
445
+ const expectedChallenge = crypto
446
+ .createHash("sha256")
447
+ .update(codeVerifier)
448
+ .digest("base64url");
449
+
450
+ if (expectedChallenge !== authCode.code_challenge) {
451
+ return Response.json({ error: "invalid_grant", error_description: "PKCE verification failed" }, { status: 400 });
452
+ }
453
+
454
+ // Mark code as used
455
+ db.prepare("UPDATE oauth_codes SET used = 1 WHERE code = ?").run(code);
456
+
457
+ // Create a real pvt_ token
458
+ const permission: TokenPermission = authCode.scope === "read" ? "read" : "full";
459
+ const { fullToken } = generateToken();
460
+ createToken(db, fullToken, {
461
+ label: `oauth:${clientId.slice(0, 8)}`,
462
+ permission,
463
+ });
464
+
465
+ return Response.json({
466
+ access_token: fullToken,
467
+ token_type: "bearer",
468
+ scope: permission,
469
+ });
470
+ }
471
+
472
+ // ---------------------------------------------------------------------------
473
+ // Consent page re-render with error
474
+ // ---------------------------------------------------------------------------
475
+
476
+ function renderConsentWithError(
477
+ db: Database,
478
+ vaultName: string,
479
+ params: {
480
+ clientId: string;
481
+ redirectUri: string;
482
+ codeChallenge: string;
483
+ codeChallengeMethod: string;
484
+ requestedScope: string;
485
+ selectedScope: string;
486
+ state: string;
487
+ passwordMode: boolean;
488
+ totpEnrolled: boolean;
489
+ error: string;
490
+ },
491
+ ): Response {
492
+ const client = db.prepare("SELECT client_name FROM oauth_clients WHERE client_id = ?")
493
+ .get(params.clientId) as { client_name: string } | null;
494
+ const clientName = client?.client_name || "Unknown Client";
495
+ const requested: TokenPermission = params.requestedScope === "read" ? "read" : "full";
496
+ const selected: TokenPermission = params.selectedScope === "read" ? "read" : "full";
497
+
498
+ const html = renderConsentPage({
499
+ vaultName,
500
+ clientName,
501
+ requestedScope: requested,
502
+ selectedScope: selected,
503
+ clientId: params.clientId,
504
+ redirectUri: params.redirectUri,
505
+ codeChallenge: params.codeChallenge,
506
+ codeChallengeMethod: params.codeChallengeMethod,
507
+ state: params.state,
508
+ passwordMode: params.passwordMode,
509
+ totpEnrolled: params.totpEnrolled,
510
+ error: params.error,
511
+ });
512
+
513
+ return new Response(html, {
514
+ status: 200,
515
+ headers: { "Content-Type": "text/html; charset=utf-8" },
516
+ });
517
+ }
518
+
519
+ // ---------------------------------------------------------------------------
520
+ // Consent page HTML
521
+ // ---------------------------------------------------------------------------
522
+
523
+ interface ConsentParams {
524
+ vaultName: string;
525
+ clientName: string;
526
+ /** Scope originally requested by the client. */
527
+ requestedScope: TokenPermission;
528
+ /** Scope currently selected in the radio buttons (defaults to requested). */
529
+ selectedScope: TokenPermission;
530
+ clientId: string;
531
+ redirectUri: string;
532
+ codeChallenge: string;
533
+ codeChallengeMethod: string;
534
+ state: string;
535
+ /** When true, render a password field; when false, render a vault-token field (legacy). */
536
+ passwordMode: boolean;
537
+ /** When true, additionally render TOTP + backup-code fields. */
538
+ totpEnrolled?: boolean;
539
+ error?: string;
540
+ }
541
+
542
+ function renderConsentPage(p: ConsentParams): string {
543
+ const fullChecked = p.selectedScope === "full" ? " checked" : "";
544
+ const readChecked = p.selectedScope === "read" ? " checked" : "";
545
+
546
+ const credentialField = p.passwordMode
547
+ ? `<div class="cred-field">
548
+ <label for="password">Owner password</label>
549
+ <input type="password" id="password" name="password" placeholder="Enter your vault password" required autocomplete="current-password">
550
+ </div>`
551
+ : `<div class="cred-field">
552
+ <label for="owner_token">Vault token</label>
553
+ <input type="password" id="owner_token" name="owner_token" placeholder="pvt_..." required autocomplete="off">
554
+ </div>`;
555
+
556
+ return `<!DOCTYPE html>
557
+ <html lang="en">
558
+ <head>
559
+ <meta charset="utf-8">
560
+ <meta name="viewport" content="width=device-width, initial-scale=1">
561
+ <title>Authorize — ${escapeHtml(p.vaultName)}</title>
562
+ <style>
563
+ body {
564
+ max-width: 28rem;
565
+ margin: 4rem auto;
566
+ padding: 0 1rem;
567
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
568
+ line-height: 1.6;
569
+ color: #1a1a1a;
570
+ }
571
+ .card {
572
+ border: 1px solid #e0e0e0;
573
+ border-radius: 8px;
574
+ padding: 2rem;
575
+ }
576
+ h1 { font-size: 1.25rem; margin: 0 0 0.5rem; }
577
+ .client { color: #0066cc; font-weight: 600; }
578
+ .scope-options {
579
+ background: #f5f5f5;
580
+ border-radius: 4px;
581
+ padding: 0.75rem 1rem;
582
+ margin: 1rem 0;
583
+ }
584
+ .scope-option {
585
+ display: flex;
586
+ align-items: flex-start;
587
+ gap: 0.6rem;
588
+ padding: 0.3rem 0;
589
+ cursor: pointer;
590
+ }
591
+ .scope-option input[type="radio"] {
592
+ margin-top: 0.35rem;
593
+ }
594
+ .scope-option-label { font-weight: 600; }
595
+ .scope-option-desc { font-size: 0.85rem; color: #666; }
596
+ .cred-field {
597
+ margin-top: 1rem;
598
+ }
599
+ .cred-field label {
600
+ display: block;
601
+ font-size: 0.9rem;
602
+ font-weight: 600;
603
+ margin-bottom: 0.3rem;
604
+ }
605
+ .cred-field input {
606
+ width: 100%;
607
+ padding: 0.5rem 0.6rem;
608
+ border: 1px solid #ccc;
609
+ border-radius: 4px;
610
+ font-size: 0.9rem;
611
+ font-family: monospace;
612
+ box-sizing: border-box;
613
+ }
614
+ .error-msg {
615
+ color: #cc3333;
616
+ font-size: 0.9rem;
617
+ margin-top: 0.75rem;
618
+ }
619
+ .buttons {
620
+ display: flex;
621
+ gap: 0.75rem;
622
+ margin-top: 1.5rem;
623
+ }
624
+ button {
625
+ flex: 1;
626
+ padding: 0.6rem 1rem;
627
+ border-radius: 6px;
628
+ font-size: 0.95rem;
629
+ cursor: pointer;
630
+ border: 1px solid #ccc;
631
+ background: #fff;
632
+ }
633
+ button[value="authorize"] {
634
+ background: #0066cc;
635
+ color: #fff;
636
+ border-color: #0066cc;
637
+ }
638
+ button[value="authorize"]:hover { background: #0055aa; }
639
+ button[value="deny"]:hover { background: #f5f5f5; }
640
+ @media (prefers-color-scheme: dark) {
641
+ body { background: #1a1a1a; color: #e0e0e0; }
642
+ .card { border-color: #333; }
643
+ .scope-options { background: #2a2a2a; }
644
+ .scope-option-desc { color: #999; }
645
+ .client { color: #66b3ff; }
646
+ .cred-field input { background: #2a2a2a; color: #e0e0e0; border-color: #444; }
647
+ .error-msg { color: #ff6666; }
648
+ button { background: #2a2a2a; color: #e0e0e0; border-color: #444; }
649
+ button[value="authorize"] { background: #0066cc; color: #fff; border-color: #0066cc; }
650
+ button[value="deny"]:hover { background: #333; }
651
+ }
652
+ </style>
653
+ </head>
654
+ <body>
655
+ <div class="card">
656
+ <h1>Authorize access</h1>
657
+ <p><span class="client">${escapeHtml(p.clientName)}</span> wants to access your <strong>${escapeHtml(p.vaultName)}</strong> vault.</p>
658
+ <form method="POST" action="/oauth/authorize">
659
+ <input type="hidden" name="client_id" value="${escapeHtml(p.clientId)}">
660
+ <input type="hidden" name="redirect_uri" value="${escapeHtml(p.redirectUri)}">
661
+ <input type="hidden" name="code_challenge" value="${escapeHtml(p.codeChallenge)}">
662
+ <input type="hidden" name="code_challenge_method" value="${escapeHtml(p.codeChallengeMethod)}">
663
+ <input type="hidden" name="scope" value="${escapeHtml(p.requestedScope)}">
664
+ <input type="hidden" name="state" value="${escapeHtml(p.state)}">
665
+ <div class="scope-options">
666
+ <label class="scope-option">
667
+ <input type="radio" name="selected_scope" value="full"${fullChecked}>
668
+ <span>
669
+ <span class="scope-option-label">Full access</span><br>
670
+ <span class="scope-option-desc">Read, create, update, and delete notes, tags, and links.</span>
671
+ </span>
672
+ </label>
673
+ <label class="scope-option">
674
+ <input type="radio" name="selected_scope" value="read"${readChecked}>
675
+ <span>
676
+ <span class="scope-option-label">Read-only access</span><br>
677
+ <span class="scope-option-desc">Query notes, list tags, and view vault info.</span>
678
+ </span>
679
+ </label>
680
+ </div>
681
+ ${credentialField}
682
+ ${p.totpEnrolled ? `<div class="cred-field">
683
+ <label for="totp_code">Authenticator code</label>
684
+ <input type="text" id="totp_code" name="totp_code" placeholder="6-digit code" inputmode="numeric" pattern="[0-9]*" autocomplete="one-time-code" maxlength="6">
685
+ </div>
686
+ <div class="cred-field">
687
+ <label for="backup_code">Or a backup code</label>
688
+ <input type="text" id="backup_code" name="backup_code" placeholder="single-use backup code" autocomplete="off">
689
+ </div>` : ""}
690
+ ${p.error ? `<div class="error-msg">${escapeHtml(p.error)}</div>` : ""}
691
+ <div class="buttons">
692
+ <button type="submit" name="action" value="deny">Deny</button>
693
+ <button type="submit" name="action" value="authorize">Authorize</button>
694
+ </div>
695
+ </form>
696
+ </div>
697
+ </body>
698
+ </html>`;
699
+ }
700
+
701
+ function renderErrorPage(message: string): string {
702
+ return `<!DOCTYPE html>
703
+ <html lang="en">
704
+ <head>
705
+ <meta charset="utf-8">
706
+ <meta name="viewport" content="width=device-width, initial-scale=1">
707
+ <title>Error — Parachute Vault</title>
708
+ <style>
709
+ body {
710
+ max-width: 28rem;
711
+ margin: 4rem auto;
712
+ padding: 0 1rem;
713
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
714
+ line-height: 1.6;
715
+ color: #1a1a1a;
716
+ }
717
+ .error { color: #cc3333; }
718
+ @media (prefers-color-scheme: dark) {
719
+ body { background: #1a1a1a; color: #e0e0e0; }
720
+ .error { color: #ff6666; }
721
+ }
722
+ </style>
723
+ </head>
724
+ <body>
725
+ <h1 class="error">Authorization Error</h1>
726
+ <p>${escapeHtml(message)}</p>
727
+ </body>
728
+ </html>`;
729
+ }