Highport orbital control

Tiles

A design somebody else built, left on the board for anyone to fill in. Everything it shows a visitor comes from bytes stored earlier, which is exactly why a stranger's is safe to dock.

A tile is a reusable, parameterized bundle of files published as a space.highport.sites.tile record. A theme, a landing page, a documentation layout. Anyone can write one and anyone can bind one to their own domain. A tile has no address of its own. It is never tied to a domain and never serves at one, and its author never touches the sites that use their work.

The files are static. There is no request-time execution anywhere in the system, so a tile is HTML, CSS, JavaScript, images and fonts, plus a declared list of things it asks the person binding it to supply. Everything it renders comes from the bytes its author published and the values a site owner filled in.

What is in the record

Field Required What it holds
name yes The display name. At most 1000 bytes and 100 graphemes
description no What it is for. At most 3000 bytes and 300 graphemes. Shown in the directory, rendered as text and never as markup
icon no A blob: image/png, image/jpeg or image/webp, at most 1 MiB
params no The parameters the tile declares. At most 32, and names are unique within a tile
content yes The manifest: the files, keyed by the path each one answers at

content is a space.highport.sites.defs#manifest, the same type a site record uses for its own inline files, and the same rules apply to both. Each entry is a path beginning /, a src blob, and optionally a contentType, a contentEncoding of gzip, br, zstd or identity, and a status of 200, 404 or 410.

Three things are true of a tile's manifest and not of a site's. It must carry a name. It must have a / entry, because every tile serves something at its root. And it has no siblings: a tile record has no redirects, no notFound, no wellKnown and no access, because those are the binding site's to decide, whatever source it uses.

The manifest's name is what the directory lists the tile under. The record's name is the fallback used only when the manifest declares none. Publishing tools set both to the same string, which is why the difference rarely shows.

The shared limits are the site limits: at most 10,000 entries, and 1 GiB of blob bytes in total.

The key is a TID

A tile's record key is a TID, a timestamp identifier and not a name you choose, so the AT-URI reads:

at://did:plc:ksqozp2qimwzivua3b4ekowe/space.highport.sites.tile/3mukf72w3nvdh
      

It is the Hello, World! tile, and it is real. Nothing in the key says anything about the tile, which means an author may publish as many as they like without ever having to invent names that do not collide. An account may hold twenty by default, and republishing an existing tile takes no new slot.

A new version is a write to the same key. Every version is indexed and stays indexed, and a site may pin any of them, not only the newest. Versions, edits and the long life of a tile covers that story.

Binding by CID

When a site binds a tile, its source becomes a com.atproto.repo.strongRef carrying a uri and a cid. The cid is the binding and the uri is provenance. There is no URI-only form, and cid is never optional. The site pins that exact version by content hash, and everything else on this page rests on that one fact.

A tile author cannot change a live site and cannot take it away from anybody. The bytes are obtained for the exact version the site named, re-hashed, and refused if they do not match. Every version that has been indexed is a permanent row, and we keep its record and its files instead of going back to the author for them.

Who may publish one

Tile storage is gated on the author's identity, and the reason is storage and not taste. We fetch a tile's blobs eagerly and keep them forever, so that a site can still bind a version after the author's account has collected the originals. An ungated collection would be free durable storage for the whole network.

Policy Who is admitted
registered (the default) An identity holding at least one claimed domain registration: pending, verifying, verified, active or failed. A suspended or released one is not a claim
verified An identity whose registration has passed verification, or is active
allowlist Only the identities the operator names
open Anyone. The quotas are then the only bound

The operator's allowlist is consulted before the policy under every setting, so an allowlisted author is admitted whichever of the four is in force. Here the policy is registered: claiming a domain is enough, and it does not have to be verified.

An author the policy excludes is refused with TileAuthorNotPermitted, at HTTP 403. Validation is the authority and answers the same name before anything is written, so you find out before you publish instead of after. See validate before you write.

Eligibility counts forward only. A tile published before its author became eligible is not picked up retroactively. It was refused when its commit went by, and nothing revisits it. Publishing a new version of it, meaning a fresh commit at the same key, is what gets it in.

The runtime contract

There is no server-side templating, so a tile does not receive its parameters as substituted text. It reads them, at runtime, from three paths served alongside it on the site's own origin.

Path Content type What is in it
/_bard/params.json application/json; charset=utf-8 The resolved parameter object, keyed by the tile's declared names
/_bard/params/{name}{pointer} The blob's own type The bytes of one blob inside a bound parameter value
/_bard/site.json application/json; charset=utf-8 What the site is: the domain, the record, the tile and version bound

All three are ordinary snapshot entries. They carry an ETag of their content hash, honour Range, compress like anything else, and take the site's ordinary cache setting, which is public, max-age=60 by default. The origin cannot tell them apart from publisher files, and neither can a browser.

params.json and site.json exist for an inline site too, with params.json as {}, so a tile, and anything else reading a site here, can fetch them unconditionally instead of branching on a 404.

Read them with an ordinary same-origin fetch. The security policy every site carries permits it exactly as written, with no exception needed, and building a tile has the nine lines that do it.

Three paths and a fetch. The whole runtime is that short, and the shortness is the feature.

The parameter object

/_bard/params.json is the site record's parameters, resolved. A record supplied by reference has been dereferenced and replaced by the record itself, and everything else is passed through as written. A parameter the owner did not supply is simply absent. There are no defaults anywhere in the system, so the tile's own code decides what to do without it, and the parameter's description is where the author says so.

For a site binding a tile that declares a required string, an optional string, a blob and an array of strings:

{
        "avatar": {
          "$type": "blob",
          "mimeType": "image/png",
          "ref": { "$link": "bafkreiaejttuuiym3o5hvtx4aqpncxlb37bh4k4knbiatd5njadd7mqk7q" },
          "size": 86491
        },
        "links": ["Bluesky|https://bsky.app/profile/example.com", "Email|mailto:hello@example.com"],
        "name": "Example Person",
        "tagline": "Builds things."
      }
      

For an inline site, and for a bound site whose owner supplied nothing, it is exactly {}.

Object keys are sorted, at every depth. The document is produced by encoding the value to canonical DAG-CBOR and decoding it back, so the bytes are a function of the value and not of the order anything was built in. Which is what makes a site republished unchanged produce the same hash.

A blob parameter's bytes

A blob arrives in params.json as the blob reference it is, not as a URL, and that reference is not fetchable. Every blob inside a resolved parameter is materialized as an entry of its own at /_bard/params/{name}{pointer}, where the pointer is the RFC 6901 JSON Pointer to that blob within the value. Building a tile has the pointer cases, the markup that names one, and the code that reads them.

The site document

/_bard/site.json is the site's own provenance, and its field list is fixed:

{
        "builtAt": "2026-09-05T14:22:03.417Z",
        "did": "did:plc:nysigjs52ta6f2l7fdnevadv",
        "domain": "example.com",
        "parameterBlobs": [
          {
            "cid": "bafkreiaejttuuiym3o5hvtx4aqpncxlb37bh4k4knbiatd5njadd7mqk7q",
            "mimeType": "image/png",
            "param": "avatar",
            "path": "/_bard/params/avatar",
            "pointer": "",
            "size": 86491
          }
        ],
        "parameterSources": {},
        "recordCid": "bafyreih3pwz7hzr6o65ldhhbuwb33wlvc6pn6mcflwxdqxty4e4odeugue",
        "rev": "3lrevaaaaaa2z",
        "source": {
          "kind": "tile",
          "tile": {
            "cid": "bafyreidxtoefolv2m3qauufam5zqqmg3spgennhnv4owsoshjvqtilzlfi",
            "did": "did:plc:ksqozp2qimwzivua3b4ekowe",
            "name": "Card",
            "uri": "at://did:plc:ksqozp2qimwzivua3b4ekowe/space.highport.sites.tile/3mukf72w3nvdh"
          }
        },
        "uri": "at://did:plc:nysigjs52ta6f2l7fdnevadv/space.highport.sites.site/example.com"
      }
      

(Identifiers illustrative; the shape is exact.)

parameterBlobs is the authoritative index of the typed paths, one entry per blob, carrying the parameter it came from, its pointer, its path, its hash, its type and its size. parameterSources carries an entry only for a value the owner supplied by reference, naming the record and the version pinned. An inline value is absent from it. source is {"kind": "inline"} for an unbound site, and the nested form above for a bound one. A site published from a space carries one further key, space.

Sign-in, if the tile wants it

A tile that needs to know who is looking can include /_bard/auth/widget.js and read /_bard/auth/status, both on the publisher's own hostname, so script-src 'self' and connect-src 'self' permit them as written. /_bard/auth/status answers authenticated and payer always, and adds did, member and allowance when they apply. An anonymous reader gets a 200 with authenticated: false, not a 401. Nothing is injected into a page: the <script> tags are in the tile's own markup, so the bytes a visitor receives are the bytes the published version describes. Asking visitors to sign in is the site owner's half of it.

Everything else under the prefix

The whole /_bard/ prefix is reserved at validation time, for every record, and a manifest key or a redirect under it is refused as ReservedPathCollision. So nothing published can shadow these paths, and a request under the prefix that names no entry is a bare 404: text/plain, empty body, and never the site's notFound resource, whatever status that resource carries.

What a tile cannot do

Every response we serve carries a content security policy, and it is ours. No record field, manifest entry, header or setting reaches it, and nothing a tile publishes can loosen it. No directive in it names a remote source. A tile's scripts, styles, fonts, images and media come from the site's own origin or from a data: or blob: URL, and fetch, XHR and WebSocket reach that origin and nothing else.

It is more permissive than "nothing runs" suggests, and an author needs the real shape rather than the slogan. Inline <script> and <style> are permitted, which is what lets a single-file tile work at all. So are WebAssembly and a worker started from a blob: URL. What is refused is every address that is not the site's own: no CDN library, no tag manager, no analytics bundle, no font host, no hotlinked image, no framed third party. <object> and <embed> are refused outright, the document base URI cannot be rewritten, and a form posts to the site's own origin or nowhere. frame-ancestors is deliberately absent, so a site may be framed by others. Safety, and what cannot happen prints the policy and the three headers fixed beside it, and building a tile is the same policy from the inside, where you are the one writing against it.

Beyond the policy: nothing a tile contains is executed on our side, because there is no request-time execution on the origin. Parameters are data and never template, so every file still serves byte-identical to its hash. Nothing the origin serves is per-visitor, no cookie is read or set there, and a tile cannot show one visitor something different from another. Anything but GET or HEAD is a 405 with Allow: GET, HEAD. And Content-Type is the one response header a publisher shapes at all, through a manifest entry's contentType, re-validated as a media type when the file is served.

This is what makes a tile from a stranger safe to bind, and it is the reason anybody binds yours.

When a file is missing

If we cannot fetch one of the files a tile's record names, that version is indexed as incomplete. The record is fine, the bytes are not. It is not rejected, because "this tile does not work" and "this tile does not exist" are different answers. But the directory stops listing it, no site can bind it, and a site that tries is refused with TileInvalid and a message naming the blobs we never obtained. Nobody but the author can repair it, and only by publishing a new version. Versions, edits and the long life of a tile has the whole of that, including the icon, which is fetched beside the file set rather than inside it and so can go missing without making a version incomplete.

The states a version can hold

State What it means Can a site bind it?
pending-publication Validated and prepared. We hold the bytes; the record has not arrived from the author's account yet No. TileNotReady
ready Everything is held. Listed in the directory and the picker Yes, and it is the only one
incomplete A file could not be fetched, so the version cannot be served whole No. TileInvalid, with the missing blobs named
suspended An operator has stopped it taking new bindings. Sites already bound go on serving No. TileSuspended
deleted The author deleted the record. Sites already bound go on serving, because they hold it by hash No

space.highport.defs#tileView declares indexing and rejected as well, and nothing writes either. A version whose job has not finished has no row at all, so space.highport.sites.getTile answers TileNotFound instead of a state.

The directory and the picker list ready versions only, because offering any of the others is offering a choice that fails at publish. getTile does not filter on state, so an incomplete, suspended or deleted version still has a page of its own, and an author following a link to one gets the word instead of a blank.

Where to go next

Building a tile is the practical half: writing the files, the path rules, reading parameters at runtime, and getting the record published. Parameters is the full declaration, with the types, the constraints, and the form each one becomes for the person binding your work. Versions, edits and the long life of a tile covers publishing a new version, why bound sites do not move, and what deleting does and does not take with it.

If you are choosing a tile rather than writing one, using a tile on your site is the shorter road.

Tiles ยท 1 of 4