@openparachute/vault 0.6.4-rc.1 → 0.6.4-rc.3
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/README.md +38 -1
- package/core/src/attribution.test.ts +273 -0
- package/core/src/cursor.ts +8 -0
- package/core/src/enforced-writes.test.ts +533 -0
- package/core/src/mcp.ts +227 -5
- package/core/src/notes.ts +243 -7
- package/core/src/query-operators.ts +117 -0
- package/core/src/schema-defaults.ts +162 -9
- package/core/src/schema.ts +61 -2
- package/core/src/store.ts +4 -1
- package/core/src/tag-schemas.ts +12 -0
- package/core/src/triggers-store.ts +6 -0
- package/core/src/types.ts +35 -14
- package/core/src/vault-projection.ts +19 -0
- package/package.json +1 -1
- package/src/attribution-threading.test.ts +350 -0
- package/src/auth.ts +82 -4
- package/src/config.ts +11 -0
- package/src/mcp-http.ts +27 -0
- package/src/mcp-tools.ts +31 -4
- package/src/routes.ts +162 -3
- package/src/routing.ts +18 -2
- package/src/scopes.test.ts +24 -0
- package/src/scopes.ts +63 -0
- package/src/triggers-api.ts +24 -0
- package/src/triggers.test.ts +33 -0
- package/src/triggers.ts +17 -0
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Write-attribution threading (vault#298) — the server-side half: how the
|
|
3
|
+
* authenticated request resolves the two provenance axes onto `AuthResult`,
|
|
4
|
+
* and how the MCP layer refines the `via` channel.
|
|
5
|
+
*
|
|
6
|
+
* - WHO (`actor`): JWT `sub` on the hub path; `operator` for the env-var
|
|
7
|
+
* bearer; `token:<id>` for legacy YAML keys.
|
|
8
|
+
* - VIA (`via`): `api` (credential class) on the REST path; `operator` for
|
|
9
|
+
* the env-var bearer; refined to `mcp` by the MCP handler.
|
|
10
|
+
*
|
|
11
|
+
* The store-layer column behavior + query filters live in
|
|
12
|
+
* core/src/attribution.test.ts. This file pins the AUTH → AuthResult mapping
|
|
13
|
+
* and the MCP via-refinement end-to-end (an MCP create lands attribution in
|
|
14
|
+
* the row).
|
|
15
|
+
*/
|
|
16
|
+
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
17
|
+
import { mkdirSync, rmSync, existsSync } from "fs";
|
|
18
|
+
import { join } from "path";
|
|
19
|
+
import { tmpdir } from "os";
|
|
20
|
+
import { generateKeyPair, exportJWK, SignJWT } from "jose";
|
|
21
|
+
import {
|
|
22
|
+
writeVaultConfig,
|
|
23
|
+
writeGlobalConfig,
|
|
24
|
+
readVaultConfig,
|
|
25
|
+
readGlobalConfig,
|
|
26
|
+
generateApiKey,
|
|
27
|
+
hashKey,
|
|
28
|
+
} from "./config.ts";
|
|
29
|
+
import { getVaultStore, clearVaultStoreCache } from "./vault-store.ts";
|
|
30
|
+
import { authenticateVaultRequest } from "./auth.ts";
|
|
31
|
+
import { resetJwksCache, resetRevocationCache } from "./hub-jwt.ts";
|
|
32
|
+
import { generateScopedMcpTools } from "./mcp-tools.ts";
|
|
33
|
+
import { parseNotesQueryOpts } from "./routes.ts";
|
|
34
|
+
import type { AuthResult } from "./auth.ts";
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// Hub-JWT fixture (mirrors auth-hub-jwt.test.ts)
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
interface Keypair {
|
|
40
|
+
privateKey: CryptoKey;
|
|
41
|
+
publicJwk: { kty: string; n: string; e: string; kid: string; alg: string; use: string };
|
|
42
|
+
kid: string;
|
|
43
|
+
}
|
|
44
|
+
async function makeKeypair(kid: string): Promise<Keypair> {
|
|
45
|
+
const { privateKey, publicKey } = await generateKeyPair("RS256", { extractable: true });
|
|
46
|
+
const jwk = await exportJWK(publicKey);
|
|
47
|
+
return {
|
|
48
|
+
privateKey,
|
|
49
|
+
publicJwk: { kty: "RSA", n: jwk.n!, e: jwk.e!, kid, alg: "RS256", use: "sig" },
|
|
50
|
+
kid,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
function startHubFixture(keys: Keypair[]): { origin: string; stop: () => void } {
|
|
54
|
+
const server = Bun.serve({
|
|
55
|
+
port: 0,
|
|
56
|
+
async fetch(req) {
|
|
57
|
+
const url = new URL(req.url);
|
|
58
|
+
if (url.pathname === "/.well-known/jwks.json") {
|
|
59
|
+
return Response.json({ keys: keys.map((k) => k.publicJwk) });
|
|
60
|
+
}
|
|
61
|
+
if (url.pathname === "/.well-known/parachute-revocation.json") {
|
|
62
|
+
return Response.json({ generated_at: new Date().toISOString(), jtis: [] });
|
|
63
|
+
}
|
|
64
|
+
return new Response("not found", { status: 404 });
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
return { origin: `http://127.0.0.1:${server.port}`, stop: () => server.stop(true) };
|
|
68
|
+
}
|
|
69
|
+
async function signJwt(
|
|
70
|
+
kp: Keypair,
|
|
71
|
+
opts: { iss: string; aud: string; scope: string; sub?: string },
|
|
72
|
+
): Promise<string> {
|
|
73
|
+
const iat = Math.floor(Date.now() / 1000);
|
|
74
|
+
return await new SignJWT({ scope: opts.scope, client_id: "test-client" })
|
|
75
|
+
.setProtectedHeader({ alg: "RS256", kid: kp.kid })
|
|
76
|
+
.setIssuer(opts.iss)
|
|
77
|
+
.setSubject(opts.sub ?? "user-1")
|
|
78
|
+
.setAudience(opts.aud)
|
|
79
|
+
.setIssuedAt(iat)
|
|
80
|
+
.setExpirationTime(iat + 60)
|
|
81
|
+
.setJti(`jti-${Math.random().toString(36).slice(2)}`)
|
|
82
|
+
.sign(kp.privateKey);
|
|
83
|
+
}
|
|
84
|
+
function bearer(token: string): Request {
|
|
85
|
+
return new Request("https://vault.test/x", { headers: { Authorization: `Bearer ${token}` } });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
let tmpHome: string;
|
|
89
|
+
let prevHome: string | undefined;
|
|
90
|
+
let prevHubOrigin: string | undefined;
|
|
91
|
+
let prevJwksOrigin: string | undefined;
|
|
92
|
+
let prevAuthToken: string | undefined;
|
|
93
|
+
let fixture: { origin: string; stop: () => void };
|
|
94
|
+
let kp: Keypair;
|
|
95
|
+
|
|
96
|
+
beforeEach(async () => {
|
|
97
|
+
tmpHome = join(tmpdir(), `vault-attr-thread-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
98
|
+
mkdirSync(join(tmpHome, "vault", "data"), { recursive: true });
|
|
99
|
+
prevHome = process.env.PARACHUTE_HOME;
|
|
100
|
+
process.env.PARACHUTE_HOME = tmpHome;
|
|
101
|
+
clearVaultStoreCache();
|
|
102
|
+
|
|
103
|
+
kp = await makeKeypair("k1");
|
|
104
|
+
fixture = startHubFixture([kp]);
|
|
105
|
+
prevHubOrigin = process.env.PARACHUTE_HUB_ORIGIN;
|
|
106
|
+
prevJwksOrigin = process.env.PARACHUTE_HUB_JWKS_ORIGIN;
|
|
107
|
+
prevAuthToken = process.env.VAULT_AUTH_TOKEN;
|
|
108
|
+
process.env.PARACHUTE_HUB_ORIGIN = fixture.origin;
|
|
109
|
+
process.env.PARACHUTE_HUB_JWKS_ORIGIN = fixture.origin;
|
|
110
|
+
delete process.env.VAULT_AUTH_TOKEN;
|
|
111
|
+
resetJwksCache();
|
|
112
|
+
resetRevocationCache();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
afterEach(() => {
|
|
116
|
+
fixture.stop();
|
|
117
|
+
clearVaultStoreCache();
|
|
118
|
+
const restore = (k: string, v: string | undefined) => {
|
|
119
|
+
if (v === undefined) delete process.env[k];
|
|
120
|
+
else process.env[k] = v;
|
|
121
|
+
};
|
|
122
|
+
restore("PARACHUTE_HOME", prevHome);
|
|
123
|
+
restore("PARACHUTE_HUB_ORIGIN", prevHubOrigin);
|
|
124
|
+
restore("PARACHUTE_HUB_JWKS_ORIGIN", prevJwksOrigin);
|
|
125
|
+
restore("VAULT_AUTH_TOKEN", prevAuthToken);
|
|
126
|
+
if (existsSync(tmpHome)) rmSync(tmpHome, { recursive: true, force: true });
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
function seedVaultWithKey(name: string): string {
|
|
130
|
+
const { fullKey, keyId } = generateApiKey();
|
|
131
|
+
writeVaultConfig({
|
|
132
|
+
name,
|
|
133
|
+
api_keys: [
|
|
134
|
+
{
|
|
135
|
+
id: keyId,
|
|
136
|
+
label: "bootstrap",
|
|
137
|
+
scope: "write",
|
|
138
|
+
key_hash: hashKey(fullKey),
|
|
139
|
+
created_at: new Date().toISOString(),
|
|
140
|
+
},
|
|
141
|
+
],
|
|
142
|
+
created_at: new Date().toISOString(),
|
|
143
|
+
});
|
|
144
|
+
getVaultStore(name);
|
|
145
|
+
return fullKey;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function seedVaultNoKey(name: string): void {
|
|
149
|
+
writeVaultConfig({ name, api_keys: [], created_at: new Date().toISOString() });
|
|
150
|
+
getVaultStore(name);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
describe("attribution threading — AuthResult actor/via derivation", () => {
|
|
154
|
+
test("hub JWT → actor = sub, via = 'api' (the REST credential class)", async () => {
|
|
155
|
+
seedVaultNoKey("journal");
|
|
156
|
+
const token = await signJwt(kp, {
|
|
157
|
+
iss: fixture.origin,
|
|
158
|
+
aud: "vault.journal",
|
|
159
|
+
scope: "vault:journal:write",
|
|
160
|
+
sub: "mathilda",
|
|
161
|
+
});
|
|
162
|
+
const result = await authenticateVaultRequest(bearer(token), readVaultConfig("journal")!);
|
|
163
|
+
expect("error" in result).toBe(false);
|
|
164
|
+
if (!("error" in result)) {
|
|
165
|
+
expect(result.actor).toBe("mathilda");
|
|
166
|
+
expect(result.via).toBe("api");
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("VAULT_AUTH_TOKEN operator bearer → actor = via = 'operator'", async () => {
|
|
171
|
+
seedVaultNoKey("journal");
|
|
172
|
+
process.env.VAULT_AUTH_TOKEN = "super-secret-operator-bearer";
|
|
173
|
+
const result = await authenticateVaultRequest(
|
|
174
|
+
bearer("super-secret-operator-bearer"),
|
|
175
|
+
readVaultConfig("journal")!,
|
|
176
|
+
);
|
|
177
|
+
expect("error" in result).toBe(false);
|
|
178
|
+
if (!("error" in result)) {
|
|
179
|
+
expect(result.actor).toBe("operator");
|
|
180
|
+
expect(result.via).toBe("operator");
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("legacy YAML api_key → actor = 'token:<id>', via = 'api' (never crashes)", async () => {
|
|
185
|
+
const fullKey = seedVaultWithKey("journal");
|
|
186
|
+
const result = await authenticateVaultRequest(bearer(fullKey), readVaultConfig("journal")!);
|
|
187
|
+
expect("error" in result).toBe(false);
|
|
188
|
+
if (!("error" in result)) {
|
|
189
|
+
expect(result.actor).toMatch(/^token:[0-9a-f]{1,}$/i);
|
|
190
|
+
expect(result.via).toBe("api");
|
|
191
|
+
expect(result.legacyDerived).toBe(true);
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
describe("attribution threading — MCP refines via to 'mcp' and stamps the write", () => {
|
|
197
|
+
function authFor(sub: string, vaultName: string): AuthResult {
|
|
198
|
+
return {
|
|
199
|
+
permission: "full",
|
|
200
|
+
scopes: [`vault:${vaultName}:write`, `vault:${vaultName}:read`],
|
|
201
|
+
legacyDerived: false,
|
|
202
|
+
scoped_tags: null,
|
|
203
|
+
vault_name: null,
|
|
204
|
+
caller_jti: null,
|
|
205
|
+
actor: sub,
|
|
206
|
+
via: "api", // base class from auth; the MCP layer should refine to "mcp"
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
test("a create-note through the MCP tools lands actor=sub + via='mcp'", async () => {
|
|
211
|
+
seedVaultNoKey("journal");
|
|
212
|
+
const auth = authFor("aaron", "journal");
|
|
213
|
+
const tools = generateScopedMcpTools("journal", auth, null);
|
|
214
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
215
|
+
const created = (await create.execute({ content: "via mcp" })) as {
|
|
216
|
+
id: string;
|
|
217
|
+
createdBy?: string | null;
|
|
218
|
+
createdVia?: string | null;
|
|
219
|
+
lastUpdatedVia?: string | null;
|
|
220
|
+
};
|
|
221
|
+
expect(created.createdBy).toBe("aaron");
|
|
222
|
+
expect(created.createdVia).toBe("mcp");
|
|
223
|
+
expect(created.lastUpdatedVia).toBe("mcp");
|
|
224
|
+
|
|
225
|
+
// Confirm it persisted to the row, not just the response shape.
|
|
226
|
+
const store = getVaultStore("journal");
|
|
227
|
+
const row = store.db
|
|
228
|
+
.prepare("SELECT created_by, created_via FROM notes WHERE id = ?")
|
|
229
|
+
.get(created.id) as { created_by: string | null; created_via: string | null };
|
|
230
|
+
expect(row.created_by).toBe("aaron");
|
|
231
|
+
expect(row.created_via).toBe("mcp");
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test("an update-note through the MCP tools bumps last_updated_via='mcp'", async () => {
|
|
235
|
+
seedVaultNoKey("journal");
|
|
236
|
+
const store = getVaultStore("journal");
|
|
237
|
+
const seed = await store.createNote("seed", { actor: "aaron", via: "api" });
|
|
238
|
+
|
|
239
|
+
const auth = authFor("aaron", "journal");
|
|
240
|
+
const tools = generateScopedMcpTools("journal", auth, null);
|
|
241
|
+
const update = tools.find((t) => t.name === "update-note")!;
|
|
242
|
+
// `force: true` waives the optimistic-concurrency precondition (we don't
|
|
243
|
+
// echo updated_at in this focused test).
|
|
244
|
+
await update.execute({ id: seed.id, content: "edited via mcp", force: true });
|
|
245
|
+
|
|
246
|
+
const after = await store.getNote(seed.id);
|
|
247
|
+
expect(after?.createdVia).toBe("api"); // set-once original channel
|
|
248
|
+
expect(after?.lastUpdatedBy).toBe("aaron");
|
|
249
|
+
expect(after?.lastUpdatedVia).toBe("mcp"); // refined channel of the latest edit
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("update-note with if_missing:'create' on a MISSING note attributes the created row", async () => {
|
|
253
|
+
// Regression: the upsert-create branch built createOpts without actor/via,
|
|
254
|
+
// so an MCP-driven upsert that CREATES a note wrote NULL attribution while
|
|
255
|
+
// create-note + REST upsert-create did it right. (vault#298 review.)
|
|
256
|
+
seedVaultNoKey("journal");
|
|
257
|
+
const store = getVaultStore("journal");
|
|
258
|
+
|
|
259
|
+
const tools = generateScopedMcpTools("journal", authFor("aaron", "journal"), null);
|
|
260
|
+
const update = tools.find((t) => t.name === "update-note")!;
|
|
261
|
+
const created = (await update.execute({
|
|
262
|
+
id: "Projects/Brand New",
|
|
263
|
+
content: "born via upsert",
|
|
264
|
+
if_missing: "create",
|
|
265
|
+
})) as { id: string; createdBy?: string | null; createdVia?: string | null };
|
|
266
|
+
|
|
267
|
+
// The note did not exist → this is a create; attribution must be set.
|
|
268
|
+
expect(created.createdBy).toBe("aaron");
|
|
269
|
+
expect(created.createdVia).toBe("mcp"); // refined channel, not NULL
|
|
270
|
+
|
|
271
|
+
// Confirm it persisted to the row, not just the echoed shape.
|
|
272
|
+
const row = store.db
|
|
273
|
+
.prepare(
|
|
274
|
+
"SELECT created_by, created_via, last_updated_by, last_updated_via FROM notes WHERE id = ?",
|
|
275
|
+
)
|
|
276
|
+
.get(created.id) as {
|
|
277
|
+
created_by: string | null;
|
|
278
|
+
created_via: string | null;
|
|
279
|
+
last_updated_by: string | null;
|
|
280
|
+
last_updated_via: string | null;
|
|
281
|
+
};
|
|
282
|
+
expect(row.created_by).toBe("aaron");
|
|
283
|
+
expect(row.created_via).toBe("mcp");
|
|
284
|
+
// First write IS the latest write — the last_updated_* pair mirrors it.
|
|
285
|
+
expect(row.last_updated_by).toBe("aaron");
|
|
286
|
+
expect(row.last_updated_via).toBe("mcp");
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test("the operator bearer keeps via='operator' even on the MCP channel", async () => {
|
|
290
|
+
seedVaultNoKey("journal");
|
|
291
|
+
const auth: AuthResult = {
|
|
292
|
+
permission: "full",
|
|
293
|
+
scopes: ["vault:admin", "vault:write", "vault:read"],
|
|
294
|
+
legacyDerived: false,
|
|
295
|
+
scoped_tags: null,
|
|
296
|
+
vault_name: null,
|
|
297
|
+
caller_jti: null,
|
|
298
|
+
actor: "operator",
|
|
299
|
+
via: "operator",
|
|
300
|
+
};
|
|
301
|
+
const tools = generateScopedMcpTools("journal", auth, null);
|
|
302
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
303
|
+
const created = (await create.execute({ content: "op write" })) as {
|
|
304
|
+
createdVia?: string | null;
|
|
305
|
+
};
|
|
306
|
+
expect(created.createdVia).toBe("operator");
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
test("content_edit (surgical find-and-replace) also attributes the latest edit", async () => {
|
|
310
|
+
seedVaultNoKey("journal");
|
|
311
|
+
const store = getVaultStore("journal");
|
|
312
|
+
const seed = await store.createNote("hello WORLD", { actor: "aaron", via: "api" });
|
|
313
|
+
|
|
314
|
+
const tools = generateScopedMcpTools("journal", authFor("mathilda", "journal"), null);
|
|
315
|
+
const update = tools.find((t) => t.name === "update-note")!;
|
|
316
|
+
await update.execute({
|
|
317
|
+
id: seed.id,
|
|
318
|
+
content_edit: { old_text: "WORLD", new_text: "there" },
|
|
319
|
+
force: true,
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
const after = await store.getNote(seed.id);
|
|
323
|
+
expect(after?.content).toBe("hello there");
|
|
324
|
+
expect(after?.createdBy).toBe("aaron"); // set-once
|
|
325
|
+
expect(after?.lastUpdatedBy).toBe("mathilda"); // content_edit flows through updateNote
|
|
326
|
+
expect(after?.lastUpdatedVia).toBe("mcp");
|
|
327
|
+
});
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
describe("attribution threading — REST query filters (symmetric with MCP)", () => {
|
|
331
|
+
test("parseNotesQueryOpts wires the four attribution filter params", () => {
|
|
332
|
+
const url = new URL(
|
|
333
|
+
"http://localhost:1940/vault/journal/api/notes" +
|
|
334
|
+
"?created_by=aaron&last_updated_by=agent:nightly" +
|
|
335
|
+
"&created_via=surface:meeting-ingest&last_updated_via=mcp",
|
|
336
|
+
);
|
|
337
|
+
const { queryOpts } = parseNotesQueryOpts(url);
|
|
338
|
+
expect(queryOpts?.createdBy).toBe("aaron");
|
|
339
|
+
expect(queryOpts?.lastUpdatedBy).toBe("agent:nightly");
|
|
340
|
+
expect(queryOpts?.createdVia).toBe("surface:meeting-ingest");
|
|
341
|
+
expect(queryOpts?.lastUpdatedVia).toBe("mcp");
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
test("absent attribution params leave the filters undefined (no spurious filtering)", () => {
|
|
345
|
+
const url = new URL("http://localhost:1940/vault/journal/api/notes?tag=daily");
|
|
346
|
+
const { queryOpts } = parseNotesQueryOpts(url);
|
|
347
|
+
expect(queryOpts?.createdBy).toBeUndefined();
|
|
348
|
+
expect(queryOpts?.lastUpdatedVia).toBeUndefined();
|
|
349
|
+
});
|
|
350
|
+
});
|
package/src/auth.ts
CHANGED
|
@@ -100,6 +100,11 @@ function tryServerWideAuth(
|
|
|
100
100
|
// would see no other operator mints; that's fine — env-var-bearer is
|
|
101
101
|
// explicitly the operator-channel, not a user surface).
|
|
102
102
|
caller_jti: null,
|
|
103
|
+
// Write-attribution (vault#298): the env-var bearer IS the operator
|
|
104
|
+
// channel. `actor: "operator"` + `via: "operator"` — a stable, honest
|
|
105
|
+
// label for cross-container hub→vault and CLI operator writes.
|
|
106
|
+
actor: "operator",
|
|
107
|
+
via: "operator",
|
|
103
108
|
};
|
|
104
109
|
}
|
|
105
110
|
|
|
@@ -137,14 +142,52 @@ export interface AuthResult {
|
|
|
137
142
|
* mints. See vault#376.
|
|
138
143
|
*/
|
|
139
144
|
caller_jti: string | null;
|
|
145
|
+
/**
|
|
146
|
+
* Write-attribution axis 1 — WHO (vault#298). The principal a write is
|
|
147
|
+
* attributed to:
|
|
148
|
+
* - Hub JWT → the validated `sub` claim (the human or the identity an
|
|
149
|
+
* agent runs as).
|
|
150
|
+
* - VAULT_AUTH_TOKEN operator bearer → `"operator"`.
|
|
151
|
+
* - Legacy YAML api_keys → `"token:<keyhash-prefix>"` so legacy writes
|
|
152
|
+
* still attribute to *something* stable, never crash.
|
|
153
|
+
* NULL only when no principal could be resolved (shouldn't happen on a
|
|
154
|
+
* successful auth, but kept nullable so a future credential class that
|
|
155
|
+
* lacks a subject degrades gracefully rather than throwing).
|
|
156
|
+
*/
|
|
157
|
+
actor: string | null;
|
|
158
|
+
/**
|
|
159
|
+
* Write-attribution axis 2 — VIA WHAT (vault#298). The interface/channel a
|
|
160
|
+
* write arrived through, derived PRAGMATICALLY from the credential class
|
|
161
|
+
* here, then REFINED by the request path at the call site (the MCP handler
|
|
162
|
+
* stamps `mcp`; the REST router keeps the credential-class default). Values:
|
|
163
|
+
* `mcp` · `surface:<name>` · `agent:<id>` · `operator` · `api`. This is the
|
|
164
|
+
* BASE value from auth — the credential class:
|
|
165
|
+
* - Hub JWT → `"api"` (the generic class; refined to `mcp` etc. downstream
|
|
166
|
+
* once the channel is known).
|
|
167
|
+
* - operator bearer → `"operator"`.
|
|
168
|
+
* - legacy YAML key → `"api"` (a REST credential; the `token:<id>` actor
|
|
169
|
+
* already carries the legacy-key identity).
|
|
170
|
+
* Never blocks the `actor` work — if the channel can't be cleanly
|
|
171
|
+
* determined the class (or `api`) stands.
|
|
172
|
+
*/
|
|
173
|
+
via: string | null;
|
|
140
174
|
}
|
|
141
175
|
|
|
142
176
|
/**
|
|
143
177
|
* Convert a legacy "read" | "full" permission into scopes + the legacyDerived
|
|
144
178
|
* flag. Used for legacy YAML key authentication paths and for tokens whose
|
|
145
179
|
* `scopes` column is still NULL.
|
|
180
|
+
*
|
|
181
|
+
* `actorLabel` (vault#298) is the write-attribution principal for this legacy
|
|
182
|
+
* credential — `token:<keyhash-prefix>` so a legacy-YAML-keyed write still
|
|
183
|
+
* attributes to a stable identity. Defaults to `"operator"` for the rare
|
|
184
|
+
* call site without a key in hand. `via` is the generic `api` class (legacy
|
|
185
|
+
* keys are a REST credential); the MCP handler refines it to `mcp` downstream.
|
|
146
186
|
*/
|
|
147
|
-
function legacyAuthResult(
|
|
187
|
+
function legacyAuthResult(
|
|
188
|
+
permission: TokenPermission,
|
|
189
|
+
actorLabel: string = "operator",
|
|
190
|
+
): AuthResult {
|
|
148
191
|
return {
|
|
149
192
|
permission,
|
|
150
193
|
scopes: legacyPermissionToScopes(permission),
|
|
@@ -152,9 +195,23 @@ function legacyAuthResult(permission: TokenPermission): AuthResult {
|
|
|
152
195
|
scoped_tags: null,
|
|
153
196
|
vault_name: null,
|
|
154
197
|
caller_jti: null,
|
|
198
|
+
actor: actorLabel,
|
|
199
|
+
via: "api",
|
|
155
200
|
};
|
|
156
201
|
}
|
|
157
202
|
|
|
203
|
+
/**
|
|
204
|
+
* Stable short label for a legacy YAML api_key, derived from its stored hash.
|
|
205
|
+
* `token:<first-12-of-hash>` — enough to distinguish keys for attribution
|
|
206
|
+
* without putting a full secret-derived hash into note rows. The hash is
|
|
207
|
+
* already a one-way digest of the key (see config.ts `verifyKey`), so a prefix
|
|
208
|
+
* leaks nothing usable.
|
|
209
|
+
*/
|
|
210
|
+
function legacyKeyActorLabel(keyHash: string): string {
|
|
211
|
+
const clean = keyHash.replace(/^[^:]*:/, ""); // drop any `algo:` prefix
|
|
212
|
+
return `token:${clean.slice(0, 12)}`;
|
|
213
|
+
}
|
|
214
|
+
|
|
158
215
|
// One-shot deprecation warning tracker, keyed by token hash / legacy label so
|
|
159
216
|
// we don't spam the log on every request.
|
|
160
217
|
const warnedLegacyTokens = new Set<string>();
|
|
@@ -303,7 +360,10 @@ export async function authenticateVaultRequest(
|
|
|
303
360
|
if (vaultKey) {
|
|
304
361
|
try { writeVaultConfig(vaultConfig); } catch {}
|
|
305
362
|
warnLegacyOnce(`yaml-vault:${vaultKey.key_hash}`, "vault.yaml api_keys");
|
|
306
|
-
return legacyAuthResult(
|
|
363
|
+
return legacyAuthResult(
|
|
364
|
+
vaultKey.scope === "read" ? "read" : "full",
|
|
365
|
+
legacyKeyActorLabel(vaultKey.key_hash),
|
|
366
|
+
);
|
|
307
367
|
}
|
|
308
368
|
|
|
309
369
|
// Legacy: check global keys from config.yaml
|
|
@@ -313,7 +373,10 @@ export async function authenticateVaultRequest(
|
|
|
313
373
|
if (globalKey) {
|
|
314
374
|
try { writeGlobalConfig(globalConfig); } catch {}
|
|
315
375
|
warnLegacyOnce(`yaml-global:${globalKey.key_hash}`, "config.yaml api_keys");
|
|
316
|
-
return legacyAuthResult(
|
|
376
|
+
return legacyAuthResult(
|
|
377
|
+
globalKey.scope === "read" ? "read" : "full",
|
|
378
|
+
legacyKeyActorLabel(globalKey.key_hash),
|
|
379
|
+
);
|
|
317
380
|
}
|
|
318
381
|
}
|
|
319
382
|
|
|
@@ -515,6 +578,18 @@ async function authenticateHubJwt(
|
|
|
515
578
|
// through verbatim — manage-token's session-pin will be null in that
|
|
516
579
|
// case, and list/revoke from that session sees no mints.
|
|
517
580
|
caller_jti: claims.jti ?? null,
|
|
581
|
+
// Write-attribution (vault#298). WHO = the validated JWT subject (the
|
|
582
|
+
// human, or the identity an agent runs as — note agent-grant tokens are
|
|
583
|
+
// minted with `sub = <user-on-whose-behalf>`, so today an agent's writes
|
|
584
|
+
// attribute to that human; a delegation-chain `via` is the deferred v2 in
|
|
585
|
+
// the issue). VIA = `api` here, the generic credential class; the request
|
|
586
|
+
// path refines it (the MCP handler stamps `mcp`). The JWT carries no
|
|
587
|
+
// clean surface-name / agent-definition-id claim — `clientId` is an
|
|
588
|
+
// opaque DCR id and `aud` is just `vault.<name>` — so per the issue's
|
|
589
|
+
// pragmatic constraint we DON'T manufacture a more specific class; the
|
|
590
|
+
// channel comes from the path instead.
|
|
591
|
+
actor: claims.sub && claims.sub.length > 0 ? claims.sub : null,
|
|
592
|
+
via: "api",
|
|
518
593
|
};
|
|
519
594
|
} catch (err) {
|
|
520
595
|
if (err instanceof MalformedScopedTagsError) {
|
|
@@ -619,7 +694,10 @@ export async function authenticateGlobalRequest(
|
|
|
619
694
|
if (matched) {
|
|
620
695
|
try { writeGlobalConfig(globalConfig); } catch {}
|
|
621
696
|
warnLegacyOnce(`yaml-global:${matched.key_hash}`, "config.yaml api_keys");
|
|
622
|
-
return legacyAuthResult(
|
|
697
|
+
return legacyAuthResult(
|
|
698
|
+
matched.scope === "read" ? "read" : "full",
|
|
699
|
+
legacyKeyActorLabel(matched.key_hash),
|
|
700
|
+
);
|
|
623
701
|
}
|
|
624
702
|
}
|
|
625
703
|
|
package/src/config.ts
CHANGED
|
@@ -215,6 +215,17 @@ export interface TriggerWhen {
|
|
|
215
215
|
missing_metadata?: string[];
|
|
216
216
|
/** Note.metadata must have ALL of these keys set (non-null). */
|
|
217
217
|
has_metadata?: string[];
|
|
218
|
+
/**
|
|
219
|
+
* Value-matched metadata predicate (vault#299 Part B). A map of
|
|
220
|
+
* field → operator-object, evaluated against the note's live metadata with
|
|
221
|
+
* the SAME operators as `query-notes` (eq/ne/gt/gte/lt/lte/in/not_in/exists)
|
|
222
|
+
* via the shared `matchesOperator` engine. ALL entries must match (AND).
|
|
223
|
+
* Lets a trigger fire only on a specific transition — e.g.
|
|
224
|
+
* `metadata: { state: { eq: "published" } }` fires when a note reaches
|
|
225
|
+
* `published`, not on every edit. Combines with the presence/tag/content
|
|
226
|
+
* filters above (all must hold).
|
|
227
|
+
*/
|
|
228
|
+
metadata?: Record<string, Record<string, unknown>>;
|
|
218
229
|
}
|
|
219
230
|
|
|
220
231
|
/**
|
package/src/mcp-http.ts
CHANGED
|
@@ -151,6 +151,11 @@ async function handleMcp(
|
|
|
151
151
|
note_path?: string | null;
|
|
152
152
|
current_updated_at?: string | null;
|
|
153
153
|
expected_updated_at?: string;
|
|
154
|
+
field?: string;
|
|
155
|
+
expected_from?: unknown;
|
|
156
|
+
to?: unknown;
|
|
157
|
+
current?: unknown;
|
|
158
|
+
violations?: unknown;
|
|
154
159
|
};
|
|
155
160
|
if (e?.code === "CONFLICT") {
|
|
156
161
|
throw new McpError(ErrorCode.InvalidRequest, message, {
|
|
@@ -161,6 +166,28 @@ async function handleMcp(
|
|
|
161
166
|
note_id: e.note_id,
|
|
162
167
|
});
|
|
163
168
|
}
|
|
169
|
+
// State-transition compare-and-set conflict (vault#299 Part B) — a
|
|
170
|
+
// DISTINCT vocabulary from `conflict` (settled lead #3): the value
|
|
171
|
+
// didn't match, not the updated_at token.
|
|
172
|
+
if (e?.code === "TRANSITION_CONFLICT") {
|
|
173
|
+
throw new McpError(ErrorCode.InvalidRequest, message, {
|
|
174
|
+
error_type: "transition_conflict",
|
|
175
|
+
note_id: e.note_id,
|
|
176
|
+
path: e.note_path ?? null,
|
|
177
|
+
field: e.field,
|
|
178
|
+
expected_from: e.expected_from,
|
|
179
|
+
to: e.to,
|
|
180
|
+
current: e.current ?? null,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
// Strict-schema rejection (vault#299 Part A) — one error carrying ALL
|
|
184
|
+
// per-field violations (settled lead #1).
|
|
185
|
+
if (e?.code === "SCHEMA_VALIDATION") {
|
|
186
|
+
throw new McpError(ErrorCode.InvalidParams, message, {
|
|
187
|
+
error_type: "schema_validation",
|
|
188
|
+
violations: e.violations ?? [],
|
|
189
|
+
});
|
|
190
|
+
}
|
|
164
191
|
if (e?.code === "PRECONDITION_REQUIRED") {
|
|
165
192
|
throw new McpError(ErrorCode.InvalidParams, message, {
|
|
166
193
|
error_type: "precondition_required",
|
package/src/mcp-tools.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { generateMcpTools } from "../core/src/mcp.ts";
|
|
9
|
-
import type { McpToolDef } from "../core/src/mcp.ts";
|
|
9
|
+
import type { McpToolDef, GenerateMcpToolsOpts } from "../core/src/mcp.ts";
|
|
10
10
|
import { getNoteTags } from "../core/src/notes.ts";
|
|
11
11
|
import type { Note } from "../core/src/types.ts";
|
|
12
12
|
import {
|
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
} from "../core/src/vault-projection.ts";
|
|
17
17
|
import { readVaultConfig, writeVaultConfig } from "./config.ts";
|
|
18
18
|
import { getVaultStore } from "./vault-store.ts";
|
|
19
|
-
import { hasScopeForVault, parseScopes, validateMintedScopes } from "./scopes.ts";
|
|
19
|
+
import { hasScopeForVault, hasMigrateScopeForVault, parseScopes, validateMintedScopes, logStrictBypass } from "./scopes.ts";
|
|
20
20
|
import type { AuthResult } from "./auth.ts";
|
|
21
21
|
import {
|
|
22
22
|
expandTokenTagScope,
|
|
@@ -166,10 +166,37 @@ export function generateScopedMcpTools(
|
|
|
166
166
|
)
|
|
167
167
|
: undefined;
|
|
168
168
|
|
|
169
|
+
// Write-attribution (vault#298). Every write through an MCP session arrives
|
|
170
|
+
// on the `mcp` channel — so we REFINE the auth's base `via` (the generic
|
|
171
|
+
// credential class) to `mcp` here, where the path/channel is known. The
|
|
172
|
+
// operator bearer keeps `operator` (its credential class IS its channel and
|
|
173
|
+
// is more informative than `mcp` for cross-container hub→vault writes); any
|
|
174
|
+
// other credential's via becomes `mcp`. `actor` (the principal) passes
|
|
175
|
+
// through unchanged.
|
|
176
|
+
const writeContext = auth
|
|
177
|
+
? { actor: auth.actor, via: auth.via === "operator" ? "operator" : "mcp" }
|
|
178
|
+
: undefined;
|
|
179
|
+
|
|
180
|
+
// Migration-bypass (vault#299): a `vault:migrate`-scoped MCP session skips
|
|
181
|
+
// strict-schema enforcement and logs every bypassed write. Orthogonal to
|
|
182
|
+
// read/write/admin — an admin token does NOT bypass unless it also holds
|
|
183
|
+
// `migrate`. `onStrictBypass` writes the same structured log line the REST
|
|
184
|
+
// path uses (the audit-log table, #300, is deferred).
|
|
185
|
+
const strictBypass = auth ? hasMigrateScopeForVault(auth.scopes, vaultName) : false;
|
|
186
|
+
const onStrictBypass: GenerateMcpToolsOpts["onStrictBypass"] = strictBypass
|
|
187
|
+
? (info) => logStrictBypass(info)
|
|
188
|
+
: undefined;
|
|
189
|
+
|
|
169
190
|
const tools = generateMcpTools(
|
|
170
191
|
store,
|
|
171
|
-
expandVisibility || nearTraversable
|
|
172
|
-
? {
|
|
192
|
+
expandVisibility || nearTraversable || writeContext || strictBypass
|
|
193
|
+
? {
|
|
194
|
+
...(expandVisibility ? { expandVisibility } : {}),
|
|
195
|
+
...(nearTraversable ? { nearTraversable } : {}),
|
|
196
|
+
...(writeContext ? { writeContext } : {}),
|
|
197
|
+
...(strictBypass ? { strictBypass } : {}),
|
|
198
|
+
...(onStrictBypass ? { onStrictBypass } : {}),
|
|
199
|
+
}
|
|
173
200
|
: undefined,
|
|
174
201
|
);
|
|
175
202
|
|