Highport orbital control

The origin's rules

No templates, no routing, nothing running when somebody arrives. Your visitors get bytes stored earlier, and this is how Orbital Control decides which ones.

Match the host to a snapshot. Match the path to an entry. Stream the stored bytes back. That is the job, and every rule below is a consequence of it.

Everything a request needs was decided when your record was indexed: which bytes answer which address, what content type each one carries, which redirects exist, what the not-found document is. The origin holds a table and streams from it. There is no template to evaluate and no route to compute; the one database query in the whole request path asks which snapshot is live for your domain, and everything after it is a table lookup and a byte stream. Which is why the rules below are exact about addresses and forgiving about nothing.

The order things are consulted

Every request walks the same list, and each step can end it.

# Step Ends the request as
1 Method 405 for anything but GET or HEAD
2 Request target length 414 over 4096 bytes
3 Header block size 431 over 32 KiB
4 The key the edge presents 403
5 Host canonicalization 400
6 Which snapshot is live for that host 404 unknown, 503 unreadable
7 Loading the snapshot 503
8 Reserved paths the reserved artifact, or a bare 404
9 Path resolution 400, a redirect, a 404, or an entry to serve
10 If-None-Match 304
11 A stored content coding the client refuses 406
12 Range 206 or 416
13 Compression, then the bytes 503 if the blob cannot be read

The key at step 4 is a shared secret the edge adds to every proxied request as X-Bard-Origin-Key; the edge is where it comes from, and a request without it never reaches a site. Steps 1 through 5 run before anything is looked up; nothing about your site is consulted until step 6. That ordering is deliberate: a request with no key is refused before the Host header is read, so a 403 for a domain we serve and a 403 for a domain nobody has registered are indistinguishable, and a leaked network position cannot be turned into a list of who publishes here. Getting to the reserved-path check at step 8 means the request already passed the key, the host, the pointer and the snapshot load — GET /_bard/health on an unknown host is a 404, not a 200.

Finding the site

The tenant comes from the Host header and from nothing else. Not the absolute-form request target, not X-Forwarded-Host, which the edge deliberately does not set.

The header is trimmed, the port is split off at the last colon and discarded, and what remains goes through the same canonicalizer that wrote your domain into our table, so a name indexed one way can never be looked up another. One admission rule is not repeated here: whether the name sits above a public suffix is asked when you register it, and a domain already in the table has already answered. Refusals, all of them 400 with an empty body:

The Host is Why it is refused
absent, or empty after trimming there is no tenant to look up
a bracketed literal, an unbracketed IPv6 literal, or an IPv4 address an address names no site here, and a request that arrived this way got past the edge that routes by name
a port that is empty, non-numeric, or larger than 65535 the value is malformed, not ambiguous
a name over 253 bytes, or with a label over 63 DNS limits, checked separately so each failure is its own
a single label, or a name whose last label is all digits neither can be a registered domain
a name whose last label is localhost, local, internal, onion, test, invalid or example reserved names, refused at serve time as at registration
containing ß, ς, a zero-width non-joiner or a zero-width joiner the four UTS #46 deviation characters, which resolve differently in different clients

One trailing dot is stripped, and the name is lowercased as part of UTS #46 ToASCII, so NGerakines.me and ngerakines.me are one site, because DNS is case-insensitive and the Host header is not.

The consequence worth knowing before you go looking for a bug: curl http://127.0.0.1:3000/ against an origin answers 400, and it is supposed to.

When the host resolves to nothing

A 404 and one HTML page, for four different situations: the domain was never registered, it was released, it was suspended, or it is registered and nothing has been published yet. Telling them apart would need the registration table the origin does not hold and must not start holding, and would make the page an oracle for which names are claimed. The page names the domain, links back to Highport, and carries Cache-Control: public, max-age=30.

A 503 is a different statement: the pointer or the snapshot could not be read. The site exists and we cannot serve it. Those are logged loudly at our end, and there is nothing for a publisher to fix.

Paths are exact

Your request path is looked up in the manifest as a key. No pattern matching, no walking a directory, because there is no directory. The manifest is a table.

Nine rules run against that table, in order, and the first one that produces an answer wins.

Rule What it does
1 Percent-decode the unreserved characters, then refuse a decoded .., a NUL byte, or a control character
2 Collapse duplicate slashes and drop . segments, keeping the trailing slash
3 Look up the normalized path and query in the redirect table
4 Discard the query, permanently
5 Look up the path in the manifest
6 If the path ends in /, try that path plus index.html and serve it in place
7 If the path does not end in / and path/index.html exists, 301 to path/
8 Under /.well-known/ or /_bard/, answer a bare 404 and stop
9 Serve notFound with its status, or our own 404 page

Index documents. index.html is the only inferred filename, and there is exactly one of it. A request for /about/ serves /about/index.html when you published that key, and a request for /about answers 301 /about/ when /about/index.html exists. A redirect, not the document, because a browser sitting at /about resolves ./post-1.html against /. No other extension is tried, no other filename, no directory listing.

Case matters. /About.html and /about.html are two different keys. If your source folder came off a case-insensitive filesystem, check the manifest instead of trusting your memory of it. The same holds for a redirect's from and for the reserved paths: /.well-known/Atproto-Did is not the reserved path, so it falls through to ordinary resolution and, with no entry of that name, to rule 8's bare 404.

Trailing slashes matter. /blog and /blog/ are two different requests with two different answers, and rules 6 and 7 are all that passes between them.

An entry beats an index. A manifest key of / serves at the root before rule 6 goes looking for /index.html, and a key of /blog serves as itself instead of redirecting to /blog/.

A redirect beats an entry at the same path. Rule 3 runs before rule 5, which is how you retire an address without deleting the file behind it. Redirects and not-found pages is where the fields are.

Percent-decoding, and what is refused

Decoding is one round, over RFC 3986's unreserved set only, which is A-Z a-z 0-9 - . _ ~. Everything else is left as written.

Three things end the request with a 400 and an empty body, checked in this order: a NUL byte, any other control character, then a decoded ... The reason is written to our logs and never to the response, so the set of paths this origin refuses is not enumerable one 400 at a time.

There is no .. resolution in the normalizer. A path carrying one was already refused, and a normalizer that popped parent segments would be a second place traversal could go wrong. The same functions normalize your manifest keys when your record is indexed, so a key that is stored is a key that can be requested.

Query strings

Rule 3 is the only place a query participates in anything. The matcher is the normalized path, a ?, and the query with the same unreserved-only decode applied. No reordering, no parsing into parameters.

After rule 3, the query is discarded and nothing downstream sees it. Two requests for the same path with different queries are the same request from rule 4 onward, which is what makes a cached response correct.

Content type

A response's content type comes from the contentType on your manifest entry when you set one, and from the file's own recorded type when you don't. This is the one response header your record gets to shape.

The value is resolved when your record is indexed, in this order: declared type, then the blob's own type, then a guess from the path, then application/octet-stream, with ; charset=utf-8 appended for text/*, application/json, application/javascript and image/svg+xml, and never appended twice. At serve time the stored string is re-validated: anything carrying an ASCII control character is refused before trimming, and the response falls back to a guess from the path and then to application/octet-stream. That last fallback carries no charset, because appending one is the indexer's job and a second implementation here would be a second place to disagree.

application/octet-stream and not text/plain as the default, because an unknown byte string a browser renders is a stored-cross-site-scripting surface and one it downloads is not.

Set it yourself when a guess from the file extension is likely to be wrong. .wasm and .webmanifest are the two the editor flags for an override, and they are the two that most often need one. Everything else is normally right without your help.

One exception worth knowing: when your notFound document answers, its content type is the one you gave that document, not one guessed from the address that missed. A missing /logo.png served through notFound comes back as HTML, correctly labelled.

Compression

Two mechanisms, and the first forecloses the second.

Bytes you compressed yourself. contentEncoding accepts br, gzip, zstd and identity, and nothing else. Naming a real coding means the stored bytes are already in it, and their hash covers those bytes. We serve them as stored and never recompress them and never decompress them.

Don't declare an encoding for bytes that aren't in it. We do not check, the browser fails to decode them, and that one address stops working.

Compression we do for you. Applied only when all five of these hold:

  1. Accept-Encoding names a coding we can produce. An absent header here means no compression, the opposite of the reading the pre-encoded path gives the same absence. That is on purpose.
  2. The resolved status is exactly 200.
  3. The entry is 4 MiB or smaller.
  4. The content type is on the allowlist: everything text/*, anything ending +json or +xml, plus exactly application/json, application/javascript, application/x-javascript, application/xml, application/wasm, application/x-ndjson, font/ttf and font/otf.
  5. The compressed output is strictly smaller than the input.

Brotli and gzip are the two codings produced. Brotli wins a tie; gzip only wins on a strictly higher q-value; an explicit q=0 refuses a coding even when * would have allowed it. A body of 256 bytes or smaller is not worth compressing and is not. Anything that goes wrong, whether the encoder, the size floor or an output that did not shrink, means the identity bytes are served, never that the request fails. The result is cached against the blob's hash and the coding, so a hot page pays for Brotli once rather than once per visitor.

An allowlist and not a denylist, because a denylist missing an entry burns processor time forever with nothing reporting it. It is also why image/svg+xml is compressed although its type begins image/.

The response headers

Sixteen lines are possible on a served entry. Twelve come from the header builder and are asserted as an exact set, so an extra one, whether a Server, an X-Powered-By or a Set-Cookie, fails our build as loudly as a missing one. Four more are written later by whichever stage knows the answer.

Header Value Yours to influence?
Content-Type your entry's resolved type yes, the only one
Content-Encoding present only when a coding was applied indirectly, via contentEncoding
Content-Length the bytes actually sent: whole entity, compressed body, or one range no
Content-Range 206 and 416 only no
ETag the blob's own hash, quoted, strong no, it is derived from the bytes
Cache-Control by response class, below no
Vary Accept-Encoding no
Accept-Ranges bytes no
X-Bard-Rev the repo commit revision the live pointer names no
X-Bard-Did the account that published the record no
X-Bard-Cid the record hash this snapshot was built from no
X-Bard-Outcome hit, notfound, redirect, reserved or error no
Content-Security-Policy the fixed policy below never
Permissions-Policy interest-cohort=() never
Referrer-Policy strict-origin-when-cross-origin never
X-Content-Type-Options nosniff never

The last four are written by a function that takes the policy and nothing else. There is no parameter through which a manifest entry could arrive, which is why a hostile contentType carrying \r\nContent-Security-Policy: default-src * changes nothing: the header set is built, not concatenated, and there is a test that drives all three builders with exactly that entry.

X-Bard-Rev is omitted instead of sent empty on a version that legitimately has no revision. The three X-Bard-* headers are not stripped between the origin and the visitor; a curl -D- at your own domain shows them.

Referrer-Policy is strict-origin-when-cross-origin and not no-referrer, deliberately. A site owner has a legitimate interest in knowing where their traffic came from.

The security policy is fixed

Every site we serve gets the same Content-Security-Policy. No field in your record influences it. This is it, in full:

Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; media-src 'self' data: blob:; font-src 'self' data:; connect-src 'self'; frame-src 'self'; object-src 'none'; base-uri 'none'; form-action 'self'
      

That it is not yours to configure is the point, and it is the reason a tile from an author you have never met is safe to bind. What cannot happen is where the policy is read out and that argument is made; building a tile is the same policy from the other side, for somebody writing inside it. A meta http-equiv policy in your own HTML can still tighten it, because a browser enforces the intersection of every policy it is given.

Caching

The bytes at a given address for a given version never change, so the validator is exact and the freshness lifetime is short. ETag is the blob's hash, quoted and strong, and a matching If-None-Match answers 304 before anything is read from storage. No object read, no compression, no bytes on the wire.

Response Cache-Control
a manifest entry, and its 304, 406 and 416 public, max-age=60
your notFound document public, max-age=60
a redirect public, max-age=60
a bare 404 under a reserved prefix, and our own 404 page public, max-age=60
/.well-known/atproto-did public, max-age=60
/.well-known/rasl/{cid} public, max-age=31536000, immutable
the unconfigured-domain page public, max-age=30
/_bard/health, /_bard/version no-store
400, 403, 405, 414, 431, 503 none emitted

The first two rows are BARD_DEFAULT_CACHE_CONTROL and the sixth is BARD_IMMUTABLE_CACHE_CONTROL; an operator can move both. Four of the other max-age=60 lines are written as literals: a redirect, the bare 404, our own 404 page, and /.well-known/atproto-did. They and do not follow that setting, so raising the default raises less than it looks like it does. immutable applies to the content-addressed prefix and to nothing else, because we cannot tell a hash-named /app.a1b2c3.js from a path that will be overwritten on your next publish.

s-maxage, stale-while-revalidate and stale-if-error are not emitted. With no shared cache in front of us they would be inert, and emitting an inert directive invites a reader to believe something is protecting them.

Nothing between us and your visitor caches, because we run our own edge and it does not cache, so a publish reaches the very next request. There is no invalidation step and nothing to purge. Going live is a pointer moving, and how your site reaches people is where the timings live.

Nothing here has a cache to purge, which means nothing here has a purge that can fail. It is the quietest part of the whole station.

Validators, and the ones we do not send

Last-Modified is never emitted and If-Modified-Since is never read. Content here is immutable and addressed by its hash, so a modification date answers no question a client has, and the stored metadata does not carry one. If-Match and If-Unmodified-Since are not read either, because there are no write methods for them to guard.

A 304 carries the four fixed headers, and that is deliberate, not incidental: a cache merges a 304's headers into the stored response, so a 304 that omitted the security policy would leave a browser serving a cached document under an old one. What it does not carry is Content-Length, Content-Type and Content-Encoding.

Ranges

Accept-Ranges: bytes is on every response with a body to range over. A single range is honoured; the rest is conservative.

Range against a 1000-byte entity Answer
bytes=0-99 206, Content-Range: bytes 0-99/1000
bytes=100- 206, bytes 100-999
bytes=-100 206, the last 100 bytes
bytes=-5000 206, the whole entity
bytes=990-5000 206, clamped to 990-999
bytes=1000-, bytes=5000-6000 416
any range against a zero-byte entity 416
bytes=-0 416, because the last zero bytes is unsatisfiable
bytes=0-99,200-299 200, the whole entity. Never a multipart body
bytes=abc, bytes=, bytes=10-5, items=0-99, 0-99 ignored, 200

Three more rules. A range is ignored entirely when the resolved status is not 200, because a partial not-found page is not a thing any client asked for. If-Range uses strong comparison, and a date-form If-Range never matches, because there is no Last-Modified to compare it against, and the safe reading of "I cannot tell" is to send the whole entity. And a range is always a range of the stored bytes: on a pre-encoded entry it slices the compressed bytes, because that is what the hash covers, and a range forecloses on-the-fly compression entirely.

A 416 sends Content-Range: bytes */{size} and Content-Length: 0, keeps the Content-Type and the ETag, both still true of the entity the client asked about, and removes Content-Encoding, because there are no bytes to describe.

Methods

GET and HEAD. Everything else is 405 Method Not Allowed with Allow: GET, HEAD, OPTIONS and TRACE included, and the check runs before everything else, reserved paths included. There is no 501 and no 400 for an unrecognized method.

There is no CORS layer anywhere in the origin, so OPTIONS gets the same 405 as POST. The documents we author for other people's software — /.well-known/atproto-did, /.well-known/rasl/{cid}, /.well-known/site.standard.publication when your record names a publication, /_bard/site.json and /_bard/params.json — carry Access-Control-Allow-Origin: * and Cross-Origin-Resource-Policy: cross-origin so a page on another origin can read them. Your own files never do.

A HEAD does every byte of work a GET does short of the transfer, the compressor included, and then drops the body. The header map is identical, Content-Length included, which is the only way to guarantee a Content-Length computed a cheaper way could not disagree with the one a GET would send.

The two reserved prefixes

/_bard/ and /.well-known/ are ours, and both are matched with their trailing slash: /.well-known on its own is an ordinary path. What validation reserves is narrower than the two prefixes: /_bard/ entirely, /.well-known/rasl/, /.well-known/acme-challenge/, and /.well-known/atproto-did exactly. A manifest key or a redirect naming one of those is ReservedPathCollision rather than something silently overridden; everything else under /.well-known/ is an ordinary key and serves at rule 5. One path joins the reserved set only on a record that asks: /.well-known/site.standard.publication, while wellKnown.standardSitePublication names a publication.

The check normalizes internally before matching, so /.well-known//rasl/{cid} and /.well-known/./atproto-did cannot route around it, and the query string is discarded there, so a reserved path never reaches your redirects. Matching is case-sensitive.

Path Who answers, and with what
/_bard/health us, 200 with {"ok":true} and no-store
/_bard/version us, 200 with the build version and revision, no-store
/_bard/params.json, /_bard/site.json, /_bard/params/… your snapshot. The tile runtime files are ordinary entries we wrote into it at index time, and they fall through to normal resolution
/.well-known/acme-challenge/… Caddy, at the edge. Reaching the origin here is a bare 404, in every configuration
/.well-known/atproto-did us, 200 text/plain with the publishing account's DID, unless one of the two cases below
/.well-known/rasl/{cid} us, the blob, when that hash is one this site references
/.well-known/site.standard.publication us, 200 text/plain with the publication's at:// address, readable from any origin, when your record names one. Otherwise your manifest entry if you published one, served like any other file, and a bare 404 if not. See Proving a standard.site publication
/.well-known/did.json your manifest entry if you published one, and a bare 404 otherwise
anything else under either prefix a bare 404 at rule 8. Your notFound document never applies here

Rule 8's bare 404 exists for one failure in particular: a single-page application whose notFound carries status 200 would otherwise answer a handle resolver's /.well-known/atproto-did with a page of HTML and a success status.

The one documented exception. A manifest entry at /.well-known/atproto-did is accepted, and it is served instead of our synthesized answer, because a domain that is somebody's handle may need to serve a DID we did not synthesize. It holds only while the atproto flag is on: with the flag off, that path is a bare 404 unconditionally, even when the manifest defines the key. A redirect there is refused in either state, because a handle resolver that follows a redirect is one that can be pointed at another account. Being your own handle covers that decision.

The content-addressed prefix. /.well-known/rasl/{cid} is scoped to exactly this site: your manifest entries, your not-found document, and any blob carried by a bound tile's parameters. A hash outside that set is a bare 404, and the scoping is the security property. Without it the address is a cross-tenant read oracle. Setting wellKnown.rasl to false makes every path under the prefix a bare 404 instead. Responses carry Content-Type: application/octet-stream, the immutable Cache-Control, an ETag, and no Vary and no Content-Encoding, ever, because a conforming client hashes what it receives. Range is honoured; a conditional request is answered with the immutable Cache-Control on the 304.

What never happens

Each of these is structural. It is not a setting somebody could turn on, and in most cases the build fails if a line of origin code so much as imports the thing that would make it possible.

Never Why it cannot
Rendering, or evaluating a template There is no template engine and no interpreter in the origin. A resolution carries a hash and a status, and nothing that could be executed
Dynamic routing Path resolution is a pure function of the snapshot and the request target, with no storage, no headers and no input of any kind, which is what makes all nine rules testable as a table
Reading a database Origin code may not import the database client at all. The one query in the pointer chain arrives as an injected interface, and there is deliberately no connection pool in the origin's request state for a later handler to reach for
Resolving an identity The identity crate is banned from this code. The DID served at /.well-known/atproto-did comes out of the snapshot, where it was written at index time
Calling your account The protocol client is banned too. Nothing here fetches a record or a blob from a PDS; every byte served was fetched, verified and stored during indexing
Reading or setting a cookie The module that handles visitor sessions is banned from origin code, and the only three mentions of cookies under it are comments saying so
Varying a response by visitor The origin's whole request state is five fields and none of them is a visitor. Sign-in gating happens at the edge, and the origin cannot see it
Sending CORS headers There is no CORS layer to configure
A metadata call to size a response Content-Length comes from the snapshot entry, which recorded it when the bytes were verified
Buffering a whole file to serve it The identity path streams a byte range. The only full read on the serving path is inside the compressor, which has already refused anything over 4 MiB

A given path at a given version resolves to exactly one file, and that file's bytes were checked against their hash before they were ever stored.

Transcripts

The origin key and the blob hashes below are invented. The shapes and the header sets are exact. Every refusal body is empty, which is where the text/plain; charset=utf-8 and Content-Length: 0 on a refusal come from. The header builder never wrote either of them.

A page

GET /about.html HTTP/1.1
      Host: example.com
      X-Bard-Origin-Key: 6f2a…
      Accept-Encoding: br, gzip
      
HTTP/1.1 200 OK
      Content-Type: text/html; charset=utf-8
      Content-Encoding: br
      Content-Length: 1837
      ETag: "bafkreiabcd…"
      Cache-Control: public, max-age=60
      Vary: Accept-Encoding
      Accept-Ranges: bytes
      Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' blob:; …
      Permissions-Policy: interest-cohort=()
      Referrer-Policy: strict-origin-when-cross-origin
      X-Content-Type-Options: nosniff
      X-Bard-Rev: 3lauf5jkxyz2b
      X-Bard-Did: did:plc:ewvi7nxzyoun6zhxrhs64oiz
      X-Bard-Cid: bafyreifk7o4rokf6cgkseo2zzxhwzxldb3jhovwnr7z4yq5uqz6tjcsova
      X-Bard-Outcome: hit
      

Content-Length is the compressed length, not the document's. The three X-Bard-* values are real ones, out of our own test fixtures.

A missing path, with a notFound document

GET /nope HTTP/1.1
      Host: example.com
      X-Bard-Origin-Key: 6f2a…
      
HTTP/1.1 404 Not Found
      Content-Type: text/html; charset=utf-8
      Content-Length: 40
      ETag: "bafkrei…notfound"
      Cache-Control: public, max-age=60
      Vary: Accept-Encoding
      Accept-Ranges: bytes
      … the fixed four, X-Bard-Rev/Did/Cid …
      X-Bard-Outcome: notfound

      <!doctype html><title>not found</title>
      

The body is your document. With notFound.status set to 200, the same request answers 200 with the same bytes.

A missing path with no notFound document

HTTP/1.1 404 Not Found
      Content-Type: text/html; charset=utf-8
      Cache-Control: public, max-age=60
      … the fixed four …
      X-Bard-Outcome: notfound

      <!doctype html><html lang=en>… <h1>This page is not part of this site</h1> …
      

No ETag, no Vary, no X-Bard-*, and no mention of Highport anywhere in the page. It appears inside your site at your domain, and it is not ours to sign.

A redirect

GET /old?foo=bar HTTP/1.1
      Host: example.com
      X-Bard-Origin-Key: 6f2a…
      
HTTP/1.1 302 Found
      Location: /blog/post-1.html
      Cache-Control: public, max-age=60
      … the fixed four …
      X-Bard-Outcome: redirect
      

The same path with ?foo=baz matches no rule, so the query is discarded at rule 4. Where an /old entry also exists, that entry is served with a 200.

The rule-7 form, where you published /deep/index.html and somebody asked for /deep:

HTTP/1.1 301 Moved Permanently
      Location: /deep/
      Cache-Control: public, max-age=60
      … the fixed four …
      X-Bard-Outcome: redirect
      

A reserved path

GET /.well-known/atproto-did HTTP/1.1
      Host: example.com
      X-Bard-Origin-Key: 6f2a…
      
HTTP/1.1 200 OK
      Content-Type: text/plain; charset=utf-8
      Cache-Control: public, max-age=60
      … the fixed four …
      X-Bard-Outcome: reserved

      did:plc:ewvi7nxzyoun6zhxrhs64oiz
      

And the content-addressed one:

GET /.well-known/rasl/bafkreig…style HTTP/1.1
      Host: example.com
      X-Bard-Origin-Key: 6f2a…
      Accept-Encoding: br
      
HTTP/1.1 200 OK
      Content-Type: application/octet-stream
      Content-Length: 31
      ETag: "bafkreig…style"
      Cache-Control: public, max-age=31536000, immutable
      Accept-Ranges: bytes
      … the fixed four, X-Bard-Rev/Did/Cid …
      X-Bard-Outcome: reserved

      :root{color-scheme:light dark}
      

No Vary and no Content-Encoding, despite the request asking for Brotli.

An unknown host

GET / HTTP/1.1
      Host: nothing-here.example.com
      X-Bard-Origin-Key: 6f2a…
      
HTTP/1.1 404 Not Found
      Content-Type: text/html; charset=utf-8
      Cache-Control: public, max-age=30
      … the fixed four …
      X-Bard-Outcome: notfound

      <!doctype html> … <h1>Nothing is published here yet</h1> … nothing-here.example.com …
      

A method we do not answer

POST /index.html HTTP/1.1
      Host: example.com
      X-Bard-Origin-Key: 6f2a…
      
HTTP/1.1 405 Method Not Allowed
      Allow: GET, HEAD
      Content-Type: text/plain; charset=utf-8
      Content-Length: 0
      … the fixed four …
      X-Bard-Outcome: error
      

Every refusal is this shape without the Allow line: a 400, a 403, a 414, a 431 and a 503 differ from it only in the status. All of them carry the four fixed headers, because a refusal is still a response a browser renders, and a 403 without nosniff is a 403 a browser may sniff as HTML.

Reproducing any of this

Through the edge, where the key is added for you:

curl -sSD- -o/dev/null https://example.com/about.html
      curl -sSD- -o/dev/null -H 'If-None-Match: "bafkrei…"' https://example.com/about.html
      curl -sSD- -H 'Range: bytes=0-9' https://example.com/index.html
      

Straight at a replica, where the Host header names the tenant and the key is required:

curl -sSD- -o/dev/null \
        -H 'Host: example.com' \
        -H "X-Bard-Origin-Key: $KEY" \
        http://127.0.0.1:3000/about.html
      

$KEY is any one of the values in BARD_ORIGIN_KEYS. Every entry in that list is accepted, which is what makes rotating the secret survivable: the new one goes on every origin first, then the edge moves to it, then the old one comes out.

Advanced · 3 of 7