Repository navigation
Conversation
Spec and implementation plan for #1234: an [auction] rewrite_clicks switch that follows rewrite_creatives when unset, so click wrapping can be controlled independently of asset rewriting. Signed-off-by: dhruv8sh <dhruv8sh@proton.me>
Describe the review fixes: the extended shared-step CHANGELOG entry and the sign status change, rollback in both directions, the reworded Auction Rewrite Control docs and renderGuard note, the fuller body-less matrix fixture, the exact default-output pin, and the stronger proxied-HTML test. Signed-off-by: dhruv8sh <dhruv8sh@proton.me>
prk-Jr
left a comment
There was a problem hiding this comment.
Summary
Spec and plan for #1234's [auction] rewrite_clicks. The design holds up well: the plan was applied verbatim to main (d8937e1) and every Rust block compiled, passed fmt, clippy-fastly and clippy-cli; every claimed pass count matched exactly (16 / 15 / 554 — also under Viceroy 0.17.0 — / 2 / 2); the red steps fail where predicted; both MATRIX_DEFAULT_*_OUTPUT constants match main byte for byte; and the normalizer leaves tsurl/tstoken identical for edge inputs (uppercase host, :443, spaces, é, IDN, tabs). Cited files are unchanged between 7a0ecb4c and main. Two blocking items are doc-accuracy problems that would flow into operator docs.
13 of the inline comments below carry a one-click GitHub
suggestion— use Commit suggestion (or Add suggestion to batch) to apply them. The remaining comments describe the fix in prose because it spans several places. All suggestions were applied together and checked with the pinned docs prettier.
Blocking
🔧 wrench
- Doc-update list misses text that becomes false — see inline at
docs/superpowers/specs/2026-10-07-1234-creative-click-rewrite-switch-design.md:482 renderGuardcaveat is wrong for the sandboxed renderer — see inline atdocs/superpowers/specs/2026-10-07-1234-creative-click-rewrite-switch-design.md:344anddocs/superpowers/plans/2026-10-07-1234-creative-click-rewrite-switch.md:1712
Non-blocking
🤔 thinking
<base>removal in single-feature modes — see inline at spec:342- Public wrappers bypass both switches — see inline at spec
:305
♻️ refactor
- Task 1 red steps can't be observed — see inline at plan
:637
⛏ nitpick
- Step 5 grep can never pass — plan
:651 - "Unchanged output" vs the shared step's Fixed items — spec
:26, plan:22 - Task 0 has no one-match case — plan
:310 - Suffix-spoof case dropped from the policy test — plan
:352 - Private intra-doc link warning — plan
:92 - Step 8 wording replaces the wrong span — plan
:1363 - Click guard isn't fully inert — spec
:357 - "Without allocating" — spec
:176 - Commit instructions vs CONTRIBUTING.md — plan
:35
Cross-cutting / body-level findings
- ⛏ Smaller precision items
- Citations off by one or two: spec :38 (
proxy.rs:1898-1906), :229 (1672-1703, matching the plan), :486 (endpoints.rs:84-90), :487 (formats.rs:375-378). - The shared CHANGELOG Fixed entry (plan :290) leaves out that unparseable quoted URLs in
<style>/style=are no longer re-quoted viacssparser::serialize_string, and that/first-party/signerror text changes (invalid url/unsupported scheme→unsupported url). "Re-quoted" also understates the old fallback://input was emittedhttps:-prefixed (src=" //exa mple.example/x "→https://exa mple.example/x). - Because settings serialize normalized, the first
ts config pushafter upgrading shows a blob diff for configs with mixed-case/whitespace/""/*exclude entries — worth one line in Rollout. - Expected files (spec :491) omits
auction/orchestrator.rsandauction/endpoints.rs. - Spec :372's provenance sentence reads as if #1231's text lacks the four tests; #1248's plan already has them.
- Spec :107-115 vs plan :886-896 doc comments differ (plan adds the "rejected by binaries that predate this field" clause); spec :122 vs plan :902 likewise.
- Plan :39: Viceroy 0.21.1 vs pinned 0.17.0 (re-run under 0.17.0 gives the same counts).
- PR checklist: the
tracingbox should readlog.
- Citations off by one or two: spec :38 (
- 🌱 Staged / multi-service rollback — spec :467: a blob pushed with
--stagingthat carriesrewrite_clicksalso has to be re-pushed without it before rolling back a staged binary, once per service. - 📌 Trailing-dot hosts bypass
exclude_domains(pre-existing) —https://excluded.example.com./xis still proxied and wrapped in both old and new code (reproduced). Not for this PR; same follow-up as noted on #1248. - 👍 Praise
- The plan is executable as written, with exact counts and byte-exact pins.
- Gating is complete: the anchor handler is the only producer of click URLs, apart from rebuild of already-signed input.
- Keeping
rewrite_clickscommented out in the example TOML is the right call, sincets config initcopies it. Option<bool>with per-path resolution keeps both existing operator groups unchanged.
CI Status
- All 22 checks PASS: Analyze (actions / javascript-typescript / python / rust), CodeQL, CLAUDE.md symlink guard, browser integration tests, integration tests, integration tests (Fastly EC lifecycle), prepare integration artifacts, cargo fmt, cargo test, cargo test (axum native), cargo test (cross-adapter parity), cargo check (cloudflare native + wasm32-unknown-unknown), cargo check/build/test (spin native + wasm32-wasip1), cargo test (ts CLI, native) (macos-latest / ubuntu-latest), vitest, format-typescript, format-docs
| | `trusted-server.example.toml` | Reword the `rewrite_creatives` comment (`trusted-server.example.toml:264-267`) to cover assets only. Add `# rewrite_clicks = true` below it, with its unset behavior and the overlay and rollback caveats. Update the `sanitize_creatives` comment, which says only `rewrite_creatives` changes the `adm`, to name both switches. | | ||
| | `docs/guide/configuration.md` | Add `rewrite_clicks` to the `[auction]` table (`configuration.md:1930-1938`). Update the processing paragraph (`configuration.md:1940-1956`) and the upgrade, rollback and overlay warning (`configuration.md:1959-1985`). | | ||
| | `docs/guide/creative-processing.md` | Update Processing Triggers (`creative-processing.md:46-56`). Reword the Auction Rewrite Control intro and table (`creative-processing.md:58-80`) so it names three settings, asset URLs follow `rewrite_creatives` and links follow `rewrite_clicks`, then add the four-combination matrix as an "Assets and clicks" subsection and document the proxied-HTML rule. Add `data-tsclick` and the switch to "Anchors (Click Tracking)" (`creative-processing.md:300-326`) and use an example.com landing URL there. Explain `clickGuard` versus `rewrite_clicks`, and note that with assets off and clicks on the injected TSJS still lets a creative that turns on `renderGuard` proxy script-inserted assets through `/first-party/sign`. | | ||
| | `docs/guide/auction-orchestration.md` | Update the `rewrite_creatives` references (lines 164, 198, 636-660) to name both switches. | |
There was a problem hiding this comment.
🔧 wrench — The doc-update list misses operator text that becomes false once rewrite_clicks exists, and plan Task 6 inherits the gap:
auction-orchestration.md:704(full example TOML),:837/:844(the environment-override section that tells operators which leaves to add) and:853(rollback note) all namerewrite_creativesalone.configuration.md:2171: "rewrite_creatives = falseskips first-party URL rewriting and creative TSJS injection" — false withrewrite_clicks = true.configuration.md:1956: "Neither setting affects HTML or CSS fetched through/first-party/proxy" — an explicitrewrite_clicksnow does.
This row's fix is below; the configuration.md row (:480) and plan Task 6 Steps 1/3 need the :1956 and :2171 lines added by hand (spans the spec table and the plan, so it can't be one suggestion).
| | `docs/guide/auction-orchestration.md` | Update the `rewrite_creatives` references (lines 164, 198, 636-660) to name both switches. | | |
| | `docs/guide/auction-orchestration.md` | Update the `rewrite_creatives` references (lines 164, 198, 634-663, the example TOML at 704, the environment-override section at 837-844 and the rollback note at 853) to name both switches. | |
|
|
||
| - `<base>` removal protects root-relative `/first-party/proxy` URLs, root-relative `/first-party/click` URLs and the root-relative `/static/tsjs=` script alike, so it is needed whichever feature is on. On the inline path URLs are absolute, but removal is kept for parity, as `inline_rewrite_strips_base_elements` (`creative.rs`) pins today. | ||
| - TSJS injection on `/auction` with clicks on delivers the click guard, which is required. | ||
| - TSJS injection on `/auction` with only clicks on also gives a creative that sets `tsCreativeConfig.renderGuard` a way to send assets inserted by its own script through `/first-party/sign` and `/first-party/proxy`, although the server left the markup's asset URLs direct. `creative-processing.md` says so. |
There was a problem hiding this comment.
🔧 wrench — This caveat doesn't hold for the shipped renderer, and plan :1712 would put it into creative-processing.md:
/auctioncreatives render in a sandbox withoutallow-same-origin(crates/trusted-server-js/lib/src/core/render.ts:14-29).- In that context
signProxyUrlreturns early onhasOpaqueOrigin()(integrations/creative/proxy_sign.ts:40), so render-guard assets load direct. - Separately,
/first-party/sign(proxy.rs:1625-1734) checks no auction setting, sorewrite_creatives = falsehas never gated it — injecting TSJS doesn't open anything new.
| - TSJS injection on `/auction` with only clicks on also gives a creative that sets `tsCreativeConfig.renderGuard` a way to send assets inserted by its own script through `/first-party/sign` and `/first-party/proxy`, although the server left the markup's asset URLs direct. `creative-processing.md` says so. | |
| - TSJS injection on `/auction` with only clicks on does not reopen asset proxying in practice: `/auction` creatives render in a sandbox without `allow-same-origin` (`render.ts:14-29`), so the render guard's `signProxyUrl` returns early on an opaque origin (`proxy_sign.ts:40`) and script-inserted assets load direct. Independently, `/first-party/sign` checks no auction setting, so `rewrite_creatives = false` has never gated it; `creative-processing.md` says so. |
| - the four-row table (assets × clicks → asset URLs, links, `<base>`, TSJS); | ||
| - "SSAT/page-bids follows the same table with absolute URLs and never injects TSJS. `exclude_domains` applies to both assets and links."; | ||
| - the `clickGuard` versus `rewrite_clicks` paragraph; | ||
| - a `renderGuard` note: "With `rewrite_creatives = false` and `rewrite_clicks = true`, `POST /auction` still injects TSJS. A creative that turns on `tsCreativeConfig.renderGuard` can therefore still send assets inserted by its own script through `/first-party/sign` and `/first-party/proxy`, even though the server left the markup's asset URLs direct." |
There was a problem hiding this comment.
🔧 wrench — Same correction as spec :344: under the sandboxed /auction renderer the render guard can't reach /first-party/sign (proxy_sign.ts:40), so this note would mislead operators.
| - a `renderGuard` note: "With `rewrite_creatives = false` and `rewrite_clicks = true`, `POST /auction` still injects TSJS. A creative that turns on `tsCreativeConfig.renderGuard` can therefore still send assets inserted by its own script through `/first-party/sign` and `/first-party/proxy`, even though the server left the markup's asset URLs direct." | |
| - a `renderGuard` note: "With `rewrite_creatives = false` and `rewrite_clicks = true`, `POST /auction` still injects TSJS, but this does not reopen asset proxying in practice: `/auction` creatives render in a sandbox without `allow-same-origin`, so the render guard skips `/first-party/sign` and script-inserted assets load direct. `/first-party/sign` itself is not gated by `rewrite_creatives`." |
|
|
||
| Apply the [Shared step](#shared-step) code to `settings.rs`, `creative.rs` and `proxy.rs`. Delete `Rewrite::is_excluded` with its `#[allow(dead_code)]`. | ||
|
|
||
| Run: `git grep -n "to_abs\|is_excluded(" crates/` |
There was a problem hiding this comment.
⛏ nitpick — This grep can never come back empty: to_abs also matches auction/telemetry.rs:1036 (…_default_to_absent_…) and publisher.rs:22620 (…_to_absolute_first_party_urls). Reproduced by applying the plan to main. Word-bounded form only hits the intended code (and -w doesn't match is_excluded_host). Same fix as on #1248.
| Run: `git grep -n "to_abs\|is_excluded(" crates/` | |
| Run: `git grep -nw -e to_abs -e is_excluded crates/` |
|
|
||
| Run: `cargo test-fastly --no-run` | ||
|
|
||
| Expected: compile errors, including `error[E0432]: unresolved import super::normalize_creative_url` and `no method named should_proxy_asset found for struct settings::Rewrite` (also `should_wrap_click` and `normalize`). |
There was a problem hiding this comment.
♻️ refactor — These five runtime reds can't be observed in step order: Steps 1–3 switch the creative.rs test import to normalize_creative_url and add should_proxy_asset/should_wrap_click/normalize calls, so the whole core test binary fails to compile (this step's own Expected line). Verified: the five do fail exactly as described when added to unmodified main first. Suggest adding those five with the old import first, watching them fail, then doing Steps 1–3. Same as #1248.
|
|
||
| Add: "Rewrites assets and clicks unconditionally; the auction settings are applied by the auction processing entry points, not here." | ||
|
|
||
| - `rewrite_inline_creative_html`: "its click guard is unnecessary for click URLs that are already absolute here". |
There was a problem hiding this comment.
⛏ nitpick — Substituting this literally into the existing sentence yields "its only job is to its click guard…"; it has to replace the whole clause.
| - `rewrite_inline_creative_html`: "its click guard is unnecessary for click URLs that are already absolute here". | |
| - `rewrite_inline_creative_html`: replace "its only job is to safeguard click URLs, which are already absolute here" with "its click guard is unnecessary for click URLs that are already absolute here". |
|
|
||
| - `rewrite_clicks` decides whether the server emits signed click URLs and `data-tsclick`. | ||
| - `clickGuard` decides whether the client repairs those URLs when creative script mutates them. It is a client-side opt-out that a creative or publisher can set through `tsCreativeConfig`. | ||
| - With `rewrite_clicks` off there is nothing to guard. The guard installs and stays inert (`click.ts:433-434`, `click.ts:467`), so plumbing the server switch into the client would add config surface with no behavior change. |
There was a problem hiding this comment.
⛏ nitpick — Not quite inert: installClickGuard still adds capture-phase click/auxclick listeners and a document-wide attribute MutationObserver (click.ts:484-512). Harmless, but worth stating accurately (also :312).
| - With `rewrite_clicks` off there is nothing to guard. The guard installs and stays inert (`click.ts:433-434`, `click.ts:467`), so plumbing the server switch into the client would add config surface with no behavior change. | |
| - With `rewrite_clicks` off there is nothing to guard. The guard still installs its click listeners and attribute observer (`click.ts:484-512`) but never acts, because no anchor carries `data-tsclick` (`click.ts:433-434`, `click.ts:467`), so plumbing the server switch into the client would add config surface with no behavior change. |
| } | ||
| ``` | ||
|
|
||
| It trims the input, maps a protocol-relative `//host/...` to `https://host/...`, and accepts only a case-insensitive `http://` or `https://` prefix. It parses absolute input in place without allocating, and returns `None` for empty, relative, non-http(s) or unparseable input. It needs no separate hostless check, because `url` rejects an `http(s)` URL without a host. It makes no policy decision and reads no settings. |
There was a problem hiding this comment.
⛏ nitpick — Url::parse allocates; what's avoided is only the intermediate format!.
| It trims the input, maps a protocol-relative `//host/...` to `https://host/...`, and accepts only a case-insensitive `http://` or `https://` prefix. It parses absolute input in place without allocating, and returns `None` for empty, relative, non-http(s) or unparseable input. It needs no separate hostless check, because `url` rejects an `http(s)` URL without a host. It makes no policy decision and reads no settings. | |
| It trims the input, maps a protocol-relative `//host/...` to `https://host/...`, and accepts only a case-insensitive `http://` or `https://` prefix. It parses absolute input without an intermediate `format!`, and returns `None` for empty, relative, non-http(s) or unparseable input. It needs no separate hostless check, because `url` rejects an `http(s)` URL without a host. It makes no policy decision and reads no settings. |
| - no local `use` inside functions; | ||
| - every public item documented; | ||
| - at most 7 function arguments. | ||
| - Commits use sentence case, imperative mood, and `git commit --signoff -S` with multiple `-m` flags. No prefixes, no attribution trailers, never a heredoc. |
There was a problem hiding this comment.
⛏ nitpick — CONTRIBUTING.md:44-53 doesn't ask for --signoff/-S (and -S fails without a GPG key), asks for subjects around 50 characters (four here are 74–79) and a wrapped body (the -m bodies are single ~230-char lines).
| - Commits use sentence case, imperative mood, and `git commit --signoff -S` with multiple `-m` flags. No prefixes, no attribution trailers, never a heredoc. | |
| - Commits follow `CONTRIBUTING.md`: sentence case, imperative mood, subject near 50 characters, wrapped body. No prefixes, no attribution trailers. |
| | `rewrite_proxied_html` (`CreativeHtmlProcessor`) | `true` | `auction.rewrites_proxied_clicks()` | `true` | | ||
| | `rewrite_creative_html`, `rewrite_inline_creative_html` (public wrappers) | `true` | `true` | as today | | ||
|
|
||
| `process_auction_creative_with_rewriter` replaces the `rewrite_creatives` check with `features.any()`. When both features are off it returns the sanitized markup unchanged, exactly as today. The `process_*` functions now call `rewrite_creative_html_impl` directly, so the public wrappers keep full-rewrite semantics and existing callers, such as `proxy.rs:3543`, are unaffected. |
There was a problem hiding this comment.
🤔 thinking — rewrite_creative_html / rewrite_inline_creative_html are pub in pub mod creative with only test callers (proxy.rs:3543 is inside #[cfg(test)], module starts at :2160). Pinning them to CreativeFeatures::ALL means any future integration calling them silently bypasses both switches. Consider pub(crate)/test-only, or a doc warning.
Summary
[auction] rewrite_creatives, controls both asset proxying and click wrapping, so an operator can't keep first-party click tracking while leaving assets direct, or the other way round.[auction] rewrite_clicksis anOption<bool>. When unset, it followsrewrite_creatives, so no existing config changes behavior on upgrade./first-party/proxy: an explicit value applies there too, while unset keeps today's always-wrap behavior.<base>removal and TSJS injection: both run when either switch is on.TsCreativeConfig.clickGuard: stays client-only.Changes
docs/superpowers/specs/2026-10-07-1234-creative-click-rewrite-switch-design.mddocs/superpowers/plans/2026-10-07-1234-creative-click-rewrite-switch.mdCloses
Part of #1234. This PR adds only the spec and plan. The implementation PR will link the approved spec and close the issue.
Test plan
cargo test-fastly && cargo test-axumcargo clippy-fastly && cargo clippy-axumcargo fmt --all -- --checkcd crates/trusted-server-js/lib && npx vitest runcd crates/trusted-server-js/lib && npm run formatcd docs && npm run formatcargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1fastly compute serveprettier --config docs/.prettierrc --checkon both files. This PR changes no code, so no Rust or JS gates were run. The plan was checked by implementing it in a local branch, where fmt, all clippy aliases,test-fastly,test-fastly-reuse,test-axum,test-cloudflare,test-spin, the CLI tests and the parity tests passed. That code is not included here.Checklist
unwrap()in production code — useexpect("should ...")tracingmacros (notprintln!)