From 51bd4ec32cbd2f3732aebb714389020dd6496526 Mon Sep 17 00:00:00 2001 From: Dave Date: Fri, 16 Oct 2026 23:22:59 +0200 Subject: [PATCH] fix(quote-assistant): credit AI writes to the assistant, not the rep who clicked AuditActor gains ignoreRequestUser: buildAuditHeaders then omits the forwarded PB token (X-Audit-Auth) and request-derived email hint, so the PB audit hook falls back to X-Audit-Actor-Name. fillQuote sets it for all its writes. Co-Authored-By: Claude Opus 5.5 --- src/lib/server/audit-context.test.ts | 28 +++++++++++++++++++ src/lib/server/audit-context.ts | 21 +++++++++++--- .../server/quote-assistant-service.test.ts | 12 +++++--- src/lib/server/quote-assistant-service.ts | 4 ++- 4 files changed, 56 insertions(+), 9 deletions(-) diff --git a/src/lib/server/audit-context.test.ts b/src/lib/server/audit-context.test.ts index 91e1426..614f1c9 100644 --- a/src/lib/server/audit-context.test.ts +++ b/src/lib/server/audit-context.test.ts @@ -50,6 +50,34 @@ describe("buildAuditHeaders", () => { expect(buildAuditHeaders(reader({}), undefined, undefined)["X-Audit-Actor-Email"]).toBeUndefined(); }); + it("omits the request user's token and email hint when the actor opts out (ignoreRequestUser)", () => { + const incoming = reader({ + authorization: "Bearer pb-user-token", + "x-wfdash-auth": "tok", + cookie: "wfdash_dev_email=sam%40wf.co.za", + }); + const out = buildAuditHeaders( + incoming, + { name: "AI quote assistant (run by Sam)", source: "agent", ignoreRequestUser: true }, + "agent-key", + ); + expect(out["X-Audit-Auth"]).toBeUndefined(); + expect(out["X-Audit-Actor-Email"]).toBeUndefined(); + expect(out).toMatchObject({ + "X-Audit-Source": "agent", + "X-Audit-Actor-Name": encodeURIComponent("AI quote assistant (run by Sam)"), + }); + }); + + it("still forwards the request user's token when the actor does not opt out", () => { + const out = buildAuditHeaders( + reader({ authorization: "Bearer pb-user-token" }), + { name: "Someone", source: "mcp" }, + "agent-key", + ); + expect(out["X-Audit-Auth"]).toBe("pb-user-token"); + }); + it("falls back to source=server outside a request", () => { expect(buildAuditHeaders(null, undefined, undefined)).toEqual({ "X-Audit-Source": "server" }); }); diff --git a/src/lib/server/audit-context.ts b/src/lib/server/audit-context.ts index 1963eb7..63989fa 100644 --- a/src/lib/server/audit-context.ts +++ b/src/lib/server/audit-context.ts @@ -18,6 +18,15 @@ export type AuditActor = { email?: string; /** web | agent | mcp | n8n | server */ source?: string; + /** + * Don't credit the person behind the current request: omit their + * forwarded PB token (`X-Audit-Auth`) and request-derived email hint. + * For writes the server makes on its own judgement (e.g. the AI quote + * assistant run from a button) — the PB hook prefers a verified token + * over the name header, so without this the change is credited to the + * user who clicked. + */ + ignoreRequestUser?: boolean; }; /** Header the dashboard's fetch wrapper uses to forward the PB token. */ @@ -68,8 +77,10 @@ export function buildAuditHeaders( const out: Record = {}; const authToken = bearer(incoming?.get("authorization") ?? null); const isAgent = !!agentApiKey && authToken === agentApiKey; - const userToken = - (!isAgent && authToken) || bearer(incoming?.get(AUDIT_AUTH_REQUEST_HEADER) ?? null); + const ignoreUser = !!explicit?.ignoreRequestUser; + const userToken = ignoreUser + ? "" + : (!isAgent && authToken) || bearer(incoming?.get(AUDIT_AUTH_REQUEST_HEADER) ?? null); const source = explicit?.source || (isAgent ? "agent" : userToken ? "web" : incoming ? "web" : "server"); out["X-Audit-Source"] = source; @@ -79,8 +90,10 @@ export function buildAuditHeaders( // a wrong name in an audit trail is worse than "Automation". const hintEmail = explicit?.email || - (incoming?.get(DEV_IDENTITY_HEADER) ?? "").trim() || - readCookie(incoming?.get("cookie") ?? "", DEV_IDENTITY_COOKIE); + (ignoreUser + ? "" + : (incoming?.get(DEV_IDENTITY_HEADER) ?? "").trim() || + readCookie(incoming?.get("cookie") ?? "", DEV_IDENTITY_COOKIE)); // Header values must be ASCII; the hook decodes these. if (explicit?.name) out["X-Audit-Actor-Name"] = encodeURIComponent(explicit.name); if (explicit?.id) out["X-Audit-Actor-Id"] = encodeURIComponent(explicit.id); diff --git a/src/lib/server/quote-assistant-service.test.ts b/src/lib/server/quote-assistant-service.test.ts index e34b48a..a4cbf16 100644 --- a/src/lib/server/quote-assistant-service.test.ts +++ b/src/lib/server/quote-assistant-service.test.ts @@ -10,7 +10,7 @@ const store = { failUpdateFor: new Set(), }; const events: string[] = []; -const actors: { name?: string; source?: string }[] = []; +const actors: { name?: string; source?: string; ignoreRequestUser?: boolean }[] = []; function itemMatchesQuotes(filter: string | undefined): Set | null { const ids = [...(filter ?? "").matchAll(/\bquote = "([^"]+)"/g)].map((m) => m[1]); @@ -65,7 +65,7 @@ const pb = { vi.mock("@/lib/server/pocketbase-admin", () => ({ getAdminPocketBase: async () => pb })); vi.mock("@/lib/server/audit-context", () => ({ - runWithAuditActor: (actor: { name?: string; source?: string }, fn: () => unknown) => { + runWithAuditActor: (actor: { name?: string; source?: string; ignoreRequestUser?: boolean }, fn: () => unknown) => { actors.push(actor); return fn(); }, @@ -416,11 +416,15 @@ describe("fillQuote", () => { addItem("q1"); const { deps } = makeDeps(); await fillQuote("q1", { deps }); - expect(actors).toEqual([{ name: "AI quote assistant", source: "agent" }]); + expect(actors).toEqual([{ name: "AI quote assistant", source: "agent", ignoreRequestUser: true }]); actors.length = 0; await fillQuote("q1", { actorName: "Dave", force: true, deps }); - expect(actors).toEqual([{ name: "AI quote assistant (run by Dave)", source: "agent" }]); + // ignoreRequestUser: the button-pressing rep's PB token must not + // override the AI actor name in the audit trail. + expect(actors).toEqual([ + { name: "AI quote assistant (run by Dave)", source: "agent", ignoreRequestUser: true }, + ]); }); it("only re-sends an unchanged no_match line when forced", async () => { diff --git a/src/lib/server/quote-assistant-service.ts b/src/lib/server/quote-assistant-service.ts index bc0e730..1aeaf75 100644 --- a/src/lib/server/quote-assistant-service.ts +++ b/src/lib/server/quote-assistant-service.ts @@ -95,7 +95,9 @@ export async function fillQuote( const result: FillQuoteResult = { quoteId, filled: 0, unmatched: 0, skipped: 0, error: "" }; - return runWithAuditActor({ name: actorName, source: "agent" }, async () => { + // ignoreRequestUser: on a manual run the request carries the rep's PB + // token, which the audit hook would otherwise credit instead of the AI. + return runWithAuditActor({ name: actorName, source: "agent", ignoreRequestUser: true }, async () => { const pb = await getAdminPocketBase(); const quote = await pb.collection("quotes").getOne(quoteId); if (!isOpenQuote(quote.status)) {