@openparachute/vault 0.4.8 → 0.4.9-rc.11
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/core.test.ts +4 -1
- package/core/src/hooks.test.ts +320 -1
- package/core/src/hooks.ts +243 -38
- package/core/src/indexed-fields.test.ts +151 -0
- package/core/src/indexed-fields.ts +98 -0
- package/core/src/mcp.ts +99 -41
- package/core/src/notes.ts +26 -2
- package/core/src/portable-md.test.ts +304 -1
- package/core/src/portable-md.ts +418 -2
- package/core/src/schema.ts +114 -2
- package/core/src/store.ts +185 -2
- package/core/src/types.ts +28 -0
- package/package.json +2 -2
- package/src/auth-hub-jwt.test.ts +147 -0
- package/src/auth.ts +121 -1
- package/src/auto-transcribe.test.ts +7 -2
- package/src/auto-transcribe.ts +6 -2
- package/src/cli.ts +131 -36
- package/src/config.ts +12 -4
- package/src/export-watch.test.ts +74 -0
- package/src/export-watch.ts +108 -7
- package/src/github-device-flow.test.ts +404 -0
- package/src/github-device-flow.ts +415 -0
- package/src/hub-jwt.test.ts +27 -2
- package/src/hub-jwt.ts +10 -0
- package/src/mcp-http.ts +48 -39
- package/src/mcp-install-interactive.test.ts +10 -21
- package/src/mcp-install-interactive.ts +12 -21
- package/src/mcp-install.test.ts +141 -30
- package/src/mcp-install.ts +109 -3
- package/src/mcp-tools.ts +460 -3
- package/src/mirror-config.test.ts +277 -14
- package/src/mirror-config.ts +482 -31
- package/src/mirror-credentials.test.ts +601 -0
- package/src/mirror-credentials.ts +700 -0
- package/src/mirror-deps.ts +67 -17
- package/src/mirror-import.test.ts +550 -0
- package/src/mirror-import.ts +487 -0
- package/src/mirror-manager.test.ts +423 -12
- package/src/mirror-manager.ts +621 -72
- package/src/mirror-per-vault.test.ts +519 -0
- package/src/mirror-registry.ts +91 -14
- package/src/mirror-routes.test.ts +966 -10
- package/src/mirror-routes.ts +1111 -7
- package/src/module-config.ts +11 -5
- package/src/routes.ts +38 -1
- package/src/routing.test.ts +92 -1
- package/src/routing.ts +193 -20
- package/src/server.ts +116 -35
- package/src/storage.test.ts +132 -7
- package/src/token-store.ts +300 -5
- package/src/transcription-worker.ts +9 -4
- package/src/triggers.ts +16 -3
- package/src/vault.test.ts +681 -2
- package/web/ui/dist/assets/index-Cn-PPMRv.js +60 -0
- package/web/ui/dist/assets/{index-BOa-JJtV.css → index-DBe8Xiah.css} +1 -1
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-BzA5LgE3.js +0 -60
package/src/storage.test.ts
CHANGED
|
@@ -8,9 +8,14 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
|
|
11
|
-
import { rmSync, existsSync, mkdirSync } from "fs";
|
|
11
|
+
import { rmSync, existsSync, mkdirSync, writeFileSync } from "fs";
|
|
12
12
|
import { join } from "path";
|
|
13
13
|
import { tmpdir } from "os";
|
|
14
|
+
import { Database } from "bun:sqlite";
|
|
15
|
+
import { SqliteStore } from "../core/src/store.ts";
|
|
16
|
+
import { initSchema } from "../core/src/schema.ts";
|
|
17
|
+
import type { Store } from "../core/src/types.ts";
|
|
18
|
+
import type { TagScopeCtx } from "./routes.ts";
|
|
14
19
|
|
|
15
20
|
const testDir = join(
|
|
16
21
|
tmpdir(),
|
|
@@ -20,6 +25,16 @@ process.env.PARACHUTE_HOME = testDir;
|
|
|
20
25
|
process.env.ASSETS_DIR = join(testDir, "assets");
|
|
21
26
|
|
|
22
27
|
const { handleStorage } = await import("./routes.ts");
|
|
28
|
+
const { expandTokenTagScope } = await import("./tag-scope.ts");
|
|
29
|
+
|
|
30
|
+
// The upload-allowlist tests never touch the store (POST /upload writes to
|
|
31
|
+
// disk only); a fresh in-memory store satisfies the now-required param.
|
|
32
|
+
function freshStore(): SqliteStore {
|
|
33
|
+
const db = new Database(":memory:");
|
|
34
|
+
initSchema(db);
|
|
35
|
+
return new SqliteStore(db);
|
|
36
|
+
}
|
|
37
|
+
const uploadStore = freshStore();
|
|
23
38
|
|
|
24
39
|
function uploadRequest(filename: string, mimeType: string): Request {
|
|
25
40
|
const form = new FormData();
|
|
@@ -33,6 +48,11 @@ function uploadRequest(filename: string, mimeType: string): Request {
|
|
|
33
48
|
});
|
|
34
49
|
}
|
|
35
50
|
|
|
51
|
+
/** Build the per-request TagScopeCtx the dispatcher hands handlers. */
|
|
52
|
+
async function tagScopeCtx(store: Store, scopedTags: string[] | null): Promise<TagScopeCtx> {
|
|
53
|
+
return { allowed: await expandTokenTagScope(store, scopedTags), raw: scopedTags };
|
|
54
|
+
}
|
|
55
|
+
|
|
36
56
|
beforeAll(() => {
|
|
37
57
|
mkdirSync(testDir, { recursive: true });
|
|
38
58
|
mkdirSync(join(testDir, "assets"), { recursive: true });
|
|
@@ -44,7 +64,7 @@ afterAll(() => {
|
|
|
44
64
|
|
|
45
65
|
describe("storage upload allowlist", () => {
|
|
46
66
|
test("accepts .pdf — knowledge-vault content (#127)", async () => {
|
|
47
|
-
const res = await handleStorage(uploadRequest("paper.pdf", "application/pdf"), "/upload", "default");
|
|
67
|
+
const res = await handleStorage(uploadRequest("paper.pdf", "application/pdf"), "/upload", "default", uploadStore);
|
|
48
68
|
expect(res.status).toBe(201);
|
|
49
69
|
const body = (await res.json()) as { mimeType: string; path: string };
|
|
50
70
|
expect(body.mimeType).toBe("application/pdf");
|
|
@@ -52,7 +72,7 @@ describe("storage upload allowlist", () => {
|
|
|
52
72
|
});
|
|
53
73
|
|
|
54
74
|
test("accepts .mp4 — mobile capture default (#127)", async () => {
|
|
55
|
-
const res = await handleStorage(uploadRequest("clip.mp4", "video/mp4"), "/upload", "default");
|
|
75
|
+
const res = await handleStorage(uploadRequest("clip.mp4", "video/mp4"), "/upload", "default", uploadStore);
|
|
56
76
|
expect(res.status).toBe(201);
|
|
57
77
|
const body = (await res.json()) as { mimeType: string };
|
|
58
78
|
expect(body.mimeType).toBe("video/mp4");
|
|
@@ -66,27 +86,132 @@ describe("storage upload allowlist", () => {
|
|
|
66
86
|
["photo.jpg", "image/jpeg"],
|
|
67
87
|
["clip.webm", "audio/webm"],
|
|
68
88
|
] as const) {
|
|
69
|
-
const res = await handleStorage(uploadRequest(name, mime), "/upload", "default");
|
|
89
|
+
const res = await handleStorage(uploadRequest(name, mime), "/upload", "default", uploadStore);
|
|
70
90
|
expect(res.status).toBe(201);
|
|
71
91
|
}
|
|
72
92
|
});
|
|
73
93
|
|
|
74
94
|
test("rejects .svg — XSS vector via inline <script> (#127)", async () => {
|
|
75
|
-
const res = await handleStorage(uploadRequest("evil.svg", "image/svg+xml"), "/upload", "default");
|
|
95
|
+
const res = await handleStorage(uploadRequest("evil.svg", "image/svg+xml"), "/upload", "default", uploadStore);
|
|
76
96
|
expect(res.status).toBe(400);
|
|
77
97
|
const body = (await res.json()) as { error: string };
|
|
78
98
|
expect(body.error).toContain(".svg");
|
|
79
99
|
});
|
|
80
100
|
|
|
81
101
|
test("rejects .html — same XSS surface as SVG (#127)", async () => {
|
|
82
|
-
const res = await handleStorage(uploadRequest("evil.html", "text/html"), "/upload", "default");
|
|
102
|
+
const res = await handleStorage(uploadRequest("evil.html", "text/html"), "/upload", "default", uploadStore);
|
|
83
103
|
expect(res.status).toBe(400);
|
|
84
104
|
const body = (await res.json()) as { error: string };
|
|
85
105
|
expect(body.error).toContain(".html");
|
|
86
106
|
});
|
|
87
107
|
|
|
88
108
|
test("rejects unknown extensions (default-deny)", async () => {
|
|
89
|
-
const res = await handleStorage(uploadRequest("payload.exe", "application/octet-stream"), "/upload", "default");
|
|
109
|
+
const res = await handleStorage(uploadRequest("payload.exe", "application/octet-stream"), "/upload", "default", uploadStore);
|
|
90
110
|
expect(res.status).toBe(400);
|
|
91
111
|
});
|
|
92
112
|
});
|
|
113
|
+
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
// GET byte-serve tag-scope enforcement (C0 adversarial-audit finding).
|
|
116
|
+
//
|
|
117
|
+
// The raw `/api/storage/<date>/<file>` endpoint historically served bytes by
|
|
118
|
+
// filesystem path with only a path-traversal guard — bypassing the tag-scope
|
|
119
|
+
// enforcement that gates every note-keyed attachment surface. A tag-scoped
|
|
120
|
+
// token could therefore fetch an out-of-scope note's attachment bytes
|
|
121
|
+
// directly if it learned the (UUID-secret) storage path. These tests pin the
|
|
122
|
+
// fix: in-scope → 200, out-of-scope → 404 (no existence oracle), unscoped →
|
|
123
|
+
// 200 (regression), path-traversal guard intact (regression).
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
describe("storage GET tag-scope enforcement", () => {
|
|
127
|
+
// Each test builds its own vault assets dir + store so rows and on-disk
|
|
128
|
+
// files line up. `vault` names the assets subdir; ASSETS_DIR is global to
|
|
129
|
+
// the test process, so we point it at this vault's dir per test.
|
|
130
|
+
const VAULT = "scope-vault";
|
|
131
|
+
|
|
132
|
+
async function setup(): Promise<{
|
|
133
|
+
store: SqliteStore;
|
|
134
|
+
assets: string;
|
|
135
|
+
inScopePath: string;
|
|
136
|
+
outScopePath: string;
|
|
137
|
+
}> {
|
|
138
|
+
const store = freshStore();
|
|
139
|
+
const assets = join(testDir, "assets", VAULT, "data");
|
|
140
|
+
mkdirSync(join(assets, "2026-05-28"), { recursive: true });
|
|
141
|
+
process.env.ASSETS_DIR = assets;
|
|
142
|
+
|
|
143
|
+
// An in-scope (#work) note + attachment, and an out-of-scope (#health)
|
|
144
|
+
// note + attachment. Both files exist on disk.
|
|
145
|
+
const workNote = await store.createNote("work note", { tags: ["work"] });
|
|
146
|
+
const healthNote = await store.createNote("health note", { tags: ["health"] });
|
|
147
|
+
|
|
148
|
+
const inScopePath = "2026-05-28/work-asset.pdf";
|
|
149
|
+
const outScopePath = "2026-05-28/health-asset.pdf";
|
|
150
|
+
writeFileSync(join(assets, inScopePath), Buffer.from([0x25, 0x50, 0x44, 0x46])); // %PDF
|
|
151
|
+
writeFileSync(join(assets, outScopePath), Buffer.from([0x25, 0x50, 0x44, 0x46]));
|
|
152
|
+
|
|
153
|
+
await store.addAttachment(workNote.id, inScopePath, "application/pdf");
|
|
154
|
+
await store.addAttachment(healthNote.id, outScopePath, "application/pdf");
|
|
155
|
+
|
|
156
|
+
return { store, assets, inScopePath, outScopePath };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function getReq(reqPath: string): Request {
|
|
160
|
+
return new Request(`http://localhost:1940/storage/${reqPath}`, { method: "GET" });
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
test("tag-scoped token (work): GET in-scope attachment → 200 (bytes served)", async () => {
|
|
164
|
+
const { store, inScopePath } = await setup();
|
|
165
|
+
const ctx = await tagScopeCtx(store, ["work"]);
|
|
166
|
+
const res = await handleStorage(getReq(inScopePath), `/${inScopePath}`, VAULT, store, ctx);
|
|
167
|
+
expect(res.status).toBe(200);
|
|
168
|
+
expect(res.headers.get("Content-Type")).toBe("application/pdf");
|
|
169
|
+
expect((await res.arrayBuffer()).byteLength).toBe(4);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("tag-scoped token (work): GET OUT-of-scope attachment → 404 (no existence oracle)", async () => {
|
|
173
|
+
const { store, outScopePath } = await setup();
|
|
174
|
+
const ctx = await tagScopeCtx(store, ["work"]);
|
|
175
|
+
const res = await handleStorage(getReq(outScopePath), `/${outScopePath}`, VAULT, store, ctx);
|
|
176
|
+
expect(res.status).toBe(404);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("tag-scoped token: GET path with NO owning attachment row → 404", async () => {
|
|
180
|
+
const { store, assets } = await setup();
|
|
181
|
+
// A real on-disk file that no attachment row references — must 404 for a
|
|
182
|
+
// scoped token (would-be existence oracle otherwise).
|
|
183
|
+
const orphanPath = "2026-05-28/orphan-on-disk.pdf";
|
|
184
|
+
writeFileSync(join(assets, orphanPath), Buffer.from([0x25, 0x50, 0x44, 0x46]));
|
|
185
|
+
const ctx = await tagScopeCtx(store, ["work"]);
|
|
186
|
+
const res = await handleStorage(getReq(orphanPath), `/${orphanPath}`, VAULT, store, ctx);
|
|
187
|
+
expect(res.status).toBe(404);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("unscoped token: GET any attachment → 200 (regression — no behavior change)", async () => {
|
|
191
|
+
const { store, outScopePath } = await setup();
|
|
192
|
+
const ctx = await tagScopeCtx(store, null); // unscoped
|
|
193
|
+
const res = await handleStorage(getReq(outScopePath), `/${outScopePath}`, VAULT, store, ctx);
|
|
194
|
+
expect(res.status).toBe(200);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("default ctx (no tagScope arg): unscoped behavior — 200 (regression)", async () => {
|
|
198
|
+
const { store, outScopePath } = await setup();
|
|
199
|
+
const res = await handleStorage(getReq(outScopePath), `/${outScopePath}`, VAULT, store);
|
|
200
|
+
expect(res.status).toBe(200);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("path-traversal guard still blocks ../ escapes (regression)", async () => {
|
|
204
|
+
const { store } = await setup();
|
|
205
|
+
const ctx = await tagScopeCtx(store, ["work"]);
|
|
206
|
+
// `/a/../../../etc/passwd` resolves outside assetsDir → 403 Invalid path.
|
|
207
|
+
const evil = "/a/../../../../../../etc/passwd";
|
|
208
|
+
const res = await handleStorage(
|
|
209
|
+
new Request(`http://localhost:1940/storage${evil}`, { method: "GET" }),
|
|
210
|
+
evil,
|
|
211
|
+
VAULT,
|
|
212
|
+
store,
|
|
213
|
+
ctx,
|
|
214
|
+
);
|
|
215
|
+
expect(res.status).toBe(403);
|
|
216
|
+
});
|
|
217
|
+
});
|
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,242 @@ 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
|
+
|
|
422
|
+
// ---------------------------------------------------------------------------
|
|
423
|
+
// mcp_mint_ledger — session-pinned index of HUB JWTs minted by manage-token
|
|
424
|
+
// (vault#403, MGT). After the auth-unification arc the tool mints hub JWTs,
|
|
425
|
+
// not pvt_* rows, so the session attribution (parent_jti → minted jti) lives
|
|
426
|
+
// here instead of in the `tokens` table. Rows are NOT credentials — only the
|
|
427
|
+
// hub `jti` (the revocation handle) plus display metadata is stored; the
|
|
428
|
+
// signed token never touches the vault DB.
|
|
429
|
+
// ---------------------------------------------------------------------------
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Record a hub-minted JWT in the session-pinned ledger. `jti` is hub's
|
|
433
|
+
* returned jti; `parentJti` is the minting MCP session (the caller's
|
|
434
|
+
* `caller_jti`).
|
|
435
|
+
*
|
|
436
|
+
* Uses INSERT OR IGNORE, NOT OR REPLACE: hub guarantees jti uniqueness, so a
|
|
437
|
+
* pre-existing row with this jti shouldn't happen. If it does, it's a real bug
|
|
438
|
+
* (a hub jti collision) — we log a warning and KEEP the existing row rather
|
|
439
|
+
* than overwriting it, because OR REPLACE would silently reset a previously-set
|
|
440
|
+
* `revoked_at` and resurrect a revoked token in the list/revoke surface.
|
|
441
|
+
*/
|
|
442
|
+
export function recordMcpMintLedger(
|
|
443
|
+
db: Database,
|
|
444
|
+
entry: {
|
|
445
|
+
jti: string;
|
|
446
|
+
parentJti: string;
|
|
447
|
+
vaultName: string;
|
|
448
|
+
label: string;
|
|
449
|
+
scopes: string[];
|
|
450
|
+
scopedTags: string[] | null;
|
|
451
|
+
expiresAt: string | null;
|
|
452
|
+
},
|
|
453
|
+
): void {
|
|
454
|
+
const scopedTags = entry.scopedTags && entry.scopedTags.length > 0 ? entry.scopedTags : null;
|
|
455
|
+
const result = db.prepare(`
|
|
456
|
+
INSERT OR IGNORE INTO mcp_mint_ledger
|
|
457
|
+
(jti, parent_jti, vault_name, label, scopes, scoped_tags, created_at, expires_at, revoked_at)
|
|
458
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL)
|
|
459
|
+
`).run(
|
|
460
|
+
entry.jti,
|
|
461
|
+
entry.parentJti,
|
|
462
|
+
entry.vaultName,
|
|
463
|
+
entry.label,
|
|
464
|
+
serializeScopes(entry.scopes),
|
|
465
|
+
scopedTags ? JSON.stringify(scopedTags) : null,
|
|
466
|
+
new Date().toISOString(),
|
|
467
|
+
entry.expiresAt,
|
|
468
|
+
);
|
|
469
|
+
if (result.changes === 0) {
|
|
470
|
+
// Row already existed — IGNORE swallowed the conflict. Surface it: a hub
|
|
471
|
+
// jti collision is a real bug worth investigating (the existing row is
|
|
472
|
+
// left untouched, including any `revoked_at`).
|
|
473
|
+
console.warn(
|
|
474
|
+
`[manage-token] mcp_mint_ledger already has a row for jti '${entry.jti}' — skipped insert (kept existing row). A hub jti collision shouldn't happen; investigate.`,
|
|
475
|
+
);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* List hub JWTs minted by a given MCP session (parent_jti) against a vault.
|
|
481
|
+
* Mirrors `listMcpMintedTokens`' shape so the manage-token list surface is
|
|
482
|
+
* unchanged on the wire. Includes `revoked_at` so callers can render a
|
|
483
|
+
* tombstone for soft-revoked rows.
|
|
484
|
+
*/
|
|
485
|
+
export function listMcpMintedHubJwts(
|
|
486
|
+
db: Database,
|
|
487
|
+
parentJti: string,
|
|
488
|
+
vaultName: string,
|
|
489
|
+
): Array<{
|
|
490
|
+
jti: string;
|
|
491
|
+
label: string;
|
|
492
|
+
scopes: string[];
|
|
493
|
+
scoped_tags: string[] | null;
|
|
494
|
+
created_at: string;
|
|
495
|
+
expires_at: string | null;
|
|
496
|
+
revoked_at: string | null;
|
|
497
|
+
}> {
|
|
498
|
+
const rows = db.prepare(`
|
|
499
|
+
SELECT jti, label, scopes, scoped_tags, created_at, expires_at, revoked_at
|
|
500
|
+
FROM mcp_mint_ledger
|
|
501
|
+
WHERE parent_jti = ? AND vault_name = ?
|
|
502
|
+
ORDER BY created_at DESC
|
|
503
|
+
`).all(parentJti, vaultName) as {
|
|
504
|
+
jti: string;
|
|
505
|
+
label: string;
|
|
506
|
+
scopes: string | null;
|
|
507
|
+
scoped_tags: string | null;
|
|
508
|
+
created_at: string;
|
|
509
|
+
expires_at: string | null;
|
|
510
|
+
revoked_at: string | null;
|
|
511
|
+
}[];
|
|
512
|
+
return rows.map((r) => ({
|
|
513
|
+
jti: r.jti,
|
|
514
|
+
label: r.label,
|
|
515
|
+
scopes: parseScopes(r.scopes),
|
|
516
|
+
scoped_tags: parseScopedTags(r.scoped_tags),
|
|
517
|
+
created_at: r.created_at,
|
|
518
|
+
expires_at: r.expires_at,
|
|
519
|
+
revoked_at: r.revoked_at,
|
|
520
|
+
}));
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Look up a single ledger row by (jti, parent_jti, vault_name) — the
|
|
525
|
+
* session-pin gate for revoke. Returns null when the jti isn't in THIS
|
|
526
|
+
* session's ledger (a different session's mint, or a never-minted jti),
|
|
527
|
+
* which the caller turns into the not_found path. Returns the row (incl.
|
|
528
|
+
* `revoked_at`) when it belongs to this session.
|
|
529
|
+
*/
|
|
530
|
+
export function findMcpMintLedgerEntry(
|
|
531
|
+
db: Database,
|
|
532
|
+
jti: string,
|
|
533
|
+
parentJti: string,
|
|
534
|
+
vaultName: string,
|
|
535
|
+
): { jti: string; revoked_at: string | null } | null {
|
|
536
|
+
const row = db.prepare(`
|
|
537
|
+
SELECT jti, revoked_at FROM mcp_mint_ledger
|
|
538
|
+
WHERE jti = ? AND parent_jti = ? AND vault_name = ?
|
|
539
|
+
LIMIT 1
|
|
540
|
+
`).get(jti, parentJti, vaultName) as { jti: string; revoked_at: string | null } | null;
|
|
541
|
+
return row ?? null;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* Mark a ledger row revoked (the local attribution-side soft-revoke; the
|
|
546
|
+
* authoritative revocation happens in hub's registry via the revoke-token
|
|
547
|
+
* call). Idempotent: a second call on an already-revoked row leaves the
|
|
548
|
+
* existing timestamp in place. Only flips rows belonging to the given
|
|
549
|
+
* session — defense-in-depth on top of the caller's `findMcpMintLedgerEntry`
|
|
550
|
+
* gate.
|
|
551
|
+
*/
|
|
552
|
+
export function markMcpMintLedgerRevoked(
|
|
553
|
+
db: Database,
|
|
554
|
+
jti: string,
|
|
555
|
+
parentJti: string,
|
|
556
|
+
vaultName: string,
|
|
557
|
+
): void {
|
|
558
|
+
db.prepare(`
|
|
559
|
+
UPDATE mcp_mint_ledger SET revoked_at = ?
|
|
560
|
+
WHERE jti = ? AND parent_jti = ? AND vault_name = ? AND revoked_at IS NULL
|
|
561
|
+
`).run(new Date().toISOString(), jti, parentJti, vaultName);
|
|
562
|
+
}
|
|
563
|
+
|
|
269
564
|
/**
|
|
270
565
|
* Find tokens whose `scoped_tags` allowlist references the given root tag.
|
|
271
566
|
* Used by tag-delete and tag-merge to fail-closed (409) when removing a
|
|
@@ -554,10 +554,15 @@ export function registerTranscriptionHook(
|
|
|
554
554
|
return registry.onAttachment({
|
|
555
555
|
name: "transcription-kickoff",
|
|
556
556
|
event: "created",
|
|
557
|
-
when: (att) =>
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
557
|
+
when: (att) => {
|
|
558
|
+
// Only "created" payloads reach this predicate (we don't subscribe
|
|
559
|
+
// to "deleted"), so `metadata` is populated. The union widening
|
|
560
|
+
// post-deletion-events just means we narrow here defensively.
|
|
561
|
+
const meta = (att as Attachment).metadata as { transcribe_status?: string } | undefined;
|
|
562
|
+
return meta?.transcribe_status === "pending";
|
|
563
|
+
},
|
|
564
|
+
handler: async (payload, store) => {
|
|
565
|
+
const attachment = payload as Attachment;
|
|
561
566
|
const vault = resolveVault(store);
|
|
562
567
|
if (!vault) {
|
|
563
568
|
logger.error(
|
package/src/triggers.ts
CHANGED
|
@@ -29,7 +29,7 @@ import { join, normalize } from "path";
|
|
|
29
29
|
import { mkdirSync, readFileSync, writeFileSync, existsSync } from "fs";
|
|
30
30
|
import crypto from "node:crypto";
|
|
31
31
|
import type { Note, Store, Attachment } from "../core/src/types.ts";
|
|
32
|
-
import type { HookRegistry, HookEvent } from "../core/src/hooks.ts";
|
|
32
|
+
import type { HookRegistry, HookEvent, NoteHookPayload } from "../core/src/hooks.ts";
|
|
33
33
|
import type { TriggerConfig, TriggerWhen } from "./config.ts";
|
|
34
34
|
import { getVaultNameForStore } from "./vault-store.ts";
|
|
35
35
|
import { assetsDir } from "./routes.ts";
|
|
@@ -56,6 +56,10 @@ export function buildPredicate(when: TriggerWhen, triggerName: string): (note: N
|
|
|
56
56
|
const pendingKey = `${triggerName}_pending_at`;
|
|
57
57
|
const renderedKey = `${triggerName}_rendered_at`;
|
|
58
58
|
|
|
59
|
+
// Hook dispatcher passes `NoteHookPayload` (Note | DeletedNoteRef). All
|
|
60
|
+
// triggers default to events `["created", "updated"]` so deleted shapes
|
|
61
|
+
// never reach this predicate, but we still type the parameter as Note
|
|
62
|
+
// — narrowing here keeps the rest of the predicate body unchanged.
|
|
59
63
|
return (note: Note) => {
|
|
60
64
|
const meta = note.metadata as Record<string, unknown> | undefined;
|
|
61
65
|
|
|
@@ -310,8 +314,17 @@ export function registerTriggers(
|
|
|
310
314
|
const unregister = hooks.onNote({
|
|
311
315
|
name: trigger.name,
|
|
312
316
|
event: events,
|
|
313
|
-
when:
|
|
314
|
-
|
|
317
|
+
when: (payload: NoteHookPayload) => {
|
|
318
|
+
// Triggers don't subscribe to "deleted"; if the union ever
|
|
319
|
+
// widens via config, the predicate sees a partial shape that
|
|
320
|
+
// simply doesn't match anything tag/metadata-based and returns
|
|
321
|
+
// false. Safe by construction.
|
|
322
|
+
return predicate(payload as Note);
|
|
323
|
+
},
|
|
324
|
+
handler: async (payload: NoteHookPayload, store: Store, hookEvent?: HookEvent) => {
|
|
325
|
+
// Same shape contract as the predicate — triggers don't
|
|
326
|
+
// subscribe to deleted events, so narrow back to Note.
|
|
327
|
+
const note = payload as Note;
|
|
315
328
|
const existingMeta = (note.metadata as Record<string, unknown> | undefined) ?? {};
|
|
316
329
|
|
|
317
330
|
// Handler-side re-check (same race-window protection as the old hooks)
|