WIP! A BB-style forum, on the ATmosphere!
We're still working... we'll be back soon when we have something to show off!
node
typescript
hono
htmx
atproto
1# ATB-55: Theme Read API Endpoints — Design
2
3**Date:** 2026-03-02
4**Status:** Approved
5**Linear:** ATB-55
6**Depends on:** ATB-51 (theme + themePolicy lexicons — already shipped)
7
8---
9
10## Summary
11
12Add database tables, firehose indexers, and read-only REST endpoints for theme and
13theme policy data. The web UI (and future mobile clients) will consume these to
14resolve which CSS tokens to render per request.
15
16---
17
18## Database Schema
19
20Three new tables added to both `packages/db/src/schema.ts` (Postgres) and
21`packages/db/src/schema.sqlite.ts` (SQLite). All follow the existing
22`(did, rkey, cid, indexed_at)` pattern.
23
24### `themes`
25
26| Column | Postgres | SQLite | Notes |
27|---|---|---|---|
28| `id` | `bigserial PK` | `integer PK autoIncrement` | |
29| `did` | `text NOT NULL` | `text NOT NULL` | Forum DID |
30| `rkey` | `text NOT NULL` | `text NOT NULL` | TID key |
31| `cid` | `text NOT NULL` | `text NOT NULL` | |
32| `name` | `text NOT NULL` | `text NOT NULL` | |
33| `colorScheme` | `text NOT NULL` | `text NOT NULL` | `"light"` or `"dark"` |
34| `tokens` | `jsonb NOT NULL` | `text NOT NULL` | SQLite: JSON string |
35| `cssOverrides` | `text` | `text` | Optional raw CSS |
36| `fontUrls` | `text[] ` | `text` | SQLite: JSON string array |
37| `createdAt` | `timestamp` | `integer (timestamp)` | |
38| `indexedAt` | `timestamp` | `integer (timestamp)` | |
39
40Indexes: `UNIQUE(did, rkey)`
41
42### `theme_policies`
43
44Singleton per forum (rkey is always `"self"`).
45
46| Column | Postgres | SQLite | Notes |
47|---|---|---|---|
48| `id` | `bigserial PK` | `integer PK autoIncrement` | |
49| `did` | `text NOT NULL` | `text NOT NULL` | Forum DID |
50| `rkey` | `text NOT NULL` | `text NOT NULL` | Always `"self"` |
51| `cid` | `text NOT NULL` | `text NOT NULL` | |
52| `defaultLightThemeUri` | `text NOT NULL` | `text NOT NULL` | AT-URI |
53| `defaultDarkThemeUri` | `text NOT NULL` | `text NOT NULL` | AT-URI |
54| `allowUserChoice` | `boolean NOT NULL` | `integer (boolean)` | |
55| `indexedAt` | `timestamp` | `integer (timestamp)` | |
56
57Indexes: `UNIQUE(did, rkey)`
58
59### `theme_policy_available_themes`
60
61Normalized join table for the `availableThemes` array from themePolicy records.
62Enables SQL-level filtering in `GET /api/themes` without application-layer iteration.
63
64| Column | Postgres | SQLite | Notes |
65|---|---|---|---|
66| `policyId` | `bigint FK → theme_policies.id ON DELETE CASCADE` | `integer FK` | |
67| `themeUri` | `text NOT NULL` | `text NOT NULL` | AT-URI of the theme |
68| `themeCid` | `text NOT NULL` | `text NOT NULL` | CID for integrity |
69
70Primary key: `(policyId, themeUri)`
71
72---
73
74## Firehose Indexer
75
76Two new `CollectionConfig` entries in `apps/appview/src/lib/indexer.ts`, following
77the pattern established by `categoryConfig`, `roleConfig`, etc.
78
79### `space.atbb.forum.theme`
80
81- `toInsertValues`: maps `name`, `colorScheme`, `tokens` (JSON.stringify for SQLite,
82 raw object for Postgres), `cssOverrides`, `fontUrls` (array for Postgres,
83 JSON.stringify for SQLite), `createdAt`, `indexedAt`
84- `toUpdateValues`: same fields minus `did` / `rkey` / `createdAt`
85- No `afterUpsert` needed
86
87### `space.atbb.forum.themePolicy`
88
89- `toInsertValues`: maps `defaultLightTheme.theme.uri`, `defaultDarkTheme.theme.uri`,
90 `allowUserChoice`, `indexedAt`
91- `afterUpsert`: atomically replaces `theme_policy_available_themes` rows for this
92 policy — DELETE existing rows by `policyId`, then INSERT one row per entry in
93 `record.availableThemes`. Same pattern as `roleConfig.afterUpsert` for permissions.
94
95Both collections registered in `firehose.ts` `createHandlerRegistry()`.
96
97---
98
99## API Endpoints
100
101New file: `apps/appview/src/routes/themes.ts`
102Factory function: `createThemesRoutes(ctx: AppContext)`
103Registered in `routes/index.ts` as `.route("/themes", createThemesRoutes(ctx))`
104
105### `GET /api/themes`
106
107Returns themes filtered to those in `themePolicy.availableThemes` via SQL join.
108Returns `{ themes: [] }` when no policy exists (no 404).
109
110Query:
111```sql
112SELECT t.* FROM themes t
113INNER JOIN theme_policy_available_themes tpa
114 ON tpa.theme_uri = ('at://' || t.did || '/space.atbb.forum.theme/' || t.rkey)
115INNER JOIN theme_policies tp ON tp.id = tpa.policy_id
116```
117
118Response: `{ themes: [{ id, uri, name, colorScheme, indexedAt }] }`
119(Token summary only — full tokens are in the single-theme endpoint.)
120
121Error codes: 500 with structured logging for DB errors.
122
123### `GET /api/themes/:rkey`
124
125Returns full theme data for a single theme identified by its rkey.
126The Forum DID comes from `ctx.config.forumDid`.
127
128Validation: 400 for empty/missing rkey (rkeys are TIDs, not BigInts — use string
129validation, not `parseBigIntParam`).
130
131Response: `{ id, uri, name, colorScheme, tokens, cssOverrides, fontUrls, createdAt, indexedAt }`
132
133Error codes: 400 (invalid rkey), 404 (theme not found), 500 (DB error).
134
135### `GET /api/theme-policy`
136
137Returns the forum's singleton themePolicy record with its `availableThemes` list.
138Returns 404 when no policy exists.
139
140Queries `theme_policies` then assembles `availableThemes` from
141`theme_policy_available_themes` join.
142
143Response:
144```json
145{
146 "defaultLightThemeUri": "at://...",
147 "defaultDarkThemeUri": "at://...",
148 "allowUserChoice": true,
149 "availableThemes": [{ "uri": "at://...", "cid": "..." }]
150}
151```
152
153Error codes: 404 (no policy), 500 (DB error).
154
155---
156
157## Tests
158
159File: `apps/appview/src/routes/__tests__/themes.test.ts`
160
161### Happy path
162
163- `GET /api/themes` returns only themes listed in `availableThemes` (not all themes in DB)
164- `GET /api/themes/:rkey` returns full token set for a known theme
165- `GET /api/theme-policy` returns correct `allowUserChoice` and `availableThemes`
166
167### Error / edge cases
168
169- `GET /api/themes` returns `{ themes: [] }` when no policy exists
170- `GET /api/themes/:rkey` returns 404 for unknown rkey
171- `GET /api/themes/:rkey` returns 400 for empty rkey
172- `GET /api/theme-policy` returns 404 when no policy exists
173- `GET /api/themes` does **not** include a theme that exists in DB but is absent from `availableThemes`
174
175---
176
177## Bruno Collection
178
179New folder: `bruno/AppView API/Themes/`
180
181| File | Method | URL |
182|---|---|---|
183| `List Available Themes.bru` | GET | `{{appview_url}}/api/themes` |
184| `Get Theme.bru` | GET | `{{appview_url}}/api/themes/{{theme_rkey}}` |
185| `Get Theme Policy.bru` | GET | `{{appview_url}}/api/theme-policy` |
186
187Each file documents all HTTP status codes the endpoint can return and uses
188environment variables for all URLs and test data.
189
190---
191
192## Out of Scope (ATB-55)
193
194- Write endpoints (`POST /api/themes`, `PUT /api/theme-policy`, etc.) — separate ticket
195- User theme preference (`PATCH /api/membership/theme`) — separate ticket
196- CSS sanitization for `cssOverrides` — required before admin write endpoints ship
197- Web UI theme resolution and injection into `BaseLayout`