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
at main 197 lines 6.7 kB view raw view rendered
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`