@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.
- package/core/src/attribution.test.ts +273 -0
- package/core/src/cursor.ts +8 -0
- package/core/src/mcp.ts +44 -1
- package/core/src/notes.ts +115 -3
- package/core/src/schema.ts +61 -2
- package/core/src/store.ts +4 -1
- package/core/src/types.ts +33 -3
- package/package.json +1 -1
- package/src/admin-spa.test.ts +18 -5
- package/src/admin-spa.ts +24 -3
- package/src/attribution-threading.test.ts +350 -0
- package/src/auth.ts +82 -4
- package/src/mcp-tools.ts +17 -2
- package/src/mirror-credentials.test.ts +47 -0
- package/src/mirror-credentials.ts +33 -0
- package/src/mirror-manager.ts +38 -22
- package/src/mirror-routes.test.ts +78 -0
- package/src/mirror-routes.ts +13 -0
- package/src/routes.ts +30 -1
- package/src/routing.ts +9 -1
package/core/src/schema.ts
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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
package/src/admin-spa.test.ts
CHANGED
|
@@ -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
|
-
//
|
|
42
|
-
//
|
|
43
|
-
expect(isAdminSpaPath("/vault/my-
|
|
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
|
|
115
|
-
const res = await serveAdminSpa(fixtureDir, "/vault/my-
|
|
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
|
|
27
|
-
*
|
|
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 =
|
|
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
|
+
});
|