Skip to content

Add inspection endpoints that show what a deployment runs and decides - #1350

Open
jwrosewell wants to merge 34 commits into
feature/8-module-contextfrom
feature/9-visibility-endpoints
Open

jwrosewell wants to merge 34 commits into
feature/8-module-contextfrom
feature/9-visibility-endpoints

Conversation

@jwrosewell

@jwrosewell jwrosewell commented Oct 14, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Changes

Area Change
crates/trusted-server-core/src 15 files, 3460 lines added, 161 removed
docs/guide 2 files, 280 lines added, 1 removed
crates/trusted-server-adapter-fastly/src 2 files, 216 lines added, 0 removed
crates/trusted-server-integration-tests/tests 1 file, 200 lines added, 0 removed
crates/trusted-server-adapter-axum/tests 1 file, 127 lines added, 0 removed
crates/trusted-server-adapter-cloudflare/tests 1 file, 102 lines added, 0 removed
crates/trusted-server-adapter-spin/tests 1 file, 97 lines added, 0 removed
crates/trusted-server-adapter-spin/src 1 file, 49 lines added, 1 removed
crates/trusted-server-adapter-axum/src 1 file, 43 lines added, 1 removed
crates/trusted-server-adapter-cloudflare/src 1 file, 29 lines added, 0 removed

Closes

Closes #1268

Spec

Spec: inspection-endpoints

Test plan

CI on this pull request at e1b6e44 passed all 20 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 e1b6e44

Checklist

  • Feature PRs: spec linked above, ending in a review log of two or more review rounds
  • 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 the one module context (#1349).

A deployment shows what it runs and what it decided, with no credential, at three addresses that are the same on every deployment. A publisher can point a reader, a regulator or an auditor at them.

What changed after the 51Degrees PR review

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.

  • The inspection pages name the permission policy. /_ts/config shows the policy's name and SHA-256 and never the file, and /_ts/permissions names the policy whose rules resolved the answer.
  • The configuration view's tests load documents that name a policy.

Fixes found in review

  • The policy file refusal names the pattern as written. For [inspect] show = ["permissions.text"] the refusal says the pattern names the policy file the push writes, where it said the pattern was under itself.

Kept current

What this pull request does

Address What it shows Who it answers
/_ts/permissions and /_ts/permissions.json The permissions resolved for the request that asks, the signals that produced them and the terms the data is held under Anyone
/_ts/config and /_ts/config.json The settings the deployment is running, with every secret masked Anyone
/_ts/data What the deployment holds against the Edge Cookie the request carries, with every identifier masked A browser opening the address as a page

One handler in core answers each address on all four adapters, and a parity test checks each. All three are read only. On Fastly each answers before the Edge Cookie lifecycle, so no cookie is set and no identity row is written.

The permissions endpoint

The answer is the evaluation a page receives as window.tsjs.permissions, with four things added that a person asking why a permission is not set needs and a page does not, being whether storage was explicitly withdrawn, which signal modules the publisher configured and which of them contributed a signal, the permission policy whose rules resolved the answer, by the name [permissions] policy gives it, and the version that answered. A policy's name is its file's stem, which two versions of one file share, so the SHA-256 that /_ts/config shows is what matches an answer to one exact file.

It resolves the request's state as a page's is resolved and writes nothing for the reader. The answer is never stored.

The configuration endpoint

It shows the settings the deployment holds after defaults are applied, with values masked as XXXX and every masked path listed.

Three kinds of value are masked.

  1. Every secret, whatever the publisher's document says. The settings loader records each value it fills from the secret store, and those paths are masked, along with every field declared a secret. Then any string that still contains one of those values is masked wherever it sits, a key included.
  2. Every value sensitive by default, unless the publisher shows it, being the origin URLs, the origin host override, the Basic-auth path and username, and the store names and ids.
  3. Everything the publisher hides.

The publisher decides with a new [inspect] section, which holds show, hide and config = false, the last of which answers 404. A pattern that would not be honored as written refuses the configuration, both when a deployment is validated and when the settings load, so a publisher cannot believe a value is hidden when it is shown.

The settings show the domain's permission policy by its name, permissions.policy, and by the SHA-256 of its file, permissions.sha256, neither masked by default, so a publisher can check which exact file a domain runs. The file itself is never shown, because the settings loader takes it out of the settings when it loads the policy, and an [inspect] pattern under permissions.text is refused, because it would change nothing the page shows. ts config push always writes permissions.sha256, so every check counts it as present, and a pattern that names it is judged alike before the push and when the pushed settings load.

A stored document is unchanged. The marking that lets the view find sensitive values is a mode of one serialization call, every other serialization writes what it wrote before, and the [inspect] section is left out of a document unless the publisher wrote one.

Both are readable from any origin

/_ts/permissions and /_ts/config both answer Access-Control-Allow-Origin: *, so another site can render them. A request from another origin carries no cookies, so that site reads the permissions of a request with no stored signal.

The data endpoint

The page shows the identity graph record stored against the identifier in the request's own ts-ec cookie, as it is stored, being when it was made, the consent recorded, the location and device class kept, and which partners hold an identifier of their own for the same browser.

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. A browser sends Sec-Fetch-* only to a trustworthy origin, so every browser on a plain http:// host other than localhost is answered 403 too.
  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. The adapters set it again after the operator's response_headers, so a site-wide Content-Security-Policy cannot remove it.
  3. No identifier is shown. The Edge Cookie's value and each partner's identifier show as XXXX. device.h2_fp_hash and device.ja4_class are shown unmasked, as the record holds them.
  4. It is never stored, and no other origin is told it may read it.

These were checked in Chrome against a server on localhost answering by the same rule with the same headers. A page's fetch, the same with the two headers set by the script, a frame and an embedded object were each answered 403. A page that opened the address with window.open could not read it, and could once the sandbox policy was left out.

So two parties other than the reader can read the page. The first is a service worker the publisher's own site registers for the whole site, and the second is whoever holds an identifier and sends it as the cookie with both headers. Neither finds an identifier in the page, which is why none is shown.

A test lists every field the record has, so a field added to the record fails that test until it is decided whether the field is an identifier.

Only the Fastly adapter keeps an identity graph, so Axum, Cloudflare and Spin answer that nothing is held.

Why

Nothing let a reader, a regulator or an auditor see what a deployment runs and what it decided, so each had to take the publisher's word for it.

What breaks

resolve_secret_references_with now returns Result<ResolvedSecrets, _>, the record of where the secrets went, in place of Result<(), _>.

Closes #1268.

jwrosewell and others added 10 commits October 8, 2026 20:10
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.
The deploy check of the inspect patterns now sees every secret leaf the settings load resolves, including a labeled Edge Cookie block's passphrase and a module's declared secrets. A show pattern naming one of them is refused at ts config validate as it is at startup. A test covers both kinds. Comments in the inspection code now state each rule once, and the API reference says the data page answers 403 on a plain http host other than localhost.
An operator's response_headers replaced headers of the same name as the
response was finalized, so a site-wide Content-Security-Policy removed the
sandbox from /_ts/data and a page that opened it could read the record. The
data page now marks its headers as required, and core sets them again after
the operator's headers on every adapter.
Comment thread crates/trusted-server-core/src/inspect/config/tests.rs Fixed
Comment thread crates/trusted-server-core/src/inspect/config/tests.rs Fixed
@jwrosewell
jwrosewell removed this pull request from stack #1401 October 15, 2026 20:51
The test that loads every declared secret checked each path with an
assertion whose message held the path, and whose failure printed the
value the view held there, which would be the secret if the view failed
to mask it. The test now collects the paths that are not masked, and
the paths not listed as masked, and compares each list with an empty
one, so a failure names every such path and prints no value.
@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
@jwrosewell
jwrosewell added this pull request to stack #1465 October 28, 2026 18:28

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.

Nothing lets a reader, a regulator or an auditor see what a deployment runs and what it decided

3 participants