@openparachute/vault 0.6.3 → 0.6.4-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.
@@ -2,7 +2,7 @@ import { Database } from "bun:sqlite";
2
2
  import { normalizePath } from "./paths.js";
3
3
  import { rebuildIndexes } from "./indexed-fields.js";
4
4
 
5
- export const SCHEMA_VERSION = 22;
5
+ export const SCHEMA_VERSION = 23;
6
6
 
7
7
  export const SCHEMA_SQL = `
8
8
  -- Notes: the universal record.
@@ -13,6 +13,17 @@ export const SCHEMA_SQL = `
13
13
  -- column; on-disk uniqueness key is (path, extension). See
14
14
  -- core/src/portable-md.ts:supportsInlineFrontmatter for the
15
15
  -- frontmatter-vs-sidecar split.
16
+ --
17
+ -- Write-attribution (v23, vault#298) — two axes of provenance, both nullable:
18
+ -- created_by / created_via — the principal + interface of the FIRST
19
+ -- write (set once at create; never rewritten).
20
+ -- last_updated_by / last_updated_via — the principal + interface of the MOST
21
+ -- RECENT write (set on every mutating update).
22
+ -- The *_by columns are the actor (a JWT sub, or an operator/token label for
23
+ -- non-JWT auth); the *_via columns are the channel the write arrived through
24
+ -- (mcp, surface:NAME, agent:ID, operator/cli, api). Legacy rows stay NULL —
25
+ -- we don't fabricate authors for writes that predate attribution. See
26
+ -- migrateToV23.
16
27
  CREATE TABLE IF NOT EXISTS notes (
17
28
  id TEXT PRIMARY KEY,
18
29
  content TEXT DEFAULT '',
@@ -20,7 +31,11 @@ CREATE TABLE IF NOT EXISTS notes (
20
31
  metadata TEXT DEFAULT '{}',
21
32
  created_at TEXT NOT NULL,
22
33
  updated_at TEXT,
23
- extension TEXT NOT NULL DEFAULT 'md'
34
+ extension TEXT NOT NULL DEFAULT 'md',
35
+ created_by TEXT,
36
+ created_via TEXT,
37
+ last_updated_by TEXT,
38
+ last_updated_via TEXT
24
39
  );
25
40
 
26
41
  -- Tags: first-class identity carrying schema, hierarchy, and typed-link
@@ -479,6 +494,13 @@ export function initSchema(db: Database): void {
479
494
  // scans. See the 2026-06-10 query-perf measurements.
480
495
  migrateToV22(db);
481
496
 
497
+ // Migrate v22 → v23: write-attribution columns on `notes`
498
+ // (created_by/created_via/last_updated_by/last_updated_via) + their indexes.
499
+ // All four nullable; existing rows backfill to NULL ("written before
500
+ // attribution" — we don't fabricate authors for legacy writes). See
501
+ // vault#298.
502
+ migrateToV23(db);
503
+
482
504
  // Rebuild any generated columns + indexes declared in indexed_fields.
483
505
  // No-op for a fresh vault; idempotent on existing vaults.
484
506
  rebuildIndexes(db);
@@ -1150,6 +1172,43 @@ function migrateToV22(db: Database): void {
1150
1172
  db.exec("CREATE INDEX IF NOT EXISTS idx_notes_updated ON notes(updated_at, id)");
1151
1173
  }
1152
1174
 
1175
+ /**
1176
+ * Migrate v22 → v23: per-identity + per-interface write attribution
1177
+ * (vault#298). Adds four nullable columns to `notes` and an index on each so
1178
+ * "what did Mathilda write" / "what came in via the meeting-ingest surface"
1179
+ * are indexed lookups, not scans:
1180
+ *
1181
+ * created_by — principal (actor) of the first write
1182
+ * created_via — interface/channel of the first write
1183
+ * last_updated_by — principal of the most recent write
1184
+ * last_updated_via — interface/channel of the most recent write
1185
+ *
1186
+ * `*_by` is the JWT `sub` (or an operator / `token:<id>` label for non-JWT
1187
+ * auth); `*_via` is the channel (`mcp`, `surface:<name>`, `agent:<id>`,
1188
+ * `operator`/`cli`, `api`). All four NULL on legacy rows — we deliberately do
1189
+ * NOT backfill an author for writes that predate attribution; NULL reads as
1190
+ * "unknown / pre-attribution," distinct from any real principal.
1191
+ *
1192
+ * Columns live here (not SCHEMA_SQL's index block) following the
1193
+ * idx_tokens_vault_name / idx_notes_updated precedent: SCHEMA_SQL runs before
1194
+ * the migration steps, so an upgrading v22 vault doesn't yet have the columns
1195
+ * when that block evaluates. Fresh vaults get the columns from the CREATE
1196
+ * TABLE above and the indexes from this same path. All idempotent — the
1197
+ * column-existence guard + CREATE INDEX IF NOT EXISTS make re-runs no-ops.
1198
+ */
1199
+ function migrateToV23(db: Database): void {
1200
+ if (!hasTable(db, "notes")) return;
1201
+ const cols = ["created_by", "created_via", "last_updated_by", "last_updated_via"];
1202
+ for (const col of cols) {
1203
+ if (!hasColumn(db, "notes", col)) {
1204
+ db.exec(`ALTER TABLE notes ADD COLUMN ${col} TEXT`);
1205
+ }
1206
+ }
1207
+ for (const col of cols) {
1208
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_notes_${col} ON notes(${col})`);
1209
+ }
1210
+ }
1211
+
1153
1212
  function hasTable(db: Database, name: string): boolean {
1154
1213
  const row = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?").get(name);
1155
1214
  return !!row;
package/core/src/store.ts CHANGED
@@ -100,7 +100,7 @@ export class BunSqliteStore implements Store {
100
100
 
101
101
  // ---- Notes ----
102
102
 
103
- async createNote(content: string, opts?: { id?: string; path?: string; tags?: string[]; metadata?: Record<string, unknown>; created_at?: string; extension?: string }): Promise<Note> {
103
+ async createNote(content: string, opts?: { id?: string; path?: string; tags?: string[]; metadata?: Record<string, unknown>; created_at?: string; extension?: string; actor?: string | null; via?: string | null }): Promise<Note> {
104
104
  const note = noteOps.createNote(this.db, content, opts);
105
105
 
106
106
  if (content) {
@@ -140,6 +140,9 @@ export class BunSqliteStore implements Store {
140
140
  metadata?: Record<string, unknown>;
141
141
  created_at?: string;
142
142
  skipUpdatedAt?: boolean;
143
+ // Write-attribution (vault#298) — principal + interface of this edit.
144
+ actor?: string | null;
145
+ via?: string | null;
143
146
  if_updated_at?: string;
144
147
  },
145
148
  ): Promise<Note> {
package/core/src/types.ts CHANGED
@@ -26,6 +26,19 @@ export interface Note {
26
26
  metadata?: Record<string, unknown>;
27
27
  createdAt: string; // ISO-8601
28
28
  updatedAt?: string;
29
+ /**
30
+ * Write-attribution (vault#298) — two axes of provenance, both nullable.
31
+ * `*By` is the principal (a JWT `sub`, or an operator / `token:<id>` label);
32
+ * `*Via` is the interface the write arrived through (`mcp`, `surface:<name>`,
33
+ * `agent:<id>`, `operator`/`cli`, `api`). The `created*` pair is set once at
34
+ * create; the `lastUpdated*` pair tracks the most recent mutating write. NULL
35
+ * = unknown / written before attribution existed (legacy rows) or by a path
36
+ * that carried no context — distinct from any real principal.
37
+ */
38
+ createdBy?: string | null;
39
+ createdVia?: string | null;
40
+ lastUpdatedBy?: string | null;
41
+ lastUpdatedVia?: string | null;
29
42
  tags?: string[];
30
43
  links?: Link[];
31
44
  /**
@@ -125,6 +138,15 @@ export interface QueryOpts {
125
138
  // for the field. Operator queries require the field to be declared
126
139
  // `indexed: true` in a tag schema; undeclared fields error loudly.
127
140
  metadata?: Record<string, unknown>;
141
+ // Write-attribution filters (vault#298). Exact-match on the indexed
142
+ // attribution columns — "what did Mathilda write" (`createdBy`/`lastUpdatedBy`)
143
+ // or "what came in via the meeting-ingest surface"
144
+ // (`createdVia`/`lastUpdatedVia`). Each is an exact string match; multiple
145
+ // AND together with the rest of the filter set.
146
+ createdBy?: string;
147
+ lastUpdatedBy?: string;
148
+ createdVia?: string;
149
+ lastUpdatedVia?: string;
128
150
  // Legacy shorthand: filters on `n.created_at` (vault ingestion time).
129
151
  // Equivalent to `dateFilter: { field: "created_at", from, to }`. Kept
130
152
  // as the common path; specifying both this and `dateFilter` rejects.
@@ -206,6 +228,12 @@ export interface NoteIndex {
206
228
  extension?: string;
207
229
  createdAt: string;
208
230
  updatedAt?: string;
231
+ /** Write-attribution (vault#298) — carried on the lean shape too so "who
232
+ * touched what" is answerable without re-fetching full content. See `Note`. */
233
+ createdBy?: string | null;
234
+ createdVia?: string | null;
235
+ lastUpdatedBy?: string | null;
236
+ lastUpdatedVia?: string | null;
209
237
  tags?: string[];
210
238
  metadata?: Record<string, unknown>;
211
239
  byteSize: number;
@@ -232,8 +260,10 @@ export interface Store {
232
260
  */
233
261
  readonly db: Database;
234
262
 
235
- // Notes
236
- createNote(content: string, opts?: { id?: string; path?: string; tags?: string[]; metadata?: Record<string, unknown>; created_at?: string; extension?: string }): Promise<Note>;
263
+ // Notes. `actor` / `via` carry write-attribution (vault#298) — the
264
+ // principal + interface stamped onto created_by/created_via (and mirrored
265
+ // into the last_updated_* pair on create). Omitted → attribution NULL.
266
+ createNote(content: string, opts?: { id?: string; path?: string; tags?: string[]; metadata?: Record<string, unknown>; created_at?: string; extension?: string; actor?: string | null; via?: string | null }): Promise<Note>;
237
267
  getNote(id: string): Promise<Note | null>;
238
268
  /**
239
269
  * Look up a note by path. Pass `extension` to disambiguate when
@@ -244,7 +274,7 @@ export interface Store {
244
274
  */
245
275
  getNoteByPath(path: string, extension?: string): Promise<Note | null>;
246
276
  getNotes(ids: string[]): Promise<Note[]>;
247
- updateNote(id: string, updates: { content?: string; append?: string; prepend?: string; path?: string; extension?: string; metadata?: Record<string, unknown>; created_at?: string; skipUpdatedAt?: boolean; if_updated_at?: string }): Promise<Note>;
277
+ updateNote(id: string, updates: { content?: string; append?: string; prepend?: string; path?: string; extension?: string; metadata?: Record<string, unknown>; created_at?: string; skipUpdatedAt?: boolean; actor?: string | null; via?: string | null; if_updated_at?: string }): Promise<Note>;
248
278
  /**
249
279
  * Set a note's `created_at` and `updated_at` explicitly. Import-only:
250
280
  * used by the portable-md round-trip path to restore timestamps from
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/vault",
3
- "version": "0.6.3",
3
+ "version": "0.6.4-rc.2",
4
4
  "description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
5
5
  "module": "src/cli.ts",
6
6
  "type": "module",
@@ -38,9 +38,22 @@ describe("isAdminSpaPath", () => {
38
38
  expect(isAdminSpaPath("/vault/work/admin/")).toBe(true);
39
39
  expect(isAdminSpaPath("/vault/work/admin/tokens")).toBe(true);
40
40
  expect(isAdminSpaPath("/vault/boulder/admin/assets/index.js")).toBe(true);
41
- // Vault names with URL-safe punctuation should still match — the regex
42
- // captures up to the next "/" so dashes / dots / digits all pass.
43
- expect(isAdminSpaPath("/vault/my-vault.2/admin")).toBe(true);
41
+ // Canonical-charset names (lowercase alphanumerics + hyphen / underscore)
42
+ // match; dots are NOT canonical (see the vault#253 reject test below).
43
+ expect(isAdminSpaPath("/vault/my-vault_2/admin")).toBe(true);
44
+ });
45
+
46
+ test("vault#253: names outside the canonical charset do NOT match the mount", () => {
47
+ // The mount used to capture `[^/]+`, so a name hub can never manage
48
+ // (dots, @, unicode) still served the admin SPA. Aligning the mount to
49
+ // hub's VAULT_NAME_CHARSET_RE (`/^[a-z0-9_-]+$/`) makes a non-canonical
50
+ // name 404 — the same answer hub gives. No legitimately-created vault can
51
+ // carry these characters (cmdCreate / init / the env var all reject them
52
+ // via validateVaultName), so nothing reachable regresses.
53
+ expect(isAdminSpaPath("/vault/my.vault/admin")).toBe(false);
54
+ expect(isAdminSpaPath("/vault/vault@v2/admin")).toBe(false);
55
+ expect(isAdminSpaPath("/vault/Work/admin")).toBe(false); // uppercase isn't canonical
56
+ expect(isAdminSpaPath("/vault/🦑/admin")).toBe(false);
44
57
  });
45
58
 
46
59
  test("does not match adjacent paths under the same vault", () => {
@@ -111,8 +124,8 @@ describe("serveAdminSpa", () => {
111
124
  expect(cssRes.headers.get("content-type")).toContain("text/css");
112
125
  });
113
126
 
114
- test("vault names with URL-safe punctuation strip cleanly", async () => {
115
- const res = await serveAdminSpa(fixtureDir, "/vault/my-vault.2/admin/assets/index-abc.js");
127
+ test("canonical-charset vault names strip cleanly", async () => {
128
+ const res = await serveAdminSpa(fixtureDir, "/vault/my-vault_2/admin/assets/index-abc.js");
116
129
  expect(res.status).toBe(200);
117
130
  expect(res.headers.get("content-type")).toContain("application/javascript");
118
131
  });
package/src/admin-spa.ts CHANGED
@@ -21,12 +21,33 @@ import { existsSync } from "node:fs";
21
21
  import { dirname, join, resolve } from "node:path";
22
22
  import { fileURLToPath } from "node:url";
23
23
 
24
+ /**
25
+ * The canonical vault-name charset, kept byte-identical with hub's
26
+ * `VAULT_NAME_CHARSET_RE` (`parachute-hub/src/vault-name.ts`) and vault's own
27
+ * `validateVaultName` (`src/vault-name.ts`): lowercase alphanumerics plus
28
+ * hyphen / underscore. Embedded here (not imported) because this regex is
29
+ * spliced into the mount-matching patterns below, and a charset class is the
30
+ * only piece they share.
31
+ *
32
+ * vault#253: the mount regexes used to capture `[^/]+` (anything-but-slash),
33
+ * so a name like `my.vault` / `vault@v2` / `🦑` matched the admin mount and
34
+ * served the SPA — even though hub rejects those names at every name-minting
35
+ * edge and can never render a "Manage Vault" link for them. A vault that can't
36
+ * be created (cmdCreate / init / the env var all run `validateVaultName`, which
37
+ * rejects out-of-charset names) shouldn't have a reachable admin mount either.
38
+ * Pinning the mount to THIS charset closes the boundary drift: a dotted name
39
+ * no longer matches → 404, the same answer hub gives.
40
+ */
41
+ const VAULT_NAME_CHARSET = "a-z0-9_-";
42
+
24
43
  /**
25
44
  * Regex anchoring the per-vault SPA mount. Matches `/vault/<name>/admin`
26
- * exactly and any subpath under it. The `<name>` capture is reused by the
27
- * prefix-strip below keep the two in sync if this regex moves.
45
+ * exactly and any subpath under it, where `<name>` is a canonical vault name
46
+ * (lowercase alphanumerics + hyphen / underscore see `VAULT_NAME_CHARSET`).
47
+ * The `<name>` capture is reused by the prefix-strip below — keep the two in
48
+ * sync if this regex moves.
28
49
  */
29
- const ADMIN_SPA_MOUNT_RE = /^\/vault\/([^/]+)\/admin(?=\/|$)/;
50
+ const ADMIN_SPA_MOUNT_RE = new RegExp(`^/vault/([${VAULT_NAME_CHARSET}]+)/admin(?=/|$)`);
30
51
 
31
52
  /**
32
53
  * Regex anchoring the DAEMON-LEVEL multi-vault SPA mount at `/vault/admin`
@@ -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
+ });