Theme Write API Endpoints — Design#
Linear: ATB-57 Date: 2026-03-02 Status: Approved, ready for implementation
Context#
The AppView needs write endpoints so admins can create, update, and delete themes, and manage the theme policy. These follow the PDS-first pattern established by category and board management.
Depends on: ATB-51 (theme lexicons), ATB-55 (theme read endpoints + DB tables)
Route Placement#
All four endpoints are added to apps/appview/src/routes/admin.ts, alongside existing category/board write endpoints. The admin router is already mounted at /admin in index.ts — no routing changes needed.
Endpoints#
| Method | Path | Permission |
|---|---|---|
POST |
/api/admin/themes |
space.atbb.permission.manageThemes |
PUT |
/api/admin/themes/:rkey |
space.atbb.permission.manageThemes |
DELETE |
/api/admin/themes/:rkey |
space.atbb.permission.manageThemes |
PUT |
/api/admin/theme-policy |
space.atbb.permission.manageThemes |
Permission Changes#
Add space.atbb.permission.manageThemes to apps/appview/src/lib/seed-roles.ts:
- Owner: already has
"*"wildcard — no change - Admin: add
manageThemesto the permissions array - Moderator / Member: no change
Input Validation#
Theme (POST and PUT)#
| Field | Rule |
|---|---|
name |
Required string, non-empty, ≤ 100 graphemes |
colorScheme |
Required, must be "light" or "dark" |
tokens |
Required, must be a non-null object; values must be strings |
cssOverrides |
Optional string (do NOT render until ATB-62 CSS sanitization ships) |
fontUrls |
Optional array of strings; each must start with "https://" |
Token keys are not validated against a known list (lenient mode — allows custom/future tokens).
Theme Policy (PUT)#
| Field | Rule |
|---|---|
availableThemes |
Required non-empty array of { uri: string, cid: string } |
defaultLightThemeUri |
Required string; must be an AT-URI present in availableThemes |
defaultDarkThemeUri |
Required string; must be an AT-URI present in availableThemes |
allowUserChoice |
Optional boolean, defaults true |
Endpoint Details#
POST /api/admin/themes#
- Parse and validate request body
- Get ForumAgent (return 503 if unavailable)
- Generate
rkey = TID.nextStr() putRecordon Forum DID's PDS withcollection: "space.atbb.forum.theme"- Return
{ uri, cid }with201
Does not wait for firehose indexing — the PDS write is the authoritative action.
PUT /api/admin/themes/:rkey#
- Parse and validate request body
- Look up existing theme by
rkey+forumDidin DB (404 if missing) - Get ForumAgent
putRecordwith same rkey, preservingcreatedAtfrom DB row- Optional fields (
cssOverrides,fontUrls,description) fall back to existing DB values if not provided in request - Return
{ uri, cid }with200
DELETE /api/admin/themes/:rkey#
- Look up theme in DB (404 if missing)
- Pre-flight conflict check: query
theme_policiesfor rows wheredefault_light_theme_uriORdefault_dark_theme_uri= this theme's AT-URI - Return
409if any match - Get ForumAgent
deleteRecordon Forum DID's PDS- Return
{ success: true }with200
PUT /api/admin/theme-policy#
Upsert semantics (creates if no policy row exists yet, updates if one does).
- Parse and validate request body
- Validate
defaultLightThemeUriis present inavailableThemes(400 if not) - Validate
defaultDarkThemeUriis present inavailableThemes(400 if not) - Get ForumAgent
putRecordwithrkey: "self",collection: "space.atbb.forum.themePolicy"- PDS record structure follows the
themeRefwrapper pattern from the lexicon:{ theme: { uri, cid } } - Return
{ uri, cid }with200
Error Codes#
| Status | Condition |
|---|---|
| 400 | Invalid/missing input field, invalid colorScheme, non-HTTPS fontUrl, default theme not in availableThemes |
| 401 | Not authenticated |
| 403 | Caller lacks manageThemes permission |
| 404 | Theme rkey not found (PUT/DELETE) |
| 409 | DELETE attempted on a theme that is the current policy default |
| 503 | DB or PDS connectivity error |
Tests#
POST /api/admin/themes#
- Happy path: returns 201 with uri and cid
- Missing
name→ 400 - Empty
name→ 400 nametoo long (> 100 graphemes) → 400- Invalid
colorScheme(not light/dark) → 400 - Missing
colorScheme→ 400 tokensnot an object → 400- Missing
tokens→ 400 - Non-HTTPS fontUrl → 400
- Permission denied (no manageThemes) → 403
- Unauthenticated → 401
- PDS/DB error → 503
PUT /api/admin/themes/:rkey#
- Happy path: updates theme, returns 200
- Partial update (no cssOverrides in body) preserves existing cssOverrides
- Unknown rkey → 404
- Same input validation failures as POST → 400
- Permission denied → 403
DELETE /api/admin/themes/:rkey#
- Happy path: deletes theme, returns 200
- Unknown rkey → 404
- Theme is defaultLightTheme in policy → 409
- Theme is defaultDarkTheme in policy → 409
- Permission denied → 403
PUT /api/admin/theme-policy#
- Happy path create (no existing policy): returns 200
- Happy path update (policy already exists): returns 200
defaultLightThemeUrinot inavailableThemes→ 400defaultDarkThemeUrinot inavailableThemes→ 400- Missing
availableThemes→ 400 - Empty
availableThemesarray → 400 - Missing
defaultLightThemeUri→ 400 - Missing
defaultDarkThemeUri→ 400 - Permission denied → 403
Bruno Collection#
New files in bruno/AppView API/Admin Themes/:
Create Theme.bruUpdate Theme.bruDelete Theme.bruUpdate Theme Policy.bru
All use {{appview_url}} for the base URL and include error code documentation.