README
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>
);
}
signIn(handle, options)sends the browser to the account's sign-in page. It returns to your home page, then to the page it left.useAccount()returns who's signed in, and finishes a sign-in that's returning. Use it on your home page.signOut()ends the session.scope(repo(collection), rpc(method), blob(type))builds the permissions to ask for.
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.