Highport orbital control
The site record
One record, filed under your domain, saying which of your files answers which address. Orbital Control reads the list and serves exactly what it names.
Your site is one record in your account: a small file listing every address your site answers and the file served at each one. Highport reads that list, fetches the files it names, and serves them at your domain. Nothing else defines your site. Change the record and the site changes. Delete it and the domain stops answering.
It has exactly one required part, source, which is either your own files or a tile. Everything else on the record is optional, and a site that uses none of it works fine.
One record, filed under your domain
Site records live in a collection of their own, space.highport.sites.site, and a record's key, the name it is filed under, is the hostname. So the record for example.com is filed under example.com, and its address on the network, its AT-URI, is:
at://did:plc:cbkjy5n7bk3ax2wplmtjofq2/space.highport.sites.site/example.com
The DID in front is your account's. The one above is a stand-in.
One record per domain. The domain is the key and not a field inside the record, which has one consequence worth knowing up front: moving a site to a different domain is a new record, not an edit. Write the new one, delete the old one.
The key has to be the domain's canonical spelling: lowercase, no trailing dot, no port, and punycode (xn--…) for a non-ASCII name. Record keys are case-sensitive and domains are not, so Example.com and example.com are two different keys naming one domain. A key that is not already canonical is refused with InvalidRecordKey instead of corrected, because a corrected key would put your site at an address you never wrote.
The domain also has to be registered to the account writing the record, and its registration has to be verified or active. If it is not, the record is refused with DomainNotOwned. A record refused only because its domain was still waiting to verify is queued again when the domain verifies, so it does not need publishing a second time. Bringing your domain alongside is where registration happens.
source: your own files, or a tile
Every site record has a source, and it is one of two things.
An inline manifest carries your files directly. It maps each path your site serves to the file served there, along with the response fields that go with it.
A reference to a tile names a published tile and pins its exact version by content hash. If that tile asks for values, they go in parameters, an object beside source. Parameters only make sense with a tile. parameters beside an inline manifest is refused with ParametersWithoutTile, and that holds even for an empty {}. Using a tile on your site covers choosing one and filling it in.
The tile form is a pair, and both halves matter. The two values here stand in for a real tile's:
"source": {
"$type": "com.atproto.repo.strongRef",
"uri": "at://did:plc:cbkjy5n7bk3ax2wplmtjofq2/space.highport.sites.tile/3mtneerja2d6u",
"cid": "bafyreih3pwz7hzr6o65ldhhbuwb33wlvc6pn6mcflwxdqxty4e4odeugue"
}
The uri says which tile. The cid says which version, and it is the binding. The author publishing a new version changes nothing on your page until you change that value yourself. There is no way to say "always the latest".
Everything below this line is about the inline form. A tile-bound site's manifest belongs to the tile and lives in its author's account.
The manifest is a table, not a directory
This is the fact everything else follows from. resources is a map from a request path to the file that answers it. It is not a folder that gets scanned, and there is no filesystem behind it. The path is the key, and a path that is not a key is not an address on your site.
"source": {
"$type": "space.highport.sites.defs#manifest",
"resources": {
"/": { "src": { "$type": "blob", "ref": { "$link": "bafkrei…" }, "mimeType": "text/html", "size": 4210 } },
"/style.css": { "src": { "$type": "blob", "ref": { "$link": "bafkrei…" }, "mimeType": "text/css", "size": 812 } }
}
}
A src is a blob reference. The $link inside it is the content hash your account recorded when you uploaded the file, and it is how Highport finds the bytes and checks it got the right ones.
The station keeps no index of your files. It keeps the table you wrote, and it reads the one line the visitor asked for.
Give the manifest a / entry. It is what answers your front page. Publish through the Hub and a root index.html becomes / for you. Highport requires a / entry of a tile and only recommends one of a site, so a record without one is still valid. Your front page falls through to your not-found page, and there is nothing else to serve there.
Path rules
Every key is an absolute request path. A key that breaks one of these rules is refused with InvalidManifest, and the message names the key and the rule it broke.
| Rule | Note |
|---|---|
Not empty, and begins with / |
Relative keys are not resolved against anything |
| At most 2048 bytes | |
| No backslash | Use forward slashes; a backslash is not a path separator here |
No .. |
Checked after percent-decoding, and against the whole string instead of segment by segment, so /notes/v1..2.html is refused as well |
| No NUL byte and no control character | Also checked after percent-decoding |
Repeated slashes are collapsed and . segments are dropped, so /a//b and /a/./b are both the key /a/b. A trailing slash is preserved. /blog/ and /blog stay two different keys, because they get two different answers. If two keys collapse onto one path, the record is refused with DuplicatePath, and the message names both spellings.
Three prefixes and one exact path are answered by Highport itself, and none of them can be a manifest key. An entry under one of them is refused with ReservedPathCollision.
| Reserved | What answers there |
|---|---|
/_bard/ |
Highport's own resources for your site, including the two files a tile reads at runtime |
/.well-known/rasl/ |
Fetching your own files by their content hash |
/.well-known/acme-challenge/ |
Certificate issuance |
/.well-known/atproto-did |
Handle resolution for your domain, with one exception covered in Redirects, missing pages, and being your own handle |
Note the literal prefixes. /.well-known/rasl with no trailing slash is yours to define, and /.well-known/security.txt and /.well-known/did.json are ordinary keys that serve normally. So is /.well-known/site.standard.publication, unless your record names a publication in wellKnown.standardSitePublication: then Highport answers that path, and an entry there is refused the same way (Proving a standard.site publication).
Matching is exact
A request is matched against your keys as a byte string. Three things follow from that, and they are the three that surprise people.
Case matters. /Index.html is not /index.html, and /BLOG/ does not reach /blog/. Paths are byte strings and case-folding depends on the language you are folding in, so there is no folding at all.
Trailing slashes matter. /docs and /docs/ are two different requests. If you want both to work, define both, or redirect one to the other.
Extensions are not stripped. A key of /about.html answers /about.html and nothing else. If you want /about to work, that is the key to write.
There is exactly one lookup beyond exact match, and it is index.html. A request for a path ending in / will also try that path plus index.html. A request for a path not ending in / that has a path/index.html entry gets a 301 to the slash form. Nothing else is inferred: no other filename, no other extension. The origin's rules has the full order.
What is on each entry
Four fields, one of them required.
| Field | Required | Default | What it does |
|---|---|---|---|
src |
yes | — | The file itself, as the blob reference your account gave back when you uploaded it |
contentType |
no | the blob's own type, then a guess from the path, then application/octet-stream |
The Content-Type header sent with it |
contentEncoding |
no | absent | Declares that the stored bytes are already compressed |
status |
no | 200 |
The HTTP status this entry answers with |
contentType
contentType is the one response header whose value you write. Everything else on the response is Highport's, including the content security policy and the referrer policy, and no field of any record can change it. Safety, and what cannot happen is where that set is written down.
You usually do not need to set it. When it is absent, the type resolves in three steps: the blob's own mimeType, recorded when you uploaded the file; then a guess from the path's extension; then application/octet-stream. ; charset=utf-8 is appended to every text/* type and to application/json, application/javascript and image/svg+xml, and never twice. A charset you declared yourself is left alone.
Set it when the type recorded on the blob would be wrong, because that type is what gets served. A blob's mimeType is whatever the tool that uploaded the file declared, and a browser declares its own guess. .wasm and .webmanifest are the two it is most likely to get wrong. The application guesses from the extension instead, and flags those two in the file table with consider an override. Beyond them, a file with an unfamiliar extension, or no extension at all, resolves to application/octet-stream, which a browser downloads instead of rendering. If your /feed should be application/atom+xml, say so.
What a declared value has to be: a real media type with a non-empty subtype, no control character anywhere in it, and no parameter other than charset. A value that fails any of those is refused with InvalidManifest, naming the entry and the type you wrote.
contentEncoding
Set contentEncoding when the bytes you uploaded are already compressed and you want them served that way. Four values are permitted: gzip, br, zstd, identity. A fifth is refused with InvalidManifest.
Highport does not check that the bytes match what you declared. Declare br on a file that is not Brotli-compressed and browsers will fail to decode that one page, with no error anywhere on our side. Nothing else on the site is affected, and the fix is to correct the field or remove it.
The other consequence: a declared encoding is served as stored and is never recompressed, so a client that will not accept that encoding gets 406 Not Acceptable for that entry instead of a decompressed copy. You stored one representation and it is the one that goes out.
Leave the field off and Highport compresses the response on the fly, when the client asks for it and the type is one it compresses. It is the right answer for most sites, and there is nothing to do. identity is a declaration like the other three, not a way of writing "none". Declaring it serves the bytes as stored, and turns that compression off for the entry. The origin's rules lists which types get compressed.
status
Three values are permitted: 200, 404 and 410. Anything else is refused with InvalidManifest. In a manifest the default is 200, which is what almost every entry wants.
410 is a tombstone. Use it at an address that used to exist and is deliberately gone. The entry still names a file, which is served as the body, and the status says the removal was deliberate. 404 on a manifest entry is unusual and is not refused.
The site editor has no control for this field, so an entry published from the Hub always serves 200. Setting 404 or 410 means writing the record from your own tool, and XRPC, and building your own tools is that path.

Limits
| Limit | Value |
|---|---|
| Entries in a manifest | 10 000 |
| Total size of the files one record names | 1 GiB |
| Length of one path | 2048 bytes |
Over the entry cap, the record is refused with one message saying so, not ten thousand messages about paths. Limits and quotas has the rest, including what counts toward each one.
A complete record
Inline files, a content type set where the guess would be wrong, one compressed file, and one tombstone. The content hashes are illustrative; yours come out of uploading your own files.
{
"$type": "space.highport.sites.site",
"source": {
"$type": "space.highport.sites.defs#manifest",
"name": "example.com",
"resources": {
"/": {
"src": {
"$type": "blob",
"ref": { "$link": "bafkrei…" },
"mimeType": "text/html",
"size": 4210
}
},
"/style.css": {
"src": {
"$type": "blob",
"ref": { "$link": "bafkrei…" },
"mimeType": "text/css",
"size": 812
},
"contentEncoding": "br"
},
"/app.wasm": {
"src": {
"$type": "blob",
"ref": { "$link": "bafkrei…" },
"mimeType": "application/octet-stream",
"size": 91344
},
"contentType": "application/wasm"
},
"/blog/": {
"src": {
"$type": "blob",
"ref": { "$link": "bafkrei…" },
"mimeType": "text/html",
"size": 6102
}
},
"/old-pricing.html": {
"src": {
"$type": "blob",
"ref": { "$link": "bafkrei…" },
"mimeType": "text/html",
"size": 980
},
"status": 410
}
}
}
}
name there is a label for the set of files. It is optional on a site, nothing is served from it, and leaving it out changes nothing. The site editor never writes one.
The smallest record that serves a page
One page, one entry, nothing optional. Every byte of this one is real. It is a fixture in Highport's own test suite, and the record hash of exactly these bytes is bafyreibcmndocru76nsp6u75s7ajxf2dcgvwaakfzov44yfyjtiau4v4rq.
{
"$type": "space.highport.sites.site",
"source": {
"$type": "space.highport.sites.defs#manifest",
"resources": {
"/": {
"src": {
"$type": "blob",
"ref": { "$link": "bafkreidxtoefolv2m3qauufam5zqqmg3spgennhnv4owsoshjvqtilzlfi" },
"mimeType": "text/html",
"size": 4210
}
}
}
}
}
Publishing it
Write the record with putRecord under the hostname key, in the account the domain is registered to. Your first site walks the whole sequence, uploads included.
Validate first. Validate before you write explains why, but the short version is that you submit exactly the record validation gave back. Editing it afterwards is not an error. It changes the record's hash, so the work already done for you no longer matches what you wrote, and the whole publish runs again from the start. The site is correct either way. It arrives later.
What else the record can carry
Four of the record's optional fields are not covered here, because each has a page of its own.
| Field | Where it is covered |
|---|---|
redirects |
Redirects, missing pages, and being your own handle |
notFound |
Redirects, missing pages, and being your own handle |
wellKnown |
Redirects, missing pages, and being your own handle |
access |
Asking visitors to sign in |
Editing a record you have already published, and taking a site down, are in Editing, deleting and moving on.
Site owners · 4 of 12