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

@@ -11,3 +11,7 @@ export {
type RecordedIdentify,
type RecordedPageView,
} from "./recording-analytics";
export {
RecordingConsent,
type RecordedConsentGrant,
} from "./recording-consent";

View File

@@ -0,0 +1,88 @@
// Local type aliases mirroring exports from `@repo/core-consent`.
// Kept inline to avoid a build-graph cycle between core-testing (tooling)
// and core-consent (optional core). Same pattern used by RecordingAuditLog,
// RecordingEventBus, and other recording doubles.
type ConsentCategory = string;
type ConsentState = "granted" | "denied" | "pending";
type ConsentGrantMeta = {
bannerVersion?: string;
policyVersion?: string;
method?: string;
};
type UserConsentState = {
readonly category: ConsentCategory;
readonly state: ConsentState;
readonly grantedAt?: Date;
readonly withdrawnAt?: Date;
readonly bannerVersion?: string;
readonly policyVersion?: string;
readonly method?: string;
};
/** Recorded grant call. */
export type RecordedConsentGrant = {
category: ConsentCategory;
meta: ConsentGrantMeta | undefined;
};
/**
* Test-side recording double for IConsent. Records all grant/withdraw calls
* for assertion while maintaining the same state semantics as the real impl
* (isGranted returns true after grant, false after withdraw).
*
* Use directly via constructor injection:
* const consent = new RecordingConsent();
* const uc = myUseCase(consent);
* await uc({ ... });
* expect(consent.grants).toHaveLength(1);
*/
export class RecordingConsent {
public grants: RecordedConsentGrant[] = [];
public withdrawals: ConsentCategory[] = [];
private state = new Map<ConsentCategory, UserConsentState>();
isGranted(category: ConsentCategory): boolean {
return this.state.get(category)?.state === "granted";
}
async grant(
category: ConsentCategory,
meta?: ConsentGrantMeta,
): Promise<void> {
this.grants.push({ category, meta });
const existing = this.state.get(category);
this.state.set(category, {
category,
state: "granted",
grantedAt: new Date(),
withdrawnAt: existing?.withdrawnAt,
bannerVersion: meta?.bannerVersion,
policyVersion: meta?.policyVersion,
method: meta?.method,
});
}
async withdraw(category: ConsentCategory): Promise<void> {
this.withdrawals.push(category);
const existing = this.state.get(category);
this.state.set(category, {
...existing,
category,
state: "denied",
withdrawnAt: new Date(),
});
}
getCategories(): UserConsentState[] {
return Array.from(this.state.values());
}
reset(): void {
this.grants = [];
this.withdrawals = [];
this.state.clear();
}
}