@openparachute/vault 0.4.9-rc.9 → 0.5.0-rc.2

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 (62) hide show
  1. package/README.md +51 -54
  2. package/core/src/core.test.ts +4 -1
  3. package/core/src/indexed-fields.test.ts +151 -0
  4. package/core/src/indexed-fields.ts +98 -0
  5. package/core/src/mcp.ts +66 -43
  6. package/core/src/notes.ts +26 -2
  7. package/core/src/portable-md.test.ts +52 -0
  8. package/core/src/portable-md.ts +48 -0
  9. package/core/src/schema.ts +87 -14
  10. package/core/src/store.ts +117 -0
  11. package/core/src/types.ts +28 -0
  12. package/package.json +2 -2
  13. package/src/auth-hub-jwt.test.ts +191 -11
  14. package/src/auth-status.ts +12 -5
  15. package/src/auth.test.ts +135 -219
  16. package/src/auth.ts +158 -107
  17. package/src/cli.ts +306 -224
  18. package/src/config.ts +12 -4
  19. package/src/export-watch.test.ts +23 -0
  20. package/src/export-watch.ts +14 -0
  21. package/src/git-preflight.test.ts +70 -0
  22. package/src/git-preflight.ts +68 -0
  23. package/src/hub-jwt.test.ts +27 -2
  24. package/src/hub-jwt.ts +10 -0
  25. package/src/init-summary.test.ts +4 -4
  26. package/src/init-summary.ts +36 -10
  27. package/src/mcp-config.test.ts +4 -2
  28. package/src/mcp-http.ts +24 -3
  29. package/src/mcp-install-interactive.test.ts +33 -71
  30. package/src/mcp-install-interactive.ts +23 -76
  31. package/src/mcp-install.test.ts +156 -55
  32. package/src/mcp-install.ts +109 -3
  33. package/src/mcp-tools.ts +249 -74
  34. package/src/mirror-config.test.ts +107 -0
  35. package/src/mirror-config.ts +275 -9
  36. package/src/mirror-credentials.test.ts +168 -17
  37. package/src/mirror-credentials.ts +155 -32
  38. package/src/mirror-deps.ts +25 -16
  39. package/src/mirror-import.test.ts +122 -16
  40. package/src/mirror-import.ts +50 -16
  41. package/src/mirror-manager.test.ts +51 -0
  42. package/src/mirror-manager.ts +116 -22
  43. package/src/mirror-per-vault.test.ts +519 -0
  44. package/src/mirror-registry.ts +91 -14
  45. package/src/mirror-routes.test.ts +81 -21
  46. package/src/mirror-routes.ts +90 -16
  47. package/src/routes.ts +39 -2
  48. package/src/routing.test.ts +203 -118
  49. package/src/routing.ts +46 -59
  50. package/src/scopes.test.ts +0 -86
  51. package/src/scopes.ts +9 -97
  52. package/src/server.ts +102 -34
  53. package/src/storage.test.ts +132 -7
  54. package/src/token-store.test.ts +88 -169
  55. package/src/token-store.ts +123 -249
  56. package/src/vault-create.test.ts +12 -4
  57. package/src/vault.test.ts +408 -103
  58. package/web/ui/dist/assets/index-DDRo6F4u.js +60 -0
  59. package/web/ui/dist/index.html +1 -1
  60. package/src/tokens-routes.test.ts +0 -727
  61. package/src/tokens-routes.ts +0 -392
  62. package/web/ui/dist/assets/index-Degr8snN.js +0 -60
@@ -1,392 +0,0 @@
1
- /**
2
- * REST handlers for `/vault/<name>/tokens`.
3
- *
4
- * The endpoint is admin-gated upstream — `route()` checks
5
- * `hasScopeForVault(auth.scopes, vaultName, "admin")` before dispatching here,
6
- * so any caller reaching this module already holds vault:admin (broad or
7
- * narrowed). POST mints a new pvt_* token with caller-narrowed scopes and
8
- * returns the plaintext exactly once. GET lists existing tokens (metadata
9
- * only — no plaintext, no hash). DELETE revokes by display id (`t_…`); a
10
- * non-existent id returns 404; an id that resolves to a different
11
- * vault's binding returns **403** (per #257 spec — silent 200 on
12
- * cross-vault revoke would let an operator believe a token was
13
- * revoked while it kept working from its owning vault).
14
- *
15
- * Scope narrowing is enforced as a strict subset check via
16
- * `validateMintedScopes` — defense-in-depth even with the admin gate, so a
17
- * future relaxation of the gate cannot accidentally permit privilege
18
- * escalation. Cross-vault scopes (`vault:<other>:<verb>`) are rejected with
19
- * the same path.
20
- *
21
- * Tag-allowlist narrowing (per patterns/tag-scoped-tokens.md) follows the
22
- * same defense-in-depth shape: the requested allowlist must be a subset of
23
- * the minter's. A `null` minter allowlist is the universe — any allowlist
24
- * may be granted. Each tag must be an existing root-tag name (no `/`) —
25
- * sub-tags are reached via the `_tags/<name>` hierarchy at enforcement time,
26
- * not at mint time.
27
- */
28
-
29
- import type { Database } from "bun:sqlite";
30
- import type { SqliteStore } from "../core/src/store.ts";
31
- import {
32
- generateToken,
33
- createToken,
34
- normalizePermission,
35
- type TokenPermission,
36
- } from "./token-store.ts";
37
- import {
38
- validateMintedScopes,
39
- parseScopes,
40
- hasScope,
41
- SCOPE_WRITE,
42
- SCOPE_ADMIN,
43
- } from "./scopes.ts";
44
-
45
- interface MintRequestBody {
46
- label?: string;
47
- /** Either an array (preferred) or a space-separated OAuth-style string. */
48
- scopes?: string[];
49
- scope?: string;
50
- /** ISO-8601 future timestamp, or null/omitted for never-expiring. */
51
- expires_at?: string | null;
52
- /**
53
- * Optional tag-allowlist. Each entry must be an existing root-tag name
54
- * (no `/`). When omitted or null, the token is unscoped (current behavior).
55
- * The minted allowlist must be a subset of the caller's allowlist.
56
- */
57
- tags?: string[] | null;
58
- }
59
-
60
- function badRequest(message: string, extra?: Record<string, unknown>): Response {
61
- return Response.json({ error: "Bad Request", message, ...extra }, { status: 400 });
62
- }
63
-
64
- function methodNotAllowed(): Response {
65
- return Response.json({ error: "Method not allowed" }, { status: 405 });
66
- }
67
-
68
- function permissionForScopes(scopes: string[]): TokenPermission {
69
- return hasScope(scopes, SCOPE_WRITE) || hasScope(scopes, SCOPE_ADMIN) ? "full" : "read";
70
- }
71
-
72
- export async function handleTokens(
73
- req: Request,
74
- store: SqliteStore,
75
- vaultName: string,
76
- callerScopes: string[],
77
- callerScopedTags: string[] | null,
78
- subpath: string,
79
- ): Promise<Response> {
80
- if (subpath === "" || subpath === "/") {
81
- if (req.method === "GET") return listHandler(store.db, vaultName);
82
- if (req.method === "POST") return mintHandler(req, store, vaultName, callerScopes, callerScopedTags);
83
- return methodNotAllowed();
84
- }
85
- const idMatch = subpath.match(/^\/([^/]+)$/);
86
- if (idMatch && idMatch[1]) {
87
- if (req.method === "DELETE") return revokeHandler(store.db, vaultName, idMatch[1]);
88
- return methodNotAllowed();
89
- }
90
- return Response.json({ error: "Not found" }, { status: 404 });
91
- }
92
-
93
- async function mintHandler(
94
- req: Request,
95
- store: SqliteStore,
96
- vaultName: string,
97
- callerScopes: string[],
98
- callerScopedTags: string[] | null,
99
- ): Promise<Response> {
100
- let body: MintRequestBody;
101
- try {
102
- const raw = await req.text();
103
- body = raw.length === 0 ? {} : (JSON.parse(raw) as MintRequestBody);
104
- } catch {
105
- return badRequest("invalid JSON body");
106
- }
107
-
108
- let requested: string[];
109
- if (Array.isArray(body.scopes)) {
110
- requested = body.scopes.filter((s): s is string => typeof s === "string" && s.length > 0);
111
- } else if (typeof body.scope === "string") {
112
- requested = parseScopes(body.scope);
113
- } else {
114
- // Default: full scope set. Admin caller is required to reach this code,
115
- // so granting full scope is within their power; explicit narrowing is
116
- // available via `scopes` / `scope` for least-privilege deployments.
117
- requested = ["vault:read", "vault:write", "vault:admin"];
118
- }
119
- if (requested.length === 0) {
120
- return badRequest("at least one scope required");
121
- }
122
-
123
- const validation = validateMintedScopes(requested, vaultName, callerScopes);
124
- if (!validation.ok) {
125
- return Response.json(
126
- { error: "Bad Request", message: "scope rejected", rejected: validation.rejected },
127
- { status: 400 },
128
- );
129
- }
130
-
131
- // Tag-allowlist narrowing. `tags === undefined` or `null` means unscoped
132
- // (no filtering). When provided, every entry must (a) be a non-empty
133
- // string, (b) contain no `/` (root tags only — sub-tags reach via the
134
- // `_tags/<name>` hierarchy at enforcement time), (c) exist in the vault's
135
- // tag list, and (d) fall within the caller's own allowlist when the caller
136
- // is themselves tag-scoped (null caller scope = universe = anything OK).
137
- //
138
- // Privilege-escalation guard: a tag-scoped minter must NOT be able to
139
- // produce a `null`-allowlist token (the universe is broader than any
140
- // finite allowlist). We reject the omission with a clear 403 rather than
141
- // silently inheriting — explicit > implicit at a security boundary.
142
- let scopedTags: string[] | null = null;
143
- if ((body.tags === undefined || body.tags === null) && callerScopedTags !== null) {
144
- return Response.json(
145
- {
146
- error: "Forbidden",
147
- error_type: "tag_scope_violation",
148
- message: `minter is tag-scoped (${callerScopedTags.join(", ")}) — request must include an explicit "tags" array within that allowlist; an unscoped token cannot be minted from a scoped one`,
149
- minter_scoped_tags: callerScopedTags,
150
- },
151
- { status: 403 },
152
- );
153
- }
154
- if (body.tags !== undefined && body.tags !== null) {
155
- if (!Array.isArray(body.tags)) {
156
- return badRequest("tags must be an array of strings or null");
157
- }
158
- if (body.tags.length === 0) {
159
- return badRequest("tags must be a non-empty array (omit the field, or pass null, for an unscoped token)");
160
- }
161
- const cleaned: string[] = [];
162
- for (const t of body.tags) {
163
- if (typeof t !== "string" || t.length === 0) {
164
- return badRequest("each tag must be a non-empty string");
165
- }
166
- if (t.includes("/")) {
167
- return badRequest(
168
- `tag "${t}" must be a root-tag name (no path separators). Sub-tags inherit via the _tags/<name> hierarchy at enforcement time.`,
169
- );
170
- }
171
- cleaned.push(t);
172
- }
173
- // Dedupe while preserving caller-supplied order.
174
- const seen = new Set<string>();
175
- const deduped: string[] = [];
176
- for (const t of cleaned) {
177
- if (!seen.has(t)) {
178
- seen.add(t);
179
- deduped.push(t);
180
- }
181
- }
182
- // Existence check against the vault's tag list. The pattern doc names
183
- // list-tags as the source of truth — keeps the CLI/SPA picker and the
184
- // server in agreement.
185
- const known = new Set((await store.listTags()).map((t) => t.name));
186
- const unknown = deduped.filter((t) => !known.has(t));
187
- if (unknown.length > 0) {
188
- return badRequest(
189
- `unknown tag(s): ${unknown.join(", ")} — must be existing root-tag names in vault '${vaultName}'`,
190
- { unknown_tags: unknown },
191
- );
192
- }
193
- // Subset rule: if the caller is tag-scoped, every requested tag must be
194
- // in the caller's allowlist. Null caller = universe.
195
- if (callerScopedTags !== null) {
196
- const callerSet = new Set(callerScopedTags);
197
- const escalating = deduped.filter((t) => !callerSet.has(t));
198
- if (escalating.length > 0) {
199
- return Response.json(
200
- {
201
- error: "Forbidden",
202
- error_type: "tag_scope_violation",
203
- message: `cannot mint a token with tag(s) outside the minter's allowlist: ${escalating.join(", ")}`,
204
- rejected_tags: escalating,
205
- minter_scoped_tags: callerScopedTags,
206
- },
207
- { status: 403 },
208
- );
209
- }
210
- }
211
- scopedTags = deduped;
212
- }
213
-
214
- let expiresAt: string | null = null;
215
- if (body.expires_at !== undefined && body.expires_at !== null) {
216
- if (typeof body.expires_at !== "string") {
217
- return badRequest("expires_at must be an ISO-8601 string or null");
218
- }
219
- const t = Date.parse(body.expires_at);
220
- if (Number.isNaN(t)) {
221
- return badRequest("expires_at is not a valid ISO-8601 timestamp");
222
- }
223
- if (t <= Date.now()) {
224
- return badRequest("expires_at must be in the future");
225
- }
226
- expiresAt = new Date(t).toISOString();
227
- }
228
-
229
- const label = typeof body.label === "string" && body.label.length > 0 ? body.label : "API token";
230
- const permission = permissionForScopes(requested);
231
-
232
- const { fullToken } = generateToken();
233
- const created = createToken(store.db, fullToken, {
234
- label,
235
- permission,
236
- scopes: requested,
237
- scoped_tags: scopedTags,
238
- expires_at: expiresAt,
239
- // Per-vault binding (v16): tokens minted via /vault/<name>/tokens are
240
- // pinned to that vault. authenticateVaultRequest rejects them at any
241
- // other vault. NULL would be legacy / server-wide; reserved for the
242
- // YAML-import path and explicit --all CLI mints (see vault#257).
243
- vault_name: vaultName,
244
- });
245
-
246
- // Display id mirrors `listTokens`: `t_` + first 12 chars of the SHA-256
247
- // hash payload (the part after the `sha256:` prefix). Stable across reads.
248
- const id = `t_${created.token_hash.slice(7, 19)}`;
249
-
250
- return Response.json(
251
- {
252
- id,
253
- token: fullToken,
254
- label: created.label,
255
- permission: created.permission,
256
- scopes: requested,
257
- scoped_tags: scopedTags,
258
- vault_name: created.vault_name,
259
- expires_at: created.expires_at,
260
- created_at: created.created_at,
261
- },
262
- { status: 201 },
263
- );
264
- }
265
-
266
- function listHandler(db: Database, vaultName: string): Response {
267
- // Direct SELECT (rather than reusing `listTokens`) so we can include the
268
- // `scopes` and `scoped_tags` columns without changing the existing
269
- // CLI-facing shape that goes through `listTokens`.
270
- //
271
- // Per-vault filter (v16): rows where vault_name matches THIS vault, plus
272
- // legacy server-wide rows (vault_name IS NULL). The latter authenticate
273
- // cross-vault by design; the operator should see them in any vault's
274
- // admin UI to revoke. Tokens bound to a different vault never appear
275
- // here — this is the SPA-side fix for vault#257.
276
- const rows = db.prepare(`
277
- SELECT token_hash, label, permission, scopes, scoped_tags, vault_name, expires_at, created_at, last_used_at
278
- FROM tokens
279
- WHERE vault_name = ? OR vault_name IS NULL
280
- ORDER BY created_at DESC
281
- `).all(vaultName) as {
282
- token_hash: string;
283
- label: string;
284
- permission: string;
285
- scopes: string | null;
286
- scoped_tags: string | null;
287
- vault_name: string | null;
288
- expires_at: string | null;
289
- created_at: string;
290
- last_used_at: string | null;
291
- }[];
292
-
293
- return Response.json({
294
- tokens: rows.map((r) => ({
295
- id: `t_${r.token_hash.slice(7, 19)}`,
296
- label: r.label,
297
- permission: normalizePermission(r.permission),
298
- scopes: parseScopes(r.scopes),
299
- scoped_tags: parseScopedTagsJSON(r.scoped_tags),
300
- vault_name: r.vault_name,
301
- expires_at: r.expires_at,
302
- created_at: r.created_at,
303
- last_used_at: r.last_used_at,
304
- })),
305
- });
306
- }
307
-
308
- /**
309
- * Defensive parser for the `tokens.scoped_tags` JSON column. Mirrors the
310
- * shape used by `token-store.ts#parseScopedTags`: collapse anything that
311
- * isn't a non-empty array of strings to `null` (the unscoped sentinel) so a
312
- * corrupt row can't masquerade as a scoped token in the listing.
313
- */
314
- function parseScopedTagsJSON(raw: string | null): string[] | null {
315
- if (!raw) return null;
316
- try {
317
- const parsed = JSON.parse(raw);
318
- if (!Array.isArray(parsed) || parsed.length === 0) return null;
319
- const cleaned = parsed.filter((s): s is string => typeof s === "string" && s.length > 0);
320
- return cleaned.length > 0 ? cleaned : null;
321
- } catch {
322
- return null;
323
- }
324
- }
325
-
326
- function revokeHandler(db: Database, vaultName: string, id: string): Response {
327
- // Per-vault scope (v16, per #257 spec): pre-check the row's vault binding
328
- // before deleting so we can distinguish three cases:
329
- //
330
- // - row missing entirely → 404 (the id never existed in this vault's DB)
331
- // - row exists, vault_name = <other> → 403 with descriptive error
332
- // - row exists, vault_name = <this> OR NULL → DELETE + 200
333
- //
334
- // The non-leakage argument for "always 200" doesn't hold given that GET
335
- // /vault/<name>/tokens already exposes the full vault-scoped + legacy-NULL
336
- // listing — so existence isn't being protected. 403 over silent 200 is the
337
- // operator-debuggable shape: clicking "revoke" and seeing success while the
338
- // token still works (because it's bound to a different vault) is the worst
339
- // UX. Tell the operator which vault owns it.
340
- //
341
- // Ambiguous prefix matches: the pre-check uses the same prefix shape as the
342
- // DELETE, so a prefix that hits multiple rows is treated as "found" if any
343
- // row matches the calling vault. The 12-char hex prefix collision space is
344
- // large enough that organic ambiguity is effectively impossible, and
345
- // security-significant ambiguity would require a chosen-prefix attack
346
- // against SHA-256.
347
- let row: { vault_name: string | null } | null;
348
- if (id.startsWith("t_")) {
349
- const hashPrefix = id.slice(2);
350
- row = db.prepare(`
351
- SELECT vault_name FROM tokens
352
- WHERE token_hash LIKE ?
353
- LIMIT 1
354
- `).get(`sha256:${hashPrefix}%`) as { vault_name: string | null } | null;
355
- } else {
356
- row = db.prepare(`
357
- SELECT vault_name FROM tokens
358
- WHERE token_hash = ?
359
- LIMIT 1
360
- `).get(id) as { vault_name: string | null } | null;
361
- }
362
-
363
- if (!row) {
364
- return Response.json({ error: "Not found", message: "no token with that id" }, { status: 404 });
365
- }
366
-
367
- if (row.vault_name !== null && row.vault_name !== vaultName) {
368
- return Response.json(
369
- {
370
- error: "Forbidden",
371
- message: `token belongs to vault '${row.vault_name}', not '${vaultName}'; revoke it from that vault's admin surface`,
372
- },
373
- { status: 403 },
374
- );
375
- }
376
-
377
- if (id.startsWith("t_")) {
378
- const hashPrefix = id.slice(2);
379
- db.prepare(`
380
- DELETE FROM tokens
381
- WHERE token_hash LIKE ?
382
- AND (vault_name = ? OR vault_name IS NULL)
383
- `).run(`sha256:${hashPrefix}%`, vaultName);
384
- } else {
385
- db.prepare(`
386
- DELETE FROM tokens
387
- WHERE token_hash = ?
388
- AND (vault_name = ? OR vault_name IS NULL)
389
- `).run(id, vaultName);
390
- }
391
- return Response.json({ revoked: true });
392
- }