README

tcmts.atproto.README · #4lk7y2bo · md file

atproto

Sign people in with their AT Protocol account, write to their repository, and read anyone's records.

tcm lib add tcm atproto

Sign in

import { repo, scope, signIn, signOut, useAccount } from "#codebase/app/lib/atproto";

const SIGN_IN = { scope: scope(repo("xyz.statusphere.status")) };

export function Account() {
  const { account, error } = useAccount();

  if (account) {
    return (
      <button onClick={() => signOut()}>
        Sign out @{account.handle}
      </button>
    );
  }

  return (
    <form action={(f) => signIn(String(f.get("handle")), SIGN_IN)}>
      <input name="handle" placeholder="alice.bsky.social" />
      {error && <p>{error}</p>}
    </form>
  );
}

On localhost, that's all. On a domain, serve your OAuth client document at /oauth/client:

export const client = atprotoClient({ name: "My app", ...SIGN_IN });

Write records

const s = await session();

await s?.createRecord("xyz.statusphere.status", {
  status: "🦋",
  createdAt: new Date().toISOString(),
});

A session also has putRecord, getRecord, listRecords, deleteRecord, fetch (any XRPC call to their PDS) and serviceAuth.

Read records

No sign-in needed.

Function Returns
resolve(handleOrDid) { did, handle, pds }
getRecord(uri) a record
listRecords(did, collection) a page of records
verifiedRecord(did, collection, rkey) a record checked against its author's signature; throws NotTheirs or NoSuchRecord
profile(did) a Bluesky profile

Follow records

Read new records from Jetstream as they're written, from a recurring job:

export const follow = every("1m", () =>
  readJetstream({
    collections: ["xyz.statusphere.status"],
    take: async (e) => {
      // each create, update or delete
    },
  }),
);

Each run goes on from where the last stopped. The first time, it reads what accounts already hold from the relay (catchUp: false skips that).

On the server

Add server: true to the sign-in options and the server learns who signed in too:

export async function deletePost(rkey: string) {
  const me = account(); // { did, handle }, or undefined
  if (!me) throw new HttpError(401, "Sign in first");
}

Set ATPROTO_SERVICE_URL to the server's address. On localhost it isn't needed.

To accept calls that another account's PDS signed for your server:

const did = await verifyServiceAuth(token, {
  audiences: [serviceDid("https://my.example")],
  lxm: "com.example.doThing",
});

Scripts

const file = path.join(os.homedir(), ".config", "my-tool", "sessions.json");
const store = fileSessions(file);

const s =
  (await resumeSession(store, { scope })) ??
  (await signInLoopback("alice.bsky.social", { scope, store }));

signInLoopback opens the browser and waits for it. signInWithAppPassword(handle, password) needs no browser.

Testing

import { fakeNetwork } from "#codebase/app/lib/atproto/testing";

const net = await fakeNetwork([
  { did: "did:plc:aliceaaaaaaaaaaaaaaaaaaa", handle: "alice.test", password: "pw" },
]);

configure(net.config);
// … sign in, write, read …
await net.close();

A whole network on this machine: a PDS for each account, OAuth, a directory, an appview, Jetstream and a relay. Sign-ins are approved at once. Give net.env to a server process. Browsers can sign in only to an app on localhost.

Configuration

configure({ plc, appview, allowLocalNetwork, serviceUrls, jetstream, relay }), or ATPROTO_DIRECTORY, ATPROTO_APPVIEW, ATPROTO_ALLOW_LOCAL=1, ATPROTO_SERVICE_URL, ATPROTO_JETSTREAM and ATPROTO_RELAY.