# 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`: maps `name`, `colorScheme`, `tokens` (JSON.stringify for SQLite, raw object for Postgres), `cssOverrides`, `fontUrls` (array for Postgres, JSON.stringify for SQLite), `createdAt`, `indexedAt` - `toUpdateValues`: same fields minus `did` / `rkey` / `createdAt` - No `afterUpsert` needed ### `space.atbb.forum.themePolicy` - `toInsertValues`: maps `defaultLightTheme.theme.uri`, `defaultDarkTheme.theme.uri`, `allowUserChoice`, `indexedAt` - `afterUpsert`: atomically replaces `theme_policy_available_themes` rows for this policy — DELETE existing rows by `policyId`, then INSERT one row per entry in `record.availableThemes`. Same pattern as `roleConfig.afterUpsert` for 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: ```sql 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: ```json { "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/themes` returns only themes listed in `availableThemes` (not all themes in DB) - `GET /api/themes/:rkey` returns full token set for a known theme - `GET /api/theme-policy` returns correct `allowUserChoice` and `availableThemes` ### Error / edge cases - `GET /api/themes` returns `{ themes: [] }` when no policy exists - `GET /api/themes/:rkey` returns 404 for unknown rkey - `GET /api/themes/:rkey` returns 400 for empty rkey - `GET /api/theme-policy` returns 404 when no policy exists - `GET /api/themes` does **not** include a theme that exists in DB but is absent from `availableThemes` --- ## 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`