feat(core-consent): add PayloadConsent, RecordingConsent and DI binders

Implements the Payload-backed IConsent that reads/writes users.consentState
and emits CONSENT_GRANT/CONSENT_WITHDRAW audit entries via injected auditLog.
Adds RecordingConsent test double in core-testing for unit-test injection.
Adds bindProductionConsent/bindDevSeedConsent DI binders and InMemoryConsent
for dev/seed contexts. Contract tests cover grant/withdraw/isGranted round-trip,
audit entry shape, metadata persistence (bannerVersion/policyVersion/method),
and getCategories reflection of state.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-19 12:39:31 +00:00
parent 5792b7412a
commit 7dd46b68b2
20 changed files with 936 additions and 27 deletions

View File

@@ -0,0 +1,178 @@
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<Record<string, unknown>>;
update(args: {
collection: string;
id: string;
data: Record<string, unknown>;
overrideAccess: true;
}): Promise<unknown>;
};
type GetPayload = (args: { config: SanitizedConfig }) => Promise<PayloadAPI>;
/**
* 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<ConsentCategory, UserConsentState>();
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<void> {
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<string, unknown>);
this.cache.set(entry.category, entry);
}
}
}
isGranted(category: ConsentCategory): boolean {
return this.cache.get(category)?.state === "granted";
}
async grant(
category: ConsentCategory,
meta?: ConsentGrantMeta,
): Promise<void> {
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();
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<void> {
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();
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());
}
private async persist(): Promise<void> {
const payload = await this.getPayloadFn({ config: this.config });
const state = Array.from(this.cache.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<string, unknown>): 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,
};
}