The AT Protocol — tcm-ts

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 (or returnTo).
  • 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 $type is the collection unless the record says), getRecord, listRecords, deleteRecord, fetch (any XRPC call at their PDS), and serviceAuth.
  • 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 ways
getRecord(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 wherever cursor keeps 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. catchUp names the collections to read so (default: those that aren't prefix.*); catchUp: false skips 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, or JETSTREAM_API_KEY), it replays the archive instead.
  • Records are as their author's PDS sent them, unchecked: verifiedRecord checks 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() });
  • signInLoopback opens 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) or memorySessions().

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.records holds 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).

Built from c482a14e