import { getPayload as _getPayload } from "payload"; import type { SanitizedConfig } from "payload"; import type { AuditLogProtocol } from "@repo/core-shared/di"; import type { IConsent } from "./consent.interface"; import type { ConsentCategory, ConsentGrantMeta, ConsentState, UserConsentState, } from "./consent-types"; type PayloadAPI = { findByID(args: { collection: string; id: string; overrideAccess: true; }): Promise>; update(args: { collection: string; id: string; data: Record; overrideAccess: true; }): Promise; }; type GetPayload = (args: { config: SanitizedConfig }) => Promise; /** * Payload-backed IConsent. Reads and writes the `users.consentState` JSON * field on the Payload `users` collection. Emits CONSENT_GRANT / * CONSENT_WITHDRAW audit entries via the injected auditLog. * * Instantiated per-user: the userId is provided at construction time. * Call `load()` before first use in production to hydrate the in-memory * cache from Payload. Mutations (grant / withdraw) update the cache * synchronously and persist to Payload asynchronously in the same call. * * The getPayload param is injectable for tests; production code omits it * and gets the real `getPayload` from `payload`. */ export class PayloadConsent implements IConsent { private cache = new Map(); constructor( private readonly userId: string, private readonly config: SanitizedConfig, private readonly auditLog: AuditLogProtocol, private readonly getPayloadFn: GetPayload = _getPayload as unknown as GetPayload, ) {} /** Hydrate the in-memory cache from Payload. */ async load(): Promise { const payload = await this.getPayloadFn({ config: this.config }); const doc = await payload.findByID({ collection: "users", id: this.userId, overrideAccess: true, }); const rawState = doc["consentState"]; if (!Array.isArray(rawState)) return; this.cache.clear(); for (const raw of rawState) { if (raw && typeof raw === "object") { const entry = deserializeEntry(raw as Record); this.cache.set(entry.category, entry); } } } isGranted(category: ConsentCategory): boolean { return this.cache.get(category)?.state === "granted"; } async grant( category: ConsentCategory, meta?: ConsentGrantMeta, ): Promise { const now = new Date(); const existing = this.cache.get(category); const entry: UserConsentState = { category, state: "granted", grantedAt: now, withdrawnAt: existing?.withdrawnAt, bannerVersion: meta?.bannerVersion, policyVersion: meta?.policyVersion, method: meta?.method, }; this.cache.set(category, entry); await this.persist([category]); await this.auditLog.record({ actorId: this.userId, actorType: "user", actorRoles: [], action: "CONSENT_GRANT", resource: { type: "consent", id: category }, at: now, scope: { feature: "core-consent", environment: process.env["NODE_ENV"] ?? "development", tenant: "default", }, from: { ipTruncated: "system", userAgent: "system" }, containsPii: false, outcome: "success", }); } async withdraw(category: ConsentCategory): Promise { const now = new Date(); const existing = this.cache.get(category); const entry: UserConsentState = { ...existing, category, state: "denied", withdrawnAt: now, }; this.cache.set(category, entry); await this.persist([category]); await this.auditLog.record({ actorId: this.userId, actorType: "user", actorRoles: [], action: "CONSENT_WITHDRAW", resource: { type: "consent", id: category }, at: now, scope: { feature: "core-consent", environment: process.env["NODE_ENV"] ?? "development", tenant: "default", }, from: { ipTruncated: "system", userAgent: "system" }, containsPii: false, outcome: "success", }); } getCategories(): UserConsentState[] { return Array.from(this.cache.values()); } /** * Read-merge-write persistence (audit finding A7 — lost-update race). * * Payload's `update` on a json field replaces the WHOLE value; there is no * targeted array-element patch. Writing this instance's per-request cache * verbatim would drop any category another request persisted since our * `load()`. Instead we re-read the freshest stored state immediately * before writing and overlay ONLY the categories this call mutated, so * two interleaved writers touching different categories both survive. * * Residual window (documented, accepted): between this read and the write, * a concurrent writer to the SAME category is last-writer-wins, and a * concurrent writer to a different category that lands inside the window * can still be overwritten. Closing it fully needs a DB-level transaction * or JSON-patch support in Payload; for consent state (idempotent, * per-subject, low frequency) read-merge-write is the accepted trade-off. */ private async persist(mutated: ConsentCategory[]): Promise { const payload = await this.getPayloadFn({ config: this.config }); // Freshest stored state, immediately before the write. const doc = await payload.findByID({ collection: "users", id: this.userId, overrideAccess: true, }); const merged = new Map(); const rawState = doc["consentState"]; if (Array.isArray(rawState)) { for (const raw of rawState) { if (raw && typeof raw === "object") { const entry = deserializeEntry(raw as Record); merged.set(entry.category, entry); } } } // Overlay only what this call changed. for (const category of mutated) { const entry = this.cache.get(category); if (entry) merged.set(category, entry); } // Adopt the merged view locally so isGranted/getCategories reflect // concurrent writers' entries too. this.cache = merged; const state = Array.from(merged.values()).map((entry) => ({ category: entry.category, state: entry.state, grantedAt: entry.grantedAt?.toISOString() ?? null, withdrawnAt: entry.withdrawnAt?.toISOString() ?? null, bannerVersion: entry.bannerVersion ?? null, policyVersion: entry.policyVersion ?? null, method: entry.method ?? null, })); await payload.update({ collection: "users", id: this.userId, data: { consentState: state }, overrideAccess: true, }); } } function deserializeEntry(raw: Record): UserConsentState { return { category: String(raw["category"]) as ConsentCategory, state: (raw["state"] as ConsentState) ?? "pending", grantedAt: raw["grantedAt"] ? new Date(raw["grantedAt"] as string) : undefined, withdrawnAt: raw["withdrawnAt"] ? new Date(raw["withdrawnAt"] as string) : undefined, bannerVersion: raw["bannerVersion"] != null ? String(raw["bannerVersion"]) : undefined, policyVersion: raw["policyVersion"] != null ? String(raw["policyVersion"]) : undefined, method: raw["method"] != null ? String(raw["method"]) : undefined, }; }