Skip to content

Let a middleware prepare with the request's services and refuse a request - #1364

Open
jwrosewell wants to merge 40 commits into
feature/22-creatingfrom
feature/23-prepare
Open

jwrosewell wants to merge 40 commits into
feature/22-creatingfrom
feature/23-prepare

Conversation

@jwrosewell

@jwrosewell jwrosewell commented Oct 14, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Changes

Area Change
crates/trusted-server-core/src 8 files, 1438 lines added, 63 removed
docs/guide 1 file, 55 lines added, 0 removed
CHANGELOG.md 1 file, 1 line added, 0 removed
crates/trusted-server-core/benches 1 file, 1 line added, 0 removed

Closes

Closes #1281

Spec

Spec: middleware-prepare

Test plan

CI on this pull request at 06ca813 passed all 32 checks.

  • 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: the full workflow set of this repository, run on this pull request at 06ca813

Checklist

  • Changes follow AGENTS.md conventions
  • No unwrap() in production code (clippy denies unwrap_used in CI)
  • Uses log macros as AGENTS.md asks (not println!)
  • New code has tests
  • No secrets or credentials committed

Stacks on making a file the origin does not have (#1363).

What this pull request does

A middleware whose decision needs the request's services, or has to wait on the store or the network, answers Middleware::prepare with a future. The request's handler runs it before the document is processed, as the middleware's module with the permissions it declares, and what it answers is kept under the middleware's name and phase for create in that phase to take.

fn prepare<'a>(&'a self, call: ModuleCall<'a>, context: &'a MiddlewareContext<'a>) -> Option<Preparation<'a>> {

    Some(Box::pin(async move {

        let lines = self.lines_for(call, context.request_host).await?;

        Ok(Box::new(lines) as Box<dyn Any + Send>)

    }))

}

fn create(&self, context: &MiddlewareContext<'_>) -> MiddlewareAction {

    let lines: Vec<String> = context.take_prepared(MODULE).unwrap_or_default();

    MiddlewareAction { stream: Some(Box::new(AppendLines::new(lines))), ..MiddlewareAction::pass() }

}

A preparation may answer a Refusal instead, with a status, an optional Retry-After in whole seconds rounded up, and a message, and the request is answered with that status, no document is processed, and nothing is stored or served from the store.

Why

create is synchronous, is given no services, and is asked inside the synchronous streaming paths that the Fastly adapter needs. A middleware that makes a well known file from what the store holds or a service answers, as robots.txt does, could not decide there, and could not fail closed when it had nothing to give, which for robots.txt matters because an empty file tells crawlers to crawl everything. The preparation runs where the handler is asynchronous on every adapter, and only its answer, a Send value, travels to the streaming paths, so create, the plan and every streaming path are as they were.

The rules

  • A middleware that prepares nothing is asked create as before, and nothing of it changes.

  • On a document fetched for the request that will not be stored, both phases are prepared, fetch then serve. On a document that may be stored, both phases are prepared too, so the serve middleware of the reader it was fetched for find what they prepared. The fetch phase is handed the request's services and nothing of the reader, and the serve phase is handed the reader's state. On a copy read from the store, the serve phase alone. The preparations of a phase run in the order the entry names the middleware.

  • The first refusal ends the preparations of a document, and the response it makes is marked private and unstorable.

  • A fetch preparation of a document that may be stored is handed the request's services and nothing of the reader, neither its permissions, location nor Edge Cookie, because its answer is stored with the document.

What breaks

MiddlewareContext, HtmlProcessorConfig and ReaderDocument each gain a required pub field prepared, so every struct literal of them outside this repository stops compiling.

How it was verified

Not exercised: no preparation has run against a real store or network, because the fixtures answer from what they are called with. The preparation on a copy read from the store has no test, because the test services have no template cache.

References #1281.

jwrosewell and others added 13 commits October 11, 2026 09:31
`Middleware::prepare` answers a future for a document whose decision needs
the request's services or has to wait, on the key-value store or the
network. The request's handler runs it before the document is processed,
once per phase the document goes through, as the middleware's module and
with the permissions it declares, and keeps what it answers under the
middleware's name in the context's `Prepared`, where `create` takes it as
the type it prepared. `create` and every streaming path stay synchronous,
so the Fastly adapter is as it was. A middleware that prepares nothing is
asked `create` as before.

A preparation may answer a `Refusal` instead, with a status, an optional
`Retry-After` and a message. The handler answers the request with that
status and the message as plain text, marks the response private and
unstorable, and processes no document, so a middleware responsible for a
file fails closed when it cannot make it. The first refusal ends the
preparations of a document. A copy read from the store prepares its serve
chain the same way before it is assembled.

The middleware fixture gains one that prepares and one that refuses, and the
tests cover what is prepared reaching `create` in either phase, the refusal's
status, headers and body, the chain keeping each answer under its name, and
the first refusal ending the preparation.
The section says when a middleware prepares, what it is called with, where
its answer goes, how a refusal is answered, and what each phase's
preparation must not depend on. The changelog records the addition.
# Conflicts:
#	CHANGELOG.md
#	crates/trusted-server-core/src/middleware.rs
#	crates/trusted-server-core/src/middleware/tests.rs
#	crates/trusted-server-core/src/publisher.rs
…e reader

A document that may be stored is prepared with the request's services alone, so no reader permission or Edge Cookie reaches the shared template. Retry-After rounds up to whole seconds. TextDocument is renamed ResponseDocument, the prepared field docs use one phrase, and the guide states which phases are prepared.
A document that is not stored is prepared in both phases before either is
planned, and both kept what they prepared in one store under the
middleware's name alone. A middleware running in both phases had its fetch
preparation replaced by its serve one, so the fetch create took the serve
value and the serve create took nothing. Prepared is now keyed by phase and
name, and MiddlewareContext::take_prepared takes what was prepared in the
context's own phase.
# Conflicts:
#	crates/trusted-server-core/src/html_processor.rs
#	crates/trusted-server-core/src/integrations/registry.rs
#	crates/trusted-server-core/src/middleware.rs
#	crates/trusted-server-core/src/publisher.rs
…signals

What the fetch phase of a document that may be stored prepares is stored
with the document and served to every later reader, so the preparation must
not see anything of the reader whose request fetched it. Core already gave
it neither the permissions nor the Edge Cookie, but it handed over the
request's services whole, and those carry the reader's client IP, the TLS
details and the host's client signals (TLS JA4 and HTTP/2).

The fetch phase of such a document is now prepared with
StoredDocumentServices, a copy of the services made with the new
RuntimeServices::without_client, which empties the client metadata and
drops the host's client signals. The serve phase, whose answer is this
reader's alone, is prepared with the reader's state as before.

The preparing fixture now writes what its preparation could read of the
reader, and a_fetch_preparation_of_a_stored_page_reads_nothing_of_the_reader
drives a page through handle_publisher_request with a template cache and a
reader carrying a client IP, TLS details, client signals and a User-Agent.
The stored page is prepared with nothing of the reader and none of those
values reach it, and the same page in inline mode, which is not stored, is
prepared with the reader's client IP.

Also repairs an assertion message in the pairing test that carried a run of
spaces where a line continuation was meant.
…error

A middleware's refusal ended the request with no log line, and core wrote
whatever status the refusal carried, a 200 or a 301 included, although a
refusal gives no document.

MiddlewareChain::prepare now passes the first refusal through
Refusal::checked, which logs it once as a warning naming the middleware,
the phase and the status, and answers a status that is not a client or
server error with 500 Internal Server Error, saying so in the same line.
The message is not logged.

The middleware tests add
a_refusal_is_logged_once_as_a_warning_naming_the_middleware and
a_refusal_answers_with_a_client_or_server_error_alone, and the end-to-end
refusal test now checks for the one warning. The tests read the log through
test_support::logs, which keeps what a test logs on its own thread.
…gets

The integration guide and the changelog entry say that the fetch phase of a
document that may be stored is prepared with no client IP, TLS details or
client signals, and that core logs a refusal as a warning naming the
middleware and answers a status that is not a client or server error with
500. Both also said that a preparation's answer is kept under the
middleware's name, where it is kept under the phase and the name.
The preparing middleware's marker, saying what its preparation could read
of the reader, was added to the middleware fixture under the name the serve
reader middleware's marker already had, so the core tests did not compile.
It is now `prepared_reader_marker`, with its two calls.
…logger

A test binary holds one logger. Core's test support installs one for the
whole binary in `test_support::logs`, and the tests of the startup
warning that an adapter sends no telemetry installed a second, so
whichever was installed second failed its test. The warning's tests now
read their lines through `test_support::logs::logged_by`, as the other
tests of a log line do, and each line is its level and then its message.
@jwrosewell
jwrosewell removed this pull request from stack #1408 October 18, 2026 20:15
@jwrosewell
jwrosewell added this pull request to stack #1418 October 19, 2026 12:18
@jwrosewell
jwrosewell removed this pull request from stack #1418 October 19, 2026 16:30
@jwrosewell
jwrosewell added this pull request to stack #1421 October 19, 2026 17:38
@jwrosewell
jwrosewell removed this pull request from stack #1421 October 19, 2026 18:29
@jwrosewell
jwrosewell added this pull request to stack #1422 October 19, 2026 19:20
@jwrosewell
jwrosewell marked this pull request as ready for review October 19, 2026 20:23
@jwrosewell
jwrosewell removed this pull request from stack #1422 October 28, 2026 11:47

This branch has not been deployed

No deployments
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.

A middleware cannot decide with the request's services, wait on a store, or answer a request with a status

2 participants