slack status without the slack
status.zzstoatzz.io/
quickslice
1# migrating to quickslice: a status app rewrite
2
3## what we built
4
5a 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
10live at https://status.zzstoatzz.io
11
12## why quickslice
13
14the 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
25quickslice 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
46since 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
51the 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. the UI
57
58since quickslice serves its own admin UI at the root path, we host our frontend separately on cloudflare pages. the frontend is vanilla JS - no framework, just a single `app.js` file.
59
60**OAuth with quickslice-client-js**
61
62the `quickslice-client-js` library handles the OAuth flow in the browser:
63
64```javascript
65const client = await QuicksliceClient.create({
66 server: 'https://your-app.fly.dev', // your quickslice instance
67 clientId: 'client_xxx', // from quickslice admin UI
68 redirectUri: window.location.origin + '/', // where OAuth redirects back
69});
70
71// start login
72await client.signIn(handle);
73
74// after redirect, client.agent is authenticated
75const { data } = await client.agent.getProfile({ actor: client.agent.session.did });
76```
77
78the `clientId` comes from registering an OAuth client in the quickslice admin UI. the redirect URI should match what you registered.
79
80**GraphQL queries**
81
82quickslice auto-generates a GraphQL API from your lexicons. querying status records looks like:
83
84```javascript
85const response = await fetch(`https://your-app.fly.dev/api/graphql`, {
86 method: 'POST',
87 headers: { 'Content-Type': 'application/json' },
88 body: JSON.stringify({
89 query: `
90 query GetStatuses($did: String!) {
91 ioZzstoatzzStatusRecords(
92 where: { did: { eq: $did } }
93 orderBy: { createdAt: DESC }
94 first: 50
95 ) {
96 nodes { uri did emoji text createdAt }
97 }
98 }
99 `,
100 variables: { did }
101 })
102});
103```
104
105the query name is auto-generated from your lexicon ID - dots become camelCase (e.g., `io.zzstoatzz.status.record` → `ioZzstoatzzStatusRecords`).
106
107no need to write resolvers or schema - it's all generated from the lexicon definitions.
108
109## problems we hit
110
111### the `sub` claim fix
112
113the 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.
114
115the 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:
116
117```dockerfile
118# Clone quickslice at the v0.17.3 tag (includes sub claim fix)
119RUN git clone --depth 1 --branch v0.17.3 https://github.com/bigmoves/quickslice.git /build
120```
121
122### secrets configuration
123
124quickslice needs two secrets for OAuth to work:
125
126```bash
127fly secrets set SECRET_KEY_BASE="$(openssl rand -base64 64 | tr -d '\n')"
128fly secrets set OAUTH_SIGNING_KEY="$(goat key generate -t p256 | tail -1)"
129```
130
131the `OAUTH_SIGNING_KEY` must be just the multibase key (starts with `z`), not the full output from goat.
132
133### EXTERNAL_BASE_URL
134
135without this, quickslice uses `0.0.0.0:8080` in its OAuth client metadata, which breaks the flow. set it to your public URL:
136
137```toml
138[env]
139 EXTERNAL_BASE_URL = 'https://your-app.fly.dev'
140```
141
142### PDS caching
143
144when 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.
145
146## deployment architecture
147
148```
149┌─────────────────────────────────────────────────────────┐
150│ cloudflare pages │
151│ your-frontend.com │
152│ │
153│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
154│ │ index.html │ │ app.js │ │ styles.css │ │
155│ └─────────────┘ └─────────────┘ └─────────────┘ │
156└─────────────────────────────────────────────────────────┘
157 │
158 │ GraphQL + OAuth
159 ▼
160┌─────────────────────────────────────────────────────────┐
161│ fly.io │
162│ your-app.fly.dev │
163│ │
164│ ┌─────────────────────────────────────────────────┐ │
165│ │ quickslice │ │
166│ │ • OAuth server (PKCE + DPoP) │ │
167│ │ • GraphQL API (auto-generated from lexicons) │ │
168│ │ • Jetstream consumer │ │
169│ │ • SQLite database │ │
170│ └─────────────────────────────────────────────────┘ │
171└─────────────────────────────────────────────────────────┘
172 │
173 │ Jetstream
174 ▼
175┌─────────────────────────────────────────────────────────┐
176│ AT Protocol │
177│ (bluesky PDS, jetstream firehose) │
178└─────────────────────────────────────────────────────────┘
179```
180
181## what quickslice eliminated
182
183the rust backend was ~2000 lines of code handling:
184
185- OAuth server implementation (PKCE + DPoP)
186- jetstream consumer for firehose ingestion
187- custom API endpoints for reading/writing statuses
188- session management
189- database queries
190
191with quickslice, all of that is replaced by:
192
193- a Dockerfile that builds quickslice from source
194- a fly.toml with env vars
195- two secrets
196
197the frontend is still custom (~1200 lines), but the backend complexity is gone.
198
199## deployment checklist
200
201when deploying quickslice:
202
203```bash
204# 1. set required secrets
205fly secrets set SECRET_KEY_BASE="$(openssl rand -base64 64 | tr -d '\n')"
206fly secrets set OAUTH_SIGNING_KEY="$(goat key generate -t p256 | tail -1)"
207
208# 2. deploy (builds from source, takes ~3 min)
209fly deploy
210
211# 3. in quickslice admin UI:
212# - set domain authority (e.g., io.zzstoatzz)
213# - add supported lexicons
214# - register OAuth client with redirect URI
215```
216
217## key takeaways
218
2191. **quickslice eliminates the hard parts** - OAuth and jetstream are notoriously tricky. quickslice handles them so you can focus on your app logic.
220
2212. **separate frontend and backend** - quickslice serves its own admin UI, so host your frontend elsewhere. cloudflare pages is free and fast.
222
2233. **pin your dependencies** - we got bit by `:latest` not being latest. pin to specific versions/tags.
224
2254. **check the image version** - `fly image show` tells you exactly what's deployed. don't assume.
226
2275. **GraphQL is your API** - quickslice auto-generates a GraphQL API from your lexicons. no need to write endpoints.
228
2296. **the sub claim matters** - AT protocol OAuth needs the `sub` claim in token responses. this was the root cause of our redirect loop.
230
231## resources
232
233- [quickslice](https://github.com/bigmoves/quickslice) - the framework
234- [AT protocol OAuth](https://atproto.com/specs/oauth) - the spec
235- [quickslice-client-js](https://github.com/bigmoves/quickslice/tree/main/quickslice-client-js) - frontend OAuth helper