feat(core-consent): extend generator with consent template + fix withCapture brand propagation

- Add consent to CORE_PACKAGE_GENERATORS in turbo/generators/config.ts so
  pnpm turbo gen core-package consent is a valid command (not hand-rollable)
- Create turbo/generators/templates/core-package/consent/ mirroring the
  analytics template shape (AGENTS.md, package.json, tsconfig, turbo, vitest,
  eslint, src/index.ts scaffolds)
- Regenerate packages/core-consent/ from the new template (replaces the
  previous hand-rolled attempt that violated the generator-first rule)
- Add __consentChecked to withCapture PROPAGATED_BRANDS so the brand bubbles
  through the full withSpan→withCapture wrapper chain to the outermost binding
  that assertFeatureConformance reads

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-19 11:01:17 +00:00
parent 9cb2fa321c
commit b992fee088
14 changed files with 150 additions and 25 deletions

View File

@@ -9,6 +9,7 @@ const nextConfig = {
"@repo/core-api", "@repo/core-api",
"@repo/core-audit", "@repo/core-audit",
"@repo/core-cms", "@repo/core-cms",
"@repo/core-consent",
"@repo/core-shared", "@repo/core-shared",
"@repo/marketing-pages", "@repo/marketing-pages",
"@repo/media", "@repo/media",

View File

@@ -1,6 +1,6 @@
{ {
"generatedAt": "2026-05-19T10:39:50.009Z", "generatedAt": "2026-05-19T11:00:08.934Z",
"commit": "f5d08dc", "commit": "9cb2fa3",
"repo": { "repo": {
"statements": 96.5, "statements": 96.5,
"branches": 91.82, "branches": 91.82,

View File

@@ -1,6 +1,6 @@
# @repo/core-consent # @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). Optional core package providing a vendor-neutral consent management interface. Scaffold via `pnpm turbo gen core-package consent`.
## Structure ## Structure
@@ -21,7 +21,7 @@ src/
- `withdraw(category)` — record consent withdrawal for a category - `withdraw(category)` — record consent withdrawal for a category
- `getCategories()` — list all known consent states - `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). 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`.
`withConsent` wraps a use-case factory at bind time, attaches the `__consentChecked` brand, and is the innermost wrapper in the composition chain: `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)` `withSpan → withCapture → withAudit → withAnalytics → withConsent → factory(deps)`

View File

@@ -5,19 +5,16 @@ import { attachBrand } from "@repo/core-shared/conformance";
export type { ConsentChecked }; export type { ConsentChecked };
/** /**
* Use-case wrapper applied at DI bind time. The wrapper is a thin closure * Use-case wrapper applied at DI bind time. Attaches the `__consentChecked`
* that forwards to `fn` unchanged and carries the `__consentChecked` brand. * brand so the boot-time assertion can verify consent-gated use cases were
* bound through the consent-aware path.
*
* The forward closure keeps the brand on a fresh function so the original * 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 * `fn` reference is not mutated — important when the same factory output is
* used elsewhere unwrapped (dev-seed paths, tests). * used elsewhere unwrapped (dev-seed paths, tests).
* *
* Composition order (innermost to outermost): * Composition order (innermost to outermost):
* withSpan → withCapture → withAudit → withAnalytics → withConsent → factory(deps) * 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<Args extends unknown[], R>( export function withConsent<Args extends unknown[], R>(
consent: IConsent, consent: IConsent,

View File

@@ -1,15 +1,9 @@
import path from "node:path"; import path from "node:path";
import { defineConfig, mergeConfig } from "vitest/config"; import { mergeConfig } from "vitest/config";
import { nodeVitestConfig } from "@repo/core-typescript/vitest.base.node"; import { nodeVitestConfig } from "@repo/core-typescript/vitest.base.node";
export default mergeConfig( export default mergeConfig(nodeVitestConfig, {
nodeVitestConfig,
defineConfig({
test: {
include: ["src/**/*.test.ts"],
},
resolve: { resolve: {
alias: { "@": path.resolve(__dirname, "./src") }, alias: { "@": path.resolve(__dirname, "./src") },
}, },
}), });
);

View File

@@ -32,6 +32,7 @@ export function withCapture<Args extends unknown[], R>(
"__instrumented", "__instrumented",
"__audited", "__audited",
"__analyzed", "__analyzed",
"__consentChecked",
] as const; ] as const;
const wrapped: (...args: Args) => Promise<R> = async (...args) => { const wrapped: (...args: Args) => Promise<R> = async (...args) => {

View File

@@ -782,17 +782,40 @@ import noRealtimeHandlerReexport from "./rules/no-realtime-handler-reexport.js";
}, },
printAnalyticsNextSteps, printAnalyticsNextSteps,
], ],
consent: () => [
() => {
assertOptionalPackageNotPresent("core-consent");
return "Guard passed — packages/core-consent does not exist yet.";
},
...emitTemplateTree("core-package/consent", "packages/core-consent"),
() => {
addToTranspilePackages(
"apps/web-next/next.config.mjs",
"@repo/core-consent",
);
return "Added @repo/core-consent to transpilePackages.";
},
printConsentNextSteps,
],
}; };
plop.setGenerator("core-package", { plop.setGenerator("core-package", {
description: description:
"Scaffold an optional core package (realtime, events, trpc, ui, audit, analytics)", "Scaffold an optional core package (realtime, events, trpc, ui, audit, analytics, consent)",
prompts: [ prompts: [
{ {
type: "list", type: "list",
name: "name", name: "name",
message: "Which optional core package?", message: "Which optional core package?",
choices: ["analytics", "audit", "events", "realtime", "trpc", "ui"], choices: [
"analytics",
"audit",
"consent",
"events",
"realtime",
"trpc",
"ui",
],
}, },
], ],
actions: (answers) => { actions: (answers) => {
@@ -1517,6 +1540,30 @@ function printAnalyticsNextSteps(): string {
].join("\n"); ].join("\n");
} }
function printConsentNextSteps(): string {
return [
"─────────────────────────────────────────────────────────────",
"@repo/core-consent scaffolded into packages/core-consent/.",
"",
"Next steps:",
"",
" 1. pnpm install # link the new workspace package",
"",
" 2. Implement consent-types.ts, consent.interface.ts, and withConsent",
" in packages/core-consent/src/ (see story 03-core-consent-foundation)",
"",
" 3. Add @repo/core-consent to feature package.json files that need consent",
"",
" 4. Wire IConsent into apps/web-next/src/server/bind-production.ts:",
' - import { type IConsent } from "@repo/core-consent";',
" - add consent field to BindAllDeps and BindProductionContext",
" - pass the consent instance into each feature binder",
"",
" 5. pnpm typecheck && pnpm lint && pnpm test",
"─────────────────────────────────────────────────────────────",
].join("\n");
}
function coreUiComponentActions(a: { function coreUiComponentActions(a: {
tier: "atom" | "molecule" | "organism"; tier: "atom" | "molecule" | "organism";
name: string; name: string;

View File

@@ -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`.
## 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`.
`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.

View File

@@ -0,0 +1,3 @@
import baseConfig from "@repo/core-eslint/base";
export default baseConfig;

View File

@@ -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"
}
}

View File

@@ -0,0 +1,2 @@
// placeholder — populated by story 03-core-consent-foundation
export {};

View File

@@ -0,0 +1,12 @@
{
"extends": "@repo/core-typescript/base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["**/*.ts"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -0,0 +1,4 @@
{
"extends": ["//"],
"tags": ["core"]
}

View File

@@ -0,0 +1,9 @@
import path from "node:path";
import { mergeConfig } from "vitest/config";
import { nodeVitestConfig } from "@repo/core-typescript/vitest.base.node";
export default mergeConfig(nodeVitestConfig, {
resolve: {
alias: { "@": path.resolve(__dirname, "./src") },
},
});