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.
%2fstays%2f. No decoding pass can invent a segment boundary the request did not have.%2eis decoded, because.is unreserved. That is exactly why the traversal check runs after the decode and not before.- One round, so
%252e%252estays literal on both sides of the seam: it decodes to%2e%2eand stops there. - A malformed escape,
%zzor a truncated%2, is copied through as literal text instead of refused.
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.
?a=1&b=2and?b=2&a=1are two different requests.- There is no subset matching:
/old?foo=bar&x=1does not match a rule written for/old?foo=bar. - A rule with no query matches only a request with no query.
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.
- An absent
Accept-Encodingis read as no objection raised, and the stored coding is served. - A client that explicitly refuses the stored coding gets
406 Not Acceptablewith an empty body,Content-Length: 0,Vary: Accept-Encoding, the classCache-Controland the four fixed headers. NoContent-Typeand noETag, because there is no entity here to describe or validate.
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:
Accept-Encodingnames 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.- The resolved status is exactly
200. - The entry is 4 MiB or smaller.
- The content type is on the allowlist: everything
text/*, anything ending+jsonor+xml, plus exactlyapplication/json,application/javascript,application/x-javascript,application/xml,application/wasm,application/x-ndjson,font/ttfandfont/otf. - 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