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:
parent
144971779f
commit
51bd4ec32c
4 changed files with 56 additions and 9 deletions
|
|
@ -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" });
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue