feat(tracking): GA4 DSR delegation via the Admin API submitUserDeletion - #608
Merged
Conversation
…rDeletion spec-data-subject-rights §3 delegates per-visitor deletion to the platform rather than building a deletion store. buildGa4UserDeletionRequest returns the network-free request shape; submitGa4UserDeletion POSTs it through an injected transport with a caller-supplied bearer token (analytics.edit scope) — no OAuth or HTTP client baked in. Grounded against live docs, not the spec: the legacy v3 that DSR-V3 references was SUNSET with Universal Analytics. The current path is the Analytics Admin API properties.submitUserDeletion, keyed by a flat clientId — so DSR-V3's shape is stale and the spec needs updating. Verified: tracking build + full unit suite (211) green; 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
#609) Closes the spec-staleness finding from #608. The `spec-data-subject-rights` §3 bullet and the DSR-V3 conformance vector cited the **legacy v3** `userDeletionRequests:upsert` (`{ id: { type: "CLIENT_ID", userId } }`) — which was **sunset with Universal Analytics**. ## Changes (spec-only, no code) - **§3 GA4 bullet** — replace the v3 reference with the current **Analytics Admin API** `properties.submitUserDeletion` (migration doc link, `analytics.edit` scope, flat `clientId` as a oneof with `userId`/`appInstanceId`/`userProvidedData`). - **Posture + §5(c)** — "calls the Admin API `submitUserDeletion`" (was "the User Deletion API"). - **DSR-V3 vector prose** — now snapshots `POST .../v1alpha/properties/{propertyId}:submitUserDeletion` with body `{ "clientId": "<client_id>" }`; notes the legacy shape is retired. - **`vectors/data-subject-rights.json`** — `dsr.ga4.delete-binding` updated to `input: { clientId, propertyId }` → `expect.request: { body: { clientId }, url }`, matching the shipped `buildGa4UserDeletionRequest` (#608). - **spec CHANGELOG 1.0.1** — citation refresh. No package behavior change (spec docs are versioned independently via their own CHANGELOG). `oxfmt` clean; DSR vector is language-neutral reference (not wired into the TS test suite).
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 GA4 half of DSR delegation (spec-data-subject-rights §3): the system delegates per-visitor deletion to the platform and does not build a deletion store, request portal, or subject-data store (§5 minimal-but-sufficient posture).
What
buildGa4UserDeletionRequest({ propertyId, clientId })→ the network-free request shape (url + body), so it can be snapshotted/verified (spec vector DSR-V3).submitGa4UserDeletion({ propertyId, clientId, accessToken, transport? })→ POSTs it with a caller-supplied bearer token through an injected transport (defaultfetch). No OAuth flow or HTTP client baked in.Server-only (
@codefast/tracking/server). Keyed byclientId— the_ga-cookie id fromextractGaClientId(shipped in #607).The spec's DSR-V3 vector references the legacy v3
userDeletionRequests:upsertwith an{ id: { type: "CLIENT_ID", userId } }shape. That v3 API was sunset with Universal Analytics. The current path is the Analytics Admin APIproperties.submitUserDeletion(POST https://analyticsadmin.googleapis.com/v1alpha/properties/{id}:submitUserDeletion, OAuthanalytics.edit), keyed by a flatclientId(a oneof withuserId/appInstanceId/userProvidedData). This PR builds against the current API;spec-data-subject-rights§3 + DSR-V3 should be updated to match (flagged, not silently diverged).Deliberately not in this PR
onErasure(id)destination hook + withdrawal→erasure wiring (DSR-V2) and the Meta cookie-clear reference (DSR-V4) — a separate architectural increment; GA4's deletion is a server-side OAuth call, not a client-Destinationmethod, so wiring it speculatively has no clean call site yet.apps/uiactivation — needs a GA4 service-account bearer token + property id (infra the showcase lacks).Verification
@codefast/trackingfull unit suite: 211 passed (2 new: request-shape snapshot, authorized send via injected transport)tsc --noEmit+oxlint --deny-warnings+oxfmtcleanChangeset:
@codefast/trackingminor.