Skip to content

Let a module name the browser's cookies and the shared store keys - #1315

Closed
Jamesr51d wants to merge 645 commits into
IABTechLab:mainfrom
jwrosewell:split/27-prefix-module
Closed

Jamesr51d wants to merge 645 commits into
IABTechLab:mainfrom
jwrosewell:split/27-prefix-module

Conversation

@Jamesr51d

@Jamesr51d Jamesr51d commented Oct 12, 2026 •

Copy link
Copy Markdown

Stacks on a vendor's shared table (#1314). The additions that follow the seven pull requests open here are listed in #777.
Compare split/26-shared-tables with split/27-prefix-module
to see only this pull request's change.

What this pull request does

[prefix]
module = "example"

A prefix module, PrefixModule, which gives two things for the publisher a request resolved to: a public prefix that starts every cookie name the browser holds, and a server key prefix that starts every key in a store publishers share. Core's own module gives ts and ts.<publisher.domain> and runs when [prefix] module selects nothing or names ts, so a stock build's names are byte for byte what they were, with a test.

A module gives the parts and each use writes its own form, because what is allowed differs by use: ts-ec for a cookie, ts.example.com for a store key. The public prefix carries no domain, because a browser already keeps each site's names apart from every other site's. The server key prefix carries the publisher's domain with a single leading www. folded, so www.example.com and example.com share keys and clients.example.com and public.example.com do not, and the domain comes from the publisher the request resolved to, never from the raw request, because a Host header can say anything and a prefix built from one would let any request start a key space of its own.

The template cache is the exception. Its surrogate keys, ts-template and ts-template-url-<digest>, and its store key, ts-template-cache-v<n>-<digest>, keep ts whatever the module gives, so ts cache purge names one key in every deployment. The store key's digest covers the request's host and the public prefix, so no two publishers share a template.

The registry resolves [prefix] module as it does [geo] and [device], refuses a name nothing this deployment runs supplies, and asks the module once when it is built, so a module naming a value its call never carries is refused at startup. Every adapter puts the resolved prefixes on the request's services, and the page handler reads them there. Every cookie core writes or reads is named through the public prefix, the Edge Cookie state carries the prefix it was read under, a module's response header naming a cookie under that prefix is refused as one under ts- was, the page's bundle tag tells the client script the prefix as data-ts-public-prefix so the script holds no copy, and the template fingerprint includes the prefix so a page stored under one is never served under another. A prefix's first part starts with a letter or a single _, so no cookie name starts with __, where browsers give names such as __Host- rules of their own. The client script refuses the same shapes.

The seam probe declares a prefix module giving _probe and probe.<publisher.domain>, and its report route answers the prefixes the request's services carry. The Axum adapter's tests select it with [prefix] module and read back both prefixes through the route.

Why

A deployment that is not Trusted Server, or one host serving several publishers, needs its own names in the browser and its own key space in a shared store, and until now both were fixed in core as ts. The names a reader's browser holds and the keys a store holds are a deployment's identity, so the module that gives them is an extension point rather than a setting, and the default keeps the stock build unchanged.

What breaks

  • Selecting a module other than core's orphans every reader's Edge Cookie, resolved marker, EIDs and tester cookie, and the identity-graph rows behind them. The Edge Cookie is read under the request's prefix alone, so a ts-ec is ignored under _ex, as the_edge_cookie_is_read_and_written_under_the_requests_prefix proves.
  • The cookie constants are renamed, COOKIE_TS_EC to COOKIE_EC and so on, because their value is no longer a whole name. A crate outside core that read them writes services.public_prefix().cookie_name(COOKIE_EC) instead.
  • ServerKeyPrefix::for_settings is removed. A caller takes services.server_key_prefix().
  • A publisher domain written with a trailing dot, example.com., now gives the same server key prefix as example.com, so its loading cache entries are made again under the new key.

How it was verified

CI passed on 580cd27d8 on the fork. Not exercised: no module outside the stock build gives a prefix. The probe's module is the one reader, through the Axum adapter.

References #1285.

Google Tag Manager changed a page through two hooks, on every page of a
deployment that selected it. An attribute hook pointed Google's tag
addresses in `src` and `href` at the first-party path, and a script hook
did the same in the text of inline scripts.

Both are now the one middleware `tag.google-tag-manager`, which runs on
the pages a `[[fetch]]` entry names it for and makes the same decisions.

The script hook kept the text it was holding in the document's state,
under its own id, so that one document's held text could not reach the
next. The handler that replaces it is made for one document and holds
the text itself. The test that an interrupted document leaves nothing
for the next one now asks the registered middleware for a handler twice.

The recorded page is unchanged. Its settings gain the entry that places
the middleware.
DataDome changed a page through two hooks, on every page of a deployment
that selected it.

An attribute hook pointed a `src` or `href` that is a DataDome script's
address at the first-party path, when `rewrite_sdk` is set. That is now
the fetch middleware `bot-protection.datadome`. It makes the same
decision under the same setting, on the pages a `[[fetch]]` entry names
it for.

A head injector wrote the client tag, and left it out for a request the
request filter had marked. A change that depends on the request cannot
be stored for every reader, so it is now the serve middleware
`bot-protection.datadome.tag`, which runs on each reader's copy of the
pages a `[[serve]]` entry names it for. It reads the same mark from the
document's state and writes the same tag.

The design text expected the tag to move to the start of the head. It
does not move. A serve middleware's head markup goes straight before
the script bundle, which is where the hook's markup went, so the
recorded page is unchanged. Its settings gain the two entries.
Prebid changed a page through two hooks, on every page of a deployment
that ran it. A head injector wrote the configuration Prebid.js reads and
the tag that loads the bundle. An attribute hook removed an element
whose `src` or `href` matches `script_patterns`, which is the
publisher's own Prebid script.

Both are now the one middleware `auction.prebid`, which runs on the
pages a `[[fetch]]` entry names it for and makes the same two changes.
Neither depends on the request, so both stay in the fetch phase and what
they write is stored with a shared template, as it was.

The recorded page is unchanged. Its settings gain the entry that places
the middleware.
A browser module can read a setting from the tag the script bundle was
loaded by. Two modules use that today, and each sets its attribute
through a method of the head injector trait, which goes when the page
hooks have converted. The attribute is not a change to a page that an
entry places. It belongs to the module's browser code and is the same on
every page the bundle is written into.

A registration now states it with `.with_bundle_tag_attribute(name,
value)`. The registry puts those after the attributes the head injectors
still ask for, in registration order, and keeps the first value a name
is given as it did. The tag is written as markup with no escaping, so a
name or a value that could end the attribute refuses startup, where the
tag's writer only asserted it in a debug build.

Core's tagging stand-in states its attribute this way, so the test that
a page's bundle tag carries it runs through the new path.
APS implemented the head injector trait, wrote no markup through it, and
used it only to put `data-ts-aps-rendering-mode="publisher_native"` on
the script bundle's tag where a demand source asks for native rendering.

It now states that attribute on its registration and supplies no
middleware, so no entry names it and no page is changed in its name. The
attribute is on the tag in the same case as before, and the recorded
page is unchanged.
GPT changed a page through two hooks, on every page of a deployment that
selected it. A head injector wrote the `tsjs.adInit` bootstrap, and an
attribute hook pointed the GPT script's address at the first-party path
when `rewrite_script` is set.

Both are now the one middleware `ad-tag.google`, which runs on the pages
a `[[fetch]]` entry names it for and makes the same two changes.

The head injector also put `data-ts-gam-attribution="true"` on the
script bundle's tag when `gam_attribution_enabled` is set. The
registration states that attribute now, so it is on the tag of every
page the bundle is written into, as it was.

The recorded page is unchanged. Its settings gain the entry that places
the middleware.
GPT diagnostics wrote its bootstrap and the tag that loads its module
through a head injector, which ran on every page of a deployment that
selected it. What it writes depends on the request, because a reader
turns diagnostics on with `?ts_console=1` and off again, so it has to
run for each reader and never once for a page that is stored.

It is now the serve middleware `ad-tag.google.diagnostics`, which runs
on each reader's copy of the pages a `[[serve]]` entry names it for. The
bootstrap still goes ahead of the script bundle and the module's tag
straight after it, and a request that did not activate diagnostics still
gets neither.

The recorded page is unchanged. Its settings gain the entry that places
the middleware, and so do the settings the browser tests run with, which
select this module for the diagnostics test.
Next.js changed a page through three hooks, on every page of a
deployment that selected it. Two script rewriters worked on the text of
the `__NEXT_DATA__` script and of the React Server Components payload
scripts, and a stream processor substituted the placeholders the second
of them left.

All three are now the one middleware `framework.nextjs`, which runs on
the pages a `[[fetch]]` entry names it for. Its action carries two text
handlers, in the order the rewriters were registered in, and the stream
processor. They share the state the module keeps for a document as they
did, because all three are made for the document together.

The rewriters keep their logic. What each is told about a chunk of text
is now a type of this crate with the same fields, less the selector,
which neither rewriter read. Their tests change by that type's name
alone.

The recorded page is unchanged. Its settings gain the entry that places
the middleware, and so do core's settings for the Next.js auction tests
that the adapters and the parity suite share. The two Google Tag Manager
tests that run Next.js alongside it name both.
Core's JavaScript asset proxy pointed a configured script at its
first-party path, or removed one that is blocked, through an attribute
hook. The hook ran on every page of a deployment that selected the
proxy, and ahead of every module's own.

It is now the middleware `js_asset_proxy`, which runs on the pages a
`[[fetch]]` entry names it for. Its selector asks about the `src` of
`script` elements alone, which the hook checked for itself.

Its place among the page changes is the place the entry gives it. A
middleware is asked about an address as the one named before it left
it, so the proxy is named first where it has to see the address the page
wrote. One test names it ahead of a module and another names the module
ahead of it, and the guide says to name it first.

The tag stand-in that core's tests run beside the proxy becomes a fetch
middleware in the same change, because the two were tested together
through the order of their hooks.

The test that asked the hook about an element that is not a script is
removed. The selector decides that now, and the page test that holds an
image with a configured address covers it. A test is added for a script
tag written in upper case.

The recorded page is unchanged. Its settings gain the entry that places
the middleware.
Core's tests stand two modules in for a vendor's. One carries a deferred
browser module and writes its settings into the head, and the other
rewrites script payloads in two passes. Both made their page change
through the hooks.

Each now supplies one fetch middleware under its own name, and a test
that runs one on a page places it with an entry.

The payload stand-in kept its captured payloads and its held script text
in the document's state, because one rewriter served every document. Its
text handler and its stream processor are now made for one document, and
share what the handler captures between the two of them.

Two tests checked that the payload stand-in had registered a stream
processor before going on. They check that its middleware runs on the
page.

The test that two configurations of a module never share a template now
also checks that each page carries the markup its own configuration
writes. Nothing looked at what the deferred stand-in wrote before.
…ware

Core's tests stand one module in for a vendor's that acts on one
request. Its preparer or its filter marks the request, and its head
injector read the mark and wrote a script ahead of the bundle and its
module's tag after it. What it writes is one reader's, which the page
path kept out of a shared template by handing a shared mode no request
state.

It now supplies one serve middleware under its own name, which reads
the mark from the state the request left. A serve middleware runs on a
reader's copy and nothing it writes is stored, so the same output is
kept out of a shared template by where it runs.

The tests of a request a module acts on place the middleware with a
`[[serve]]` entry.

The tests that render whole documents compose the serve entry as the
production function does, which runs none on a template every reader
shares. They also run a serve middleware that writes for every reader,
marked or not, and refuse its output in a shared template. Without it a
template that ran the serve entry would still pass, because a shared
mode is handed no request state and the stand-in writes nothing without
a mark.
Core moves the origin's address in `srcset` and `imagesrcset`, as it
does in `href`, `src` and `action`, and no test said so. Taking either
rewrite out left every test passing.

This test runs both through the document processor, with a bare host
among the candidates of the `srcset`, and a `srcset` that names another
host, which is left as it is.
Every module that changed a page through a hook now supplies a
middleware, core's JavaScript asset proxy and its test stand-ins
included, so nothing implements the hooks any longer.

Removed from `trusted-server-core`:

- `IntegrationAttributeRewriter`, `IntegrationScriptRewriter`,
  `IntegrationHtmlStreamProcessorFactory` and `IntegrationHeadInjector`
- their context types, `IntegrationAttributeContext`,
  `IntegrationScriptContext`, `IntegrationHtmlContext` and
  `IntegrationHtmlStreamContext`
- the registration methods `with_attribute_rewriter`,
  `with_script_rewriter`, `with_html_stream_processor` and
  `with_head_injector`, and the registry's four lists behind them
- `AttributeRewriteOutcome`, which was the registry's answer after
  asking every attribute rewriter
- `ScriptTextAccumulator`, which kept a script's text in the document's
  state because one rewriter served every document. A text handler is
  made for one document and keeps its own.

The document processor asks nothing of the registry about a page any
more. It moves the origin's address in the five attributes it always
has, and carries out the plan of the middleware an entry names.

A request's state reached a document through the hooks, and a function
handed a shared mode none of it, so that nothing made for one request
could be stored. That function is removed with its reader. What a
request left now reaches a document through the `[[serve]]` entry alone,
which does not run on the pass that builds a template every reader
shares, and the fetch chain is handed none of it. The tests that render
whole documents fail if the serve entry is run on a shared template.

Tests of a hook are removed where a middleware test already asserts the
same thing. The test that a registration from the auction plan goes
ahead of a section's module reads the order from the middleware names
the registry lists.

The guides describe a middleware as the one way a module changes a
page, and the changelog lists what is removed.
A tool that writes a configuration, as `ts audit` does, selects the
modules it finds on a page. A module's page changes run only where an
entry names its middleware, and a tool had no way to learn those names
without naming a module itself.

`trusted-server-modules` now lists the middleware the stock modules
supply, core's script proxy among them, each with the integration that
supplies it and the phase it runs in, in the order the registry
registers the modules. `middleware_for` gives the names for a set of
integration ids and a phase.

The list is held to what the modules register. One test builds a
registry from the settings each module's recorded page is made with and
compares what it registers for each phase, and another holds the order
to the stock builders'. The registry gains `middleware_in`, which gives
the names that run in a phase.
`ts audit generate` writes a draft configuration that selects the
modules it can configure from what it finds on a page. A module changes
a page only where an entry names its middleware, so a draft with no
entry selected modules that changed nothing.

The draft now ends with one `[[fetch]]` entry, and one `[[serve]]` entry
where a selected module supplies a serve middleware, that run those
modules' middleware on every HTML page. The names and their order come
from the stock list, so the tool names no middleware itself. The script
proxy is placed with the rest, so an asset an operator enables after
review is rewritten as well as served.

`trusted-server.example.toml` documents the two entries with every
middleware the stock modules supply. The stock list's tests hold the
template's entries and the configuration guide's table to the list, in
order, so neither can fall behind a module.

The guide's table is put in that order, and the changelog says how a
deployment keeps a page as it was when it upgrades. The lockr guide and
the getting started guide say a page change needs an entry, as the other
module guides do.
The tests of a module's page change kept the names of the hooks they
once called, such as `head_injector_emits_config_script` and
`attribute_rewriter_rewrites_gpt_urls`, which name traits that no longer
exist. They are renamed for the parts of a middleware they now test,
`head_markup_`, `element_handler_` and `text_handler`. Thirty-nine names
and section comments change in eight module crates, and no test's body
does.

Four comments in core still spoke of script rewriters. They speak of
text handlers, and the description of the state a document's middleware
share says what it holds in each phase. The test of the payload
stand-in's placeholders is renamed, because the stand-in no longer keeps
them in that state.
A sandbox that serves several requests kept the one application it built
first and handed it to every request after. That is right while a service
reads one app-config blob. The commit after this one lets a service read a
blob for each host, which needs an application for each, and an
application built from one publisher's blob must never answer a request
for another's.

The sandbox's payload is now `RetainedApps`, which keeps each application
under the key of the blob it was built from and finds one by that key and
by nothing else. It retains only a successful build and counts every
attempt, as the framework's one slot did. The framework hands its payload
out by shared reference and offers no way to replace it, so the holder is
made once and its list sits behind a `RefCell`. It keeps the eight
applications served most recently, because all of them share the
sandbox's one memory bound.

The build count a sandbox reports, in the counters probe, the response
headers and the retirement line, is the holder's. A test now holds the
count a response carries to the builds made, which nothing did before.

Nothing changes for a service that reads one blob. It builds one
application under one key.
One Fastly service can serve several publishers, each from an application
config of its own. A `__KEY` selector that contains `{host}` is a
template. The entry point puts the request's host in the placeholder's
place, lowercased and without a port or a trailing dot, before anything
reads settings, so the debug probes, the application build and the
finalizers all read the blob of the publisher the request is for.

A host with no blob is answered `421 Misdirected Request`, and the answer
is not to be stored. It is never answered from the logical store ID or
from another host's blob, because a shared fallback is how a host nobody
configured comes to be served another publisher's site. Only letters,
digits, hyphens and dots make a host name, so a `Host` header cannot be
made into the logical store ID or the key of a blob's chunk, each of
which carries an underscore. A lookup that fails is answered `500`,
because a request is not served while it is unknown which blob is its
own.

The placeholder goes in the selector that already chooses the key, and
not in a switch of its own, so that a staged version stays apart from
production. A staged deploy copies a service's runtime entries into a
store of its own and points only the key selector at
`<logical-store-id>_staging`. A separate switch would be copied with the
rest, and a staged version would then read the production blobs. The
selector is replaced, so a staged version reads the one staging blob.

A selector without the placeholder is the key as it stands and nothing is
looked up, so a service that reads one blob is unchanged.
`scripts/config-by-host-local-test.sh` pushes an app-config blob under
each of two hosts and one at the logical store ID, each with a stub
origin of its own, and runs the adapter under Viceroy. Without the
placeholder every host reads the one blob. With it each host is answered
from its own origin, in any letter case and with a port or a trailing
dot, and a host with no blob, the logical store ID sent as a host and a
longer name that ends in a publisher's host are each answered 421 with
no publisher's page.

Each response carries the sandbox's counters. In the default mode the
fourth response is the first request of its sandbox, which has built one
application. In `reuse` mode, built with the reusable sandbox, it is the
fourth request of its sandbox, which has built two. Two publishers then
shared a sandbox and never an application, which is the fault a unit
test cannot show because it sits in the entry point.

CI runs both modes in the Fastly job.
The configuration guide's section on the Fastly runtime config store
gains "One blob for each host", with the push for each host, the selector
entry, a table of what each request reads and is answered, and what the
publishers on one service share. The Fastly guide points to it, and the
changelog records the addition.
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.
`ts keys rotate` and `ts keys deactivate` do from an operator's machine
what `POST /_ts/admin/keys/rotate` and `POST /_ts/admin/keys/deactivate` do
from inside the running service. They drive the same rotation code in core,
`KeyRotationManager`, over two store implementations that write through the
`fastly` CLI with the operator's own token from `FASTLY_API_TOKEN`.

The token reaches `fastly` in its environment and a private key reaches it
on standard input, so neither is in an argument list another process can
read. Every value is joined to its flag with `=`, so a key id beginning
with `-` is never read as a flag.

A key id is checked before anything is asked of `fastly`. A new key takes
the rule the rotate route applies, `kid_is_creatable`. A key being retired
takes the looser rule the deactivate route applies, exposed here as
`kid_is_well_formed`, so that a key created under an earlier rule can still
be removed. Neither command takes `current-kid` or `active-kids` for a key.
Named as a key, either entry would be written over by a rotation or deleted
with the key.

Two things differ from the stores the service reads, and the command's
stores allow for both.

- A read is a call to the platform and can fail for a reason other than a
  missing entry. The rotation library takes any failed read for an entry
  that does not exist, which would write a key list without the keys
  already active, or let the current key leave the active set. So the
  stores tell a `404` from any other failure and write nothing once a read
  has failed.
- The service's management client takes a `404` on a delete for success,
  which is how a delete tried again after a partial failure finishes. The
  stores do the same, after asking for the store, because the API answers
  `404` for a missing store too.

`fastly_cli` holds the runner, so the next command that administers a
service through the host uses the same one. Its report of a failed `fastly`
names the command by its words and says what `fastly` printed, including
when the process stopped before it read its input.

The command comes before the removal of the two routes, so that no commit
leaves an operator without a way to rotate a key.
The command called `POST /_ts/admin/cache/purge` on the publisher's domain
with a shared password. It now runs `fastly service purge --key` with the
operator's own token from `FASTLY_API_TOKEN`, so the credential that can
empty the cache stays with the platform's access control and off the domain
every reader reaches. A token with the `purge_select` scope is enough, which
allows a purge by surrogate key and does not allow a purge of everything.

`fastly service purge` is the Fastly CLI's name for the command since its
version 14.0.0, where `fastly purge` became a deprecated alias. The
repository pins 15.1.0.

The surrogate key is derived in the command by `reader_url_surrogate_key`,
the function the service tags a cached page with, and `--all` names
`ts-template`, the key every cached object carries. Both scopes purge what
they purged before. Neither runs `fastly service purge --all`, which would
also flush objects the service cached without one of those keys.

`--service <url>`, `--username` and `TRUSTED_SERVER_ADMIN_PASSWORD` go and
`--service-id <id>` arrives. A service id that is not letters and digits is
refused, because it becomes an argument of the `fastly` process.

The tests that drove the command against a fixture service are replaced by
tests against a fake `fastly`. The command changes before the route is
removed, for the reason `ts keys` comes first.

The guide's command table gains the two `ts cache` rows it lacked.
`GET /_ts/admin/ec` and `GET /_ts/admin/ec/{id}` read any reader's identity
graph record, and `GET /_ts/admin/eids` decoded the caller's EID cookies,
on the publisher's own domain behind one shared password. `GET /_ts/data`
shows a reader what is held against that reader's own Edge Cookie and asks
for no password, so the operator lookups go.

What goes with them: `ec/admin.rs`, `KvIdentityGraph::lookup_raw`, the
decoder's diagnostic view of an EID cookie, the two probes that established
handler coverage for the lookup by id, the adapters' routes and their
not-supported stand-ins, and the tests and guide sections for all of it.

What stays is the check that keeps administration paths away from the
origin. It moves to `closed_paths.rs` as `closed_path_response`, with the
matcher unchanged: every path beneath `/_ts/admin`, and the `/admin/keys`
alias beside it, as sent and after each of up to four percent-decoding
rounds. A request there that no route claims is answered `404` by the
server, so a caller's `Authorization` header and body are never forwarded.
The three removed paths are answered that way for every method, where a
method other than GET was answered `405`.
Jamesr51d and others added 24 commits October 13, 2026 17:18
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
# Conflicts:
#	docs/guide/configuration.md
@jwrosewell

Copy link
Copy Markdown
Collaborator

Moved into this repository as #1368, 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.

@jwrosewell jwrosewell closed this Oct 15, 2026
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.

2 participants