ATB-55: Theme Read API Endpoints — Design#
Date: 2026-03-02 Status: Approved Linear: ATB-55 Depends on: ATB-51 (theme + themePolicy lexicons — already shipped)
Summary#
Add database tables, firehose indexers, and read-only REST endpoints for theme and theme policy data. The web UI (and future mobile clients) will consume these to resolve which CSS tokens to render per request.
Database Schema#
Three new tables added to both packages/db/src/schema.ts (Postgres) and
packages/db/src/schema.sqlite.ts (SQLite). All follow the existing
(did, rkey, cid, indexed_at) pattern.
themes#
| Column | Postgres | SQLite | Notes |
|---|---|---|---|
id |
bigserial PK |
integer PK autoIncrement |
|
did |
text NOT NULL |
text NOT NULL |
Forum DID |
rkey |
text NOT NULL |
text NOT NULL |
TID key |
cid |
text NOT NULL |
text NOT NULL |
|
name |
text NOT NULL |
text NOT NULL |
|
colorScheme |
text NOT NULL |
text NOT NULL |
"light" or "dark" |
tokens |
jsonb NOT NULL |
text NOT NULL |
SQLite: JSON string |
cssOverrides |
text |
text |
Optional raw CSS |
fontUrls |
text[] |
text |
SQLite: JSON string array |
createdAt |
timestamp |
integer (timestamp) |
|
indexedAt |
timestamp |
integer (timestamp) |
Indexes: UNIQUE(did, rkey)
theme_policies#
Singleton per forum (rkey is always "self").
| Column | Postgres | SQLite | Notes |
|---|---|---|---|
id |
bigserial PK |
integer PK autoIncrement |
|
did |
text NOT NULL |
text NOT NULL |
Forum DID |
rkey |
text NOT NULL |
text NOT NULL |
Always "self" |
cid |
text NOT NULL |
text NOT NULL |
|
defaultLightThemeUri |
text NOT NULL |
text NOT NULL |
AT-URI |
defaultDarkThemeUri |
text NOT NULL |
text NOT NULL |
AT-URI |
allowUserChoice |
boolean NOT NULL |
integer (boolean) |
|
indexedAt |
timestamp |
integer (timestamp) |
Indexes: UNIQUE(did, rkey)
theme_policy_available_themes#
Normalized join table for the availableThemes array from themePolicy records.
Enables SQL-level filtering in GET /api/themes without application-layer iteration.
| Column | Postgres | SQLite | Notes |
|---|---|---|---|
policyId |
bigint FK → theme_policies.id ON DELETE CASCADE |
integer FK |
|
themeUri |
text NOT NULL |
text NOT NULL |
AT-URI of the theme |
themeCid |
text NOT NULL |
text NOT NULL |
CID for integrity |
Primary key: (policyId, themeUri)
Firehose Indexer#
Two new CollectionConfig entries in apps/appview/src/lib/indexer.ts, following
the pattern established by categoryConfig, roleConfig, etc.
space.atbb.forum.theme#
toInsertValues: mapsname,colorScheme,tokens(JSON.stringify for SQLite, raw object for Postgres),cssOverrides,fontUrls(array for Postgres, JSON.stringify for SQLite),createdAt,indexedAttoUpdateValues: same fields minusdid/rkey/createdAt- No
afterUpsertneeded
space.atbb.forum.themePolicy#
toInsertValues: mapsdefaultLightTheme.theme.uri,defaultDarkTheme.theme.uri,allowUserChoice,indexedAtafterUpsert: atomically replacestheme_policy_available_themesrows for this policy — DELETE existing rows bypolicyId, then INSERT one row per entry inrecord.availableThemes. Same pattern asroleConfig.afterUpsertfor permissions.
Both collections registered in firehose.ts createHandlerRegistry().
API Endpoints#
New file: apps/appview/src/routes/themes.ts
Factory function: createThemesRoutes(ctx: AppContext)
Registered in routes/index.ts as .route("/themes", createThemesRoutes(ctx))
GET /api/themes#
Returns themes filtered to those in themePolicy.availableThemes via SQL join.
Returns { themes: [] } when no policy exists (no 404).
Query:
SELECT t.* FROM themes t
INNER JOIN theme_policy_available_themes tpa
ON tpa.theme_uri = ('at://' || t.did || '/space.atbb.forum.theme/' || t.rkey)
INNER JOIN theme_policies tp ON tp.id = tpa.policy_id
Response: { themes: [{ id, uri, name, colorScheme, indexedAt }] }
(Token summary only — full tokens are in the single-theme endpoint.)
Error codes: 500 with structured logging for DB errors.
GET /api/themes/:rkey#
Returns full theme data for a single theme identified by its rkey.
The Forum DID comes from ctx.config.forumDid.
Validation: 400 for empty/missing rkey (rkeys are TIDs, not BigInts — use string
validation, not parseBigIntParam).
Response: { id, uri, name, colorScheme, tokens, cssOverrides, fontUrls, createdAt, indexedAt }
Error codes: 400 (invalid rkey), 404 (theme not found), 500 (DB error).
GET /api/theme-policy#
Returns the forum's singleton themePolicy record with its availableThemes list.
Returns 404 when no policy exists.
Queries theme_policies then assembles availableThemes from
theme_policy_available_themes join.
Response:
{
"defaultLightThemeUri": "at://...",
"defaultDarkThemeUri": "at://...",
"allowUserChoice": true,
"availableThemes": [{ "uri": "at://...", "cid": "..." }]
}
Error codes: 404 (no policy), 500 (DB error).
Tests#
File: apps/appview/src/routes/__tests__/themes.test.ts
Happy path#
GET /api/themesreturns only themes listed inavailableThemes(not all themes in DB)GET /api/themes/:rkeyreturns full token set for a known themeGET /api/theme-policyreturns correctallowUserChoiceandavailableThemes
Error / edge cases#
GET /api/themesreturns{ themes: [] }when no policy existsGET /api/themes/:rkeyreturns 404 for unknown rkeyGET /api/themes/:rkeyreturns 400 for empty rkeyGET /api/theme-policyreturns 404 when no policy existsGET /api/themesdoes not include a theme that exists in DB but is absent fromavailableThemes
Bruno Collection#
New folder: bruno/AppView API/Themes/
| File | Method | URL |
|---|---|---|
List Available Themes.bru |
GET | {{appview_url}}/api/themes |
Get Theme.bru |
GET | {{appview_url}}/api/themes/{{theme_rkey}} |
Get Theme Policy.bru |
GET | {{appview_url}}/api/theme-policy |
Each file documents all HTTP status codes the endpoint can return and uses environment variables for all URLs and test data.
Out of Scope (ATB-55)#
- Write endpoints (
POST /api/themes,PUT /api/theme-policy, etc.) — separate ticket - User theme preference (
PATCH /api/membership/theme) — separate ticket - CSS sanitization for
cssOverrides— required before admin write endpoints ship - Web UI theme resolution and injection into
BaseLayout