tangled
alpha
login
or
join now
malpercio.dev
/
atbb
5
fork
atom
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
5
fork
atom
overview
issues
pulls
pipelines
finished plans
malpercio.dev
1 week ago
594cd4a5
8ef04dae
-1595
2 changed files
expand all
collapse all
unified
split
docs
plans
2026-03-02-atb-57-theme-write-api.md
2026-03-02-theme-write-api-design.md
-1417
docs/plans/2026-03-02-atb-57-theme-write-api.md
···
1
1
-
# ATB-57: Theme Write API Endpoints — Implementation Plan
2
2
-
3
3
-
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
4
4
-
5
5
-
**Goal:** Add admin write endpoints for creating, updating, and deleting themes, and managing the theme policy singleton.
6
6
-
7
7
-
**Architecture:** All four endpoints live in `apps/appview/src/routes/admin.ts` (same file as category/board write endpoints), gated by a new `space.atbb.permission.manageThemes` permission. They follow the established PDS-first pattern: validate → get ForumAgent → `putRecord`/`deleteRecord` on Forum DID's PDS → return `{ uri, cid }`. The firehose indexer handles DB rows asynchronously.
8
8
-
9
9
-
**Tech Stack:** Hono, Drizzle ORM (postgres.js), AT Protocol (`com.atproto.repo.putRecord`), `@atproto/common-web` TID generator, Vitest
10
10
-
11
11
-
---
12
12
-
13
13
-
## Context & Patterns to Know
14
14
-
15
15
-
**The PDS-first write pattern** (established by categories/boards in `admin.ts`):
16
16
-
1. Parse + validate request body (`safeParseJsonBody`)
17
17
-
2. For PUT/DELETE: look up existing DB row first — 404 if missing
18
18
-
3. `getForumAgentOrError` — returns 503 if ForumAgent not configured
19
19
-
4. Call `agent.com.atproto.repo.putRecord` / `deleteRecord`
20
20
-
5. Return `{ uri, cid }` from result
21
21
-
22
22
-
**Test scaffolding** (from `admin.test.ts`): Auth and permissions middleware are **mocked at module level** — `requireAuth` always passes the `mockUser` object, `requirePermission` always calls `next()`. Tests set `ctx.forumAgent` to a mock with `mockPutRecord` / `mockDeleteRecord`. Tests use `describe.sequential` because they share a single `TestContext`.
23
23
-
24
24
-
**Running tests:**
25
25
-
```bash
26
26
-
PATH=/path/to/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm --filter @atbb/appview test
27
27
-
# or for a single file:
28
28
-
PATH=/path/to/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/admin.test.ts
29
29
-
```
30
30
-
31
31
-
Replace `/path/to/` with the absolute path to the repo root (check with `pwd`).
32
32
-
33
33
-
**Imports needed in `admin.ts`** — you'll need to add:
34
34
-
- `themes, themePolicies` to the `@atbb/db` import
35
35
-
- `or` to the `drizzle-orm` import (it's not there yet)
36
36
-
37
37
-
---
38
38
-
39
39
-
## Task 1: Add `manageThemes` permission to seed-roles
40
40
-
41
41
-
**Files:**
42
42
-
- Modify: `apps/appview/src/lib/seed-roles.ts`
43
43
-
44
44
-
No test needed (it's runtime seed data, not business logic). The Admin role needs `space.atbb.permission.manageThemes` added.
45
45
-
46
46
-
**Step 1: Add permission to Admin role**
47
47
-
48
48
-
In `seed-roles.ts`, find the `"Admin"` entry in `DEFAULT_ROLES` and add `"space.atbb.permission.manageThemes"` to its `permissions` array:
49
49
-
50
50
-
```typescript
51
51
-
{
52
52
-
name: "Admin",
53
53
-
description: "Can manage forum structure and users",
54
54
-
permissions: [
55
55
-
"space.atbb.permission.manageCategories",
56
56
-
"space.atbb.permission.manageRoles",
57
57
-
"space.atbb.permission.manageMembers",
58
58
-
"space.atbb.permission.manageThemes", // ← add this line
59
59
-
"space.atbb.permission.moderatePosts",
60
60
-
"space.atbb.permission.banUsers",
61
61
-
"space.atbb.permission.pinTopics",
62
62
-
"space.atbb.permission.lockTopics",
63
63
-
"space.atbb.permission.createTopics",
64
64
-
"space.atbb.permission.createPosts",
65
65
-
],
66
66
-
priority: 10,
67
67
-
critical: true,
68
68
-
},
69
69
-
```
70
70
-
71
71
-
**Step 2: Verify existing tests still pass**
72
72
-
73
73
-
```bash
74
74
-
PATH=$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm --filter @atbb/appview exec vitest run
75
75
-
```
76
76
-
77
77
-
Expected: all existing tests pass.
78
78
-
79
79
-
**Step 3: Commit**
80
80
-
81
81
-
```bash
82
82
-
git add apps/appview/src/lib/seed-roles.ts
83
83
-
git commit -m "feat(appview): add manageThemes permission to Admin role (ATB-57)"
84
84
-
```
85
85
-
86
86
-
---
87
87
-
88
88
-
## Task 2: Write failing tests for `POST /api/admin/themes`
89
89
-
90
90
-
**Files:**
91
91
-
- Modify: `apps/appview/src/routes/__tests__/admin.test.ts`
92
92
-
93
93
-
**Step 1: Add import for `themes` table**
94
94
-
95
95
-
At the top of `admin.test.ts`, find the `@atbb/db` import and add `themes`:
96
96
-
97
97
-
```typescript
98
98
-
import { memberships, roles, rolePermissions, users, forums, categories, boards, posts, modActions, themes } from "@atbb/db";
99
99
-
```
100
100
-
101
101
-
**Step 2: Add the test describe block**
102
102
-
103
103
-
At the bottom of the file (inside `describe.sequential("Admin Routes", ...)`, before the closing brace), add:
104
104
-
105
105
-
```typescript
106
106
-
describe("POST /api/admin/themes", () => {
107
107
-
it("creates theme and returns 201 with uri and cid", async () => {
108
108
-
const res = await app.request("/api/admin/themes", {
109
109
-
method: "POST",
110
110
-
headers: { "Content-Type": "application/json" },
111
111
-
body: JSON.stringify({
112
112
-
name: "Neobrutal Light",
113
113
-
colorScheme: "light",
114
114
-
tokens: { "color-bg": "#f5f0e8", "color-text": "#1a1a1a" },
115
115
-
}),
116
116
-
});
117
117
-
expect(res.status).toBe(201);
118
118
-
const body = await res.json();
119
119
-
expect(body.uri).toBeDefined();
120
120
-
expect(body.cid).toBeDefined();
121
121
-
expect(mockPutRecord).toHaveBeenCalledOnce();
122
122
-
});
123
123
-
124
124
-
it("includes cssOverrides and fontUrls when provided", async () => {
125
125
-
const res = await app.request("/api/admin/themes", {
126
126
-
method: "POST",
127
127
-
headers: { "Content-Type": "application/json" },
128
128
-
body: JSON.stringify({
129
129
-
name: "Custom Theme",
130
130
-
colorScheme: "dark",
131
131
-
tokens: { "color-bg": "#1a1a1a" },
132
132
-
cssOverrides: ".card { border-radius: 4px; }",
133
133
-
fontUrls: ["https://fonts.googleapis.com/css2?family=Space+Grotesk"],
134
134
-
}),
135
135
-
});
136
136
-
expect(res.status).toBe(201);
137
137
-
const call = mockPutRecord.mock.calls[0][0];
138
138
-
expect(call.record.cssOverrides).toBe(".card { border-radius: 4px; }");
139
139
-
expect(call.record.fontUrls).toEqual(["https://fonts.googleapis.com/css2?family=Space+Grotesk"]);
140
140
-
});
141
141
-
142
142
-
it("returns 400 when name is missing", async () => {
143
143
-
const res = await app.request("/api/admin/themes", {
144
144
-
method: "POST",
145
145
-
headers: { "Content-Type": "application/json" },
146
146
-
body: JSON.stringify({ colorScheme: "light", tokens: {} }),
147
147
-
});
148
148
-
expect(res.status).toBe(400);
149
149
-
const body = await res.json();
150
150
-
expect(body.error).toMatch(/name/i);
151
151
-
});
152
152
-
153
153
-
it("returns 400 when name is empty string", async () => {
154
154
-
const res = await app.request("/api/admin/themes", {
155
155
-
method: "POST",
156
156
-
headers: { "Content-Type": "application/json" },
157
157
-
body: JSON.stringify({ name: " ", colorScheme: "light", tokens: {} }),
158
158
-
});
159
159
-
expect(res.status).toBe(400);
160
160
-
});
161
161
-
162
162
-
it("returns 400 when colorScheme is invalid", async () => {
163
163
-
const res = await app.request("/api/admin/themes", {
164
164
-
method: "POST",
165
165
-
headers: { "Content-Type": "application/json" },
166
166
-
body: JSON.stringify({ name: "Test", colorScheme: "purple", tokens: {} }),
167
167
-
});
168
168
-
expect(res.status).toBe(400);
169
169
-
const body = await res.json();
170
170
-
expect(body.error).toMatch(/colorScheme/i);
171
171
-
});
172
172
-
173
173
-
it("returns 400 when colorScheme is missing", async () => {
174
174
-
const res = await app.request("/api/admin/themes", {
175
175
-
method: "POST",
176
176
-
headers: { "Content-Type": "application/json" },
177
177
-
body: JSON.stringify({ name: "Test", tokens: {} }),
178
178
-
});
179
179
-
expect(res.status).toBe(400);
180
180
-
});
181
181
-
182
182
-
it("returns 400 when tokens is missing", async () => {
183
183
-
const res = await app.request("/api/admin/themes", {
184
184
-
method: "POST",
185
185
-
headers: { "Content-Type": "application/json" },
186
186
-
body: JSON.stringify({ name: "Test", colorScheme: "light" }),
187
187
-
});
188
188
-
expect(res.status).toBe(400);
189
189
-
const body = await res.json();
190
190
-
expect(body.error).toMatch(/tokens/i);
191
191
-
});
192
192
-
193
193
-
it("returns 400 when tokens is an array (not an object)", async () => {
194
194
-
const res = await app.request("/api/admin/themes", {
195
195
-
method: "POST",
196
196
-
headers: { "Content-Type": "application/json" },
197
197
-
body: JSON.stringify({ name: "Test", colorScheme: "light", tokens: ["a", "b"] }),
198
198
-
});
199
199
-
expect(res.status).toBe(400);
200
200
-
});
201
201
-
202
202
-
it("returns 400 when a token value is not a string", async () => {
203
203
-
const res = await app.request("/api/admin/themes", {
204
204
-
method: "POST",
205
205
-
headers: { "Content-Type": "application/json" },
206
206
-
body: JSON.stringify({ name: "Test", colorScheme: "light", tokens: { "color-bg": 123 } }),
207
207
-
});
208
208
-
expect(res.status).toBe(400);
209
209
-
const body = await res.json();
210
210
-
expect(body.error).toMatch(/tokens/i);
211
211
-
});
212
212
-
213
213
-
it("returns 400 when a fontUrl is not HTTPS", async () => {
214
214
-
const res = await app.request("/api/admin/themes", {
215
215
-
method: "POST",
216
216
-
headers: { "Content-Type": "application/json" },
217
217
-
body: JSON.stringify({
218
218
-
name: "Test",
219
219
-
colorScheme: "light",
220
220
-
tokens: {},
221
221
-
fontUrls: ["http://example.com/font.css"],
222
222
-
}),
223
223
-
});
224
224
-
expect(res.status).toBe(400);
225
225
-
const body = await res.json();
226
226
-
expect(body.error).toMatch(/https/i);
227
227
-
});
228
228
-
229
229
-
it("returns 503 when ForumAgent is not configured", async () => {
230
230
-
ctx.forumAgent = null;
231
231
-
const res = await app.request("/api/admin/themes", {
232
232
-
method: "POST",
233
233
-
headers: { "Content-Type": "application/json" },
234
234
-
body: JSON.stringify({ name: "Test", colorScheme: "light", tokens: {} }),
235
235
-
});
236
236
-
expect(res.status).toBe(503);
237
237
-
});
238
238
-
});
239
239
-
```
240
240
-
241
241
-
**Step 3: Run to verify tests fail**
242
242
-
243
243
-
```bash
244
244
-
PATH=$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/admin.test.ts
245
245
-
```
246
246
-
247
247
-
Expected: tests fail with something like `Cannot find description for POST /api/admin/themes` or 404 responses.
248
248
-
249
249
-
---
250
250
-
251
251
-
## Task 3: Implement `POST /api/admin/themes`
252
252
-
253
253
-
**Files:**
254
254
-
- Modify: `apps/appview/src/routes/admin.ts`
255
255
-
256
256
-
**Step 1: Update imports**
257
257
-
258
258
-
Add `themes, themePolicies` to the `@atbb/db` import line:
259
259
-
260
260
-
```typescript
261
261
-
import { memberships, roles, rolePermissions, users, forums, backfillProgress, backfillErrors, categories, boards, posts, modActions, themes, themePolicies } from "@atbb/db";
262
262
-
```
263
263
-
264
264
-
Add `or` to the `drizzle-orm` import:
265
265
-
266
266
-
```typescript
267
267
-
import { eq, and, sql, asc, desc, count, or } from "drizzle-orm";
268
268
-
```
269
269
-
270
270
-
**Step 2: Add validation helper (inline in the handler)**
271
271
-
272
272
-
Add the following handler to `admin.ts` before the `return app;` at the bottom. Insert it after the DELETE `/boards/:id` handler and before the GET `/modlog` handler:
273
273
-
274
274
-
```typescript
275
275
-
/**
276
276
-
* POST /api/admin/themes
277
277
-
*
278
278
-
* Create a new theme record on Forum DID's PDS.
279
279
-
* Writes space.atbb.forum.theme with a fresh TID rkey.
280
280
-
* The firehose indexer creates the DB row asynchronously.
281
281
-
*/
282
282
-
app.post(
283
283
-
"/themes",
284
284
-
requireAuth(ctx),
285
285
-
requirePermission(ctx, "space.atbb.permission.manageThemes"),
286
286
-
async (c) => {
287
287
-
const { body, error: parseError } = await safeParseJsonBody(c);
288
288
-
if (parseError) return parseError;
289
289
-
290
290
-
const { name, colorScheme, tokens, cssOverrides, fontUrls } = body;
291
291
-
292
292
-
if (typeof name !== "string" || name.trim().length === 0) {
293
293
-
return c.json({ error: "name is required and must be a non-empty string" }, 400);
294
294
-
}
295
295
-
if (typeof colorScheme !== "string" || (colorScheme !== "light" && colorScheme !== "dark")) {
296
296
-
return c.json({ error: 'colorScheme is required and must be "light" or "dark"' }, 400);
297
297
-
}
298
298
-
if (tokens === null || tokens === undefined || typeof tokens !== "object" || Array.isArray(tokens)) {
299
299
-
return c.json({ error: "tokens is required and must be a plain object" }, 400);
300
300
-
}
301
301
-
for (const [key, val] of Object.entries(tokens as Record<string, unknown>)) {
302
302
-
if (typeof val !== "string") {
303
303
-
return c.json({ error: `tokens["${key}"] must be a string` }, 400);
304
304
-
}
305
305
-
}
306
306
-
if (cssOverrides !== undefined && typeof cssOverrides !== "string") {
307
307
-
return c.json({ error: "cssOverrides must be a string" }, 400);
308
308
-
}
309
309
-
if (fontUrls !== undefined) {
310
310
-
if (!Array.isArray(fontUrls)) {
311
311
-
return c.json({ error: "fontUrls must be an array of strings" }, 400);
312
312
-
}
313
313
-
for (const url of fontUrls as unknown[]) {
314
314
-
if (typeof url !== "string" || !url.startsWith("https://")) {
315
315
-
return c.json({ error: "fontUrls must contain only HTTPS URLs" }, 400);
316
316
-
}
317
317
-
}
318
318
-
}
319
319
-
320
320
-
const { agent, error: agentError } = getForumAgentOrError(ctx, c, "POST /api/admin/themes");
321
321
-
if (agentError) return agentError;
322
322
-
323
323
-
const rkey = TID.nextStr();
324
324
-
const now = new Date().toISOString();
325
325
-
326
326
-
try {
327
327
-
const result = await agent.com.atproto.repo.putRecord({
328
328
-
repo: ctx.config.forumDid,
329
329
-
collection: "space.atbb.forum.theme",
330
330
-
rkey,
331
331
-
record: {
332
332
-
$type: "space.atbb.forum.theme",
333
333
-
name: name.trim(),
334
334
-
colorScheme,
335
335
-
tokens,
336
336
-
...(typeof cssOverrides === "string" && { cssOverrides }),
337
337
-
...(Array.isArray(fontUrls) && { fontUrls }),
338
338
-
createdAt: now,
339
339
-
},
340
340
-
});
341
341
-
342
342
-
return c.json({ uri: result.data.uri, cid: result.data.cid }, 201);
343
343
-
} catch (error) {
344
344
-
return handleRouteError(c, error, "Failed to create theme", {
345
345
-
operation: "POST /api/admin/themes",
346
346
-
logger: ctx.logger,
347
347
-
});
348
348
-
}
349
349
-
}
350
350
-
);
351
351
-
```
352
352
-
353
353
-
**Step 3: Run tests to verify they pass**
354
354
-
355
355
-
```bash
356
356
-
PATH=$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/admin.test.ts
357
357
-
```
358
358
-
359
359
-
Expected: POST /api/admin/themes tests all pass.
360
360
-
361
361
-
**Step 4: Commit**
362
362
-
363
363
-
```bash
364
364
-
git add apps/appview/src/routes/admin.ts apps/appview/src/routes/__tests__/admin.test.ts
365
365
-
git commit -m "feat(appview): POST /api/admin/themes — create theme on Forum PDS (ATB-57)"
366
366
-
```
367
367
-
368
368
-
---
369
369
-
370
370
-
## Task 4: Write failing tests + implement `PUT /api/admin/themes/:rkey`
371
371
-
372
372
-
**Files:**
373
373
-
- Modify: `apps/appview/src/routes/__tests__/admin.test.ts`
374
374
-
- Modify: `apps/appview/src/routes/admin.ts`
375
375
-
376
376
-
**Step 1: Add tests for PUT**
377
377
-
378
378
-
Add inside `describe.sequential("Admin Routes", ...)` in `admin.test.ts`:
379
379
-
380
380
-
```typescript
381
381
-
describe("PUT /api/admin/themes/:rkey", () => {
382
382
-
beforeEach(async () => {
383
383
-
// Seed a theme row for the update tests
384
384
-
await ctx.db.insert(themes).values({
385
385
-
did: ctx.config.forumDid,
386
386
-
rkey: "3lblputtest1",
387
387
-
cid: "bafyputtest",
388
388
-
name: "Original Name",
389
389
-
colorScheme: "light",
390
390
-
tokens: { "color-bg": "#ffffff" },
391
391
-
cssOverrides: ".btn { font-weight: 700; }",
392
392
-
fontUrls: ["https://fonts.googleapis.com/css2?family=Space+Grotesk"],
393
393
-
createdAt: new Date("2026-01-01T00:00:00Z"),
394
394
-
indexedAt: new Date(),
395
395
-
});
396
396
-
});
397
397
-
398
398
-
it("updates theme and returns 200 with uri and cid", async () => {
399
399
-
const res = await app.request("/api/admin/themes/3lblputtest1", {
400
400
-
method: "PUT",
401
401
-
headers: { "Content-Type": "application/json" },
402
402
-
body: JSON.stringify({
403
403
-
name: "Updated Name",
404
404
-
colorScheme: "dark",
405
405
-
tokens: { "color-bg": "#1a1a1a" },
406
406
-
}),
407
407
-
});
408
408
-
expect(res.status).toBe(200);
409
409
-
const body = await res.json();
410
410
-
expect(body.uri).toBeDefined();
411
411
-
expect(body.cid).toBeDefined();
412
412
-
});
413
413
-
414
414
-
it("preserves existing cssOverrides when not provided in request", async () => {
415
415
-
const res = await app.request("/api/admin/themes/3lblputtest1", {
416
416
-
method: "PUT",
417
417
-
headers: { "Content-Type": "application/json" },
418
418
-
body: JSON.stringify({
419
419
-
name: "Updated Name",
420
420
-
colorScheme: "light",
421
421
-
tokens: { "color-bg": "#f0f0f0" },
422
422
-
// cssOverrides intentionally omitted
423
423
-
}),
424
424
-
});
425
425
-
expect(res.status).toBe(200);
426
426
-
const call = mockPutRecord.mock.calls[0][0];
427
427
-
expect(call.record.cssOverrides).toBe(".btn { font-weight: 700; }");
428
428
-
});
429
429
-
430
430
-
it("preserves existing fontUrls when not provided in request", async () => {
431
431
-
const res = await app.request("/api/admin/themes/3lblputtest1", {
432
432
-
method: "PUT",
433
433
-
headers: { "Content-Type": "application/json" },
434
434
-
body: JSON.stringify({
435
435
-
name: "Updated Name",
436
436
-
colorScheme: "light",
437
437
-
tokens: {},
438
438
-
// fontUrls intentionally omitted
439
439
-
}),
440
440
-
});
441
441
-
expect(res.status).toBe(200);
442
442
-
const call = mockPutRecord.mock.calls[0][0];
443
443
-
expect(call.record.fontUrls).toEqual(["https://fonts.googleapis.com/css2?family=Space+Grotesk"]);
444
444
-
});
445
445
-
446
446
-
it("preserves original createdAt in the PDS record", async () => {
447
447
-
const res = await app.request("/api/admin/themes/3lblputtest1", {
448
448
-
method: "PUT",
449
449
-
headers: { "Content-Type": "application/json" },
450
450
-
body: JSON.stringify({ name: "Updated", colorScheme: "light", tokens: {} }),
451
451
-
});
452
452
-
expect(res.status).toBe(200);
453
453
-
const call = mockPutRecord.mock.calls[0][0];
454
454
-
expect(call.record.createdAt).toBe("2026-01-01T00:00:00.000Z");
455
455
-
});
456
456
-
457
457
-
it("returns 404 for unknown rkey", async () => {
458
458
-
const res = await app.request("/api/admin/themes/nonexistent", {
459
459
-
method: "PUT",
460
460
-
headers: { "Content-Type": "application/json" },
461
461
-
body: JSON.stringify({ name: "X", colorScheme: "light", tokens: {} }),
462
462
-
});
463
463
-
expect(res.status).toBe(404);
464
464
-
});
465
465
-
466
466
-
it("returns 400 when name is missing", async () => {
467
467
-
const res = await app.request("/api/admin/themes/3lblputtest1", {
468
468
-
method: "PUT",
469
469
-
headers: { "Content-Type": "application/json" },
470
470
-
body: JSON.stringify({ colorScheme: "light", tokens: {} }),
471
471
-
});
472
472
-
expect(res.status).toBe(400);
473
473
-
});
474
474
-
475
475
-
it("returns 400 when colorScheme is invalid", async () => {
476
476
-
const res = await app.request("/api/admin/themes/3lblputtest1", {
477
477
-
method: "PUT",
478
478
-
headers: { "Content-Type": "application/json" },
479
479
-
body: JSON.stringify({ name: "Test", colorScheme: "sepia", tokens: {} }),
480
480
-
});
481
481
-
expect(res.status).toBe(400);
482
482
-
});
483
483
-
484
484
-
it("returns 400 when tokens is an array", async () => {
485
485
-
const res = await app.request("/api/admin/themes/3lblputtest1", {
486
486
-
method: "PUT",
487
487
-
headers: { "Content-Type": "application/json" },
488
488
-
body: JSON.stringify({ name: "Test", colorScheme: "light", tokens: ["a"] }),
489
489
-
});
490
490
-
expect(res.status).toBe(400);
491
491
-
});
492
492
-
});
493
493
-
```
494
494
-
495
495
-
**Step 2: Run to verify tests fail**
496
496
-
497
497
-
```bash
498
498
-
PATH=$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/admin.test.ts
499
499
-
```
500
500
-
501
501
-
Expected: PUT tests fail with 404 (endpoint doesn't exist yet).
502
502
-
503
503
-
**Step 3: Implement PUT handler in `admin.ts`**
504
504
-
505
505
-
Add after the POST `/themes` handler:
506
506
-
507
507
-
```typescript
508
508
-
/**
509
509
-
* PUT /api/admin/themes/:rkey
510
510
-
*
511
511
-
* Update an existing theme. Fetches the existing row from DB to
512
512
-
* preserve createdAt and fall back optional fields not in the request.
513
513
-
* The firehose indexer updates the DB row asynchronously.
514
514
-
*/
515
515
-
app.put(
516
516
-
"/themes/:rkey",
517
517
-
requireAuth(ctx),
518
518
-
requirePermission(ctx, "space.atbb.permission.manageThemes"),
519
519
-
async (c) => {
520
520
-
const themeRkey = c.req.param("rkey").trim();
521
521
-
522
522
-
const { body, error: parseError } = await safeParseJsonBody(c);
523
523
-
if (parseError) return parseError;
524
524
-
525
525
-
const { name, colorScheme, tokens, cssOverrides, fontUrls } = body;
526
526
-
527
527
-
if (typeof name !== "string" || name.trim().length === 0) {
528
528
-
return c.json({ error: "name is required and must be a non-empty string" }, 400);
529
529
-
}
530
530
-
if (typeof colorScheme !== "string" || (colorScheme !== "light" && colorScheme !== "dark")) {
531
531
-
return c.json({ error: 'colorScheme is required and must be "light" or "dark"' }, 400);
532
532
-
}
533
533
-
if (tokens === null || tokens === undefined || typeof tokens !== "object" || Array.isArray(tokens)) {
534
534
-
return c.json({ error: "tokens is required and must be a plain object" }, 400);
535
535
-
}
536
536
-
for (const [key, val] of Object.entries(tokens as Record<string, unknown>)) {
537
537
-
if (typeof val !== "string") {
538
538
-
return c.json({ error: `tokens["${key}"] must be a string` }, 400);
539
539
-
}
540
540
-
}
541
541
-
if (cssOverrides !== undefined && typeof cssOverrides !== "string") {
542
542
-
return c.json({ error: "cssOverrides must be a string" }, 400);
543
543
-
}
544
544
-
if (fontUrls !== undefined) {
545
545
-
if (!Array.isArray(fontUrls)) {
546
546
-
return c.json({ error: "fontUrls must be an array of strings" }, 400);
547
547
-
}
548
548
-
for (const url of fontUrls as unknown[]) {
549
549
-
if (typeof url !== "string" || !url.startsWith("https://")) {
550
550
-
return c.json({ error: "fontUrls must contain only HTTPS URLs" }, 400);
551
551
-
}
552
552
-
}
553
553
-
}
554
554
-
555
555
-
let theme: typeof themes.$inferSelect;
556
556
-
try {
557
557
-
const [row] = await ctx.db
558
558
-
.select()
559
559
-
.from(themes)
560
560
-
.where(and(eq(themes.did, ctx.config.forumDid), eq(themes.rkey, themeRkey)))
561
561
-
.limit(1);
562
562
-
563
563
-
if (!row) {
564
564
-
return c.json({ error: "Theme not found" }, 404);
565
565
-
}
566
566
-
theme = row;
567
567
-
} catch (error) {
568
568
-
return handleRouteError(c, error, "Failed to look up theme", {
569
569
-
operation: "PUT /api/admin/themes/:rkey",
570
570
-
logger: ctx.logger,
571
571
-
themeRkey,
572
572
-
});
573
573
-
}
574
574
-
575
575
-
const { agent, error: agentError } = getForumAgentOrError(ctx, c, "PUT /api/admin/themes/:rkey");
576
576
-
if (agentError) return agentError;
577
577
-
578
578
-
// putRecord is a full replacement — fall back to existing values for
579
579
-
// optional fields not provided in the request body, to avoid data loss.
580
580
-
const resolvedCssOverrides = typeof cssOverrides === "string" ? cssOverrides : theme.cssOverrides;
581
581
-
const resolvedFontUrls = Array.isArray(fontUrls) ? fontUrls : (theme.fontUrls as string[] | null);
582
582
-
583
583
-
try {
584
584
-
const result = await agent.com.atproto.repo.putRecord({
585
585
-
repo: ctx.config.forumDid,
586
586
-
collection: "space.atbb.forum.theme",
587
587
-
rkey: theme.rkey,
588
588
-
record: {
589
589
-
$type: "space.atbb.forum.theme",
590
590
-
name: name.trim(),
591
591
-
colorScheme,
592
592
-
tokens,
593
593
-
...(resolvedCssOverrides != null && { cssOverrides: resolvedCssOverrides }),
594
594
-
...(resolvedFontUrls != null && { fontUrls: resolvedFontUrls }),
595
595
-
createdAt: theme.createdAt.toISOString(),
596
596
-
updatedAt: new Date().toISOString(),
597
597
-
},
598
598
-
});
599
599
-
600
600
-
return c.json({ uri: result.data.uri, cid: result.data.cid });
601
601
-
} catch (error) {
602
602
-
return handleRouteError(c, error, "Failed to update theme", {
603
603
-
operation: "PUT /api/admin/themes/:rkey",
604
604
-
logger: ctx.logger,
605
605
-
themeRkey,
606
606
-
});
607
607
-
}
608
608
-
}
609
609
-
);
610
610
-
```
611
611
-
612
612
-
**Step 4: Run tests to verify they pass**
613
613
-
614
614
-
```bash
615
615
-
PATH=$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/admin.test.ts
616
616
-
```
617
617
-
618
618
-
Expected: all PUT tests pass.
619
619
-
620
620
-
**Step 5: Commit**
621
621
-
622
622
-
```bash
623
623
-
git add apps/appview/src/routes/admin.ts apps/appview/src/routes/__tests__/admin.test.ts
624
624
-
git commit -m "feat(appview): PUT /api/admin/themes/:rkey — update theme on Forum PDS (ATB-57)"
625
625
-
```
626
626
-
627
627
-
---
628
628
-
629
629
-
## Task 5: Write failing tests + implement `DELETE /api/admin/themes/:rkey`
630
630
-
631
631
-
**Files:**
632
632
-
- Modify: `apps/appview/src/routes/__tests__/admin.test.ts`
633
633
-
- Modify: `apps/appview/src/routes/admin.ts`
634
634
-
635
635
-
**Step 1: Add tests**
636
636
-
637
637
-
Import `themePolicies, themePolicyAvailableThemes` at the top of `admin.test.ts`:
638
638
-
639
639
-
```typescript
640
640
-
import { memberships, roles, rolePermissions, users, forums, categories, boards, posts, modActions, themes, themePolicies, themePolicyAvailableThemes } from "@atbb/db";
641
641
-
```
642
642
-
643
643
-
Then add inside `describe.sequential("Admin Routes", ...)`:
644
644
-
645
645
-
```typescript
646
646
-
describe("DELETE /api/admin/themes/:rkey", () => {
647
647
-
const themeRkey = "3lbldeltest1";
648
648
-
const themeUri = `at://${ctx?.config?.forumDid ?? "did:plc:test-forum"}/space.atbb.forum.theme/${themeRkey}`;
649
649
-
650
650
-
beforeEach(async () => {
651
651
-
await ctx.db.insert(themes).values({
652
652
-
did: ctx.config.forumDid,
653
653
-
rkey: themeRkey,
654
654
-
cid: "bafydeltest",
655
655
-
name: "Theme To Delete",
656
656
-
colorScheme: "light",
657
657
-
tokens: { "color-bg": "#ffffff" },
658
658
-
createdAt: new Date(),
659
659
-
indexedAt: new Date(),
660
660
-
});
661
661
-
});
662
662
-
663
663
-
it("deletes theme and returns 200 with success: true", async () => {
664
664
-
const res = await app.request(`/api/admin/themes/${themeRkey}`, {
665
665
-
method: "DELETE",
666
666
-
});
667
667
-
expect(res.status).toBe(200);
668
668
-
const body = await res.json();
669
669
-
expect(body.success).toBe(true);
670
670
-
expect(mockDeleteRecord).toHaveBeenCalledOnce();
671
671
-
});
672
672
-
673
673
-
it("returns 404 for unknown rkey", async () => {
674
674
-
const res = await app.request("/api/admin/themes/doesnotexist", {
675
675
-
method: "DELETE",
676
676
-
});
677
677
-
expect(res.status).toBe(404);
678
678
-
});
679
679
-
680
680
-
it("returns 409 when theme is the defaultLightTheme in policy", async () => {
681
681
-
const [policy] = await ctx.db.insert(themePolicies).values({
682
682
-
did: ctx.config.forumDid,
683
683
-
rkey: "self",
684
684
-
cid: "bafypolicydel",
685
685
-
defaultLightThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/${themeRkey}`,
686
686
-
defaultDarkThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/other`,
687
687
-
allowUserChoice: true,
688
688
-
indexedAt: new Date(),
689
689
-
}).returning();
690
690
-
691
691
-
const res = await app.request(`/api/admin/themes/${themeRkey}`, {
692
692
-
method: "DELETE",
693
693
-
});
694
694
-
expect(res.status).toBe(409);
695
695
-
const body = await res.json();
696
696
-
expect(body.error).toMatch(/default/i);
697
697
-
});
698
698
-
699
699
-
it("returns 409 when theme is the defaultDarkTheme in policy", async () => {
700
700
-
await ctx.db.insert(themePolicies).values({
701
701
-
did: ctx.config.forumDid,
702
702
-
rkey: "self",
703
703
-
cid: "bafypolicydel2",
704
704
-
defaultLightThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/other`,
705
705
-
defaultDarkThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/${themeRkey}`,
706
706
-
allowUserChoice: true,
707
707
-
indexedAt: new Date(),
708
708
-
});
709
709
-
710
710
-
const res = await app.request(`/api/admin/themes/${themeRkey}`, {
711
711
-
method: "DELETE",
712
712
-
});
713
713
-
expect(res.status).toBe(409);
714
714
-
});
715
715
-
716
716
-
it("deletes successfully when theme exists in policy availableThemes but not as a default", async () => {
717
717
-
// A theme can be in availableThemes without being a default — this should NOT block deletion
718
718
-
// (the 409 guard only checks defaultLightThemeUri / defaultDarkThemeUri columns)
719
719
-
const [policy] = await ctx.db.insert(themePolicies).values({
720
720
-
did: ctx.config.forumDid,
721
721
-
rkey: "self",
722
722
-
cid: "bafypolicyavail",
723
723
-
defaultLightThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/other`,
724
724
-
defaultDarkThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/other`,
725
725
-
allowUserChoice: true,
726
726
-
indexedAt: new Date(),
727
727
-
}).returning();
728
728
-
await ctx.db.insert(themePolicyAvailableThemes).values({
729
729
-
policyId: policy.id,
730
730
-
themeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/${themeRkey}`,
731
731
-
themeCid: "bafydeltest",
732
732
-
});
733
733
-
734
734
-
const res = await app.request(`/api/admin/themes/${themeRkey}`, {
735
735
-
method: "DELETE",
736
736
-
});
737
737
-
expect(res.status).toBe(200);
738
738
-
});
739
739
-
});
740
740
-
```
741
741
-
742
742
-
**Step 2: Run to verify tests fail**
743
743
-
744
744
-
```bash
745
745
-
PATH=$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/admin.test.ts
746
746
-
```
747
747
-
748
748
-
Expected: DELETE tests fail.
749
749
-
750
750
-
**Step 3: Implement DELETE handler in `admin.ts`**
751
751
-
752
752
-
Add after the PUT `/themes/:rkey` handler:
753
753
-
754
754
-
```typescript
755
755
-
/**
756
756
-
* DELETE /api/admin/themes/:rkey
757
757
-
*
758
758
-
* Delete a theme. Pre-flight: refuses with 409 if the theme is set as
759
759
-
* defaultLightTheme or defaultDarkTheme in the theme policy.
760
760
-
* The firehose indexer removes the DB row asynchronously.
761
761
-
*/
762
762
-
app.delete(
763
763
-
"/themes/:rkey",
764
764
-
requireAuth(ctx),
765
765
-
requirePermission(ctx, "space.atbb.permission.manageThemes"),
766
766
-
async (c) => {
767
767
-
const themeRkey = c.req.param("rkey").trim();
768
768
-
769
769
-
let theme: typeof themes.$inferSelect;
770
770
-
try {
771
771
-
const [row] = await ctx.db
772
772
-
.select()
773
773
-
.from(themes)
774
774
-
.where(and(eq(themes.did, ctx.config.forumDid), eq(themes.rkey, themeRkey)))
775
775
-
.limit(1);
776
776
-
777
777
-
if (!row) {
778
778
-
return c.json({ error: "Theme not found" }, 404);
779
779
-
}
780
780
-
theme = row;
781
781
-
} catch (error) {
782
782
-
return handleRouteError(c, error, "Failed to look up theme", {
783
783
-
operation: "DELETE /api/admin/themes/:rkey",
784
784
-
logger: ctx.logger,
785
785
-
themeRkey,
786
786
-
});
787
787
-
}
788
788
-
789
789
-
// Pre-flight conflict check: refuse if this theme is a policy default
790
790
-
const themeUri = `at://${theme.did}/space.atbb.forum.theme/${theme.rkey}`;
791
791
-
try {
792
792
-
const [conflictingPolicy] = await ctx.db
793
793
-
.select({ id: themePolicies.id })
794
794
-
.from(themePolicies)
795
795
-
.where(
796
796
-
and(
797
797
-
eq(themePolicies.did, ctx.config.forumDid),
798
798
-
or(
799
799
-
eq(themePolicies.defaultLightThemeUri, themeUri),
800
800
-
eq(themePolicies.defaultDarkThemeUri, themeUri)
801
801
-
)
802
802
-
)
803
803
-
)
804
804
-
.limit(1);
805
805
-
806
806
-
if (conflictingPolicy) {
807
807
-
return c.json(
808
808
-
{ error: "Cannot delete a theme that is currently set as a default. Update the theme policy first." },
809
809
-
409
810
810
-
);
811
811
-
}
812
812
-
} catch (error) {
813
813
-
return handleRouteError(c, error, "Failed to check theme policy", {
814
814
-
operation: "DELETE /api/admin/themes/:rkey",
815
815
-
logger: ctx.logger,
816
816
-
themeRkey,
817
817
-
});
818
818
-
}
819
819
-
820
820
-
const { agent, error: agentError } = getForumAgentOrError(ctx, c, "DELETE /api/admin/themes/:rkey");
821
821
-
if (agentError) return agentError;
822
822
-
823
823
-
try {
824
824
-
await agent.com.atproto.repo.deleteRecord({
825
825
-
repo: ctx.config.forumDid,
826
826
-
collection: "space.atbb.forum.theme",
827
827
-
rkey: theme.rkey,
828
828
-
});
829
829
-
830
830
-
return c.json({ success: true });
831
831
-
} catch (error) {
832
832
-
return handleRouteError(c, error, "Failed to delete theme", {
833
833
-
operation: "DELETE /api/admin/themes/:rkey",
834
834
-
logger: ctx.logger,
835
835
-
themeRkey,
836
836
-
});
837
837
-
}
838
838
-
}
839
839
-
);
840
840
-
```
841
841
-
842
842
-
**Step 4: Run tests to verify they pass**
843
843
-
844
844
-
```bash
845
845
-
PATH=$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/admin.test.ts
846
846
-
```
847
847
-
848
848
-
Expected: all DELETE tests pass.
849
849
-
850
850
-
**Step 5: Commit**
851
851
-
852
852
-
```bash
853
853
-
git add apps/appview/src/routes/admin.ts apps/appview/src/routes/__tests__/admin.test.ts
854
854
-
git commit -m "feat(appview): DELETE /api/admin/themes/:rkey — delete theme, 409 if default (ATB-57)"
855
855
-
```
856
856
-
857
857
-
---
858
858
-
859
859
-
## Task 6: Write failing tests + implement `PUT /api/admin/theme-policy`
860
860
-
861
861
-
**Files:**
862
862
-
- Modify: `apps/appview/src/routes/__tests__/admin.test.ts`
863
863
-
- Modify: `apps/appview/src/routes/admin.ts`
864
864
-
865
865
-
**Step 1: Add tests**
866
866
-
867
867
-
Add inside `describe.sequential("Admin Routes", ...)`:
868
868
-
869
869
-
```typescript
870
870
-
describe("PUT /api/admin/theme-policy", () => {
871
871
-
const lightUri = `at://did:plc:test-forum/space.atbb.forum.theme/3lbllight1`;
872
872
-
const darkUri = `at://did:plc:test-forum/space.atbb.forum.theme/3lbldark11`;
873
873
-
874
874
-
const validBody = {
875
875
-
availableThemes: [
876
876
-
{ uri: lightUri, cid: "bafylight" },
877
877
-
{ uri: darkUri, cid: "bafydark" },
878
878
-
],
879
879
-
defaultLightThemeUri: lightUri,
880
880
-
defaultDarkThemeUri: darkUri,
881
881
-
allowUserChoice: true,
882
882
-
};
883
883
-
884
884
-
it("creates policy (upsert) and returns 200 with uri and cid", async () => {
885
885
-
const res = await app.request("/api/admin/theme-policy", {
886
886
-
method: "PUT",
887
887
-
headers: { "Content-Type": "application/json" },
888
888
-
body: JSON.stringify(validBody),
889
889
-
});
890
890
-
expect(res.status).toBe(200);
891
891
-
const body = await res.json();
892
892
-
expect(body.uri).toBeDefined();
893
893
-
expect(body.cid).toBeDefined();
894
894
-
expect(mockPutRecord).toHaveBeenCalledOnce();
895
895
-
});
896
896
-
897
897
-
it("writes PDS record with themeRef wrapper structure", async () => {
898
898
-
await app.request("/api/admin/theme-policy", {
899
899
-
method: "PUT",
900
900
-
headers: { "Content-Type": "application/json" },
901
901
-
body: JSON.stringify(validBody),
902
902
-
});
903
903
-
const call = mockPutRecord.mock.calls[0][0];
904
904
-
expect(call.record.$type).toBe("space.atbb.forum.themePolicy");
905
905
-
expect(call.rkey).toBe("self");
906
906
-
// availableThemes wrapped in { theme: { uri, cid } }
907
907
-
expect(call.record.availableThemes[0]).toEqual({ theme: { uri: lightUri, cid: "bafylight" } });
908
908
-
expect(call.record.defaultLightTheme).toEqual({ theme: { uri: lightUri, cid: "bafylight" } });
909
909
-
expect(call.record.defaultDarkTheme).toEqual({ theme: { uri: darkUri, cid: "bafydark" } });
910
910
-
expect(call.record.allowUserChoice).toBe(true);
911
911
-
});
912
912
-
913
913
-
it("defaults allowUserChoice to true when not provided", async () => {
914
914
-
const { allowUserChoice: _, ...bodyWithout } = validBody;
915
915
-
await app.request("/api/admin/theme-policy", {
916
916
-
method: "PUT",
917
917
-
headers: { "Content-Type": "application/json" },
918
918
-
body: JSON.stringify(bodyWithout),
919
919
-
});
920
920
-
const call = mockPutRecord.mock.calls[0][0];
921
921
-
expect(call.record.allowUserChoice).toBe(true);
922
922
-
});
923
923
-
924
924
-
it("returns 400 when availableThemes is missing", async () => {
925
925
-
const { availableThemes: _, ...bodyWithout } = validBody;
926
926
-
const res = await app.request("/api/admin/theme-policy", {
927
927
-
method: "PUT",
928
928
-
headers: { "Content-Type": "application/json" },
929
929
-
body: JSON.stringify(bodyWithout),
930
930
-
});
931
931
-
expect(res.status).toBe(400);
932
932
-
const body = await res.json();
933
933
-
expect(body.error).toMatch(/availableThemes/i);
934
934
-
});
935
935
-
936
936
-
it("returns 400 when availableThemes is empty array", async () => {
937
937
-
const res = await app.request("/api/admin/theme-policy", {
938
938
-
method: "PUT",
939
939
-
headers: { "Content-Type": "application/json" },
940
940
-
body: JSON.stringify({ ...validBody, availableThemes: [] }),
941
941
-
});
942
942
-
expect(res.status).toBe(400);
943
943
-
});
944
944
-
945
945
-
it("returns 400 when availableThemes item is missing cid", async () => {
946
946
-
const res = await app.request("/api/admin/theme-policy", {
947
947
-
method: "PUT",
948
948
-
headers: { "Content-Type": "application/json" },
949
949
-
body: JSON.stringify({
950
950
-
...validBody,
951
951
-
availableThemes: [{ uri: lightUri }], // missing cid
952
952
-
defaultLightThemeUri: lightUri,
953
953
-
defaultDarkThemeUri: lightUri,
954
954
-
}),
955
955
-
});
956
956
-
expect(res.status).toBe(400);
957
957
-
});
958
958
-
959
959
-
it("returns 400 when defaultLightThemeUri is not in availableThemes", async () => {
960
960
-
const res = await app.request("/api/admin/theme-policy", {
961
961
-
method: "PUT",
962
962
-
headers: { "Content-Type": "application/json" },
963
963
-
body: JSON.stringify({
964
964
-
...validBody,
965
965
-
defaultLightThemeUri: "at://did:plc:test-forum/space.atbb.forum.theme/notinlist",
966
966
-
}),
967
967
-
});
968
968
-
expect(res.status).toBe(400);
969
969
-
const body = await res.json();
970
970
-
expect(body.error).toMatch(/defaultLightThemeUri/i);
971
971
-
});
972
972
-
973
973
-
it("returns 400 when defaultDarkThemeUri is not in availableThemes", async () => {
974
974
-
const res = await app.request("/api/admin/theme-policy", {
975
975
-
method: "PUT",
976
976
-
headers: { "Content-Type": "application/json" },
977
977
-
body: JSON.stringify({
978
978
-
...validBody,
979
979
-
defaultDarkThemeUri: "at://did:plc:test-forum/space.atbb.forum.theme/notinlist",
980
980
-
}),
981
981
-
});
982
982
-
expect(res.status).toBe(400);
983
983
-
const body = await res.json();
984
984
-
expect(body.error).toMatch(/defaultDarkThemeUri/i);
985
985
-
});
986
986
-
987
987
-
it("returns 400 when defaultLightThemeUri is missing", async () => {
988
988
-
const { defaultLightThemeUri: _, ...bodyWithout } = validBody;
989
989
-
const res = await app.request("/api/admin/theme-policy", {
990
990
-
method: "PUT",
991
991
-
headers: { "Content-Type": "application/json" },
992
992
-
body: JSON.stringify(bodyWithout),
993
993
-
});
994
994
-
expect(res.status).toBe(400);
995
995
-
});
996
996
-
997
997
-
it("returns 400 when defaultDarkThemeUri is missing", async () => {
998
998
-
const { defaultDarkThemeUri: _, ...bodyWithout } = validBody;
999
999
-
const res = await app.request("/api/admin/theme-policy", {
1000
1000
-
method: "PUT",
1001
1001
-
headers: { "Content-Type": "application/json" },
1002
1002
-
body: JSON.stringify(bodyWithout),
1003
1003
-
});
1004
1004
-
expect(res.status).toBe(400);
1005
1005
-
});
1006
1006
-
1007
1007
-
it("returns 503 when ForumAgent is not configured", async () => {
1008
1008
-
ctx.forumAgent = null;
1009
1009
-
const res = await app.request("/api/admin/theme-policy", {
1010
1010
-
method: "PUT",
1011
1011
-
headers: { "Content-Type": "application/json" },
1012
1012
-
body: JSON.stringify(validBody),
1013
1013
-
});
1014
1014
-
expect(res.status).toBe(503);
1015
1015
-
});
1016
1016
-
});
1017
1017
-
```
1018
1018
-
1019
1019
-
**Step 2: Run to verify tests fail**
1020
1020
-
1021
1021
-
```bash
1022
1022
-
PATH=$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/admin.test.ts
1023
1023
-
```
1024
1024
-
1025
1025
-
Expected: PUT /admin/theme-policy tests fail with 404.
1026
1026
-
1027
1027
-
**Step 3: Implement PUT /theme-policy handler in `admin.ts`**
1028
1028
-
1029
1029
-
Add after the DELETE `/themes/:rkey` handler (and before GET `/modlog`):
1030
1030
-
1031
1031
-
```typescript
1032
1032
-
/**
1033
1033
-
* PUT /api/admin/theme-policy
1034
1034
-
*
1035
1035
-
* Create or update the themePolicy singleton (rkey: "self") on Forum DID's PDS.
1036
1036
-
* Upsert semantics: works whether or not a policy record exists yet.
1037
1037
-
* The firehose indexer creates/updates the DB row asynchronously.
1038
1038
-
*/
1039
1039
-
app.put(
1040
1040
-
"/theme-policy",
1041
1041
-
requireAuth(ctx),
1042
1042
-
requirePermission(ctx, "space.atbb.permission.manageThemes"),
1043
1043
-
async (c) => {
1044
1044
-
const { body, error: parseError } = await safeParseJsonBody(c);
1045
1045
-
if (parseError) return parseError;
1046
1046
-
1047
1047
-
const { availableThemes, defaultLightThemeUri, defaultDarkThemeUri, allowUserChoice } = body;
1048
1048
-
1049
1049
-
if (!Array.isArray(availableThemes) || availableThemes.length === 0) {
1050
1050
-
return c.json({ error: "availableThemes is required and must be a non-empty array" }, 400);
1051
1051
-
}
1052
1052
-
for (const t of availableThemes as unknown[]) {
1053
1053
-
if (
1054
1054
-
typeof (t as any)?.uri !== "string" ||
1055
1055
-
typeof (t as any)?.cid !== "string"
1056
1056
-
) {
1057
1057
-
return c.json({ error: "Each availableThemes entry must have uri and cid string fields" }, 400);
1058
1058
-
}
1059
1059
-
}
1060
1060
-
1061
1061
-
if (typeof defaultLightThemeUri !== "string" || !defaultLightThemeUri.startsWith("at://")) {
1062
1062
-
return c.json({ error: "defaultLightThemeUri is required and must be an AT-URI" }, 400);
1063
1063
-
}
1064
1064
-
if (typeof defaultDarkThemeUri !== "string" || !defaultDarkThemeUri.startsWith("at://")) {
1065
1065
-
return c.json({ error: "defaultDarkThemeUri is required and must be an AT-URI" }, 400);
1066
1066
-
}
1067
1067
-
1068
1068
-
const availableUris = (availableThemes as Array<{ uri: string; cid: string }>).map((t) => t.uri);
1069
1069
-
if (!availableUris.includes(defaultLightThemeUri)) {
1070
1070
-
return c.json({ error: "defaultLightThemeUri must be present in availableThemes" }, 400);
1071
1071
-
}
1072
1072
-
if (!availableUris.includes(defaultDarkThemeUri)) {
1073
1073
-
return c.json({ error: "defaultDarkThemeUri must be present in availableThemes" }, 400);
1074
1074
-
}
1075
1075
-
1076
1076
-
const resolvedAllowUserChoice = typeof allowUserChoice === "boolean" ? allowUserChoice : true;
1077
1077
-
1078
1078
-
const typedAvailableThemes = availableThemes as Array<{ uri: string; cid: string }>;
1079
1079
-
const lightTheme = typedAvailableThemes.find((t) => t.uri === defaultLightThemeUri)!;
1080
1080
-
const darkTheme = typedAvailableThemes.find((t) => t.uri === defaultDarkThemeUri)!;
1081
1081
-
1082
1082
-
const { agent, error: agentError } = getForumAgentOrError(ctx, c, "PUT /api/admin/theme-policy");
1083
1083
-
if (agentError) return agentError;
1084
1084
-
1085
1085
-
try {
1086
1086
-
const result = await agent.com.atproto.repo.putRecord({
1087
1087
-
repo: ctx.config.forumDid,
1088
1088
-
collection: "space.atbb.forum.themePolicy",
1089
1089
-
rkey: "self",
1090
1090
-
record: {
1091
1091
-
$type: "space.atbb.forum.themePolicy",
1092
1092
-
availableThemes: typedAvailableThemes.map((t) => ({
1093
1093
-
theme: { uri: t.uri, cid: t.cid },
1094
1094
-
})),
1095
1095
-
defaultLightTheme: { theme: { uri: lightTheme.uri, cid: lightTheme.cid } },
1096
1096
-
defaultDarkTheme: { theme: { uri: darkTheme.uri, cid: darkTheme.cid } },
1097
1097
-
allowUserChoice: resolvedAllowUserChoice,
1098
1098
-
updatedAt: new Date().toISOString(),
1099
1099
-
},
1100
1100
-
});
1101
1101
-
1102
1102
-
return c.json({ uri: result.data.uri, cid: result.data.cid });
1103
1103
-
} catch (error) {
1104
1104
-
return handleRouteError(c, error, "Failed to update theme policy", {
1105
1105
-
operation: "PUT /api/admin/theme-policy",
1106
1106
-
logger: ctx.logger,
1107
1107
-
});
1108
1108
-
}
1109
1109
-
}
1110
1110
-
);
1111
1111
-
```
1112
1112
-
1113
1113
-
**Step 4: Run tests to verify they pass**
1114
1114
-
1115
1115
-
```bash
1116
1116
-
PATH=$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/admin.test.ts
1117
1117
-
```
1118
1118
-
1119
1119
-
Expected: all theme-policy tests pass.
1120
1120
-
1121
1121
-
**Step 5: Run full test suite**
1122
1122
-
1123
1123
-
```bash
1124
1124
-
PATH=$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm --filter @atbb/appview exec vitest run
1125
1125
-
```
1126
1126
-
1127
1127
-
Expected: all tests pass.
1128
1128
-
1129
1129
-
**Step 6: Commit**
1130
1130
-
1131
1131
-
```bash
1132
1132
-
git add apps/appview/src/routes/admin.ts apps/appview/src/routes/__tests__/admin.test.ts
1133
1133
-
git commit -m "feat(appview): PUT /api/admin/theme-policy — upsert policy singleton on Forum PDS (ATB-57)"
1134
1134
-
```
1135
1135
-
1136
1136
-
---
1137
1137
-
1138
1138
-
## Task 7: Add Bruno collection files
1139
1139
-
1140
1140
-
**Files:**
1141
1141
-
- Create: `bruno/AppView API/Admin Themes/Create Theme.bru`
1142
1142
-
- Create: `bruno/AppView API/Admin Themes/Update Theme.bru`
1143
1143
-
- Create: `bruno/AppView API/Admin Themes/Delete Theme.bru`
1144
1144
-
- Create: `bruno/AppView API/Admin Themes/Update Theme Policy.bru`
1145
1145
-
1146
1146
-
**Step 1: Create directory and files**
1147
1147
-
1148
1148
-
Create `bruno/AppView API/Admin Themes/Create Theme.bru`:
1149
1149
-
1150
1150
-
```bru
1151
1151
-
meta {
1152
1152
-
name: Create Theme
1153
1153
-
type: http
1154
1154
-
seq: 1
1155
1155
-
}
1156
1156
-
1157
1157
-
post {
1158
1158
-
url: {{appview_url}}/api/admin/themes
1159
1159
-
}
1160
1160
-
1161
1161
-
body:json {
1162
1162
-
{
1163
1163
-
"name": "Neobrutal Light",
1164
1164
-
"colorScheme": "light",
1165
1165
-
"tokens": {
1166
1166
-
"color-bg": "#f5f0e8",
1167
1167
-
"color-text": "#1a1a1a",
1168
1168
-
"color-primary": "#ff5c00"
1169
1169
-
},
1170
1170
-
"fontUrls": ["https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;700"]
1171
1171
-
}
1172
1172
-
}
1173
1173
-
1174
1174
-
assert {
1175
1175
-
res.status: eq 201
1176
1176
-
res.body.uri: isDefined
1177
1177
-
res.body.cid: isDefined
1178
1178
-
}
1179
1179
-
1180
1180
-
docs {
1181
1181
-
Create a new theme record on the Forum DID's PDS.
1182
1182
-
The firehose indexer creates the DB row asynchronously.
1183
1183
-
1184
1184
-
**Requires:** space.atbb.permission.manageThemes
1185
1185
-
1186
1186
-
Body:
1187
1187
-
- name (required): Theme display name, non-empty
1188
1188
-
- colorScheme (required): "light" or "dark"
1189
1189
-
- tokens (required): Plain object of CSS design token key-value pairs. Values must be strings.
1190
1190
-
- cssOverrides (optional): Raw CSS string for structural overrides (not rendered until ATB-62 sanitization ships)
1191
1191
-
- fontUrls (optional): Array of HTTPS URLs for font stylesheets
1192
1192
-
1193
1193
-
Returns (201):
1194
1194
-
{
1195
1195
-
"uri": "at://did:plc:.../space.atbb.forum.theme/abc123",
1196
1196
-
"cid": "bafyrei..."
1197
1197
-
}
1198
1198
-
1199
1199
-
Error codes:
1200
1200
-
- 400: Missing name/colorScheme/tokens, invalid colorScheme, non-HTTPS fontUrl, token value not a string, malformed JSON
1201
1201
-
- 401: Not authenticated
1202
1202
-
- 403: Missing manageThemes permission
1203
1203
-
- 503: ForumAgent not configured or PDS network error
1204
1204
-
}
1205
1205
-
```
1206
1206
-
1207
1207
-
Create `bruno/AppView API/Admin Themes/Update Theme.bru`:
1208
1208
-
1209
1209
-
```bru
1210
1210
-
meta {
1211
1211
-
name: Update Theme
1212
1212
-
type: http
1213
1213
-
seq: 2
1214
1214
-
}
1215
1215
-
1216
1216
-
put {
1217
1217
-
url: {{appview_url}}/api/admin/themes/{{theme_rkey}}
1218
1218
-
}
1219
1219
-
1220
1220
-
body:json {
1221
1221
-
{
1222
1222
-
"name": "Neobrutal Light (Updated)",
1223
1223
-
"colorScheme": "light",
1224
1224
-
"tokens": {
1225
1225
-
"color-bg": "#f5f0e8",
1226
1226
-
"color-text": "#1a1a1a",
1227
1227
-
"color-primary": "#ff5c00"
1228
1228
-
}
1229
1229
-
}
1230
1230
-
}
1231
1231
-
1232
1232
-
assert {
1233
1233
-
res.status: eq 200
1234
1234
-
res.body.uri: isDefined
1235
1235
-
res.body.cid: isDefined
1236
1236
-
}
1237
1237
-
1238
1238
-
docs {
1239
1239
-
Update an existing theme record. Full replacement of the PDS record.
1240
1240
-
Optional fields (cssOverrides, fontUrls) fall back to their existing values
1241
1241
-
when omitted from the request body.
1242
1242
-
1243
1243
-
**Requires:** space.atbb.permission.manageThemes
1244
1244
-
1245
1245
-
Path params:
1246
1246
-
- rkey: Theme record key (TID)
1247
1247
-
1248
1248
-
Body: same as Create Theme (all fields).
1249
1249
-
1250
1250
-
Returns (200):
1251
1251
-
{
1252
1252
-
"uri": "at://did:plc:.../space.atbb.forum.theme/abc123",
1253
1253
-
"cid": "bafyrei..."
1254
1254
-
}
1255
1255
-
1256
1256
-
Error codes:
1257
1257
-
- 400: Invalid input (same as Create Theme)
1258
1258
-
- 401: Not authenticated
1259
1259
-
- 403: Missing manageThemes permission
1260
1260
-
- 404: Theme not found
1261
1261
-
- 503: ForumAgent not configured or PDS network error
1262
1262
-
}
1263
1263
-
```
1264
1264
-
1265
1265
-
Create `bruno/AppView API/Admin Themes/Delete Theme.bru`:
1266
1266
-
1267
1267
-
```bru
1268
1268
-
meta {
1269
1269
-
name: Delete Theme
1270
1270
-
type: http
1271
1271
-
seq: 3
1272
1272
-
}
1273
1273
-
1274
1274
-
delete {
1275
1275
-
url: {{appview_url}}/api/admin/themes/{{theme_rkey}}
1276
1276
-
}
1277
1277
-
1278
1278
-
assert {
1279
1279
-
res.status: eq 200
1280
1280
-
res.body.success: eq true
1281
1281
-
}
1282
1282
-
1283
1283
-
docs {
1284
1284
-
Delete a theme record. Fails with 409 if the theme is currently set as
1285
1285
-
the defaultLightTheme or defaultDarkTheme in the theme policy.
1286
1286
-
1287
1287
-
**Requires:** space.atbb.permission.manageThemes
1288
1288
-
1289
1289
-
Path params:
1290
1290
-
- rkey: Theme record key (TID)
1291
1291
-
1292
1292
-
Returns (200):
1293
1293
-
{
1294
1294
-
"success": true
1295
1295
-
}
1296
1296
-
1297
1297
-
Error codes:
1298
1298
-
- 401: Not authenticated
1299
1299
-
- 403: Missing manageThemes permission
1300
1300
-
- 404: Theme not found
1301
1301
-
- 409: Theme is the current defaultLightTheme or defaultDarkTheme — update theme policy first
1302
1302
-
- 503: ForumAgent not configured or PDS network error
1303
1303
-
}
1304
1304
-
```
1305
1305
-
1306
1306
-
Create `bruno/AppView API/Admin Themes/Update Theme Policy.bru`:
1307
1307
-
1308
1308
-
```bru
1309
1309
-
meta {
1310
1310
-
name: Update Theme Policy
1311
1311
-
type: http
1312
1312
-
seq: 4
1313
1313
-
}
1314
1314
-
1315
1315
-
put {
1316
1316
-
url: {{appview_url}}/api/admin/theme-policy
1317
1317
-
}
1318
1318
-
1319
1319
-
body:json {
1320
1320
-
{
1321
1321
-
"availableThemes": [
1322
1322
-
{ "uri": "at://did:plc:example/space.atbb.forum.theme/3lbllight1", "cid": "bafylight" },
1323
1323
-
{ "uri": "at://did:plc:example/space.atbb.forum.theme/3lbldark11", "cid": "bafydark" }
1324
1324
-
],
1325
1325
-
"defaultLightThemeUri": "at://did:plc:example/space.atbb.forum.theme/3lbllight1",
1326
1326
-
"defaultDarkThemeUri": "at://did:plc:example/space.atbb.forum.theme/3lbldark11",
1327
1327
-
"allowUserChoice": true
1328
1328
-
}
1329
1329
-
}
1330
1330
-
1331
1331
-
assert {
1332
1332
-
res.status: eq 200
1333
1333
-
res.body.uri: isDefined
1334
1334
-
res.body.cid: isDefined
1335
1335
-
}
1336
1336
-
1337
1337
-
docs {
1338
1338
-
Create or update the themePolicy singleton on the Forum DID's PDS.
1339
1339
-
Uses upsert semantics: works whether or not a policy record exists yet.
1340
1340
-
1341
1341
-
**Requires:** space.atbb.permission.manageThemes
1342
1342
-
1343
1343
-
Body:
1344
1344
-
- availableThemes (required): Non-empty array of { uri, cid } theme references.
1345
1345
-
Both defaultLightThemeUri and defaultDarkThemeUri must be present in this list.
1346
1346
-
- defaultLightThemeUri (required): AT-URI of the default light-mode theme.
1347
1347
-
Must be in availableThemes.
1348
1348
-
- defaultDarkThemeUri (required): AT-URI of the default dark-mode theme.
1349
1349
-
Must be in availableThemes.
1350
1350
-
- allowUserChoice (optional, default true): Whether users can pick their own theme.
1351
1351
-
1352
1352
-
Returns (200):
1353
1353
-
{
1354
1354
-
"uri": "at://did:plc:.../space.atbb.forum.themePolicy/self",
1355
1355
-
"cid": "bafyrei..."
1356
1356
-
}
1357
1357
-
1358
1358
-
Error codes:
1359
1359
-
- 400: Missing/empty availableThemes, missing defaultLightThemeUri/defaultDarkThemeUri,
1360
1360
-
default URI not in availableThemes list, malformed JSON
1361
1361
-
- 401: Not authenticated
1362
1362
-
- 403: Missing manageThemes permission
1363
1363
-
- 503: ForumAgent not configured or PDS network error
1364
1364
-
}
1365
1365
-
```
1366
1366
-
1367
1367
-
**Step 2: Verify the collection directory exists and files are created**
1368
1368
-
1369
1369
-
```bash
1370
1370
-
ls "bruno/AppView API/Admin Themes/"
1371
1371
-
```
1372
1372
-
1373
1373
-
Expected: 4 `.bru` files listed.
1374
1374
-
1375
1375
-
**Step 3: Run full test suite one final time**
1376
1376
-
1377
1377
-
```bash
1378
1378
-
PATH=$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm --filter @atbb/appview exec vitest run
1379
1379
-
```
1380
1380
-
1381
1381
-
Expected: all tests pass.
1382
1382
-
1383
1383
-
**Step 4: Commit**
1384
1384
-
1385
1385
-
```bash
1386
1386
-
git add "bruno/AppView API/Admin Themes/"
1387
1387
-
git commit -m "docs(bruno): add Admin Themes collection for ATB-57 write endpoints"
1388
1388
-
```
1389
1389
-
1390
1390
-
---
1391
1391
-
1392
1392
-
## Task 8: Linear + plan doc update
1393
1393
-
1394
1394
-
**Step 1: Mark plan doc complete**
1395
1395
-
1396
1396
-
In `docs/plans/2026-03-02-atb-57-theme-write-api.md`, update the status line to:
1397
1397
-
```
1398
1398
-
**Status:** Complete (ATB-57)
1399
1399
-
```
1400
1400
-
1401
1401
-
Rename/move the plan doc to `docs/plans/complete/`:
1402
1402
-
```bash
1403
1403
-
mv docs/plans/2026-03-02-atb-57-theme-write-api.md docs/plans/complete/2026-03-02-atb-57-theme-write-api.md
1404
1404
-
mv docs/plans/2026-03-02-theme-write-api-design.md docs/plans/complete/2026-03-02-theme-write-api-design.md
1405
1405
-
```
1406
1406
-
1407
1407
-
**Step 2: Commit plan doc move**
1408
1408
-
1409
1409
-
```bash
1410
1410
-
git add docs/plans/
1411
1411
-
git commit -m "docs: mark ATB-57 plan docs complete, move to docs/plans/complete/"
1412
1412
-
```
1413
1413
-
1414
1414
-
**Step 3: Update Linear**
1415
1415
-
1416
1416
-
- Set ATB-57 status to **Done**
1417
1417
-
- Add a comment: "Implemented POST/PUT/DELETE /api/admin/themes and PUT /api/admin/theme-policy in admin.ts. Added manageThemes permission to Admin role in seed-roles.ts. Bruno collection added under Admin Themes/. All tests pass."
-178
docs/plans/2026-03-02-theme-write-api-design.md
···
1
1
-
# Theme Write API Endpoints — Design
2
2
-
3
3
-
**Linear:** ATB-57
4
4
-
**Date:** 2026-03-02
5
5
-
**Status:** Approved, ready for implementation
6
6
-
7
7
-
---
8
8
-
9
9
-
## Context
10
10
-
11
11
-
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.
12
12
-
13
13
-
**Depends on:** ATB-51 (theme lexicons), ATB-55 (theme read endpoints + DB tables)
14
14
-
15
15
-
---
16
16
-
17
17
-
## Route Placement
18
18
-
19
19
-
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.
20
20
-
21
21
-
---
22
22
-
23
23
-
## Endpoints
24
24
-
25
25
-
| Method | Path | Permission |
26
26
-
|--------|------|-----------|
27
27
-
| `POST` | `/api/admin/themes` | `space.atbb.permission.manageThemes` |
28
28
-
| `PUT` | `/api/admin/themes/:rkey` | `space.atbb.permission.manageThemes` |
29
29
-
| `DELETE` | `/api/admin/themes/:rkey` | `space.atbb.permission.manageThemes` |
30
30
-
| `PUT` | `/api/admin/theme-policy` | `space.atbb.permission.manageThemes` |
31
31
-
32
32
-
---
33
33
-
34
34
-
## Permission Changes
35
35
-
36
36
-
Add `space.atbb.permission.manageThemes` to `apps/appview/src/lib/seed-roles.ts`:
37
37
-
38
38
-
- **Owner**: already has `"*"` wildcard — no change
39
39
-
- **Admin**: add `manageThemes` to the permissions array
40
40
-
- **Moderator / Member**: no change
41
41
-
42
42
-
---
43
43
-
44
44
-
## Input Validation
45
45
-
46
46
-
### Theme (POST and PUT)
47
47
-
48
48
-
| Field | Rule |
49
49
-
|-------|------|
50
50
-
| `name` | Required string, non-empty, ≤ 100 graphemes |
51
51
-
| `colorScheme` | Required, must be `"light"` or `"dark"` |
52
52
-
| `tokens` | Required, must be a non-null object; values must be strings |
53
53
-
| `cssOverrides` | Optional string (do NOT render until ATB-62 CSS sanitization ships) |
54
54
-
| `fontUrls` | Optional array of strings; each must start with `"https://"` |
55
55
-
56
56
-
Token keys are **not** validated against a known list (lenient mode — allows custom/future tokens).
57
57
-
58
58
-
### Theme Policy (PUT)
59
59
-
60
60
-
| Field | Rule |
61
61
-
|-------|------|
62
62
-
| `availableThemes` | Required non-empty array of `{ uri: string, cid: string }` |
63
63
-
| `defaultLightThemeUri` | Required string; must be an AT-URI present in `availableThemes` |
64
64
-
| `defaultDarkThemeUri` | Required string; must be an AT-URI present in `availableThemes` |
65
65
-
| `allowUserChoice` | Optional boolean, defaults `true` |
66
66
-
67
67
-
---
68
68
-
69
69
-
## Endpoint Details
70
70
-
71
71
-
### `POST /api/admin/themes`
72
72
-
73
73
-
1. Parse and validate request body
74
74
-
2. Get ForumAgent (return 503 if unavailable)
75
75
-
3. Generate `rkey = TID.nextStr()`
76
76
-
4. `putRecord` on Forum DID's PDS with `collection: "space.atbb.forum.theme"`
77
77
-
5. Return `{ uri, cid }` with `201`
78
78
-
79
79
-
Does not wait for firehose indexing — the PDS write is the authoritative action.
80
80
-
81
81
-
### `PUT /api/admin/themes/:rkey`
82
82
-
83
83
-
1. Parse and validate request body
84
84
-
2. Look up existing theme by `rkey` + `forumDid` in DB (404 if missing)
85
85
-
3. Get ForumAgent
86
86
-
4. `putRecord` with same rkey, preserving `createdAt` from DB row
87
87
-
5. Optional fields (`cssOverrides`, `fontUrls`, `description`) fall back to existing DB values if not provided in request
88
88
-
6. Return `{ uri, cid }` with `200`
89
89
-
90
90
-
### `DELETE /api/admin/themes/:rkey`
91
91
-
92
92
-
1. Look up theme in DB (404 if missing)
93
93
-
2. Pre-flight conflict check: query `theme_policies` for rows where `default_light_theme_uri` OR `default_dark_theme_uri` = this theme's AT-URI
94
94
-
3. Return `409` if any match
95
95
-
4. Get ForumAgent
96
96
-
5. `deleteRecord` on Forum DID's PDS
97
97
-
6. Return `{ success: true }` with `200`
98
98
-
99
99
-
### `PUT /api/admin/theme-policy`
100
100
-
101
101
-
Upsert semantics (creates if no policy row exists yet, updates if one does).
102
102
-
103
103
-
1. Parse and validate request body
104
104
-
2. Validate `defaultLightThemeUri` is present in `availableThemes` (400 if not)
105
105
-
3. Validate `defaultDarkThemeUri` is present in `availableThemes` (400 if not)
106
106
-
4. Get ForumAgent
107
107
-
5. `putRecord` with `rkey: "self"`, `collection: "space.atbb.forum.themePolicy"`
108
108
-
6. PDS record structure follows the `themeRef` wrapper pattern from the lexicon: `{ theme: { uri, cid } }`
109
109
-
7. Return `{ uri, cid }` with `200`
110
110
-
111
111
-
---
112
112
-
113
113
-
## Error Codes
114
114
-
115
115
-
| Status | Condition |
116
116
-
|--------|-----------|
117
117
-
| 400 | Invalid/missing input field, invalid colorScheme, non-HTTPS fontUrl, default theme not in availableThemes |
118
118
-
| 401 | Not authenticated |
119
119
-
| 403 | Caller lacks `manageThemes` permission |
120
120
-
| 404 | Theme rkey not found (PUT/DELETE) |
121
121
-
| 409 | DELETE attempted on a theme that is the current policy default |
122
122
-
| 503 | DB or PDS connectivity error |
123
123
-
124
124
-
---
125
125
-
126
126
-
## Tests
127
127
-
128
128
-
### `POST /api/admin/themes`
129
129
-
- Happy path: returns 201 with uri and cid
130
130
-
- Missing `name` → 400
131
131
-
- Empty `name` → 400
132
132
-
- `name` too long (> 100 graphemes) → 400
133
133
-
- Invalid `colorScheme` (not light/dark) → 400
134
134
-
- Missing `colorScheme` → 400
135
135
-
- `tokens` not an object → 400
136
136
-
- Missing `tokens` → 400
137
137
-
- Non-HTTPS fontUrl → 400
138
138
-
- Permission denied (no manageThemes) → 403
139
139
-
- Unauthenticated → 401
140
140
-
- PDS/DB error → 503
141
141
-
142
142
-
### `PUT /api/admin/themes/:rkey`
143
143
-
- Happy path: updates theme, returns 200
144
144
-
- Partial update (no cssOverrides in body) preserves existing cssOverrides
145
145
-
- Unknown rkey → 404
146
146
-
- Same input validation failures as POST → 400
147
147
-
- Permission denied → 403
148
148
-
149
149
-
### `DELETE /api/admin/themes/:rkey`
150
150
-
- Happy path: deletes theme, returns 200
151
151
-
- Unknown rkey → 404
152
152
-
- Theme is defaultLightTheme in policy → 409
153
153
-
- Theme is defaultDarkTheme in policy → 409
154
154
-
- Permission denied → 403
155
155
-
156
156
-
### `PUT /api/admin/theme-policy`
157
157
-
- Happy path create (no existing policy): returns 200
158
158
-
- Happy path update (policy already exists): returns 200
159
159
-
- `defaultLightThemeUri` not in `availableThemes` → 400
160
160
-
- `defaultDarkThemeUri` not in `availableThemes` → 400
161
161
-
- Missing `availableThemes` → 400
162
162
-
- Empty `availableThemes` array → 400
163
163
-
- Missing `defaultLightThemeUri` → 400
164
164
-
- Missing `defaultDarkThemeUri` → 400
165
165
-
- Permission denied → 403
166
166
-
167
167
-
---
168
168
-
169
169
-
## Bruno Collection
170
170
-
171
171
-
New files in `bruno/AppView API/Admin Themes/`:
172
172
-
173
173
-
- `Create Theme.bru`
174
174
-
- `Update Theme.bru`
175
175
-
- `Delete Theme.bru`
176
176
-
- `Update Theme Policy.bru`
177
177
-
178
178
-
All use `{{appview_url}}` for the base URL and include error code documentation.