+157
notes/quickslice-migration.md
+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