@openparachute/vault 0.3.3 → 0.4.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 (79) hide show
  1. package/.parachute/module.json +15 -0
  2. package/core/src/core.test.ts +2252 -7
  3. package/core/src/links.ts +1 -1
  4. package/core/src/mcp.ts +801 -67
  5. package/core/src/note-schemas.ts +232 -0
  6. package/core/src/notes.ts +313 -35
  7. package/core/src/obsidian.ts +3 -3
  8. package/core/src/paths.ts +1 -1
  9. package/core/src/query-operators.ts +23 -7
  10. package/core/src/schema-defaults.ts +287 -0
  11. package/core/src/schema.ts +393 -9
  12. package/core/src/store.ts +248 -6
  13. package/core/src/tag-hierarchy.ts +137 -0
  14. package/core/src/tag-schemas.ts +242 -42
  15. package/core/src/types.ts +100 -6
  16. package/core/src/wikilinks.ts +3 -3
  17. package/package.json +13 -3
  18. package/src/admin-spa.test.ts +161 -0
  19. package/src/admin-spa.ts +161 -0
  20. package/src/auth-hub-jwt.test.ts +231 -0
  21. package/src/auth-status.ts +84 -0
  22. package/src/auth.test.ts +135 -23
  23. package/src/auth.ts +144 -15
  24. package/src/backup.ts +4 -7
  25. package/src/cli.ts +322 -57
  26. package/src/config.test.ts +44 -0
  27. package/src/config.ts +68 -40
  28. package/src/hub-jwt.test.ts +296 -0
  29. package/src/hub-jwt.ts +79 -0
  30. package/src/init.test.ts +216 -0
  31. package/src/mcp-http.ts +30 -28
  32. package/src/mcp-install.ts +1 -1
  33. package/src/mcp-tools.ts +294 -6
  34. package/src/module-config.ts +1 -1
  35. package/src/oauth.test.ts +345 -0
  36. package/src/oauth.ts +85 -14
  37. package/src/owner-auth.ts +57 -1
  38. package/src/prompt.ts +6 -5
  39. package/src/routes.ts +686 -58
  40. package/src/routing.test.ts +466 -1
  41. package/src/routing.ts +108 -24
  42. package/src/scopes.test.ts +66 -8
  43. package/src/scopes.ts +163 -37
  44. package/src/server.ts +24 -2
  45. package/src/services-manifest.test.ts +20 -0
  46. package/src/services-manifest.ts +9 -2
  47. package/src/stop-signal.test.ts +85 -0
  48. package/src/storage.test.ts +92 -0
  49. package/src/tag-scope.ts +118 -0
  50. package/src/token-store.test.ts +47 -0
  51. package/src/token-store.ts +128 -13
  52. package/src/tokens-routes.test.ts +720 -0
  53. package/src/tokens-routes.ts +392 -0
  54. package/src/transcription-worker.test.ts +5 -0
  55. package/src/triggers.ts +1 -1
  56. package/src/two-factor.ts +2 -2
  57. package/src/vault-create.test.ts +193 -0
  58. package/src/vault-name.test.ts +123 -0
  59. package/src/vault-name.ts +80 -0
  60. package/src/vault.test.ts +868 -3
  61. package/tsconfig.json +8 -1
  62. package/.claude/settings.local.json +0 -8
  63. package/.dockerignore +0 -8
  64. package/.env.example +0 -9
  65. package/CHANGELOG.md +0 -175
  66. package/CLAUDE.md +0 -125
  67. package/Caddyfile +0 -3
  68. package/Dockerfile +0 -22
  69. package/bun.lock +0 -219
  70. package/bunfig.toml +0 -2
  71. package/deploy/parachute-vault.service +0 -20
  72. package/docker-compose.yml +0 -50
  73. package/docs/HTTP_API.md +0 -434
  74. package/docs/auth-model.md +0 -340
  75. package/fly.toml +0 -24
  76. package/package/package.json +0 -32
  77. package/railway.json +0 -14
  78. package/scripts/migrate-audio-to-opus.test.ts +0 -237
  79. package/scripts/migrate-audio-to-opus.ts +0 -499
package/src/oauth.ts CHANGED
@@ -46,6 +46,37 @@ export interface AuthorizePostOptions {
46
46
  // Helpers
47
47
  // ---------------------------------------------------------------------------
48
48
 
49
+ /**
50
+ * Today the consent page binds one of two scope strings — "read" or "full" —
51
+ * with `read ⊂ full`. `narrowerScope` picks the more-restrictive of two
52
+ * inputs (used to floor `selected` by `requested` at /oauth/authorize),
53
+ * `isScopeSubset` checks an inbound /oauth/token scope against the bound
54
+ * scope. Both default to "full" only if **both** inputs allow "full",
55
+ * otherwise narrow to "read". When the consent vocabulary expands beyond
56
+ * read/full, both helpers should switch to vault:read|write|admin and the
57
+ * inheritance rules in scopes.ts (`hasScope`).
58
+ */
59
+ function normalizeConsentScope(s: string | null | undefined): "read" | "full" {
60
+ return s === "read" ? "read" : "full";
61
+ }
62
+
63
+ function narrowerScope(a: string, b: string): "read" | "full" {
64
+ return normalizeConsentScope(a) === "read" || normalizeConsentScope(b) === "read"
65
+ ? "read"
66
+ : "full";
67
+ }
68
+
69
+ function isScopeSubset(requested: string, bound: string): boolean {
70
+ // Strict: only "read" / "full" are acceptable on the wire today. Unknown
71
+ // scope strings are rejected as out-of-bounds rather than silently
72
+ // normalized — otherwise `scope=vault:admin` would coast through when
73
+ // bound is "full".
74
+ if (requested !== "read" && requested !== "full") return false;
75
+ const bnd = normalizeConsentScope(bound);
76
+ if (bnd === "full") return requested === "read" || requested === "full";
77
+ return requested === "read";
78
+ }
79
+
49
80
  /**
50
81
  * Public-facing base URL of the server. Honors `x-forwarded-*` headers so a
51
82
  * Cloudflare Tunnel / Tailscale Funnel / reverse-proxied deployment advertises
@@ -351,7 +382,7 @@ export async function handleAuthorizePost(
351
382
  const { vaultName, clientIp, ownerPasswordHash, totpSecret, rateLimiter = authorizeRateLimit } = opts;
352
383
  const totpEnrolled = typeof totpSecret === "string" && totpSecret.length > 0;
353
384
 
354
- let form: FormData;
385
+ let form: Awaited<ReturnType<typeof req.formData>>;
355
386
  try {
356
387
  form = await req.formData();
357
388
  } catch {
@@ -363,13 +394,12 @@ export async function handleAuthorizePost(
363
394
  const redirectUri = form.get("redirect_uri") as string;
364
395
  const codeChallenge = form.get("code_challenge") as string;
365
396
  const codeChallengeMethod = form.get("code_challenge_method") as string || "S256";
366
- // Requested scope (from hidden field, carried from GET) and selected scope
367
- // (from radio button on the consent page). Default selected to requested.
368
- const requestedScope = form.get("scope") as string || "full";
397
+ // Requested scope is carried from the GET via a hidden field on the consent
398
+ // page; the user's radio-button choice arrives in `selected_scope`. The
399
+ // required-ness check runs *after* the deny short-circuit below — a deny
400
+ // POST doesn't mint anything and shouldn't need scope to refuse.
401
+ const requestedScopeRaw = form.get("scope");
369
402
  const selectedScopeRaw = form.get("selected_scope") as string | null;
370
- const selectedScope = selectedScopeRaw === "read" || selectedScopeRaw === "full"
371
- ? selectedScopeRaw
372
- : (requestedScope === "read" ? "read" : "full");
373
403
  const state = form.get("state") as string || "";
374
404
 
375
405
  if (!clientId || !redirectUri || !codeChallenge) {
@@ -404,6 +434,20 @@ export async function handleAuthorizePost(
404
434
  return Response.redirect(redirect.toString(), 302);
405
435
  }
406
436
 
437
+ // Past this point we're processing consent — scope must be explicitly
438
+ // present. Defaulting absent scope to "full" would silently cement a
439
+ // grant the user never confirmed (#197).
440
+ if (typeof requestedScopeRaw !== "string" || requestedScopeRaw.length === 0) {
441
+ return Response.json(
442
+ { error: "invalid_request", error_description: "scope is required" },
443
+ { status: 400 },
444
+ );
445
+ }
446
+ const requestedScope = requestedScopeRaw;
447
+ const selectedScope = selectedScopeRaw === "read" || selectedScopeRaw === "full"
448
+ ? selectedScopeRaw
449
+ : (requestedScope === "read" ? "read" : "full");
450
+
407
451
  // Rate-limit the owner-auth step. Applied before any credential check so
408
452
  // brute-force attempts are capped regardless of which path (password or
409
453
  // legacy token) is being used.
@@ -483,7 +527,13 @@ export async function handleAuthorizePost(
483
527
 
484
528
  if (clientIp) rateLimiter.recordSuccess(clientIp);
485
529
 
486
- // Generate auth code — persist the user-selected scope (not the requested one)
530
+ // Generate auth code — bind the NARROWER of (requested, selected). The
531
+ // user can shrink the requested scope at consent time (e.g. flip "full"
532
+ // to "read"); they cannot broaden it. Without this floor, a malicious
533
+ // form could smuggle `selected_scope=full` even when /authorize?scope=read
534
+ // was the original ask, escalating beyond what the client requested at
535
+ // authorize time (#94, RFC 6749 §3.3).
536
+ const boundScope = narrowerScope(requestedScope, selectedScope);
487
537
  const code = crypto.randomBytes(32).toString("base64url");
488
538
  const expiresAt = new Date(Date.now() + 10 * 60 * 1000).toISOString(); // 10 minutes
489
539
 
@@ -492,7 +542,7 @@ export async function handleAuthorizePost(
492
542
  db.prepare(`
493
543
  INSERT INTO oauth_codes (code, client_id, code_challenge, code_challenge_method, scope, redirect_uri, expires_at, created_at, vault_name)
494
544
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
495
- `).run(code, clientId, codeChallenge, codeChallengeMethod, selectedScope, redirectUri, expiresAt, new Date().toISOString(), vaultName ?? null);
545
+ `).run(code, clientId, codeChallenge, codeChallengeMethod, boundScope, redirectUri, expiresAt, new Date().toISOString(), vaultName ?? null);
496
546
 
497
547
  redirect.searchParams.set("code", code);
498
548
  return Response.redirect(redirect.toString(), 302);
@@ -607,14 +657,35 @@ export async function handleToken(
607
657
  return Response.json({ error: "invalid_grant", error_description: "PKCE verification failed" }, { status: 400 });
608
658
  }
609
659
 
660
+ // RFC 6749 §3.3 / §6: a `scope` parameter at /oauth/token, if present,
661
+ // must equal or be a subset of the scope bound to the auth code at
662
+ // /oauth/authorize. Reject expansion attempts as `invalid_scope` rather
663
+ // than silently honoring the bound scope (#94). Absent param → use bound.
664
+ const requestedTokenScopeRaw = params.get("scope");
665
+ let effectiveScope = authCode.scope;
666
+ if (requestedTokenScopeRaw !== null && requestedTokenScopeRaw.trim().length > 0) {
667
+ const requested = requestedTokenScopeRaw.trim();
668
+ if (!isScopeSubset(requested, authCode.scope)) {
669
+ return Response.json(
670
+ {
671
+ error: "invalid_scope",
672
+ error_description:
673
+ "Requested scope exceeds the scope bound at authorization time.",
674
+ },
675
+ { status: 400 },
676
+ );
677
+ }
678
+ effectiveScope = requested;
679
+ }
680
+
610
681
  // Mark code as used
611
682
  db.prepare("UPDATE oauth_codes SET used = 1 WHERE code = ?").run(code);
612
683
 
613
- // Translate the consent-time selected scope into both the legacy permission
614
- // column and the OAuth-standard scope list we now persist on the token row.
615
- // The consent page only offers read vs full today; full becomes the
616
- // admin-inheriting scope set so hub admin operations keep working.
617
- const permission: TokenPermission = authCode.scope === "read" ? "read" : "full";
684
+ // Translate the (possibly-narrowed) effective scope into both the legacy
685
+ // permission column and the OAuth-standard scope list we persist on the
686
+ // token row. The consent page only offers read vs full today; full becomes
687
+ // the admin-inheriting scope set so hub admin operations keep working.
688
+ const permission: TokenPermission = effectiveScope === "read" ? "read" : "full";
618
689
  const scopes = legacyPermissionToScopes(permission);
619
690
  const scopeString = serializeScopes(scopes);
620
691
 
package/src/owner-auth.ts CHANGED
@@ -88,6 +88,9 @@ interface RateLimitEntry {
88
88
  * - Up to MAX_FAILURES failed attempts within WINDOW_MS → lockout
89
89
  * - Lockout lasts LOCKOUT_MS
90
90
  * - A successful attempt clears the IP's counter
91
+ * - Hard cap on entry count — when full, the oldest insertion is evicted
92
+ * before a new one is recorded. Prevents memory exhaustion via IP /
93
+ * client_id enumeration (#93).
91
94
  */
92
95
  export class RateLimiter {
93
96
  private entries = new Map<string, RateLimitEntry>();
@@ -96,6 +99,7 @@ export class RateLimiter {
96
99
  private readonly maxFailures = 10,
97
100
  private readonly windowMs = 60_000,
98
101
  private readonly lockoutMs = 15 * 60_000,
102
+ private readonly maxEntries = 10_000,
99
103
  ) {}
100
104
 
101
105
  /**
@@ -130,6 +134,7 @@ export class RateLimiter {
130
134
  const entry = this.entries.get(ip);
131
135
 
132
136
  if (!entry || now - entry.firstFailureAt > this.windowMs) {
137
+ this.evictIfFull();
133
138
  this.entries.set(ip, {
134
139
  failures: 1,
135
140
  firstFailureAt: now,
@@ -153,7 +158,58 @@ export class RateLimiter {
153
158
  reset(): void {
154
159
  this.entries.clear();
155
160
  }
161
+
162
+ /** Current entry count — exposed for tests + observability. */
163
+ size(): number {
164
+ return this.entries.size;
165
+ }
166
+
167
+ /**
168
+ * Evict the oldest insertion(s) until size < maxEntries. Map preserves
169
+ * insertion order, so `keys().next().value` is the oldest. We re-insert
170
+ * on window-rollover (delete + new set), so insertion order tracks
171
+ * recency-of-failure closely enough for FIFO eviction.
172
+ */
173
+ private evictIfFull(): void {
174
+ while (this.entries.size >= this.maxEntries) {
175
+ const oldest = this.entries.keys().next().value;
176
+ if (oldest === undefined) break;
177
+ this.entries.delete(oldest);
178
+ }
179
+ }
156
180
  }
157
181
 
158
- /** Singleton rate limiter for the OAuth consent endpoint. */
182
+ /**
183
+ * Singleton rate limiter — kept for back-compat with callers that don't pass
184
+ * through per-vault routing. Fresh callers should prefer
185
+ * `getAuthorizeRateLimiter(vaultName)` so traffic on one vault's consent flow
186
+ * doesn't lock out IPs on another vault's consent flow (#93).
187
+ *
188
+ * @deprecated Use `getAuthorizeRateLimiter(vaultName)` instead. The singleton
189
+ * cross-pollutes per-vault consent traffic — one vault under brute-force can
190
+ * lock out IPs on every other vault's consent page.
191
+ */
159
192
  export const authorizeRateLimit = new RateLimiter();
193
+
194
+ /**
195
+ * Per-vault rate limiter registry. The vault count is admin-bounded (vaults
196
+ * are created via CLI, not by clients) so this Map can grow only with operator
197
+ * action — no attacker-driven growth path. Each instance carries the
198
+ * default 10,000-entry IP cap, scoped to its vault (#93).
199
+ */
200
+ const vaultAuthorizeRateLimiters = new Map<string, RateLimiter>();
201
+
202
+ /** Lazily get-or-create the rate limiter for a given vault. */
203
+ export function getAuthorizeRateLimiter(vaultName: string): RateLimiter {
204
+ let limiter = vaultAuthorizeRateLimiters.get(vaultName);
205
+ if (!limiter) {
206
+ limiter = new RateLimiter();
207
+ vaultAuthorizeRateLimiters.set(vaultName, limiter);
208
+ }
209
+ return limiter;
210
+ }
211
+
212
+ /** For tests: drop all per-vault limiters. */
213
+ export function resetVaultAuthorizeRateLimiters(): void {
214
+ vaultAuthorizeRateLimiters.clear();
215
+ }
package/src/prompt.ts CHANGED
@@ -141,17 +141,18 @@ export async function askPassword(question: string): Promise<string> {
141
141
  export async function choose(question: string, options: { label: string; value: string; description?: string }[]): Promise<string> {
142
142
  console.log(question);
143
143
  for (let i = 0; i < options.length; i++) {
144
- const desc = options[i].description ? ` — ${options[i].description}` : "";
145
- console.log(` ${i + 1}) ${options[i].label}${desc}`);
144
+ const opt = options[i]!;
145
+ const desc = opt.description ? ` ${opt.description}` : "";
146
+ console.log(` ${i + 1}) ${opt.label}${desc}`);
146
147
  }
147
148
  process.stdout.write(` Choice [1]: `);
148
149
 
149
150
  for await (const line of console) {
150
151
  const answer = line.trim();
151
- if (answer === "") return options[0].value;
152
+ if (answer === "") return options[0]!.value;
152
153
  const idx = parseInt(answer, 10) - 1;
153
- if (idx >= 0 && idx < options.length) return options[idx].value;
154
+ if (idx >= 0 && idx < options.length) return options[idx]!.value;
154
155
  process.stdout.write(` Please enter 1-${options.length}: `);
155
156
  }
156
- return options[0].value;
157
+ return options[0]!.value;
157
158
  }