@openparachute/vault 0.6.4-rc.1 → 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/src/auth.ts CHANGED
@@ -100,6 +100,11 @@ function tryServerWideAuth(
100
100
  // would see no other operator mints; that's fine — env-var-bearer is
101
101
  // explicitly the operator-channel, not a user surface).
102
102
  caller_jti: null,
103
+ // Write-attribution (vault#298): the env-var bearer IS the operator
104
+ // channel. `actor: "operator"` + `via: "operator"` — a stable, honest
105
+ // label for cross-container hub→vault and CLI operator writes.
106
+ actor: "operator",
107
+ via: "operator",
103
108
  };
104
109
  }
105
110
 
@@ -137,14 +142,52 @@ export interface AuthResult {
137
142
  * mints. See vault#376.
138
143
  */
139
144
  caller_jti: string | null;
145
+ /**
146
+ * Write-attribution axis 1 — WHO (vault#298). The principal a write is
147
+ * attributed to:
148
+ * - Hub JWT → the validated `sub` claim (the human or the identity an
149
+ * agent runs as).
150
+ * - VAULT_AUTH_TOKEN operator bearer → `"operator"`.
151
+ * - Legacy YAML api_keys → `"token:<keyhash-prefix>"` so legacy writes
152
+ * still attribute to *something* stable, never crash.
153
+ * NULL only when no principal could be resolved (shouldn't happen on a
154
+ * successful auth, but kept nullable so a future credential class that
155
+ * lacks a subject degrades gracefully rather than throwing).
156
+ */
157
+ actor: string | null;
158
+ /**
159
+ * Write-attribution axis 2 — VIA WHAT (vault#298). The interface/channel a
160
+ * write arrived through, derived PRAGMATICALLY from the credential class
161
+ * here, then REFINED by the request path at the call site (the MCP handler
162
+ * stamps `mcp`; the REST router keeps the credential-class default). Values:
163
+ * `mcp` · `surface:<name>` · `agent:<id>` · `operator` · `api`. This is the
164
+ * BASE value from auth — the credential class:
165
+ * - Hub JWT → `"api"` (the generic class; refined to `mcp` etc. downstream
166
+ * once the channel is known).
167
+ * - operator bearer → `"operator"`.
168
+ * - legacy YAML key → `"api"` (a REST credential; the `token:<id>` actor
169
+ * already carries the legacy-key identity).
170
+ * Never blocks the `actor` work — if the channel can't be cleanly
171
+ * determined the class (or `api`) stands.
172
+ */
173
+ via: string | null;
140
174
  }
141
175
 
142
176
  /**
143
177
  * Convert a legacy "read" | "full" permission into scopes + the legacyDerived
144
178
  * flag. Used for legacy YAML key authentication paths and for tokens whose
145
179
  * `scopes` column is still NULL.
180
+ *
181
+ * `actorLabel` (vault#298) is the write-attribution principal for this legacy
182
+ * credential — `token:<keyhash-prefix>` so a legacy-YAML-keyed write still
183
+ * attributes to a stable identity. Defaults to `"operator"` for the rare
184
+ * call site without a key in hand. `via` is the generic `api` class (legacy
185
+ * keys are a REST credential); the MCP handler refines it to `mcp` downstream.
146
186
  */
147
- function legacyAuthResult(permission: TokenPermission): AuthResult {
187
+ function legacyAuthResult(
188
+ permission: TokenPermission,
189
+ actorLabel: string = "operator",
190
+ ): AuthResult {
148
191
  return {
149
192
  permission,
150
193
  scopes: legacyPermissionToScopes(permission),
@@ -152,9 +195,23 @@ function legacyAuthResult(permission: TokenPermission): AuthResult {
152
195
  scoped_tags: null,
153
196
  vault_name: null,
154
197
  caller_jti: null,
198
+ actor: actorLabel,
199
+ via: "api",
155
200
  };
156
201
  }
157
202
 
203
+ /**
204
+ * Stable short label for a legacy YAML api_key, derived from its stored hash.
205
+ * `token:<first-12-of-hash>` — enough to distinguish keys for attribution
206
+ * without putting a full secret-derived hash into note rows. The hash is
207
+ * already a one-way digest of the key (see config.ts `verifyKey`), so a prefix
208
+ * leaks nothing usable.
209
+ */
210
+ function legacyKeyActorLabel(keyHash: string): string {
211
+ const clean = keyHash.replace(/^[^:]*:/, ""); // drop any `algo:` prefix
212
+ return `token:${clean.slice(0, 12)}`;
213
+ }
214
+
158
215
  // One-shot deprecation warning tracker, keyed by token hash / legacy label so
159
216
  // we don't spam the log on every request.
160
217
  const warnedLegacyTokens = new Set<string>();
@@ -303,7 +360,10 @@ export async function authenticateVaultRequest(
303
360
  if (vaultKey) {
304
361
  try { writeVaultConfig(vaultConfig); } catch {}
305
362
  warnLegacyOnce(`yaml-vault:${vaultKey.key_hash}`, "vault.yaml api_keys");
306
- return legacyAuthResult(vaultKey.scope === "read" ? "read" : "full");
363
+ return legacyAuthResult(
364
+ vaultKey.scope === "read" ? "read" : "full",
365
+ legacyKeyActorLabel(vaultKey.key_hash),
366
+ );
307
367
  }
308
368
 
309
369
  // Legacy: check global keys from config.yaml
@@ -313,7 +373,10 @@ export async function authenticateVaultRequest(
313
373
  if (globalKey) {
314
374
  try { writeGlobalConfig(globalConfig); } catch {}
315
375
  warnLegacyOnce(`yaml-global:${globalKey.key_hash}`, "config.yaml api_keys");
316
- return legacyAuthResult(globalKey.scope === "read" ? "read" : "full");
376
+ return legacyAuthResult(
377
+ globalKey.scope === "read" ? "read" : "full",
378
+ legacyKeyActorLabel(globalKey.key_hash),
379
+ );
317
380
  }
318
381
  }
319
382
 
@@ -515,6 +578,18 @@ async function authenticateHubJwt(
515
578
  // through verbatim — manage-token's session-pin will be null in that
516
579
  // case, and list/revoke from that session sees no mints.
517
580
  caller_jti: claims.jti ?? null,
581
+ // Write-attribution (vault#298). WHO = the validated JWT subject (the
582
+ // human, or the identity an agent runs as — note agent-grant tokens are
583
+ // minted with `sub = <user-on-whose-behalf>`, so today an agent's writes
584
+ // attribute to that human; a delegation-chain `via` is the deferred v2 in
585
+ // the issue). VIA = `api` here, the generic credential class; the request
586
+ // path refines it (the MCP handler stamps `mcp`). The JWT carries no
587
+ // clean surface-name / agent-definition-id claim — `clientId` is an
588
+ // opaque DCR id and `aud` is just `vault.<name>` — so per the issue's
589
+ // pragmatic constraint we DON'T manufacture a more specific class; the
590
+ // channel comes from the path instead.
591
+ actor: claims.sub && claims.sub.length > 0 ? claims.sub : null,
592
+ via: "api",
518
593
  };
519
594
  } catch (err) {
520
595
  if (err instanceof MalformedScopedTagsError) {
@@ -619,7 +694,10 @@ export async function authenticateGlobalRequest(
619
694
  if (matched) {
620
695
  try { writeGlobalConfig(globalConfig); } catch {}
621
696
  warnLegacyOnce(`yaml-global:${matched.key_hash}`, "config.yaml api_keys");
622
- return legacyAuthResult(matched.scope === "read" ? "read" : "full");
697
+ return legacyAuthResult(
698
+ matched.scope === "read" ? "read" : "full",
699
+ legacyKeyActorLabel(matched.key_hash),
700
+ );
623
701
  }
624
702
  }
625
703
 
package/src/mcp-tools.ts CHANGED
@@ -166,10 +166,25 @@ export function generateScopedMcpTools(
166
166
  )
167
167
  : undefined;
168
168
 
169
+ // Write-attribution (vault#298). Every write through an MCP session arrives
170
+ // on the `mcp` channel — so we REFINE the auth's base `via` (the generic
171
+ // credential class) to `mcp` here, where the path/channel is known. The
172
+ // operator bearer keeps `operator` (its credential class IS its channel and
173
+ // is more informative than `mcp` for cross-container hub→vault writes); any
174
+ // other credential's via becomes `mcp`. `actor` (the principal) passes
175
+ // through unchanged.
176
+ const writeContext = auth
177
+ ? { actor: auth.actor, via: auth.via === "operator" ? "operator" : "mcp" }
178
+ : undefined;
179
+
169
180
  const tools = generateMcpTools(
170
181
  store,
171
- expandVisibility || nearTraversable
172
- ? { ...(expandVisibility ? { expandVisibility } : {}), ...(nearTraversable ? { nearTraversable } : {}) }
182
+ expandVisibility || nearTraversable || writeContext
183
+ ? {
184
+ ...(expandVisibility ? { expandVisibility } : {}),
185
+ ...(nearTraversable ? { nearTraversable } : {}),
186
+ ...(writeContext ? { writeContext } : {}),
187
+ }
173
188
  : undefined,
174
189
  );
175
190
 
package/src/routes.ts CHANGED
@@ -44,6 +44,16 @@ import { findTokensReferencingTag } from "./token-store.ts";
44
44
  export type TagScopeCtx = { allowed: Set<string> | null; raw: string[] | null };
45
45
 
46
46
  const NO_TAG_SCOPE: TagScopeCtx = { allowed: null, raw: null };
47
+
48
+ /**
49
+ * Write-attribution context (vault#298) for REST writes — the principal
50
+ * (`actor`) and interface (`via`) threaded from the authenticated request into
51
+ * `store.createNote` / `store.updateNote`. Both null for paths without an auth
52
+ * context (the no-op default). See `WriteContext` in core/src/notes.ts.
53
+ */
54
+ export type WriteCtx = { actor: string | null; via: string | null };
55
+
56
+ const NO_WRITE_CTX: WriteCtx = { actor: null, via: null };
47
57
  import {
48
58
  expandContent,
49
59
  DEFAULT_EXPAND_DEPTH,
@@ -567,6 +577,13 @@ export function parseNotesQueryOpts(url: URL): {
567
577
  pathPrefix: parseQuery(url, "path_prefix") ?? undefined,
568
578
  extension: parseExtensionFilter(url),
569
579
  metadata: bracket.metadata ?? metadataAlias.metadata,
580
+ // Write-attribution filters (vault#298) — symmetric with the MCP
581
+ // query-notes tool so a REST caller can ask "what did Mathilda write" /
582
+ // "what came in via the meeting-ingest surface" the same way.
583
+ createdBy: parseQuery(url, "created_by") ?? undefined,
584
+ lastUpdatedBy: parseQuery(url, "last_updated_by") ?? undefined,
585
+ createdVia: parseQuery(url, "created_via") ?? undefined,
586
+ lastUpdatedVia: parseQuery(url, "last_updated_via") ?? undefined,
570
587
  ...(bracket.dateFilter
571
588
  ? { dateFilter: bracket.dateFilter }
572
589
  : parseQuery(url, "date_field")
@@ -704,9 +721,10 @@ export async function handleNotes(
704
721
  subpath: string,
705
722
  vault?: string,
706
723
  tagScope: TagScopeCtx = NO_TAG_SCOPE,
724
+ writeCtx: WriteCtx = NO_WRITE_CTX,
707
725
  ): Promise<Response> {
708
726
  try {
709
- return await handleNotesInner(req, store, subpath, vault, tagScope);
727
+ return await handleNotesInner(req, store, subpath, vault, tagScope, writeCtx);
710
728
  } catch (e: any) {
711
729
  const ambig = ambiguousPathResponse(e);
712
730
  if (ambig) return ambig;
@@ -720,6 +738,7 @@ async function handleNotesInner(
720
738
  subpath: string,
721
739
  vault?: string,
722
740
  tagScope: TagScopeCtx = NO_TAG_SCOPE,
741
+ writeCtx: WriteCtx = NO_WRITE_CTX,
723
742
  ): Promise<Response> {
724
743
  const url = new URL(req.url);
725
744
  const method = req.method;
@@ -1109,6 +1128,9 @@ async function handleNotesInner(
1109
1128
  metadata: item.metadata,
1110
1129
  created_at: item.createdAt ?? item.created_at,
1111
1130
  ...(extension !== undefined ? { extension } : {}),
1131
+ // Write-attribution (vault#298) — REST batch create.
1132
+ actor: writeCtx.actor,
1133
+ via: writeCtx.via,
1112
1134
  });
1113
1135
 
1114
1136
  // Create explicit links
@@ -1383,6 +1405,9 @@ async function handleNotesInner(
1383
1405
  ...(body.created_at !== undefined ? { created_at: body.created_at as string } : {}),
1384
1406
  ...(body.createdAt !== undefined ? { created_at: body.createdAt as string } : {}),
1385
1407
  ...(createExt !== undefined ? { extension: createExt } : {}),
1408
+ // Write-attribution (vault#298) — REST upsert-create branch.
1409
+ actor: writeCtx.actor,
1410
+ via: writeCtx.via,
1386
1411
  };
1387
1412
  const content = (body.content as string | undefined) ?? "";
1388
1413
  const created = await store.createNote(content, createOpts);
@@ -1568,6 +1593,10 @@ async function handleNotesInner(
1568
1593
  }
1569
1594
 
1570
1595
  if (Object.keys(updates).length > 0) {
1596
+ // Write-attribution (vault#298) — REST update. Stamp the most-recent-
1597
+ // write columns on the same UPDATE that bumps updated_at.
1598
+ updates.actor = writeCtx.actor;
1599
+ updates.via = writeCtx.via;
1571
1600
  await store.updateNote(note.id, updates);
1572
1601
  }
1573
1602
 
package/src/routing.ts CHANGED
@@ -71,6 +71,7 @@ import {
71
71
  handleStorage,
72
72
  handleViewNote,
73
73
  type TagScopeCtx,
74
+ type WriteCtx,
74
75
  } from "./routes.ts";
75
76
  import { handleSubscribe } from "./subscribe.ts";
76
77
  import { handleTriggers } from "./triggers-api.ts";
@@ -812,7 +813,14 @@ export async function route(
812
813
  raw: auth.scoped_tags,
813
814
  };
814
815
 
815
- if (apiPath.startsWith("/notes")) return handleNotes(req, store, apiPath.slice(6), vaultName, tagScope);
816
+ // Write-attribution context (vault#298). `auth.actor` is the principal;
817
+ // `auth.via` is the credential class (`api` for hub JWTs + legacy keys,
818
+ // `operator` for the env-var bearer). The REST surface IS the `api` channel,
819
+ // so no refinement is needed here — the base via stands (the MCP handler is
820
+ // the one that refines to `mcp`). Threaded only into the write handler.
821
+ const writeCtx: WriteCtx = { actor: auth.actor, via: auth.via };
822
+
823
+ if (apiPath.startsWith("/notes")) return handleNotes(req, store, apiPath.slice(6), vaultName, tagScope, writeCtx);
816
824
  // Live-query SSE subscription (design 2026-06-08). Snapshot + scoped live
817
825
  // upsert/remove events over text/event-stream. Auth + tag-scope already
818
826
  // resolved above and threaded through, mirroring the /notes branch.