A video game where you play as a misaligned AI, deceiving and building power. cameron.tngl.io/misaligned
misaligned ATPROTO.md
23 kB · 260 lines
Markdown
at main

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.network servers lack the spacesAlpha: true flag in com.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.resolveHandle for spaces.cameron.stream worked 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 release 0.0.0, which semver ranks higher. @atproto/lex-data, lex-json and lex-client each have a 0.0.0 published with workspace:* still in its dependencies, so npm install fails with Unsupported URL Type "workspace:". Fixed with overrides pinning them. Bulletin, Bluesky's example app, carries the same overrides (plus lex-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 from window.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 with buildAtprotoLoopbackClientId({ scope, redirect_uris: [page] }).
  • Loopback clients are a good start. From 127.0.0.1 a page signs in with no hosted metadata and no key, and can ask for a narrower scope per sign-in with signInRedirect(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's createSpace and createRecord don't look it up.
  • Publishing a lexicon takes a DNS record and a record. _lexicon.telemetry.misaligned.app TXT "did=<did>" points the app.misaligned.telemetry namespace at an account, and the lexicon is a com.atproto.lexicon.schema record 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_kem key) failed long after DNS and the record were in place; Bulletin's my.bulletin.board (published from an account on an ordinary *.host.bsky.network server, #atproto key only) reached consent at once. Each piece checks out by hand (DNS, DID document, com.atproto.sync.getRecord returns a CAR, the document passes lexiconDocumentSchema); 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. createSpace and createRecord worked with it, and the space keeps its real type in its URI.
  • The granted scope isn't the requested string. The server rewrites authority=self to 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: memberListPolicy for read and write, open app access; gone since the Oct 29 alpha, below), com.atproto.space.createRecord with validate: 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 pack it to read. action is read_self, read, create, update, delete (all but read_self when none is named); manage is create, update, delete (none by default); skey defaults to * and authority to self; a scope that names no collection can write nothing. The game asks for space:*?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_uri on 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 (/misaligned without the slash answers 308). It must come back as application/json. Vite copies public/ 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.html would 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.html are 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=update included: 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, needing read on 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-256 did:key as credentialKid) 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 (authorizeUser says 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-audience header, and the signature covers it and the authorization header (RFC 9421, ES256, 64 bytes, tag atproto-space-credential). The audience is the space's owner for listRepos, and the author for their own listRecords. A wrong one answers BadSpaceAudience.
  • No need for the alpha's crypto packages in a browser. WebCrypto makes the key and signs, and a did:key is the compressed point after 0x80 0x24 in base58btc. Checked against @atproto/space's verifyCredentialSig and @atproto/crypto's did:key on 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. listRepos takes only a space credential, not OAuth, so even the owner can't list who has written without the whole reader's path. listMembers is the other way: OAuth only, "a space credential is not sufficient". listRecords takes either.
  • listRecords under a credential carries no proof. getRecord, getRepo and listRepoOps with a credential want a MAC asked for in Atproto-Space-Want-Mac, keyed against the author's #atproto_kem key, and Bulletin checks the whole repo with it (verifyRepoStarFull). listRecords gives the values as the author's server has them. Good enough for reading our own runs; a reader that must trust others' servers should read getRepo and check it.
  • Paging is by what came last. listRepos pages by space revision and ends with an empty page with no cursor; a repo that writes while you page turns up again. listRecords gives 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, so authorization, atproto-space-audience and 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.memberspace since 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, removeMember and updateSpace check manage: 'update', deleteSpace checks delete, listMembers and getSpace only read_self. The alpha's sign-in server took the scope with manage=update added 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)), so space.ts's SpaceError works in the browser (Vite compiles it) but read.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:did and 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.

  • applyWrites is strict, and has no upsert. A #create of a record that is there fails with RecordAlreadyExists, an #update or #delete of one that isn't with RecordNotFound, and the whole call fails with it (one commit, all or none). putRecord is the forgiving one: it looks first and creates or updates (and asks the grant for whichever it is), and deleteRecord of a missing record answers fine. So a sender must know what the space holds. It keeps that beside each record (inSpace in the store), and when the space disagrees it lists the collections in the call (listRecords with excludeValues, 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; putRecord is 2 whichever it does, deleteRecord 1), 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) and RateLimit-Policy (5000;w=3600), for whichever limit has the fewest points left, and a 429 adds Retry-After (seconds); all are in Access-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.
  • listRecords pages up to 1,000 (50 unless asked), can leave the values out (excludeValues), and answers RepoNotFound for an account that has never written in the space, which a sender reads as "holds nothing".
  • No beacon. navigator.sendBeacon can'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 403 Missing 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.ts now spots it (lacksMethod in scope.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 answers AuthMissing to both. scripts/spaces-check.ts asks that for every method space.ts and read.ts name, and says which kind each is. All 16 are there on the alpha server; the simplespace ones the game used answer AuthMissing both ways.
  • Moved. createSpace, getSpace, updateSpace and deleteSpace are under com.atproto.space; the member list is com.atproto.memberspace (putMember, removeMember, listMembers). checkUserAccess (a managing app's) is com.atproto.managedspace.
  • One policy instead of three settings. createSpace takes {spaceType, skey, policy}, and readPolicy, writePolicy and appAccess are 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) and com.atproto.managedspace.policy (a managingApp DID answers for every read and write). updateSpace replaces the policy whole; a policy of another kind empties the member list. getSpace answers {uri, policy}, and a missing or deleted space is SpaceNotFound by name now (it was an unnamed 4xx); createSpace answers SpaceAlreadyExists and UnsupportedPolicy; putMember and the others answer PolicyMismatch on 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 wholesale updateSpace. No allowedApps: a list there makes every reader's app prove which app it is (a client attestation, checked at getSpaceCredential; writes never are). If the space turns out not to be a memberspace, share makes it one first.
  • Old spaces carried over. The server's migration made every simplespace a memberspace (public read or write became publicRead or publicWrite, an app allow-list became allowedApps, 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: false also drops them from the writer set (listRepos), and so does removeMember. read decides whether they get a credential, write only whether the owner tracks their writes (a write was never refused). listMembers pages (100 by default, 1,000 at most), takes OAuth only, and lists everyone but the owner. With publishMembers the list is also published to readers as com.atproto.memberspace.member records 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-scopes is the same between the two releases, byte for byte. createSpace wants manage=create; updateSpace, putMember, removeMember and removeWriter want manage=update; deleteSpace manage=delete; getSpace and listMembers read_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. getSpaceCredential answers SpaceNotFound (it was UserNotAuthorized) 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. getSpace is 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, getDelegationToken and getSpaceCredential take the same parameters, and the delegation token, credential and signatures are unchanged (@atproto/space only 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: clearRecords empties your own repo in a space in one call (1 point, needing delete on every collection in it; the game's Delete from my space keeps going page by page, as it must keep the consent records), removeWriter takes an account off listRepos, listSpaces lists the spaces you own or have written in, and exportSpaceMeta, importSpaceMeta and importRepo move an account with its spaces.
  • Installing went cleanly. Every alpha package at 20261029171913, no stray 0.0.0 taken, and the same three overrides (lex-data, lex-json, lex-client; lex-cbor isn't in the game's tree). The browser sign-in packages were identical to the 22nd's; only @atproto/api, @atproto/pds and @atproto/space changed. Bulletin moved the same day (4c599c2): its boards are managedspaces now.