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 <noreply@anthropic.com>
This commit is contained in:
Dave 2026-10-16 23:22:59 +02:00
parent 144971779f
commit 51bd4ec32c
4 changed files with 56 additions and 9 deletions

View file

@ -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" });
});

View file

@ -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<string, string> = {};
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);

View file

@ -10,7 +10,7 @@ const store = {
failUpdateFor: new Set<string>(),
};
const events: string[] = [];
const actors: { name?: string; source?: string }[] = [];
const actors: { name?: string; source?: string; ignoreRequestUser?: boolean }[] = [];
function itemMatchesQuotes(filter: string | undefined): Set<string> | 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 () => {

View file

@ -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<Quote>(quoteId);
if (!isOpenQuote(quote.status)) {