Skip to content

feat(tracking): GA4 DSR delegation via the Admin API submitUserDeletion - #608

Merged
thevuong merged 1 commit into
mainfrom
feat/tracking-ga4-user-deletion
Jul 18, 2026
Merged

feat(tracking): GA4 DSR delegation via the Admin API submitUserDeletion#608
thevuong merged 1 commit into
mainfrom
feat/tracking-ga4-user-deletion

Conversation

@thevuong

Copy link
Copy Markdown
Contributor

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 (default fetch). No OAuth flow or HTTP client baked in.

Server-only (@codefast/tracking/server). Keyed by clientId — the _ga-cookie id from extractGaClientId (shipped in #607).

⚠️ Spec correction (grounded against live docs, not recalled)

The spec's DSR-V3 vector references the legacy v3 userDeletionRequests:upsert with an { id: { type: "CLIENT_ID", userId } } shape. That v3 API was sunset with Universal Analytics. The current path is the Analytics Admin API properties.submitUserDeletion (POST https://analyticsadmin.googleapis.com/v1alpha/properties/{id}:submitUserDeletion, OAuth analytics.edit), keyed by a flat clientId (a oneof with userId/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

  • The generic 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-Destination method, so wiring it speculatively has no clean call site yet.
  • apps/ui activation — needs a GA4 service-account bearer token + property id (infra the showcase lacks).

Verification

  • @codefast/tracking full unit suite: 211 passed (2 new: request-shape snapshot, authorized send via injected transport)
  • tsc --noEmit + oxlint --deny-warnings + oxfmt clean

Changeset: @codefast/tracking minor.

…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.
@vercel

vercel Bot commented Jul 18, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
codefast-tanstack-start Ready Ready Preview, Comment Jul 18, 2026 9:43am
codefastlabs Ready Ready Preview, Comment Jul 18, 2026 9:43am

@codecov

codecov Bot commented Jul 18, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 42.85714% with 4 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
packages/tracking/src/server/user-deletion.ts 42.85% 3 Missing and 1 partial ⚠️
Files with missing lines Coverage Δ
packages/tracking/src/server/user-deletion.ts 42.85% <42.85%> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@thevuong
thevuong merged commit 6cdd930 into main Jul 18, 2026
10 checks passed
@thevuong
thevuong deleted the feat/tracking-ga4-user-deletion branch July 18, 2026 09:44
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant