diff --git a/crates/trusted-server-cli/src/commands/dev/lint/domains.rs b/crates/trusted-server-cli/src/commands/dev/lint/domains.rs index 8978af61c0..28c07c5b36 100644 --- a/crates/trusted-server-cli/src/commands/dev/lint/domains.rs +++ b/crates/trusted-server-cli/src/commands/dev/lint/domains.rs @@ -46,19 +46,25 @@ pub const EXACT_HOSTS: &[&str] = &[ "sdk.privacy-center.org", // sourcepoint "cdn.privacy-mgmt.com", + "cdn-eu.privacy-mgmt.com", // lockr "aim.loc.kr", "identity.loc.kr", // datadome "js.datadome.co", "api-js.datadome.co", + "api-fastly.datadome.co", // aps / Amazon "aax.amazon-adsystem.com", "aax-events.amazon-adsystem.com", + "client.aps.amazon-adsystem.com", + "web.ads.aps.amazon-adsystem.com", // permutive "api.permutive.com", "secure-signals.permutive.app", "cdn.permutive.com", + "events.permutive.app", + "sync.permutive.com", // Google Tag Manager / Analytics "www.googletagmanager.com", "www.google-analytics.com", @@ -70,6 +76,13 @@ pub const EXACT_HOSTS: &[&str] = &[ "cdn.prebid.org", // Fastly platform "api.fastly.com", + // inmobi: the Choice consent prompt script and its CDN + "cmp.inmobi.com", + "cdn.inmobi.com", + // ad-tag.google: the AdSense loader + "pagead2.googlesyndication.com", + // permission-signal.mtm: the Model Terms document + "m4ow.uk", ]; /// Hosts where exact match AND any subdomain (`*.host`) is allowed. @@ -116,6 +129,8 @@ pub const REFERENCE_HOSTS: &[&str] = &[ "rslstandard.org", // Specs (other) "webassembly.org", + // IETF RFCs + "www.rfc-editor.org", // Fastly docs "www.fastly.com", "developer.fastly.com", @@ -125,6 +140,8 @@ pub const REFERENCE_HOSTS: &[&str] = &[ // Vendor docs "docs.datadome.co", "docs.prebid.org", + "support.google.com", + "developers.google.com", // Tooling docs "vitepress.dev", "playwright.dev", @@ -1555,6 +1572,15 @@ fn path_is_scanned(rel_path: &str) -> bool { { return false; } + // A module crate's own fixtures, the page its middleware is tested on + // and the page it records: exactly crates///src/fixtures/. + if components.len() > 5 + && components[0] == "crates" + && components[3] == "src" + && components[4] == "fixtures" + { + return false; + } let basename = components.last().copied().unwrap_or(""); // Excluded lockfiles (exact basename). Name comparisons below are @@ -2588,6 +2614,9 @@ mod path_is_scanned_tests { "crates/integration-tests/fixtures/frameworks/nextjs/app/page.tsx", "crates/integration-tests/fixtures/frameworks/nextjs/Dockerfile", "crates/integration-tests/fixtures/frameworks/wordpress/Dockerfile", + "crates/cmp/inmobi/src/lib.rs", + "crates/cmp/inmobi/fixtures/page.html", + "crates/trusted-server-cli/src/fixtures/page.html", "README.md", "CHANGELOG.md", "CONTRIBUTING.md", @@ -2607,6 +2636,8 @@ mod path_is_scanned_tests { for p in [ "crates/trusted-server-core/src/integrations/nextjs/fixtures/inlined-data-escaped.html", "crates/trusted-server-core/src/integrations/google_tag_manager/fixtures/captured.html", + "crates/cmp/inmobi/src/fixtures/page-change.input.html", + "crates/testing/testlight/src/fixtures/page-change.settings.toml", "node_modules/foo.js", ".worktrees/x/y.rs", ".claude/worktrees/x/y.rs", diff --git a/docs/superpowers/specs/2026-05-18-check-domains-design.md b/docs/superpowers/specs/2026-05-18-check-domains-design.md index 4ab3c49903..72020eb56d 100644 --- a/docs/superpowers/specs/2026-05-18-check-domains-design.md +++ b/docs/superpowers/specs/2026-05-18-check-domains-design.md @@ -284,19 +284,22 @@ a legitimate documentation source we link to repeatedly?" Integration proxies and loopback. Subdomains are **not** allowed (e.g., `anything.api.privacy-center.org` is disallowed). -| Category | Hosts | -| ---------------------------------------------------- | ------------------------------------------------------------------------------ | -| Loopback | `127.0.0.1`, `::1`, `localhost` | -| Integration proxies (didomi) | `api.privacy-center.org`, `sdk.privacy-center.org` | -| Integration proxies (sourcepoint) | `cdn.privacy-mgmt.com` | -| Integration proxies (lockr) | `aim.loc.kr`, `identity.loc.kr` | -| Integration proxies (datadome) | `js.datadome.co`, `api-js.datadome.co` | -| Integration proxies (aps / Amazon) | `aax.amazon-adsystem.com`, `aax-events.amazon-adsystem.com` | -| Integration proxies (permutive) | `api.permutive.com`, `secure-signals.permutive.app`, `cdn.permutive.com` | -| Integration proxies (Google Tag Manager / Analytics) | `www.googletagmanager.com`, `www.google-analytics.com`, `analytics.google.com` | -| Integration proxies (adserver mock) | `securepubads.g.doubleclick.net`, `origin-mocktioneer.cdintel.com` | -| Integration proxies (Prebid CDN) | `cdn.prebid.org` | -| Integration proxies (Fastly platform) | `api.fastly.com` | +| Category | Hosts | +| ---------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | +| Loopback | `127.0.0.1`, `::1`, `localhost` | +| Integration proxies (didomi) | `api.privacy-center.org`, `sdk.privacy-center.org` | +| Integration proxies (sourcepoint) | `cdn.privacy-mgmt.com`, `cdn-eu.privacy-mgmt.com` | +| Integration proxies (lockr) | `aim.loc.kr`, `identity.loc.kr` | +| Integration proxies (datadome) | `js.datadome.co`, `api-js.datadome.co`, `api-fastly.datadome.co` | +| Integration proxies (aps / Amazon) | `aax.amazon-adsystem.com`, `aax-events.amazon-adsystem.com`, `client.aps.amazon-adsystem.com`, `web.ads.aps.amazon-adsystem.com` | +| Integration proxies (permutive) | `api.permutive.com`, `secure-signals.permutive.app`, `cdn.permutive.com`, `events.permutive.app`, `sync.permutive.com` | +| Integration proxies (Google Tag Manager / Analytics) | `www.googletagmanager.com`, `www.google-analytics.com`, `analytics.google.com` | +| Integration proxies (adserver mock) | `securepubads.g.doubleclick.net`, `origin-mocktioneer.cdintel.com` | +| Integration proxies (Prebid CDN) | `cdn.prebid.org` | +| Integration proxies (Fastly platform) | `api.fastly.com` | +| Integration proxies (inmobi) | `cmp.inmobi.com`, `cdn.inmobi.com` | +| Integration proxies (Google ad tag) | `pagead2.googlesyndication.com` | +| Integration proxies (Model Terms signal) | `m4ow.uk` | ### Subdomain-permitting hosts (`SUBDOMAIN_HOSTS`) @@ -335,9 +338,10 @@ suppress per-line. | IAB Tech Lab | `iab.com`, `iabtechlab.com`, `iabtechlab.github.io`, `iabeurope.github.io` | | Specs (supply chain) | `in-toto.io`, `rslstandard.org` | | Specs (other) | `webassembly.org` | +| IETF RFCs | `www.rfc-editor.org` | | Fastly docs | `www.fastly.com`, `developer.fastly.com`, `manage.fastly.com` | | Cloudflare docs | `developers.cloudflare.com` | -| Vendor docs | `docs.datadome.co`, `docs.prebid.org` | +| Vendor docs | `docs.datadome.co`, `docs.prebid.org`, `support.google.com`, `developers.google.com` | | Tooling docs | `vitepress.dev`, `playwright.dev`, `testcontainers.com`, `grafana.com`, `docsearch.algolia.com` | One-off references not on this list (e.g., a single arxiv.org link in @@ -562,6 +566,15 @@ fenced code blocks. `crates/integration-tests/fixtures/frameworks/nextjs/app/*.tsx`, which we deliberately scan). +- **A module crate's own fixtures**, `crates///src/fixtures/**`. + A module in a crate of its own under `crates///` (the layout + the seam design gives) keeps the page its middleware is tested on, and the + page it records, under `src/fixtures/`. Those pages carry the vendor's own + script hosts and other vendors' tags the test proves untouched, as the + publisher captures above do. The rule is the two-level crate layout + exactly, so `crates//src/fixtures/` and + `crates/integration-tests/fixtures/...` stay scanned. + **Source files under `crates/integration-tests/fixtures/frameworks/*` — including `.tsx`, `.ts`, `.json`, `next.config.mjs`, `Dockerfile` — ARE scanned.** Only the publisher-capture path above is excluded. diff --git a/docs/superpowers/specs/2026-07-30-client-cycle-ec-resolve-design.md b/docs/superpowers/specs/2026-07-30-client-cycle-ec-resolve-design.md new file mode 100644 index 0000000000..ef6454bde2 --- /dev/null +++ b/docs/superpowers/specs/2026-07-30-client-cycle-ec-resolve-design.md @@ -0,0 +1,542 @@ +# Design Spec: Client-Cycle Edge Cookie Modules and the Resolve Endpoint + +**Status:** Proposed. The implementation is the series branch +`split/4-client-resolve`, carried by pull request +IABTechLab/trusted-server#1346, and is not merged to main. +The anti-replay reservation of §3.9 and the vendor envelope verification it +serves are deliberately not in v1. They land with the first real vendor +scheme, which brings the concrete envelope format the reservation design must +fit. §8 compares v1 with the 2026-07-31 draft and says why the feature is +normative rather than deferred. +**Author:** Engineering +**Issue references:** #778 (series), successor spec of the 2026-07-31 draft +**Related specs:** `2026-07-30-pluggable-providers-design.md`, +`2026-07-30-permission-model-design.md` +**Related PRs:** IABTechLab/trusted-server#1346 (Tech Lab pull request) carries the +implementation. IABTechLab/trusted-server#1345 (Tech Lab pull request) carries the +permission model that the implementation builds on. +IABTechLab/trusted-server#1342 holds every design document of the stack. +**Last updated:** 2026-10-16 + +> **Why this spec exists.** A client-cycle Edge Cookie module lets a browser +> post a value to a public endpoint (`POST /_ts/api/v1/ec/resolve`), and the +> endpoint turns that value into the first-party `ts-ec` cookie. The value +> comes from outside the edge, so the endpoint needs a threat model before it +> has an implementation. This spec is that threat model and the requirements +> that follow from it, each marked with what the code on the series branch +> does. + +--- + +## 1. Overview + +A **client-cycle** EC module establishes the identifier via a browser +round trip, where server-injected first-party JS obtains or derives a value in the +page (typically a signed envelope from a vendor identity system), posts it to +a Trusted Server endpoint, and the endpoint, after module-specific +verification, sets the first-party `ts-ec` cookie. + +A client-cycle module differs from a server-side module in one +security-critical way, which is that **the identifier is +attacker-influenceable input**, not server-derived evidence. Everything in +this spec follows from that. + +This feature is normative in the series rather than deferred because the first +vendor integration this project targets works client-side by design, since the +page script talks to the vendor's identity system and hands the result to +the edge, so the server-side path alone cannot carry it. That integration is a +planned vendor module, and v1 builds the endpoint that module will verify +against. + +## 2. Threat model + +| Threat | Vector | Consequence if unmitigated | +| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Cross-site identity fixation** | `text/plain` POST is a CORS-simple request: any page on the web can `fetch(resolveUrl, {method: "POST", credentials: "include", body: payload})` with no preflight | An attacker pins a chosen identity onto a victim's first-party cookie jar, a login-CSRF for the ad-identity layer, and the victim's activity accretes to an attacker-controlled ID | +| **Replay** | A captured valid payload (from the attacker's own session or a leak) replayed against another browser | Same as fixation, without needing to create payloads | +| **Phantom identity** | Endpoint sets the cookie without an identity-graph row | Later requests carry an EC that the KV graph has never seen. Downstream sync and withdrawal logic operate on an identity that half-exists (the organic generation path explicitly refuses to write a cookie when the graph write fails, for exactly this reason) | +| **Un-tombstoneable identity** | Core does not recognize the module's identifier shape | Withdrawal cannot expire or tombstone the identity, which is a compliance failure and not only a bug | +| **Amplification** | The page script cannot observe an HttpOnly cookie, so it cannot know the cookie is already set | A POST on every page view of every session, for example from a script that gates on reading a cookie its own server marked HttpOnly, which makes the guard permanently false | + +## 3. Requirements on the endpoint + +`POST /_ts/api/v1/ec/resolve` MUST: + +1. **Reject cross-site requests with an origin check.** Implemented. v1 + authorizes a request only when its `Origin` header is the same origin as an + accepted origin under RFC 6454, meaning the scheme, host and port triple of + RFC 6454 section 4 compared by the rule of its section 5, with a missing + port standing for the scheme's default, and with a value that is not a + serialized origin under its section 6.2 never matching. The default + accepted set is the single origin `https://{publisher.domain}` and nothing + else, so a sibling subdomain, the `http://` scheme and a non-default port + are all refused. A missing, unreadable or foreign `Origin`, the opaque value + `null` included, is rejected with `403`. Browsers always send `Origin` on + POST `fetch`, so its absence means a non-browser caller, which has no + business on a page-script endpoint. + + An operator may accept further origins with `[ec] resolve_allowed_origins`, + a list that is empty by default, for a publisher whose pages are served + from `www` or from another domain. Each entry is compared by the same RFC + 6454 rule and must itself be a serialized origin, being `http://` or + `https://` and a host with an optional port, and nothing after it. An entry + with a path (a trailing slash included), a query, a fragment, a user name or + another scheme could never match a request, so it is refused when the + settings load and again when the configuration is validated for deploy, + with a message naming the entry. The list is exact and there is no suffix + match on `publisher.domain`, because a suffix match would admit every + subdomain of the apex, including ones the publisher may not control, and + the Edge Cookie is scoped to the apex. + + The origin check is defense in depth and not the primary control. The + primary control is the module's envelope verification with audience and + session binding, which §3.9 parks and which still has to land with the + vendor scheme. + +2. **Verify the payload per module.** The endpoint hands the posted payload to + the selected module's `resolve_from_client` and creates only what the module + returns. Whether the payload is trustworthy is the module's responsibility, + stated on the trait. A real vendor module verifies a signed, audience-bound, + expiring envelope. The draft's session-binding and replay analysis (see + §3.9) is the bar that verification must clear when the vendor scheme lands. + The demo module verifies a fixed constant and is compiled in only under the + `client-fixed-demo` feature and in test builds (§5). `resolve_from_client` + is normative in v1, with a no-op default, so a server-side module needs no + change of its own. Deployments that select a server-side module are still + affected in other ways, which the list after the table of checks gives. + +3. **Preserve the identity-graph invariant.** Implemented, with differences + from organic creation that bear on withdrawal. The graph row is written + before the cookie is set, under the key made from the module's `{code}~` + prefix and its `normalize_id_for_kv` canonical form, which is the key the + organic path uses. + - The write is `create_or_revive`, where organic creation uses + `create_if_absent`. With no row for the key, both write one. With a live + row, resolve writes nothing, so the row keeps its original expiry while + the cookie and the marker are issued again for a full year. With a + withdrawal tombstone (the row a withdrawal leaves behind for 24 hours, + marked as not consented, which batch sync reads to refuse partner + updates), resolve overwrites the tombstone with a live row and deletes the + record that marks the withdrawal as finished. Resolve revives a tombstone + whenever the requester's resolved permissions pass the gate. Organic + creation never revives one, because it discards a candidate whose key + already has a row and asks the module for another. Whether resolve should + revive a tombstone is an open question (§7.7). + - With no graph available the endpoint creates nothing, sets no cookie and + answers `204`. Resolve is stricter than organic creation, which with no + graph still commits the identifier for the rest of the request and skips + only the row write, although its response then carries no cookie. + - A graph write failure answers `503`, with no cookie. + - The new row holds the same fields organic creation stores, taken from the + resolve request itself. The fields are the raw TCF and GPP consent strings + that a permission signal module accepted, the country, region, network + number (ASN) and metro code from the geo lookup, the publisher domain, and + the device class signals the adapter derived for the request. + +4. **Round-trip through the lifecycle contract.** Implemented. Read-back goes + through the selected module's `accepts_id`, the KV key through + `normalize_id_for_kv`, and withdrawal finds the row through the owning + module's key, as it does for any other identity. A round-trip test drives + an opaque client identifier through organic deferral, resolve, cookie set, + and verbatim read-back, using a test-only module that overrides + `accepts_id`. The demo module `client_fixed` accepts its own fixed word, + and a test withdraws its identity (§5). For 24 hours after a withdrawal a + later resolve can revive the row (item 3). + +5. **Exist on every adapter, where parity means identical behavior, including + identical refusal.** Partially implemented, documented. + - The Fastly adapter routes `POST` on the path in every deployment, + whichever module is selected. The adapter passes the same bot-gated identity + graph as organic generation, so unrecognized clients cannot create through + resolve either. A test pins that a POST with no `Origin` reaches the + handler and gets its `403`. + - The Axum, Cloudflare and Spin adapters deliberately do not route the path, + matching `identify` and `batch-sync`, which need platform KV those + adapters do not wire. A POST to the path there falls through to the + integration proxy and the publisher fallback like any unmatched path, so + the posted value goes to the publisher origin. The only record is a + comment in the Spin adapter's route list, which covers all three, and no + test pins the non-routing. + - Nothing rejects a client-cycle selection at startup on those three + adapters. The integration registry in core adds the page script to the + bundle by module name whichever adapter runs, so the bundle carries the + script on those adapters too, and the script posts to a path they send to + the origin. + - The draft's stronger ask, identical startup rejection of the client-cycle + selection on adapters that cannot serve it, is the agreed follow-up when + the portability adapters gain KV (§7.4). + +6. **Be uncacheable and permission-gated on the module's full declaration.** + Implemented. Every response the handler builds carries + `Cache-Control: no-store`. An error that propagates out of the handler + becomes the adapter's own error response instead, which on Fastly is built + with a plain-text body and no `Cache-Control` header of its own. The gate is + the selected module's complete `required_permissions()` through the same + resolved permission state as organic creating, not a hard-coded storage + check. With no module selected the gate is closed. + +7. **Bound every input.** Implemented. The request body is at most 65,536 + bytes, where an advertised `Content-Length` over the limit answers `413` + before the read, and the read body is re-checked so a missing or false + length does not bypass the bound. The `Content-Type` allowlist is + `text/plain` and `application/json`, matched on the media type alone, + case-insensitively, ignoring parameters (the browser's default + `text/plain;charset=UTF-8` passes). A missing type or any other type answers + `415`. Core first wraps the identifier the module returns in the module's + registered code (`provider-code-registry.md`), so the cookie and the + identity-graph key carry `{code}~value` and a client-set identity is + namespaced to its module exactly like an edge-created one (the demo's cookie + value is `cfix~an-ec`). The global identifier bounds then apply to the + wrapped identifier. The wrapped identifier must be at most 256 bytes, which + include the five bytes of `{code}~`, and use only the cookie-safe alphabet + of letters, digits and the characters `.`, `_`, `~` and `-`, the same bounds + as every other create path. A violation answers `400` and the identifier is never + rewritten. The answers, and the order of the checks that give them, are in + the table after item 9. + +8. **Define behavior against an existing identity, with no silent + replacement.** Implemented. "The same identity" means the same cookie value, + compared as written with the `{code}~` envelope on both sides, and not + through `normalize_id_for_kv`. Resolving to the same identity writes nothing + to a live row and issues the cookie and the marker again. Resolving to a + different identity while the request carries an identity the selected module + owns is rejected with `409`. A `ts-ec` the selected module does not own is + not an existing identity, so a resolve replaces it, which is the module + switch of §4. Any legitimate re-identification flow (account link, vendor + migration) is an explicit linking design this spec does not authorize. + +9. **Replay consumption and graph persistence as one sequence that is safe to + repeat.** Not in v1, by decision rather than omission. The draft's + reservation design (a reservation written to a single key only if that key + is absent, with an owner hash, lease epochs, fenced transitions, and a + family epoch that makes a revocation win over a resolve) presupposes a + payload with a unique id, a session binding, and a validity window, which + are properties of a concrete vendor envelope format that does not exist yet. + The design also presupposes a reservation record (a state, an owner hash and + a lease epoch, written under the payload's unique id) that nothing in core + writes today. The conditional storage write is not what is missing. The Fastly + adapter's store, the only production store, already writes a key only if + the key is absent (`EcKvWriteMode::Add`) or only if the stored generation + still matches (`IfGenerationMatch`), and core already uses the second to + revive a withdrawn row (`create_or_revive`). Designing the reservation + against a hypothetical envelope would repeat the mistake this series exists + to fix. v1's stance is that the endpoint is safe without it for the modules + v1 ships (the demo creates a constant and is compiled out of production + builds), and the reservation lands with the first vendor scheme, designed + against its real envelope, with the draft's §3.9 as the starting bar. Until + then the draft's text is preserved below as the agreed requirement. + +The 2026-07-31 draft's §3.9 reservation requirement is retained verbatim as +the bar for the vendor-scheme implementation: + +> Neither naive order works: consume-the-nonce-first makes a subsequent +> graph failure unretryable (the token is spent, the identity never +> existed); graph-first lets the losers of a replay race leave residual +> rows. Required shape: consumption is an atomic single-key reservation +> (CAS) keyed by the payload's unique id, with explicit states: `pending` → +> `committed` | `failed`, each carrying an owner hash (the session binding) +> and a monotonic lease epoch. Takeover of an expired `pending` lease +> increments the epoch, and every state transition is a fenced CAS on +> (state, epoch). `failed` is retryable by the same owner at a higher +> epoch. The graph write happens under the reservation and is deterministic +> under its key. A duplicate must never receive the cookie unless it proves +> the original session binding; a requester matching the reservation's +> owner hash has the `Set-Cookie` re-emitted (lost-response recovery), +> anyone else gets a terminal response with no cookie. The same-identity +> no-op first checks the family revocation record, and "revocation wins" is +> enforced by a CAS conditioned on the family epoch read at the start. + +### Order of checks + +The handler runs these checks in this order and answers at the first one that +fails. An adapter that serves the endpoint must give the same answers in the +same order. Every answer the handler builds carries `Cache-Control: no-store`. + +| Step | Check | Answer | +| ---- | ---------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | +| 1 | `Origin` is missing, unreadable or not an accepted origin | `403` | +| 2 | `Content-Type` is missing, or is not `text/plain` or `application/json` | `415` | +| 3 | The selected module's required permissions are not all set, or no module is selected | `204`, no cookie | +| 4 | An advertised `Content-Length`, or the body once read, is over 65,536 bytes | `413` | +| 5 | The module's `resolve_from_client` returns an error, or the module asks for a response header inside core's reserved surface | The adapter's error response, which is `500` for an Edge Cookie error | +| 6 | The module returns no identifier, as when it cannot verify the payload | `204`, no Edge Cookie, with the module's own response headers | +| 7 | The wrapped identifier is over 256 bytes or outside the alphabet | `400` | +| 8 | The request carries a different identity that the selected module owns | `409` | +| 9 | No identity graph is available, because `[ec] ec_store` is not set or the client is not recognized as a browser | `204`, no cookie | +| 10 | The graph write fails | `503`, no cookie | +| 11 | None of the above | `200`, with `ts-ec`, `ts-ecr=1` and any response headers the module asked for | + +The code tests the gate and then the module, and a request with no module +selected has a closed gate, so both give the answer of step 3. A module's +response headers may not set a cookie whose name starts with `ts-`, a header +whose name starts with `x-ts-`, or any of `cache-control`, `connection`, +`content-length`, `keep-alive`, `proxy-authenticate`, `proxy-authorization`, +`te`, `trailer`, `transfer-encoding` and `upgrade`. A module that asks for any +of these has broken its contract, so the request fails at step 5 and the +header is not dropped, on the path that creates an identifier and on the path +that does not. Each answer in the table has a test. For the `500`, the test checks the error +that refusing a reserved response header returns, which `error.rs` maps to +`500`. + +### Effects on deployments that select a server-side module + +The no-op default of `resolve_from_client` leaves server-side modules alone. +Three other changes reach every deployment, whatever module is selected. + +1. The Fastly adapter registers the route in every deployment. A POST that + passes the origin, content-type and size checks answers `204`, because the + default `resolve_from_client` creates nothing, and no POST to the path + reaches the publisher origin. +2. Every withdrawal response also expires `ts-ecr`, because `expire_ec_cookie` + calls `expire_ec_resolved_marker`. +3. The marker rule of §4 runs on every response to a request that has an open + gate and carries a `ts-ec` the selected module does not own. + +The API reference entry for the endpoint (`docs/guide/api-reference.md` on the +series branch) lists only `200`, `204` and `413` and says `Auth: None`. The +entry has no `Origin` rule and none of the `400`, `403`, `409`, `415`, `500` +and `503` answers. + +## 4. Requirements on the page script + +- **The re-post guard must not depend on reading an HttpOnly cookie.** + Implemented as the draft's first option. The resolve response sets a + non-HttpOnly companion marker cookie (`ts-ecr=1`) carrying no identity, + which is the only signal the page has that a resolve succeeded. The + marker shares the Edge Cookie's scope and lifetime and is expired + together with it on withdrawal, so a visitor who later re-establishes + the permission can resolve again. Only a `200` sets the marker. The demo + page script does not read the status of the answer, so after any other + answer (a `403` for an origin the operator has not listed, a `204` for a + deployment with no `[ec] ec_store`, a `409` or a `503`) it posts again on + every later page view, and the amplification of §2 returns until a resolve + succeeds (§7.9). +- **The marker must not outlive the module that set it.** The marker + carries no identity, so unlike the `ts-ec` cookie it is not namespaced + by the module code envelope, and a long `Max-Age` that only + withdrawal expires would leave it standing across a module switch. + The visitor would then hold a marker saying a resolve has already + succeeded with no identity behind it, and the page script would + suppress the re-post that would start a new one, so the visitor sits + with no identity rather than a restarted one. Core therefore expires + the marker, in the finalization step that runs after the handler, on any + request whose permission gate is open and that carries a `ts-ec` the + selected module does not own, which is the same `{code}~` ownership test + core already applies to the identifier itself. A switch then restarts the + client cycle instead of stalling it. The finalization step also runs after + the resolve handler. So the first resolve after a switch sets `ts-ecr=1`, + the finalization step then appends an expiry for the same cookie, and the + browser, which applies the two in order, ends without a marker. The page + script posts once more on its next view, and that post carries the new + identity, which the module owns, so the marker then stays. The marker stays + only for a module that owns its own cookie, as the demo module does (§5). + No test runs the handler and the finalization step together after a module + switch (§7.8). +- **The page leg is permission-gated before vendor contact.** The demo + module contacts no vendor (it posts a constant), but it follows the rule + a vendor module will follow, which is to declare the Data Use its + server-side module requires (`necessary.operations.storage`, kept in step + by a Rust test), wait on `tsjs.whenPermissions()`, and post only when that + Data Use is in the set. With no permission state on the page it does not + post. The draft's live-CMP re-check binds the first vendor module rather + than v1, because the demo has no vendor contact to re-check before. A + vendor module must not derive identity or + contact the vendor for a visitor whose resolved permissions do not + satisfy the module's declaration, must re-check immediately before + vendor contact (consent can change between document delivery and + asynchronous vendor contact, including BFCache restoration), and its + injection is keyed off the module selection exactly as the demo's is + today. +- **How the resolved permissions reach the browser.** The requirement above + assumes the page can read the server's decision. The binding constraint is + that the JavaScript bundle is composed at startup and served under a content + hash, so one body is shared across every visitor and cannot carry + per-visitor permission state, which means the signal has to be per request. + The permission model (IABTechLab/trusted-server#1345), which this branch + builds on, picks the injected-value carrier and delivers it. The page + receives `window.tsjs.permissions`, an object of four lists, + `{"awaiting": [], "set": [], "signals": [], "tdls": []}`. The `set` list + names the Data Uses set for the request, using the same keys as + `permissions.yaml` and `Permission::as_str()`. The `awaiting` list names the + Data Uses still waiting for a signal, `signals` carries the signals the + modules read and found valid, and `tdls` carries the terms documents the data + is available under. The page always receives all four, and an empty state is + `{"awaiting":[],"set":[],"signals":[],"tdls":[]}`. Under inline assembly the + object is injected as a `` into the head, last among its own head markup and after the shim and the bootstrap that the loader relies on, so the loader is fetched from the module's own first-party route. The setting is `false` by default, because a page that already carries the loader would load it twice. + +**Why.** A publisher whose page templates forbid third-party scripts cannot put a Google tag on the page, so it has to come from the proxy. Nothing did that. The Tag Manager module re-serves a tag the page already carries, which needs a page that already loads Google. And a page that loads Google's tags itself sends measurement data before any consent default is set. + +## 3. What is not in this change + +- An AdSense unit. The per-unit `` markup is a property of the publisher's own page, not of this configuration. +- Removing an inline `gtag('config', ...)` from the publisher's page, which would mean buffering every response. +- A guide page of its own under `docs/guide/integrations/`. The configuration guide's `[tag.google-tag]` section and its table of middleware state the contract. + +**Open questions.** Each of these is a question about the code. This spec records what the code does and leaves the questions open. + +1. Should the paths no test runs get tests? They are a document with no ``, a `wait_for_update` of 0 and of 60001, an empty `body_open_inserts`, which registers no handler, and the AdSense loader after the tag snippet. +2. Should the check the Tag Manager route makes call the function this module calls? The rule for which tag ids the Tag Manager module serves is written in `tag_id_is_allowed` and again in `first_party_gtag_js_path`, so the two agree only while their text stays the same. + +## 4. Acceptance + +CI passed on all 25 checks of the pull request at `1940d63f1`, and on all 19 checks of the fork's closed pull request at the same commit. The description of the pull request lists the checks that were run, says that Google's own handling of the snippets was not exercised, and leaves its manual test with `fastly compute serve` unticked. The code fixes of 2026-10-16 sit on top of that commit and have not yet been through CI. Locally the crate's 41 tests pass, clippy on the crate with warnings denied passes and `cargo fmt --all -- --check` passes, and each fix's new tests fail with that fix reverted. The module's 41 tests, one test in core, one in the seam probe and three in the ad tag module hold the claims in section 2 as follows. + +- **Each snippet is Google's text with only the id substituted.** For the tag snippet, `the_tag_snippet_is_googles_own_with_the_id_substituted`. For the AdSense loader, `the_adsense_loader_is_googles_own_with_the_account_substituted`. For the container script, `the_container_script_is_googles_own_with_the_id_substituted`. For the noscript iframe, `the_container_noscript_is_googles_own_with_the_id_substituted`. The consent snippet has no such test. +- **The consent defaults.** `every_consent_type_defaults_to_denied`, `the_tag_waits_for_the_platform_s_update_before_it_fires` (500 and no region), `a_publisher_may_scope_the_defaults_to_regions_and_set_the_wait`, `a_publisher_may_grant_a_consent_type_up_front`, `a_region_that_is_not_a_code_is_refused_by_name` and `a_region_setting_leaves_every_other_visitor_with_all_four_denied`, which holds the second command with no region. +- **Who gets the loaders.** `a_reader_who_has_not_allowed_storage_gets_the_consent_defaults_alone`, `a_reader_who_allows_storage_gets_every_configured_loader` and `no_loader_is_written_where_no_permission_state_reached_the_middleware` plan the middleware through core's serve preparation. `a_page_for_a_reader_who_has_not_agreed_carries_no_google_loader` runs a page for a reader in Germany who has given no signal and a reader in California who has opted out, under the policy core ships. The page tests prepare both phases as core does for a page it does not store. +- **The order within the module's markup.** `consent_defaults_come_before_the_config_command`, `consent_defaults_come_before_the_container` and `a_container_and_a_tag_are_both_written_container_first`. +- **Each id on its own.** `each_id_on_its_own_writes_the_consent_defaults_and_its_own_snippet`. +- **Where each loader points.** `the_loader_names_googles_host_when_tag_manager_is_not_enabled`, `the_loader_names_the_first_party_path_when_tag_manager_serves_the_tag`, `the_loader_names_googles_host_when_tag_manager_would_not_serve_the_tag`, `the_container_loader_names_googles_host_when_tag_manager_is_not_enabled`, `the_container_loader_names_the_first_party_path_for_the_same_container` and `the_container_loader_names_googles_host_for_a_different_container`. +- **The page's own loader.** `a_gtag_loader_is_recognised_however_it_is_written`, `a_page_already_loading_the_same_tag_ends_with_one_loader`, `a_loader_the_fetch_rewrite_made_first_party_is_removed_as_well` and `a_page_loading_another_tag_keeps_it`. `a_page_through_both_modules_ends_with_one_first_party_loader` runs the Tag Manager module's `[[fetch]]` middleware and this module's `[[serve]]` middleware on one page, which ends with one loader, on the first-party path. +- **Placement on a page.** `the_snippets_land_in_the_head_once` and `the_snippet_reaches_a_real_shaped_head_before_the_publishers_own_scripts` hold the head snippets once, before the page's own head content, with no `[[fetch]]` middleware present. `the_noscript_lands_immediately_after_the_opening_body_tag_once`, `a_second_body_does_not_get_a_second_noscript` and `a_page_with_no_container_configured_is_left_alone_at_the_body` hold the noscript iframe. `a_page_with_no_head_gets_the_noscript_and_none_of_the_head_snippets` holds a document with no ``. +- **The settings.** `an_unnamed_integration_does_not_register`, `a_config_naming_no_id_is_refused`, `an_id_of_the_wrong_shape_is_refused_by_name`, `an_unknown_setting_is_refused` and `the_example_s_placeholder_ids_are_refused`, which reads the tag and container placeholders from `trusted-server.example.toml`. `module_constant_is_the_crate_folder` holds `MODULE` to the crate's folder. +- **The new place in core.** `a_body_open_insert_is_written_once_after_the_opening_body_tag` in `html_processor.rs` drives the serve processor with two inserts and a second ``. The seam probe's middleware writes a marker immediately after the opening body tag, and its page test, `the_middleware_changes_a_page_where_an_entry_names_it`, reads the marker back once, directly after ``, through the fetch processor. +- **The ad tag module's loader.** `head_inserts_write_the_loader_tag_when_asked`, `head_inserts_write_the_loader_tag_last` and `head_inserts_write_no_loader_tag_by_default` in `crates/ad-tag/google/src/lib.rs`. + +## Revision record + +| Date | Change | +| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 2026-10-12 | First draft, from the pull request and its issue as they stand. | +| 2026-10-16 | Fixes from review round 1. The header and the pull request pointers, where the head snippets land, the reason for the stock list order, the settings, the consent defaults, the Tag Manager module, the ad tag module's loader setting, failure, adapters, compatibility, the content security policy, the tests, the open questions and the review log. | +| 2026-10-16 | Fixes from review round 2. The content security policy and the source of the consent snippet. | +| 2026-10-16 | Code fixes from review round 1, code findings 1 to 5 and 7 and the case with no head in finding 6. The loaders and the noscript iframe are written only for a reader whose permissions allow storage on the device, a region setting gives every other visitor a default with all four types denied, the example's tag id placeholder fails validation and `AGENTS.md` lists the module. Tests run a page through both modules and a document with no head, and hold the crate's name to its folder. Open questions 1 to 5 and 7 and the case with no head in 6 leave the list. | + +## Review log + +1. **Round 1, 2026-10-16.** A review in a fresh session, against `split/30-google-tag` at `1940d63f1`. Issues found, 5 of them blocking. The five were that the spec said the snippets land at the start of the head when they land before the script bundle's tag and after what a `[[fetch]]` middleware wrote, that it gave a reason for the module's place in the stock list that does not hold, that it never mentioned the `write_loader` setting the pull request adds to the Google ad tag module, that its consent design left out `wait_for_update`, `region` and the states a consent type can take, and that its header called a closed fork pull request ready for review and named a closed pull request as the place of the specs. This revision states what the code does at that head for each of the five, and adds the settings, the failure cases, the adapters, the tests that hold each claim and the code questions the review raised, which wait for code changes under Open questions. +2. **Round 2, 2026-10-16.** A lighter review in a fresh session, checking the round 1 fixes against `split/30-google-tag` at `1940d63f1`. Issues found, none blocking. This revision says what a policy that allows inline scripts by hash does with the snippets and that the consent snippet follows Google's published guide. diff --git a/docs/superpowers/specs/2026-10-12-inmobi-choice-module-design.md b/docs/superpowers/specs/2026-10-12-inmobi-choice-module-design.md new file mode 100644 index 0000000000..f01b5344d3 --- /dev/null +++ b/docs/superpowers/specs/2026-10-12-inmobi-choice-module-design.md @@ -0,0 +1,220 @@ +# Design Spec: The InMobi Choice consent prompt as a module + +**Status:** Proposed, 2026-10-12. Implemented as the pull request [IABTechLab/trusted-server#1359](https://github.com/IABTechLab/trusted-server/pull/1359), row 18 of the stack, answering [IABTechLab/trusted-server#1276](https://github.com/IABTechLab/trusted-server/issues/1276). [IABTechLab/trusted-server#1342](https://github.com/IABTechLab/trusted-server/pull/1342) carries every design document of the stack in one place. +**Author:** 51Degrees (contributed), for Tech Lab review +**Related specs:** `2026-08-27-integration-provider-seam-design.md`, `2026-10-12-page-middleware-design.md` (the entries and the two phases), `2026-10-12-consent-prompt-phases-design.md` (lets a consent prompt run in the serve phase) +**Related PRs:** [IABTechLab/trusted-server#1359](https://github.com/IABTechLab/trusted-server/pull/1359) builds on the Model Terms cookie name in [IABTechLab/trusted-server#1358](https://github.com/IABTechLab/trusted-server/pull/1358) (row 17), replaces [IABTechLab/trusted-server#1305](https://github.com/IABTechLab/trusted-server/pull/1305) (closed, an earlier pull request for this change against `main`) and was reviewed on [jwrosewell/trusted-server#13](https://github.com/jwrosewell/trusted-server/pull/13) (closed). The change that lets a vendor module live outside core is [IABTechLab/trusted-server#1348](https://github.com/IABTechLab/trusted-server/pull/1348) (row 7), page changes as middleware are in [IABTechLab/trusted-server#1351](https://github.com/IABTechLab/trusted-server/pull/1351) (row 10), middleware on other media types is in [IABTechLab/trusted-server#1360](https://github.com/IABTechLab/trusted-server/pull/1360) (row 19) and a consent prompt in the serve phase is in [IABTechLab/trusted-server#1362](https://github.com/IABTechLab/trusted-server/pull/1362) (row 21) +**Issues:** [IABTechLab/trusted-server#1276](https://github.com/IABTechLab/trusted-server/issues/1276). [IABTechLab/trusted-server#1289](https://github.com/IABTechLab/trusted-server/issues/1289) audits Didomi, Sourcepoint and Osano against the rules a consent prompt is held to, and [IABTechLab/trusted-server#777](https://github.com/IABTechLab/trusted-server/issues/777) lists the stack +**Last updated:** 2026-10-16 + +> **Why this spec exists.** A publisher who loads the TCF consent prompt from their own InMobi account has had no module that places the prompt on a page, and nothing written says what a consent prompt module does to a page. This change adds the module `cmp.inmobi`, and this spec records what the module writes and in what order, what it refuses, what it leaves to others, and what is checked. + +## 1. Problem + +The repository carries consent prompt modules for Didomi, Sourcepoint and Osano, each changing a page in its own way, and none for InMobi Choice, the TCF consent prompt a publisher loads from their own InMobi account. TCF is the Transparency and Consent Framework of the Interactive Advertising Bureau (IAB), the standard way a page asks a reader for consent and passes the answer to the tags on the page. Nothing states what a consent prompt module is held to, so each one decides for itself where its tag goes, whether the page gets stubs of the IAB interfaces before the prompt's loader runs, and how a reader's answer reaches the permission model. A stub is a small script that the page runs first, so that a call to an IAB interface made before the real prompt has loaded is answered at once or waits in a queue. The permission model is the part of core, the `trusted-server-core` crate, that decides for each request what may be done with the reader's data. + +Line numbers in this document are those of commit `badd6fb15`, the head of IABTechLab/trusted-server#1359, unless a row is named. + +## 2. Design + +`crates/cmp/inmobi` (package `trusted-server-cmp-inmobi`) is the module `cmp.inmobi`. A deployment selects the module with `[cmp] module = "inmobi"`, gives it the account's address in `[cmp.inmobi]`, and places it with a `[[fetch]]` entry. + +```toml +[cmp] +module = "inmobi" + +[cmp.inmobi] +script_url = "https://cmp.inmobi.com/choice///choice.js?tag_version=V3" +# cmp_id = 10 + +[[fetch]] +media_type = "text/html" +middleware = ["cmp.inmobi", "tag.google-tag-manager"] +``` + +**What the middleware writes.** Where a `[[fetch]]` entry for `text/html` names `cmp.inmobi`, the module's middleware writes one piece of markup for the page's `` (`Choice::create`, `lib.rs` lines 234 to 239), which holds three parts in this order. + +1. The IAB TCF v2 stub (`tcf_stub.js`). It creates the hidden `__tcfapiLocator` frame, which tells other frames that the interface is on this page, answers `ping` as a stub that has not loaded, accepts `setGdprApplies` for version 2 with a boolean and reports the value in `ping`, queues every other call so that `__tcfapi()` with no arguments returns the queue, and relays calls that frames post to the window. +2. A GPP stub (`gpp_stub.js`), which the code describes as InMobi's. GPP is the IAB Global Privacy Platform, whose interface `__gpp` carries a reader's privacy choices for several regions, the US states among them. The stub answers `ping`, `addEventListener`, `removeEventListener`, `hasSection`, `getSection` and `getField` at once, queues every other call for the prompt, creates the hidden `__gppLocator` frame, and relays calls that frames post to the window. Its `ping` data, which the listener answers repeat, reports the stub's status (`cmpStatus: "stub"`), the CMP id, which is the number the IAB registers for a consent management platform, and eight supported sections (`2:tcfeuv2`, `6:uspv1`, `7:usnatv1`, `8:usca`, `9:usvav1`, `10:uscov1`, `11:usutv1` and `12:usctv1`), whatever the account's prompt supports. +3. The account's Choice loader, written as `