Highport orbital control
Safety, and what cannot happen
The interesting part of this page is the list of things that cannot happen, and why each one is structural instead of a promise Orbital Control is making.
Almost everything on this page is a thing a published record cannot do. Each one holds because of how serving works, not because we have undertaken not to do it: a page here is bytes stored earlier, streamed by a process with no database on its request path, no template engine, no interpreter, and no way to run anything a publisher wrote on the server. No field in any record reaches any of it, and nothing on a request changes it. The one lever that exists belongs to the operator running the deployment, and it is named below.
If you are deciding whether to bind a tile somebody else wrote, the short answer is in The security policy and the honest version of it is in the section straight after. If you are deciding whether to point a domain here, Between one site and another is the part that matters.
Most of this page is a list of things Orbital Control cannot do, which is a better assurance than a list of things it has undertaken not to.
The response headers every site gets
Four headers are attached to every response the origin sends, including refusals and 304s. Their values are fixed by the deployment, and nothing on the request or in the record reaches them.
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'
Permissions-Policy: interest-cohort=()
Referrer-Policy: strict-origin-when-cross-origin
X-Content-Type-Options: nosniff
The Content-Security-Policy above is the compiled default; an operator can replace it with BARD_CONTENT_SECURITY_POLICY. The other three are constants. A configured policy that cannot be a header value at all falls back to the compiled default instead of being dropped, because dropping it would answer a typo by serving every site with no policy at all.
The function that writes those four takes the policy and nothing else. There is no parameter through which a manifest entry could arrive, so there is no code path by which a record could influence one. Not a field, not a header name in a manifest key, not a value smuggled through a content type. The set is built and not concatenated, so a header name written into a record field stays part of that field's value and never becomes a header, and a regression test drives all three builders with exactly such an entry.
A 304 carries the four as well, for the reason the origin's rules gives about what a cache does with a 304's headers, and that page also lists the sixteen headers an ordinary entry response can carry and where each value comes from.
Two rows are what the rest of this page argues from.
| Header | Shaped by the record? |
|---|---|
Content-Type |
yes, the only one |
Content-Security-Policy, Permissions-Policy, Referrer-Policy, X-Content-Type-Options |
never |
The other eleven describe the bytes being sent, how long they may be held, and where they came from: ETag, Cache-Control, Vary, Accept-Ranges, Content-Length, Content-Range, Content-Encoding and the four X-Bard-* diagnostics. Content-Encoding is the only one of those a record touches at all, and it is covered below.
The one header a record shapes
Content-Type is it, and the checks around it are the reason it is safe to let a publisher choose one.
The declared value is checked at index time and checked again at serve time, by the same function. It must parse as a media type. Any ASCII control character anywhere in the raw input is refused, and refused before the value is trimmed, because trimming first would accept "text/html\r" and hand back something that looks clean, which would make the property depend on the order of two lines. An empty subtype is refused, because "text/" parses. No media-type parameter other than charset is permitted.
A stored value that does not survive the second check falls back through a guess from the path to application/octet-stream, and so does a resolved string that cannot be a header value. The full resolution order, and why the last resort is application/octet-stream and not text/plain, are in the origin's rules.
X-Content-Type-Options: nosniff is on every response, so a browser honours the declared type instead of guessing a more interesting one.
contentEncoding is the other publisher-supplied value that reaches a header, and it is closed to gzip, br, zstd and identity. Anything else is a validation error, so there is no free text on that path either.
The security policy
No fetching directive names a remote origin. Each is 'self', or 'none', or 'self' plus data: and blob:, which are schemes that can only carry bytes the page already has. A page talks to the origin that served it, and to nothing else.
| A page here can | A page here cannot |
|---|---|
fetch any path on its own site, including /_bard/params.json, /_bard/site.json and /_bard/params/… |
Call any other origin. No API calls, no analytics endpoint, no tag manager |
Run an inline <script> |
Load a remote <script src> |
Run WebAssembly, and load a worker from a blob: URL |
Hotlink an image, embed a remote frame, or load a font from a CDN |
Bundle and self-host its own fonts, and use data: images |
Use <object> or <embed> at all |
| Frame its own pages, and be framed by others | Rewrite the document base URI, or post a form off-site |
A blanket https: in any fetching directive would be an exfiltration channel, and connect-src is only the obvious one. new Image().src = 'https://elsewhere/?' + secret needs no fetch at all, and frame-src https: is arbitrary third-party code running inside the page. A configuration test asserts that no directive in the default policy contains https: anywhere.
frame-ancestors is absent on purpose, so a site served here can be framed by other people. frame-src 'self' is the other direction: a site may frame its own pages and nobody else's.
A <meta http-equiv> policy inside a published document can only tighten what applies, because a browser enforces the intersection of every policy it was delivered. There is no field in any record that loosens one.
What the policy is not
It is not a sandbox, and describing it as one would be wrong in a way an auditor will notice within a minute of reading a page's source.
script-src includes 'unsafe-inline' and 'wasm-unsafe-eval'. A tile executes. It runs its own JavaScript in the visitor's browser on the publisher's domain, with full access to that document, its DOM, and any same-origin storage that domain has. A tile that wanted to redraw the page, read a query string, or spend the visitor's CPU can do all three.
What makes a stranger's tile safe to bind is the second half of the policy, not the first: it cannot reach any other origin. Nothing it loads and nothing it sends can address one. No fetch, no image, no frame, no form post, no font, no remote script that could have been swapped out later. And nothing it renders can arrive from anywhere else, so what it shows a visitor is what was in the bytes at the CID the site bound, plus the parameters the site owner filled in. A tile author cannot change either of those after the fact, because a site binds a version by content hash and never follows a later one.
That is a narrower claim than "a tile is sandboxed" and it is the true one. It is also the claim that actually matters, because the risks people worry about with somebody else's code are exactly the ones a closed connect-src and a static origin remove: calling home, loading a payload later, showing one visitor something different from another.
One asset gets a stricter policy, and it is ours and not a publisher's. A tile icon fetched through space.highport.sites.getTileIcon is served from highport.space itself instead of from the publisher's domain, so it carries Content-Security-Policy: default-src 'none'; sandbox. An SVG is a script document, and one served from the host that also serves the control plane would execute on that origin.
Validation as a safety boundary
Nothing is served until a record has been through the validator. The rules below run at index time, and the path rules run again at serve time through the same functions: one implementation, called twice, so the two cannot disagree.
Paths
A manifest key must be non-empty, must begin with /, must be 2048 bytes or fewer, must not contain a backslash, and must contain no NUL byte, no control character, and no ...
The .. check runs after one round of percent-decoding, not before, so %2e%2e%2f is refused instead of passed through to be decoded later by somebody else. What that decode does and does not touch is set out in the origin's rules.
One rule belongs to the validator and not to the decoder: .. is refused as a substring, not as a segment. That is stricter than a segment test and it is intentional, because a segment test admits keys the serve-time check would then refuse on every request.
Keys are normalized, with duplicate slashes collapsed, . segments dropped and the trailing slash preserved. Two keys that normalize to the same string are a DuplicatePath rejection, not a coin toss about which one wins.
At serve time the same normalizer runs on the request target, and a decoded .., a NUL or a control character is a 400 with an empty body. The reason is written to a debug log and never to the client, so the set of paths the origin refuses is not enumerable one 400 at a time. A request target over 4096 bytes is a 414 and a header block over 32 KiB is a 431, both refused before the host is looked up.
Matching is case-sensitive everywhere, at both ends.
Reserved paths
Four prefixes and paths belong to us, and a manifest entry that lands on one is a ReservedPathCollision, not a silently ignored entry.
| Path or prefix | Who answers it |
|---|---|
/_bard/ |
Us: the tile runtime files, /_bard/health, /_bard/version |
/.well-known/rasl/ |
Us: content-addressed retrieval, scoped to this site |
/.well-known/acme-challenge/ |
The edge, during certificate issuance |
/.well-known/atproto-did |
Us, unless you serve it yourself |
The last row is the one documented override. A publisher may define /.well-known/atproto-did in their manifest, and it is served, as long as wellKnown.atproto is not false, because a domain that is somebody's handle may need to serve a DID document we did not synthesize. A publisher may never redirect to that path or from it. A handle resolver that follows a 302 away from it is a resolver that has been pointed at another identity's DID, so the redirect rules have no override and a redirect touching that path is refused in both directions.
Prefix matching is literal and runs on the normalized key, so /_bard//x and /%5Fbard/x are both caught, while /.well-known/rasl with no trailing slash is an ordinary publisher-writable path.
A path named in access.requireAuth may not be exactly /.well-known/atproto-did. That is a refusal and not a silent drop, because dropping it would leave the publisher believing the reverse of what is true. Turning it on is covered in Asking visitors to sign in, and the gate itself is at the edge.
Everything else the validator checks
| Rule | Refusal |
|---|---|
Entry count within BARD_MAX_MANIFEST_ENTRIES (default 10 000). Returns alone, so twenty thousand path errors do not bury the one that explains them |
InvalidManifest / TileInvalid |
Every src.ref.$link parses as a DASL CID, with codec raw 0x55, because a dag-cbor CID names a record and not a file |
InvalidManifest / TileInvalid |
src.size is not negative |
InvalidManifest / TileInvalid |
contentType is a media type, no CR or LF, no parameter but charset |
InvalidManifest / TileInvalid |
contentEncoding is one of gzip, br, zstd, identity |
InvalidManifest / TileInvalid |
status is one of 200, 404, 410 |
InvalidManifest / TileInvalid |
Sum of blob sizes within BARD_MAX_SITE_BYTES (default 1 GiB) |
InvalidManifest / TileInvalid |
| At most 256 redirects | InvalidManifest |
A redirect to is not protocol-relative, begins with /, and normalizes to a literal key of the manifest, so /blog/ is refused even where /blog/index.html exists |
RedirectTargetNotFound, or ReservedPathCollision for a reserved target |
A redirect status is one of 301, 302, 307, 308 |
InvalidManifest |
The redirect rule is worth reading twice, because it is what keeps this from being an open-redirect primitive for anybody who wants one: a redirect target is always an absolute path on the same site, and a target naming a path the manifest does not define is refused at validation instead of discovered by a visitor.
A record's declared status is clamped again at serve time to {200, 404, 410}, and anything else becomes 200. A status no client or cache has a rule for produces browser-dependent behavior nobody can reproduce.
The two configured ceilings belong to the deployment. Limits and quotas carries the current numbers, and the fields these rules apply to are described in The site record. Every rule above can be checked before you write, because space.highport.manage.validate calls the same manifest validator the indexer calls — see Validate before you write. It is the same implementation and not quite the same pass: for a site that binds a tile, the indexer additionally runs the tile's own content rules, which validate leaves to the tile's own job.
What a manifest cannot express
| Cannot | Why |
|---|---|
Set any response header but Content-Type |
The resource type has no field for one, and the function that writes the fixed four takes no entry |
| Have anything executed on the server | There is no template engine and no interpreter on the serving path; parameters are data and are never interpolated into publisher bytes |
| Vary a response by visitor | The origin's request state has five fields and none of them is a visitor; a design-rules test fails if that changes |
| Set or read a cookie | Nothing in the origin touches one, ever |
Answer a method other than GET or HEAD |
Everything else is a 405 with Allow: GET, HEAD, OPTIONS included |
| Claim a reserved path | ReservedPathCollision at validation |
| Use a query string for anything but redirect matching | One rule reads the query; the next rule discards it permanently |
How files are verified
Every byte we store is verified against its content hash before the write, and the check is a type, not a call. The only function that writes into the content-addressed key space takes a verified blob, and the only way to construct one is a constructor that hashes the bytes against the CID and refuses on mismatch. There is no argument a future call site could pass to say the check already happened, because there is no such argument to add.
Three gates, in increasing cost:
- The string parses as a CID at all.
- It satisfies the DASL profile: CIDv1 only, codec raw
0x55or dag-cbor0x71, hash SHA-2560x12or BLAKE30x1e, digest exactly 32 bytes. - The bytes are rehashed under the CID's own algorithm and the whole CID is rebuilt and compared. Rebuilding instead of comparing digest bytes means a digest that matches under a different codec is still a mismatch.
Writes are create-only. The key is a hash of the bytes, so an object already at that key is byte-identical to the one being written, and the store's "already exists" answer is folded into success and not into a retry. Two indexers fetching the same shared asset is the normal case, not a race.
A mismatch stops the whole job. An atomic flag halts every other in-flight fetch immediately, so a repository shown to be serving wrong bytes is not asked ten thousand more times, and the failure is logged with the DID, the CID, the endpoint it came from, the role the blob played, the byte count and the method. It is classified as an upstream fault and retried rather than as a rejection, deliberately: telling a publisher to re-upload a file whose bytes are fine would be advice they cannot act on.
The same path is used for the two resources we write ourselves. /_bard/params.json and /_bard/site.json have no repository to be fetched from, so they are handed to the same write function from memory, and our own documents are hashed on the way in exactly as a stranger's are.
What that rules out, and what it does not
It rules out bytes entering the store that do not hash to the name they are stored under. A PDS that answers a blob request with an error page, a truncated body, a substituted file, or a single flipped bit produces a mismatch and no write. A CIDv0, an exotic codec or a wrong digest length is refused before any network call is spent on it.
It does not mean a byte is re-hashed on its way out to a browser. The origin streams from the store without rehashing, because the key it read from is the hash. What travels with the response instead is the identity: ETag is the blob's CID, quoted and strong, so anything that wants to check can.
/.well-known/rasl/{cid} is the endpoint for actually doing that, and the origin's rules has its response shape, which is chosen so that what a client hashes is what the store holds.
The endpoint is scoped to the site asking. The set is exactly this snapshot's entries, its parameter blobs and its notFound resource; every other CID is a bare 404, and without that scoping the address would let anybody read one publisher's bytes from another publisher's domain. The snapshot's own record CID and the bound tile's CID are deliberately out of scope too, because those name records rather than servable content.
One caveat worth stating plainly: a CDN placed in front of us is a cache we do not know about and do not control. During integration testing one such service injected its own analytics script into HTML responses. The published policy refused the script, so nothing ran. But the bytes the visitor received were no longer the bytes the record published, and the snapshot's CID no longer described the document. If content integrity end to end is the property you want, do not put a rewriting proxy in the path.
Between one site and another
A site is a pointer to an immutable snapshot, and a snapshot is a pure function of the record, the bound tile and the resolved parameters. It is keyed by the record CID, encoded as canonical DAG-CBOR, and never edited, because publishing is a new object and a pointer swap. A snapshot whose version field is not the one this origin understands is refused rather than reinterpreted, because a decoder that ignores fields it does not recognize would happily serve a half-understood manifest.
Serving is: host to pointer, pointer to snapshot, path to entry, stream the blob. Seven of the rows below are enforced by a build-time scan of the origin's own source, which fails the build on an import of the crate that would make one of them possible. A review can be skipped, and this cannot.
| Never happens while a request is being served | How that is held |
|---|---|
| A database query | The sqlx import is banned outright. The one lookup that needs Postgres arrives as an injected fallback, and no connection pool is in request state |
| A call to anybody's PDS | The AT Protocol client crate is banned from the serving code |
| A DID resolution | The identity crate is banned. The DID served at /.well-known/atproto-did comes out of the snapshot |
| Firehose consumption | The consumer crate is banned |
| A raw cache command | Only the typed cache wrapper is reachable |
| A billing or analytics event | The consumption module is banned |
| Reading or setting a cookie | The edge-auth module is banned. Three greps for Set-Cookie in the serving code find three comments saying it never happens |
| Template evaluation, or publisher code of any kind | There is no template engine and no interpreter in the serving code; a resolved path carries a CID and a status, and nothing else |
| A cross-origin allowance | There is no CORS layer, and no access-control string, anywhere in it |
What follows for tenants: one site cannot read another's bytes, because retrieval by CID is scoped to the asking site's own snapshot. One site cannot influence another's headers, because no record influences any header on its own site either. One site cannot slow another down through anything it publishes beyond the byte cost of its own responses, because there is no shared work being done on its behalf. And a domain whose snapshot storage cannot be read answers 503 for itself alone; the failure has no way to become somebody else's.
Blobs are fetched from the repository that references them, so a manifest that names a CID its own publisher's account does not hold ends the job with BlobUnavailable instead of resolving to whoever else happens to have those bytes. The one thing tenants do share is the content-addressed store, and sharing it is safe for the same reason the writes are create-only: two publishers naming the same CID are naming the same bytes.
The operator's tools
Three levers, and they do different things. All of the admin methods answer 403 to everyone when BARD_ADMIN_DIDS is empty, which is correct for a deployment that has not decided who its operators are and is the first thing to check when a call that should work does not.
| Method | What it does |
|---|---|
space.highport.admin.createDenylist |
Adds a subject: a domain, a DID, a record URI, a record CID, a space, or a pattern. Announced to every process by broadcast, with a 30-second table poll as the backstop |
space.highport.admin.setDomainState |
Moves a registration to suspended or released. Restoring is narrower than it looks: only a domain that was still verifying when it was suspended can be moved back |
space.highport.manage.releaseDomain |
The owner's own method: deactivates the pointer, sets the registration to released, and clears its TLS columns, in one transaction |
A denylist entry stops future work: a new commit is dropped at admission with nothing surfaced to the publisher, a new certificate is refused at the permission endpoint before any database read, a warm-up is skipped, a registration is refused, and any authenticated control-plane call from that DID answers AccountSuspended. It does not by itself stop a site that is already serving, because nothing in the origin reads the denylist. Taking a live site off needs the record deleted, releaseDomain, or setDomainState with suspended, as well as the denylist entry.
A control-plane replica that cannot load the denylist at boot fails to boot. An empty denylist answers "not denied" about everything, so a replica that failed to load one and started anyway would serve exactly the content an operator took down.
What a visitor sees
| Situation | What happens |
|---|---|
The domain is verified or active and a certificate is held |
The site |
The domain is unregistered, pending, verifying, failed, suspended, released, or denylisted, and no certificate is held |
The permission endpoint answers 403 and the TLS handshake fails. There is no HTTP response, so no page and no status code. The visitor gets a browser TLS error |
| The domain resolves here but no site is active for it | A plain page titled "Nothing published here yet", answered 404. One page for four situations, deliberately. See the origin's rules |
| A path that does not exist on a serving site | The publisher's notFound resource, or a plain page that says the address is right for the site and wrong for the page. That page names no service, because it appears inside somebody else's site at their domain |
What this does not protect against
None of the above is a claim about whether a page is honest, and it is worth being exact about where the guarantees stop.
- A page can mislead a visitor, and nothing here checks. The validator checks shapes, paths, hashes and limits. It has no opinion about what a document says, what it asks for, or what it claims to be. A convincing sign-in form for some other service is a static HTML file like any other.
- The policy governs what a page loads, not where it sends the visitor. Read the directives above and every one of them is about a subresource, a frame, a form target or the base URI. A link the visitor follows, or a script assigning
location, moves the top-level document to any address it likes, and a query string rides along with it. No directive in the policy covers that, so "cannot reach another origin" is a statement about loading and sending, not about navigating away. - A tile author decides how your parameters are rendered. The two first-party tiles that read parameters write text nodes and never markup, deliberately. But a tile that chose to render a site owner's parameter as HTML would be handing that owner script execution on their own domain, which is theirs to have. If you are binding a tile, the tile's source is published and readable; if you are writing one, write text nodes.
- A site owner is responsible for what they publish. The policy constrains what a page can reach. It does not stop a page being wrong.
- The author policy is configuration, not review. Who may publish a tile is a deployment setting. Nobody reads a tile before it appears in the browser, and appearing there is not an endorsement.
- A page here can be framed.
frame-ancestorsis deliberately absent, so another site can put yours in a frame. - A publisher chooses their own content types. Within the syntax rules, a file can be served as any type its owner declares.
nosniffmeans a browser honours that choice instead of improving on it, which cuts both ways. - A binding is durable in both directions. A tile author cannot change what your site serves, and equally cannot withdraw it, because you hold the version by hash and the bytes stay held. If you want off a version, that is your edit to make.
- Certificate revocation is not part of releasing a domain. Releasing stops the domain being served and stops a new certificate being authorized, immediately. A certificate already issued stays in the store until it expires.
For what happens to a record between publication and serving, see How the station notices. For the rules the origin applies to a request, see The origin's rules. For certificates and the gate in front of everything, see The edge.
Advanced · 5 of 7