the statusphere demo reworked into a vite/react app in a monorepo

Merge pull request #9 from bluesky-social/paul/tutorial-v1

Tutorial first draft

authored by

Paul Frazee and committed by
GitHub
546f4ae8 74dc0f96

+1024 -245
+644
TUTORIAL.md
···
··· 1 + # Tutorial 2 + 3 + In this guide, we're going to build a simple multi-user app that publishes your current "status" as an emoji. 4 + 5 + ![A screenshot of our example application](./docs/app-screenshot.png) 6 + 7 + At various points we will cover how to: 8 + 9 + - Signin via OAuth 10 + - Fetch information about users (profiles) 11 + - Listen to the network firehose for new data 12 + - Publish data on the user's account using a custom schema 13 + 14 + 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. 15 + 16 + ## Where are we going? 17 + 18 + 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. 19 + 20 + 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: 21 + 22 + - `nytimes.com` is feeling 📰 according to `https://nytimes.com/status.json` 23 + - `bsky.app` is feeling 🦋 according to `https://bsky.app/status.json` 24 + - `reddit.com` is feeling 🤓 according to `https://reddit.com/status.json` 25 + 26 + 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. 27 + 28 + > `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. 29 + 30 + ## Step 1. Starting with our ExpressJS app 31 + 32 + Start by cloning the repo and installing packages. 33 + 34 + ```bash 35 + git clone TODO 36 + cd TODO 37 + npm i 38 + npm run dev # you can leave this running and it will auto-reload 39 + ``` 40 + 41 + 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/). 42 + 43 + Our starting stack: 44 + 45 + - Typescript 46 + - NodeJS web server ([express](https://expressjs.com/)) 47 + - SQLite database ([Kysley](https://kysely.dev/)) 48 + - Server-side rendering ([uhtml](https://www.npmjs.com/package/uhtml)) 49 + 50 + With each step we'll explain how our Web app taps into the Atmosphere. Refer to the codebase for more detailed code &mdash; again, this tutorial is going to keep it light and quick to digest. 51 + 52 + ## Step 2. Signing in with OAuth 53 + 54 + 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. 55 + 56 + 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: 57 + 58 + ![A diagram of the OAuth elements](./docs/diagram-oauth.png) 59 + 60 + 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. 61 + 62 + ![A screenshot of the login UI](./docs/app-login.png) 63 + 64 + 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`). 65 + 66 + ```html 67 + <!-- src/pages/login.ts --> 68 + <form action="/login" method="post" class="login-form"> 69 + <input 70 + type="text" 71 + name="handle" 72 + placeholder="Enter your handle (eg alice.bsky.social)" 73 + required 74 + /> 75 + <button type="submit">Log in</button> 76 + </form> 77 + ``` 78 + 79 + 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. 80 + 81 + ```typescript 82 + /** src/routes.ts **/ 83 + // Login handler 84 + router.post( 85 + '/login', 86 + handler(async (req, res) => { 87 + // Initiate the OAuth flow 88 + const url = await oauthClient.authorize(handle) 89 + return res.redirect(url.toString()) 90 + }) 91 + ) 92 + ``` 93 + 94 + 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. 95 + 96 + 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. 97 + 98 + ```typescript 99 + /** src/routes.ts **/ 100 + // OAuth callback to complete session creation 101 + router.get( 102 + '/oauth/callback', 103 + handler(async (req, res) => { 104 + // Store the credentials 105 + const { agent } = await oauthClient.callback(params) 106 + 107 + // Attach the account DID to our user via a cookie 108 + const session = await getIronSession(req, res) 109 + session.did = agent.accountDid 110 + await session.save() 111 + 112 + // Send them back to the app 113 + return res.redirect('/') 114 + }) 115 + ) 116 + ``` 117 + 118 + 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. 119 + 120 + ## Step 3. Fetching the user's profile 121 + 122 + Why don't we learn something about our user? In [Bluesky](https://bsky.app), users publish a "profile" record which looks like this: 123 + 124 + ```typescript 125 + interface ProfileRecord { 126 + displayName?: string // a human friendly name 127 + description?: string // a short bio 128 + avatar?: BlobRef // small profile picture 129 + banner?: BlobRef // banner image to put on profiles 130 + createdAt?: string // declared time this profile data was added 131 + // ... 132 + } 133 + ``` 134 + 135 + 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). 136 + 137 + 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. 138 + 139 + ```typescript 140 + await agent.getRecord({ 141 + repo: agent.accountDid, // The user 142 + collection: 'app.bsky.actor.profile', // The collection 143 + rkey: 'self', // The record key 144 + }) 145 + ``` 146 + 147 + When asking for a record, we provide three pieces of information. 148 + 149 + - **repo** The [DID](https://atproto.com/specs/did) which identifies the user, 150 + - **collection** The collection name, and 151 + - **rkey** The record key 152 + 153 + 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. 154 + 155 + Let's update our homepage to fetch this profile record: 156 + 157 + ```typescript 158 + /** src/routes.ts **/ 159 + // Homepage 160 + router.get( 161 + '/', 162 + handler(async (req, res) => { 163 + // If the user is signed in, get an agent which communicates with their server 164 + const agent = await getSessionAgent(req, res, ctx) 165 + 166 + if (!agent) { 167 + // Serve the logged-out view 168 + return res.type('html').send(page(home())) 169 + } 170 + 171 + // Fetch additional information about the logged-in user 172 + const { data: profileRecord } = await agent.getRecord({ 173 + repo: agent.accountDid, // our user's repo 174 + collection: 'app.bsky.actor.profile', // the bluesky profile record type 175 + rkey: 'self', // the record's key 176 + }) 177 + 178 + // Serve the logged-in view 179 + return res 180 + .type('html') 181 + .send(page(home({ profile: profileRecord.value || {} }))) 182 + }) 183 + ) 184 + ``` 185 + 186 + With that data, we can give a nice personalized welcome banner for our user: 187 + 188 + ![A screenshot of the banner image](./docs/app-banner.png) 189 + 190 + ```html 191 + <!-- pages/home.ts --> 192 + <div class="card"> 193 + ${profile 194 + ? html`<form action="/logout" method="post" class="session-form"> 195 + <div> 196 + Hi, <strong>${profile.displayName || 'friend'}</strong>. 197 + What's your status today? 198 + </div> 199 + <div> 200 + <button type="submit">Log out</button> 201 + </div> 202 + </form>` 203 + : html`<div class="session-form"> 204 + <div><a href="/login">Log in</a> to set your status!</div> 205 + <div> 206 + <a href="/login" class="button">Log in</a> 207 + </div> 208 + </div>`} 209 + </div> 210 + ``` 211 + 212 + ## Step 4. Reading & writing records 213 + 214 + You can think of the user repositories as collections of JSON records: 215 + 216 + ![A diagram of a repository](./docs/diagram-repo.png) 217 + 218 + Let's look again at how we read the "profile" record: 219 + 220 + ```typescript 221 + await agent.getRecord({ 222 + repo: agent.accountDid, // The user 223 + collection: 'app.bsky.actor.profile', // The collection 224 + rkey: 'self', // The record key 225 + }) 226 + ``` 227 + 228 + We write records using a similar API. Since our goal is to write "status" records, let's look at how that will happen: 229 + 230 + ```typescript 231 + // Generate a time-based key for our record 232 + const rkey = TID.nextStr() 233 + 234 + // Write the 235 + await agent.putRecord({ 236 + repo: agent.accountDid, // The user 237 + collection: 'com.example.status', // The collection 238 + rkey, // The record key 239 + record: { // The record value 240 + status: "👍", 241 + createdAt: new Date().toISOString() 242 + } 243 + }) 244 + ``` 245 + 246 + Our `POST /status` route is going to use this API to publish the user's status to their repo. 247 + 248 + ```typescript 249 + /** src/routes.ts **/ 250 + // "Set status" handler 251 + router.post( 252 + '/status', 253 + handler(async (req, res) => { 254 + // If the user is signed in, get an agent which communicates with their server 255 + const agent = await getSessionAgent(req, res, ctx) 256 + if (!agent) { 257 + return res.status(401).type('html').send('<h1>Error: Session required</h1>') 258 + } 259 + 260 + // Construct their status record 261 + const record = { 262 + $type: 'com.example.status', 263 + status: req.body?.status, 264 + createdAt: new Date().toISOString(), 265 + } 266 + 267 + try { 268 + // Write the status record to the user's repository 269 + await agent.putRecord({ 270 + repo: agent.accountDid, 271 + collection: 'com.example.status', 272 + rkey: TID.nextStr(), 273 + record, 274 + }) 275 + } catch (err) { 276 + logger.warn({ err }, 'failed to write record') 277 + return res.status(500).type('html').send('<h1>Error: Failed to write record</h1>') 278 + } 279 + 280 + res.status(200).json({}) 281 + }) 282 + ) 283 + ``` 284 + 285 + Now in our homepage we can list out the status buttons: 286 + 287 + ```html 288 + <!-- src/pages/home.ts --> 289 + <form action="/status" method="post" class="status-options"> 290 + ${STATUS_OPTIONS.map(status => html` 291 + <button class="status-option" name="status" value="${status}"> 292 + ${status} 293 + </button> 294 + `)} 295 + </form> 296 + ``` 297 + 298 + And here we are! 299 + 300 + ![A screenshot of the app's status options](./docs/app-status-options.png) 301 + 302 + ## Step 5. Creating a custom "status" schema 303 + 304 + Repo collections are typed, meaning that they have a defined schema. The `app.bsky.actor.profile` type definition [can be found here](https://github.com/bluesky-social/atproto/blob/main/lexicons/app/bsky/actor/profile.json). 305 + 306 + Anybody can create a new schema using the [Lexicon](https://atproto.com/specs/lexicon) language, which is very similar to [JSON-Schema](http://json-schema.org/). The schemas use [reverse-DNS IDs](https://atproto.com/specs/nsid) which indicate ownership, but for this demo app we're going to use `com.example` which is safe for non-production software. 307 + 308 + > ### Why create a schema? 309 + > 310 + > Schemas help other applications understand the data your app is creating. By publishing your schemas, you make it easier for other application authors to publish data in a format your app will recognize and handle. 311 + 312 + Let's create our schema in the `/lexicons` folder of our codebase. You can [read more about how to define schemas here](https://atproto.com/guides/lexicon). 313 + 314 + ```json 315 + /** lexicons/status.json **/ 316 + { 317 + "lexicon": 1, 318 + "id": "com.example.status", 319 + "defs": { 320 + "main": { 321 + "type": "record", 322 + "key": "tid", 323 + "record": { 324 + "type": "object", 325 + "required": ["status", "createdAt"], 326 + "properties": { 327 + "status": { 328 + "type": "string", 329 + "minLength": 1, 330 + "maxGraphemes": 1, 331 + "maxLength": 32 332 + }, 333 + "createdAt": { 334 + "type": "string", 335 + "format": "datetime" 336 + } 337 + } 338 + } 339 + } 340 + } 341 + } 342 + ``` 343 + 344 + Now let's run some code-generation using our schema: 345 + 346 + ```bash 347 + ./node_modules/.bin/lex gen-server ./src/lexicon ./lexicons/* 348 + ``` 349 + 350 + This will produce Typescript interfaces as well as runtime validation functions that we can use in our app. Here's what that generated code looks like: 351 + 352 + ```typescript 353 + /** src/lexicon/types/com/example/status.ts **/ 354 + export interface Record { 355 + status: string 356 + createdAt: string 357 + [k: string]: unknown 358 + } 359 + 360 + export function isRecord(v: unknown): v is Record { 361 + return ( 362 + isObj(v) && 363 + hasProp(v, '$type') && 364 + (v.$type === 'com.example.status#main' || v.$type === 'com.example.status') 365 + ) 366 + } 367 + 368 + export function validateRecord(v: unknown): ValidationResult { 369 + return lexicons.validate('com.example.status#main', v) 370 + } 371 + ``` 372 + 373 + Let's use that code to improve the `POST /status` route: 374 + 375 + ```typescript 376 + /** src/routes.ts **/ 377 + import * as Status from '#/lexicon/types/com/example/status' 378 + // ... 379 + // "Set status" handler 380 + router.post( 381 + '/status', 382 + handler(async (req, res) => { 383 + // ... 384 + 385 + // Construct & validate their status record 386 + const record = { 387 + $type: 'com.example.status', 388 + status: req.body?.status, 389 + createdAt: new Date().toISOString(), 390 + } 391 + if (!Status.validateRecord(record).success) { 392 + return res.status(400).json({ error: 'Invalid status' }) 393 + } 394 + 395 + // ... 396 + }) 397 + ) 398 + ``` 399 + 400 + ## Step 6. Listening to the firehose 401 + 402 + So far, we have: 403 + 404 + - Logged in via OAuth 405 + - Created a custom schema 406 + - Read & written records for the logged in user 407 + 408 + Now we want to fetch the status records from other users. 409 + 410 + Remember how we referred to our app as being like a Google, crawling around the repos to get their records? One advantage we have in the AT Protocol is that each repo publishes an event log of their updates. 411 + 412 + ![A diagram of the event stream](./docs/diagram-event-stream.png) 413 + 414 + Using a [Relay service](https://docs.bsky.app/docs/advanced-guides/federation-architecture#relay) we can listen to an aggregated firehose of these events across all users in the network. In our case what we're looking for are valid `com.example.status` records. 415 + 416 + 417 + ```typescript 418 + /** src/firehose.ts **/ 419 + import * as Status from '#/lexicon/types/com/example/status' 420 + // ... 421 + const firehose = new Firehose({}) 422 + 423 + for await (const evt of firehose.run()) { 424 + // Watch for write events 425 + if (evt.event === 'create' || evt.event === 'update') { 426 + const record = evt.record 427 + 428 + // If the write is a valid status update 429 + if ( 430 + evt.collection === 'com.example.status' && 431 + Status.isRecord(record) && 432 + Status.validateRecord(record).success 433 + ) { 434 + // Store the status 435 + // TODO 436 + } 437 + } 438 + } 439 + ``` 440 + 441 + Let's create a SQLite table to store these statuses: 442 + 443 + ```typescript 444 + /** src/db.ts **/ 445 + // Create our statuses table 446 + await db.schema 447 + .createTable('status') 448 + .addColumn('uri', 'varchar', (col) => col.primaryKey()) 449 + .addColumn('authorDid', 'varchar', (col) => col.notNull()) 450 + .addColumn('status', 'varchar', (col) => col.notNull()) 451 + .addColumn('createdAt', 'varchar', (col) => col.notNull()) 452 + .addColumn('indexedAt', 'varchar', (col) => col.notNull()) 453 + .execute() 454 + ``` 455 + 456 + Now we can write these statuses into our database as they arrive from the firehose: 457 + 458 + ```typescript 459 + /** src/firehose.ts **/ 460 + // If the write is a valid status update 461 + if ( 462 + evt.collection === 'com.example.status' && 463 + Status.isRecord(record) && 464 + Status.validateRecord(record).success 465 + ) { 466 + // Store the status in our SQLite 467 + await db 468 + .insertInto('status') 469 + .values({ 470 + uri: evt.uri.toString(), 471 + authorDid: evt.author, 472 + status: record.status, 473 + createdAt: record.createdAt, 474 + indexedAt: new Date().toISOString(), 475 + }) 476 + .onConflict((oc) => 477 + oc.column('uri').doUpdateSet({ 478 + status: record.status, 479 + indexedAt: new Date().toISOString(), 480 + }) 481 + ) 482 + .execute() 483 + } 484 + ``` 485 + 486 + You can almost think of information flowing in a loop: 487 + 488 + ![A diagram of the flow of information](./docs/diagram-info-flow.png) 489 + 490 + Applications write to the repo. The write events are then emitted on the firehose where they're caught by the apps and ingested into their databases. 491 + 492 + Why sync from the event log like this? Because there are other apps in the network that will write the records we're interested in. By subscribing to the event log, we ensure that we catch all the data we're interested in &mdash; including data published by other apps! 493 + 494 + ## Step 7. Listing the latest statuses 495 + 496 + Now that we have statuses populating our SQLite, we can produce a timeline of status updates by users. We also use a [DID](https://atproto.com/specs/did)-to-handle resolver so we can show a nice username with the statuses: 497 + 498 + ```typescript 499 + /** src/routes.ts **/ 500 + // Homepage 501 + router.get( 502 + '/', 503 + handler(async (req, res) => { 504 + // ... 505 + 506 + // Fetch data stored in our SQLite 507 + const statuses = await db 508 + .selectFrom('status') 509 + .selectAll() 510 + .orderBy('indexedAt', 'desc') 511 + .limit(10) 512 + .execute() 513 + 514 + // Map user DIDs to their domain-name handles 515 + const didHandleMap = await resolver.resolveDidsToHandles( 516 + statuses.map((s) => s.authorDid) 517 + ) 518 + 519 + // ... 520 + }) 521 + ) 522 + ``` 523 + 524 + Our HTML can now list these status records: 525 + 526 + ```html 527 + <!-- src/pages/home.ts --> 528 + ${statuses.map((status, i) => { 529 + const handle = didHandleMap[status.authorDid] || status.authorDid 530 + return html` 531 + <div class="status-line"> 532 + <div> 533 + <div class="status">${status.status}</div> 534 + </div> 535 + <div class="desc"> 536 + <a class="author" href="https://bsky.app/profile/${handle}">@${handle}</a> 537 + was feeling ${status.status} on ${status.indexedAt}. 538 + </div> 539 + </div> 540 + ` 541 + })} 542 + ``` 543 + 544 + ![A screenshot of the app status timeline](./docs/app-status-history.png) 545 + 546 + ## Step 8. Optimistic updates 547 + 548 + As a final optimization, let's introduce "optimistic updates." 549 + 550 + Remember the information flow loop with the repo write and the event log? 551 + 552 + ![A diagram of the flow of information](./docs/diagram-info-flow.png) 553 + 554 + Since we're updating our users' repos locally, we can short-circuit that flow to our own database: 555 + 556 + ![A diagram illustrating optimistic updates](./docs/diagram-optimistic-update.png) 557 + 558 + This is an important optimization to make, because it ensures that the user sees their own changes while using your app. When the event eventually arrives from the firehose, we just discard it since we already have it saved locally. 559 + 560 + To do this, we just update `POST /status` to include an additional write to our SQLite DB: 561 + 562 + ```typescript 563 + /** src/routes.ts **/ 564 + // "Set status" handler 565 + router.post( 566 + '/status', 567 + handler(async (req, res) => { 568 + // ... 569 + 570 + let uri 571 + try { 572 + // Write the status record to the user's repository 573 + const res = await agent.putRecord({ 574 + repo: agent.accountDid, 575 + collection: 'com.example.status', 576 + rkey: TID.nextStr(), 577 + record, 578 + }) 579 + uri = res.uri 580 + } catch (err) { 581 + logger.warn({ err }, 'failed to write record') 582 + return res.status(500).json({ error: 'Failed to write record' }) 583 + } 584 + 585 + try { 586 + // Optimistically update our SQLite <-- HERE! 587 + await db 588 + .insertInto('status') 589 + .values({ 590 + uri, 591 + authorDid: agent.accountDid, 592 + status: record.status, 593 + createdAt: record.createdAt, 594 + indexedAt: new Date().toISOString(), 595 + }) 596 + .execute() 597 + } catch (err) { 598 + logger.warn( 599 + { err }, 600 + 'failed to update computed view; ignoring as it should be caught by the firehose' 601 + ) 602 + } 603 + 604 + res.status(200).json({}) 605 + }) 606 + ) 607 + ``` 608 + 609 + You'll notice this code looks almost exactly like what we're doing in `firehose.ts`. 610 + 611 + ## Thinking in AT Proto 612 + 613 + In this tutorial we've covered the key steps to building an atproto app. Data is published in its canonical form on users' `at://` repos and then aggregated into apps' databases to produce views of the network. 614 + 615 + When building your app, think in these four key steps: 616 + 617 + - Design the [Lexicon](#) schemas for the records you'll publish into the Atmosphere. 618 + - Create a database for aggregating the records into useful views. 619 + - Build your application to write the records on your users' repos. 620 + - Listen to the firehose to aggregate data across the network. 621 + 622 + Remember this flow of information throughout: 623 + 624 + ![A diagram of the flow of information](./docs/diagram-info-flow.png) 625 + 626 + This is how every app in the Atmosphere works, including the [Bluesky social app](https://bsky.app). 627 + 628 + ## Next steps 629 + 630 + If you want to practice what you've learned, here are some additional challenges you could try: 631 + 632 + - Sync the profile records of all users so that you can show their display names instead of their handles. 633 + - Count the number of each status used and display the total counts. 634 + - Fetch the authed user's `app.bsky.graph.follow` follows and show statuses from them. 635 + - Create a different kind of schema, like a way to post links to websites and rate them 1 through 4 stars. 636 + 637 + You can find more information here: 638 + 639 + |Resources|-| 640 + |-|-| 641 + |[@ ATProto docs](https://atproto.com)|Learn more about the AT Protocol.| 642 + |[🦋 Bluesky API docs](https://docs.bsky.app/)|See how Bluesky works as an ATProto app.| 643 + |[📦 ATProto monorepo](https://github.com/bluesky-social/atproto)|See the source code first-hand.| 644 + |[💬 ATProto discussions board](https://github.com/bluesky-social/atproto/discussions)|Ask any questions you have!|
docs/app-banner.png

This is a binary file and will not be displayed.

docs/app-login.png

This is a binary file and will not be displayed.

docs/app-screenshot.png

This is a binary file and will not be displayed.

docs/app-status-history.png

This is a binary file and will not be displayed.

docs/app-status-options.png

This is a binary file and will not be displayed.

docs/diagram-event-stream.png

This is a binary file and will not be displayed.

docs/diagram-info-flow.png

This is a binary file and will not be displayed.

docs/diagram-oauth.png

This is a binary file and will not be displayed.

docs/diagram-optimistic-update.png

This is a binary file and will not be displayed.

docs/diagram-repo.png

This is a binary file and will not be displayed.

+49
lexicons/profile.json
···
··· 1 + { 2 + "lexicon": 1, 3 + "id": "app.bsky.actor.profile", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "A declaration of a Bluesky account profile.", 8 + "key": "literal:self", 9 + "record": { 10 + "type": "object", 11 + "properties": { 12 + "displayName": { 13 + "type": "string", 14 + "maxGraphemes": 64, 15 + "maxLength": 640 16 + }, 17 + "description": { 18 + "type": "string", 19 + "description": "Free-form profile description text.", 20 + "maxGraphemes": 256, 21 + "maxLength": 2560 22 + }, 23 + "avatar": { 24 + "type": "blob", 25 + "description": "Small image to be displayed next to posts from account. AKA, 'profile picture'", 26 + "accept": ["image/png", "image/jpeg"], 27 + "maxSize": 1000000 28 + }, 29 + "banner": { 30 + "type": "blob", 31 + "description": "Larger horizontal image to display behind profile view.", 32 + "accept": ["image/png", "image/jpeg"], 33 + "maxSize": 1000000 34 + }, 35 + "labels": { 36 + "type": "union", 37 + "description": "Self-label values, specific to the Bluesky application, on the overall account.", 38 + "refs": ["com.atproto.label.defs#selfLabels"] 39 + }, 40 + "joinedViaStarterPack": { 41 + "type": "ref", 42 + "ref": "com.atproto.repo.strongRef" 43 + }, 44 + "createdAt": { "type": "string", "format": "datetime" } 45 + } 46 + } 47 + } 48 + } 49 + }
+3 -3
lexicons/status.json
··· 4 "defs": { 5 "main": { 6 "type": "record", 7 - "key": "literal:self", 8 "record": { 9 "type": "object", 10 - "required": ["status", "updatedAt"], 11 "properties": { 12 "status": { 13 "type": "string", ··· 15 "maxGraphemes": 1, 16 "maxLength": 32 17 }, 18 - "updatedAt": { "type": "string", "format": "datetime" } 19 } 20 } 21 }
··· 4 "defs": { 5 "main": { 6 "type": "record", 7 + "key": "tid", 8 "record": { 9 "type": "object", 10 + "required": ["status", "createdAt"], 11 "properties": { 12 "status": { 13 "type": "string", ··· 15 "maxGraphemes": 1, 16 "maxLength": 32 17 }, 18 + "createdAt": { "type": "string", "format": "datetime" } 19 } 20 } 21 }
+2 -25
package-lock.json
··· 9 "version": "0.0.1", 10 "license": "MIT", 11 "dependencies": { 12 "@atproto/identity": "^0.4.0", 13 "@atproto/lexicon": "0.4.1-rc.0", 14 "@atproto/oauth-client-node": "0.0.2-rc.2", ··· 23 "kysely": "^0.27.4", 24 "multiformats": "^9.9.0", 25 "pino": "^9.3.2", 26 - "pino-http": "^10.0.0", 27 "uhtml": "^4.5.9" 28 }, 29 "devDependencies": { ··· 142 "version": "0.4.1", 143 "resolved": "https://registry.npmjs.org/@atproto/common/-/common-0.4.1.tgz", 144 "integrity": "sha512-uL7kQIcBTbvkBDNfxMXL6lBH4fO2DQpHd2BryJxMtbw/4iEPKe9xBYApwECHhEIk9+zhhpTRZ15FJ3gxTXN82Q==", 145 "dependencies": { 146 "@atproto/common-web": "^0.3.0", 147 "@ipld/dag-cbor": "^7.0.3", ··· 1944 "resolved": "https://registry.npmjs.org/gc-hook/-/gc-hook-0.3.1.tgz", 1945 "integrity": "sha512-E5M+O/h2o7eZzGhzRZGex6hbB3k4NWqO0eA+OzLRLXxhdbYPajZnynPwAtphnh+cRHPwsj5Z80dqZlfI4eK55A==" 1946 }, 1947 - "node_modules/get-caller-file": { 1948 - "version": "2.0.5", 1949 - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", 1950 - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", 1951 - "engines": { 1952 - "node": "6.* || 8.* || >= 10.*" 1953 - } 1954 - }, 1955 "node_modules/get-intrinsic": { 1956 "version": "1.2.4", 1957 "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", ··· 2748 "readable-stream": "^4.0.0", 2749 "split2": "^4.0.0" 2750 } 2751 - }, 2752 - "node_modules/pino-http": { 2753 - "version": "10.2.0", 2754 - "resolved": "https://registry.npmjs.org/pino-http/-/pino-http-10.2.0.tgz", 2755 - "integrity": "sha512-am03BxnV3Ckx68OkbH0iZs3indsrH78wncQ6w1w51KroIbvJZNImBKX2X1wjdY8lSyaJ0UrX/dnO2DY3cTeCRw==", 2756 - "dependencies": { 2757 - "get-caller-file": "^2.0.5", 2758 - "pino": "^9.0.0", 2759 - "pino-std-serializers": "^7.0.0", 2760 - "process-warning": "^3.0.0" 2761 - } 2762 - }, 2763 - "node_modules/pino-http/node_modules/process-warning": { 2764 - "version": "3.0.0", 2765 - "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-3.0.0.tgz", 2766 - "integrity": "sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ==" 2767 }, 2768 "node_modules/pino-pretty": { 2769 "version": "11.2.2",
··· 9 "version": "0.0.1", 10 "license": "MIT", 11 "dependencies": { 12 + "@atproto/common": "^0.4.1", 13 "@atproto/identity": "^0.4.0", 14 "@atproto/lexicon": "0.4.1-rc.0", 15 "@atproto/oauth-client-node": "0.0.2-rc.2", ··· 24 "kysely": "^0.27.4", 25 "multiformats": "^9.9.0", 26 "pino": "^9.3.2", 27 "uhtml": "^4.5.9" 28 }, 29 "devDependencies": { ··· 142 "version": "0.4.1", 143 "resolved": "https://registry.npmjs.org/@atproto/common/-/common-0.4.1.tgz", 144 "integrity": "sha512-uL7kQIcBTbvkBDNfxMXL6lBH4fO2DQpHd2BryJxMtbw/4iEPKe9xBYApwECHhEIk9+zhhpTRZ15FJ3gxTXN82Q==", 145 + "license": "MIT", 146 "dependencies": { 147 "@atproto/common-web": "^0.3.0", 148 "@ipld/dag-cbor": "^7.0.3", ··· 1945 "resolved": "https://registry.npmjs.org/gc-hook/-/gc-hook-0.3.1.tgz", 1946 "integrity": "sha512-E5M+O/h2o7eZzGhzRZGex6hbB3k4NWqO0eA+OzLRLXxhdbYPajZnynPwAtphnh+cRHPwsj5Z80dqZlfI4eK55A==" 1947 }, 1948 "node_modules/get-intrinsic": { 1949 "version": "1.2.4", 1950 "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", ··· 2741 "readable-stream": "^4.0.0", 2742 "split2": "^4.0.0" 2743 } 2744 }, 2745 "node_modules/pino-pretty": { 2746 "version": "11.2.2",
+1
package.json
··· 14 "clean": "rimraf dist coverage" 15 }, 16 "dependencies": { 17 "@atproto/identity": "^0.4.0", 18 "@atproto/lexicon": "0.4.1-rc.0", 19 "@atproto/oauth-client-node": "0.0.2-rc.2",
··· 14 "clean": "rimraf dist coverage" 15 }, 16 "dependencies": { 17 + "@atproto/common": "^0.4.1", 18 "@atproto/identity": "^0.4.0", 19 "@atproto/lexicon": "0.4.1-rc.0", 20 "@atproto/oauth-client-node": "0.0.2-rc.2",
-61
src/auth/session.ts
··· 1 - import assert from 'node:assert' 2 - import type { IncomingMessage, ServerResponse } from 'node:http' 3 - import { getIronSession } from 'iron-session' 4 - import { env } from '#/lib/env' 5 - import { AppContext } from '#/index' 6 - 7 - export type Session = { did: string } 8 - 9 - export async function createSession( 10 - req: IncomingMessage, 11 - res: ServerResponse<IncomingMessage>, 12 - did: string 13 - ) { 14 - const session = await getSessionRaw(req, res) 15 - assert(!session.did, 'session already exists') 16 - session.did = did 17 - await session.save() 18 - return { did: session.did } 19 - } 20 - 21 - export async function destroySession( 22 - req: IncomingMessage, 23 - res: ServerResponse<IncomingMessage> 24 - ) { 25 - const session = await getSessionRaw(req, res) 26 - await session.destroy() 27 - return null 28 - } 29 - 30 - export async function getSession( 31 - req: IncomingMessage, 32 - res: ServerResponse<IncomingMessage> 33 - ) { 34 - const session = await getSessionRaw(req, res) 35 - if (!session.did) return null 36 - return { did: session.did } 37 - } 38 - 39 - export async function getSessionAgent( 40 - req: IncomingMessage, 41 - res: ServerResponse<IncomingMessage>, 42 - ctx: AppContext 43 - ) { 44 - const session = await getSessionRaw(req, res) 45 - if (!session.did) return null 46 - return await ctx.oauthClient.restore(session.did).catch(async (err) => { 47 - ctx.logger.warn({ err }, 'oauth restore failed') 48 - await destroySession(req, res) 49 - return null 50 - }) 51 - } 52 - 53 - async function getSessionRaw( 54 - req: IncomingMessage, 55 - res: ServerResponse<IncomingMessage> 56 - ) { 57 - return await getIronSession<Session>(req, res, { 58 - cookieName: 'sid', 59 - password: env.COOKIE_SECRET, 60 - }) 61 - }
···
+94
src/db.ts
···
··· 1 + import SqliteDb from 'better-sqlite3' 2 + import { 3 + Kysely, 4 + Migrator, 5 + SqliteDialect, 6 + Migration, 7 + MigrationProvider, 8 + } from 'kysely' 9 + 10 + // Types 11 + 12 + export type DatabaseSchema = { 13 + status: Status 14 + auth_session: AuthSession 15 + auth_state: AuthState 16 + } 17 + 18 + export type Status = { 19 + uri: string 20 + authorDid: string 21 + status: string 22 + createdAt: string 23 + indexedAt: string 24 + } 25 + 26 + export type AuthSession = { 27 + key: string 28 + session: AuthSessionJson 29 + } 30 + 31 + export type AuthState = { 32 + key: string 33 + state: AuthStateJson 34 + } 35 + 36 + type AuthStateJson = string 37 + 38 + type AuthSessionJson = string 39 + 40 + // Migrations 41 + 42 + const migrations: Record<string, Migration> = {} 43 + 44 + const migrationProvider: MigrationProvider = { 45 + async getMigrations() { 46 + return migrations 47 + }, 48 + } 49 + 50 + migrations['001'] = { 51 + async up(db: Kysely<unknown>) { 52 + await db.schema 53 + .createTable('status') 54 + .addColumn('uri', 'varchar', (col) => col.primaryKey()) 55 + .addColumn('authorDid', 'varchar', (col) => col.notNull()) 56 + .addColumn('status', 'varchar', (col) => col.notNull()) 57 + .addColumn('createdAt', 'varchar', (col) => col.notNull()) 58 + .addColumn('indexedAt', 'varchar', (col) => col.notNull()) 59 + .execute() 60 + await db.schema 61 + .createTable('auth_session') 62 + .addColumn('key', 'varchar', (col) => col.primaryKey()) 63 + .addColumn('session', 'varchar', (col) => col.notNull()) 64 + .execute() 65 + await db.schema 66 + .createTable('auth_state') 67 + .addColumn('key', 'varchar', (col) => col.primaryKey()) 68 + .addColumn('state', 'varchar', (col) => col.notNull()) 69 + .execute() 70 + }, 71 + async down(db: Kysely<unknown>) { 72 + await db.schema.dropTable('auth_state').execute() 73 + await db.schema.dropTable('auth_session').execute() 74 + await db.schema.dropTable('status').execute() 75 + }, 76 + } 77 + 78 + // APIs 79 + 80 + export const createDb = (location: string): Database => { 81 + return new Kysely<DatabaseSchema>({ 82 + dialect: new SqliteDialect({ 83 + database: new SqliteDb(location), 84 + }), 85 + }) 86 + } 87 + 88 + export const migrateToLatest = async (db: Database) => { 89 + const migrator = new Migrator({ db, provider: migrationProvider }) 90 + const { error } = await migrator.migrateToLatest() 91 + if (error) throw error 92 + } 93 + 94 + export type Database = Kysely<DatabaseSchema>
-20
src/db/index.ts
··· 1 - import SqliteDb from 'better-sqlite3' 2 - import { Kysely, Migrator, SqliteDialect } from 'kysely' 3 - import { migrationProvider } from './migrations' 4 - import type { DatabaseSchema } from './schema' 5 - 6 - export const createDb = (location: string): Database => { 7 - return new Kysely<DatabaseSchema>({ 8 - dialect: new SqliteDialect({ 9 - database: new SqliteDb(location), 10 - }), 11 - }) 12 - } 13 - 14 - export const migrateToLatest = async (db: Database) => { 15 - const migrator = new Migrator({ db, provider: migrationProvider }) 16 - const { error } = await migrator.migrateToLatest() 17 - if (error) throw error 18 - } 19 - 20 - export type Database = Kysely<DatabaseSchema>
···
-36
src/db/migrations.ts
··· 1 - import type { Kysely, Migration, MigrationProvider } from 'kysely' 2 - 3 - const migrations: Record<string, Migration> = {} 4 - 5 - export const migrationProvider: MigrationProvider = { 6 - async getMigrations() { 7 - return migrations 8 - }, 9 - } 10 - 11 - migrations['001'] = { 12 - async up(db: Kysely<unknown>) { 13 - await db.schema 14 - .createTable('status') 15 - .addColumn('authorDid', 'varchar', (col) => col.primaryKey()) 16 - .addColumn('status', 'varchar', (col) => col.notNull()) 17 - .addColumn('updatedAt', 'varchar', (col) => col.notNull()) 18 - .addColumn('indexedAt', 'varchar', (col) => col.notNull()) 19 - .execute() 20 - await db.schema 21 - .createTable('auth_session') 22 - .addColumn('key', 'varchar', (col) => col.primaryKey()) 23 - .addColumn('session', 'varchar', (col) => col.notNull()) 24 - .execute() 25 - await db.schema 26 - .createTable('auth_state') 27 - .addColumn('key', 'varchar', (col) => col.primaryKey()) 28 - .addColumn('state', 'varchar', (col) => col.notNull()) 29 - .execute() 30 - }, 31 - async down(db: Kysely<unknown>) { 32 - await db.schema.dropTable('auth_state').execute() 33 - await db.schema.dropTable('auth_session').execute() 34 - await db.schema.dropTable('status').execute() 35 - }, 36 - }
···
-26
src/db/schema.ts
··· 1 - export type DatabaseSchema = { 2 - status: Status 3 - auth_session: AuthSession 4 - auth_state: AuthState 5 - } 6 - 7 - export type Status = { 8 - authorDid: string 9 - status: string 10 - updatedAt: string 11 - indexedAt: string 12 - } 13 - 14 - export type AuthSession = { 15 - key: string 16 - session: AuthSessionJson 17 - } 18 - 19 - export type AuthState = { 20 - key: string 21 - state: AuthStateJson 22 - } 23 - 24 - type AuthStateJson = string 25 - 26 - type AuthSessionJson = string
···
+9 -3
src/firehose/ingester.ts
··· 24 await this.db 25 .insertInto('status') 26 .values({ 27 authorDid: evt.author, 28 status: record.status, 29 - updatedAt: record.updatedAt, 30 indexedAt: new Date().toISOString(), 31 }) 32 .onConflict((oc) => 33 - oc.column('authorDid').doUpdateSet({ 34 status: record.status, 35 - updatedAt: record.updatedAt, 36 indexedAt: new Date().toISOString(), 37 }) 38 ) 39 .execute() 40 } 41 } 42 } 43 }
··· 24 await this.db 25 .insertInto('status') 26 .values({ 27 + uri: evt.uri.toString(), 28 authorDid: evt.author, 29 status: record.status, 30 + createdAt: record.createdAt, 31 indexedAt: new Date().toISOString(), 32 }) 33 .onConflict((oc) => 34 + oc.column('uri').doUpdateSet({ 35 status: record.status, 36 indexedAt: new Date().toISOString(), 37 }) 38 ) 39 .execute() 40 } 41 + } else if ( 42 + evt.event === 'delete' && 43 + evt.collection === 'com.example.status' 44 + ) { 45 + // Remove the status from our SQLite 46 + await this.db.deleteFrom('status').where({ uri: evt.uri.toString() }) 47 } 48 } 49 }
+30
src/lexicon/index.ts
··· 16 17 export class Server { 18 xrpc: XrpcServer 19 com: ComNS 20 21 constructor(options?: XrpcOptions) { 22 this.xrpc = createXrpcServer(schemas, options) 23 this.com = new ComNS(this) 24 } 25 } 26
··· 16 17 export class Server { 18 xrpc: XrpcServer 19 + app: AppNS 20 com: ComNS 21 22 constructor(options?: XrpcOptions) { 23 this.xrpc = createXrpcServer(schemas, options) 24 + this.app = new AppNS(this) 25 this.com = new ComNS(this) 26 + } 27 + } 28 + 29 + export class AppNS { 30 + _server: Server 31 + bsky: AppBskyNS 32 + 33 + constructor(server: Server) { 34 + this._server = server 35 + this.bsky = new AppBskyNS(server) 36 + } 37 + } 38 + 39 + export class AppBskyNS { 40 + _server: Server 41 + actor: AppBskyActorNS 42 + 43 + constructor(server: Server) { 44 + this._server = server 45 + this.actor = new AppBskyActorNS(server) 46 + } 47 + } 48 + 49 + export class AppBskyActorNS { 50 + _server: Server 51 + 52 + constructor(server: Server) { 53 + this._server = server 54 } 55 } 56
+61 -3
src/lexicon/lexicons.ts
··· 4 import { LexiconDoc, Lexicons } from '@atproto/lexicon' 5 6 export const schemaDict = { 7 ComExampleStatus: { 8 lexicon: 1, 9 id: 'com.example.status', ··· 13 key: 'literal:self', 14 record: { 15 type: 'object', 16 - required: ['status', 'updatedAt'], 17 properties: { 18 status: { 19 type: 'string', ··· 21 maxGraphemes: 1, 22 maxLength: 32, 23 }, 24 - updatedAt: { 25 type: 'string', 26 format: 'datetime', 27 }, ··· 33 } 34 export const schemas: LexiconDoc[] = Object.values(schemaDict) as LexiconDoc[] 35 export const lexicons: Lexicons = new Lexicons(schemas) 36 - export const ids = { ComExampleStatus: 'com.example.status' }
··· 4 import { LexiconDoc, Lexicons } from '@atproto/lexicon' 5 6 export const schemaDict = { 7 + AppBskyActorProfile: { 8 + lexicon: 1, 9 + id: 'app.bsky.actor.profile', 10 + defs: { 11 + main: { 12 + type: 'record', 13 + description: 'A declaration of a Bluesky account profile.', 14 + key: 'literal:self', 15 + record: { 16 + type: 'object', 17 + properties: { 18 + displayName: { 19 + type: 'string', 20 + maxGraphemes: 64, 21 + maxLength: 640, 22 + }, 23 + description: { 24 + type: 'string', 25 + description: 'Free-form profile description text.', 26 + maxGraphemes: 256, 27 + maxLength: 2560, 28 + }, 29 + avatar: { 30 + type: 'blob', 31 + description: 32 + "Small image to be displayed next to posts from account. AKA, 'profile picture'", 33 + accept: ['image/png', 'image/jpeg'], 34 + maxSize: 1000000, 35 + }, 36 + banner: { 37 + type: 'blob', 38 + description: 39 + 'Larger horizontal image to display behind profile view.', 40 + accept: ['image/png', 'image/jpeg'], 41 + maxSize: 1000000, 42 + }, 43 + labels: { 44 + type: 'union', 45 + description: 46 + 'Self-label values, specific to the Bluesky application, on the overall account.', 47 + refs: ['lex:com.atproto.label.defs#selfLabels'], 48 + }, 49 + joinedViaStarterPack: { 50 + type: 'ref', 51 + ref: 'lex:com.atproto.repo.strongRef', 52 + }, 53 + createdAt: { 54 + type: 'string', 55 + format: 'datetime', 56 + }, 57 + }, 58 + }, 59 + }, 60 + }, 61 + }, 62 ComExampleStatus: { 63 lexicon: 1, 64 id: 'com.example.status', ··· 68 key: 'literal:self', 69 record: { 70 type: 'object', 71 + required: ['status', 'createdAt'], 72 properties: { 73 status: { 74 type: 'string', ··· 76 maxGraphemes: 1, 77 maxLength: 32, 78 }, 79 + createdAt: { 80 type: 'string', 81 format: 'datetime', 82 }, ··· 88 } 89 export const schemas: LexiconDoc[] = Object.values(schemaDict) as LexiconDoc[] 90 export const lexicons: Lexicons = new Lexicons(schemas) 91 + export const ids = { 92 + AppBskyActorProfile: 'app.bsky.actor.profile', 93 + ComExampleStatus: 'com.example.status', 94 + }
+38
src/lexicon/types/app/bsky/actor/profile.ts
···
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { ValidationResult, BlobRef } from '@atproto/lexicon' 5 + import { lexicons } from '../../../../lexicons' 6 + import { isObj, hasProp } from '../../../../util' 7 + import { CID } from 'multiformats/cid' 8 + import * as ComAtprotoLabelDefs from '../../../com/atproto/label/defs' 9 + import * as ComAtprotoRepoStrongRef from '../../../com/atproto/repo/strongRef' 10 + 11 + export interface Record { 12 + displayName?: string 13 + /** Free-form profile description text. */ 14 + description?: string 15 + /** Small image to be displayed next to posts from account. AKA, 'profile picture' */ 16 + avatar?: BlobRef 17 + /** Larger horizontal image to display behind profile view. */ 18 + banner?: BlobRef 19 + labels?: 20 + | ComAtprotoLabelDefs.SelfLabels 21 + | { $type: string; [k: string]: unknown } 22 + joinedViaStarterPack?: ComAtprotoRepoStrongRef.Main 23 + createdAt?: string 24 + [k: string]: unknown 25 + } 26 + 27 + export function isRecord(v: unknown): v is Record { 28 + return ( 29 + isObj(v) && 30 + hasProp(v, '$type') && 31 + (v.$type === 'app.bsky.actor.profile#main' || 32 + v.$type === 'app.bsky.actor.profile') 33 + ) 34 + } 35 + 36 + export function validateRecord(v: unknown): ValidationResult { 37 + return lexicons.validate('app.bsky.actor.profile#main', v) 38 + }
+1 -1
src/lexicon/types/com/example/status.ts
··· 8 9 export interface Record { 10 status: string 11 - updatedAt: string 12 [k: string]: unknown 13 } 14
··· 8 9 export interface Record { 10 status: string 11 + createdAt: string 12 [k: string]: unknown 13 } 14
+10 -11
src/pages/home.ts
··· 1 - import type { Status } from '#/db/schema' 2 import { html } from '../lib/view' 3 import { shell } from './shell' 4 ··· 37 type Props = { 38 statuses: Status[] 39 didHandleMap: Record<string, string> 40 - profile?: { displayName?: string; handle: string } 41 myStatus?: Status 42 } 43 ··· 60 ${profile 61 ? html`<form action="/logout" method="post" class="session-form"> 62 <div> 63 - Hi, <strong>${profile.displayName || profile.handle}</strong>. 64 - what's your status today? 65 </div> 66 <div> 67 <button type="submit">Log out</button> ··· 74 </div> 75 </div>`} 76 </div> 77 - <div class="status-options"> 78 ${STATUS_OPTIONS.map( 79 (status) => 80 - html`<div 81 class=${myStatus?.status === status 82 ? 'status-option selected' 83 : 'status-option'} 84 - data-value="${status}" 85 - data-authed=${profile ? '1' : '0'} 86 > 87 ${status} 88 - </div>` 89 )} 90 - </div> 91 ${statuses.map((status, i) => { 92 const handle = didHandleMap[status.authorDid] || status.authorDid 93 const date = ts(status) ··· 106 ` 107 })} 108 </div> 109 - <script src="/public/home.js"></script> 110 </div>` 111 } 112
··· 1 + import type { Status } from '#/db' 2 import { html } from '../lib/view' 3 import { shell } from './shell' 4 ··· 37 type Props = { 38 statuses: Status[] 39 didHandleMap: Record<string, string> 40 + profile?: { displayName?: string } 41 myStatus?: Status 42 } 43 ··· 60 ${profile 61 ? html`<form action="/logout" method="post" class="session-form"> 62 <div> 63 + Hi, <strong>${profile.displayName || 'friend'}</strong>. What's 64 + your status today? 65 </div> 66 <div> 67 <button type="submit">Log out</button> ··· 74 </div> 75 </div>`} 76 </div> 77 + <form action="/status" method="post" class="status-options"> 78 ${STATUS_OPTIONS.map( 79 (status) => 80 + html`<button 81 class=${myStatus?.status === status 82 ? 'status-option selected' 83 : 'status-option'} 84 + name="status" 85 + value="${status}" 86 > 87 ${status} 88 + </button>` 89 )} 90 + </form> 91 ${statuses.map((status, i) => { 92 const handle = didHandleMap[status.authorDid] || status.authorDid 93 const date = ts(status) ··· 106 ` 107 })} 108 </div> 109 </div>` 110 } 111
-32
src/pages/public/home.js
··· 1 - Array.from(document.querySelectorAll('.status-option'), (el) => { 2 - el.addEventListener('click', async (ev) => { 3 - setError('') 4 - 5 - if (el.dataset.authed !== '1') { 6 - window.location = '/login' 7 - return 8 - } 9 - 10 - const res = await fetch('/status', { 11 - method: 'POST', 12 - headers: { 'content-type': 'application/json' }, 13 - body: JSON.stringify({ status: el.dataset.value }), 14 - }) 15 - const body = await res.json() 16 - if (body?.error) { 17 - setError(body.error) 18 - } else { 19 - location.reload() 20 - } 21 - }) 22 - }) 23 - 24 - function setError(str) { 25 - const errMsg = document.querySelector('.error') 26 - if (str) { 27 - errMsg.classList.add('visible') 28 - errMsg.textContent = str 29 - } else { 30 - errMsg.classList.remove('visible') 31 - } 32 - }
···
+1
src/pages/public/styles.css
··· 139 font-size: 2rem; 140 width: 3rem; 141 height: 3rem; 142 background-color: #fff; 143 border: 1px solid var(--border-color); 144 border-radius: 3rem;
··· 139 font-size: 2rem; 140 width: 3rem; 141 height: 3rem; 142 + padding: 0; 143 background-color: #fff; 144 border: 1px solid var(--border-color); 145 border-radius: 3rem;
+81 -24
src/routes.ts
··· 1 import path from 'node:path' 2 import { OAuthResolverError } from '@atproto/oauth-client-node' 3 import { isValidHandle } from '@atproto/syntax' 4 import express from 'express' 5 - import { createSession, destroySession, getSessionAgent } from '#/auth/session' 6 import type { AppContext } from '#/index' 7 import { home } from '#/pages/home' 8 import { login } from '#/pages/login' 9 import { page } from '#/lib/view' 10 import * as Status from '#/lexicon/types/com/example/status' 11 12 // Helper function for defining routes 13 const handler = ··· 24 } 25 } 26 27 export const createRouter = (ctx: AppContext) => { 28 const router = express.Router() 29 ··· 45 const params = new URLSearchParams(req.originalUrl.split('?')[1]) 46 try { 47 const { agent } = await ctx.oauthClient.callback(params) 48 - await createSession(req, res, agent.accountDid) 49 } catch (err) { 50 ctx.logger.error({ err }, 'oauth callback failed') 51 return res.redirect('/?error') ··· 96 router.post( 97 '/logout', 98 handler(async (req, res) => { 99 - await destroySession(req, res) 100 return res.redirect('/') 101 }) 102 ) ··· 120 .selectFrom('status') 121 .selectAll() 122 .where('authorDid', '=', agent.accountDid) 123 .executeTakeFirst() 124 : undefined 125 ··· 134 } 135 136 // Fetch additional information about the logged-in user 137 - const { data: profile } = await agent.getProfile({ 138 - actor: agent.accountDid, 139 }) 140 - didHandleMap[profile.handle] = agent.accountDid 141 142 // Serve the logged-in view 143 - return res 144 - .type('html') 145 - .send(page(home({ statuses, didHandleMap, profile, myStatus }))) 146 }) 147 ) 148 ··· 153 // If the user is signed in, get an agent which communicates with their server 154 const agent = await getSessionAgent(req, res, ctx) 155 if (!agent) { 156 - return res.status(401).json({ error: 'Session required' }) 157 } 158 159 // Construct & validate their status record 160 const record = { 161 $type: 'com.example.status', 162 status: req.body?.status, 163 - updatedAt: new Date().toISOString(), 164 } 165 if (!Status.validateRecord(record).success) { 166 - return res.status(400).json({ error: 'Invalid status' }) 167 } 168 169 try { 170 // Write the status record to the user's repository 171 - await agent.com.atproto.repo.putRecord({ 172 repo: agent.accountDid, 173 collection: 'com.example.status', 174 - rkey: 'self', 175 record, 176 validate: false, 177 }) 178 } catch (err) { 179 ctx.logger.warn({ err }, 'failed to write record') 180 - return res.status(500).json({ error: 'Failed to write record' }) 181 } 182 183 try { ··· 188 await ctx.db 189 .insertInto('status') 190 .values({ 191 authorDid: agent.accountDid, 192 status: record.status, 193 - updatedAt: record.updatedAt, 194 indexedAt: new Date().toISOString(), 195 }) 196 - .onConflict((oc) => 197 - oc.column('authorDid').doUpdateSet({ 198 - status: record.status, 199 - updatedAt: record.updatedAt, 200 - indexedAt: new Date().toISOString(), 201 - }) 202 - ) 203 .execute() 204 } catch (err) { 205 ctx.logger.warn( ··· 208 ) 209 } 210 211 - res.status(200).json({}) 212 }) 213 ) 214
··· 1 + import assert from 'node:assert' 2 import path from 'node:path' 3 + import type { IncomingMessage, ServerResponse } from 'node:http' 4 import { OAuthResolverError } from '@atproto/oauth-client-node' 5 import { isValidHandle } from '@atproto/syntax' 6 + import { TID } from '@atproto/common' 7 import express from 'express' 8 + import { getIronSession } from 'iron-session' 9 import type { AppContext } from '#/index' 10 import { home } from '#/pages/home' 11 import { login } from '#/pages/login' 12 + import { env } from '#/lib/env' 13 import { page } from '#/lib/view' 14 import * as Status from '#/lexicon/types/com/example/status' 15 + import * as Profile from '#/lexicon/types/app/bsky/actor/profile' 16 + 17 + type Session = { did: string } 18 19 // Helper function for defining routes 20 const handler = ··· 31 } 32 } 33 34 + // Helper function to get the Atproto Agent for the active session 35 + async function getSessionAgent( 36 + req: IncomingMessage, 37 + res: ServerResponse<IncomingMessage>, 38 + ctx: AppContext 39 + ) { 40 + const session = await getIronSession<Session>(req, res, { 41 + cookieName: 'sid', 42 + password: env.COOKIE_SECRET, 43 + }) 44 + if (!session.did) return null 45 + try { 46 + return await ctx.oauthClient.restore(session.did) 47 + } catch (err) { 48 + ctx.logger.warn({ err }, 'oauth restore failed') 49 + await session.destroy() 50 + return null 51 + } 52 + } 53 + 54 export const createRouter = (ctx: AppContext) => { 55 const router = express.Router() 56 ··· 72 const params = new URLSearchParams(req.originalUrl.split('?')[1]) 73 try { 74 const { agent } = await ctx.oauthClient.callback(params) 75 + const session = await getIronSession<Session>(req, res, { 76 + cookieName: 'sid', 77 + password: env.COOKIE_SECRET, 78 + }) 79 + assert(!session.did, 'session already exists') 80 + session.did = agent.accountDid 81 + await session.save() 82 } catch (err) { 83 ctx.logger.error({ err }, 'oauth callback failed') 84 return res.redirect('/?error') ··· 129 router.post( 130 '/logout', 131 handler(async (req, res) => { 132 + const session = await getIronSession<Session>(req, res, { 133 + cookieName: 'sid', 134 + password: env.COOKIE_SECRET, 135 + }) 136 + await session.destroy() 137 return res.redirect('/') 138 }) 139 ) ··· 157 .selectFrom('status') 158 .selectAll() 159 .where('authorDid', '=', agent.accountDid) 160 + .orderBy('indexedAt', 'desc') 161 .executeTakeFirst() 162 : undefined 163 ··· 172 } 173 174 // Fetch additional information about the logged-in user 175 + const { data: profileRecord } = await agent.com.atproto.repo.getRecord({ 176 + repo: agent.accountDid, 177 + collection: 'app.bsky.actor.profile', 178 + rkey: 'self', 179 }) 180 + const profile = 181 + Profile.isRecord(profileRecord.value) && 182 + Profile.validateRecord(profileRecord.value).success 183 + ? profileRecord.value 184 + : {} 185 186 // Serve the logged-in view 187 + return res.type('html').send( 188 + page( 189 + home({ 190 + statuses, 191 + didHandleMap, 192 + profile, 193 + myStatus, 194 + }) 195 + ) 196 + ) 197 }) 198 ) 199 ··· 204 // If the user is signed in, get an agent which communicates with their server 205 const agent = await getSessionAgent(req, res, ctx) 206 if (!agent) { 207 + return res 208 + .status(401) 209 + .type('html') 210 + .send('<h1>Error: Session required</h1>') 211 } 212 213 // Construct & validate their status record 214 + const rkey = TID.nextStr() 215 const record = { 216 $type: 'com.example.status', 217 status: req.body?.status, 218 + createdAt: new Date().toISOString(), 219 } 220 if (!Status.validateRecord(record).success) { 221 + return res 222 + .status(400) 223 + .type('html') 224 + .send('<h1>Error: Invalid status</h1>') 225 } 226 227 + let uri 228 try { 229 // Write the status record to the user's repository 230 + const res = await agent.com.atproto.repo.putRecord({ 231 repo: agent.accountDid, 232 collection: 'com.example.status', 233 + rkey, 234 record, 235 validate: false, 236 }) 237 + uri = res.data.uri 238 } catch (err) { 239 ctx.logger.warn({ err }, 'failed to write record') 240 + return res 241 + .status(500) 242 + .type('html') 243 + .send('<h1>Error: Failed to write record</h1>') 244 } 245 246 try { ··· 251 await ctx.db 252 .insertInto('status') 253 .values({ 254 + uri, 255 authorDid: agent.accountDid, 256 status: record.status, 257 + createdAt: record.createdAt, 258 indexedAt: new Date().toISOString(), 259 }) 260 .execute() 261 } catch (err) { 262 ctx.logger.warn( ··· 265 ) 266 } 267 268 + return res.redirect('/') 269 }) 270 ) 271