Repository navigation
Conversation
The integration guide, the integrations overview, the configuration guides and the onboarding page still described an integration as code inside `trusted-server-core`, or as "still in core" with the name its crate would have. Every page integration now has a crate of its own, so they say that: an integration is a crate under `crates/<type>/<vendor>/` here or in a repository of the vendor's, and a module's name is that crate's path. The agent notes list the module crate folders in the workspace layout, and the onboarding table gains the rows for a module crate and for the stock list.
Core's error type had a `Prebid` variant that only Prebid raised. Prebid's page integration and the Prebid Server demand raise `Integration` under their own names now, as every other module does, and core's error type names no vendor's variant. A reader sees no difference. Both variants answer 502 with the generic message, and the detail is logged as "Integration error (prebid)" or "Integration error (auction.prebid-server)" where it was "Prebid error".
`models.rs` held `AdResponse` and `Callback`, the response of one ad server, kept with dead code allowed "for external ad-server compatibility". Nothing in the workspace reads either type, so the file goes, with the ten tests of how the two types deserialize. The `Gam` error variant named Google Ad Manager and nothing raised it, so it goes from the error type and from the tables that list every variant.
Core's OpenRTB model held `RequestExt`, `PrebidExt`, `ImpExt`, `PrebidImpExt` and `ImpStoredRequest`, the typed `ext.prebid` objects of a request and an impression. Core's driver writes an implementation's extensions as the objects the implementation hands it and uses none of these types. Only the provider that Prebid Server's crate keeps for its tests builds a request through them, so they sit beside it there, compiled for its tests alone.
The limit on the zone a browser names for a slot was `MAX_PREBID_ZONE_BYTES`, and the admitted request headers were described as approved for "Prebid transport forwarding". Routing admits both for every demand source, so the limit is `MAX_ZONE_BYTES` and the headers are approved for forwarding to a demand source.
Each module crate says who maintains it in `[package.metadata.maintainers]` of its manifest, the way Prebid.js requires a named maintainer of every adapter. Three crates did not: the seam probe, the stock list and, in part, Testlight, which named an owner and no status. They do now, and the demonstration ad server's entry, which read "No vendor has not yet adopted this crate", says that the crate is the project's own. A status is one of three. `vendor owned` is a crate its vendor has adopted, `seeking vendor owner` is one the Trusted Server maintainers hold for a vendor, and `project owned` is one that is the project's own. A test in the stock list crate reads the manifest of every crate under `crates/<type>/<vendor>/`, and its own, and fails for one with no declaration, no owner or a status that is not one of the three. The integration guide states the convention and its checklist asks for it.
Deploy validation reads the integration builders the process registered, because the app config validates through a trait that takes no arguments. The `ts` tool registers the modules a stock build ships before it validates. Three other places validate a configuration in a process of their own and registered nothing, so once the Prebid Server demand left core they refused the fixture's `[demand]` source as an implementation the build does not have: - the integration test crate's Viceroy configuration generator, which is the "Generate integration Viceroy configs" step of the integration workflow, - the integration tests' own configuration envelope, - two of the command line tool's integration tests in `audit_config_diagnostics.rs`, one of which asserted a refusal and would have passed for the wrong reason. Each registers the stock list first, as the tool does. The tool's own unit test of a refused candidate validates once before the code under test registers, so it registers too. The integration test crate gains a dependency on `trusted-server-modules`.
`generated_blob_verifies_and_applies_origin_override` builds the envelope and then loads settings from it the way an adapter does at startup. It called the loader that knows core's own modules alone, so it refused the fixture's Prebid Server source once that implementation left core. It passes the stock list, as every adapter does. The integration workflow runs this binary's tests before the container tests, so the failure also hid whether those run.
The plain OpenRTB demand implementation is `auction-protocol.openrtb`, and it now lives in `crates/auction-protocol/openrtb`. The stock list offers it, so a `[demand.<name>]` table with `implementation = "auction-protocol.openrtb"` selects it as before and its `request_ext` and `imp_ext` settings do not change. It is the project's own, and its manifest says so. It was the last module in core's `integrations` folder, which now holds the registry, the builder a module registers with and the JavaScript asset proxy. Core's built-in list offers no auction implementation. What reads an ordinary OpenRTB response is core's and not this implementation's, so `parse_openrtb_response` moves beside the response extraction it wraps in `auction::openrtb`, and stays public for an implementation in a crate of its own. Core's tests of the plan compiler, the routing, the request driver and the page path ran on the plain implementation. They run on a stand-in compiled for core's tests only, `auction.plain-fixture`, which takes every eligible slot, inherits the auction's timeout, leaves its endpoint as written and writes the `request_ext` and `imp_ext` its table sets, so both golden requests stay in core unchanged. `demand_named` takes the implementation its sources run. Two of core's tests were the implementation's own and went with it, under names that say what they cover: - `openrtb_extensions_are_bounded_and_cannot_claim_reserved_fields` is `static_extensions_are_bounded_and_cannot_claim_reserved_fields`, - `standard_static_extensions_have_no_invented_bidder_param_location` is `static_extensions_have_no_invented_bidder_param_location`. The crate also holds both golden requests, signed and unsigned, byte for byte as core holds them, a test that its endpoint is left as written and the test that its name is its folder. The Prebid crate's two tests of a bidder that runs on both sides name the plain implementation and hand its builder to the validation they call.
…tegration-seam-impl
Core builds one `ModuleContext` where it calls modules, by borrowing what the request already holds, and every module called there borrows the same context. A module is handed a `ModuleCall`, which is the context together with the permissions that module declares, and its process function names what it needs as parameters. `ModuleCall::inject` passes each one in from the context. A parameter is the request (method, path, query, host and scheme), the reader's evidence, the connection, the response, the resolved permissions, the decoded consent, the settings, the services, the location, the device signals, the Edge Cookie identifier, the document state or the request's typed extensions. It may be an `Option` of one, which is `None` when the value cannot be passed, or a tuple of them. A function takes the module first and up to eight parameters, `inject_with` passes one argument of the caller's own after the module, and the function may be async. The location, the device signals and the Edge Cookie identifier each carry the permissions the module that produced them declares. Such a value is passed only to a module that declares every one of those permissions and is granted them on this request. A withheld value is `None` for an `Option` parameter, and a call that names the value itself is skipped, with the reason in the debug log. Core holds modules as trait objects, which cannot have a generic method, so each kind of module's trait method will take a `ModuleCall` and the module's implementation hands its own function to `inject`. That call is made where the module's type is known, so nothing is boxed, allocated or copied. Nothing calls the context yet. The commits that follow hand it to each kind of module. `PermissionSet` gains `difference`, and a `Display` that lists its Data Use identifiers, which the reason for a skipped call uses.
`DeviceModule::detect` takes a `ModuleCall`, and a device module names what it reads through `inject`. Classification now sees the request it classifies, being its method, address, host and scheme, and the settings, as well as the evidence and the services it had before. The Fastly entry point is the one place a device module is asked. It builds the context from the Fastly request before that request is converted, reading the host from the `Host` header and the scheme from the TLS mark the entry point has already set from Fastly's own metadata. The module it asks is borrowed, whether a registration supplied it or the `[device]` selector built it, so the request allocates nothing more than before. Classification runs before the permissions are resolved, so no value whose use needs a permission is passed to it. Signals stay infallible. A module whose function is not passed what it names answers `DeviceSignals::unknown`, an unknown device that does not look like a browser, and a test asks the built-in module with no evidence to show it. The built-in module converts in core, and the Fastly module and the seam probe's in their own crates. `module_context::test_support::request` gives a module's own tests a request, behind the `test-utils` feature.
`EdgeCookieModule::generate` takes a `ModuleCall`, and `resolve_from_client` takes a `ModuleCall` and the posted payload. A module names what it needs through `inject`, or `inject_with` for the payload. `IdentityInput` and `ClientResolveInput` are gone, because the permissions and consent they carried are in the context, along with the request the identifier is created for, its host and scheme, the settings, the location and the device signals. `EcContext` snapshots what the request resolved to beside the headers it already kept, under the same condition, so generation later in the request and orphan recovery hand the module the request they are for. The snapshot holds the host and the scheme where it held the path and the query alone. `candidate_id` takes the settings and builds the context from the snapshot and its own state. The resolve endpoint builds the context from the resolve request and the state read for it, and lends the module the normalized client IP that state holds, the same form the module sees when it generates. The built-in HMAC and host signal modules, the `client_fixed` module and the shared wrapper convert in core, and the seam probe's module in its own crate. A module whose function is not passed what it names produces no identifier. The HMAC module still refuses a request whose evidence carries no client IP. The test of the evidence a module reads at generate time also checks the host and address reach it, and the resolve round trip checks the module is handed the resolve request.
Permission assembly builds one module context per request, carrying what the request resolved to, the evidence, consent, settings, services and location, and every signal module is asked in it. `assemble_permissions` takes that context in place of the consent and evidence, and reads both from it. `SignalInput` keeps every field and gains the context, and `call()` gives a module its call into it, so `signal` can name anything else the request offers. `valid_signal` and `tdls` take a `ModuleCall`. A module declares `required_permissions`, none by default, though nothing gated is passed here because the permissions are what the run produces. `SignalInput::new` still gives an input for a module asked on its own, in `ModuleContext::empty`, and `with_context` puts one in a request's context. The request view on this path borrows from the request, so assembly allocates nothing more per request. `http_util` gives the host and scheme borrowed, and `RequestInfo::from_request` owns copies of them as before. The GPC, GPP, Model Terms, TCF and US Privacy modules read their signals and terms in their own functions, handed to the call, each naming only what it reads, being the consent record for the four IAB schemes and the evidence for the Model Terms. `ModuleContext` is `Copy`, being borrows alone, so a caller with no request builds on the empty context. The permissions inspector does, and the combined signal tests assemble in a context. A new core test shows a module answering for the host of the request it is handed.
`IntegrationProxy::handle` takes the route's `ModuleCall` and the request, in place of the settings and the services, and a route declares the permissions it needs with `required_permissions`, none by default. The registry builds the context for the request it dispatches from the request's resolved state, so a route can name the permissions, consent, location, device signals and Edge Cookie identifier resolved for the request, as well as the settings and the services. The identifier only reaches a route that declares the permission its use needs, as for every other kind of module. Each route hands its own function to `inject_with`, with the request as that function's own argument, and the function names only what it reads. Seven routes name the settings and the services, four name the services alone, and the APS renderer, which answers the same page to everyone, takes nothing from its call. The tests of a route call its function directly. The route takes the request, so the context holds a copy of what the request resolved to, being its method, address, host and scheme. It holds no copy of the headers, because the route has the request itself, so a route is not passed the evidence. The Axum, Cloudflare and Spin adapters dispatched a module route with an empty Edge Cookie context, which carried no permissions. They now resolve it for the request, as the publisher path on those adapters does and as Fastly does for every request. On a navigation the registry generates an identifier for the route's context when the permissions allow, as it does on Fastly. These three adapters do not run the Edge Cookie finalization that writes the cookie on a response, so on them that identifier lasts for the one request. A new test shows a route handed its request's permissions and identifier, and one that declares nothing seeing the permissions and not the identifier. `ModuleCall::empty` gives a call outside any request, for a test of a route that takes nothing.
The response, the document state and the request's typed extension map leave the context, with `ModuleResponse`, `ModuleExtensions` and the three builder methods that set them. Nothing here sets them. They belong to the middleware that follows and arrive with it, so every value the context offers has a caller that hands it. The table of what is absent where now says what this tree does. The evidence is absent in a route, which holds the request itself. The connection, the permissions and the consent are absent where the device is classified, and the permissions where the signal modules run. The settings and the services are absent outside any request, which is the empty context a module asked on its own is called in.
A new section sets out the module context for a module author: the function a module hands to its call, the values that function may name, what happens when one cannot be passed, the permissions a gated value needs, and what the context carries for each kind of module. The example is a route that names the settings and, when it may be passed, the location.
A deployment answers `GET /_ts/permissions` with a page, and `/_ts/permissions.json` with the same information as data, with no credential. It shows the permissions resolved for the request that asks, the signals that produced them and the terms the data is held under, so a publisher can point a reader, a regulator or an auditor at it and nobody has to take anybody's word for what was decided. The answer is the evaluation a page receives as `window.tsjs.permissions`. `PermissionState::page_value` is now the one place that shape is spelled, and `page_json` writes it. The endpoint adds what a person asking why a permission is not set needs and a page does not: whether storage was explicitly withdrawn, which signal modules the publisher configured and which of them contributed a signal, and the version that answered. The handler is core's, `inspect::permissions::handle_permissions`, and all four adapters route to it, so the answer is the same on each, which a parity test checks. It resolves the request's state as a page's is resolved and writes nothing for the reader. On Fastly it answers before the Edge Cookie lifecycle, so no cookie is set and no identity row is written. The answer is never stored, and is readable from any origin so another site can render it. A request from another origin carries no cookies, so that site reads the resolution of a request with no stored signal.
`inspect::config::build_view` gives the settings a deployment is running, after defaults are applied, with values masked as `XXXX` and every masked path listed. Anyone can read the scripts a page runs, and this is what lets anyone read the configuration that runs at the edge. Three kinds of value are masked. 1. Every secret, whatever the publisher's document says. The settings loader now records each leaf it fills from the secret store, with the value it wrote, and the view masks those paths, every leaf declared a secret, and then any string that still contains one of the values, wherever it sits, a key included. 2. Every value sensitive by default, unless the publisher shows it. `Redacted` values are, and so are the settings now written with `redacted::sensitive`, being the origin URLs, the origin host override, the Basic-auth path, and the store names and ids. 3. Everything the publisher hides. The publisher decides with a new `[inspect]` section, `show`, `hide` and `config = false`, each entry a path pattern such as `proxy.asset_routes[].origin_url`. A pattern that would not be honored as written refuses the configuration, both when a deployment is validated and when the settings load. That is a pattern that matches nothing, a `show` naming a secret, a pattern another pattern already covers, a `show` a `hide` covers, a pattern that changes nothing, and any pattern beside `config = false`. Marking is a mode of serialization. `redacted::to_marked_value` wraps each sensitive value under a marker key while it runs, and every other serialization, the configuration push included, is unchanged, which a test checks field by field. The section is left out of a stored document unless the publisher wrote one, which a test checks too, so an existing document gains nothing. The note at the top of `redacted.rs` said the build script compiles the file. It does not, on this branch or on `main`, so the note goes. `handle_config` answers `/_ts/config` and `/_ts/config.json`. No adapter routes to it yet.
`GET /_ts/config` answers the masked view of the running settings as a page, and `/_ts/config.json` as data, with no credential, on all four adapters. Each routes to core's `handle_config`, so the view is the same on each, which a parity test checks. It says nothing about the request, so on Fastly it is answered ahead of everything a request passes through, and the Edge Cookie lifecycle does not run. `[inspect] config = false` answers `404`. The API reference describes the endpoint, and the configuration guide describes `[inspect]`, what is always masked and what is masked by default.
`inspect::data::handle_data` answers `/_ts/data` with the identity graph record stored against the identifier in the request's own `ts-ec` cookie, being when the record was made, the consent recorded, the location and device class kept, and which partners hold an identifier of their own for the same browser. A deployment that writes an identifier to a browser can show the person using that browser what it keeps against it. The Edge Cookie is `HttpOnly`, so a script on the page cannot read the identifier, and the page does not undo that. 1. Only a browser opening the address as a page is answered, which the browser marks with `Sec-Fetch-Mode: navigate` and `Sec-Fetch-Dest: document`. Anything else is answered `403`. 2. The page is sent with `Content-Security-Policy: sandbox`, so the document has an origin of its own and a page that opened it cannot read it. 3. No identifier is shown. The Edge Cookie's value and each partner's identifier show as `XXXX`. 4. It is never stored, and no other origin is told it may read it. Checked in Chrome against a server answering by the same rule with the same headers. A page's `fetch` was refused, with and without the two headers set by the script, and so were a frame and an embedded object. A page that opened the address could not read it, and could once the sandbox policy was left out. A service worker registered for the whole site did read it, as it reads every navigation on the site, and so can whoever holds an identifier and sends it as the cookie. Neither finds an identifier in the page, which is why none is shown. The record is shown as it is stored. A test sets every field the record has and lists them, so a field added to the record fails until it is decided whether the field is an identifier. The page renderer takes a lede of its own, because this address has no `.json` form, and the mask moves up to `inspect` now that two pages use it. `ec::request_cookie_ec` hands out the identifier a request's cookie carries. No adapter routes to the handler yet.
`GET /_ts/data` routes to core's `handle_data` on all four adapters. Fastly keeps the identity graph, so it hands the handler the graph and the Edge Cookie module the deployment selects. It answers ahead of the Edge Cookie lifecycle, so nothing is written for the reader. Axum, Cloudflare and Spin keep no identity graph, so each answers that nothing is held, and a parity test checks that the three refuse and answer alike. The API reference describes the endpoint, who can read the page and why no identifier is in it.
…form A module changes a page through four hook traits today. The integration seam design replaces them with one middleware contract (section 3.7), and asks that each converted implementation reproduces its recorded output. This is the recording, made by the hooks as they are. `html_processor::test_support::assert_page_is_recorded`, behind `test-utils`, runs a page through the document pipeline of a deployment that writes a module's settings and links its builder, and compares what a reader receives with a file, byte for byte. With `TS_RECORD_PAGES` set it writes the file instead. The script bundle's `?v=` hash is replaced by a fixed word, so a recording changes when a module's page change does and not when a browser module is rebuilt. Eleven module crates and core's own script proxy each gain one test and three files under `src/fixtures`, being the settings a deployment writes, the page the origin sends and the page a reader receives. A `.gitattributes` line keeps the two pages' line endings as committed, as the seam probe does for its script. Between them the recordings pass through all twenty hook implementations in this tree, being nine attribute rewriters, three script rewriters, one stream processor and seven head injectors. Two of the head injectors act on a request their module marked, the Google Publisher Tag diagnostics and DataDome's tag, and the recordings are of a request with no mark, so the first writes nothing and the second writes its tag. Two things the recordings show as they are. The Google Tag Manager page holds `'&l='+l` where the origin's snippet holds `'&l='+l`, because a rewritten script is written back escaped. And Prebid's script removal takes out a link whose address matches a script pattern, as it takes out a script.
The wasm clock lint reaches core's build script, which runs on the host.
Core's version_header module is removed, with its call from the finalize step of the Axum, Cloudflare and Spin adapters and from the Fastly adapter's health probe and its settings fallback, so no response carries x-ts-version unless a [[serve]] entry names the service_version middleware. That middleware now writes the git version the build compiled in from TRUSTED_SERVER__GIT_VERSION or local git as X-TS-Version, when the build knows one, and the version the adapter reports for the service as X-TS-Service-Version, when it reports one. HEADER_X_TS_FASTLY_VERSION, which nothing wrote, goes with the module. The health tests of the three adapters prove the probe carries no version, the middleware's own tests prove each header is written when its version is known and nothing when neither is, and the publisher tests prove both headers reach a buffered page and neither reaches a page no entry names the middleware for. The configuration guide, the getting started guide, the EdgeZero guide and the changelog say the same.
# Conflicts: # docs/guide/configuration.md
jwrosewell
marked this pull request as ready for review
October 14, 2026 12:35
This was referenced Oct 14, 2026
Collaborator
|
Moved into this repository as #1365, one branch of the stack, so the review continues there. This pull request is closed in its favor and its comments stay here for the record. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacks on a middleware that prepares (#1311). The additions that follow the seven pull requests open here are listed in #777.
Compare
split/23-preparewithsplit/24-loading-cacheto see only this pull request's change.
What this pull request does
Core declares a loading cache,
PlatformLoadingCacheintrusted_server_core::platform, which loads a missing value once per key however many requests ask for it at the same moment, gives every request waiting on that load the value or the error, and holds nothing that failed. Each cache is named by aCacheSpecwith the lifetimes of its copies, a brief one in process memory and one in the host's shared store, and keys under aStoreKeythat starts with the publisher's prefix, so publishers sharing a store never share a copy.Each cache is selected by naming, in its own
[cache.<name>]table, a module that offers a loading cache. A table that names none getsNoLoadingCache, which holds nothing and runs every load. A name no linked crate offers a cache from is refused when the service starts, with the names that could be written. On Cloudflare, which builds the state for every request, that refusal is per request. Each robots.txt module's answer is the first thing held this way, so a module is asked once per refresh however many instances and requests want its rules.The implementation is a module crate,
crates/loading-cache/51degrees, maintained by 51Degrees, written with the publishedfiftyone-cachingcrate over each host's own stores, being Fastly's cache and key-value store, Workers KV and Spin's key-value store. Every load runs in its caller. It offers the cache from its builder, the stock list carries it, and an adapter takes the selections from the settings and the builders it is given withloading_caches_from, so no adapter names the crate.Why
The same answer is asked for by every instance and every request, and a cache that lets every miss load at once asks a module's service as many times as there are requests. One load per key at a time, with the result shared by every waiter, is the pattern the other 51Degrees language packages use, and the crate that implements it is published for every 51Degrees service to use. Core keeps only the interface, so core names no vendor and depends on no package.
What breaks
Holding a robots.txt module's answer is now opt-in. Pull request 9 held each answer in the key-value store on Axum, Spin and Cloudflare. With
[cache.robots-txt] moduleunset, which the example leaves commented out, nothing is held and every request for the file asks each module's service. A deployment keeps the holding by adding the table.How it was verified
CI passed on
9dc219c41on the fork. Not exercised: no load has run against a real host store. Fastly's cache is checked on Viceroy, and Workers KV and Spin's store through core's doubles. The page template does not load through the cache.References #1282.