@openparachute/vault 0.4.9-rc.2 → 0.4.9-rc.4
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/core/src/mcp.ts +35 -0
- package/core/src/schema.ts +51 -2
- package/package.json +1 -1
- package/src/auth.ts +29 -1
- package/src/mcp-http.ts +24 -36
- package/src/mcp-tools.ts +286 -2
- package/src/mirror-routes.test.ts +59 -1
- package/src/mirror-routes.ts +41 -2
- package/src/routing.test.ts +73 -0
- package/src/routing.ts +34 -1
- package/src/token-store.ts +158 -5
- package/src/vault.test.ts +380 -1
- package/web/ui/dist/assets/index-BJX47k5V.js +60 -0
- package/web/ui/dist/assets/{index-BOa-JJtV.css → index-KA1P2P3z.css} +1 -1
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-BzA5LgE3.js +0 -60
|
@@ -16,7 +16,11 @@ import {
|
|
|
16
16
|
MirrorManager,
|
|
17
17
|
type MirrorDeps,
|
|
18
18
|
} from "./mirror-manager.ts";
|
|
19
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
handleMirrorGet,
|
|
21
|
+
handleMirrorPut,
|
|
22
|
+
handleMirrorRunNow,
|
|
23
|
+
} from "./mirror-routes.ts";
|
|
20
24
|
|
|
21
25
|
// Same env-restore pattern as mirror-manager.test.ts — keeps HOME +
|
|
22
26
|
// PARACHUTE_HOME from leaking between test files.
|
|
@@ -378,3 +382,57 @@ describe("handleMirrorPut", () => {
|
|
|
378
382
|
await manager.stop();
|
|
379
383
|
});
|
|
380
384
|
});
|
|
385
|
+
|
|
386
|
+
// ---------------------------------------------------------------------------
|
|
387
|
+
// POST /.parachute/mirror/run-now
|
|
388
|
+
// ---------------------------------------------------------------------------
|
|
389
|
+
|
|
390
|
+
describe("handleMirrorRunNow", () => {
|
|
391
|
+
let home: string;
|
|
392
|
+
afterEach(() => {
|
|
393
|
+
if (home) fs.rmSync(home, { recursive: true, force: true });
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
test("returns 400 when mirror is disabled (avoids stale-status no-op)", async () => {
|
|
397
|
+
home = tmp("mirror-runnow-disabled-");
|
|
398
|
+
const { manager, exportCalls } = makeManager(home);
|
|
399
|
+
const res = await handleMirrorRunNow(manager);
|
|
400
|
+
expect(res.status).toBe(400);
|
|
401
|
+
const body = (await res.json()) as { error: string; message: string };
|
|
402
|
+
expect(body.error).toContain("not enabled");
|
|
403
|
+
// The disabled-guard short-circuits BEFORE manager.runNow(), so no
|
|
404
|
+
// export attempt happens — pinning this distinguishes the guard from
|
|
405
|
+
// a "200 with stale status" pass-through that would have looked
|
|
406
|
+
// identical to the operator.
|
|
407
|
+
expect(exportCalls()).toHaveLength(0);
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
test("fires an export pass and returns the updated config+status on success", async () => {
|
|
411
|
+
home = tmp("mirror-runnow-happy-");
|
|
412
|
+
const { manager, deps, exportCalls } = makeManager(home);
|
|
413
|
+
deps.writeMirrorConfig({
|
|
414
|
+
...defaultMirrorConfig(),
|
|
415
|
+
enabled: true,
|
|
416
|
+
location: "internal",
|
|
417
|
+
watch: false,
|
|
418
|
+
auto_commit: false,
|
|
419
|
+
});
|
|
420
|
+
await manager.start();
|
|
421
|
+
// The initial export from start() already ran once. We pin the
|
|
422
|
+
// delta — run-now must trigger a SECOND export pass and the
|
|
423
|
+
// response must carry the updated status.
|
|
424
|
+
const exportsBefore = exportCalls().length;
|
|
425
|
+
const res = await handleMirrorRunNow(manager);
|
|
426
|
+
expect(res.status).toBe(200);
|
|
427
|
+
const body = (await res.json()) as {
|
|
428
|
+
config: MirrorConfig;
|
|
429
|
+
status: { enabled: boolean; last_export_at: string | null; mirror_path: string };
|
|
430
|
+
};
|
|
431
|
+
expect(body.config.enabled).toBe(true);
|
|
432
|
+
expect(body.status.enabled).toBe(true);
|
|
433
|
+
expect(body.status.last_export_at).not.toBeNull();
|
|
434
|
+
expect(body.status.mirror_path).toContain("mirror");
|
|
435
|
+
expect(exportCalls().length).toBe(exportsBefore + 1);
|
|
436
|
+
await manager.stop();
|
|
437
|
+
});
|
|
438
|
+
});
|
package/src/mirror-routes.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* HTTP surface for the mirror lifecycle.
|
|
3
3
|
*
|
|
4
|
-
* GET /vault/<name>/.parachute/mirror
|
|
5
|
-
* PUT /vault/<name>/.parachute/mirror
|
|
4
|
+
* GET /vault/<name>/.parachute/mirror — read current config + runtime status
|
|
5
|
+
* PUT /vault/<name>/.parachute/mirror — update config + reload watch loop
|
|
6
|
+
* POST /vault/<name>/.parachute/mirror/run-now — fire a one-shot export+commit+push pass
|
|
6
7
|
*
|
|
7
8
|
* URL note: the design doc names this `/admin/mirror`, but vault's
|
|
8
9
|
* existing routing already mounts the admin SPA's static-file bundle at
|
|
@@ -137,6 +138,44 @@ export async function handleMirrorPut(
|
|
|
137
138
|
);
|
|
138
139
|
}
|
|
139
140
|
|
|
141
|
+
/**
|
|
142
|
+
* `POST /vault/<name>/.parachute/mirror/run-now` — fire a one-shot export
|
|
143
|
+
* cycle right now (export → optional commit → optional push), using the
|
|
144
|
+
* persisted config. Same response shape as GET so the admin SPA reuses
|
|
145
|
+
* one decoder for both initial-load and after-trigger refresh.
|
|
146
|
+
*
|
|
147
|
+
* Refuses to fire (400) when the mirror is disabled: `runNow()` would
|
|
148
|
+
* already no-op in that case, but returning a 200 with stale status
|
|
149
|
+
* lets a misclick look successful. The 400 is the actionable surface
|
|
150
|
+
* — "enable the mirror first, then re-trigger."
|
|
151
|
+
*
|
|
152
|
+
* Mutating verb, vault:admin-gated upstream in `routing.ts` (alongside
|
|
153
|
+
* the GET/PUT). Auth is already enforced by the time this handler runs.
|
|
154
|
+
*/
|
|
155
|
+
export async function handleMirrorRunNow(
|
|
156
|
+
manager: MirrorManager,
|
|
157
|
+
): Promise<Response> {
|
|
158
|
+
const status = manager.getStatus();
|
|
159
|
+
if (!status.enabled) {
|
|
160
|
+
return Response.json(
|
|
161
|
+
{
|
|
162
|
+
error: "Mirror not enabled",
|
|
163
|
+
message:
|
|
164
|
+
"Mirror must be enabled (and successfully bootstrapped) before a manual run can fire. Enable it via PUT /.parachute/mirror first.",
|
|
165
|
+
},
|
|
166
|
+
{ status: 400 },
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
const updated = await manager.runNow();
|
|
170
|
+
return Response.json(
|
|
171
|
+
{
|
|
172
|
+
config: manager.getConfig(),
|
|
173
|
+
status: updated,
|
|
174
|
+
},
|
|
175
|
+
{ headers: { "Access-Control-Allow-Origin": "*" } },
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
140
179
|
/**
|
|
141
180
|
* Convenience for tests + future callers: build the GET response from a
|
|
142
181
|
* known-good config without needing a real MirrorManager.
|
package/src/routing.test.ts
CHANGED
|
@@ -1822,3 +1822,76 @@ describe("/vault/<name>/.parachute/mirror — auth + dispatch", () => {
|
|
|
1822
1822
|
expect([405, 503]).toContain(res.status);
|
|
1823
1823
|
});
|
|
1824
1824
|
});
|
|
1825
|
+
|
|
1826
|
+
// ---------------------------------------------------------------------------
|
|
1827
|
+
// /vault/<name>/.parachute/mirror/run-now — manual-trigger endpoint added
|
|
1828
|
+
// alongside the SPA UI. Tests pin the auth gate matches the parent
|
|
1829
|
+
// endpoint; handler-shape coverage lives in mirror-routes.test.ts.
|
|
1830
|
+
// ---------------------------------------------------------------------------
|
|
1831
|
+
|
|
1832
|
+
describe("/vault/<name>/.parachute/mirror/run-now — auth + dispatch", () => {
|
|
1833
|
+
test("unauthenticated → 401", async () => {
|
|
1834
|
+
createVault("journal");
|
|
1835
|
+
const p = "/vault/journal/.parachute/mirror/run-now";
|
|
1836
|
+
const res = await route(
|
|
1837
|
+
new Request(`http://localhost:1940${p}`, { method: "POST" }),
|
|
1838
|
+
p,
|
|
1839
|
+
);
|
|
1840
|
+
expect(res.status).toBe(401);
|
|
1841
|
+
});
|
|
1842
|
+
|
|
1843
|
+
test("vault:read token → 403 insufficient_scope", async () => {
|
|
1844
|
+
createVault("journal");
|
|
1845
|
+
const store = getVaultStore("journal");
|
|
1846
|
+
const { fullToken } = generateToken();
|
|
1847
|
+
createToken(store.db, fullToken, {
|
|
1848
|
+
label: "reader",
|
|
1849
|
+
permission: "read",
|
|
1850
|
+
scopes: ["vault:read"],
|
|
1851
|
+
});
|
|
1852
|
+
const p = "/vault/journal/.parachute/mirror/run-now";
|
|
1853
|
+
const res = await route(
|
|
1854
|
+
new Request(`http://localhost:1940${p}`, {
|
|
1855
|
+
method: "POST",
|
|
1856
|
+
headers: { authorization: `Bearer ${fullToken}` },
|
|
1857
|
+
}),
|
|
1858
|
+
p,
|
|
1859
|
+
);
|
|
1860
|
+
expect(res.status).toBe(403);
|
|
1861
|
+
const body = (await res.json()) as { error_type?: string; required_scope?: string };
|
|
1862
|
+
expect(body.error_type).toBe("insufficient_scope");
|
|
1863
|
+
expect(body.required_scope).toBe("vault:admin");
|
|
1864
|
+
});
|
|
1865
|
+
|
|
1866
|
+
test("admin token reaches the handler — 503 when manager not wired, 400 when wired+disabled", async () => {
|
|
1867
|
+
// Mirrors the parent endpoint's harness behavior: test ordering
|
|
1868
|
+
// determines whether a previous test wired a manager. Either way
|
|
1869
|
+
// the auth gate passed, which is what this routing-level test pins.
|
|
1870
|
+
createVault("journal");
|
|
1871
|
+
const token = createAdminToken("journal");
|
|
1872
|
+
const p = "/vault/journal/.parachute/mirror/run-now";
|
|
1873
|
+
const res = await route(
|
|
1874
|
+
new Request(`http://localhost:1940${p}`, {
|
|
1875
|
+
method: "POST",
|
|
1876
|
+
headers: { authorization: `Bearer ${token}` },
|
|
1877
|
+
}),
|
|
1878
|
+
p,
|
|
1879
|
+
);
|
|
1880
|
+
expect([400, 503]).toContain(res.status);
|
|
1881
|
+
});
|
|
1882
|
+
|
|
1883
|
+
test("non-POST methods return 405 when manager is wired", async () => {
|
|
1884
|
+
createVault("journal");
|
|
1885
|
+
const token = createAdminToken("journal");
|
|
1886
|
+
const p = "/vault/journal/.parachute/mirror/run-now";
|
|
1887
|
+
const res = await route(
|
|
1888
|
+
new Request(`http://localhost:1940${p}`, {
|
|
1889
|
+
method: "GET",
|
|
1890
|
+
headers: { authorization: `Bearer ${token}` },
|
|
1891
|
+
}),
|
|
1892
|
+
p,
|
|
1893
|
+
);
|
|
1894
|
+
// 503 short-circuits the method check when no manager is wired.
|
|
1895
|
+
expect([405, 503]).toContain(res.status);
|
|
1896
|
+
});
|
|
1897
|
+
});
|
package/src/routing.ts
CHANGED
|
@@ -72,7 +72,7 @@ import {
|
|
|
72
72
|
} from "./oauth-discovery.ts";
|
|
73
73
|
import { handleConfigSchema, handleConfig } from "./module-config.ts";
|
|
74
74
|
import { buildAuthStatus } from "./auth-status.ts";
|
|
75
|
-
import { handleMirrorGet, handleMirrorPut } from "./mirror-routes.ts";
|
|
75
|
+
import { handleMirrorGet, handleMirrorPut, handleMirrorRunNow } from "./mirror-routes.ts";
|
|
76
76
|
import { getMirrorManager } from "./mirror-registry.ts";
|
|
77
77
|
|
|
78
78
|
/**
|
|
@@ -511,6 +511,39 @@ export async function route(
|
|
|
511
511
|
return Response.json({ error: "Method not allowed" }, { status: 405 });
|
|
512
512
|
}
|
|
513
513
|
|
|
514
|
+
// /.parachute/mirror/run-now — fire a one-shot export+commit+push pass.
|
|
515
|
+
// Same admin gate as the GET/PUT above; same manager presence check.
|
|
516
|
+
// POST-only — a GET would imply "read the result of running" which
|
|
517
|
+
// isn't the verb (the rolling status is already available on the
|
|
518
|
+
// parent GET endpoint).
|
|
519
|
+
if (subpath === "/.parachute/mirror/run-now") {
|
|
520
|
+
if (!hasScopeForVault(auth.scopes, vaultName, "admin")) {
|
|
521
|
+
return Response.json(
|
|
522
|
+
{
|
|
523
|
+
error: "Forbidden",
|
|
524
|
+
error_type: "insufficient_scope",
|
|
525
|
+
message: `This endpoint requires the '${SCOPE_ADMIN}' scope (or '${SCOPE_ADMIN.replace("vault:", `vault:${vaultName}:`)}').`,
|
|
526
|
+
required_scope: SCOPE_ADMIN,
|
|
527
|
+
granted_scopes: auth.scopes,
|
|
528
|
+
},
|
|
529
|
+
{ status: 403 },
|
|
530
|
+
);
|
|
531
|
+
}
|
|
532
|
+
const manager = getMirrorManager();
|
|
533
|
+
if (!manager) {
|
|
534
|
+
return Response.json(
|
|
535
|
+
{
|
|
536
|
+
error: "Mirror manager not initialized",
|
|
537
|
+
message:
|
|
538
|
+
"The vault server hasn't wired a mirror manager yet (no vaults exist, or boot failed). Check logs for [mirror] entries.",
|
|
539
|
+
},
|
|
540
|
+
{ status: 503 },
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
if (req.method === "POST") return handleMirrorRunNow(manager);
|
|
544
|
+
return Response.json({ error: "Method not allowed" }, { status: 405 });
|
|
545
|
+
}
|
|
546
|
+
|
|
514
547
|
const apiMatch = subpath.match(/^\/api(\/.*)?$/);
|
|
515
548
|
if (!apiMatch) {
|
|
516
549
|
return Response.json({ error: "Not found" }, { status: 404 });
|
package/src/token-store.ts
CHANGED
|
@@ -63,6 +63,26 @@ export interface Token {
|
|
|
63
63
|
expires_at: string | null;
|
|
64
64
|
created_at: string;
|
|
65
65
|
last_used_at: string | null;
|
|
66
|
+
/**
|
|
67
|
+
* Provenance (v19). 'mcp_mint' = minted via manage-token MCP tool;
|
|
68
|
+
* NULL = pre-v19 / CLI / REST / YAML-import. Used by manage-token list
|
|
69
|
+
* to restrict the surface to MCP-session-managed tokens. See vault#376.
|
|
70
|
+
*/
|
|
71
|
+
created_via: string | null;
|
|
72
|
+
/**
|
|
73
|
+
* Session pin (v19). When this token was minted via manage-token, this
|
|
74
|
+
* is the display id (`t_<prefix>`) of the calling session's token (for
|
|
75
|
+
* pvt_* MCP sessions) or the hub JWT's jti claim (for hub-issued
|
|
76
|
+
* sessions). NULL otherwise.
|
|
77
|
+
*/
|
|
78
|
+
parent_jti: string | null;
|
|
79
|
+
/**
|
|
80
|
+
* Soft-revoke timestamp (v19). When set, `resolveToken` returns null
|
|
81
|
+
* and the row stays in place for audit history. manage-token revoke is
|
|
82
|
+
* idempotent — calling revoke a second time on the same jti is a no-op
|
|
83
|
+
* with ok=true. NULL = active.
|
|
84
|
+
*/
|
|
85
|
+
revoked_at: string | null;
|
|
66
86
|
}
|
|
67
87
|
|
|
68
88
|
export interface ResolvedToken {
|
|
@@ -88,6 +108,12 @@ export interface ResolvedToken {
|
|
|
88
108
|
* vault. See vault#257.
|
|
89
109
|
*/
|
|
90
110
|
vault_name: string | null;
|
|
111
|
+
/**
|
|
112
|
+
* Display id (`t_<hashprefix>`) of THIS token. Surfaced so callers that
|
|
113
|
+
* later mint child tokens (manage-token MCP tool) can stamp parent_jti
|
|
114
|
+
* without re-derivation. Pre-v19 lookups still compute this on the fly.
|
|
115
|
+
*/
|
|
116
|
+
jti: string;
|
|
91
117
|
}
|
|
92
118
|
|
|
93
119
|
/**
|
|
@@ -149,6 +175,17 @@ export function createToken(
|
|
|
149
175
|
*/
|
|
150
176
|
vault_name?: string | null;
|
|
151
177
|
expires_at?: string | null;
|
|
178
|
+
/**
|
|
179
|
+
* Provenance tag (v19). `'mcp_mint'` for tokens minted via the
|
|
180
|
+
* manage-token MCP tool; omit/null for CLI / REST / YAML paths.
|
|
181
|
+
*/
|
|
182
|
+
created_via?: string | null;
|
|
183
|
+
/**
|
|
184
|
+
* Session pin (v19). Display id (`t_<prefix>`) or hub JWT `jti` of the
|
|
185
|
+
* caller that minted this token via manage-token. Used by the
|
|
186
|
+
* manage-token list/revoke surface to scope itself to one session.
|
|
187
|
+
*/
|
|
188
|
+
parent_jti?: string | null;
|
|
152
189
|
},
|
|
153
190
|
): Token {
|
|
154
191
|
const tokenHash = hashKey(fullToken);
|
|
@@ -159,10 +196,12 @@ export function createToken(
|
|
|
159
196
|
const scopedTags = opts.scoped_tags && opts.scoped_tags.length > 0 ? opts.scoped_tags : null;
|
|
160
197
|
const scopedTagsStr = scopedTags ? JSON.stringify(scopedTags) : null;
|
|
161
198
|
const vaultName = opts.vault_name ?? null;
|
|
199
|
+
const createdVia = opts.created_via ?? null;
|
|
200
|
+
const parentJti = opts.parent_jti ?? null;
|
|
162
201
|
|
|
163
202
|
db.prepare(`
|
|
164
|
-
INSERT INTO tokens (token_hash, label, permission, scopes, scoped_tags, scope_tag, scope_path_prefix, expires_at, created_at, vault_name)
|
|
165
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
203
|
+
INSERT INTO tokens (token_hash, label, permission, scopes, scoped_tags, scope_tag, scope_path_prefix, expires_at, created_at, vault_name, created_via, parent_jti)
|
|
204
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
166
205
|
`).run(
|
|
167
206
|
tokenHash,
|
|
168
207
|
opts.label,
|
|
@@ -174,6 +213,8 @@ export function createToken(
|
|
|
174
213
|
opts.expires_at ?? null,
|
|
175
214
|
now,
|
|
176
215
|
vaultName,
|
|
216
|
+
createdVia,
|
|
217
|
+
parentJti,
|
|
177
218
|
);
|
|
178
219
|
|
|
179
220
|
return {
|
|
@@ -187,6 +228,9 @@ export function createToken(
|
|
|
187
228
|
expires_at: opts.expires_at ?? null,
|
|
188
229
|
created_at: now,
|
|
189
230
|
last_used_at: null,
|
|
231
|
+
created_via: createdVia,
|
|
232
|
+
parent_jti: parentJti,
|
|
233
|
+
revoked_at: null,
|
|
190
234
|
};
|
|
191
235
|
}
|
|
192
236
|
|
|
@@ -200,8 +244,15 @@ export function resolveToken(db: Database, providedToken: string): ResolvedToken
|
|
|
200
244
|
// preimage, which is computationally infeasible regardless of timing leaks.
|
|
201
245
|
const candidateHash = hashKey(providedToken);
|
|
202
246
|
|
|
247
|
+
// Defensive SELECT for revoked_at: the column exists post-v19, but a
|
|
248
|
+
// freshly-opened ResolvedToken-only test fixture might run on a DB the
|
|
249
|
+
// migration hasn't touched. SQLite returns NULL for missing columns when
|
|
250
|
+
// the table is queried via prepared statements only after migration; here
|
|
251
|
+
// initSchema fires on every store-open path, so the column is guaranteed
|
|
252
|
+
// present in production. Tests instantiating bare DBs against this
|
|
253
|
+
// module are expected to call initSchema first.
|
|
203
254
|
const row = db.prepare(`
|
|
204
|
-
SELECT token_hash, permission, scopes, scoped_tags, expires_at, vault_name
|
|
255
|
+
SELECT token_hash, permission, scopes, scoped_tags, expires_at, vault_name, revoked_at
|
|
205
256
|
FROM tokens WHERE token_hash = ?
|
|
206
257
|
`).get(candidateHash) as {
|
|
207
258
|
token_hash: string;
|
|
@@ -210,10 +261,16 @@ export function resolveToken(db: Database, providedToken: string): ResolvedToken
|
|
|
210
261
|
scoped_tags: string | null;
|
|
211
262
|
expires_at: string | null;
|
|
212
263
|
vault_name: string | null;
|
|
264
|
+
revoked_at: string | null;
|
|
213
265
|
} | null;
|
|
214
266
|
|
|
215
267
|
if (!row) return null;
|
|
216
268
|
|
|
269
|
+
// Soft-revoked tokens never authenticate (v19). The row stays in place
|
|
270
|
+
// for audit; resolveToken just treats it as not-found from the caller's
|
|
271
|
+
// perspective.
|
|
272
|
+
if (row.revoked_at) return null;
|
|
273
|
+
|
|
217
274
|
// Check expiry
|
|
218
275
|
if (row.expires_at && new Date(row.expires_at) < new Date()) {
|
|
219
276
|
return null;
|
|
@@ -229,8 +286,9 @@ export function resolveToken(db: Database, providedToken: string): ResolvedToken
|
|
|
229
286
|
const scopes = hasVaultScope ? parsed : legacyPermissionToScopes(permission);
|
|
230
287
|
const legacyDerived = !hasVaultScope;
|
|
231
288
|
const scoped_tags = parseScopedTags(row.scoped_tags);
|
|
289
|
+
const jti = `t_${row.token_hash.slice(7, 19)}`;
|
|
232
290
|
|
|
233
|
-
return { permission, scopes, legacyDerived, scoped_tags, vault_name: row.vault_name };
|
|
291
|
+
return { permission, scopes, legacyDerived, scoped_tags, vault_name: row.vault_name, jti };
|
|
234
292
|
}
|
|
235
293
|
|
|
236
294
|
/**
|
|
@@ -252,7 +310,8 @@ export function listTokens(
|
|
|
252
310
|
const params = opts.vaultName ? [opts.vaultName] : [];
|
|
253
311
|
const rows = db.prepare(`
|
|
254
312
|
SELECT token_hash, label, permission, scope_tag, scope_path_prefix,
|
|
255
|
-
scoped_tags, vault_name, expires_at, created_at, last_used_at
|
|
313
|
+
scoped_tags, vault_name, expires_at, created_at, last_used_at,
|
|
314
|
+
created_via, parent_jti, revoked_at
|
|
256
315
|
FROM tokens ${where}
|
|
257
316
|
ORDER BY created_at DESC
|
|
258
317
|
`).all(...params) as (Omit<Token, "scoped_tags"> & { scoped_tags: string | null })[];
|
|
@@ -266,6 +325,100 @@ export function listTokens(
|
|
|
266
325
|
}));
|
|
267
326
|
}
|
|
268
327
|
|
|
328
|
+
/**
|
|
329
|
+
* List tokens minted via the manage-token MCP tool by a given session
|
|
330
|
+
* (parent_jti). Used by `manage-token` action="list" to scope its surface
|
|
331
|
+
* to its own session's mints — operators with multiple MCP sessions open
|
|
332
|
+
* don't see each other's tokens, and CLI/REST-minted tokens never appear.
|
|
333
|
+
*
|
|
334
|
+
* Returns metadata only (no token-hash exposure beyond the display id);
|
|
335
|
+
* the display id is what the caller uses to revoke. Includes `revoked_at`
|
|
336
|
+
* so the UI can render a tombstone for soft-revoked rows.
|
|
337
|
+
*/
|
|
338
|
+
export function listMcpMintedTokens(
|
|
339
|
+
db: Database,
|
|
340
|
+
parentJti: string,
|
|
341
|
+
vaultName: string,
|
|
342
|
+
): Array<{
|
|
343
|
+
jti: string;
|
|
344
|
+
label: string;
|
|
345
|
+
scopes: string[];
|
|
346
|
+
scoped_tags: string[] | null;
|
|
347
|
+
created_at: string;
|
|
348
|
+
expires_at: string | null;
|
|
349
|
+
revoked_at: string | null;
|
|
350
|
+
}> {
|
|
351
|
+
const rows = db.prepare(`
|
|
352
|
+
SELECT token_hash, label, scopes, scoped_tags, created_at, expires_at, revoked_at
|
|
353
|
+
FROM tokens
|
|
354
|
+
WHERE created_via = 'mcp_mint'
|
|
355
|
+
AND parent_jti = ?
|
|
356
|
+
AND vault_name = ?
|
|
357
|
+
ORDER BY created_at DESC
|
|
358
|
+
`).all(parentJti, vaultName) as {
|
|
359
|
+
token_hash: string;
|
|
360
|
+
label: string;
|
|
361
|
+
scopes: string | null;
|
|
362
|
+
scoped_tags: string | null;
|
|
363
|
+
created_at: string;
|
|
364
|
+
expires_at: string | null;
|
|
365
|
+
revoked_at: string | null;
|
|
366
|
+
}[];
|
|
367
|
+
return rows.map((r) => ({
|
|
368
|
+
jti: `t_${r.token_hash.slice(7, 19)}`,
|
|
369
|
+
label: r.label,
|
|
370
|
+
scopes: parseScopes(r.scopes),
|
|
371
|
+
scoped_tags: parseScopedTags(r.scoped_tags),
|
|
372
|
+
created_at: r.created_at,
|
|
373
|
+
expires_at: r.expires_at,
|
|
374
|
+
revoked_at: r.revoked_at,
|
|
375
|
+
}));
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Soft-revoke a token minted via manage-token, scoped to the session that
|
|
380
|
+
* minted it. Idempotent: revoking an already-revoked or never-existent jti
|
|
381
|
+
* returns the same shape; second-call to revoke is intentionally still
|
|
382
|
+
* ok=true so the AI's revoke step doesn't surface a confusing failure on a
|
|
383
|
+
* retry after a network blip. The row stays in place for audit trail —
|
|
384
|
+
* resolveToken treats revoked_at-set rows as not-found.
|
|
385
|
+
*
|
|
386
|
+
* `parentJti` + `vaultName` scope the lookup: a token minted by a
|
|
387
|
+
* different MCP session (or against a different vault) returns ok=false.
|
|
388
|
+
* Returns { ok: true, already_revoked? } when the operation matched a row.
|
|
389
|
+
*/
|
|
390
|
+
export function softRevokeMcpToken(
|
|
391
|
+
db: Database,
|
|
392
|
+
jti: string,
|
|
393
|
+
parentJti: string,
|
|
394
|
+
vaultName: string,
|
|
395
|
+
): { ok: true; already_revoked: boolean } | { ok: false; reason: "not_found" } {
|
|
396
|
+
if (!jti.startsWith("t_")) {
|
|
397
|
+
return { ok: false, reason: "not_found" };
|
|
398
|
+
}
|
|
399
|
+
const hashPrefix = jti.slice(2);
|
|
400
|
+
const row = db.prepare(`
|
|
401
|
+
SELECT token_hash, revoked_at FROM tokens
|
|
402
|
+
WHERE token_hash LIKE ?
|
|
403
|
+
AND created_via = 'mcp_mint'
|
|
404
|
+
AND parent_jti = ?
|
|
405
|
+
AND vault_name = ?
|
|
406
|
+
LIMIT 1
|
|
407
|
+
`).get(`sha256:${hashPrefix}%`, parentJti, vaultName) as {
|
|
408
|
+
token_hash: string;
|
|
409
|
+
revoked_at: string | null;
|
|
410
|
+
} | null;
|
|
411
|
+
|
|
412
|
+
if (!row) return { ok: false, reason: "not_found" };
|
|
413
|
+
if (row.revoked_at) {
|
|
414
|
+
// Second revoke: idempotent — already done, surface true with the flag.
|
|
415
|
+
return { ok: true, already_revoked: true };
|
|
416
|
+
}
|
|
417
|
+
db.prepare("UPDATE tokens SET revoked_at = ? WHERE token_hash = ?")
|
|
418
|
+
.run(new Date().toISOString(), row.token_hash);
|
|
419
|
+
return { ok: true, already_revoked: false };
|
|
420
|
+
}
|
|
421
|
+
|
|
269
422
|
/**
|
|
270
423
|
* Find tokens whose `scoped_tags` allowlist references the given root tag.
|
|
271
424
|
* Used by tag-delete and tag-merge to fail-closed (409) when removing a
|