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:
178
packages/core-consent/src/payload-consent.ts
Normal file
178
packages/core-consent/src/payload-consent.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user