The AT Protocol
The atproto library signs people in with their AT Protocol account (a Bluesky one, say),
acts as them on their own repository, and reads anyone's records. It's what tcm's forge, registry and command line
sign in with.
tcm lib add tcm atproto # the release your tcm ships, offline, mounted at <root>.lib.atproto
Signing in, in an app
import { signIn, signOut, useAccount, scope, repo } from "#codebase/app/lib/atproto";
const SIGN_IN = { scope: scope(repo("app.example.post")) };
export function Account() {
const { account, error } = useAccount();
if (account) return <button onClick={() => signOut()}>@{account.handle ?? account.did}</button>;
return (
<form action={(f) => signIn(String(f.get("handle")), SIGN_IN)}>
<input name="handle" placeholder="alice.bsky.social" />
{error && <p>{error}</p>}
</form>
);
}
signIn(handle, options)sends the browser to the account's own sign-in page. It comes back to the app's home page, then to the page it left (orreturnTo).useAccount()says who's signed in in this browser, and finishes a sign-in coming back. Use it on the home page, and anywhere else that needs to know.session()acts as the account:putRecord,createRecord(its$typeis the collection unless the record says),getRecord,listRecords,deleteRecord,fetch(any XRPC call at their PDS), andserviceAuth.signOut()revokes the sign-in and forgets it.- Scopes are built from their parts:
scope(repo("app.example.post"), rpc("app.example.call", aud), blob("image/*")).
The client document
On localhost, nothing is needed: the browser signs in as AT Protocol's loopback client. On a domain, the app serves its OAuth client document:
// in app.oauth, served with `tcm start --http app.oauth=/oauth`
export const client = atprotoClient({ name: "My app", ...SIGN_IN });
client in the options is where it's served, if not /oauth/client.
Telling the app's server
With server: true, signing in also signs in to the app's server: the account's PDS signs a token for that server
alone, which it checks and keeps in the caller's server session (setSession, from the server library). Then:
export async function deletePost(rkey: string) {
const me = account(); // { did, handle } of a sign-in the server checked, or undefined
if (!me) throw new HttpError(401, "Sign in first");
}
The server must know its own address to check it: ATPROTO_SERVICE_URL (space-separated, if several), or
configure({ serviceUrls }). On localhost it's the address asked.
Reading anyone's data
No sign-in needed:
resolve(handleOrDid){ did, handle?, pds }, the handle checked both waysgetRecord(uri)- a record as their PDS serves it
verifiedRecord(did, collection, rkey)- a record checked against their repository's signature (
NotTheirs,NoSuchRecord) listRecords(did, collection)- a page of their records
profile(did)- their Bluesky profile, as their repository signs it
Following records as they're written
readJetstream reads the network's new records from Jetstream, from
where it was left, until it's caught up. Run it from a recurring job:
export const follow = every("1m", () =>
readJetstream({
collections: ["app.example.post"], // or "app.example.*"
take: async (e) => {
if (e.kind !== "commit") return;
if (e.commit.operation === "delete") await posts.delete(`${e.did}/${e.commit.rkey}`);
else await posts.put(`${e.did}/${e.commit.rkey}`, { did: e.did, ...(e.commit.record as Post) });
},
}),
);
- Where it was left is kept by the feed's name (
feed, default the collections), or wherevercursorkeeps it ({ load, save }). Each event and the cursor are saved in one transaction. - The first time, or after more than 10 minutes away, it reads what accounts already hold of the collections
from the relay (
listReposByCollection, then each account's PDS), then the stream from now.catchUpnames the collections to read so (default: those that aren'tprefix.*);catchUp: falseskips what was written meanwhile. A record caught up may come again from the stream, so taking one twice should change nothing. - With an API key for Jetstream's archive (
apiKey, orJETSTREAM_API_KEY), it replays the archive instead. - Records are as their author's PDS sent them, unchecked:
verifiedRecordchecks one against their repository.
Programs and scripts
const store = fileSessions(path.join(os.homedir(), ".config", "my-tool", "sessions.json"));
const s = (await resumeSession(store, { scope })) ?? (await signInLoopback("alice.bsky.social", { scope, store }));
await s.createRecord("app.example.post", { text: "Hello", createdAt: new Date().toISOString() });
signInLoopbackopens the browser on this machine and waits for it to come back.signInWithAppPassword(who, password)needs no browser (servers, CI).- Sign-ins are kept in a
SessionStore:fileSessions(path)(a file only you can read) ormemorySessions().
Services
A server that takes sign-ins from other programs checks the service-auth tokens their PDSes sign for it:
verifyServiceAuth(token, { audiences: [serviceDid("https://my.example")], lxm }) returns the DID. serviceDid
names a server by its domain alone (with a port only on localhost, as PDSes allow).
Testing
atproto/testing runs a whole network on this machine: a PDS for each account, with OAuth, a directory, an appview,
Jetstream and a relay's listing. Its records are signed, and signing in says yes at once.
import { fakeNetwork } from "#codebase/app/lib/atproto/testing";
export const testPosting = async () => {
const net = await fakeNetwork([{ did: "did:plc:aliceaaaaaaaaaaaaaaaaaaa", handle: "alice.test", password: "pw" }]);
configure(net.config); // or net.env, for an app's server
try {
const s = await signInWithAppPassword("alice.test", "pw");
await s.createRecord("app.example.post", { text: "Hello" });
} finally {
await net.close();
}
};
net.seed(did, collection, rkey, value)puts a record in, as its author would have;net.recordsholds them.net.forge(uri)signs a record with the wrong key from then on;net.unsigned(…)says on Jetstream a record was written that its repository doesn't hold: for testing that an app checks what it's told.- Browsers sign in as the loopback client only: the app must be on localhost. Their OAuth client checks a DID's
form, so give accounts real-looking ones (
did:plc:and 24 letters a–z or 2–7).
Configuring
configure({ plc, appview, allowLocalNetwork, serviceUrls, jetstream, relay }), or ATPROTO_DIRECTORY,
ATPROTO_APPVIEW, ATPROTO_ALLOW_LOCAL=1, ATPROTO_SERVICE_URL, ATPROTO_JETSTREAM and ATPROTO_RELAY: a private
network's directory (one that also resolves handles), where browsers resolve handles, plain http and local addresses
allowed (a test's network, never one on the internet), the app server's own addresses, and the Jetstream and relay
read (default Bluesky's).