diff --git a/coverage/summary.json b/coverage/summary.json index ab937f4..52608ac 100644 --- a/coverage/summary.json +++ b/coverage/summary.json @@ -1,18 +1,18 @@ { - "generatedAt": "2026-05-19T10:16:15.209Z", - "commit": "8cf9f4b", + "generatedAt": "2026-05-19T10:39:50.009Z", + "commit": "f5d08dc", "repo": { - "statements": 96.48, - "branches": 91.72, - "functions": 96.83, - "lines": 96.48, + "statements": 96.5, + "branches": 91.82, + "functions": 96.89, + "lines": 96.5, "counts": { - "lf": 4264, - "lh": 4114, - "brf": 809, - "brh": 742, - "fnf": 252, - "fnh": 244 + "lf": 4291, + "lh": 4141, + "brf": 819, + "brh": 752, + "fnf": 257, + "fnh": 249 } }, "byPackage": { @@ -58,18 +58,32 @@ "fnh": 10 } }, - "@repo/core-shared": { - "statements": 98, - "branches": 95.81, - "functions": 92, - "lines": 98, + "@repo/core-consent": { + "statements": 100, + "branches": 100, + "functions": 100, + "lines": 100, "counts": { - "lf": 1050, - "lh": 1029, - "brf": 310, - "brh": 297, - "fnf": 100, - "fnh": 92 + "lf": 10, + "lh": 10, + "brf": 4, + "brh": 4, + "fnf": 4, + "fnh": 4 + } + }, + "@repo/core-shared": { + "statements": 98.03, + "branches": 95.89, + "functions": 92.08, + "lines": 98.03, + "counts": { + "lf": 1067, + "lh": 1046, + "brf": 316, + "brh": 303, + "fnf": 101, + "fnh": 93 } }, "@repo/marketing-pages": { diff --git a/packages/core-consent/AGENTS.md b/packages/core-consent/AGENTS.md new file mode 100644 index 0000000..d08df96 --- /dev/null +++ b/packages/core-consent/AGENTS.md @@ -0,0 +1,29 @@ +# @repo/core-consent + +Optional core package providing a vendor-neutral consent management interface. Scaffold via `pnpm turbo gen core-package consent` (once the generator supports it). + +## Structure + +``` +src/ + consent-types.ts # ConsentCategory, ConsentState, UserConsentState + consent.interface.ts # IConsent — isGranted, grant, withdraw, getCategories + with-consent.ts # withConsent wrapper attaching ConsentChecked brand + index.ts # Barrel export +``` + +## Design + +`IConsent` exposes four methods: + +- `isGranted(category)` — synchronous check whether consent is granted +- `grant(category)` — record consent grant for a category +- `withdraw(category)` — record consent withdrawal for a category +- `getCategories()` — list all known consent states + +The interface is vendor-neutral: no storage implementation is bundled here. Concrete implementations (e.g. a Payload-backed store) are wired at DI bind time in `bind-production` (Story 04). + +`withConsent` wraps a use-case factory at bind time, attaches the `__consentChecked` brand, and is the innermost wrapper in the composition chain: +`withSpan → withCapture → withAudit → withAnalytics → withConsent → factory(deps)` + +See `docs/architecture/agent-first-workflow-and-conformance.md` for the dependency-injection conventions. diff --git a/packages/core-consent/eslint.config.js b/packages/core-consent/eslint.config.js new file mode 100644 index 0000000..7440d8f --- /dev/null +++ b/packages/core-consent/eslint.config.js @@ -0,0 +1,3 @@ +import baseConfig from "@repo/core-eslint/base"; + +export default baseConfig; diff --git a/packages/core-consent/package.json b/packages/core-consent/package.json new file mode 100644 index 0000000..94a0c68 --- /dev/null +++ b/packages/core-consent/package.json @@ -0,0 +1,26 @@ +{ + "name": "@repo/core-consent", + "version": "0.0.1", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "build": "tsc --noEmit", + "lint": "eslint .", + "typecheck": "tsc --noEmit", + "test": "vitest run --passWithNoTests" + }, + "dependencies": { + "@repo/core-shared": "workspace:*" + }, + "devDependencies": { + "@repo/core-eslint": "workspace:*", + "@repo/core-testing": "workspace:*", + "@repo/core-typescript": "workspace:*", + "@vitest/coverage-v8": "^3.0.0", + "typescript": "^5.8.0", + "vitest": "^3.0.0" + } +} diff --git a/packages/core-consent/src/consent-types.ts b/packages/core-consent/src/consent-types.ts new file mode 100644 index 0000000..ab2dbd3 --- /dev/null +++ b/packages/core-consent/src/consent-types.ts @@ -0,0 +1,22 @@ +/** + * Known consent categories. The `(string & {})` escape hatch keeps the union + * open for custom categories while still providing autocomplete for the + * standard values. + */ +export type ConsentCategory = + | "necessary" + | "functional" + | "analytics" + | "marketing" + | (string & {}); + +/** Whether a subject has granted or denied consent for a category. */ +export type ConsentState = "granted" | "denied" | "pending"; + +/** Per-category consent record for a single subject. */ +export type UserConsentState = { + readonly category: ConsentCategory; + readonly state: ConsentState; + readonly grantedAt?: Date; + readonly withdrawnAt?: Date; +}; diff --git a/packages/core-consent/src/consent.interface.ts b/packages/core-consent/src/consent.interface.ts new file mode 100644 index 0000000..d550aec --- /dev/null +++ b/packages/core-consent/src/consent.interface.ts @@ -0,0 +1,22 @@ +import type { ConsentCategory, UserConsentState } from "./consent-types"; + +/** + * Vendor-neutral consent management interface. + * + * Feature binders that receive a consent instance operate through this + * interface. Concrete implementations (Payload-backed, in-memory, etc.) are + * wired at DI bind time and never imported by feature packages directly. + */ +export interface IConsent { + /** Synchronous check — true when the subject has granted the category. */ + isGranted(category: ConsentCategory): boolean; + + /** Record a consent grant for the given category. */ + grant(category: ConsentCategory): Promise; + + /** Record a consent withdrawal for the given category. */ + withdraw(category: ConsentCategory): Promise; + + /** Return the full list of per-category consent states. */ + getCategories(): UserConsentState[]; +} diff --git a/packages/core-consent/src/index.ts b/packages/core-consent/src/index.ts new file mode 100644 index 0000000..a681d8a --- /dev/null +++ b/packages/core-consent/src/index.ts @@ -0,0 +1,8 @@ +export type { + ConsentCategory, + ConsentState, + UserConsentState, +} from "./consent-types"; +export type { IConsent } from "./consent.interface"; +export type { ConsentChecked } from "./with-consent"; +export { withConsent } from "./with-consent"; diff --git a/packages/core-consent/src/with-consent.test.ts b/packages/core-consent/src/with-consent.test.ts new file mode 100644 index 0000000..1ab6775 --- /dev/null +++ b/packages/core-consent/src/with-consent.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect, expectTypeOf } from "vitest"; +import { withConsent, type ConsentChecked } from "@/with-consent"; +import type { IConsent } from "@/consent.interface"; +import { isConsentChecked } from "@repo/core-shared/conformance"; + +function makeConsent(): IConsent { + return { + isGranted: () => true, + grant: () => Promise.resolve(), + withdraw: () => Promise.resolve(), + getCategories: () => [], + }; +} + +describe("withConsent", () => { + it("returns a ConsentChecked", () => { + const consent = makeConsent(); + const fn = async (_input: { id: string }) => ({ ok: true }); + const wrapped = withConsent(consent, fn); + expectTypeOf(wrapped).toMatchTypeOf>(); + }); + + it("attaches __consentChecked as a non-enumerable property on the wrapped function", () => { + const consent = makeConsent(); + const fn = async () => ({ ok: true }); + const wrapped = withConsent(consent, fn); + expect(isConsentChecked(wrapped)).toBe(true); + expect(Object.keys(wrapped)).not.toContain("__consentChecked"); + }); + + it("does NOT pollute the original input function with the brand", () => { + const consent = makeConsent(); + const fn = async () => ({ ok: true }); + const wrapped = withConsent(consent, fn); + expect(isConsentChecked(fn)).toBe(false); + expect(wrapped).not.toBe(fn); + }); + + it("passes input and output through unchanged", async () => { + const consent = makeConsent(); + const fn = async (input: { id: string }) => ({ ok: true, id: input.id }); + const wrapped = withConsent(consent, fn); + const result = await wrapped({ id: "abc" }); + expect(result).toEqual({ ok: true, id: "abc" }); + }); + + it("propagates errors", async () => { + const consent = makeConsent(); + const err = new Error("boom"); + const wrapped = withConsent(consent, async () => { + throw err; + }); + await expect(wrapped()).rejects.toBe(err); + }); +}); diff --git a/packages/core-consent/src/with-consent.ts b/packages/core-consent/src/with-consent.ts new file mode 100644 index 0000000..fcce947 --- /dev/null +++ b/packages/core-consent/src/with-consent.ts @@ -0,0 +1,30 @@ +import type { IConsent } from "./consent.interface"; +import type { ConsentChecked } from "@repo/core-shared/conformance"; +import { attachBrand } from "@repo/core-shared/conformance"; + +export type { ConsentChecked }; + +/** + * Use-case wrapper applied at DI bind time. The wrapper is a thin closure + * that forwards to `fn` unchanged and carries the `__consentChecked` brand. + * The forward closure keeps the brand on a fresh function so the original + * `fn` reference is not mutated — important when the same factory output is + * used elsewhere unwrapped (dev-seed paths, tests). + * + * Composition order (innermost to outermost): + * withSpan → withCapture → withAudit → withAnalytics → withConsent → factory(deps) + * + * The wrapper exists to: + * (1) require callers to pass the consent instance at bind time (dep is available) + * (2) attach the `__consentChecked` brand so the boot-time assertion can verify + * consent-gated use cases were bound through the consent-aware path. + */ +export function withConsent( + consent: IConsent, + fn: (...args: Args) => Promise, +): ConsentChecked<(...args: Args) => Promise> { + void consent; + const wrapped: (...args: Args) => Promise = (...args) => fn(...args); + attachBrand(wrapped, "__consentChecked"); + return wrapped as ConsentChecked<(...args: Args) => Promise>; +} diff --git a/packages/core-consent/tsconfig.json b/packages/core-consent/tsconfig.json new file mode 100644 index 0000000..8facef5 --- /dev/null +++ b/packages/core-consent/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "@repo/core-typescript/base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": ".", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/core-consent/turbo.json b/packages/core-consent/turbo.json new file mode 100644 index 0000000..dcb8fb3 --- /dev/null +++ b/packages/core-consent/turbo.json @@ -0,0 +1,4 @@ +{ + "extends": ["//"], + "tags": ["core"] +} diff --git a/packages/core-consent/vitest.config.ts b/packages/core-consent/vitest.config.ts new file mode 100644 index 0000000..2f81734 --- /dev/null +++ b/packages/core-consent/vitest.config.ts @@ -0,0 +1,15 @@ +import path from "node:path"; +import { defineConfig, mergeConfig } from "vitest/config"; +import { nodeVitestConfig } from "@repo/core-typescript/vitest.base.node"; + +export default mergeConfig( + nodeVitestConfig, + defineConfig({ + test: { + include: ["src/**/*.test.ts"], + }, + resolve: { + alias: { "@": path.resolve(__dirname, "./src") }, + }, + }), +); diff --git a/packages/core-shared/src/conformance/assert-bindings.test.ts b/packages/core-shared/src/conformance/assert-bindings.test.ts index f2f52a3..7a88aac 100644 --- a/packages/core-shared/src/conformance/assert-bindings.test.ts +++ b/packages/core-shared/src/conformance/assert-bindings.test.ts @@ -285,6 +285,82 @@ describe("assertFeatureConformance", () => { ).toThrow(/Analyzed|__analyzed/); }); + it("throws when feature requiresConsent but binding is missing __consentChecked brand", () => { + const container = new Container(); + const sym = Symbol("test.processData"); + const ctx = makeCtx(); + const wrappedNoConsent = withSpan( + ctx.tracer, + { name: "test.processData", op: "use-case" }, + withCapture( + ctx.logger, + { feature: "test", layer: "use-case" }, + async (x: number) => x, + ), + ); + container.bind(sym).toConstantValue(wrappedNoConsent); + + const manifest = defineFeature({ + name: "test", + requiredCores: [], + useCases: { + processData: { + mutates: false, + audits: [], + publishes: [], + consumes: [], + }, + }, + realtimeChannels: [], + jobs: [], + requiresConsent: ["analytics"], + } as const); + + expect(() => + assertFeatureConformance(container, manifest, { processData: sym }, ctx), + ).toThrow(ConformanceError); + expect(() => + assertFeatureConformance(container, manifest, { processData: sym }, ctx), + ).toThrow(/__consentChecked/); + }); + + it("passes when feature requiresConsent and binding carries __consentChecked brand", () => { + const container = new Container(); + const sym = Symbol("test.processData"); + const ctx = makeCtx(); + const base = withSpan( + ctx.tracer, + { name: "test.processData", op: "use-case" }, + withCapture( + ctx.logger, + { feature: "test", layer: "use-case" }, + async (x: number) => x, + ), + ); + attachBrand(base, "__consentChecked"); + container.bind(sym).toConstantValue(base); + + const manifest = defineFeature({ + name: "test", + requiredCores: [], + useCases: { + processData: { + mutates: false, + audits: [], + publishes: [], + consumes: [], + }, + }, + realtimeChannels: [], + jobs: [], + requiresConsent: ["analytics"], + } as const); + + expect(() => + assertFeatureConformance(container, manifest, { processData: sym }, ctx), + ).not.toThrow(); + }); + it("passes when analyticsEvents is empty and __analyzed brand is absent", () => { const container = new Container(); const sym = Symbol("test.signIn"); diff --git a/packages/core-shared/src/conformance/assert-bindings.ts b/packages/core-shared/src/conformance/assert-bindings.ts index 40d2750..b3abaaa 100644 --- a/packages/core-shared/src/conformance/assert-bindings.ts +++ b/packages/core-shared/src/conformance/assert-bindings.ts @@ -5,6 +5,7 @@ import { isCaptured, isAudited, isAnalyzed, + isConsentChecked, } from "./brand-runtime"; import { ConformanceError } from "./conformance-error"; import type { BindContext } from "../di/bind-context"; @@ -70,5 +71,12 @@ export function assertFeatureConformance( ); } } + if ((manifest.requiresConsent?.length ?? 0) > 0) { + if (!isConsentChecked(bound)) { + throw new ConformanceError( + `${manifest.name}.${name}: feature declares requiresConsent but binding is missing __consentChecked brand — was withConsent applied at bind time?`, + ); + } + } } } diff --git a/packages/core-shared/src/conformance/brand-runtime.ts b/packages/core-shared/src/conformance/brand-runtime.ts index 6b5fda7..c51998a 100644 --- a/packages/core-shared/src/conformance/brand-runtime.ts +++ b/packages/core-shared/src/conformance/brand-runtime.ts @@ -13,9 +13,14 @@ * commitment, not a mutable flag. */ -import type { Analyzed } from "./brands"; +import type { Analyzed, ConsentChecked } from "./brands"; -type Brand = "__instrumented" | "__captured" | "__audited" | "__analyzed"; +type Brand = + | "__instrumented" + | "__captured" + | "__audited" + | "__analyzed" + | "__consentChecked"; /** * Attaches the brand as a non-enumerable property on the given function. @@ -57,3 +62,9 @@ export function isAudited(fn: unknown): boolean { export function isAnalyzed(fn: unknown): fn is Analyzed { return hasBrand(fn, "__analyzed"); } + +export function isConsentChecked( + fn: unknown, +): fn is ConsentChecked { + return hasBrand(fn, "__consentChecked"); +} diff --git a/packages/core-shared/src/conformance/brands.ts b/packages/core-shared/src/conformance/brands.ts index 762e370..ed8190a 100644 --- a/packages/core-shared/src/conformance/brands.ts +++ b/packages/core-shared/src/conformance/brands.ts @@ -10,3 +10,4 @@ export type Instrumented = F & { readonly __instrumented: true }; export type Captured = F & { readonly __captured: true }; export type Analyzed = F & { readonly __analyzed: true }; +export type ConsentChecked = F & { readonly __consentChecked: true }; diff --git a/packages/core-shared/src/conformance/define-feature.ts b/packages/core-shared/src/conformance/define-feature.ts index 61fc2b3..04f330c 100644 --- a/packages/core-shared/src/conformance/define-feature.ts +++ b/packages/core-shared/src/conformance/define-feature.ts @@ -29,6 +29,14 @@ export type FeatureManifest = { readonly realtimeChannels: readonly string[]; readonly jobs: readonly string[]; readonly coverage?: CoverageManifest; + /** + * Consent categories this feature's use cases require before processing + * personal data. When non-empty, `assertFeatureConformance` requires every + * bound use case to carry the `__consentChecked` brand from `withConsent`. + * Defaults to `[]` — existing features with no consent requirements omit + * or declare an empty array. + */ + readonly requiresConsent?: readonly string[]; }; /** diff --git a/packages/core-shared/src/conformance/index.ts b/packages/core-shared/src/conformance/index.ts index 9bc3be6..e68672e 100644 --- a/packages/core-shared/src/conformance/index.ts +++ b/packages/core-shared/src/conformance/index.ts @@ -1,4 +1,9 @@ -export type { Instrumented, Captured, Analyzed } from "./brands"; +export type { + Instrumented, + Captured, + Analyzed, + ConsentChecked, +} from "./brands"; export type { FeatureManifest, UseCaseManifest } from "./define-feature"; export { defineFeature } from "./define-feature"; export type { @@ -23,6 +28,7 @@ export { isCaptured, isAudited, isAnalyzed, + isConsentChecked, } from "./brand-runtime"; export { ConformanceError } from "./conformance-error"; export { assertFeatureConformance } from "./assert-bindings"; diff --git a/packages/core-shared/src/instrumentation/with-span.ts b/packages/core-shared/src/instrumentation/with-span.ts index d7c752d..20df903 100644 --- a/packages/core-shared/src/instrumentation/with-span.ts +++ b/packages/core-shared/src/instrumentation/with-span.ts @@ -2,7 +2,12 @@ import type { ITracer, SpanOpts } from "./tracer.interface"; import type { Instrumented } from "../conformance/brands"; import { attachBrand } from "../conformance/brand-runtime"; -const PROPAGATED_BRANDS = ["__captured", "__audited", "__analyzed"] as const; +const PROPAGATED_BRANDS = [ + "__captured", + "__audited", + "__analyzed", + "__consentChecked", +] as const; export function withSpan( tracer: ITracer, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f31375a..6a5e34b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,6 +25,9 @@ importers: "@types/node": specifier: ^22.0.0 version: 22.19.17 + "@typescript-eslint/parser": + specifier: ^8.25.0 + version: 8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) fallow: specifier: ^2.73.0 version: 2.73.0 @@ -584,6 +587,31 @@ importers: specifier: ^3.0.0 version: 3.2.4(@types/debug@4.1.13)(@types/node@22.19.17)(happy-dom@20.8.9)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0) + packages/core-consent: + dependencies: + "@repo/core-shared": + specifier: workspace:* + version: link:../core-shared + devDependencies: + "@repo/core-eslint": + specifier: workspace:* + version: link:../core-eslint + "@repo/core-testing": + specifier: workspace:* + version: link:../core-testing + "@repo/core-typescript": + specifier: workspace:* + version: link:../core-typescript + "@vitest/coverage-v8": + specifier: ^3.0.0 + version: 3.2.4(vitest@3.2.4(@types/debug@4.1.13)(@types/node@25.5.2)(happy-dom@20.8.9)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0)) + typescript: + specifier: ^5.8.0 + version: 5.9.3 + vitest: + specifier: ^3.0.0 + version: 3.2.4(@types/debug@4.1.13)(@types/node@25.5.2)(happy-dom@20.8.9)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0) + packages/core-eslint: dependencies: globals: