@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/mcp-tools.ts CHANGED
@@ -9,8 +9,14 @@ import { generateMcpTools } from "../core/src/mcp.ts";
9
9
  import type { McpToolDef } from "../core/src/mcp.ts";
10
10
  import { readVaultConfig, writeVaultConfig } from "./config.ts";
11
11
  import { getVaultStore } from "./vault-store.ts";
12
- import { hasScope, SCOPE_WRITE } from "./scopes.ts";
12
+ import { hasScopeForVault } from "./scopes.ts";
13
13
  import type { AuthResult } from "./auth.ts";
14
+ import {
15
+ expandTokenTagScope,
16
+ noteWithinTagScope,
17
+ tagsWithinScope,
18
+ } from "./tag-scope.ts";
19
+ import { findTokensReferencingTag } from "./token-store.ts";
14
20
 
15
21
  /**
16
22
  * Get the MCP server instruction for a vault.
@@ -46,10 +52,291 @@ export function generateScopedMcpTools(vaultName: string, auth?: AuthResult): Mc
46
52
  const tools = generateMcpTools(store);
47
53
 
48
54
  overrideVaultInfo(tools, vaultName, auth);
55
+ applyTagDependencyGuards(tools, vaultName);
56
+ applyTagScopeWrappers(tools, vaultName, auth);
49
57
 
50
58
  return tools;
51
59
  }
52
60
 
61
+ /**
62
+ * Tag-delete and (future) tag-merge always check for tag-scoped tokens
63
+ * referencing the doomed tag — regardless of whether the *deleter* is
64
+ * itself tag-scoped. A successful delete that orphans an allowlist would
65
+ * silently widen surface area downstream. Mirrors the REST 409
66
+ * `tag_in_use_by_tokens` envelope.
67
+ */
68
+ function applyTagDependencyGuards(tools: McpToolDef[], vaultName: string): void {
69
+ const store = getVaultStore(vaultName);
70
+ wrapReadTool(tools, "delete-tag", async (orig, params) => {
71
+ const tag = (params as any).tag ?? (params as any).name;
72
+ if (typeof tag === "string") {
73
+ const referenced_by = findTokensReferencingTag(store.db, tag);
74
+ if (referenced_by.length > 0) {
75
+ return {
76
+ error: "TagInUseByTokens",
77
+ error_type: "tag_in_use_by_tokens",
78
+ message: `Tag "${tag}" is referenced by ${referenced_by.length} tag-scoped token(s); revoke or re-mint them before deleting.`,
79
+ tag,
80
+ referenced_by,
81
+ };
82
+ }
83
+ }
84
+ return await orig(params);
85
+ });
86
+ }
87
+
88
+ /**
89
+ * Wrap read-tool execute() functions to filter results down to what the
90
+ * token's `scoped_tags` allowlist permits. No-op when the token is
91
+ * unscoped — the wrappers fast-path on `auth.scoped_tags === null` so
92
+ * unscoped sessions retain identical pre-tag-scope behavior.
93
+ *
94
+ * Read tools handled here:
95
+ * - query-notes: filter single-note returns + result lists
96
+ * - list-tags: filter to allowlisted tags + descendants
97
+ * - find-path: require both endpoints (and every hop) in scope
98
+ * - synthesize-notes: anchor + neighbors all gated by scope
99
+ *
100
+ * Write-tool gating happens in handleScopedMcp at the verb-scope layer
101
+ * AND inside each tool's wrapper here (so a tag-scoped `vault:write`
102
+ * token can't write outside its allowlist). See applyTagScopeWriteGuards.
103
+ */
104
+ function applyTagScopeWrappers(
105
+ tools: McpToolDef[],
106
+ vaultName: string,
107
+ auth: AuthResult | undefined,
108
+ ): void {
109
+ if (!auth || !auth.scoped_tags || auth.scoped_tags.length === 0) return;
110
+ const store = getVaultStore(vaultName);
111
+ // Lazy: only build the expanded allowlist on first tool call.
112
+ let allowedPromise: Promise<Set<string> | null> | null = null;
113
+ const getAllowed = (): Promise<Set<string> | null> => {
114
+ if (!allowedPromise) {
115
+ allowedPromise = expandTokenTagScope(store, auth.scoped_tags);
116
+ }
117
+ return allowedPromise;
118
+ };
119
+ const rawTags = auth.scoped_tags;
120
+
121
+ wrapReadTool(tools, "query-notes", async (orig, params) => {
122
+ const allowed = await getAllowed();
123
+ const result = await orig(params);
124
+ if (!allowed) return result;
125
+ // Single-note shape (`{...note}` with `id`) vs list shape (array).
126
+ if (Array.isArray(result)) {
127
+ return result.filter((n: any) => noteWithinTagScope(n, allowed, rawTags));
128
+ }
129
+ if (result && typeof result === "object" && "id" in result && "tags" in result) {
130
+ return noteWithinTagScope(result as any, allowed, rawTags)
131
+ ? result
132
+ : { error: "Note not found", id: (result as any).id };
133
+ }
134
+ return result;
135
+ });
136
+
137
+ wrapReadTool(tools, "list-tags", async (orig, params) => {
138
+ const allowed = await getAllowed();
139
+ const result = await orig(params);
140
+ if (!allowed || !Array.isArray(result)) return result;
141
+ return result.filter((t: any) => allowed.has(t.name));
142
+ });
143
+
144
+ wrapReadTool(tools, "find-path", async (orig, params) => {
145
+ const allowed = await getAllowed();
146
+ const result = await orig(params);
147
+ if (!allowed || !result || typeof result !== "object" || !("path" in result)) return result;
148
+ const ids = (result as any).path as string[];
149
+ for (const id of ids) {
150
+ const note = await store.getNote(id);
151
+ if (!note || !noteWithinTagScope(note, allowed, rawTags)) {
152
+ return null;
153
+ }
154
+ }
155
+ return result;
156
+ });
157
+
158
+ wrapReadTool(tools, "synthesize-notes", async (orig, params) => {
159
+ const allowed = await getAllowed();
160
+ if (!allowed) return await orig(params);
161
+ // Verify the anchor is in scope first — out-of-scope anchor 404s as if
162
+ // the note doesn't exist, mirroring the REST find-path semantics.
163
+ const anchorId = (params as any).id ?? (params as any).note_id;
164
+ if (anchorId) {
165
+ const anchor = await store.getNote(anchorId as string);
166
+ if (!anchor || !noteWithinTagScope(anchor, allowed, rawTags)) {
167
+ return { error: "Note not found", id: anchorId };
168
+ }
169
+ }
170
+ const result = await orig(params);
171
+ // Filter neighbors to those in scope. The synthesize-notes shape exposes
172
+ // `neighbors` (array of note objects with tags) — mirror the query-notes
173
+ // filter pattern here.
174
+ if (result && typeof result === "object" && Array.isArray((result as any).neighbors)) {
175
+ (result as any).neighbors = (result as any).neighbors.filter((n: any) =>
176
+ noteWithinTagScope(n, allowed, rawTags),
177
+ );
178
+ }
179
+ return result;
180
+ });
181
+
182
+ // ---- Write-side guards ----
183
+ //
184
+ // The verb-scope check (`vault:write`) is enforced at the dispatch layer
185
+ // in handleScopedMcp. These wrappers add the second axis: a scoped
186
+ // `vault:write` token can only mutate within its tag-allowlist, never
187
+ // outside it. Tag operations (`update-tag`, `delete-tag`) gate on the
188
+ // tag name itself; note operations gate on the prospective tag set.
189
+
190
+ const forbidden = (msg: string): unknown => ({
191
+ error: "Forbidden",
192
+ error_type: "tag_scope_violation",
193
+ message: `${msg} (token tag-allowlist: ${rawTags.join(", ")})`,
194
+ scoped_tags: rawTags,
195
+ });
196
+
197
+ wrapReadTool(tools, "create-note", async (orig, params) => {
198
+ const allowed = await getAllowed();
199
+ if (!allowed) return await orig(params);
200
+ // Single or batch shape: `{notes: [...]}` is the batch form (mirrors HTTP).
201
+ const items = Array.isArray((params as any).notes)
202
+ ? (params as any).notes
203
+ : [params];
204
+ for (const item of items) {
205
+ const itemTags = Array.isArray((item as any).tags) ? ((item as any).tags as string[]) : [];
206
+ if (!tagsWithinScope(itemTags, allowed, rawTags)) {
207
+ return forbidden("create-note: every note must carry at least one tag in the token's allowlist");
208
+ }
209
+ }
210
+ return await orig(params);
211
+ });
212
+
213
+ wrapReadTool(tools, "update-note", async (orig, params) => {
214
+ const allowed = await getAllowed();
215
+ if (!allowed) return await orig(params);
216
+ const items = Array.isArray((params as any).notes)
217
+ ? (params as any).notes
218
+ : [params];
219
+ for (const item of items) {
220
+ const id = (item as any).id ?? (item as any).note_id;
221
+ if (!id) continue;
222
+ const existing = await store.getNote(id as string);
223
+ if (!existing || !noteWithinTagScope(existing, allowed, rawTags)) {
224
+ return { error: "Note not found", id };
225
+ }
226
+ const removed = new Set<string>((item as any).tags?.remove ?? []);
227
+ const projected = new Set<string>((existing.tags ?? []).filter((t) => !removed.has(t)));
228
+ for (const t of ((item as any).tags?.add ?? []) as string[]) projected.add(t);
229
+ if (!tagsWithinScope([...projected], allowed, rawTags)) {
230
+ return forbidden("update-note: post-update tag set must satisfy the token's allowlist");
231
+ }
232
+ }
233
+ return await orig(params);
234
+ });
235
+
236
+ wrapReadTool(tools, "delete-note", async (orig, params) => {
237
+ const allowed = await getAllowed();
238
+ if (!allowed) return await orig(params);
239
+ const id = (params as any).id ?? (params as any).note_id;
240
+ if (id) {
241
+ const existing = await store.getNote(id as string);
242
+ if (!existing || !noteWithinTagScope(existing, allowed, rawTags)) {
243
+ return { error: "Note not found", id };
244
+ }
245
+ }
246
+ return await orig(params);
247
+ });
248
+
249
+ wrapReadTool(tools, "update-tag", async (orig, params) => {
250
+ const allowed = await getAllowed();
251
+ if (!allowed) return await orig(params);
252
+ const tag = (params as any).tag ?? (params as any).name;
253
+ if (typeof tag === "string" && !allowed.has(tag)) {
254
+ return forbidden(`update-tag: tag "${tag}" is outside the token's allowlist`);
255
+ }
256
+ return await orig(params);
257
+ });
258
+
259
+ wrapReadTool(tools, "delete-tag", async (orig, params) => {
260
+ const allowed = await getAllowed();
261
+ if (!allowed) return await orig(params);
262
+ const tag = (params as any).tag ?? (params as any).name;
263
+ if (typeof tag === "string" && !allowed.has(tag)) {
264
+ return forbidden(`delete-tag: tag "${tag}" is outside the token's allowlist`);
265
+ }
266
+ return await orig(params);
267
+ });
268
+
269
+ // Note-schemas mappings — same auth boundary as REST `handleNoteSchemas`.
270
+ // `tag`-kind mappings are tag-scoped data; `path_prefix`-kind mappings carry
271
+ // no tag-axis information and stay visible/writable. The single-tag check
272
+ // delegates to `tagsWithinScope` so the string-form fallback is honored.
273
+
274
+ wrapReadTool(tools, "list-note-schemas", async (orig, params) => {
275
+ const allowed = await getAllowed();
276
+ if (!allowed) return await orig(params);
277
+ const result = await orig(params);
278
+ const filterMappings = (mappings: any[]): any[] =>
279
+ mappings.filter(
280
+ (m: any) => m.match_kind !== "tag" || tagsWithinScope([m.match_value], allowed, rawTags),
281
+ );
282
+ if (Array.isArray(result)) {
283
+ return result.map((s: any) =>
284
+ Array.isArray(s.mappings) ? { ...s, mappings: filterMappings(s.mappings) } : s,
285
+ );
286
+ }
287
+ if (result && typeof result === "object" && Array.isArray((result as any).mappings)) {
288
+ return { ...(result as any), mappings: filterMappings((result as any).mappings) };
289
+ }
290
+ return result;
291
+ });
292
+
293
+ wrapReadTool(tools, "set-schema-mapping", async (orig, params) => {
294
+ const allowed = await getAllowed();
295
+ if (!allowed) return await orig(params);
296
+ const match_kind = (params as any).match_kind;
297
+ const match_value = (params as any).match_value;
298
+ if (
299
+ match_kind === "tag" &&
300
+ typeof match_value === "string" &&
301
+ !tagsWithinScope([match_value], allowed, rawTags)
302
+ ) {
303
+ return forbidden(`set-schema-mapping: tag "${match_value}" is outside the token's allowlist`);
304
+ }
305
+ return await orig(params);
306
+ });
307
+
308
+ wrapReadTool(tools, "delete-schema-mapping", async (orig, params) => {
309
+ const allowed = await getAllowed();
310
+ if (!allowed) return await orig(params);
311
+ const match_kind = (params as any).match_kind;
312
+ const match_value = (params as any).match_value;
313
+ if (
314
+ match_kind === "tag" &&
315
+ typeof match_value === "string" &&
316
+ !tagsWithinScope([match_value], allowed, rawTags)
317
+ ) {
318
+ return forbidden(`delete-schema-mapping: tag "${match_value}" is outside the token's allowlist`);
319
+ }
320
+ return await orig(params);
321
+ });
322
+ }
323
+
324
+ function wrapReadTool(
325
+ tools: McpToolDef[],
326
+ name: string,
327
+ wrapper: (orig: (params: Record<string, unknown>) => Promise<unknown>, params: Record<string, unknown>) => Promise<unknown>,
328
+ ): void {
329
+ const tool = tools.find((t) => t.name === name);
330
+ if (!tool) return;
331
+ // McpToolDef.execute returns `unknown | Promise<unknown>` (sync OR async).
332
+ // Adapt to the wrapper's strictly-async signature so wrappers can `await
333
+ // orig(params)` uniformly without re-checking each tool.
334
+ const orig = tool.execute;
335
+ const origAsync = (params: Record<string, unknown>): Promise<unknown> =>
336
+ Promise.resolve(orig(params));
337
+ tool.execute = (params) => wrapper(origAsync, params);
338
+ }
339
+
53
340
  function overrideVaultInfo(
54
341
  tools: McpToolDef[],
55
342
  vaultName: string,
@@ -64,12 +351,13 @@ function overrideVaultInfo(
64
351
 
65
352
  if (params.description !== undefined) {
66
353
  // Secondary scope check: vault-info is read-gated so read-only callers
67
- // can fetch stats, but mutating the vault description requires write.
68
- // Without this, a vault:read token could bypass the outer gate by
69
- // passing `description` to a tool the outer gate considers read-only.
70
- if (!auth || !hasScope(auth.scopes, SCOPE_WRITE)) {
354
+ // can fetch stats, but mutating the vault description requires write
355
+ // for THIS vault. Without this, a vault:read token could bypass the
356
+ // outer gate by passing `description` to a tool the outer gate
357
+ // considers read-only.
358
+ if (!auth || !hasScopeForVault(auth.scopes, vaultName, "write")) {
71
359
  throw new Error(
72
- `Forbidden: updating the vault description requires the '${SCOPE_WRITE}' scope. Granted scopes: ${auth?.scopes.join(" ") || "(none)"}.`,
360
+ `Forbidden: updating the vault description requires the 'vault:write' scope (or 'vault:${vaultName}:write'). Granted scopes: ${auth?.scopes.join(" ") || "(none)"}.`,
73
361
  );
74
362
  }
75
363
  config.description = params.description as string;
@@ -84,7 +84,7 @@ export function buildConfigSchema(): ModuleConfigSchema {
84
84
  export function buildConfigValues(
85
85
  vaultConfig: VaultConfig,
86
86
  globalConfig: GlobalConfig,
87
- env: { SCRIBE_URL?: string } = process.env,
87
+ env: { SCRIBE_URL?: string | undefined } = process.env as { SCRIBE_URL?: string },
88
88
  ): Record<string, unknown> {
89
89
  return {
90
90
  audio_retention: vaultConfig.audio_retention ?? "keep",
package/src/oauth.test.ts CHANGED
@@ -280,6 +280,28 @@ describe("OAuth authorization", () => {
280
280
  expect(location.searchParams.get("state")).toBe("mystate");
281
281
  });
282
282
 
283
+ test("POST authorize without scope is rejected (no silent default to 'full', #197)", async () => {
284
+ const ownerToken = createOwnerToken();
285
+ const clientId = await registerClient();
286
+ const { codeChallenge } = generatePkce();
287
+ const req = makeRequest("https://vault.test/oauth/authorize", {
288
+ method: "POST",
289
+ body: new URLSearchParams({
290
+ action: "authorize",
291
+ client_id: clientId,
292
+ redirect_uri: "https://example.com/callback",
293
+ code_challenge: codeChallenge,
294
+ code_challenge_method: "S256",
295
+ // No scope field — pre-#197 this silently consented to "full".
296
+ owner_token: ownerToken,
297
+ }),
298
+ });
299
+ const res = await handleAuthorizePost(req, db);
300
+ expect(res.status).toBe(400);
301
+ const body = (await res.json()) as { error?: string };
302
+ expect(body.error).toBe("invalid_request");
303
+ });
304
+
283
305
  test("POST authorize (deny) redirects with error", async () => {
284
306
  const clientId = await registerClient();
285
307
  const { codeChallenge } = generatePkce();
@@ -1809,3 +1831,326 @@ describe("OAuth Phase 0: PARACHUTE_HUB_ORIGIN", () => {
1809
1831
  });
1810
1832
  });
1811
1833
  });
1834
+
1835
+ // ---------------------------------------------------------------------------
1836
+ // Per-vault rate limiter + memory cap (#93)
1837
+ // ---------------------------------------------------------------------------
1838
+
1839
+ describe("OAuth consent — per-vault rate limiting (#93)", () => {
1840
+ test("getAuthorizeRateLimiter returns the same instance for the same vault name", async () => {
1841
+ const { getAuthorizeRateLimiter, resetVaultAuthorizeRateLimiters } =
1842
+ await import("./owner-auth.ts");
1843
+ resetVaultAuthorizeRateLimiters();
1844
+ const a1 = getAuthorizeRateLimiter("alpha");
1845
+ const a2 = getAuthorizeRateLimiter("alpha");
1846
+ expect(a1).toBe(a2);
1847
+ });
1848
+
1849
+ test("getAuthorizeRateLimiter returns distinct instances per vault", async () => {
1850
+ const { getAuthorizeRateLimiter, resetVaultAuthorizeRateLimiters } =
1851
+ await import("./owner-auth.ts");
1852
+ resetVaultAuthorizeRateLimiters();
1853
+ const work = getAuthorizeRateLimiter("work");
1854
+ const personal = getAuthorizeRateLimiter("personal");
1855
+ expect(work).not.toBe(personal);
1856
+ });
1857
+
1858
+ test("a lockout on one vault's limiter does not lock the same IP on another vault's limiter", async () => {
1859
+ const { getAuthorizeRateLimiter, resetVaultAuthorizeRateLimiters } =
1860
+ await import("./owner-auth.ts");
1861
+ resetVaultAuthorizeRateLimiters();
1862
+ const ip = "192.0.2.55";
1863
+ const work = getAuthorizeRateLimiter("work");
1864
+ // Pump enough failures on `work` to trip the default 10-failure threshold.
1865
+ for (let i = 0; i < 10; i++) work.recordFailure(ip);
1866
+ expect(work.check(ip).allowed).toBe(false);
1867
+ // The unrelated vault's limiter should still allow this IP.
1868
+ const personal = getAuthorizeRateLimiter("personal");
1869
+ expect(personal.check(ip).allowed).toBe(true);
1870
+ });
1871
+
1872
+ test("entry count is hard-capped — oldest IP is evicted FIFO when full", async () => {
1873
+ const { RateLimiter } = await import("./owner-auth.ts");
1874
+ // Tiny cap (3) so we don't have to hammer the limiter to prove eviction.
1875
+ const limiter = new RateLimiter(10, 60_000, 60_000, 3);
1876
+ limiter.recordFailure("10.0.0.1");
1877
+ limiter.recordFailure("10.0.0.2");
1878
+ limiter.recordFailure("10.0.0.3");
1879
+ expect(limiter.size()).toBe(3);
1880
+ // Adding a 4th IP must evict the oldest (10.0.0.1) to stay at the cap.
1881
+ limiter.recordFailure("10.0.0.4");
1882
+ expect(limiter.size()).toBe(3);
1883
+ // The evicted IP is treated as untracked → fresh check is allowed.
1884
+ expect(limiter.check("10.0.0.1").allowed).toBe(true);
1885
+ // Newer entries remain locked into their failure state.
1886
+ expect(limiter.check("10.0.0.4").allowed).toBe(true); // still under threshold
1887
+ });
1888
+ });
1889
+
1890
+ // ---------------------------------------------------------------------------
1891
+ // Server-bound scope at /authorize, subset enforcement at /token (#94)
1892
+ // ---------------------------------------------------------------------------
1893
+
1894
+ describe("OAuth scope binding (#94, RFC 6749 §3.3 / §6)", () => {
1895
+ test("/authorize floors selected scope to requested — form cannot smuggle a broader scope", async () => {
1896
+ const ownerToken = createOwnerToken();
1897
+ const clientId = await registerClient();
1898
+ const { codeChallenge } = generatePkce();
1899
+ const redirectUri = "https://example.com/callback";
1900
+
1901
+ const authRes = await handleAuthorizePost(
1902
+ makeRequest("https://vault.test/oauth/authorize", {
1903
+ method: "POST",
1904
+ body: new URLSearchParams({
1905
+ action: "authorize",
1906
+ client_id: clientId,
1907
+ redirect_uri: redirectUri,
1908
+ code_challenge: codeChallenge,
1909
+ code_challenge_method: "S256",
1910
+ scope: "read", // requested = read
1911
+ selected_scope: "full", // smuggled broader value
1912
+ owner_token: ownerToken,
1913
+ }),
1914
+ }),
1915
+ db,
1916
+ { vaultName: "default" },
1917
+ );
1918
+ expect(authRes.status).toBe(302);
1919
+ const code = new URL(authRes.headers.get("location")!).searchParams.get("code")!;
1920
+
1921
+ // The bound scope on the issued auth code must be the narrower of the two.
1922
+ const row = db
1923
+ .prepare("SELECT scope FROM oauth_codes WHERE code = ?")
1924
+ .get(code) as { scope: string };
1925
+ expect(row.scope).toBe("read");
1926
+ });
1927
+
1928
+ test("/token rejects requested scope broader than bound (read → full)", async () => {
1929
+ const ownerToken = createOwnerToken();
1930
+ const clientId = await registerClient();
1931
+ const { codeVerifier, codeChallenge } = generatePkce();
1932
+ const redirectUri = "https://example.com/callback";
1933
+
1934
+ const authRes = await handleAuthorizePost(
1935
+ makeRequest("https://vault.test/oauth/authorize", {
1936
+ method: "POST",
1937
+ body: new URLSearchParams({
1938
+ action: "authorize",
1939
+ client_id: clientId,
1940
+ redirect_uri: redirectUri,
1941
+ code_challenge: codeChallenge,
1942
+ code_challenge_method: "S256",
1943
+ scope: "read",
1944
+ owner_token: ownerToken,
1945
+ }),
1946
+ }),
1947
+ db,
1948
+ { vaultName: "default" },
1949
+ );
1950
+ const code = new URL(authRes.headers.get("location")!).searchParams.get("code")!;
1951
+
1952
+ const tokenRes = await handleToken(
1953
+ makeRequest("https://vault.test/oauth/token", {
1954
+ method: "POST",
1955
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
1956
+ body: new URLSearchParams({
1957
+ grant_type: "authorization_code",
1958
+ code,
1959
+ code_verifier: codeVerifier,
1960
+ client_id: clientId,
1961
+ redirect_uri: redirectUri,
1962
+ scope: "full", // attempt to broaden
1963
+ }).toString(),
1964
+ }),
1965
+ db,
1966
+ "default",
1967
+ );
1968
+ expect(tokenRes.status).toBe(400);
1969
+ const body = (await tokenRes.json()) as { error?: string };
1970
+ expect(body.error).toBe("invalid_scope");
1971
+ });
1972
+
1973
+ test("/token accepts a narrower requested scope (full → read) and reflects it on the token", async () => {
1974
+ const ownerToken = createOwnerToken();
1975
+ const clientId = await registerClient();
1976
+ const { codeVerifier, codeChallenge } = generatePkce();
1977
+ const redirectUri = "https://example.com/callback";
1978
+
1979
+ const authRes = await handleAuthorizePost(
1980
+ makeRequest("https://vault.test/oauth/authorize", {
1981
+ method: "POST",
1982
+ body: new URLSearchParams({
1983
+ action: "authorize",
1984
+ client_id: clientId,
1985
+ redirect_uri: redirectUri,
1986
+ code_challenge: codeChallenge,
1987
+ code_challenge_method: "S256",
1988
+ scope: "full",
1989
+ owner_token: ownerToken,
1990
+ }),
1991
+ }),
1992
+ db,
1993
+ { vaultName: "default" },
1994
+ );
1995
+ const code = new URL(authRes.headers.get("location")!).searchParams.get("code")!;
1996
+
1997
+ const tokenRes = await handleToken(
1998
+ makeRequest("https://vault.test/oauth/token", {
1999
+ method: "POST",
2000
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
2001
+ body: new URLSearchParams({
2002
+ grant_type: "authorization_code",
2003
+ code,
2004
+ code_verifier: codeVerifier,
2005
+ client_id: clientId,
2006
+ redirect_uri: redirectUri,
2007
+ scope: "read", // narrower than bound
2008
+ }).toString(),
2009
+ }),
2010
+ db,
2011
+ "default",
2012
+ );
2013
+ expect(tokenRes.status).toBe(200);
2014
+ const body = (await tokenRes.json()) as { scope?: string };
2015
+ expect(body.scope).toBe("vault:read");
2016
+ });
2017
+
2018
+ test("/token treats whitespace-only scope as absent and falls through to bound scope (#196)", async () => {
2019
+ // Guard at oauth.ts checks `scope !== null && scope.trim().length > 0`.
2020
+ // A client sending `scope= ` is the same as omitting `scope` — we
2021
+ // must not run subset enforcement against the whitespace string and
2022
+ // reject it as invalid.
2023
+ const ownerToken = createOwnerToken();
2024
+ const clientId = await registerClient();
2025
+ const { codeVerifier, codeChallenge } = generatePkce();
2026
+ const redirectUri = "https://example.com/callback";
2027
+
2028
+ const authRes = await handleAuthorizePost(
2029
+ makeRequest("https://vault.test/oauth/authorize", {
2030
+ method: "POST",
2031
+ body: new URLSearchParams({
2032
+ action: "authorize",
2033
+ client_id: clientId,
2034
+ redirect_uri: redirectUri,
2035
+ code_challenge: codeChallenge,
2036
+ code_challenge_method: "S256",
2037
+ scope: "read",
2038
+ owner_token: ownerToken,
2039
+ }),
2040
+ }),
2041
+ db,
2042
+ { vaultName: "default" },
2043
+ );
2044
+ const code = new URL(authRes.headers.get("location")!).searchParams.get("code")!;
2045
+
2046
+ const tokenRes = await handleToken(
2047
+ makeRequest("https://vault.test/oauth/token", {
2048
+ method: "POST",
2049
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
2050
+ body: new URLSearchParams({
2051
+ grant_type: "authorization_code",
2052
+ code,
2053
+ code_verifier: codeVerifier,
2054
+ client_id: clientId,
2055
+ redirect_uri: redirectUri,
2056
+ scope: " ", // whitespace only — should fall through to bound
2057
+ }).toString(),
2058
+ }),
2059
+ db,
2060
+ "default",
2061
+ );
2062
+ expect(tokenRes.status).toBe(200);
2063
+ const body = (await tokenRes.json()) as { scope?: string };
2064
+ expect(body.scope).toBe("vault:read");
2065
+ });
2066
+
2067
+ test("/token rejects unknown scope strings even when the bound scope is broad", async () => {
2068
+ const ownerToken = createOwnerToken();
2069
+ const clientId = await registerClient();
2070
+ const { codeVerifier, codeChallenge } = generatePkce();
2071
+ const redirectUri = "https://example.com/callback";
2072
+
2073
+ const authRes = await handleAuthorizePost(
2074
+ makeRequest("https://vault.test/oauth/authorize", {
2075
+ method: "POST",
2076
+ body: new URLSearchParams({
2077
+ action: "authorize",
2078
+ client_id: clientId,
2079
+ redirect_uri: redirectUri,
2080
+ code_challenge: codeChallenge,
2081
+ code_challenge_method: "S256",
2082
+ scope: "full",
2083
+ owner_token: ownerToken,
2084
+ }),
2085
+ }),
2086
+ db,
2087
+ { vaultName: "default" },
2088
+ );
2089
+ const code = new URL(authRes.headers.get("location")!).searchParams.get("code")!;
2090
+
2091
+ const tokenRes = await handleToken(
2092
+ makeRequest("https://vault.test/oauth/token", {
2093
+ method: "POST",
2094
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
2095
+ body: new URLSearchParams({
2096
+ grant_type: "authorization_code",
2097
+ code,
2098
+ code_verifier: codeVerifier,
2099
+ client_id: clientId,
2100
+ redirect_uri: redirectUri,
2101
+ scope: "vault:admin", // not in the consent vocabulary
2102
+ }).toString(),
2103
+ }),
2104
+ db,
2105
+ "default",
2106
+ );
2107
+ expect(tokenRes.status).toBe(400);
2108
+ const body = (await tokenRes.json()) as { error?: string };
2109
+ expect(body.error).toBe("invalid_scope");
2110
+ });
2111
+
2112
+ test("/token uses the bound scope when no scope param is sent (regression)", async () => {
2113
+ const ownerToken = createOwnerToken();
2114
+ const clientId = await registerClient();
2115
+ const { codeVerifier, codeChallenge } = generatePkce();
2116
+ const redirectUri = "https://example.com/callback";
2117
+
2118
+ const authRes = await handleAuthorizePost(
2119
+ makeRequest("https://vault.test/oauth/authorize", {
2120
+ method: "POST",
2121
+ body: new URLSearchParams({
2122
+ action: "authorize",
2123
+ client_id: clientId,
2124
+ redirect_uri: redirectUri,
2125
+ code_challenge: codeChallenge,
2126
+ code_challenge_method: "S256",
2127
+ scope: "read",
2128
+ owner_token: ownerToken,
2129
+ }),
2130
+ }),
2131
+ db,
2132
+ { vaultName: "default" },
2133
+ );
2134
+ const code = new URL(authRes.headers.get("location")!).searchParams.get("code")!;
2135
+
2136
+ const tokenRes = await handleToken(
2137
+ makeRequest("https://vault.test/oauth/token", {
2138
+ method: "POST",
2139
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
2140
+ body: new URLSearchParams({
2141
+ grant_type: "authorization_code",
2142
+ code,
2143
+ code_verifier: codeVerifier,
2144
+ client_id: clientId,
2145
+ redirect_uri: redirectUri,
2146
+ // no scope param
2147
+ }).toString(),
2148
+ }),
2149
+ db,
2150
+ "default",
2151
+ );
2152
+ expect(tokenRes.status).toBe(200);
2153
+ const body = (await tokenRes.json()) as { scope?: string };
2154
+ expect(body.scope).toBe("vault:read");
2155
+ });
2156
+ });