Skip to content

Keep headers that describe the deployment off a publisher's pages - #1302

Closed
Jamesr51d wants to merge 496 commits into
IABTechLab:mainfrom
jwrosewell:split/15-diagnostic-headers
Closed

Jamesr51d wants to merge 496 commits into
IABTechLab:mainfrom
jwrosewell:split/15-diagnostic-headers

Conversation

@Jamesr51d

@Jamesr51d Jamesr51d commented Oct 12, 2026 •

Copy link
Copy Markdown

Stacks on robots.txt from modules (#1301). The additions that follow the seven pull requests open here are listed in #777.
Compare split/14-robots-txt with split/15-diagnostic-headers
to see only this pull request's change.

What changes

A response no longer carries X-Served-By, X-Cache or X-Cache-Hits, unless this server answered a request for a path beneath /_ts.

Header What it tells whoever reads it
X-Served-By Which node answered
X-Cache Whether a cache answered
X-Cache-Hits How many times that cache has answered with the object

No adapter writes a version on a response. What the build was made from is in the attestation document (#1297), under build, and the platform's version number of the service under platform.serviceVersion, which is where a deployment is asked about itself. A deployment that wants the versions on its pages names core's service_version middleware in a [[serve]] entry. The middleware writes X-TS-Version, the git version the build compiled in from TRUSTED_SERVER__GIT_VERSION or local git, when the build knows one, and X-TS-Service-Version, the version the adapter reports for the service, when it reports one, on every page the entry covers and on nothing else.

[[serve]]
media_type = "text/html"
middleware = ["service_version"]

How it works

pub const ROUTE_NAMESPACE: &str = "/_ts";

pub fn strip_deployment_headers<T>(path: &str, namespace: &str, response: &mut Response<T>)
pub fn remove_deployment_headers<T>(response: &mut Response<T>)
  • Who answered decides beneath /_ts. The publisher fallback removes the three from whatever the origin sends, on every path. The finalize middleware of each of the four adapters then removes them from every response outside the namespace. So a request for an unrouted /_ts/anything, which the fallback sends to the origin, does not get the origin's headers back.
  • A path is in the namespace only at a boundary. /_ts and /_ts/permissions are. /_tsx and /_ts-archive are a publisher's pages, and are stripped like any other.
  • The removal comes before the finalize step. A header that finalizing writes is kept, an operator's [response_headers] among them. An operator who wants X-Cache on every page can still set it there.
pub type ResponseHeader = (HeaderName, HeaderValue);

pub struct MiddlewareAction {
    pub response_headers: Vec<ResponseHeader>,
    // ...
}

pub struct MiddlewareContext<'a> {
    pub platform: &'a PlatformIdentity,
    // ...
}
  • A serve middleware may write a response header. Core writes the headers a serve chain returns on the reader's response at each place a serve pass runs, being the assembled copy of a stored page, the streamed copy and the inline copy of a page that is not stored. A fetch middleware that writes one is refused when the chain is planned, because what a fetch middleware leaves is stored for every reader.
  • Every middleware is told the platform, as the adapter reports it for the attestation evidence, through the registry, which each adapter tells once at startup.
  • service_version is core's own middleware. No section selects it, an entry names it, and it writes X-TS-Version, the git version the build compiled in, and X-TS-Service-Version, the platform's version number of the service, each only when its version is known.

Why

A response to a reader carries what the page needs and nothing about the deployment, so that the response stays as clean and free of clutter as it can be. The headers above describe how the site is built, to anyone who asks for any page, without that being a decision anyone made. Three arrive from the publisher's origin or a cache on the way to it, and the version was written by the adapter itself.

A question about a deployment has addresses of its own. The inspection pages (#1294) answer beneath /_ts, and so does attestation (#1297) unless its endpoint is moved, and what each shows is the publisher's decision. The rule here is the same one. What describes the deployment is answered where a deployment is asked about itself, and is left off everything else. A deployment that has a reason to put the service version on its pages says so in its settings, with the middleware, and the decision is then its own.

What breaks

  • An operator who reads X-Cache or X-Served-By from a page fetched through this server, to see whether the origin's cache answered, no longer sees them. The origin still sends them, and they can be read at the origin.
  • A check that read X-TS-Version from a page no longer finds it, whichever adapter served the page and whichever version it carried. It reads the attestation document, where build says what the build was made from and platform.serviceVersion is the platform's number, or the deployment names service_version in a [[serve]] entry.

How it was verified

CI passed on a43a05a33 on the fork. Not exercised: a response the Fastly entry point finalizes outside the middleware, being the router's own 405 and the two debug endpoints, is not stripped. It carries nothing from an origin.

References #1273.

What an operator selects to supply a capability is a module, and provider
keeps the one meaning it has on main, an auction provider instance. This
renames the words this branch adds on top of the Edge Cookie seam, which
already says module, and only those words: every provider word was
compared with upstream main, and a word that exists there is untouched.

- DeviceProvider is DeviceModule, and BuiltinDeviceProvider,
  FastlyDeviceProvider and the test stand-ins are BuiltinDeviceModule,
  FastlyDeviceModule and so on. DeviceConfig's provider_key is module_key,
  and build_device_provider and build_geo_provider are build_device_module
  and build_geo_module.
- HostSignalProvider is HostSignalModule, HostSignalsProviderConfig is
  HostSignalsModuleConfig, and HOST_SIGNALS_PROVIDER_KEY is
  HOST_SIGNALS_MODULE_KEY.
- The [device] and [geo] selectors read module, as [ec] does, so the
  environment overrides read TRUSTED_SERVER__device__module and
  TRUSTED_SERVER__geo__module, and labeled_provider_block_names is
  labeled_module_block_names.
- Test helpers and test names say module, and the plain word says module
  wherever it meant a device, geo or Edge Cookie module, in the core code,
  the adapters, the device and geo crates, the example settings, the
  fixtures and the guides.
Brings the refreshed device and geo branch, and through it the Edge Cookie
seam and upstream main at 7a0ecb4, into the permission model branch. The
lower branches' names say module, so every line here that used one of
those names says module too, and most conflicts settled as that rename
applied to this branch's own text. The rest, settled by hand:

- Upstream removed the legacy consent store, so the consent pipeline's
  ec_id and kv_store inputs, its read fallback, its KV pipeline tests and
  the Fastly test helper that named a missing consent store are gone, and
  this branch's default jurisdiction input stays. The consent-era tests of
  allows_ec_creation and gate_eids_by_consent do not return, because the
  permission model replaced those functions here.
- Withdrawal is the permission signal modules' answer on this branch, so
  the pull sync marker expiry upstream added reads storage_withdrawn, and
  the consent-derived flag upstream computes above the gate goes.
- The generated-cookie gate test is upstream's two-case table, worded for
  the permission gate.
- build_pull_sync_context keeps this branch's sharing gate inside
  upstream's new signature, which takes the partner registry and returns
  nothing when no partner is pull-enabled.
- The test services keep this branch's geo helper beside the renamed
  Edge Cookie helper, and the publisher keeps gate_eids_by_permissions
  with upstream's sharedid cookie import.
The one-time warning told the operator that the jurisdiction would be
unknown and Edge Cookie creation would fail closed. On this branch neither
is true. With no location the caller supplies the declared jurisdiction,
which is the permission policy's top node, so identity and the auction
both keep running. The warning described behavior from before the policy
tree existed.

That matters because it is the line an operator reads when advertising
stops. Being told identity is blocked when it is allowed sends them
looking in the wrong place, and a deployment that selects no geo module
sees this message on every start.

It now says what happens rather than only what is missing. The
jurisdiction is the declared one rather than one resolved from the
request, country and region rules cannot match, and a lookup that fails,
as opposed to resolving nothing, is the case that does fail closed.
A permission whose baseline requires a signal, and for which every
configured module answered neutral, was simply not in the resolved set,
so a page could not tell "nobody has answered yet" from "refused". A
prompt that never loaded then looked like a visitor who said no.

PermissionState gains awaiting, the permissions in exactly that position,
recorded by resolution alongside the set. It reaches the page as a third
key of window.tsjs.permissions beside set and tdls, and the page core
normalises it like the other two. Nothing yet acts on it. That is the next
step, the page core holding what depends on an awaited permission.
The awaiting list said every permission that requires a signal and got
none. Some of those no module in the deployment could ever grant, the
marketing channel opt-ins in the shipped sample policy for one, and a page
told to wait for them would wait for ever, which is the defect the list
exists to remove.

Each module now declares, through grants on the trait, which permissions
it can answer Grant for under the policy it is given. An opt-out declares
nothing. TCF declares every Data Use a purpose maps to, and nothing when
the policy silences the record. The assembly narrows awaiting to the union
of those declarations, so a permission nothing here could grant is simply
unset.

Start-up compares the policy against the declarations and warns, naming
every permission the policy requires a signal for somewhere that no
configured module can grant. It warns rather than refuses, because the
shipped policy itself carries two such permissions and a deployment that
never uses those channels is not misconfigured.

PermissionSet gains intersect, union and without. PermissionMaps gains
requires_signal_anywhere, the union over the whole tree. The guide and the
seam's README describe awaiting.
…alid ones

Core no longer answers for any scheme. The override that revoked
everything ahead of the modules when a record arrived unreadable is gone,
and with it ConsentContext::has_malformed_record. What an absent,
unreadable or expired signal means for the permissions is the decision of
the module for that scheme, taken in its own answer and taken silently.
The four shipped modules read their own unreadable record as the refusal
it may have carried, on the permissions their scheme covers, and say
nothing about any other scheme, so the configured order decides a
readable record from one scheme beside an unreadable one from another, as
it decides everything else.

The permission state gains signals, one ValidSignal for each signal a
configured module read and found valid, as received, with the module and
the scheme. The page reads it as the fourth key of
window.tsjs.permissions. A signal nobody vouched for goes no further,
because after assembly the consent context keeps only the raw strings the
valid signals name, so a corrupt or expired record never reaches a bid
request.

The start-up comparison of the policy against the modules, added in the
previous commit, is removed. Nothing is logged about a signal.
…ookie

A fifth permission signal module, crates/permission-signal/mtm, identifier
mtm. It reads the answer a preference platform recorded, one of the three
words standard, personalized and non-marketing, from the first party
cookie __mtm_pref. The name carries no vendor in it, so any platform that
asked the visitor may set it, and Trusted Server reads the cookie and never
writes it. With no word present the module says nothing, so the country
and region rules stand, which is clause 5.3 of the Model Terms.

standard and personalized both grant storage, contextual advertising,
measurement, content performance, market research, product improvement
and basic content selection. Only personalized grants the four Data Uses
that depend on browsing history or interactions, and standard refuses
them rather than leaving them alone, because the visitor chose against
them. non-marketing changes nothing: the visitor named neither kind of
marketing, so the module has no marketing choice to act on and the
country and region rules decide. Under a group where every marketing Data
Use requires a signal nothing is granted, and under one where they are
granted the baseline applies. A deployment that wants a decline to bind
there sets that in its own permissions policy.

No word withdraws. The word is the valid signal, as received, and whenever
one is present the module declares the versioned Model Terms document as
the terms the data is available under. Any other value is treated as
absent.

Every adapter offers it last, after the four IAB schemes. The TCF module's
declaration of what it can grant is computed once rather than on every
request. A test over a platform's real Set-Cookie strings pins that the
cookie is never HttpOnly, because a browser lets a script neither read nor
replace such a cookie and the platform that owns the question would stop
seeing its own answer.
What an operator selects to supply a capability is a module, and provider
keeps the one meaning it has on main, an auction provider instance. This
renames the words this branch adds on top of the device and geo seams,
which already say module, and only those words: every provider word was
compared with upstream main, and a word that exists there is untouched,
as is the icu_provider crate the permissions inspector's lock file names.

- PermissionSignalProvider is PermissionSignalModule, and GpcProvider,
  GppSaleOptOutProvider, UsPrivacyProvider, TcfProvider and MtmProvider
  are GpcModule, GppSaleOptOutModule, UsPrivacyModule, TcfModule and
  MtmModule.
- build_permission_signal_providers is build_permission_signal_modules,
  the adapters' shipped_signal_providers is shipped_signal_modules, and
  the permission_signal_providers field and arguments say modules.
- The [permission_signal] selector reads module, as the seams' selectors
  do, so the refusal of the removed sources key names module.
- Test doubles, test helpers and test names say module, and the plain word
  says module wherever it meant a permission signal, geo, device or Edge
  Cookie module, in the core code, the five module crates, the adapters,
  the sample policy, the fixtures and the guides.
The five permission signal crates carry their own unit tests, and no CI job
ran them, because the test-axum alias covered the adapter crate alone. The
test-axum and clippy-axum aliases now name the five crates as well, so the
Axum job tests and lints them.
Comments say what the code does. The "today" qualifiers go, along with
the claim that no shipped geo module reports a lookup failure and the
counts of shipped schemes, which change as schemes are added. Device
storage gates every shipped Edge Cookie module, not only the built-in
one.

The fifteen tests of how from_yaml refuses a malformed policy are one
table, each case named with the refusal it must report, so a new refusal
is one row rather than a new test shaped like the last one.
Brings the refreshed permission model branch, and through it the device
and geo seams, the Edge Cookie seam and upstream main at 7a0ecb4, into
the client-resolve branch. The lower branches' names say module, so every
line here that used one of those names says module too, and most
conflicts settled as that rename applied to this branch's own text. The
rest, settled by hand:

- The Fastly entry point keeps this branch's resolve route import beside
  upstream's EcContext and EidSyncSource imports, and constants keeps the
  resolved-cookie marker beside upstream's pull-complete cookie.
- The seam keeps this branch's client_fixed implementation id and its
  retired spelling, with the built-in list naming all three ids, under the
  trimmed docs the seam now carries.
- The settings keep this branch's refusal of the client-fixed spelling and
  its startup compilation check, with the host-signals refusal gone as the
  device and geo branch removed it.

This branch's own names still say provider, and are renamed in a commit
of their own.
The sandbox tests load settings that select the HMAC Edge Cookie module
through the deprecated passphrase form and no geo module. On this branch
that pair needs the single-jurisdiction acknowledgment, as every other
test settings document in the adapter carries, so without it the settings
refuse to load and the test traps under Viceroy before it starts.
An [ec] resolve_allowed_origins entry such as "https://www.example.com/"
loaded cleanly, then matched no request, because origins_match treats
any value that is not a serialized origin as matching nothing. Every POST
to the resolve endpoint from that origin answered 403, with only a warn
line saying why.

The parse inside origins_match is now parse_serialized_origin in
ec/resolve.rs, and it returns the reason a value is not a serialized
origin. origins_match reads the request's Origin and each entry through
it, and Ec::validate_resolve_allowed_origins refuses an entry it rejects,
naming the entry and the reason. The check runs in
Settings::finalize_deserialized beside the module selection check, which
covers from_toml, from_json_value and the runtime config store load, and
in validate_settings_for_deploy_with, so ts config validate and ts config
push refuse the entry before it can stop an instance loading its
settings.

parse_serialized_origin is stricter than the old closure on both sides.
It requires http:// or https:// and then nothing from a slash, a
backslash, a question mark, a hash, an at sign, whitespace or a control
character before handing the value to the url crate for the host and
port. The old closure let through userinfo, the ftp, ws and wss schemes,
a path that normalizes to a slash, a backslash, a missing double slash
and surrounding spaces, each of which matched itself. A browser sends
only http or https as the Origin of a page's POST, so no real request
that matched before stops matching.

The field doc, docs/guide/configuration.md and trusted-server.example.toml
say each entry must be a bare origin and is refused otherwise.
handle_ec_resolve rebuilt its module with build_module, passing the
module the composition root had threaded into the request as the
injected one. On Fastly that is the built-in module itself when hmac is
selected, so build_module found the built-in name claimed twice and the
endpoint answered 500 for a valid selection, where it should answer 204
for a module that creates nothing from a client post. The same happened
to client_fixed in a build that compiles it, which is the one built-in
module whose page script posts here.

The endpoint now uses EcContext::selected_module, the module the
request's own context resolved. The context's host signal and injected
module fields existed only for the rebuild, so they and their accessors
are removed.

The resolve tests built contexts with no module and relied on the
rebuild, so their helper now resolves the selected module the way a
request's context does. Tests
resolve_answers_204_for_a_threaded_module_that_creates_nothing and
resolve_sets_the_cookie_for_a_threaded_client_fixed_module read a
context with the module threaded through the services, as Fastly does,
and fail without the change.
The Edge Cookie settings refused the old client-fixed spelling with a
message naming the spelling to write. No configuration in this repository
uses it, so the constant, the refusal in validate_module_selection,
names_retired_provider, the test of the refusal and the doc paragraphs
describing it are gone.

An old spelling now fails as any unknown implementation does when the
module is built at startup, with a message listing the implementations
the deployment has, which an_unknown_implementation_fails_naming_the_known_ones
covers.
set_provider_ec_cookie validated and wrote the same cookie as
set_ec_cookie, which no longer checks the HMAC identifier shape either,
so the resolve endpoint had a second copy of one function. The copy is
deleted, handle_ec_resolve calls set_ec_cookie, and the client_fixed
module's doc links to it.

The three tests of the copy fold into the set_ec_cookie tests, as two
tables. One writes an HMAC identifier, an opaque value and a base64url
value verbatim with the security attributes, and the other refuses a
semicolon, a carriage return and line feed, a line feed and a space with
no cookie at all. The second fails when set_ec_cookie stops validating.
Both pages failed the docs prettier check in CI. This is prettier's own
output, with no change to the wording.
The inspector called ConsentContext::has_malformed_record, which went
when each permission signal module took over the meaning of its own
unreadable signal, so the inspector's wasm build failed in CI. The field
is removed rather than replaced, because nothing at this level can
answer it and reporting false would read as none having been found.
Ten resolve tests built the same request and context and differed only
in a value and the answer they expected. They become two tables that
name the case in every assertion and keep every case.

resolve_answers_204_with_no_cookie_when_it_creates_nothing covers the
closed permission gate, no module, no identity graph and a value that
fails verification. resolve_refuses_a_request_it_cannot_take covers no
Origin, a foreign origin, a sibling subdomain, plain http, a port, a
form body and a body over the limit, and now also asserts that each sets
no cookie. Both fail when the origin and gate checks are taken out.
The workspace pins the edgezero adapters to a revision rather than the
v0.0.8 tag, and the inspector resolves against the same crates, so its
own lock follows.
The header double's mint flag becomes the identifier it returns, so a
test can post an identifier the cookie cannot carry, and a second helper
takes a request the test builds itself.

Three tests are added, each failing with the behavior it covers broken:
an identifier outside the cookie bounds is refused with 400 and leaves
no cookie and no identity-graph row, a failed identity-graph write
answers 503 with no cookie, and a Content-Length over the limit is
refused before the body is read.

The reserved-effect tests say created where they said minted, and the
comments say what the code does rather than what it once did.
The docs format job also runs prettier over the Markdown outside docs/,
which rejected both files. This is prettier's own output, with no change
to the wording.
Three tests built the same ClientResolveInput and differed only in the
module, the payload and the identifier expected. They become one table
naming each case: client_fixed with the known word creates the
identifier, client_fixed with another word creates nothing, and hmac
creates nothing from any payload because a server-side module inherits
the no-op resolve_from_client default.
Brings the four commits that fixed the permission branch's CI: the
inspector no longer calls a removed method, its lock follows the
workspace's edgezero revision, and prettier's formatting of the two
permission guide pages, the agent guide and the permission signal README.

# Conflicts:
#	AGENTS.md
What an operator selects to supply a capability is a module, and provider
keeps the one meaning it has on main, an auction provider instance. This
renames the words this branch adds on top of the permission model, which
already says module, and only those words: every provider word was
compared with upstream main, and a word that exists there is untouched,
as is the icu_provider crate the permissions inspector's lock file names.

- ClientFixedProvider is ClientFixedModule, CLIENT_FIXED_PROVIDER_KEY is
  CLIENT_FIXED_MODULE_KEY, and check_named_provider_configuration is
  check_named_module_configuration.
- The test doubles ResolveHeaderProvider and TestIdProvider are
  ResolveHeaderModule and TestIdModule, HEADER_PROVIDER_ID is
  HEADER_MODULE_ID, and the test helpers and test names say module.
- The plain word says module wherever it meant an Edge Cookie module, in
  the resolve endpoint, the cookie and finalize code, the integration
  registry, the demonstration page script, the cargo feature's
  description and the API reference.
resolve_conflicts_when_a_different_identity_already_exists built its
context by hand with no module, so once the endpoint stopped rebuilding
one it answered 204 for no module where the test expects the 409
conflict. The context now carries the module the settings select, as the
other resolve tests' contexts do.
Brings main at 7a0ecb4 and the refreshed lower branches into the
documentation set. Nine of the eleven conflict blocks differed only by the
module rename and are settled as this branch's text with the rename
applied. By hand: the evidence module keeps the HostSignals trait where
this branch moved it, the setup guide keeps this branch's description of
the ec_id value ahead of main's text on grouped mappings, and the Edge
Cookie guide keeps the client_fixed paragraph and takes main's
completion-marker text for the ec_identity_store paragraph where this
branch moved it.
What an operator selects to supply a capability is a module, and provider
keeps the one meaning it has on main, an auction provider instance. This
renames the words this branch adds on top of the client resolve path,
which already says module, and only those words: every provider word was
compared with upstream main, and a word that exists there is untouched,
as is the icu_provider crate the permissions inspector's lock file names.

- The environment overrides the configuration guide and the error
  reference show read TRUSTED_SERVER__EC__MODULE,
  TRUSTED_SERVER__DEVICE__MODULE and TRUSTED_SERVER__GEO__MODULE, and the
  selectors in every example read module.
- The configuration, Edge Cookie, setup, error reference and Fastly
  guides, the example settings, the evidence module's documentation and
  the no-client-IP test's message say module wherever they meant an Edge
  Cookie, device or geo module.
Brings the fix that threads the selected module into the resolve conflict
test's context.
…seam-impl

Brings main at 7a0ecb4 and the refreshed chain below this branch into the
seam implementation. The seam made the Edge Cookie, device and geo traits
async with a runtime services argument, so the Edge Cookie files the chain
refreshed in their synchronous form (the resolve endpoint, the context, the
seam trait file, finalization, the edge cookie helpers, pull sync, batch
sync and the admin lookup) are taken as the chain has them and put back in
the seam's shape, with the seam's own tests that read the config store
through the services restored.

Main's changes that the seam had moved out of core are carried into the
seam's implementations: a Prebid Server stored request is sent only when
the slot's intent allows the fallback, an implementation marks an
impression it has no demand for as omitted, the driver leaves those
impressions out and makes no request when none is left, and the parse
state keeps only the slots that were sent. Main's split between the
reader's and the request's origin requirement is carried into the seam's
publisher, which keys personalization rather than one vendor's tag. The
build script keeps both jobs, the migration guard's generated source list
and the manifest's default config store id.

The names the lower branches gave their identifiers are applied to the
seam's lines, so the seam compiles against the refreshed chain. The seam's
own names keep their spelling for its rename commit. Main's new tests that
name demand sources with hyphens take the snake_case names the seam
requires, and those written against main's plan shape are rewritten onto
the seam's demand table.
Jamesr51d and others added 23 commits October 13, 2026 13:21
main's rule that partner EIDs are written to the identity graph only with consent for personalised ads is stated in the guides as the storage and personalised-ad permissions, the pair the permission model already uses for sharing.
…d run the Cloudflare auction test with no demand modules

The build script's TRUSTED_SERVER__GIT_VERSION reaches test processes and the strict loader refused it as an unknown field. Cloudflare runs one demand source at a time, so the fixture's two demand modules stopped worker startup.
The wasm clock lint reaches core's build script, which runs on the host.
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.
@jwrosewell

Copy link
Copy Markdown
Collaborator

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