@volter/twin-bluesky
v0.1.37
Published
Local Bluesky twin (an account's PDS over XRPC: app-password sessions, service-auth tokens, post/follow/profile records with facets and image, video or link-card embeds, blob upload and ranged getBlob; the App View's profile, author feed and thread; the v
Readme
@volter/twin-bluesky — an account's posts on Bluesky
A Bluesky account through its PDS (sessions from an app password, service-auth tokens, repo records and blobs over XRPC), the App View reads bsky.app draws a profile, a feed and a thread from, and Bluesky's video service — with a bsky.app-style mirror to preview each post as bsky.app shows it before a deploy sends it — offline, against local state.
bun packages/twin/bluesky/src/cli.ts serve --root /tmp/world # the XRPC surface
bun packages/twin/bluesky/src/cli.ts mirror --root /tmp/world # bsky.app, on the same stateThe official client (@atproto/api) reaches it through the hosts on the descriptor: bsky.social
and *.host.bsky.network (XRPC), api.bsky.app / public.api.bsky.app (actor and feed reads),
video.bsky.app (the video service) and cdn.bsky.app (/img/). The video service and the CDN are
other origins at Bluesky; the twin serves them under its own base at /video.bsky.app/… and
/cdn.bsky.app/…, and a request the World routed from the real host lands there.
bsky.app itself is claimed for its two public pages, the ones an app links a person to once it has
posted (Postiz: https://bsky.app/profile/<did>/post/<rkey>): /profile/<did or handle>/post/<rkey>
(the post: author, text with its facets, image / video / link-card embed, full time) and
/profile/<did or handle> (the profile header over its posts, newest first). They are drawn
server-side from the App View this twin serves (src/bluesky-web.ts), with no session, as the public
App View answers. A post or actor the twin does not hold is a 200 page saying "Post not found" / "Profile
not found", as bsky.app (an SPA) answers it (observed 2026-09-28; src/bluesky-web.ts has the requests).
A link facet or link card whose URI is not http(s) is drawn as text, never linked. Media loads from cdn.bsky.app, the
video service's poster and the PDS's getBlob.
Coverage
| Area | What the twin serves |
|---|---|
| sessions | createSession with a handle, DID or email and an app password (scope com.atproto.appPass) or the account password (com.atproto.access); every password session carries the email, as the PDS answers it: HS256 access (120 min) and refresh (90 days) JWTs, the DID and a DID document naming the PDS endpoint (the twin's base). refreshSession, getSession. Expired tokens are 400 ExpiredToken, others InvalidToken; no bearer is 401 AuthMissing; a bad password 401 "Invalid identifier or password"; the Authorization header is parsed as the PDS parses it (DPoP / OAuth and Basic refused by name). resolveHandle for the handles the twin holds. |
| service auth | getServiceAuth (aud, lxm, exp with the PDS's BadExpiration rules): a JWT with iss, aud, exp, lxm, jti, signed by the repo key the DID document names. Bluesky signs ES256K; WebCrypto has no secp256k1, so the twin's repo key is P-256 (ES256), the other curve atproto accepts. |
| records | createRecord, putRecord, deleteRecord, getRecord, listRecords for app.bsky.feed.post (text, byte-indexed link / mention / tag facets, langs, tags, a reply, an images, video or external embed), app.bsky.graph.follow and app.bsky.actor.profile/self (name, bio, pronouns, website, self-labels, avatar, banner; swapRecord honoured as upsertProfile sends it), each validated against its lexicon. Following a subject again replaces the earlier follow, as the PDS's backlink check does. Keys are TIDs of the write's world instant, never reissued; the CID is the record's DAG-CBOR CID (it matches a reference encoder). A blob a record names must be one the account uploaded (400 BlobNotFound). Deleting what is not there answers 200 with no commit, as the PDS does. |
| blobs | uploadBlob (up to the PDS's 52,428,800 bytes; the CID is the raw sha2-256 CID of the bytes) and com.atproto.sync.getBlob for a blob a live record names (a temporary upload is "Blob not found"), with HTTP Range (206/416; an open-ended range capped at 8 MiB, longer answers streamed in 8 MiB reads). |
| App View | getProfile (counts computed from the follow and post records the twin holds, never told), getAuthorFeed (newest first, cursor, the posts_* filters), getPostThread (parents and replies). Views carry embeds as bsky.app receives them: video#view (playlist and thumbnail on the video host), images#view (thumb and fullsize on the CDN), external#view. The CDN serves the stored image as it is (no resizing); the video thumbnail is a stand-in poster (the twin decodes no frames); the HLS playlist is not modelled — the file is served by getBlob. |
| video service | uploadVideo (a service token for the PDS's did:web and com.atproto.repo.uploadBlob, did and name in the query, video/mp4 bytes), getJobStatus (ENCODING → SCANNING → UPLOADING → COMPLETED with the blob, one second of world time each — a frozen World clock holds a job where it is), getUploadLimits (25 videos / 10 GB a day less today's jobs; an unconfirmed email cannot upload). The same bytes again answer 409 already_exists with the job. A file that is not an MP4, or over ten minutes, fails the job (validation_failure). |
| errors | XRPC's {error, message}; 501 MethodNotImplemented for an unknown method; a real Bluesky method or feature the twin does not model is refused by name as 501 [twin gap] …, never answered as a success; 405 under --read-only. |
| connector | Perform (the kernel's exchange strategy trades the sealed handle + app password at createSession; the pack never holds either): the World's account for the root is the one of the root's DID, or of its handle under a DID the World minted (translated to the root's DID at the boundary, both ways); only that account crosses — another account the World holds performs as nothing; an account the vendor does not know (asked through getProfile) is kept out of what crosses: a reply into its thread is refused before any bytes go out, a follow of it crosses nothing, a mention of it is dropped, while an account seeded under a real DID crosses as written; a World holding no account for the root fails the entry loudly; deletes read the key first and never remove a different record; a reply to a post that is not at Bluesky is refused (settled — the queue moves on) and a pin of one is left out; posts and follows cross as createRecord under their local key, read first so a retry adopts rather than posts twice; a post deleted before the deploy never crosses (one whose create already crossed is adopted, then deleted); the video's service token lasts 30 minutes of the vendor's clock; deletes as deleteRecord on the repo getSession names, the profile as putRecord (and its deletion as deleteRecord). Blobs cross first — images through uploadBlob; a video through the video service with a service token, presigned (another origin; sent without the session), then getJobStatus until COMPLETED, bounded at 120 s (a retryable failure past it). Refresh: the account, its profile record, and every post and follow record (listRecords, with the App View's indexedAt); observed under the World's account for the root (a record the translation changes is content-addressed anew); a World holding accounts but none of the root's DID or current handle is refused loudly, never forked into a second account. Both charge the pack's rate budget, one ledger per sealed credential. |
| mirror | bsky.app over the twin's own XRPC (every answer's HTTP Date is the World's instant): sign in with the handle and an app password (an ended session, or the Sign out button, returns to the form); the profile (banner, avatar, name, handle, counts, bio, tabs) over its feed with the inline video player (poster, controls, the file from getBlob), images and link cards; a post's thread page. Relative times read that clock. |
| /_twin/* | Twin-only control plane: seed an account (handle, DID, email, password), an app password on it, and an access token by value. Passwords and tokens land in the log only as SHA-256s under _ bookkeeping types. |
src/bluesky-capabilities.ts is the denominator: 121 capabilities, 68 done. What is not modelled
is enumerated there as a todo — likes, reposts and quotes, the multipart video upload bsky.app now uses, OAuth, the timeline and feed generators, lists,
blocks and mutes, notifications, DID resolution, app-password management, account
creation, applyWrites, sync of whole repos and the firehose, the HLS playlist, labels and moderation,
chat, and the mirror's composer — not omitted.
The rate budget (src/bluesky-budget.ts): Bluesky publishes 3000 requests / 5 min per IP, 30
createSession / 5 min per account and 5,000 write points an hour; the declaration stays at the
kernel fallback's shape and prices writes by Bluesky's points (create 3, update 2, delete 1).
No official Python SDK exists (atproto on PyPI is a community SDK), so adoption claims none.
