# Highport

> Highport serves a website at a domain you own from a record in your own AT Protocol repository. You upload your files to your own PDS as blobs, write a `space.highport.sites.site` record whose key is the hostname and whose manifest maps each request path to one of those blobs, prove the domain with a DNS TXT record, and Highport fetches, verifies, snapshots and serves it. Nothing executes at request time and Highport holds no originals.

This document is for a coding agent that has been asked to put a site on Highport and has no other context. It states the record types, how to resolve their schemas off the network, the small subset of the XRPC surface a publishing tool calls, how to authenticate, and the publish loop end to end. It links to the manual for everything else; the manual pages are HTML and each covers one thing in full.

The service identity is `did:web:highport.space` and every method is at `https://highport.space/xrpc/<NSID>`. The public site is `highport.space` and the interactive application is `hub.highport.space`; an agent never needs the second one.

## The record types

Two collections a publisher writes to, one declaration a space authority writes, and one permission set a client asks for.

| NSID | What it is | Record key |
| --- | --- | --- |
| `space.highport.sites.site` | One site: which files answer which paths, or which tile version to render | The hostname, canonical — lowercase, no trailing dot, no port, punycode for a non-ASCII name |
| `space.highport.sites.tile` | One published tile: files plus typed parameter declarations, for other people to bind | A TID, and Highport may mint it for you |
| `space.highport.sites.space` | A space declaration, held at the space host rather than in an ordinary repository | Any |
| `space.highport.authManage` | A permission set, not a record type — what an OAuth client asks for | — |

Site and tile records live in the author's own repository, at `at://<did>/space.highport.sites.site/<hostname>` and `at://<did>/space.highport.sites.tile/<tid>`. A space record is addressed differently, at `at://<authority>/space/space.highport.sites.space/<key>`, and the site records inside a space are written with `com.atproto.space.putRecord` at the space host rather than `com.atproto.repo.putRecord` at an account. Spaces are optional and a deployment may have them switched off, in which case every space method answers `SpacesDisabled` with a `501`.

A site record has exactly one `source`. An **inline manifest** (`space.highport.sites.defs#manifest`) is a map from request path to blob reference. A **tile reference** is a `com.atproto.repo.strongRef` naming a tile's AT-URI and pinning one version by `cid`, with the values it asks for in a `parameters` object beside `source`. `parameters` beside an inline manifest is refused as `ParametersWithoutTile`, including an empty `{}`.

The manifest is the thing to get right first, because it is a lookup table and not a directory:

- Every key is an absolute request path beginning with `/`, at most 2048 bytes, with no backslash, no `..` and no control characters — all checked after percent-decoding.
- Matching is an exact byte comparison. Case matters, trailing slashes matter, and extensions are not stripped: `/about` and `/about.html` are two addresses and only the keys you wrote exist.
- The one inference is `index.html`. A request ending in `/` also tries that path plus `index.html`, and a request not ending in `/` that has a `path/index.html` entry gets a `301` to the slash form.
- Repeated slashes collapse and `.` segments drop, so `/a//b` and `/a/./b` are both `/a/b`; two keys that collapse onto one path are refused as `DuplicatePath`.
- `/_bard/`, `/.well-known/rasl/`, `/.well-known/acme-challenge/` and the exact path `/.well-known/atproto-did` are Highport's and are refused as `ReservedPathCollision`.
- Each entry carries `src` (required, the blob reference), and optionally `contentType`, `contentEncoding` (`gzip`, `br`, `zstd` or `identity`) and `status` (`200`, `404` or `410`).
- Give the manifest a `/` entry. It is required of a tile and recommended of a site; without one the front page falls through to the not-found resource.
- Limits: 10 000 entries, 1 GiB total across the files one record names, 2048 bytes per path.

A tile's files are served the same way, and a tile additionally reads three paths Highport materializes under the reserved prefix at runtime: `/_bard/params.json` (the resolved parameters, `{}` when there are none, so it is always safe to fetch), `/_bard/params/{name}{pointer}` (the _bytes_ of a blob inside a parameter value, where the pointer is RFC 6901 within that value and empty for a bare blob), and `/_bard/site.json` (the domain, record, version and the blob inventory). A blob parameter arrives in `params.json` as the blob reference it is, which is not fetchable — read `params.json` to learn whether a file was supplied and fetch the bytes from `/_bard/params/…`. That is the mistake to expect.

## Resolving a schema

Every method and record type here is itself a `com.atproto.lexicon.schema` record published on the network, so an agent about to write a record can fetch the schema for it rather than guessing. Reverse everything but the last segment of the NSID to get the authority, look up `_lexicon.<authority>` in DNS for the DID that owns that namespace, resolve that DID to its PDS, and fetch one record whose key is the full NSID:

```text
space.highport.sites.site
  → authority: sites.highport.space
  → DNS TXT  _lexicon.sites.highport.space   → did=<publisher>
  → that DID's document                      → its PDS
  → com.atproto.repo.getRecord   collection=com.atproto.lexicon.schema
                                 rkey=space.highport.sites.site
```

Four authorities cover the whole namespace:

| Authority | What lives under it |
| --- | --- |
| `highport.space` | `space.highport.defs` — the shared views and status vocabularies every method answers with |
| `sites.highport.space` | The record types, the manifest and resource definitions they share, the public queries, the space declaration and the permission set |
| `manage.highport.space` | The site owner's methods |
| `admin.highport.space` | The operator's methods |

The schemas are published under `@lexicons.highport.space` (`did:plc:ciygg5hma4q7ah2kxaszkyob`) and browsable at [lexicon.garden/identity/lexicons.highport.space](https://lexicon.garden/identity/lexicons.highport.space). AT Protocol Lexicon has no map type, so the manifest is declared `unknown` in the lexicon and has a companion JSON Schema at [/schemas/manifest.json](/schemas/manifest.json), which is advisory — `space.highport.manage.validate` is the authoritative check.

Two properties to build against. **There is no version number anywhere on this surface** — not in a path, a header or a parameter — so a client branches on the fields and error names it finds. And **every open vocabulary tolerates a value you have not seen**: domain status, certificate status, index state, sync state, event kind and job state are all open, so pass an unrecognised value through rather than failing to parse.

## The XRPC surface

Forty-six methods exist. A publishing agent calls these, and can ignore the rest.

Public, no credential, `GET`:

| NSID | What it answers |
| --- | --- |
| `space.highport.sites.getSite` | The site serving a domain: owner, active record, revision, bound tile. `domain` required |
| `space.highport.sites.resolveDomain` | Which identity and which record serve a domain. `domain` required |
| `space.highport.sites.listTiles` | The directory of published tiles. `did`, `limit`, `cursor` |
| `space.highport.sites.getTile` | One tile at its latest version or a pinned `cid`, including its parameter declarations. `uri` required |

Authenticated, the ones a publish needs:

| NSID | Type | What it does |
| --- | --- | --- |
| `space.highport.manage.register` | procedure | Claim a domain and get back the two DNS records that prove and route it |
| `space.highport.manage.verify` | procedure | Run the DNS check now instead of waiting for the poller |
| `space.highport.manage.validate` | procedure | Check a record before writing it and get back the record to submit verbatim |
| `space.highport.manage.getIndexStatus` | query | Per-blob progress on a publish. Exactly one of `uri` or `domain` |
| `space.highport.manage.reindex` | procedure | Re-run the pipeline over the record Highport already holds |

A `query` is a `GET` with parameters in the query string; a `procedure` is a `POST` with `Content-Type: application/json`. The wrong verb is a `405` and an NSID this deployment does not serve is `501 {"error":"MethodNotImplemented"}`. Failures are `{"error": "<Name>", "message": "<sentence>"}`, and three kinds are not: a body over 2 MiB is a plain-text `413`, a malformed query string or unparseable JSON is a plain-text 4xx from the HTTP layer, and the edge's rate limit is a bare `429` with `Retry-After`. Listings take `limit` (1 to 100, default 50) and `cursor`; pass back the cursor a response returned and stop when there is none.

Authenticating a method against a domain is not the same as being permitted to act on it. A domain that is not yours answers `DomainNotFound`, the same as one nobody has registered, so these cannot be used to discover who holds what. `validate` reports `prepared: false` with `reason: DomainNotVerified` for both, on a `200`. `getIndexStatus` is the one that distinguishes: somebody else's domain is `NotOwner` (`403`) and an unregistered one is `RecordNotFound` (`404`).

The failure names worth branching on: `DomainNotOwned` and `DomainTaken` on registration and on writing a record for a domain that is not verified; `InvalidRecordKey` for a record key that is not the domain's canonical spelling; `InvalidManifest`, `DuplicatePath`, `ReservedPathCollision` and `ParametersWithoutTile` from validation; `TileNotFound` for a tile that is inside a space or has been validated and not yet written; `SpacesDisabled` and `AnalyticsDisabled` for a deployment that runs without those parts.

## Authentication

An interactive client signs a person in with OAuth and asks for the scope string `atproto blob:*/* include:space.highport.authManage?aud=did:web:highport.space#bard_control`. The `include:` is an NSID that the person's own authorization server resolves off the network the same way it resolves a schema, so the consent screen is built from the published permission set rather than from a hand-assembled list. Ask for the permission set, not the expansion. It covers twenty-eight of the twenty-nine owner methods — every one but `purge`, which needs its own bare `rpc:` scope — and `blob:*/*` is what permits `com.atproto.repo.uploadBlob`. Keep the `?aud=`: the set's method grant inherits its audience from it, and without it the set grants no methods.

A server-side tool with no browser uses AT Protocol inter-service authentication instead: a short-lived JWT signed by a key in the calling identity's DID document, sent as `Authorization: Bearer <token>`. Highport verifies these and never issues one. **One token authorizes one method** — the `lxm` claim is compared against the last segment of the path — so mint a fresh token per call. `aud` is `did:web:highport.space` or `did:web:highport.space#bard_control`, compared exactly; `iat` and `exp` are both required and `exp - iat` may be at most 300 seconds; `jti` is optional on ordinary methods and, when omitted, the token is replayable for the rest of its lifetime by anyone who saw it. The easiest way to get one is `com.atproto.server.createSession` at your own PDS followed by `com.atproto.server.getServiceAuth` with `aud`, `lxm` and an `exp` you set yourself — the 300-second ceiling is Highport's rather than your PDS's, and a wider window is refused as `TokenLifetimeTooLong`. Signing a token yourself needs a real crypto library: the signature is raw `r‖s` and not the DER that `openssl dgst -sign` produces.

Refusals: `AuthenticationRequired` (401), `InvalidAudience` (401), `InvalidLexiconMethod` (401), `TokenLifetimeTooLong` (401), `TokenReplayed` (401, mint a fresh token and retry), `AccountSuspended` (403), `Forbidden` (403), `RateLimitExceeded` (429, 300 calls per sixty seconds per identity).

## Publishing a site, end to end

1. **Register the domain.** `POST space.highport.manage.register`. It answers with two DNS records: a `TXT` at `_bard.<domain>` whose value is `did=<your DID>`, which proves the domain is yours, and a `CNAME` or `ALIAS` at the domain itself pointing at the registration's own traffic address, which routes the traffic. Use the value the response returns: it looks like `t-k3vbd7kgdlswa.highport-dns.net` and is different for every registration. Older registrations were given `go.highport.space`, which still works. An apex domain cannot take a `CNAME`, so it needs whatever its provider calls `ALIAS`, `ANAME` or CNAME flattening. Most providers want the ownership record's name relative to the zone, entered as `_bard` alone; typing the full name into a relative field produces `_bard.example.com.example.com`, which nothing reads. `_atproto.<domain>` is never accepted as proof. Each hostname is registered, verified and published separately: `example.com` and `www.example.com` are two of everything.
2. **Upload your files** to your own PDS with `com.atproto.repo.uploadBlob`. They stay in your account; Highport fetches them later and verifies every one against its content hash.
3. **Force a DNS check** with `POST space.highport.manage.verify` instead of waiting for the poller, and wait for the registration to reach `verified`. A record that is present but competing — a second account's `did=` at the same label — blocks verification for as long as it is there.
4. **Assemble the site record** per the rules above, and **call `POST space.highport.manage.validate`** with the record and the `rkey` you mean to use. It answers with the AT-URI, the CID the record will have, and the record itself, and it starts fetching your blobs before you have written anything.
5. **Write back exactly the record it returned**, with `com.atproto.repo.putRecord`, at exactly the key it returned. Any edit changes the hash and the preparation no longer applies to what you wrote — never an error, just slower. Use `putRecord` and never `createRecord`, including the first time, because for a tile the key may be one Highport minted.
6. **Poll `space.highport.manage.getIndexStatus`** every couple of seconds until it settles. `active` means the site is serving. `failed`, `rejected`, `expired` and `superseded` are the other four terminal states.
7. **Confirm with `space.highport.sites.resolveDomain`**, which is public and needs no token.

Publishing a tile is the same shape against `space.highport.sites.tile`, with two differences: send no `rkey` for a first version and Highport mints a TID and hands it back inside the `uri` on the validation answer, which is the key the write must land at; and poll `getIndexStatus` by `uri` to `active` before anyone binds the version.

Moving a site to a different domain is a new record rather than an edit, because the domain is the record key. Write the new one, delete the old one.

## The manual

### Visitors

For when you're on a site somebody has launched with Highport.

- [Welcome aboard](/docs/welcome): What Highport is, what you can do here without an account, and how the rest of this manual is arranged.
- [Sites that ask who you are](/docs/sites-that-ask): What the redirect to a sign-in form does, why a gated image fails silently instead, and the row-by-row account of what the site's owner ends up knowing about you.
- [When a site is unavailable](/docs/bandwidth): Seven things a broken site can put on your screen, from "Nothing is published here yet" to a certificate warning, and which of them you can do anything about.
- [Tiles, from the outside](/docs/tiles-outside): Somebody else's design, running on somebody else's domain. Why it cannot phone home, what its author can still change, and where to browse the ones that exist.

### Site owners

For when you want to launch your own site with Highport.

- [Site owner authentication](/docs/signing-in): There is no Highport account. What the handle box does, what your own server's consent screen is agreeing to, and every way a sign-in can fail.
- [Bringing your domain alongside](/docs/domains): The two DNS records and which of them proves what, apex domains that cannot take a CNAME, the 168-hour hold on an unverified claim, and every state a registration moves through.
- [Your first site](/docs/first-site): The editor end to end: what a dropped folder becomes, the seven verdicts that decide whether Publish is available, and what the progress panel is telling you afterwards.
- [The site record](/docs/site-record): The record field by field. Why the manifest is a lookup table and not a folder, why /docs and /docs/ are two different addresses, and what each entry can carry.
- [Redirects, missing pages, and being your own handle](/docs/routing): Redirect targets have to be literal manifest keys, which is the rule that catches nearly everybody. Plus the three statuses a not-found page can take, and the handle collision worth checking for.
- [Using a tile on your site](/docs/using-tiles): Where to find a design, what the form its author generated is asking you for, and the eight named refusals you can meet. A binding is by content hash, so nothing moves under you.
- [Spaces](/docs/spaces): A private record store with a public website on the end of it. How access is granted by members and lapses on its own, the three calls that publish into one, and what disconnecting takes down.
- [Handing out names under your domain](/docs/delegation): Giving other accounts names under a domain you own: the one wildcard record, reserving a name for someone, the record that quietly breaks a name, certificates one per name or one for all of them, and what sharing a domain between strangers means.
- [Asking visitors to sign in](/docs/private-sites): The prefix list that decides which paths ask a reader to sign in, and why only a hand-written record can set one. What a gated visitor goes through, and what lands in your visitor table.
- [Limits and quotas](/docs/limits): Every ceiling with its default and its error name: three domains, twenty tiles, ten thousand manifest entries, a gibibyte a site. Plus how bandwidth is counted and who gets turned away.
- [Editing, deleting and moving on](/docs/housekeeping): Four ways to stop, and only one of them cannot be undone. The five record fields the editor quietly drops, what survives a delete, and why asking for a re-index rebuilds nothing.
- [How your site reaches people](/docs/serving): Five hops from DNS to your bytes, why a busy domain updates more slowly than an idle one, every cache header we send, and what a proxying CDN in front of us breaks.

### Tiles

For when you're having a hard time falling asleep at night.

- [Tiles](/docs/tiles): The record's five fields, the three files a tile reads at runtime, the five states a version can be in, and why publishing one at all is gated on holding a domain.
- [Building a tile](/docs/tile-building): The Card tile read end to end, from its record to the two functions that fetch a site owner's values. Also the security policy you are writing inside, and the three calls that publish.
- [Parameters](/docs/tile-parameters): Six types and ten constraints, and which constraint is legal on which type. What each declaration becomes as a form control, and every error a bad one produces on either side.
- [Versions, edits and the long life of a tile](/docs/tile-versions): You cannot ship a fix to your existing users, and you cannot withdraw a bad version. Why that is the guarantee rather than the gap, and how to pin an older version by hand.

### Advanced

For when something surprises you, or you're writing a tool of your own.

- [Validate before you write](/docs/validate): The two-call publish in full: eleven validation steps, twenty manifest rules, every field in the answer, and a runnable transcript from signing in to a page that serves.
- [How the station notices](/docs/indexing): The pipeline stage by stage, from the Jetstream frame to the pointer swap. Every job state, every rejection, the retry schedule, and how a commit we missed gets picked up anyway.
- [The origin's rules](/docs/origin): Nine path-resolution rules in the order they run, the sixteen response headers and the one you control, range requests, and the exact list of types we compress.
- [The edge](/docs/edge): Where TLS terminates and a certificate is minted mid-handshake, the ACME budget behind that, how the access gate fails open, and the single log line every byte is billed from.
- [Safety, and what cannot happen](/docs/safety): The four fixed headers and the policy behind them, the hash check no caller can skip, the rules a record passes before it serves, and a closing list of what none of this protects you from.
- [The permission set](/docs/permission-set): The permission set your own server shows you, read out entry by entry: the twenty-nine operations it names, where your tokens actually live, and the one method no scope covers at all.
- [XRPC, and building your own tools](/docs/xrpc): All fifty-two methods grouped by who calls them, the three ways to get a token without a browser, the error envelope and the refusals that skip it, and where the schemas resolve from.

## Schemas

- [/schemas/manifest.json](/schemas/manifest.json): the companion JSON Schema for the manifest, which Lexicon cannot express. Advisory; `space.highport.manage.validate` is the authoritative check
- [The published lexicons](https://lexicon.garden/identity/lexicons.highport.space): every `space.highport.*` schema as a `com.atproto.lexicon.schema` record, browsable
- [/status.json](/status.json): whether this deployment is serving
