Skip to content

Rc/july admin ec route#931

Draft
prk-Jr wants to merge 13 commits into
rc/julyfrom
rc/july_admin_ec_route
Draft

Rc/july admin ec route#931
prk-Jr wants to merge 13 commits into
rc/julyfrom
rc/july_admin_ec_route

Conversation

@prk-Jr

@prk-Jr prk-Jr commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator

Summary

Changes

File Change

Closes

Closes #

Test plan

  • cargo test-fastly && cargo test-axum
  • cargo clippy-fastly && cargo clippy-axum
  • cargo fmt --all -- --check
  • JS tests: cd crates/trusted-server-js/lib && npx vitest run
  • JS format: cd crates/trusted-server-js/lib && npm run format
  • Docs format: cd docs && npm run format
  • WASM build: cargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1
  • Manual testing via fastly compute serve
  • Other:

Checklist

  • Changes follow CLAUDE.md conventions
  • No unwrap() in production code — use expect("should ...")
  • Uses tracing macros (not println!)
  • New code has tests
  • No secrets or credentials committed

prk-Jr added 13 commits July 17, 2026 18:01
Adds GET /_ts/admin/ec/{id} (explicit EC ID) and GET /_ts/admin/ec
(EC ID from the caller's ts-ec cookie) so operators can inspect EC
identity graph entries and debug KV-to-auction EID propagation.

The core handler returns the stored KvEntry verbatim (including raw
consent strings and partner UIDs), the KV metadata mirror, the store
generation marker, and a derived auction view showing exactly which
EIDs the auction would attach and why each stored partner ID was
skipped (empty_uid, not_in_registry, bidstream_disabled). Corrupt
entries are returned with the parse error and raw body via the new
KvIdentityGraph::lookup_raw instead of failing closed.

The routes join Settings::ADMIN_ENDPOINTS so startup validation
rejects configs whose basic-auth handler regex does not cover them.
The EC identity graph is Fastly KV backed, so the Axum, Cloudflare,
and Spin adapters register the routes to local 501 responses, keeping
them off the publisher fallback that would forward the Authorization
header to the origin.

Closes #921
The dispatch arm reused EcRequestState::kv_graph, which is deliberately
None for clients that fail the browser gate. Operators hit this
auth-gated endpoint with curl, so every lookup returned 501 as if no
EC store were configured. Build the identity graph directly from
settings instead, and document why the bot-gated copy must not be used.

Also point the bare-route no-cookie 404 at the explicit-id route, since
the ts-ec cookie (Domain-scoped, Secure) cannot exist on localhost.
Adds GET /_ts/admin/eids, complementing the EC lookup endpoint with the
client-side half of EID propagation: it decodes the request's ts-eids
and sharedId cookies and previews what cookie ingestion would write
into the EC entry's ids map — matched partner UIDs (deduplicated
exactly like the ingestion path) and unmatched sources that would be
dropped.

The endpoint always responds 200; missing or malformed cookies are
reported in the payload rather than as errors. It is pure request
inspection with no KV access, so every adapter serves the real handler.
The path joins Settings::ADMIN_ENDPOINTS for basic-auth coverage
validation.
Introduce an operator-armed render-trace overlay so the winning auction
recorded in window.tsjs.renders can be confirmed visually on the page,
on both the SSAT/GAM and /auction paths.

Server: GET /_ts/trace toggles a host-only ts-trace cookie and redirects
to /, gated by the new [debug] trace_route_enabled flag (404 when off).
Registered on all four adapters (Fastly/Cloudflare/Axum/Spin) and
documented in trusted-server.example.toml.

Client: while the cookie is armed, TSJS draws a floating
Google-Publisher-Console-style panel summarising every traced slot and a
confirmation badge on each genuinely-rendered creative. The panel reports
honest per-slot status — ok / hidden / gam-only / empty — derived from
separate signals (gamEmpty from GAM's slotRenderEnded, injected = whether
TS actually placed the creative, visible = ancestor-aware visibility) so
it never overclaims a render GAM merely fired. Clicking a row copies the
full record; the badge is shown only on ok slots.
Review feedback on the admin EC lookup asked for readable dates. The
echoed entry now carries derived created_iso and consent.updated_iso
fields (yyyy-MM-ddTHH:mm:ss.SSSZ) next to the stored unix-seconds
values, which stay untouched so the echo remains faithful to KV.
…es' into rc/july_admin_ec_route

# Conflicts:
#	crates/trusted-server-adapter-axum/src/app.rs
#	crates/trusted-server-adapter-cloudflare/src/app.rs
#	crates/trusted-server-adapter-spin/src/app.rs
#	crates/trusted-server-js/lib/src/core/request.ts
#	crates/trusted-server-js/lib/src/integrations/gpt/index.ts
The prod page runs two independent auctions against the same placements:
the one-time SSAT auction on navigation, and an ongoing client-side
/auction driven by GAM refresh through the Prebid.js trustedServer
adapter. Only the SSAT path was traced, so every Prebid render was
invisible to the overlay.

Instrument the /auction path at its authoritative render signal. The
adapter now forwards the server-side trace tuple to Prebid as
meta.tsAuctionId / meta.tsAdmHash, and a bidWon listener records an
`auction`-path entry (servedFrom: prebid) keyed by the ad-unit code with
that auction's own ID, hash and visibility. Only bids carrying
meta.tsAuctionId — i.e. the trustedServer seat — are traced, so a
client-side bidder's render is never attributed to Trusted Server. The
listener only observes and stamps; it does not touch Prebid's rendering.

Also fix an overclaim in the SSAT path: with inject_adm_for_testing off
there is no adm to inject, which left `injected` unset and let
panelStatus fall through to `ok` — claiming a confirmed TS render for a
slot TS had merely targeted. Targeting-only renders now report
`injected: false`, and `ok` requires an explicit confirmed placement so
any future path that omits the signal degrades to gam-only rather than
silently claiming success.
…ec_route

# Conflicts:
#	crates/trusted-server-js/lib/src/integrations/gpt/index.ts
#	crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts
A client framework can replace the ad divs after GPT slots were bound to
them: the publisher's React app serves ids like `ad-header-0-_R_ssr_` and
swaps them for client ids (`ad-header-0-_r_1_`) during hydration. GPT is
left holding slots whose element no longer exists — GAM reports
"defineSlot was called without a corresponding DIV", still fetches a
creative for them, and the bid is silently wasted with nowhere to render.

Waiting for the divs to merely exist cannot help, because at adInit()
time the server-rendered divs are present and are only later replaced.
So rather than delaying the initial ad request, detect the swap after the
fact: a debounced MutationObserver armed after adInit() looks for
TS-defined slots whose element left the document and re-runs adInit(),
which destroys the orphans and re-binds against the live DOM — reusing
the publisher's own slot for that div when they have since defined one.

Bounded deliberately, since each re-bind re-requests the affected slots:
a 250ms quiet period, a 5s watch window (measured: the swap lands ~2-3s
in, so the existing 2s SPA wait would miss it), and at most two re-binds
per page load, with the attempt budget shared across the adInit() the
watcher itself triggers so it cannot loop.

Verified against the live page through the dev proxy: two orphaned slots
were detected and re-bound, leaving zero orphans, with all three slots
tracing to live client-id elements.
The panel collapsed each slot to a single row with a ×N counter, so a
publisher page that refreshes its slots on every render (autoblog's
ad-service refreshes from its own slotRenderEnded handler) showed a
climbing number instead of what actually happened. Keep an append-only
`window.tsjs.renderLog` alongside the per-slot `renders` registry and
render it newest-first, one entry per render, with a wall-clock time and
a `#N` sequence. The log is trimmed to the most recent entries so a page
that refreshes indefinitely cannot grow it without bound; `renders` still
collapses per slot for "did this ever render" checks.

Also badge every slot that actually shows a creative, not just confirmed
TS renders. Gating the badge on `ok` meant production — where
inject_adm_for_testing is off and TS only applies GAM targeting — never
displayed one at all, since every slot is honestly `gam-only`. The badge
now carries its status colour and mark: green ✓ for a confirmed TS
render, blue ◐ for gam-only. Slots with nothing on screen (`empty`) or
nothing visible (`hidden`) stay unbadged, as there is no creative to
label.
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