slack status without the slack status.zzstoatzz.io/
quickslice

add quickslice migration notes

Changed files
+157
notes
+157
notes/quickslice-migration.md
··· 1 + # migrating to quickslice: a status app rewrite 2 + 3 + ## what we built 4 + 5 + a bluesky status app that lets users set emoji statuses (like slack status) stored in their AT protocol repository. the app has two parts: 6 + 7 + - **backend**: [quickslice](https://github.com/bigmoves/quickslice) on fly.io - handles OAuth, GraphQL API, and jetstream ingestion 8 + - **frontend**: vanilla JS SPA on cloudflare pages 9 + 10 + live at https://status.zzstoatzz.io 11 + 12 + ## why quickslice 13 + 14 + the original implementation was a custom rust backend using atrium-rs. it worked, but maintaining OAuth, jetstream ingestion, and all the AT protocol plumbing was a lot. quickslice handles all of that out of the box: 15 + 16 + - OAuth 2.0 with PKCE + DPoP (the hard part of AT protocol) 17 + - GraphQL API auto-generated from your lexicons 18 + - jetstream consumer for real-time firehose data 19 + - admin UI for managing OAuth clients 20 + 21 + ## the migration 22 + 23 + ### 1. lexicon design 24 + 25 + quickslice ingests data based on lexicons you define. we have two: 26 + 27 + **io.zzstoatzz.status.record** - the actual status 28 + ```json 29 + { 30 + "emoji": "๐Ÿ”ฅ", 31 + "text": "shipping code", 32 + "createdAt": "2025-12-13T12:00:00Z" 33 + } 34 + ``` 35 + 36 + **io.zzstoatzz.status.preferences** - user display preferences 37 + ```json 38 + { 39 + "accentColor": "#4a9eff", 40 + "theme": "dark" 41 + } 42 + ``` 43 + 44 + ### 2. frontend architecture 45 + 46 + since quickslice serves its own admin UI at the root path, we couldn't bundle our frontend into the same container. this led to a clean separation: 47 + 48 + - quickslice backend on fly.io (`zzstoatzz-quickslice-status.fly.dev`) 49 + - static frontend on cloudflare pages (`status.zzstoatzz.io`) 50 + 51 + the frontend uses the `quickslice-client-js` library for OAuth: 52 + ```html 53 + <script src="https://cdn.jsdelivr.net/gh/bigmoves/quickslice@v0.17.3/quickslice-client-js/dist/quickslice-client.min.js"></script> 54 + ``` 55 + 56 + ### 3. OAuth flow 57 + 58 + quickslice handles the OAuth server side. the frontend just needs to: 59 + 60 + 1. create a client with `QuicksliceClient.create()` 61 + 2. call `client.signIn()` to start the flow 62 + 3. handle the callback (quickslice redirects back with auth tokens) 63 + 4. use `client.agent` for authenticated AT protocol operations 64 + 65 + the redirect URI is just the root of your site (e.g., `https://status.zzstoatzz.io/`). 66 + 67 + ## problems we hit 68 + 69 + ### the `sub` claim fix 70 + 71 + the biggest issue: after OAuth login, the app would redirect loop infinitely. the AT protocol SDK needs a `sub` claim in the OAuth token response to identify the user, but quickslice v0.17.2 didn't include it. 72 + 73 + the fix was in v0.17.3 (commit `0b2d54a`), but `ghcr.io/bigmoves/quickslice:latest` still pointed to v0.17.2. we had to build from source: 74 + 75 + ```dockerfile 76 + # Clone quickslice at the v0.17.3 tag (includes sub claim fix) 77 + RUN git clone --depth 1 --branch v0.17.3 https://github.com/bigmoves/quickslice.git /build 78 + ``` 79 + 80 + ### secrets configuration 81 + 82 + quickslice needs two secrets for OAuth to work: 83 + 84 + ```bash 85 + fly secrets set SECRET_KEY_BASE="$(openssl rand -base64 64 | tr -d '\n')" 86 + fly secrets set OAUTH_SIGNING_KEY="$(goat key generate -t p256 | tail -1)" 87 + ``` 88 + 89 + the `OAUTH_SIGNING_KEY` must be just the multibase key (starts with `z`), not the full output from goat. 90 + 91 + ### EXTERNAL_BASE_URL 92 + 93 + without this, quickslice uses `0.0.0.0:8080` in its OAuth client metadata, which breaks the flow. set it to your public URL: 94 + 95 + ```toml 96 + [env] 97 + EXTERNAL_BASE_URL = 'https://zzstoatzz-quickslice-status.fly.dev' 98 + ``` 99 + 100 + ### PDS caching 101 + 102 + when debugging OAuth issues, be aware that your PDS caches OAuth client metadata. if you fix something on the server, the PDS might still have the old metadata cached. this caused some confusion during debugging. 103 + 104 + ## deployment architecture 105 + 106 + ``` 107 + โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” 108 + โ”‚ cloudflare pages โ”‚ 109 + โ”‚ status.zzstoatzz.io โ”‚ 110 + โ”‚ โ”‚ 111 + โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ 112 + โ”‚ โ”‚ index.html โ”‚ โ”‚ app.js โ”‚ โ”‚ styles.css โ”‚ โ”‚ 113 + โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ 114 + โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ 115 + โ”‚ 116 + โ”‚ GraphQL + OAuth 117 + โ–ผ 118 + โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” 119 + โ”‚ fly.io โ”‚ 120 + โ”‚ zzstoatzz-quickslice-status.fly.dev โ”‚ 121 + โ”‚ โ”‚ 122 + โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ 123 + โ”‚ โ”‚ quickslice โ”‚ โ”‚ 124 + โ”‚ โ”‚ โ€ข OAuth server (PKCE + DPoP) โ”‚ โ”‚ 125 + โ”‚ โ”‚ โ€ข GraphQL API (auto-generated from lexicons) โ”‚ โ”‚ 126 + โ”‚ โ”‚ โ€ข Jetstream consumer โ”‚ โ”‚ 127 + โ”‚ โ”‚ โ€ข SQLite database โ”‚ โ”‚ 128 + โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ 129 + โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ 130 + โ”‚ 131 + โ”‚ Jetstream 132 + โ–ผ 133 + โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” 134 + โ”‚ AT Protocol โ”‚ 135 + โ”‚ (bluesky PDS, jetstream firehose) โ”‚ 136 + โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ 137 + ``` 138 + 139 + ## key takeaways 140 + 141 + 1. **quickslice eliminates the hard parts** - OAuth and jetstream are notoriously tricky. quickslice handles them so you can focus on your app logic. 142 + 143 + 2. **separate frontend and backend** - quickslice serves its own admin UI, so host your frontend elsewhere. cloudflare pages is free and fast. 144 + 145 + 3. **pin your dependencies** - we got bit by `:latest` not being latest. pin to specific versions/tags. 146 + 147 + 4. **check the image version** - `fly image show` tells you exactly what's deployed. don't assume. 148 + 149 + 5. **GraphQL is your API** - quickslice auto-generates a GraphQL API from your lexicons. no need to write endpoints. 150 + 151 + 6. **the sub claim matters** - AT protocol OAuth needs the `sub` claim in token responses. this was the root cause of our redirect loop. 152 + 153 + ## resources 154 + 155 + - [quickslice](https://github.com/bigmoves/quickslice) - the framework 156 + - [AT protocol OAuth](https://atproto.com/specs/oauth) - the spec 157 + - [quickslice-client-js](https://github.com/bigmoves/quickslice/tree/main/quickslice-client-js) - frontend OAuth helper