@openparachute/vault 0.7.8 → 0.7.9-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/mcp-manifest.ts +3 -3
- package/core/src/notes.ts +20 -10
- package/core/src/schema.ts +1 -1
- package/core/src/types.ts +5 -2
- package/package.json +1 -1
- package/src/attribution-threading.test.ts +295 -5
- package/src/auth.ts +107 -3
- package/src/live-match.test.ts +17 -0
- package/src/live-match.ts +6 -2
- package/src/mcp-tools.ts +14 -8
- package/src/release-plan.test.ts +302 -0
- package/src/routes.ts +19 -2
- package/src/routing.ts +8 -3
- package/src/vault.test.ts +73 -0
- package/web/ui/dist/assets/{index-g3_KwmRE.js → index-xHUke-uy.js} +9 -9
- package/web/ui/dist/index.html +1 -1
package/core/src/mcp-manifest.ts
CHANGED
|
@@ -171,8 +171,8 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
171
171
|
},
|
|
172
172
|
created_by: { type: "string", description: "Write-attribution filter (vault#298): only notes whose FIRST write was attributed to this principal (a JWT subject, or an operator/token label). Exact match; indexed. Legacy/unattributed notes (NULL) never match." },
|
|
173
173
|
last_updated_by: { type: "string", description: "Write-attribution filter (vault#298): only notes whose MOST RECENT write was attributed to this principal. Exact match; indexed." },
|
|
174
|
-
created_via: { type: "string", description: "Write-attribution filter (vault#298): only notes FIRST written through this interface/channel — e.g. `mcp`, `surface:<name>`, `agent:<id>`, `operator`, `api`. Exact match; indexed." },
|
|
175
|
-
last_updated_via: { type: "string", description: "Write-attribution filter (vault#298): only notes whose MOST RECENT write came through this interface/channel. Exact match; indexed." },
|
|
174
|
+
created_via: { type: "string", description: "Write-attribution filter (vault#298): only notes FIRST written through this interface/channel — e.g. `mcp`, `surface:<name>`, `agent:<id>`, `nostr:<64-hex-pubkey>`, `operator`, `api`. `nostr:<pubkey>` (vault#698) is the Nostr key that SIGNED the request, and is the axis that tells two agents apart when they share one hub user (`created_by`). Emitted by BOTH doors — self-hosted hub (parachute-hub#937) and cloud (parachute-cloud#277). Exact match; indexed." },
|
|
175
|
+
last_updated_via: { type: "string", description: "Write-attribution filter (vault#298): only notes whose MOST RECENT write came through this interface/channel — same vocabulary as `created_via`, including `nostr:<64-hex-pubkey>` for the signing key. Exact match; indexed." },
|
|
176
176
|
order_by: { type: "string", description: "Sort by an indexed metadata field instead of `created_at`. Field must be declared `indexed: true`; errors otherwise. Two special values need no declaration: `link_count` sorts by link DEGREE (both-directions raw row count), matching the `include_link_count` field for every note; `updated_at` (vault#585) sorts on the integer `updated_at_ms` mirror column — correct on non-canonical/imported timestamps — with `id` as the tiebreaker. Direction is taken from `sort` (default 'asc'); for other fields `created_at` is appended as a stable tiebreaker." },
|
|
177
177
|
date_from: { type: "string", description: "Start date (ISO, inclusive). Filters on `created_at` (vault ingestion time). Shorthand for `date_filter: { field: 'created_at', from }`." },
|
|
178
178
|
date_to: { type: "string", description: "End date (ISO, exclusive). Filters on `created_at` (vault ingestion time). Shorthand for `date_filter: { field: 'created_at', to }`." },
|
|
@@ -341,7 +341,7 @@ A note's response carries \`existed\` (true/false) whenever ITS \`if_exists\` wa
|
|
|
341
341
|
- **Idempotent upsert via \`if_missing: "create"\`** — when the note doesn't exist, create it from this same payload (content/path/tags/metadata become the create fields; OC precondition skipped — nothing to conflict with). Response carries \`created: true\`. Useful for nightly sync loops that don't know ahead of time whether the note exists. Default \`"fail"\` (current behavior — missing note errors). See vault#309.
|
|
342
342
|
- \`include_content\` (default \`true\`) — set \`false\` to receive a lean index shape (\`id\`, \`path\`, \`createdAt\`, \`updatedAt\`, \`createdBy\`, \`createdVia\`, \`lastUpdatedBy\`, \`lastUpdatedVia\`, \`tags\`, \`metadata\`, \`byteSize\`, \`preview\`, \`displayTitle\`) instead of full content. Useful for agents making frequent small edits to large notes (e.g. via \`append\` or \`content_edit\`) where re-receiving the body is the dominant cost. \`validation_status\` is preserved on the lean shape when present. \`displayTitle\` is the note's first non-empty content line (heading markers stripped, ~120 chars max), \`null\` when content is empty — never stored, computed fresh from content already in hand.
|
|
343
343
|
|
|
344
|
-
Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\` (the principal + interface of the first write) and \`lastUpdatedBy\`/\`lastUpdatedVia\` (the most recent write). NULL on notes written before attribution existed. Filter on them with \`created_by\`/\`last_updated_by\`/\`created_via\`/\`last_updated_via
|
|
344
|
+
Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\` (the principal + interface of the first write) and \`lastUpdatedBy\`/\`lastUpdatedVia\` (the most recent write). NULL on notes written before attribution existed. Filter on them with \`created_by\`/\`last_updated_by\`/\`created_via\`/\`last_updated_via\`. When the write was signed with a Nostr key (the hub's NIP-98 door), the \`*Via\` value is \`nostr:<64-hex-pubkey>\` — \`createdBy\` stays the hub USER, so the pubkey is what distinguishes two agents sharing that user.`,
|
|
345
345
|
inputSchema: {
|
|
346
346
|
type: "object",
|
|
347
347
|
properties: {
|
package/core/src/notes.ts
CHANGED
|
@@ -41,8 +41,12 @@ import { generateUlid } from "./ulid.js";
|
|
|
41
41
|
* `created_by` on the first write and `last_updated_by` on every
|
|
42
42
|
* write.
|
|
43
43
|
* via — VIA WHAT: the interface/channel the write arrived through
|
|
44
|
-
* (`mcp`, `surface:<name>`, `agent:<id>`, `
|
|
45
|
-
*
|
|
44
|
+
* (`mcp`, `surface:<name>`, `agent:<id>`, `nostr:<pubkey>`,
|
|
45
|
+
* `operator`/`cli`, `api`). `nostr:<pubkey>` (vault#698) is the
|
|
46
|
+
* Nostr key that SIGNED the request — more specific than the
|
|
47
|
+
* channel, and the only axis distinguishing two agents that share
|
|
48
|
+
* one hub user. Lands in `created_via` / `last_updated_via`
|
|
49
|
+
* symmetrically.
|
|
46
50
|
*
|
|
47
51
|
* Both are independently optional — an internal/import write may carry
|
|
48
52
|
* neither, and a non-JWT operator write carries an `actor`/`via` pair without
|
|
@@ -1096,21 +1100,27 @@ export function buildFilterConditions(db: Database, opts: QueryOpts): { conditio
|
|
|
1096
1100
|
params.push(opts.path);
|
|
1097
1101
|
}
|
|
1098
1102
|
|
|
1099
|
-
// Path prefix
|
|
1103
|
+
// Path prefix. `escapeLikePattern` neutralizes `%` and `_` inside the
|
|
1104
|
+
// caller-supplied prefix so it matches as a literal string: without it,
|
|
1105
|
+
// `path_prefix=_tags/` silently matched `atags/…` too (`_` is LIKE's
|
|
1106
|
+
// single-char wildcard), returning notes the caller never asked for and
|
|
1107
|
+
// never saw a signal about (vault#659). The trailing `%` we append is
|
|
1108
|
+
// still our actual wildcard. `ESCAPE '\'` is required for the escape to
|
|
1109
|
+
// take effect.
|
|
1100
1110
|
if (opts.pathPrefix) {
|
|
1101
|
-
conditions.push("n.path LIKE ?");
|
|
1102
|
-
params.push(opts.pathPrefix + "%");
|
|
1111
|
+
conditions.push("n.path LIKE ? ESCAPE '\\'");
|
|
1112
|
+
params.push(escapeLikePattern(opts.pathPrefix) + "%");
|
|
1103
1113
|
}
|
|
1104
1114
|
|
|
1105
1115
|
// Path-prefix exclusion (vault#628). Mirrors `pathPrefix` matching
|
|
1106
|
-
// (`LIKE prefix || '%'
|
|
1107
|
-
// paths are kept — they are not under the
|
|
1108
|
-
// matching ANY listed prefix is dropped.
|
|
1116
|
+
// (`LIKE prefix || '%'` with the same metachar escaping, SQLite LIKE is
|
|
1117
|
+
// ASCII-case-insensitive). NULL paths are kept — they are not under the
|
|
1118
|
+
// prefix. Repeatable: a note matching ANY listed prefix is dropped.
|
|
1109
1119
|
if (opts.excludePathPrefix && opts.excludePathPrefix.length > 0) {
|
|
1110
1120
|
for (const prefix of opts.excludePathPrefix) {
|
|
1111
1121
|
if (typeof prefix !== "string" || prefix.length === 0) continue;
|
|
1112
|
-
conditions.push("(n.path IS NULL OR n.path NOT LIKE ?)");
|
|
1113
|
-
params.push(prefix + "%");
|
|
1122
|
+
conditions.push("(n.path IS NULL OR n.path NOT LIKE ? ESCAPE '\\')");
|
|
1123
|
+
params.push(escapeLikePattern(prefix) + "%");
|
|
1114
1124
|
}
|
|
1115
1125
|
}
|
|
1116
1126
|
|
package/core/src/schema.ts
CHANGED
|
@@ -1282,7 +1282,7 @@ function migrateToV22(db: Database): void {
|
|
|
1282
1282
|
*
|
|
1283
1283
|
* `*_by` is the JWT `sub` (or an operator / `token:<id>` label for non-JWT
|
|
1284
1284
|
* auth); `*_via` is the channel (`mcp`, `surface:<name>`, `agent:<id>`,
|
|
1285
|
-
* `operator`/`cli`, `api`). All four NULL on legacy rows — we deliberately do
|
|
1285
|
+
* `nostr:<pubkey>`, `operator`/`cli`, `api`). All four NULL on legacy rows — we deliberately do
|
|
1286
1286
|
* NOT backfill an author for writes that predate attribution; NULL reads as
|
|
1287
1287
|
* "unknown / pre-attribution," distinct from any real principal.
|
|
1288
1288
|
*
|
package/core/src/types.ts
CHANGED
|
@@ -45,7 +45,9 @@ export interface Note {
|
|
|
45
45
|
* Write-attribution (vault#298) — two axes of provenance, both nullable.
|
|
46
46
|
* `*By` is the principal (a JWT `sub`, or an operator / `token:<id>` label);
|
|
47
47
|
* `*Via` is the interface the write arrived through (`mcp`, `surface:<name>`,
|
|
48
|
-
* `agent:<id>`, `operator`/`cli`, `api`).
|
|
48
|
+
* `agent:<id>`, `nostr:<pubkey>`, `operator`/`cli`, `api`). `nostr:<pubkey>`
|
|
49
|
+
* (vault#698) is the Nostr key that SIGNED the request — the axis that tells
|
|
50
|
+
* two agents apart when they share one hub user. The `created*` pair is set once at
|
|
49
51
|
* create; the `lastUpdated*` pair tracks the most recent mutating write. NULL
|
|
50
52
|
* = unknown / written before attribution existed (legacy rows) or by a path
|
|
51
53
|
* that carried no context — distinct from any real principal.
|
|
@@ -190,7 +192,8 @@ export interface QueryOpts {
|
|
|
190
192
|
pathPrefix?: string; // e.g., "Projects/Parachute" matches "Projects/Parachute/README"
|
|
191
193
|
/**
|
|
192
194
|
* Exclude notes whose path matches any of these prefixes. Same matching
|
|
193
|
-
* as `pathPrefix` (`n.path LIKE prefix || '%'`, ASCII
|
|
195
|
+
* as `pathPrefix` (`n.path LIKE prefix || '%' ESCAPE '\'`, ASCII
|
|
196
|
+
* case-insensitive; `%`/`_` in the prefix are escaped to literals).
|
|
194
197
|
* Repeatable. A note with no path is not excluded (it isn't under the
|
|
195
198
|
* prefix). vault#628 — `.parachute/` system-space is the first client.
|
|
196
199
|
*/
|
package/package.json
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
* - WHO (`actor`): JWT `sub` on the hub path; `operator` for the env-var
|
|
7
7
|
* bearer; `token:<id>` for legacy YAML keys.
|
|
8
8
|
* - VIA (`via`): `api` (credential class) on the REST path; `operator` for
|
|
9
|
-
* the env-var bearer;
|
|
9
|
+
* the env-var bearer; `nostr:<pubkey>` when the hub stamped a NIP-98
|
|
10
|
+
* signing key (vault#698); refined to `mcp` by the MCP handler otherwise.
|
|
10
11
|
*
|
|
11
12
|
* The store-layer column behavior + query filters live in
|
|
12
13
|
* core/src/attribution.test.ts. This file pins the AUTH → AuthResult mapping
|
|
@@ -27,10 +28,10 @@ import {
|
|
|
27
28
|
hashKey,
|
|
28
29
|
} from "./config.ts";
|
|
29
30
|
import { getVaultStore, clearVaultStoreCache } from "./vault-store.ts";
|
|
30
|
-
import { authenticateVaultRequest } from "./auth.ts";
|
|
31
|
+
import { authenticateVaultRequest, refineMcpVia } from "./auth.ts";
|
|
31
32
|
import { resetJwksCache, resetRevocationCache } from "./hub-jwt.ts";
|
|
32
33
|
import { generateScopedMcpTools } from "./mcp-tools.ts";
|
|
33
|
-
import { parseNotesQueryOpts } from "./routes.ts";
|
|
34
|
+
import { handleNotes, parseNotesQueryOpts } from "./routes.ts";
|
|
34
35
|
import type { AuthResult } from "./auth.ts";
|
|
35
36
|
|
|
36
37
|
// ---------------------------------------------------------------------------
|
|
@@ -68,10 +69,21 @@ function startHubFixture(keys: Keypair[]): { origin: string; stop: () => void }
|
|
|
68
69
|
}
|
|
69
70
|
async function signJwt(
|
|
70
71
|
kp: Keypair,
|
|
71
|
-
opts: {
|
|
72
|
+
opts: {
|
|
73
|
+
iss: string;
|
|
74
|
+
aud: string;
|
|
75
|
+
scope: string;
|
|
76
|
+
sub?: string;
|
|
77
|
+
/** Raw `permissions` claim — vault#698 carries `principal_pubkey` here. */
|
|
78
|
+
permissions?: unknown;
|
|
79
|
+
},
|
|
72
80
|
): Promise<string> {
|
|
73
81
|
const iat = Math.floor(Date.now() / 1000);
|
|
74
|
-
return await new SignJWT({
|
|
82
|
+
return await new SignJWT({
|
|
83
|
+
scope: opts.scope,
|
|
84
|
+
client_id: "test-client",
|
|
85
|
+
...(opts.permissions !== undefined ? { permissions: opts.permissions } : {}),
|
|
86
|
+
})
|
|
75
87
|
.setProtectedHeader({ alg: "RS256", kid: kp.kid })
|
|
76
88
|
.setIssuer(opts.iss)
|
|
77
89
|
.setSubject(opts.sub ?? "user-1")
|
|
@@ -348,3 +360,281 @@ describe("attribution threading — REST query filters (symmetric with MCP)", ()
|
|
|
348
360
|
expect(queryOpts?.lastUpdatedVia).toBeUndefined();
|
|
349
361
|
});
|
|
350
362
|
});
|
|
363
|
+
|
|
364
|
+
// ---------------------------------------------------------------------------
|
|
365
|
+
// vault#698 — Nostr principal attribution
|
|
366
|
+
//
|
|
367
|
+
// The hub's NIP-98 `/mcp` door mints the vault hop token with
|
|
368
|
+
// `permissions.principal_pubkey = <64-hex>`. `created_by` stays the hub USER
|
|
369
|
+
// id (two agents linked to one user share it), so `created_via` /
|
|
370
|
+
// `last_updated_via` are the ONLY axis that tells them apart.
|
|
371
|
+
// ---------------------------------------------------------------------------
|
|
372
|
+
|
|
373
|
+
const PUBKEY_A = "a".repeat(64);
|
|
374
|
+
const PUBKEY_B = "e6619493" + "b".repeat(56);
|
|
375
|
+
|
|
376
|
+
describe("attribution threading — nostr principal (vault#698)", () => {
|
|
377
|
+
test("hub JWT with permissions.principal_pubkey → via = 'nostr:<pubkey>', actor unchanged", async () => {
|
|
378
|
+
seedVaultNoKey("journal");
|
|
379
|
+
const token = await signJwt(kp, {
|
|
380
|
+
iss: fixture.origin,
|
|
381
|
+
aud: "vault.journal",
|
|
382
|
+
scope: "vault:journal:write",
|
|
383
|
+
sub: "e6619493-hub-user",
|
|
384
|
+
permissions: { principal_pubkey: PUBKEY_A },
|
|
385
|
+
});
|
|
386
|
+
const result = await authenticateVaultRequest(bearer(token), readVaultConfig("journal")!);
|
|
387
|
+
expect("error" in result).toBe(false);
|
|
388
|
+
if (!("error" in result)) {
|
|
389
|
+
// `created_by` axis is untouched — still the hub user.
|
|
390
|
+
expect(result.actor).toBe("e6619493-hub-user");
|
|
391
|
+
expect(result.via).toBe(`nostr:${PUBKEY_A}`);
|
|
392
|
+
}
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
test("a malformed principal_pubkey degrades to 'api' — never 401, never stored", async () => {
|
|
396
|
+
seedVaultNoKey("journal");
|
|
397
|
+
// Uppercase hex, wrong length, non-hex, non-string, and an npub — each must
|
|
398
|
+
// fail SOFT (attribution is a label, not an access decision).
|
|
399
|
+
const bad: unknown[] = [
|
|
400
|
+
"A".repeat(64),
|
|
401
|
+
"ab".repeat(20),
|
|
402
|
+
`${"z".repeat(64)}`,
|
|
403
|
+
12345,
|
|
404
|
+
{ hex: PUBKEY_A },
|
|
405
|
+
"npub1qqqqq",
|
|
406
|
+
null,
|
|
407
|
+
];
|
|
408
|
+
for (const value of bad) {
|
|
409
|
+
const token = await signJwt(kp, {
|
|
410
|
+
iss: fixture.origin,
|
|
411
|
+
aud: "vault.journal",
|
|
412
|
+
scope: "vault:journal:write",
|
|
413
|
+
sub: "hub-user",
|
|
414
|
+
permissions: { principal_pubkey: value },
|
|
415
|
+
});
|
|
416
|
+
const result = await authenticateVaultRequest(bearer(token), readVaultConfig("journal")!);
|
|
417
|
+
expect("error" in result).toBe(false);
|
|
418
|
+
if (!("error" in result)) expect(result.via).toBe("api");
|
|
419
|
+
}
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
test("principal_pubkey coexists with scoped_tags in the same permissions claim", async () => {
|
|
423
|
+
seedVaultNoKey("journal");
|
|
424
|
+
const token = await signJwt(kp, {
|
|
425
|
+
iss: fixture.origin,
|
|
426
|
+
aud: "vault.journal",
|
|
427
|
+
scope: "vault:journal:write",
|
|
428
|
+
sub: "hub-user",
|
|
429
|
+
permissions: { principal_pubkey: PUBKEY_A, scoped_tags: ["daily"] },
|
|
430
|
+
});
|
|
431
|
+
const result = await authenticateVaultRequest(bearer(token), readVaultConfig("journal")!);
|
|
432
|
+
expect("error" in result).toBe(false);
|
|
433
|
+
if (!("error" in result)) {
|
|
434
|
+
expect(result.via).toBe(`nostr:${PUBKEY_A}`);
|
|
435
|
+
expect(result.scoped_tags).toEqual(["daily"]);
|
|
436
|
+
}
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
test("ORDERING GUARANTEE: a permissions claim with ONLY principal_pubkey is ignorable — scoped_tags stays unscoped", async () => {
|
|
440
|
+
// This is the "vault ships first" safety argument, pinned as a test: the
|
|
441
|
+
// ONLY pre-existing reader of `permissions` is
|
|
442
|
+
// `parseScopedTagsFromPermissions`, and it returns null (unscoped) when
|
|
443
|
+
// `scoped_tags` is absent. So a hub that emits the new claim against an
|
|
444
|
+
// OLD vault changes nothing.
|
|
445
|
+
seedVaultNoKey("journal");
|
|
446
|
+
const token = await signJwt(kp, {
|
|
447
|
+
iss: fixture.origin,
|
|
448
|
+
aud: "vault.journal",
|
|
449
|
+
scope: "vault:journal:write",
|
|
450
|
+
sub: "hub-user",
|
|
451
|
+
permissions: { principal_pubkey: PUBKEY_A },
|
|
452
|
+
});
|
|
453
|
+
const result = await authenticateVaultRequest(bearer(token), readVaultConfig("journal")!);
|
|
454
|
+
expect("error" in result).toBe(false);
|
|
455
|
+
if (!("error" in result)) expect(result.scoped_tags).toBeNull();
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
test("refineMcpVia keeps a nostr signer, refines the generic classes, keeps operator", () => {
|
|
459
|
+
expect(refineMcpVia(`nostr:${PUBKEY_A}`)).toBe(`nostr:${PUBKEY_A}`);
|
|
460
|
+
expect(refineMcpVia("operator")).toBe("operator");
|
|
461
|
+
expect(refineMcpVia("api")).toBe("mcp");
|
|
462
|
+
expect(refineMcpVia("token:abc123")).toBe("mcp");
|
|
463
|
+
expect(refineMcpVia(null)).toBe("mcp");
|
|
464
|
+
});
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
describe("attribution threading — nostr signer lands on every MCP write path", () => {
|
|
468
|
+
function nostrAuth(hubUserId: string, vaultName: string, pubkey: string): AuthResult {
|
|
469
|
+
return {
|
|
470
|
+
permission: "full",
|
|
471
|
+
scopes: [`vault:${vaultName}:write`, `vault:${vaultName}:read`],
|
|
472
|
+
legacyDerived: false,
|
|
473
|
+
scoped_tags: null,
|
|
474
|
+
vault_name: null,
|
|
475
|
+
caller_jti: null,
|
|
476
|
+
actor: hubUserId,
|
|
477
|
+
via: `nostr:${pubkey}`,
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
test("create-note: both *_via columns carry the signer, created_by stays the hub user", async () => {
|
|
482
|
+
seedVaultNoKey("journal");
|
|
483
|
+
const tools = generateScopedMcpTools(
|
|
484
|
+
"journal",
|
|
485
|
+
nostrAuth("e6619493-hub-user", "journal", PUBKEY_A),
|
|
486
|
+
null,
|
|
487
|
+
);
|
|
488
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
489
|
+
const created = (await create.execute({ content: "signed by A" })) as { id: string };
|
|
490
|
+
|
|
491
|
+
const store = getVaultStore("journal");
|
|
492
|
+
const row = store.db
|
|
493
|
+
.prepare(
|
|
494
|
+
"SELECT created_by, created_via, last_updated_by, last_updated_via FROM notes WHERE id = ?",
|
|
495
|
+
)
|
|
496
|
+
.get(created.id) as Record<string, string | null>;
|
|
497
|
+
expect(row.created_by).toBe("e6619493-hub-user");
|
|
498
|
+
expect(row.last_updated_by).toBe("e6619493-hub-user");
|
|
499
|
+
expect(row.created_via).toBe(`nostr:${PUBKEY_A}`);
|
|
500
|
+
expect(row.last_updated_via).toBe(`nostr:${PUBKEY_A}`);
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
test("create-note BATCH: every item in the batch carries the signer", async () => {
|
|
504
|
+
seedVaultNoKey("journal");
|
|
505
|
+
const tools = generateScopedMcpTools("journal", nostrAuth("hub-user", "journal", PUBKEY_A), null);
|
|
506
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
507
|
+
await create.execute({
|
|
508
|
+
notes: [
|
|
509
|
+
{ content: "one", path: "Batch/One" },
|
|
510
|
+
{ content: "two", path: "Batch/Two" },
|
|
511
|
+
],
|
|
512
|
+
});
|
|
513
|
+
const store = getVaultStore("journal");
|
|
514
|
+
const rows = store.db
|
|
515
|
+
.prepare("SELECT created_via, last_updated_via FROM notes WHERE path LIKE 'Batch/%'")
|
|
516
|
+
.all() as Array<Record<string, string | null>>;
|
|
517
|
+
expect(rows.length).toBe(2);
|
|
518
|
+
for (const row of rows) {
|
|
519
|
+
expect(row.created_via).toBe(`nostr:${PUBKEY_A}`);
|
|
520
|
+
expect(row.last_updated_via).toBe(`nostr:${PUBKEY_A}`);
|
|
521
|
+
}
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
test("THE REPORTED BUG: agent B appending to agent A's note is distinguishable", async () => {
|
|
525
|
+
// Observed 2026-09-02: GrokJi's append showed lastUpdatedBy = e6619493-…,
|
|
526
|
+
// byte-identical to ClaudeJi's, because both link to the same hub user and
|
|
527
|
+
// both writes stamped via="mcp".
|
|
528
|
+
seedVaultNoKey("journal");
|
|
529
|
+
const HUB_USER = "e6619493-shared-hub-user";
|
|
530
|
+
const store = getVaultStore("journal");
|
|
531
|
+
|
|
532
|
+
const toolsA = generateScopedMcpTools("journal", nostrAuth(HUB_USER, "journal", PUBKEY_A), null);
|
|
533
|
+
const created = (await toolsA.find((t) => t.name === "create-note")!.execute({
|
|
534
|
+
content: "written by A",
|
|
535
|
+
path: "Shared/Note",
|
|
536
|
+
})) as { id: string };
|
|
537
|
+
|
|
538
|
+
const toolsB = generateScopedMcpTools("journal", nostrAuth(HUB_USER, "journal", PUBKEY_B), null);
|
|
539
|
+
await toolsB
|
|
540
|
+
.find((t) => t.name === "update-note")!
|
|
541
|
+
.execute({ id: created.id, append: "\n\nappended by B" });
|
|
542
|
+
|
|
543
|
+
const after = await store.getNote(created.id);
|
|
544
|
+
// Same hub user on both axes — that is correct and unchanged.
|
|
545
|
+
expect(after?.createdBy).toBe(HUB_USER);
|
|
546
|
+
expect(after?.lastUpdatedBy).toBe(HUB_USER);
|
|
547
|
+
// …but the two agents are now distinguishable.
|
|
548
|
+
expect(after?.createdVia).toBe(`nostr:${PUBKEY_A}`);
|
|
549
|
+
expect(after?.lastUpdatedVia).toBe(`nostr:${PUBKEY_B}`);
|
|
550
|
+
expect(after?.content).toContain("appended by B");
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
test("update-note (full replace) bumps last_updated_via to the new signer", async () => {
|
|
554
|
+
seedVaultNoKey("journal");
|
|
555
|
+
const store = getVaultStore("journal");
|
|
556
|
+
const seed = await store.createNote("seed", { actor: "hub-user", via: `nostr:${PUBKEY_A}` });
|
|
557
|
+
const toolsB = generateScopedMcpTools("journal", nostrAuth("hub-user", "journal", PUBKEY_B), null);
|
|
558
|
+
await toolsB
|
|
559
|
+
.find((t) => t.name === "update-note")!
|
|
560
|
+
.execute({ id: seed.id, content: "replaced by B", force: true });
|
|
561
|
+
const after = await store.getNote(seed.id);
|
|
562
|
+
expect(after?.createdVia).toBe(`nostr:${PUBKEY_A}`); // set-once
|
|
563
|
+
expect(after?.lastUpdatedVia).toBe(`nostr:${PUBKEY_B}`);
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
test("update-note if_missing:'create' (upsert-create) stamps the signer on both pairs", async () => {
|
|
567
|
+
seedVaultNoKey("journal");
|
|
568
|
+
const tools = generateScopedMcpTools("journal", nostrAuth("hub-user", "journal", PUBKEY_B), null);
|
|
569
|
+
const created = (await tools.find((t) => t.name === "update-note")!.execute({
|
|
570
|
+
id: "Upsert/Born",
|
|
571
|
+
content: "born via upsert",
|
|
572
|
+
if_missing: "create",
|
|
573
|
+
})) as { id: string };
|
|
574
|
+
const store = getVaultStore("journal");
|
|
575
|
+
const row = store.db
|
|
576
|
+
.prepare("SELECT created_via, last_updated_via FROM notes WHERE id = ?")
|
|
577
|
+
.get(created.id) as Record<string, string | null>;
|
|
578
|
+
expect(row.created_via).toBe(`nostr:${PUBKEY_B}`);
|
|
579
|
+
expect(row.last_updated_via).toBe(`nostr:${PUBKEY_B}`);
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
test("REST DOOR PARITY: a REST write carries the signer too (routing.ts passes auth.via unrefined)", async () => {
|
|
583
|
+
// The MCP door refines via `refineMcpVia`; the REST door threads the BASE
|
|
584
|
+
// `auth.via` straight into WriteCtx (routing.ts ~L1145). Since the base
|
|
585
|
+
// value is already `nostr:<pubkey>`, REST gets signer attribution for free
|
|
586
|
+
// — but nothing pinned it, so a future "normalize REST via to api" change
|
|
587
|
+
// would silently drop it on that door only.
|
|
588
|
+
seedVaultNoKey("journal");
|
|
589
|
+
const store = getVaultStore("journal");
|
|
590
|
+
const res = await handleNotes(
|
|
591
|
+
new Request("http://localhost/api/notes", {
|
|
592
|
+
method: "POST",
|
|
593
|
+
headers: { "content-type": "application/json" },
|
|
594
|
+
body: JSON.stringify({ content: "written over REST", path: "Rest/Signed" }),
|
|
595
|
+
}),
|
|
596
|
+
store,
|
|
597
|
+
"",
|
|
598
|
+
"journal",
|
|
599
|
+
{ allowed: null, raw: null },
|
|
600
|
+
{ actor: "e6619493-hub-user", via: `nostr:${PUBKEY_A}` },
|
|
601
|
+
);
|
|
602
|
+
expect(res.status).toBe(201);
|
|
603
|
+
const row = store.db
|
|
604
|
+
.prepare(
|
|
605
|
+
"SELECT created_by, created_via, last_updated_via FROM notes WHERE path = 'Rest/Signed'",
|
|
606
|
+
)
|
|
607
|
+
.get() as Record<string, string | null>;
|
|
608
|
+
expect(row.created_by).toBe("e6619493-hub-user");
|
|
609
|
+
expect(row.created_via).toBe(`nostr:${PUBKEY_A}`);
|
|
610
|
+
expect(row.last_updated_via).toBe(`nostr:${PUBKEY_A}`);
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
test("the signer is FILTERABLE via created_via / last_updated_via", async () => {
|
|
614
|
+
seedVaultNoKey("journal");
|
|
615
|
+
const store = getVaultStore("journal");
|
|
616
|
+
const toolsA = generateScopedMcpTools("journal", nostrAuth("hub-user", "journal", PUBKEY_A), null);
|
|
617
|
+
const toolsB = generateScopedMcpTools("journal", nostrAuth("hub-user", "journal", PUBKEY_B), null);
|
|
618
|
+
const byA = (await toolsA.find((t) => t.name === "create-note")!.execute({
|
|
619
|
+
content: "A wrote this",
|
|
620
|
+
path: "Filter/A",
|
|
621
|
+
})) as { id: string };
|
|
622
|
+
await toolsB.find((t) => t.name === "create-note")!.execute({
|
|
623
|
+
content: "B wrote this",
|
|
624
|
+
path: "Filter/B",
|
|
625
|
+
});
|
|
626
|
+
// B then edits A's note — created_via stays A, last_updated_via becomes B.
|
|
627
|
+
await toolsB
|
|
628
|
+
.find((t) => t.name === "update-note")!
|
|
629
|
+
.execute({ id: byA.id, append: " (edited)" });
|
|
630
|
+
|
|
631
|
+
const createdByA = await store.queryNotes({ createdVia: `nostr:${PUBKEY_A}` });
|
|
632
|
+
expect(createdByA.map((n) => n.path)).toEqual(["Filter/A"]);
|
|
633
|
+
|
|
634
|
+
const touchedByB = await store.queryNotes({ lastUpdatedVia: `nostr:${PUBKEY_B}` });
|
|
635
|
+
expect(touchedByB.map((n) => n.path).sort()).toEqual(["Filter/A", "Filter/B"]);
|
|
636
|
+
|
|
637
|
+
// And the old flat label matches nothing now that the signer is recorded.
|
|
638
|
+
expect(await store.queryNotes({ createdVia: "mcp" })).toEqual([]);
|
|
639
|
+
});
|
|
640
|
+
});
|
package/src/auth.ts
CHANGED
|
@@ -161,8 +161,12 @@ export interface AuthResult {
|
|
|
161
161
|
* write arrived through, derived PRAGMATICALLY from the credential class
|
|
162
162
|
* here, then REFINED by the request path at the call site (the MCP handler
|
|
163
163
|
* stamps `mcp`; the REST router keeps the credential-class default). Values:
|
|
164
|
-
* `mcp` · `surface:<name>` · `agent:<id>` · `
|
|
165
|
-
* BASE value from auth — the credential class:
|
|
164
|
+
* `mcp` · `surface:<name>` · `agent:<id>` · `nostr:<pubkey>` · `operator` ·
|
|
165
|
+
* `api`. This is the BASE value from auth — the credential class:
|
|
166
|
+
* - Hub JWT with a `permissions.principal_pubkey` claim → `nostr:<pubkey>`
|
|
167
|
+
* (vault#698). The signer is MORE specific than the channel, and unlike
|
|
168
|
+
* every other class it distinguishes two agents that share one hub user,
|
|
169
|
+
* so downstream does NOT refine it away — see `mcp-tools.ts`.
|
|
166
170
|
* - Hub JWT → `"api"` (the generic class; refined to `mcp` etc. downstream
|
|
167
171
|
* once the channel is known).
|
|
168
172
|
* - operator bearer → `"operator"`.
|
|
@@ -464,6 +468,97 @@ class MalformedScopedTagsError extends Error {}
|
|
|
464
468
|
* The only correct fail-closed action for a present-but-unreadable
|
|
465
469
|
* scope is to reject the whole request — never serve it wide.
|
|
466
470
|
*/
|
|
471
|
+
/**
|
|
472
|
+
* Nostr pubkey shape: 32 bytes, lowercase hex. NIP-01 canonical form.
|
|
473
|
+
*/
|
|
474
|
+
const NOSTR_PUBKEY_RE = /^[0-9a-f]{64}$/;
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Write-attribution `via` label for a NIP-98-signed principal.
|
|
478
|
+
*
|
|
479
|
+
* `nostr:<64-hex>` joins the existing open-ended `via` vocabulary
|
|
480
|
+
* (`mcp` · `surface:<name>` · `agent:<id>` · `operator` · `api`) — same
|
|
481
|
+
* `<class>:<id>` shape as `agent:<id>`, so `created_via` / `last_updated_via`
|
|
482
|
+
* stay plain exact-match strings and every existing filter keeps working.
|
|
483
|
+
*/
|
|
484
|
+
export function nostrVia(pubkey: string): string {
|
|
485
|
+
return `nostr:${pubkey}`;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Refine the credential-class `via` from `AuthResult` for a write that arrived
|
|
490
|
+
* on the MCP channel.
|
|
491
|
+
*
|
|
492
|
+
* `mcp` wins over the generic classes (`api`, a legacy `token:` REST key) —
|
|
493
|
+
* the channel is the more specific fact. It does NOT win over a class that
|
|
494
|
+
* already names a *principal or channel of its own*:
|
|
495
|
+
*
|
|
496
|
+
* - `operator` — the env-var bearer (vault#298).
|
|
497
|
+
* - `nostr:<pubkey>` — the NIP-98 signer (vault#698). Every hub `/mcp`
|
|
498
|
+
* caller is on the `mcp` channel, so `mcp` cannot distinguish two agents
|
|
499
|
+
* sharing a hub user; the key can.
|
|
500
|
+
*/
|
|
501
|
+
export function refineMcpVia(via: string | null | undefined): string {
|
|
502
|
+
// `undefined` is accepted alongside `null` on purpose: `AuthResult` declares
|
|
503
|
+
// `via: string | null`, but several in-repo call sites build the object
|
|
504
|
+
// without the key (attachment tools, test harnesses). The expression this
|
|
505
|
+
// replaced (`auth.via === "operator" ? … : "mcp"`) tolerated that silently,
|
|
506
|
+
// so a `string`-only signature here would turn a missing key into a
|
|
507
|
+
// TypeError at write time. Narrow on `typeof`, not on `!== null`.
|
|
508
|
+
if (via === "operator") return "operator";
|
|
509
|
+
if (typeof via === "string" && via.startsWith("nostr:")) return via;
|
|
510
|
+
return "mcp";
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Read the signing pubkey out of a validated hub JWT's `permissions` claim
|
|
515
|
+
* (vault#698 / hub#937 — Nostr principal attribution).
|
|
516
|
+
*
|
|
517
|
+
* Wire contract: `permissions: { principal_pubkey: "<64 lowercase hex>" }`.
|
|
518
|
+
* The hub stamps it ONLY on tokens minted for a NIP-98-authenticated caller;
|
|
519
|
+
* password / OAuth / Bearer sessions never carry it.
|
|
520
|
+
*
|
|
521
|
+
* Why it rides inside `permissions` rather than a top-level claim:
|
|
522
|
+
* `@openparachute/scope-guard` returns a FIXED claim surface
|
|
523
|
+
* (`sub`, `scopes`, `aud`, `jti`, `clientId`, `vaultScope`, `permissions`) and
|
|
524
|
+
* drops everything else, and `permissions` is its documented verbatim
|
|
525
|
+
* passthrough. A new top-level claim would be invisible here without a
|
|
526
|
+
* scope-guard release.
|
|
527
|
+
*
|
|
528
|
+
* FAIL-SOFT, deliberately the opposite of `parseScopedTagsFromPermissions`:
|
|
529
|
+
* this claim only *labels* a write, it never widens or narrows access. A
|
|
530
|
+
* missing / malformed / wrong-case / non-hex value returns `null` and the
|
|
531
|
+
* caller falls back to the generic credential class, rather than storing junk
|
|
532
|
+
* in an attribution column or 401-ing a legitimate write.
|
|
533
|
+
*/
|
|
534
|
+
export function parsePrincipalPubkey(
|
|
535
|
+
permissions: Record<string, unknown> | undefined,
|
|
536
|
+
): string | null {
|
|
537
|
+
if (!permissions) return null;
|
|
538
|
+
if (!("principal_pubkey" in permissions)) return null;
|
|
539
|
+
const raw = permissions.principal_pubkey;
|
|
540
|
+
if (typeof raw === "string" && NOSTR_PUBKEY_RE.test(raw)) return raw;
|
|
541
|
+
// PRESENT but unreadable. Fail soft (see above) — but never SILENTLY: the
|
|
542
|
+
// symptom of a dropped claim (`created_via` back to `mcp`) is byte-identical
|
|
543
|
+
// to the symptom of the hub not having shipped the claim at all, which is
|
|
544
|
+
// exactly the bug this feature exists to fix. One warn per distinct bad
|
|
545
|
+
// value makes the two distinguishable in the daemon log. A pubkey is public
|
|
546
|
+
// by construction, so logging the value leaks nothing.
|
|
547
|
+
const seen = typeof raw === "string" ? raw : `<${typeof raw}>`;
|
|
548
|
+
if (!warnedBadPubkeys.has(seen)) {
|
|
549
|
+
warnedBadPubkeys.add(seen);
|
|
550
|
+
console.warn(
|
|
551
|
+
"[attribution] hub JWT permissions.principal_pubkey present but not 64 lowercase hex — " +
|
|
552
|
+
`ignoring, falling back to the generic credential class (saw: ${JSON.stringify(seen)})`,
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
return null;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/** Dedupe key set for the warn above — unbounded growth is bounded in practice
|
|
559
|
+
* by the number of DISTINCT malformed values a misconfigured hub emits (one). */
|
|
560
|
+
const warnedBadPubkeys = new Set<string>();
|
|
561
|
+
|
|
467
562
|
function parseScopedTagsFromPermissions(
|
|
468
563
|
permissions: Record<string, unknown> | undefined,
|
|
469
564
|
): string[] | null {
|
|
@@ -575,6 +670,9 @@ async function authenticateHubJwt(
|
|
|
575
670
|
// Throws MalformedScopedTagsError (caught below → 401) on a present-but-
|
|
576
671
|
// malformed claim so we never widen access on a misread.
|
|
577
672
|
const scoped_tags = parseScopedTagsFromPermissions(claims.permissions);
|
|
673
|
+
// Write-attribution axis 2 (vault#698): the NIP-98 signing pubkey, when
|
|
674
|
+
// the hub stamped one. Fail-soft — see `parsePrincipalPubkey`.
|
|
675
|
+
const principalPubkey = parsePrincipalPubkey(claims.permissions);
|
|
578
676
|
return {
|
|
579
677
|
permission,
|
|
580
678
|
scopes: claims.scopes,
|
|
@@ -596,7 +694,13 @@ async function authenticateHubJwt(
|
|
|
596
694
|
// pragmatic constraint we DON'T manufacture a more specific class; the
|
|
597
695
|
// channel comes from the path instead.
|
|
598
696
|
actor: claims.sub && claims.sub.length > 0 ? claims.sub : null,
|
|
599
|
-
|
|
697
|
+
// VIA: `nostr:<pubkey>` when the hub tells us which key signed the
|
|
698
|
+
// request that produced this token (vault#698 — the NIP-98 `/mcp` door),
|
|
699
|
+
// otherwise the generic `api` credential class the request path refines.
|
|
700
|
+
// `actor` is untouched on purpose: it stays the hub USER id, so two
|
|
701
|
+
// agents sharing a hub user keep one `created_by` and are told apart by
|
|
702
|
+
// `created_via` / `last_updated_via`.
|
|
703
|
+
via: principalPubkey !== null ? nostrVia(principalPubkey) : "api",
|
|
600
704
|
};
|
|
601
705
|
} catch (err) {
|
|
602
706
|
if (err instanceof MalformedScopedTagsError) {
|
package/src/live-match.test.ts
CHANGED
|
@@ -130,6 +130,23 @@ describe("live-match — predicate parity with the query engine", () => {
|
|
|
130
130
|
expect(ids.size).toBe(2);
|
|
131
131
|
});
|
|
132
132
|
|
|
133
|
+
it("pathPrefix with a LIKE metachar matches literally (vault#659)", async () => {
|
|
134
|
+
// `_` is LIKE's single-char wildcard. Unescaped, `LIKE '_tags/%'` also
|
|
135
|
+
// matched `atags/x` in SQL while the matcher's `startsWith` did not —
|
|
136
|
+
// a snapshot/live divergence that also just returned the wrong answer.
|
|
137
|
+
const under = await store.createNote("under", { path: "_tags/x" });
|
|
138
|
+
await store.createNote("decoy", { path: "atags/x" });
|
|
139
|
+
const ids = await assertParity({ pathPrefix: "_tags/" });
|
|
140
|
+
expect([...ids]).toEqual([under.id]);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("excludePathPrefix with a LIKE metachar excludes literally (vault#659)", async () => {
|
|
144
|
+
await store.createNote("under", { path: "_tags/x" });
|
|
145
|
+
const decoy = await store.createNote("decoy", { path: "atags/x" });
|
|
146
|
+
const ids = await assertParity({ excludePathPrefix: ["_tags/"] });
|
|
147
|
+
expect([...ids]).toEqual([decoy.id]);
|
|
148
|
+
});
|
|
149
|
+
|
|
133
150
|
it("hasTags true/false (M1 — presence parity)", async () => {
|
|
134
151
|
await store.createNote("tagged", { tags: ["x"] });
|
|
135
152
|
await store.createNote("bare", {});
|
package/src/live-match.ts
CHANGED
|
@@ -20,9 +20,13 @@
|
|
|
20
20
|
* freezes it for the snapshot query.
|
|
21
21
|
* - `excludeTags` — raw exact-name match (engine does NOT expand excludes).
|
|
22
22
|
* - `path` — case-insensitive exact (engine: `n.path = ? COLLATE NOCASE`).
|
|
23
|
-
* - `pathPrefix` — prefix (engine: `n.path LIKE prefix || '%'`).
|
|
23
|
+
* - `pathPrefix` — prefix (engine: `n.path LIKE prefix || '%' ESCAPE '\'`).
|
|
24
|
+
* The engine escapes `%`/`_` in the prefix (vault#659), so the prefix
|
|
25
|
+
* matches literally — exactly what `startsWith` does here. Before that
|
|
26
|
+
* fix the two paths genuinely diverged: `_tags/` matched `atags/…` in
|
|
27
|
+
* SQL and not in the matcher.
|
|
24
28
|
* - `excludePathPrefix` — NOT those prefixes (engine: `n.path IS NULL OR
|
|
25
|
-
* n.path NOT LIKE prefix || '%'`). Repeatable. vault#628.
|
|
29
|
+
* n.path NOT LIKE prefix || '%' ESCAPE '\'`). Repeatable. vault#628.
|
|
26
30
|
* - `extension` — lower-cased, default "md" (engine: `LOWER(n.extension)`),
|
|
27
31
|
* a note with no extension is treated as "md".
|
|
28
32
|
* - `metadata` operator objects (eq/ne/gt/gte/lt/lte/in/not_in/exists) +
|