@openparachute/vault 0.3.1 → 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.
- package/.parachute/module.json +15 -0
- package/README.md +9 -5
- package/core/src/core.test.ts +2252 -7
- package/core/src/links.ts +1 -1
- package/core/src/mcp.ts +801 -67
- package/core/src/note-schemas.ts +232 -0
- package/core/src/notes.ts +313 -35
- package/core/src/obsidian.ts +3 -3
- package/core/src/paths.ts +1 -1
- package/core/src/query-operators.ts +23 -7
- package/core/src/schema-defaults.ts +287 -0
- package/core/src/schema.ts +393 -9
- package/core/src/store.ts +248 -6
- package/core/src/tag-hierarchy.ts +137 -0
- package/core/src/tag-schemas.ts +242 -42
- package/core/src/types.ts +100 -6
- package/core/src/wikilinks.ts +3 -3
- package/package.json +13 -3
- package/src/admin-spa.test.ts +161 -0
- package/src/admin-spa.ts +161 -0
- package/src/auth-hub-jwt.test.ts +231 -0
- package/src/auth-status.ts +84 -0
- package/src/auth.test.ts +135 -23
- package/src/auth.ts +144 -15
- package/src/backup.ts +4 -7
- package/src/cli.ts +384 -78
- package/src/config.test.ts +44 -0
- package/src/config.ts +68 -40
- package/src/hub-jwt.test.ts +296 -0
- package/src/hub-jwt.ts +79 -0
- package/src/init-summary.test.ts +133 -0
- package/src/init-summary.ts +90 -0
- package/src/init.test.ts +216 -0
- package/src/mcp-http.ts +30 -28
- package/src/mcp-install.ts +1 -1
- package/src/mcp-tools.ts +294 -6
- package/src/module-config.ts +1 -1
- package/src/oauth.test.ts +345 -0
- package/src/oauth.ts +85 -14
- package/src/owner-auth.ts +57 -1
- package/src/prompt.ts +31 -14
- package/src/routes.ts +686 -58
- package/src/routing.test.ts +466 -1
- package/src/routing.ts +108 -24
- package/src/scopes.test.ts +66 -8
- package/src/scopes.ts +163 -37
- package/src/server.ts +24 -2
- package/src/services-manifest.test.ts +20 -0
- package/src/services-manifest.ts +9 -2
- package/src/stop-signal.test.ts +85 -0
- package/src/storage.test.ts +92 -0
- package/src/tag-scope.ts +118 -0
- package/src/token-store.test.ts +47 -0
- package/src/token-store.ts +128 -13
- package/src/tokens-routes.test.ts +720 -0
- package/src/tokens-routes.ts +392 -0
- package/src/transcription-worker.test.ts +5 -0
- package/src/triggers.ts +1 -1
- package/src/two-factor.ts +2 -2
- package/src/vault-create.test.ts +193 -0
- package/src/vault-name.test.ts +123 -0
- package/src/vault-name.ts +80 -0
- package/src/vault.test.ts +868 -3
- package/tsconfig.json +8 -1
- package/.claude/settings.local.json +0 -8
- package/.dockerignore +0 -8
- package/.env.example +0 -9
- package/CHANGELOG.md +0 -175
- package/CLAUDE.md +0 -125
- package/Caddyfile +0 -3
- package/Dockerfile +0 -22
- package/bun.lock +0 -219
- package/bunfig.toml +0 -2
- package/deploy/parachute-vault.service +0 -20
- package/docker-compose.yml +0 -50
- package/docs/HTTP_API.md +0 -434
- package/docs/auth-model.md +0 -340
- package/fly.toml +0 -24
- package/package/package.json +0 -32
- package/railway.json +0 -14
- package/scripts/migrate-audio-to-opus.test.ts +0 -237
- 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 {
|
|
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
|
|
69
|
-
// passing `description` to a tool the outer gate
|
|
70
|
-
|
|
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 '
|
|
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;
|
package/src/module-config.ts
CHANGED
|
@@ -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
|
+
});
|