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

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:

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/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