feat(tracking): TCF/GPP interop consent reconciler (spec-ad-consent-frameworks) - #612
Merged
Conversation
spec-ad-consent-frameworks: the system reads an external CMP and reconciles it with the
native { ads, analytics } decision — it never becomes a CMP or mints TC/GPP strings.
reconcileAdFrameworkConsent applies the §3 precedence (a governing CMP overrides its
categories; fail-closed to denied while loading; a missing or out-of-scope CMP leaves
native standing; GPC only tightens ads), covering vectors V1-V6. hasTcfApi/hasGppApi
detect the __tcfapi/__gpp read APIs without invoking them. TCF purpose ids and the Google
vendor id are deliberately not hard-coded — that mapping is ad-ops policy, so the caller
derives the CmpConsentSignal it passes in.
Verified: tracking full suite (227) green; apps/ui vite build unaffected; check-types +
oxlint + oxfmt clean.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Codecov Report❌ Patch coverage is
🚀 New features to boost your workflow:
|
thevuong
added a commit
that referenced
this pull request
Jul 18, 2026
…plemented (#613) Follow-up to #612. The TCF/GPP interop reconciler is now shipped, so the `spec-ad-consent-frameworks` status line is stale. ## Change (spec-only, non-normative) - **Status line** — "design target … not yet implemented" → notes that `@codefast/tracking` ships `reconcileAdFrameworkConsent` + `hasTcfApi`/`hasGppApi` (§3, vectors V1–V6), while deriving the `CmpConsentSignal` from a live CMP's `TCData`/GPP remains ad-ops policy the integrator supplies (§5). - **spec CHANGELOG 1.0.2** — status refresh (patch). The §3 contract and vectors are unchanged. ## Note (not in this PR) Other spec status lines are now similarly out of date after this session's implementation work and could be refreshed in a follow-up: `spec-destinations` §5 ("not yet implemented" — Meta/TikTok/UET references now shipped), `spec-consent-receipts` (signing + durable store shipped), `spec-data-subject-rights` ("partially met" — GA4 deletion + `onErasure` now shipped). Left out to keep this PR scoped. `oxfmt` clean; no code change.
thevuong
added a commit
that referenced
this pull request
Jul 20, 2026
This PR was opened by the [Changesets release](https://github.com/changesets/action) GitHub action. When you're ready to do a release, you can merge this and the packages will be published to npm automatically. If you're not ready to do a release yet, that's fine, whenever you add more changesets to main, this PR will be updated.⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ `main` is currently in **pre mode** so this branch has prereleases rather than normal releases. If you want to exit prereleases, run `changeset pre exit` on `main`.⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ # Releases ## @codefast/tracking@1.0.0-canary.7 ### Minor Changes - [#605](#605) [`cb46bdd`](cb46bdd) Thanks [@thevuong](https://github.com/thevuong)! - Adds the shared ad-destination frame for consuming one `{ ads, analytics }` decision across ad platforms (spec-destinations §5): `toAdConsentState(decision)` normalizes it to the two independent levers — `analytics` drives whether events transmit, `ads` drives Limited Data Use — so per-vendor mappings cannot drift. Ships a reference Meta destination (`createMetaDestination`, `toMetaDataProcessingOptions`) that maps each event and the live `ads` decision to Meta's `dataProcessingOptions` (geolocated LDU when `ads` is denied) and hands it to an injected `transport`. Consent-restriction mapping only — the Pixel/CAPI transport and credentials are the integrator's to supply; an ad sink is never `exempt`. - [#612](#612) [`1337fc3`](1337fc3) Thanks [@thevuong](https://github.com/thevuong)! - Adds the TCF/GPP interop reconciler (spec-ad-consent-frameworks): the system **reads** an external CMP and reconciles it with the native `{ ads, analytics }` decision — it never becomes a CMP or mints TC/GPP strings. `reconcileAdFrameworkConsent({ native, cmp, hasGlobalPrivacyControlSignal })` applies the §3 precedence (a governing CMP overrides its categories; fail-closed to denied while the CMP is loading; a missing or out-of-scope CMP leaves native standing; GPC only tightens `ads`), covering conformance vectors V1–V6. `hasTcfApi`/`hasGppApi` detect the `__tcfapi`/`__gpp` read APIs without invoking them. TCF purpose ids and the Google vendor id are deliberately **not** hard-coded — that mapping is ad-ops policy, so the caller derives the `CmpConsentSignal` it passes in. - [#615](#615) [`2bcd31f`](2bcd31f) Thanks [@thevuong](https://github.com/thevuong)! - Let `codefast mirror` generate `package.json#exports` from `dist/`, the same as every other library package (di, theme), instead of hand-curating them under mirror's preserve mode. The per-module build output is unchanged, so mirror emits a subpath for each built module, and the root becomes the client entry. Breaking: - The root `@codefast/tracking` is now the **client entry** — it re-exports the isomorphic core plus the whole browser surface (`createClientTracker`, `createConsentRuntime`, the React bindings, the gtag + ad-network destinations). Server code must import the core it needs from `@codefast/tracking/core/*`, not from the root. - The `./client`, `./server`, `./core`, `./react`, and `./destinations` group barrels are gone. Use the client root for browser code, or a module's own subpath for granular/server imports (`@codefast/tracking/server/initial-consent`, `@codefast/tracking/client/gpc`, …). - The TanStack Start adapter is now `@codefast/tracking/adapters/tanstack-start` (was `/tanstack-start`); the import-protection deny-list is `@codefast/tracking/tooling/import-protection` (was `/import-protection`). `SERVER_ONLY_SUBPATHS` now denies `server/**` and `adapters/**`. - [#617](#617) [`b979371`](b979371) Thanks [@thevuong](https://github.com/thevuong)! - Harden the package from a full audit — correctness, coverage, and a leaner public surface. - **`isConsentReceiptInput` now validates `method` and `subjectIdType` against their enums**, not just `typeof === "string"` — the untrusted-body guard no longer narrows a bogus value to a closed union member. - **`CookieAnonymousId` gains `current()`** — a non-minting read of the existing id (`undefined` when none) so a consent receipt stamps the id the visitor already carries instead of a throwaway that never correlates for erasure. - **`coarsenIp` rejects out-of-range IPv4 octets** (`"999.…"`) rather than storing a malformed coarse value. - **Microsoft UET consent routes through the shared `toAdConsentState` ad lever**, so its `ad_storage` mapping can't drift from Meta/TikTok. - **Dropped unused foreign type re-exports** so each type has one home: `InitialConsent` no longer re-exported from `adapters/tanstack-start` or `server/initial-consent` (import it from `core/consent`), and the `AnonymousIdResponseCookieOptions` alias is gone — `setAnonymousIdResponseCookie` takes `AnonymousIdCookieOptions` from `server/anonymous-id-cookie` directly. - **Collapsed the `TrackedEvent` envelope to a single interface** — the unused `TrackedEventBase` and `TrackEvent` names are gone (`TrackedEvent` keeps the `type: "track"` discriminant for a future additive union). Also adds test coverage for the previously-untested `recordConsentReceiptFromRequest` adapter path (no-store header, body-IP rejection, coarsened IP, PII-free ack). - [#617](#617) [`b979371`](b979371) Thanks [@thevuong](https://github.com/thevuong)! - Remove two leftover indirection layers in the server lane that no call site used. Breaking: - The `@codefast/tracking/adapters/request-context` subpath is gone. Its `RequestContext` seam (a `getHeader`/`setHeader` interface) plus the parallel `*FromContext`/`*OnContext` helpers existed only to back a hypothetical future `./next`/`./remix` adapter, but there was exactly one adapter and it duplicated every signature and doc comment. `@codefast/tracking/adapters/tanstack-start` now calls `getRequestHeader`/`setResponseHeader` directly; its public surface (`resolveInitialConsentFromRequest`, `setAnonymousIdResponseCookie`, `clearAnonymousIdResponseCookie`, `recordConsentReceiptFromRequest`) is unchanged. - `resolveRegion(headers)` is removed from `@codefast/tracking/server/region`. It was a pre-fail-closed leftover with no production call site, and its missing-geo semantics (unknown region → opt-out) contradicted the fail-closed invariant the server-first path relies on. Use `resolveRegionFromCountryCode` (what the production path already uses via `resolveInitialConsent`), or `resolveInitialConsentFromRequest` for the full per-request resolution. - [#606](#606) [`5a4ff42`](5a4ff42) Thanks [@thevuong](https://github.com/thevuong)! - Adds `createDurableReceiptStore({ backend })` — a durable `ReceiptStore` over an injected `ReceiptStoreBackend` (a minimal id-keyed `get`/`put` primitive). The package supplies the append-only contract and adaptation; the deployment supplies the backend client (Vercel KV, Postgres, an append-only log), so no database dependency is baked in. `put` MUST be idempotent-by-id so the append-only guarantee holds atomically under retries/concurrency (e.g. KV set-if-absent, Postgres `INSERT … ON CONFLICT DO NOTHING`) — the frame delegates rather than doing a racy get-then-put. Pair it with a real backend in production, where `createInMemoryReceiptStore` is not a lawful store on its own. - [#604](#604) [`7bb4be7`](7bb4be7) Thanks [@thevuong](https://github.com/thevuong)! - `createClientTracker` now accepts an optional `isExemptionAllowed` gate, consulted before an `exempt` destination receives an event while the consent gate is closed. ePrivacy audience-measurement exemption is jurisdiction-dependent (spec-destinations §2), so it must be gateable per region rather than assumed global — returning `false` withholds even exempt sinks where exemption is not defensible. Omit it to keep the prior behavior (exempt everywhere). The gate is irrelevant once consent is granted, since every destination then receives the full envelope. - [#608](#608) [`6cdd930`](6cdd930) Thanks [@thevuong](https://github.com/thevuong)! - Adds GA4 DSR delegation (spec-data-subject-rights §3): the system delegates per-visitor deletion to the platform rather than building a deletion store. `buildGa4UserDeletionRequest({ propertyId, clientId })` returns the network-free request shape and `submitGa4UserDeletion({ …, accessToken, transport? })` POSTs it — targeting the current **Analytics Admin API** `properties.submitUserDeletion` (the legacy v3 `userDeletionRequests:upsert` was sunset with Universal Analytics), keyed by a flat `clientId`. Authorization is the caller's: pass a bearer token for the `analytics.edit` scope; no OAuth or HTTP client is baked in. Server-only (`@codefast/tracking/server`). - [#617](#617) [`b979371`](b979371) Thanks [@thevuong](https://github.com/thevuong)! - Adopt TanStack Start's first-class server helpers in the adapter instead of hand-rolling over raw request/response headers. - The anonymous-id cookie is now written with `setCookie`/`deleteCookie` (from `@tanstack/react-start/server`) rather than `setResponseHeader("set-cookie", …)`. The raw header call **replaces** any existing `Set-Cookie` on the response — it would clobber a session or framework cookie set on the same response — whereas `setCookie` appends. No behavior change to the emitted cookie (still `Path=/; Max-Age=1y; SameSite=Lax; Secure`, not `HttpOnly`). - The connection IP for consent receipts is now read with `getRequestIP({ xForwardedFor: true })` — the maintained, platform-aware path — instead of hand-parsing `x-forwarded-for`/`x-real-ip`. Breaking (`@codefast/tracking/server/anonymous-id-cookie`): the string builders `buildAnonymousIdSetCookie`/`buildClearAnonymousIdSetCookie` are replaced by `resolveAnonymousIdCookie`/`resolveClearAnonymousIdCookie`, which return the validated name/value plus cookie attributes for a framework `setCookie`/`deleteCookie` call. `isValidAnonymousId` is unchanged; the cookie-name guard is now the exported `assertValidAnonymousIdCookieName`. - [#607](#607) [`749dd16`](749dd16) Thanks [@thevuong](https://github.com/thevuong)! - Adds server-side GA4 Measurement Protocol primitives for forwarding a **server-owned** event (re-added now that a consumer tracks one — a server-recorded consent decision): `sendMeasurementProtocolEvents` POSTs `{ client_id, events, consent? }` to the credentialed `/mp/collect` endpoint through an injected `transport` (default `fetch`), so no HTTP client or credentials are baked in; `extractGaClientId` derives the GA4 `client_id` from a `_ga` cookie; `toMeasurementProtocolConsent` maps the package `ConsentDecision` to the MP `consent` signals. Server-only (`@codefast/tracking/server`). The caller owns the credentials and the consent gate. - [#602](#602) [`5ca04e2`](5ca04e2) Thanks [@thevuong](https://github.com/thevuong)! - `createClientTracker` now accepts an optional `onDeliveryError` hook, called once per failed delivery (a destination throwing synchronously or rejecting) with `{ destination, error, event }`. The tracker still swallows the failure so tracking never breaks the interaction — the hook is a metering seam for wiring delivery failures to a monitor in production. The hook is itself guarded, so a throwing observer can't break the interaction either. Exposes the `DeliveryErrorContext` type from `@codefast/tracking/client`. - [#610](#610) [`fdb8d7c`](fdb8d7c) Thanks [@thevuong](https://github.com/thevuong)! - Adds the per-destination erasure capability for DSR withdrawal (spec-data-subject-rights §3, DSR-V2/V4): `Destination` gains an optional `onErasure(id)` hook, and `createClientTracker` returns an `erase(id)` method that invokes each destination's `onErasure` once on withdrawal, swallowing failures so a destination can never break the flow. The reference `createMetaDestination` implements `onErasure` as cookie-clear (via an injected `clearCookies` seam) plus stop-send — Meta exposes no per-visitor deletion API, so the binding never fabricates one. Destinations with nothing to erase omit the hook. - [#611](#611) [`a5527c3`](a5527c3) Thanks [@thevuong](https://github.com/thevuong)! - Extends the ad-destination frame with two more reference vendors (spec-destinations §5), consuming the same `{ ads, analytics }` decision: `createMicrosoftUetDestination` / `toMicrosoftUetConsent` map `ads` to UET's only enforced signal, `ad_storage` (UET has no `analytics_storage`); `createTiktokDestination` / `toTiktokConsent` map `ads` to TikTok's single `limited_data_use` boolean (not Meta's `dataProcessingOptions` structure). Both take an injected `transport` (no pixel id, tag id, or network client baked in) and implement `onErasure` as cookie-clear + stop-send, since neither exposes a per-visitor deletion API. `consentRequirement` stays `"required"`. ## @codefast/typescript-config@1.0.0-canary.7 ### Minor Changes - [`71cf664`](71cf664) Thanks [@thevuong](https://github.com/thevuong)! - Add a `library-build.json` preset holding the shared emit options for the native `tsc` package build (`noEmit: false`, `declaration`, `declarationMap`, `sourceMap`, `types: ["node"]`). Each package's `tsconfig.build.json` now uses array `extends` (`["./tsconfig.json", "@codefast/typescript-config/library-build.json"]`) and keeps only its local `outDir`/`rootDir` and `include` — dropping the duplicated emit block across all library packages. Path-relative options stay local because `extends` resolves them against the file that declares them. ## @codefast/ui@1.0.0-canary.7 ### Patch Changes - [#632](#632) [`44035ca`](44035ca) Thanks [@thevuong](https://github.com/thevuong)! - Annotate `CheckboxGroupItem`'s `onCheckedChange` callback with a `CheckedState` type derived from the underlying Radix primitive. Radix's `radix-ui` umbrella namespace merge dropped the contextual type for the inline callback parameter, tripping `noImplicitAny` during the build; deriving the type from `Root` keeps it in lockstep with the primitive. - Updated dependencies []: - @codefast/tailwind-variants@1.0.0-canary.7 ## @codefast/cli@1.0.0-canary.7 ## @codefast/di@1.0.0-canary.7 ## @codefast/tailwind-variants@1.0.0-canary.7 ## @codefast/theme@1.0.0-canary.7 ## @codefast/benchmark-di-inversify@1.0.0-canary.7 ### Patch Changes - Updated dependencies []: - @codefast/benchmark-harness@1.0.0-canary.7 - @codefast/benchmark-viewer@1.0.0-canary.7 - @codefast/di@1.0.0-canary.7 ## @codefast/benchmark-tailwind-variants@1.0.0-canary.7 ### Patch Changes - Updated dependencies []: - @codefast/benchmark-harness@1.0.0-canary.7 - @codefast/benchmark-viewer@1.0.0-canary.7 - @codefast/tailwind-variants@1.0.0-canary.7 ## @codefast/benchmark-viewer@1.0.0-canary.7 ### Patch Changes - Updated dependencies []: - @codefast/benchmark-harness@1.0.0-canary.7 - @codefast/tailwind-variants@1.0.0-canary.7 ## @codefast/benchmark-harness@1.0.0-canary.7 Co-authored-by: Vuong Phan <13298232+thevuong@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements the "read a CMP, don't become one" interop layer (spec-ad-consent-frameworks §1/§3). The system reads an external IAB CMP and reconciles it with the native
{ ads, analytics }model; it never becomes a certified CMP, mints TC/GPP strings, or ingests the Global Vendor List (the spec's explicit non-goal — no call site needs the system to mint strings).What
reconcileAdFrameworkConsent({ native, cmp, hasGlobalPrivacyControlSignal })→ the reconciled decision, applying §3 precedence:out-of-scope/gdprApplies=false(V6) CMP leaves native standing — never force-denies;ads, never loosen a CMP grant (V5); a GPP US sale/share opt-out setsads=falseand leaves first-partyanalytics(V4).hasTcfApi/hasGppApidetect the__tcfapi/__gppread APIs by function presence, without invoking them.Deliberately the caller's, per the spec
CmpConsentSignal(fromTCData/GPP over__tcfapi/__gpp) and passes it in; this package owns only the reconciliation.tcString+addtlConsentverbatim to Google tags is the integrator's (the reconciler never mints them).Verification
@codefast/trackingfull unit suite: 227 passed (7 new: vectors V1–V6 + API detection)pnpm --filter @apps/ui build— greencheck-types+oxlint --deny-warnings+oxfmtcleanNote:
spec-ad-consent-frameworks's status line still reads "not yet implemented" — the reconciler is now implemented; the CMP read + purpose mapping remain the integrator's. A follow-up spec status refresh could note that split.Changeset:
@codefast/trackingminor.