Highport orbital control
The permission set
One grant, three collections, and a named list of methods. Nothing else in your account is reachable with it, and that includes Orbital Control.
When you sign in to the Hub at hub.highport.space, your server asks whether to grant space.highport.authManage. It is a published document, a lexicon like any other, which your server fetches off the network and renders on its own consent screen. It is the single thing that decides what Highport may touch in your account.
Orbital Control asks for what it needs to move your site and nothing past it. The screen doing the asking is your own server's, and the answer stays yours.
The scope we ask for
Three space-separated tokens, and this is the string in full:
atproto blob:*/* include:space.highport.authManage?aud=did:web:highport.space#bard_control
| Token | What it is | What it covers |
|---|---|---|
atproto |
The base scope | The protocol's baseline: your identity, and the ability to make authenticated calls at all. Every AT Protocol OAuth client asks for it. |
blob:*/* |
A direct grant | Uploading a file of any media type to your own account, through com.atproto.repo.uploadBlob. |
include:space.highport.authManage?aud=did:web:highport.space#bard_control |
A permission set, and the service its methods are granted for | Everything itemised below. The aud is our control plane, and the set's method grant takes its audience from it. |
The upload grant sits outside the permission set because it cannot go inside one. blob, account and identity permissions are not permitted in a set at all; a client has to request them directly. That is worth knowing if you are writing a set of your own, because nothing catches the mistake: the document validates, publishes, and even expands correctly in a resolver, and then every upload fails with ScopeMissingError at the one server that has to honour it.
What it grants
Three collections
Two repo entries, because they do not carry the same actions.
{
"type": "permission",
"resource": "repo",
"collection": ["space.highport.sites.site", "space.highport.sites.tile"],
"action": ["create", "update", "delete"]
}
{
"type": "permission",
"resource": "repo",
"collection": ["space.highport.policy"],
"action": ["create", "update"]
}
Creating, updating and deleting records in space.highport.sites.site and space.highport.sites.tile. Those are the only two record types we index, and apart from the one below they are the only two the grant can write.
The third collection is not a record you write. space.highport.policy is where your acceptance of the terms and privacy policy is recorded, in your own repository rather than in a table of ours, so the agreement travels with your account and you can read it back without asking us. It carries create and update and no delete: withdrawing acceptance is not a record edit, and the Hub never issues one.
There is no read action on any of the three because there is no read action to ask for. Repository reads are public and need no grant, so we make them without one. That covers getRecord, listRecords and describeRepo.
The Hub refuses both halves of that boundary before a request leaves it. A write naming a collection outside the three is refused with InvalidRequest and a 400; a write naming a repository other than your own is refused with Forbidden and a 403. Your own server would refuse both anyway. Checking here puts the failure next to the mistake rather than three hops away.
Your spaces
Two entries, and they are not symmetrical.
| Entry | Authority | Actions | What it is for |
|---|---|---|---|
| Read | *, any space you belong to |
read |
Minting a delegation token for a space, which is how a member keeps our access to somebody else's space alive |
| Write and manage | absent, so self, meaning your own spaces only |
create, update, delete, plus manage for the same three |
Writing site and tile records into your own spaces, and creating, editing and deleting the spaces themselves |
Both entries name spaceType: "space.highport.sites.space" and the same two collections. Writing into somebody else's space is deliberately not requested, because we would never read it.
Twenty-nine methods
{
"type": "permission",
"resource": "rpc",
"inheritAud": true,
"lxm": ["…twenty-nine NSIDs…"]
}
| Group | Methods |
|---|---|
| Domains | space.highport.manage.register, space.highport.manage.verify, space.highport.manage.getDomain, space.highport.manage.listDomains, space.highport.manage.releaseDomain |
| Records | space.highport.manage.validate, space.highport.manage.getIndexStatus, space.highport.manage.reindex |
| Spaces | space.highport.manage.connectSpace, space.highport.manage.grantSpaceAccess, space.highport.manage.syncSpace, space.highport.manage.disconnectSpace, space.highport.manage.getSpace, space.highport.manage.listSpaces, space.highport.manage.listSpaceSites, space.highport.manage.listSpaceTiles, space.highport.manage.listSpaceEvents |
| Names under your domain | space.highport.manage.enableDelegation, space.highport.manage.disableDelegation, space.highport.manage.setCertificateMode, space.highport.manage.reserveDomain, space.highport.manage.releaseReservation, space.highport.manage.listReservations |
| Traffic and billing | space.highport.manage.getSiteTraffic, space.highport.manage.listSiteVisitors, space.highport.manage.getMyActivity, space.highport.manage.getConsumption, space.highport.manage.listDomainBilling, space.highport.manage.setDomainBilling |
The audience is inherited rather than written into the set: inheritAud: true takes it from the ?aud= on the include: you ask for, which is how the permission specification has a set name a service, and an rpc permission that names the audience itself is one authorization servers drop from the set. If your tool asks for the permission set, put ?aud=did:web:highport.space#bard_control on its include: — without it the set grants none of these methods. An earlier version of this page, and of the set, named the audience in the set.
The Hub never uses this part of the grant. It reaches the same operations in-process, over routes authenticated by your session. The rpc entry is here so that a command-line tool or a third-party client asking for the same permission set is exactly as capable as the Hub is. XRPC, and building your own tools is the method catalogue.
File upload
blob:*/* accepts any media type, and we narrow nothing: there is no allowlist and no denylist, because tile and site resources are arbitrary media and a narrower grant would break legitimate files for no security gain. The type recorded on a blob is the one your tooling declares, never one a browser guessed.
The scope form carries no size at all. One upload through the Hub is capped at 64 MiB, and your own PDS applies whatever limit it applies. We relay its refusal instead of flattening it. Limits and quotas has the rest of the ceilings.
Nothing else is granted.
What it doesn't grant
| Your posts, profile and follows | Out of scope. Can't be read, can't be written. |
| Any other collection in your account | Out of scope. Every repo and space entry carries an explicit collection list, and between them they name three. |
| Your email address | Never requested, never seen. An account permission cannot appear in a permission set. |
| Your handle or your identity | Same rule: identity permissions cannot appear in a set either. |
| Writing into another person's space | Only the read entry names an authority other than yourself. |
| Acting as you anywhere else on the network | Not possible with this grant. |
The document contains exactly one wildcard, authority: "*" on the space read entry, and it widens which spaces, never which collections or which methods. Anything not named is not granted.
A stolen session reaches what the grant covers and nothing beyond it. Scoping the grant this narrowly, instead of asking for a general one, is the reason why.
How your server resolves it
An include: is an NSID, and an NSID resolves through DNS. Your server does this before it can show you anything:
space.highport.authManage
-> authority sites.highport.space (every segment but the last, reversed)
-> DNS TXT _lexicon.sites.highport.space -> did=<the publishing identity>
-> that DID's document -> its PDS
-> com.atproto.repo.getRecord repo=<the publishing identity>
collection=com.atproto.lexicon.schema
rkey=space.highport.authManage
The record key is the full NSID and not a timestamp, which is what makes the last step a single fetch instead of a listing. Nothing in the chain reads the document over plain HTTP; serving it at a URL is a convenience for people.
What that means for the screen you see: we render none of it. The consent screen belongs to your own server, and it assembles the screen from six strings.
| On the screen | Where it comes from |
|---|---|
| Who is asking | client_name in our client metadata document, the literal highport.space |
| The logo | logo_uri, which is https://hub.highport.space/brand/logo-mark.svg |
| Terms and privacy links | tos_uri and policy_uri, which are https://policies.highport.space/terms/ and /privacy/ |
| The grant heading | The permission set's title, "Manage highport.space sites, tiles, spaces, and bandwidth" |
| The grant description | The permission set's detail |
| The name of the space type | The space.highport.sites.space declaration's name, "highport.space sites space" |
We control those strings and nothing else. We never see the screen, never learn whether it was shown, and learn only which scopes were granted afterwards.
Where your tokens live
They never reach your browser.
| What | Where it is kept | For how long |
|---|---|---|
| The access token, DPoP-bound | Our server, in Redis at hub:session:{id}, sealed |
Minutes; refreshed inside a 300-second window |
| The refresh token | The same record | Single-use, replaced on each refresh |
| The P-256 private key they are bound to | The same record | Minted fresh for each sign-in |
| Whether your server granted the space permissions | The same record | Read at sign-in and again on every refresh |
| The session id | A sealed cookie named bard_session |
90 days, cookie and record alike |
The cookie is Secure, HttpOnly, SameSite=Lax, and host-only on hub.highport.space, with no Domain attribute at all, so page JavaScript cannot read it and no other host ever receives it. The id inside it is 32 bytes from a cryptographic random source and is replaced when you sign in, so a session id fixed before sign-in cannot survive it.
What the page itself can see is every field of GET /auth/session, and not one of the five is a credential:
{
"did": "did:plc:cbkjy5n7bk3ax2wplmtjofq2",
"handle": "alice.example.com",
"pds": "https://pds.example.com",
"csrf": "hR2mQ8vX1pL0nT7bK4sW9c",
"spaces": true
}
handle and pds are omitted instead of null when they don't resolve, and the handle is only reported when it resolves back to the DID that claims it. The csrf value is presented on every mutating request as an x-csrf-token header, or as a csrf_token field on the one multipart upload route, where a form cannot set a header.
What the page can still do, while it is open, is ask our server to spend the session on the operations above. That is the ordinary cost of being signed in to anything, set out in plain terms on Site owner authentication.
Two ways to authenticate
The two are separate transports into the same operations. Anything the Hub can do, a tool can do over /xrpc with inter-service authentication and the same lexicons.
| Browser session | Service token | |
|---|---|---|
| Host | hub.highport.space |
highport.space |
| Credential | The bard_session cookie |
Authorization: Bearer <JWT> |
| Where it came from | An OAuth grant of this permission set | Your own PDS, on request |
| Which part of the grant it spends | repo, both space entries, and blob:*/* |
The rpc entry, and only that |
| Cross-origin | Same origin only; every mutating call carries x-csrf-token |
Wildcard CORS, safe because no route on that host reads a cookie |
| What it reaches | Our operations in-process, as your DID | The same operations, through the XRPC handlers |
The rpc entry is what lets your own PDS mint a service token at all: it signs a short-lived JWT with your repository signing key, addressed to did:web:highport.space#bard_control and naming one method as its lxm. One token authorizes one method, so a tool mints a fresh one per call. XRPC, and building your own tools has the claims, the ceilings and every refusal.
The one method no permission set names
One more space.highport.manage.* method exists, is mounted, and is authenticated exactly like the twenty-nine. But no permission set names it, so an include: alone will not get you a token for it.
| Method | Why it is not in the set |
|---|---|
space.highport.manage.purge |
An operator escape hatch, deliberately excluded |
Ask for it directly instead, as a bare rpc: scope in your scope string. A permission set is a shorthand, not a ceiling:
rpc:space.highport.manage.purge?aud=did:web:highport.space#bard_control
Two other groups are outside every scope for different reasons. The six public queries need no credential of any kind: space.highport.sites.getSite, resolveDomain, listSites, getTile, listTiles and getTileIcon. The sixteen space.highport.admin.* methods are not scope-gated at all: they check the issuing DID against the deployment's operator list, and a deployment that has named no operators answers 403 to everyone.
Asking for it from your own tool
Request atproto blob:*/* include:space.highport.authManage?aud=did:web:highport.space#bard_control. Your server resolves the permission set off the network, the same way it resolves a lexicon, and builds the consent screen from its description.
If your authorization server cannot resolve an include:, write the same grant out by hand. This is that expansion, one line, space-separated:
atproto
blob:*/*
repo:space.highport.sites.site?action=create&action=update&action=delete
repo:space.highport.sites.tile?action=create&action=update&action=delete
repo:space.highport.policy?action=create&action=update
space:space.highport.sites.space?authority=*&collection=space.highport.sites.site&collection=space.highport.sites.tile&action=read
space:space.highport.sites.space?collection=space.highport.sites.site&collection=space.highport.sites.tile&action=create&action=update&action=delete&manage=create&manage=update&manage=delete
rpc:space.highport.manage.register?aud=did:web:highport.space#bard_control
… one rpc: token per method in the list above
A repo token naming one collection may use that shorthand, and one naming two or more must use the query form — repo?collection=…&collection=… — because the shorthand has nowhere to put the second. Both spellings mean the same thing, and the granted scope your server reports back may use either.
Against a Highport deployment other than highport.space, the rpc half of the set is unusable. Its audience is fixed at did:web:highport.space#bard_control and the audience check is an exact comparison, so a development or self-hosted instance refuses every token minted under it with InvalidAudience. Use the expansion above with that deployment's own did:web:<host>#bard_control instead. The repo and space entries name no audience and work anywhere.
You don't have to use a browser session at all. A command-line tool mints a service token on your own server and calls highport.space/xrpc directly, reaching the same operations with the same authorization; only the way you prove who you are is different.
Spaces
For a space, exactly one extra thing happens with your session. Your server mints a sixty-second, single-use token addressed to that one space, and hands it over. We trade it for a credential bound to our own key.
Your session isn't stored, isn't forwarded and isn't extended by any of it. That token names no client application at all. It is addressed to the space host and signed by you.
A server that does not support spaces yet will not refuse the sign-in. It grants the rest, expands the two space entries to nothing, and signs you in, so we read the granted scope back and record the answer on your session. When the space grants are missing, the Spaces area says so instead of offering you actions that would fail. Spaces has the detail.
Advanced · 6 of 7