Repository navigation
Add one module context for every kind of module - #1349
Open
jwrosewell wants to merge 29 commits into
Open
jwrosewell wants to merge 29 commits into
jwrosewell wants to merge 29 commits into
Conversation
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.
…once A route whose parameter the request's permissions withhold answers 403 as before. A value absent where the route runs, or needing permissions that are not resolved there, is a fault in how the module was written or placed, and answers 500 rather than 403. The injection rule is stated in the module_context rustdoc and the trait methods link to it. The route helpers' repeated doc sentence, three repeats of the Edge Cookie snapshot comment and the guide's copy of the parameter table are removed. A misplaced test helper doc is moved back to its function, and the device-signal gate's limit for a module the adapter builds is stated.
This was referenced Oct 15, 2026
The merge applies without a textual conflict. This row hands module routes the module context, so the Sourcepoint proxy's work moved from IntegrationProxy::handle into SourcepointIntegration::route, and the Sourcepoint rewrite (#1183) carried at split/7 lands in route. The handler tests carried with that rewrite called handle with settings and services, which this row's handle no longer takes. They call route with the request and services instead, as the row's other module tests do, and their unused settings bindings are removed.
This was referenced Oct 17, 2026
This branch has not been deployed
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.
Summary
Changes
crates/trusted-server-core/srcdocs/guidecrates/trusted-server-adapter-fastly/srccrates/cmp/sourcepointcrates/cmp/didomicrates/audience/permutivecrates/testing/seam-probecrates/tag/google-tag-managercrates/trusted-server-adapter-axum/testscrates/auction/prebidcrates/permission-signal/mtmcrates/testing/testlightcrates/ad-tag/googlecrates/identity/lockrcrates/bot-protection/datadomecrates/device/fastlycrates/auction/apscrates/permission-signal/tcfcrates/permission-signal/gppcrates/permission-signal/us-privacycrates/permission-signal/gpctools/permissions-inspectorcrates/trusted-server-adapter-cloudflare/srccrates/trusted-server-adapter-spin/srccrates/trusted-server-adapter-axum/srcCloses
Closes #1267
Spec
Spec: module-context
Test plan
CI on this pull request at 6a0561c passed all 32 checks.
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 serveChecklist
unwrap()in production code (clippy deniesunwrap_usedin CI)logmacros as AGENTS.md asks (notprintln!)Stacks on the integration seam (#1348), and depends on it because every module this pull request converts is one #1348 moved into a crate of its own.
What this pull request does
Core builds one context for a request where it calls modules, and each module names what it needs from it. A value whose use needs a permission is passed only to a module that declares that permission and is granted it on the request.
The trait method takes a
ModuleCall, and a new value is one more thing a function may name.The values a function may name, and where each is absent, are listed once, in the
module_contextrustdoc. A parameter may be anOption, which isNonewhen the value cannot be passed. A function that names a value itself is not called when it cannot be passed. A route then answers403when the request's permissions withhold the value, and500when the value is absent where the route runs.DeviceModule::detectSignalInput::call,valid_signalandtdlsgenerateandresolve_from_clientIntegrationProxy::handleA request preparer and a response finalizer stay plain functions on the builder. A preparer runs before anything is resolved and holds the request mutably. A finalizer is handed only what its own module left.
Why
Each kind of module had its own parameter list, so handing a module one more value meant changing every implementation of its trait.
What breaks
The module traits change once, on purpose, so that a further value later does not break them again. Every module in this repository is converted.
DeviceModuledetect(&self, request_info, services)detect(&self, call)EdgeCookieModulegenerate(&self, request_info, input, services)andresolve_from_client(&self, input, services)generate(&self, call)andresolve_from_client(&self, call, payload).IdentityInputandClientResolveInputare removedPermissionSignalModulevalid_signal(&self, consent, evidence)andtdls(&self, consent, evidence)valid_signal(&self, call)andtdls(&self, call).required_permissionsis new, none by defaultIntegrationProxyhandle(&self, settings, services, req)handle(&self, call, req).required_permissionsis new, none by defaultassemble_permissions(consent, evidence, geo, modules)(context, geo, modules)What it costs a request
Closes #1267.