diff --git a/README.md b/README.md index 01d2010..68dcf6d 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,21 @@ -# AT Protocol Express App +# AT Protocol "Statusphere" Example App -A demo application covering: - - public firehose ingestion - - identity and login with OAuth - - writing to the network +An example application covering: + +- Signin via OAuth +- Fetch information about users (profiles) +- Listen to the network firehose for new data +- Publish data on the user's account using a custom schema + +See https://atproto.com/guides/applications for a guide through the codebase. ## Getting Started -### Development + ```sh -pnpm i +git clone https://github.com/bluesky-social/statusphere-example-app.git +cd statusphere-example-app cp .env.template .env -pnpm run dev +npm install +npm run dev # Navigate to http://localhost:8080 ``` diff --git a/TUTORIAL.md b/TUTORIAL.md deleted file mode 100644 index 358f1f8..0000000 --- a/TUTORIAL.md +++ /dev/null @@ -1,644 +0,0 @@ -# Tutorial - -In this guide, we're going to build a simple multi-user app that publishes your current "status" as an emoji. - - - -At various points we will cover how to: - -- Signin via OAuth -- Fetch information about users (profiles) -- Listen to the network firehose for new data -- Publish data on the user's account using a custom schema - -We're going to keep this light so you can quickly wrap your head around ATProto. There will be links with more information about each step. - -## Where are we going? - -Data in the Atmosphere is stored on users' personal repos. It's almost like each user has their own website. Our goal is to aggregate data from the users into our SQLite DB. - -Think of our app like a Google. If Google's job was to say which emoji each website had under `/status.json`, then it would show something like: - -- `nytimes.com` is feeling 📰 according to `https://nytimes.com/status.json` -- `bsky.app` is feeling 🦋 according to `https://bsky.app/status.json` -- `reddit.com` is feeling 🤓 according to `https://reddit.com/status.json` - -The Atmosphere works the same way, except we're going to check `at://` instead of `https://`. Each user has a data repo under an `at://` URL. We'll crawl all the `at://`s in the Atmosphere for all the "status.json" records and aggregate them into our SQLite database. - -> `at://` is the URL scheme of the AT Protocol. Under the hood it uses common tech like HTTP and DNS, but it adds all of the features we'll be using in this tutorial. - -## Step 1. Starting with our ExpressJS app - -Start by cloning the repo and installing packages. - -```bash -git clone TODO -cd TODO -npm i -npm run dev # you can leave this running and it will auto-reload -``` - -Our repo is a regular Web app. We're rendering our HTML server-side like it's 1999. We also have a SQLite database that we're managing with [Kysley](https://kysely.dev/). - -Our starting stack: - -- Typescript -- NodeJS web server ([express](https://expressjs.com/)) -- SQLite database ([Kysley](https://kysely.dev/)) -- Server-side rendering ([uhtml](https://www.npmjs.com/package/uhtml)) - -With each step we'll explain how our Web app taps into the Atmosphere. Refer to the codebase for more detailed code — again, this tutorial is going to keep it light and quick to digest. - -## Step 2. Signing in with OAuth - -When somebody logs into our app, they'll give us read & write access to their personal `at://` repo. We'll use that to write the `status.json` record. - -We're going to accomplish this using OAuth ([spec](https://github.com/bluesky-social/proposals/tree/main/0004-oauth)). Most of the OAuth flows are going to be handled for us using the [@atproto/oauth-client-node](https://github.com/bluesky-social/atproto/tree/main/packages/oauth/oauth-client-node) library. This is the arrangement we're aiming toward: - - - -When the user logs in, the OAuth client will create a new session with their repo server and give us read/write access along with basic user info. - - - -Our login page just asks the user for their "handle," which is the domain name associated with their account. For [Bluesky](https://bsky.app) users, these tend to look like `alice.bsky.social`, but they can be any kind of domain (eg `alice.com`). - -```html - -
-``` - -When they submit the form, we tell our OAuth client to initiate the authorization flow and then redirect the user to their server to complete the process. - -```typescript -/** src/routes.ts **/ -// Login handler -router.post( - '/login', - handler(async (req, res) => { - // Initiate the OAuth flow - const url = await oauthClient.authorize(handle) - return res.redirect(url.toString()) - }) -) -``` - -This is the same kind of SSO flow that Google or GitHub uses. The user will be asked for their password, then asked to confirm the session with your application. - -When that finishes, they'll be sent back to `/oauth/callback` on our Web app. The OAuth client stores the access tokens for the server, and then we attach their account's [DID](https://atproto.com/specs/did) to their cookie-session. - -```typescript -/** src/routes.ts **/ -// OAuth callback to complete session creation -router.get( - '/oauth/callback', - handler(async (req, res) => { - // Store the credentials - const { agent } = await oauthClient.callback(params) - - // Attach the account DID to our user via a cookie - const session = await getIronSession(req, res) - session.did = agent.accountDid - await session.save() - - // Send them back to the app - return res.redirect('/') - }) -) -``` - -With that, we're in business! We now have a session with the user's `at://` repo server and can use that to access their data. - -## Step 3. Fetching the user's profile - -Why don't we learn something about our user? In [Bluesky](https://bsky.app), users publish a "profile" record which looks like this: - -```typescript -interface ProfileRecord { - displayName?: string // a human friendly name - description?: string // a short bio - avatar?: BlobRef // small profile picture - banner?: BlobRef // banner image to put on profiles - createdAt?: string // declared time this profile data was added - // ... -} -``` - -You can examine this record directly using [atproto-browser.vercel.app](https://atproto-browser.vercel.app). For instance, [this is the profile record for @bsky.app](https://atproto-browser.vercel.app/at?u=at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.actor.profile/self). - -We're going to use the [Agent](https://github.com/bluesky-social/atproto/tree/main/packages/api) associated with the user's OAuth session to fetch this record. - -```typescript -await agent.getRecord({ - repo: agent.accountDid, // The user - collection: 'app.bsky.actor.profile', // The collection - rkey: 'self', // The record key -}) -``` - -When asking for a record, we provide three pieces of information. - -- **repo** The [DID](https://atproto.com/specs/did) which identifies the user, -- **collection** The collection name, and -- **rkey** The record key - -We'll explain the collection name shortly. Record keys are strings with [some restrictions](https://atproto.com/specs/record-key#record-key-syntax) and a couple of common patterns. The `"self"` pattern is used when a collection is expected to only contain one record which describes the user. - -Let's update our homepage to fetch this profile record: - -```typescript -/** src/routes.ts **/ -// Homepage -router.get( - '/', - handler(async (req, res) => { - // If the user is signed in, get an agent which communicates with their server - const agent = await getSessionAgent(req, res, ctx) - - if (!agent) { - // Serve the logged-out view - return res.type('html').send(page(home())) - } - - // Fetch additional information about the logged-in user - const { data: profileRecord } = await agent.getRecord({ - repo: agent.accountDid, // our user's repo - collection: 'app.bsky.actor.profile', // the bluesky profile record type - rkey: 'self', // the record's key - }) - - // Serve the logged-in view - return res - .type('html') - .send(page(home({ profile: profileRecord.value || {} }))) - }) -) -``` - -With that data, we can give a nice personalized welcome banner for our user: - - - -```html - - -``` - -## Step 4. Reading & writing records - -You can think of the user repositories as collections of JSON records: - - - -Let's look again at how we read the "profile" record: - -```typescript -await agent.getRecord({ - repo: agent.accountDid, // The user - collection: 'app.bsky.actor.profile', // The collection - rkey: 'self', // The record key -}) -``` - -We write records using a similar API. Since our goal is to write "status" records, let's look at how that will happen: - -```typescript -// Generate a time-based key for our record -const rkey = TID.nextStr() - -// Write the -await agent.putRecord({ - repo: agent.accountDid, // The user - collection: 'com.example.status', // The collection - rkey, // The record key - record: { // The record value - status: "👍", - createdAt: new Date().toISOString() - } -}) -``` - -Our `POST /status` route is going to use this API to publish the user's status to their repo. - -```typescript -/** src/routes.ts **/ -// "Set status" handler -router.post( - '/status', - handler(async (req, res) => { - // If the user is signed in, get an agent which communicates with their server - const agent = await getSessionAgent(req, res, ctx) - if (!agent) { - return res.status(401).type('html').send('