Repository navigation
Prove a module outside core can serve its own endpoint - #1353
Open
jwrosewell wants to merge 50 commits into
Open
jwrosewell wants to merge 50 commits into
jwrosewell wants to merge 50 commits into
Conversation
Core gains `build_info`, which holds the crate version, the time the build was made, the commit and the build run, each fixed when the build was compiled. A WebAssembly guest is given no custom environment variable when it runs on some hosts, so a fact read then would read as absent there. `SOURCE_DATE_EPOCH` gives the build time where it is set, so a reproducible build stays reproducible, and the time of the build stands in otherwise. `TRUSTED_SERVER_COMMIT` and `TRUSTED_SERVER_BUILD_RUN` give the other two, and each reads `unknown` without its input. The date arithmetic is written out in a file the build script and a test both compile, so the build script takes on no date dependency. Each is the builder's own report and nothing measures the binary. The attestation evidence in the next commit is what reads them.
With `[attestation]` in the settings, core answers with evidence in the sense of RFC 9334. It names the host the request arrived for, the publisher whose settings answered, the platform service, the build, the time and the caller's nonce, and it is signed with ECDSA P-256 over a configured context, one line feed and the evidence bytes. The context keeps a signature made here from passing as one the same key made for any other purpose. A module on a CDN cannot measure its own bytes, so the evidence shows who operates the deployment and not what its code is. A verifier holding the operator's public keys decides whether to trust it, and the page sends the reader there and claims nothing itself. - `[attestation]` holds `endpoint`, `operator`, `context` and `verify_url`, and refuses any other field. The endpoint defaults to `/_ts/attestation`, answers as JSON with `.json` added, and may not be an address the deployment already answers. - The host signed is the request's `Host` header. A forwarded host header is never read. - The signing keys are a schedule the build script compiles in from the file `TRUSTED_SERVER_ATTESTATION_KEYS` names, each in force from its start until the next one starts. No store the settings can reach holds one. A build given no schedule signs nothing and answers 503. - An endpoint under a prefix of its own owns that prefix. The prefix lists its pages and any other address beneath it is not found. Under the shared `/_ts` prefix only reads of the two addresses are taken. A settings validation error now carries the validator's message where it gives one, which is how a refused endpoint names the address it clashed with.
Each adapter registers the routes the settings ask for. Reads reach the handler, and other methods reach the publisher's origin unless the deployment owns the prefix. The Fastly adapter answers ahead of the Edge Cookie lifecycle and the request filters, because the pages are read only and a verifier fetching one from a server must not be turned away as a bot. The Fastly adapter reports the service, the version, the staging flag and the location from the environment variables Fastly gives every Compute service. The Axum, Cloudflare and Spin adapters report their own name. Each adapter's tests hold every fixed route it registers beneath an underscore prefix to the list an endpoint may not take, so a new fixed route cannot leave an endpoint free to register its address twice. The Fastly tests build a router for every endpoint the settings accept, and the parity tests hold the other three adapters to one answer for the owned prefix, the redirect from the default address and a deployment with no section.
`docs/guide/attestation.md` says what the evidence proves and what it does not, the addresses and answers, the settings, the key schedule, the formats, the checks a verifier makes, how replay and relay are met, and the limits. Every test it names is in the tree. The configuration guide gains the `[attestation]` section, the API reference the endpoint and its two rows in the route table, the example configuration a commented block, the docs sidebar the page, and the changelog the addition.
The build script read the key schedule itself, so nothing tested what it took and what it refused. The reading moves to `key_schedule.rs`, a file the build script and the attestation tests both compile, as the build time's arithmetic already is. A schedule's key id and key are written into Rust source as strings. The tests hold that nothing either holds can end the string it is written in, that a line of any other shape stops the build by its line number, and that the message never repeats the line, because the line holds a private key. A file saved with either line ending reads the same.
`scripts/attestation-local-test.sh` makes two signing keys, builds the Fastly adapter with their schedule compiled in and runs it under Viceroy. It then checks what the running adapter serves with OpenSSL and the public key alone, sharing no code with the adapter. The signature verifies over the context, a line feed and the payload. It fails with the other key of the schedule, under another context and once the host in the payload is changed. The key in force signs, and not the one that starts later. The evidence names the host the request named, lower case and without its port, and the publisher, the operator, the nonce, the platform, and the commit, run and build time the build was given. A forwarded host header never becomes the signed host, no nonce is signed unless one is asked for, a malformed nonce is refused, and another method is not answered with evidence. This is the one check of the schedule a build compiles in, because test builds carry no key. CI runs it last in the Fastly job, because it builds core with a schedule.
The inspector builds core from a lock file of its own, and core now uses ECDSA on P-256, so its resolution gains `ecdsa` and `rfc6979` at the versions the workspace lock holds.
A schedule with two lines sharing a key id or a start second now fails the build, so the signing key never depends on line order. An endpoint under a prefix a common framework serves its assets from is refused. The last-key warning is logged once for each process. The guide says GET and HEAD, says where a build leaves its private keys in clear, and says deployment or build for the running binary. Comments that restated the guide or narrated tests are cut.
A builder that names the release tag, as TRUSTED_SERVER_BUILD_TAG, has it reported under build.tag beside the commit and the run, or `unknown` when it names none. A check that reads a deployment after a release compares what is running with the tag that was shipped, without a header on any page.
The wasm clock lint reaches core's build script, which runs on the host.
This was referenced Oct 15, 2026
The handler read the time with current_timestamp, which gives second 0 when the clock fails, so a schedule key in force from the epoch signed evidence issued at 1970-01-01T00:00:00Z. The handler now reads the clock with checked_current_timestamp and answers 503 without signing when it fails. a_clock_that_cannot_be_read_signs_nothing covers the page and the JSON address.
The build checked only the shape and characters of each key in the schedule, so a key that is not a P-256 private key passed the build and answered 503 from the second it came into force, which for a later key could be long after the binary went live. The reading of a key moves into signing_key.rs, which the build script and src/attestation.rs both include, and constant_source reads every key with it, so the build stops at the first key that cannot sign, naming its line and key id but never the key. The endpoint keeps its 503 for a key that cannot sign when it runs. a_schedule_key_that_cannot_sign_is_refused_without_showing_it covers a key that is not base64 and one that is not a P-256 private key, both starting as late as a schedule allows.
Cloudflare registered the attestation handler through make_handler, which runs every registered preparer first and answers a preparer's error in place of the page, so on Cloudflare alone a preparer could change the host or nonce that is signed, or refuse the request. Axum, Spin and Fastly answer the pages without the preparers. Cloudflare now does the same through its own attestation_route_handler. attestation_routes_skip_the_request_preparers registers a preparer that refuses every request, shows that it refuses a request for the publisher's origin, and checks that the list at the prefix, the JSON address and the redirect from the default address answer as they do without it.
The answers table adds the 503 for a clock that cannot be read, and the signing keys section says that a key that cannot sign fails the build, naming its line and key id but never the key. The changelog entry and the API reference say the same.
# Conflicts: # crates/trusted-server-core/src/settings.rs
The registry refuses a second route on one method and path when it is built, whether one module declares both or two modules declare one each, and the message names the method and the path. Nothing tested that before. Two methods on one path are accepted, each routed to the module that declares it.
The probe declares GET and HEAD on /seam-probe/own-path as written, so a test can show a module outside core answering a path of its own. The handler answers a GET with SEAM_PROBE_OWN_BODY and a HEAD with the same headers and no body, which it leaves out itself because nothing in core, the adapters or EdgeZero strips one. It counts its runs against the token in SEAM_PROBE_COUNT_HEADER, which own_path_runs_for reads, through the counter the preparer uses.
On each adapter a GET of /seam-probe/own-path answers from the probe, a HEAD answers with no body, and a POST, which the probe does not declare, never reaches its handler. A module of the test's own that also declares GET on that path is refused with the probe when the state is built. A module route on GET /_ts/config never runs, because the inspection page answers it, while the same module's route on POST /_ts/config does.
The route tests of Cloudflare and Spin run the same five checks, so a module route outside /integrations/ is shown on all four adapters. Both adapters take the probe as a development dependency, and the probe's own documentation now names all four.
The integration guide gains "Serving an endpoint from a module". It says that a module may declare any path, that two routes on one method and path are refused when the registry is built, that a module route is reached only where the adapter answers no route of its own for that method and path, and that the steps before it are the adapter's request cycle. It tells a module author that HEAD and OPTIONS each need a route of their own and that a HEAD route sends no body. The rule that proxy routes sit beneath the prefix, and the Id row of the builder table, now allow a path of the module's own.
The row carries its own copy of the design its tests and guide section follow, docs/superpowers/specs/2026-10-26-module-endpoints-design.md.
This was referenced Oct 28, 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-adapter-cloudflare/testscrates/trusted-server-adapter-spin/testscrates/trusted-server-adapter-axum/testscrates/trusted-server-adapter-fastly/srccrates/trusted-server-core/srcdocs/superpowerscrates/testing/seam-probedocs/guidecrates/trusted-server-adapter-cloudflare/Cargo.tomlcrates/trusted-server-adapter-spin/Cargo.tomlCargo.lockCloses
Part of #777
Spec
Spec: module-endpoints
Test plan
CI on this pull request at acd7975 passed all 20 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 one app-config blob for each host (#1352).
A module outside core can already answer a path of its own on every adapter, so a deployment that wants an endpoint core does not have serves it from a module, and core needs no interface for it. This pull request first added attestation to core. At the 51Degrees PR review on 23 October it was agreed with IAB Tech Lab that attestation leaves the stack, with no attestation interface in core, and it continues on 51Degrees' own deployment. As decided by 51Degrees after the review, this row keeps tests on all four adapters that prove a module outside core serves an endpoint of its own, and a guide section that says how. No production code in core or in any adapter changes.
What changed after the 51Degrees PR review
Agreed at the review on 23 October, point 4. Attestation leaves the stack, with no attestation interface in core.
Agreed at the review on 23 October, point 1. No configuration or permission policy is compiled into the WASM, and a WASM without a policy does not start. Since #1345 a loaded domain carries its permission policy and every services graph needs one, so the tests below give theirs the sample policy.
Kept current
mainat 7aa5c99 and the changes made lower in the stack.What this pull request does
Attestation leaves. The first commit removes what this row's attestation commits added or changed, so the row's files equal those of the row below (Add one app-config blob for each host #1352) before anything new is added. That takes out
attestation.rs, the key schedule and build identity in core's build script, the route on each adapter, the attestation guide and its local test script.A route outside
/integrations/in the seam probe. The seam probe, the test module incrates/testing/seam-probethrough which tests drive the integration seam from outside core, declaresGETandHEADon/seam-probe/own-path, its constantSEAM_PROBE_OWN_PATH. AGETanswers200withSEAM_PROBE_OWN_BODY, and aHEADanswers200with the same headers and no body, which the handler leaves out itself because nothing in core, the adapters or EdgeZero strips one. The handler counts its runs against the token a request carries inSEAM_PROBE_COUNT_HEADER, as the probe's request preparer counts its own.The same five checks on all four adapters, each through the adapter's own router, or through the build of the adapter's state where the check expects a refusal.
GETof/seam-probe/own-pathanswers200with the probe's body.HEADof the path answers200with no body.POSTof the path, which the probe does not declare, never reaches the probe's handler, and the answer is not the probe's.GETon the path is refused with the probe when the state is built, and the error holdsIntegration route registration failed for GET /seam-probe/own-path.GET /_ts/confignever runs, because the inspection page answers it, and the same module's route onPOST /_ts/configdoes.The checks sit beside the probe's other tests on Fastly and Axum, and in the route tests of Cloudflare and Spin, which take the probe as a development dependency.
A registry test. Core gains a test that a second route on one method and path is refused when the registry is built, whether one module declares both or two modules declare one each, and that two methods on one path are each routed to the module that declares them. Nothing tested that before.
A guide section. "Serving an endpoint from a module", in
docs/guide/integration-guide.md, says that a module may declare any path throughroutes(), that two routes on one method and path are refused when the registry is built, that a module route is reached only where the adapter answers no route of its own for that method and path, and that the steps before it are the adapter's request cycle. It tells a module author thatHEADandOPTIONSeach need a route of their own and that aHEADroute sends no body. The rule that proxy routes sit beneath the module's prefix, and the id row of the builder table, now allow a path of the module's own.Why
The agreement at the review rests on a module already being able to serve an endpoint such as an attestation page, so core needs no attestation interface. No test showed a module outside core answering a path outside
/integrations/on all four adapters, and no guide said how a module does so, so this row adds both. The design,2026-10-27-module-endpoints-design.md, also sets out what runs before a module route on each adapter and which requests a module route never sees.What does not change
PlatformIdentity, what an adapter reports about the platform it runs on, stays in core. It was defined inattestation.rs, and row 15 (Keep headers that describe the deployment off a publisher's pages #1356) adds it to core's platform module, withPlatformIdentity::namedand theUNKNOWNconstant, where core'sservice_versionmiddleware reads it.How it was verified
Locally on Windows, on this pull request's tree, these passed:
cargo fmt --all -- --check, clippy-fastly, clippy-axum, clippy-cli, clippy-cloudflare-wasm and clippy-spin-wasm, the core library's tests (2,797),cargo test-axum(960, 1 ignored),cargo test-cloudflare(63, 1 ignored),cargo test-spin(101, 1 ignored), the Fastly adapter's library tests under Viceroy (280), the seam probe's tests (14) and the docs checks.The checks were shown to fail. With the probe's
GETandHEADroutes moved beneath its prefix and aPOSTroute added on the path, checks 1 to 4 failed on all four adapters, and check 5, which does not use the probe's path, passed.References #777.