Building on ATProto: what we ran into #
A running log of what developing Misaligned's telemetry on the ATProto spaces alpha was like: what surprised
us, what was rough, and what we did about it. Newest last. Versions: everything at
0.0.0-spaces-alpha-20261022141228 until the Oct 29 alpha (the last section), 0.0.0-spaces-alpha-20261029171913
since; the account on spaces-alpha.host.bsky.network. node scripts/spaces-check.ts asks that server whether it
still has every method the game calls.
Finding where spaces work #
- Bluesky's own servers don't run spaces. bsky.social and the
*.host.bsky.networkservers lack thespacesAlpha: trueflag incom.atproto.server.describeServer, so almost no player can write space records. An app has to ask each player's server before offering anything that needs spaces. - Handles on the alpha server resolve unreliably through Bluesky.
com.atproto.identity.resolveHandleforspaces.cameron.streamworked on bsky.social once, then bsky.social and public.api.bsky.app both said "Unable to resolve handle" while the alpha server and DNS still had it. It made a live account look deleted. The sign-in page asks several resolvers in turn.
Installing #
- A
^range on an alpha pulls in a broken release. The alpha packages depend on each other with^0.0.0-spaces-alpha-…, and that range also matches the plain release0.0.0, which semver ranks higher.@atproto/lex-data,lex-jsonandlex-clienteach have a0.0.0published withworkspace:*still in its dependencies, sonpm installfails withUnsupported URL Type "workspace:". Fixed withoverridespinning them. Bulletin, Bluesky's example app, carries the same overrides (pluslex-cbor), so it's known.
Signing in #
- The browser client's default loopback client id breaks off the site root.
new BrowserOAuthClient()builds its loopback id fromwindow.location, path included, and then throws "Invalid loopback client ID: Value must not contain a path component" for any page but/. Fixed by building the id withbuildAtprotoLoopbackClientId({ scope, redirect_uris: [page] }). - Loopback clients are a good start. From
127.0.0.1a page signs in with no hosted metadata and no key, and can ask for a narrower scope per sign-in withsignInRedirect(handle, { scope }). - Consent words are broader than the grant.
repo:com.atproto.lexicon.schema?action=create&action=update, which can write only lexicon records, shows as "Repository · Publish changes". A player can't tell a narrow grant from a wide one.
Spaces #
- A space scope needs the space type's lexicon published, even when the scope names its collections. The
scope code says collections are filled in from the lexicon only when missing, but the sign-in server looks the
lexicon up anyway, for the consent screen (
getSpacesFromScope), and fails with "Unable to retrieve space declarations". The server'screateSpaceandcreateRecorddon't look it up. - Publishing a lexicon takes a DNS record and a record.
_lexicon.telemetry.misaligned.app TXT "did=<did>"points theapp.misaligned.telemetrynamespace at an account, and the lexicon is acom.atproto.lexicon.schemarecord keyed by its NSID in that account's repo. The record can be written from the browser with the narrow scope above, so no app password is needed. - Failed lexicon lookups are cached as long as good ones. The sign-in server keeps a lookup for 5 minutes
(
LEXICON_REFRESH_FREQUENCY), failures included, and every failed try resets the clock, so retrying blind keeps you waiting. Publish, try too soon, and you wait 5 minutes for the server to look again. The error doesn't say it's a cached miss. - A lexicon published from an account on the alpha server doesn't resolve for the alpha's own sign-in
server. Ours (on
spaces-alpha.host.bsky.network, whose DID document has the new#atproto_kemkey) failed long after DNS and the record were in place; Bulletin'smy.bulletin.board(published from an account on an ordinary*.host.bsky.networkserver,#atprotokey only) reached consent at once. Each piece checks out by hand (DNS, DID document,com.atproto.sync.getRecordreturns a CAR, the document passeslexiconDocumentSchema); the failing step is hidden behind "Unable to retrieve space declarations". Likely the new key in the DID document or the alpha's new commit format in the record's proof. Unconfirmed. - The error hides which step failed. Lexicon resolution has four (DNS, DID document, signed record, document schema); the sign-in server reports all of them as one message.
space:*skips the lexicon while experimenting. With*as the space type there's no type for the sign-in server to look up, and the scope can still be narrow:space:*?skey=test&collection=…&action=read& action=create&manage=create.createSpaceandcreateRecordworked with it, and the space keeps its real type in its URI.- The granted scope isn't the requested string. The server rewrites
authority=selfto the user's DID in the token, so an app checking "was I granted exactly this scope?" by string compare thinks it wasn't. Match on the parts that matter, or parse with@atproto/oauth-scopes. - Space consent is one generic line. Any space scope, however narrow, shows as "Spaces · Access permissioned spaces".
- The full path works from a browser. Sign in as a loopback client,
com.atproto.simplespace.createSpace(owner-only:memberListPolicyfor read and write,openapp access; gone since the Oct 29 alpha, below),com.atproto.space.createRecordwithvalidate: false,com.atproto.space.listRecords. Without a token the same listing answers 401, and the record isn't in the public repo. - From the spec, worth remembering: a space's write policy can't stop a write (it only decides whether the authority tracks the writer); access control isn't encryption (each author's server operator can read their records); there's no firehose for space data, so a reader pulls from each author's server.
Sign-in in the game #
- A space scope's grammar lives in
@atproto/oauth-scopes, which the browser client doesn't depend on;npm packit to read.actionisread_self,read,create,update,delete(all butread_selfwhen none is named);manageiscreate,update,delete(none by default);skeydefaults to*andauthoritytoself; a scope that names no collection can write nothing. The game asks forspace:*?skey=self&collection=<each of five>&action=read&action=create&action=update&action=delete&manage=create, and the alpha's sign-in server took it whole (from 127.0.0.1 it went straight to the password page). - A public client can live under a path, with rules: the provider wants
client_urion the client id's origin and a parent of its path (/misaligned/for/misaligned/client-metadata.json), and it fetches the metadata with redirects refused, so the id must answer directly (/misalignedwithout the slash answers 308). It must come back asapplication/json. Vite copiespublic/to the build's root, so with base./the file sits at/misaligned/client-metadata.json. - The way back is one exact page. The server returns to the redirect URI as written, and the browser
client only takes the answer on that path:
/misaligned/works,/misaligned/index.htmlwould not, and the page's query (?dev) is lost on the way. - A loopback client on localhost moves the page to 127.0.0.1. The browser client's
init()sends the page to the IP before anything else, and the game's save is kept per origin, so the save would be left behind. The game only starts the sign-in client on 127.0.0.1 and says so on localhost. - Two loopback clients on one origin share a session store. A loopback client's id carries its page, so the
game and
atproto.htmlare two clients, but sessions are kept per origin; one signed in on the other page turns up, possibly with less scope. The game checks the grant covers its scope by parts and offers to sign in again if not. - A browser already signed in at the server skips the password. From 127.0.0.1 with the account's own sign-in
remembered by its server, the alpha went straight to Authorize (
#step=consent) for the whole scope,manage=updateincluded: one click grants it, and the screen still says only "Spaces · Access permissioned spaces". - The sign-in library is heavy (about 200 kB, 57 kB zipped). The game loads it only when a player signs in, or was signed in before.
Reading as a reader #
What the alpha's code says about the reader's path (@atproto/pds and @atproto/space at this version, and
Bulletin), before a live try. src/atproto/read.ts follows it.
- Three tokens, three lifetimes. The delegation token (
getDelegationToken, from the reader's own server, needingreadon the space in the grant) lasts 60 seconds and is good once. The space credential (getSpaceCredential, a POST to the space's owner with the token and a P-256did:keyascredentialKid) lasts 10 minutes, and there's no asking for longer: the 60-minute limit is only the token format's. A reader script has to work inside ten minutes or come back for another. - The owner reads its own space the reader's way. The owner is always let in (
authorizeUsersays yes for the space's own DID before looking at any policy), so an owner-only space with nobody on its member list still gives its owner a credential. - Each signed request names who it's for. The credential carries no audience; the request does, in an
atproto-space-audienceheader, and the signature covers it and theauthorizationheader (RFC 9421, ES256, 64 bytes, tagatproto-space-credential). The audience is the space's owner forlistRepos, and the author for their ownlistRecords. A wrong one answersBadSpaceAudience. - No need for the alpha's crypto packages in a browser. WebCrypto makes the key and signs, and a
did:keyis the compressed point after0x80 0x24in base58btc. Checked against@atproto/space'sverifyCredentialSigand@atproto/crypto'sdid:keyon 50 keys, from a scratch install; signatures are kept low-S as atproto's own keys sign, though the verifier allows either. - Who can call what is uneven.
listRepostakes only a space credential, not OAuth, so even the owner can't list who has written without the whole reader's path.listMembersis the other way: OAuth only, "a space credential is not sufficient".listRecordstakes either. listRecordsunder a credential carries no proof.getRecord,getRepoandlistRepoOpswith a credential want a MAC asked for inAtproto-Space-Want-Mac, keyed against the author's#atproto_kemkey, and Bulletin checks the whole repo with it (verifyRepoStarFull).listRecordsgives the values as the author's server has them. Good enough for reading our own runs; a reader that must trust others' servers should readgetRepoand check it.- Paging is by what came last.
listRepospages by space revision and ends with an empty page with no cursor; a repo that writes while you page turns up again.listRecordsgives a cursor only when a page is full. - CORS lets a browser sign. The server's
cors()reflects whatever headers a page asks to send, soauthorization,atproto-space-audienceand the two signature headers go cross-origin (from the code; untried live). - Taking a member away doesn't take back a credential.
removeMember(com.atproto.memberspacesince Oct 29) stops new credentials; one already held reads on to its ten minutes. The server can be told of revoked credentials (notifyCredentialRevoked), but nothing in the alpha sends that when a member goes. - Sharing needs
manage=update.putMember,removeMemberandupdateSpacecheckmanage: 'update',deleteSpacechecksdelete,listMembersandgetSpaceonlyread_self. The alpha's sign-in server took the scope withmanage=updateadded and went straight to the password page (from 127.0.0.1). A grant from before it doesn't cover it, so the game asks to sign in again once. - Node runs these files as they are, and refuses some TypeScript. Node strips types and stops at parameter
properties (
constructor(readonly x)), sospace.ts'sSpaceErrorworks in the browser (Vite compiles it) butread.ts, which the reader script also loads, writes its fields out. - One store of consent records, two writers. The You page made the space match the browser by deleting
every consent record that isn't a kind switched on, which would have taken a share with someone else with it.
A share with another reader is keyed
kind:didand the You page now leaves those alone.
Sending to the space #
What the alpha's code says about writing many records (@atproto/pds and @atproto/xrpc-server at this version),
before a live try. src/telemetry-send.ts follows it.
applyWritesis strict, and has no upsert. A#createof a record that is there fails withRecordAlreadyExists, an#updateor#deleteof one that isn't withRecordNotFound, and the whole call fails with it (one commit, all or none).putRecordis the forgiving one: it looks first and creates or updates (and asks the grant for whichever it is), anddeleteRecordof a missing record answers fine. So a sender must know what the space holds. It keeps that beside each record (inSpacein the store), and when the space disagrees it lists the collections in the call (listRecordswithexcludeValues, keys only) and writes again as they are.- A write can land and its answer not. The server lets a POST run to the end when the client goes away (only GETs are cancelled), so a send cut off by a closed tab, or a lost answer, is in the space while the game thinks it isn't. The next create of it fails as above. This is why the sender can't trust its own count.
- 200 writes and 1,000,000 bytes a call. The handler refuses more than 200 writes (the lexicon itself sets no
limit, unlike
com.atproto.repo.applyWrites's), and the JSON body is capped at 1,000,000 bytes (jsonLimit): that is the whole call, not a record. The game sends at most 200 writes and about 400 KB a call. - The rate limit is points, shared with everything the account writes. A create costs 3 points, an update 2, a
delete 1 (in
applyWrites;putRecordis 2 whichever it does,deleteRecord1), out of 5,000 an hour and 35,000 a day (repo-write-hour,repo-write-day), keyed by the account's DID: the same buckets as its ordinary repo writes, and every other app's. The points are taken after the sign-in and the body are checked and before the handler runs, so a call that fails (a create that already exists) still costs. On top, 3,000 requests per 5 minutes per IP. The game keeps to 1,500 points an hour and 10,000 a day, sends half as often past half of that, and keeps 500 of the server's in hand. - The rate-limit headers can be read from a browser. Each limited call answers with
RateLimit-Limit,RateLimit-Remaining,RateLimit-Reset(seconds since 1970, not seconds from now) andRateLimit-Policy(5000;w=3600), for whichever limit has the fewest points left, and a 429 addsRetry-After(seconds); all are inAccess-Control-Expose-Headers. The game waits as long as a 429 says, at least. - A write needs the space to exist. For the account's own spaces, a write to one that isn't there (or was deleted)
fails with
SpaceNotFound; the sender makes it again. A write to someone else's space makes the writer's repo there without checking. listRecordspages up to 1,000 (50 unless asked), can leave the values out (excludeValues), and answersRepoNotFoundfor an account that has never written in the space, which a sender reads as "holds nothing".- No beacon.
navigator.sendBeaconcan't carry the DPoP proof every call needs, so a last send as the page goes away is an ordinary call that may or may not finish; whatever it missed goes on the next visit, and whatever it wrote without hearing back is put right as above.
The Oct 29 alpha #
The first live test of sending failed: the You page said getSpace: Missing required scope "rpc:com.atproto.simplespace.getSpace?aud=did:web:api.bsky.app%23bsky_appview", and the sender "your server refused:
this sign-in may not let the game write to your space". Bluesky's "refactor the simplespace api" (Oct 28, released as
0.0.0-spaces-alpha-20261029171913 on Oct 29 and running on the alpha server that day) had taken away
com.atproto.simplespace. From its lexicons, the server's code and a probe of the live server:
- A method that's gone fails as a missing scope. The server passes any method it doesn't have on to Bluesky's
AppView, and an OAuth sign-in needs an
rpc:scope for that, so a dropped method answers 403Missing required scope "rpc:<method>?aud=did:web:api.bsky.app%23bsky_appview". It reads as a sign-in short of something; it is a method gone.space.tsnow spots it (lacksMethodinscope.ts) and says "your server doesn't have : the spaces alpha may have changed", on the You page and in the sender's "Not sent". - Asking without signing in doesn't tell either. Passed on, a missing method answers 401
AuthMissing, as a method that's there does when called without a sign-in. What does tell: the wrong HTTP verb. The server's own methods check it first and answer 400 "Incorrect HTTP method (GET) expected POST" (or POST, expected GET); a passed-on one answersAuthMissingto both.scripts/spaces-check.tsasks that for every methodspace.tsandread.tsname, and says which kind each is. All 16 are there on the alpha server; thesimplespaceones the game used answerAuthMissingboth ways. - Moved.
createSpace,getSpace,updateSpaceanddeleteSpaceare undercom.atproto.space; the member list iscom.atproto.memberspace(putMember,removeMember,listMembers).checkUserAccess(a managing app's) iscom.atproto.managedspace. - One policy instead of three settings.
createSpacetakes{spaceType, skey, policy}, andreadPolicy,writePolicyandappAccessare gone. A policy is one of three:com.atproto.personalspace.policy(the owner alone, no settings, no members, ever),com.atproto.memberspace.policy(a member list the owner keeps:publicRead,publicWrite,allowedApps,publishPolicy,publishMembers, all off or absent unless set) andcom.atproto.managedspace.policy(amanagingAppDID answers for every read and write).updateSpacereplaces the policy whole; a policy of another kind empties the member list.getSpaceanswers{uri, policy}, and a missing or deleted space isSpaceNotFoundby name now (it was an unnamed 4xx);createSpaceanswersSpaceAlreadyExistsandUnsupportedPolicy;putMemberand the others answerPolicyMismatchon a space that isn't a memberspace. - The game's space is a memberspace with nobody on the list. The owner is let in under every policy ("so it can't
lock itself out"), so with no members and nothing public only the player reads and writes. Sharing puts a reader on
the list,
read: true, write: false; stopping takes them off. Not personalspace: it can never be shared, and moving it to a memberspace later is a wholesaleupdateSpace. NoallowedApps: a list there makes every reader's app prove which app it is (a client attestation, checked atgetSpaceCredential; writes never are). If the space turns out not to be a memberspace,sharemakes it one first. - Old spaces carried over. The server's migration made every simplespace a memberspace (
publicread or write becamepublicReadorpublicWrite, an app allow-list becameallowedApps, members kept), or a managedspace if it had a managing app. A space the game made before is already what it makes now. - How membership works now.
putMember {space, did, read, write}(all four required) adds or replaces one entry;write: falsealso drops them from the writer set (listRepos), and so doesremoveMember.readdecides whether they get a credential,writeonly whether the owner tracks their writes (a write was never refused).listMemberspages (100 by default, 1,000 at most), takes OAuth only, and lists everyone but the owner. WithpublishMembersthe list is also published to readers ascom.atproto.memberspace.memberrecords in the owner's repo, keyed by DID; the game leaves it off, so who you share with stays between you and your server. - The grant didn't change.
@atproto/oauth-scopesis the same between the two releases, byte for byte.createSpacewantsmanage=create;updateSpace,putMember,removeMemberandremoveWriterwantmanage=update;deleteSpacemanage=delete;getSpaceandlistMembersread_self. The game's scope is as it was, so a grant from before covers it and nobody signs in again. From 127.0.0.1 the alpha's sign-in server took it and went to the password page. - A refused reader is told there is no space.
getSpaceCredentialanswersSpaceNotFound(it wasUserNotAuthorized) to someone the policy keeps out, so they can't learn the space exists. On the reader's path "no space yet" and "not a member" now look the same.getSpaceis the owner's alone (OAuth); a reader learns the policy only if it's published (publishPolicy), which is why Bulletin stopped checking a board's policy and now just asks for a credential. - Writes and reads are as they were.
applyWrites,putRecord,deleteRecord,createRecord,listRecords,listRepos,getDelegationTokenandgetSpaceCredentialtake the same parameters, and the delegation token, credential and signatures are unchanged (@atproto/spaceonly gained what moving an account needs). A write to the two collections the server publishes itself (com.atproto.space.policy,com.atproto.memberspace.member) is refused. - New:
clearRecordsempties your own repo in a space in one call (1 point, needingdeleteon every collection in it; the game's Delete from my space keeps going page by page, as it must keep the consent records),removeWritertakes an account offlistRepos,listSpaceslists the spaces you own or have written in, andexportSpaceMeta,importSpaceMetaandimportRepomove an account with its spaces. - Installing went cleanly. Every alpha package at
20261029171913, no stray0.0.0taken, and the same threeoverrides(lex-data,lex-json,lex-client;lex-cborisn't in the game's tree). The browser sign-in packages were identical to the 22nd's; only@atproto/api,@atproto/pdsand@atproto/spacechanged. Bulletin moved the same day (4c599c2): its boards are managedspaces now.