@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.
@@ -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>`, `operator`/`cli`, `api`).
45
- * Lands in `created_via` / `last_updated_via` symmetrically.
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 || '%'`, SQLite LIKE is ASCII-case-insensitive). NULL
1107
- // paths are kept — they are not under the prefix. Repeatable: a note
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
 
@@ -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`). The `created*` pair is set once at
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 case-insensitive).
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/vault",
3
- "version": "0.7.8",
3
+ "version": "0.7.9-rc.2",
4
4
  "description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
5
5
  "module": "src/cli.ts",
6
6
  "type": "module",
@@ -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; refined to `mcp` by the MCP handler.
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: { iss: string; aud: string; scope: string; sub?: string },
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({ scope: opts.scope, client_id: "test-client" })
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>` · `operator` · `api`. This is the
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
- via: "api",
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) {
@@ -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) +