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
1import { Hono } from "hono";
2import type { AppContext } from "../lib/app-context.js";
3import type { Variables } from "../types.js";
4import { requireAuth } from "../middleware/auth.js";
5import { requirePermission, requireAnyPermission, getUserRole } from "../middleware/permissions.js";
6import { memberships, roles, rolePermissions, users, forums, backfillProgress, backfillErrors, categories, boards, posts, modActions, themes, themePolicies } from "@atbb/db";
7import { eq, and, sql, asc, desc, count, inArray, or } from "drizzle-orm";
8import { isProgrammingError } from "../lib/errors.js";
9import { BackfillStatus } from "../lib/backfill-manager.js";
10import { CursorManager } from "../lib/cursor-manager.js";
11import {
12 handleRouteError,
13 safeParseJsonBody,
14 getForumAgentOrError,
15} from "../lib/route-errors.js";
16import { TID } from "@atproto/common-web";
17import { parseBigIntParam, serializeBigInt, serializeDate } from "./helpers.js";
18import { sanitizeCssOverrides } from "@atbb/css-sanitizer";
19
20export function createAdminRoutes(ctx: AppContext) {
21 const app = new Hono<{ Variables: Variables }>();
22
23 /**
24 * POST /api/admin/members/:did/role
25 *
26 * Assign a role to a forum member.
27 */
28 app.post(
29 "/members/:did/role",
30 requireAuth(ctx),
31 requirePermission(ctx, "space.atbb.permission.manageRoles"),
32 async (c) => {
33 const targetDid = c.req.param("did");
34 const user = c.get("user")!;
35
36 // Parse and validate request body
37 const { body, error: parseError } = await safeParseJsonBody(c);
38 if (parseError) return parseError;
39
40 const { roleUri } = body;
41
42 if (typeof roleUri !== "string") {
43 return c.json({ error: "roleUri is required and must be a string" }, 400);
44 }
45
46 // Validate roleUri format
47 if (!roleUri.startsWith("at://") || !roleUri.includes("/space.atbb.forum.role/")) {
48 return c.json({ error: "Invalid roleUri format" }, 400);
49 }
50
51 // Extract role rkey from roleUri
52 const roleRkey = roleUri.split("/").pop();
53 if (!roleRkey) {
54 return c.json({ error: "Invalid roleUri format" }, 400);
55 }
56
57 try {
58 // Validate role exists
59 const [role] = await ctx.db
60 .select()
61 .from(roles)
62 .where(
63 and(
64 eq(roles.did, ctx.config.forumDid),
65 eq(roles.rkey, roleRkey)
66 )
67 )
68 .limit(1);
69
70 if (!role) {
71 return c.json({ error: "Role not found" }, 404);
72 }
73
74 // Priority check: Can't assign role with equal or higher authority
75 const assignerRole = await getUserRole(ctx, user.did);
76 if (!assignerRole) {
77 return c.json({ error: "You do not have a role assigned" }, 403);
78 }
79
80 if (role.priority <= assignerRole.priority) {
81 return c.json({
82 error: "Cannot assign role with equal or higher authority",
83 }, 403);
84 }
85
86 // Get target user's membership
87 const [membership] = await ctx.db
88 .select()
89 .from(memberships)
90 .where(eq(memberships.did, targetDid))
91 .limit(1);
92
93 if (!membership) {
94 return c.json({ error: "User is not a member of this forum" }, 404);
95 }
96
97 // Fetch forum CID for membership record
98 const [forum] = await ctx.db
99 .select({ cid: forums.cid })
100 .from(forums)
101 .where(eq(forums.did, ctx.config.forumDid))
102 .limit(1);
103
104 if (!forum) {
105 return c.json({ error: "Forum record not found in database" }, 500);
106 }
107
108 // Get ForumAgent for PDS write operations
109 const { agent, error: agentError } = getForumAgentOrError(ctx, c, "POST /api/admin/members/:did/role");
110 if (agentError) return agentError;
111
112 try {
113 // Update membership record on user's PDS using ForumAgent
114 await agent.com.atproto.repo.putRecord({
115 repo: targetDid,
116 collection: "space.atbb.membership",
117 rkey: membership.rkey,
118 record: {
119 $type: "space.atbb.membership",
120 forum: { forum: { uri: membership.forumUri, cid: forum.cid } },
121 role: { role: { uri: roleUri, cid: role.cid } },
122 joinedAt: membership.joinedAt?.toISOString(),
123 createdAt: membership.createdAt.toISOString(),
124 },
125 });
126
127 return c.json({
128 success: true,
129 roleAssigned: role.name,
130 targetDid,
131 });
132 } catch (error) {
133 return handleRouteError(c, error, "Failed to assign role", {
134 operation: "POST /api/admin/members/:did/role",
135 logger: ctx.logger,
136 targetDid,
137 roleUri,
138 });
139 }
140 } catch (error) {
141 return handleRouteError(c, error, "Failed to process role assignment", {
142 operation: "POST /api/admin/members/:did/role",
143 logger: ctx.logger,
144 targetDid,
145 roleUri,
146 });
147 }
148 }
149 );
150
151 /**
152 * GET /api/admin/roles
153 *
154 * List all available roles for the forum.
155 */
156 app.get(
157 "/roles",
158 requireAuth(ctx),
159 requirePermission(ctx, "space.atbb.permission.manageRoles"),
160 async (c) => {
161 try {
162 const rolesList = await ctx.db
163 .select({
164 id: roles.id,
165 name: roles.name,
166 description: roles.description,
167 priority: roles.priority,
168 rkey: roles.rkey,
169 did: roles.did,
170 })
171 .from(roles)
172 .where(eq(roles.did, ctx.config.forumDid))
173 .orderBy(asc(roles.priority));
174
175 const rolesWithPermissions = await Promise.all(
176 rolesList.map(async (role) => {
177 const perms = await ctx.db
178 .select({ permission: rolePermissions.permission })
179 .from(rolePermissions)
180 .where(eq(rolePermissions.roleId, role.id));
181 return {
182 id: role.id.toString(),
183 name: role.name,
184 description: role.description,
185 permissions: perms.map((p) => p.permission),
186 priority: role.priority,
187 uri: `at://${role.did}/space.atbb.forum.role/${role.rkey}`,
188 };
189 })
190 );
191
192 return c.json({ roles: rolesWithPermissions });
193 } catch (error) {
194 return handleRouteError(c, error, "Failed to retrieve roles", {
195 operation: "GET /api/admin/roles",
196 logger: ctx.logger,
197 });
198 }
199 }
200 );
201
202 /**
203 * GET /api/admin/members
204 *
205 * List all forum members with their assigned roles.
206 */
207 app.get(
208 "/members",
209 requireAuth(ctx),
210 requirePermission(ctx, "space.atbb.permission.manageMembers"),
211 async (c) => {
212 try {
213 const membersList = await ctx.db
214 .select({
215 did: memberships.did,
216 handle: users.handle,
217 role: roles.name,
218 roleUri: memberships.roleUri,
219 joinedAt: memberships.joinedAt,
220 })
221 .from(memberships)
222 .leftJoin(users, eq(memberships.did, users.did))
223 .leftJoin(
224 roles,
225 sql`${memberships.roleUri} LIKE 'at://' || ${roles.did} || '/space.atbb.forum.role/' || ${roles.rkey}`
226 )
227 .where(eq(memberships.forumUri, `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`))
228 .orderBy(asc(roles.priority), asc(users.handle))
229 .limit(100);
230
231 return c.json({
232 members: membersList.map(member => ({
233 did: member.did,
234 handle: member.handle || member.did,
235 role: member.role || "Guest",
236 roleUri: member.roleUri,
237 joinedAt: member.joinedAt?.toISOString(),
238 })),
239 isTruncated: membersList.length === 100,
240 });
241 } catch (error) {
242 return handleRouteError(c, error, "Failed to retrieve members", {
243 operation: "GET /api/admin/members",
244 logger: ctx.logger,
245 });
246 }
247 }
248 );
249
250
251 /**
252 * GET /api/admin/members/me
253 *
254 * Returns the calling user's own membership, role name, and permissions.
255 * Any authenticated user may call this — no special permission required.
256 * Returns 404 if the user has no membership record for this forum.
257 */
258 app.get("/members/me", requireAuth(ctx), async (c) => {
259 const user = c.get("user")!;
260
261 try {
262 const forumUri = `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`;
263 const [member] = await ctx.db
264 .select({
265 did: memberships.did,
266 handle: users.handle,
267 roleUri: memberships.roleUri,
268 roleName: roles.name,
269 roleId: roles.id,
270 })
271 .from(memberships)
272 .leftJoin(users, eq(memberships.did, users.did))
273 .leftJoin(
274 roles,
275 sql`${memberships.roleUri} LIKE 'at://' || ${roles.did} || '/space.atbb.forum.role/' || ${roles.rkey}`
276 )
277 .where(
278 and(
279 eq(memberships.did, user.did),
280 eq(memberships.forumUri, forumUri)
281 )
282 )
283 .limit(1);
284
285 if (!member) {
286 return c.json({ error: "Membership not found" }, 404);
287 }
288
289 let permissions: string[] = [];
290 if (member.roleId) {
291 const perms = await ctx.db
292 .select({ permission: rolePermissions.permission })
293 .from(rolePermissions)
294 .where(eq(rolePermissions.roleId, member.roleId));
295 permissions = perms.map((p) => p.permission);
296 }
297
298 return c.json({
299 did: member.did,
300 handle: member.handle || user.did,
301 role: member.roleName || "Guest",
302 roleUri: member.roleUri,
303 permissions,
304 });
305 } catch (error) {
306 return handleRouteError(c, error, "Failed to retrieve your membership", {
307 operation: "GET /api/admin/members/me",
308 logger: ctx.logger,
309 did: user.did,
310 });
311 }
312 });
313
314 /**
315 * POST /api/admin/backfill
316 *
317 * Trigger a backfill operation. Runs asynchronously.
318 * Returns 202 Accepted immediately.
319 * Use ?force=catch_up or ?force=full_sync to override gap detection.
320 */
321 app.post(
322 "/backfill",
323 requireAuth(ctx),
324 requirePermission(ctx, "space.atbb.permission.manageForum"),
325 async (c) => {
326 const backfillManager = ctx.backfillManager;
327 if (!backfillManager) {
328 return c.json({ error: "Backfill manager not available" }, 503);
329 }
330
331 if (backfillManager.getIsRunning()) {
332 return c.json({ error: "A backfill is already in progress" }, 409);
333 }
334
335 // Determine backfill type
336 const force = c.req.query("force");
337 let type: BackfillStatus;
338
339 if (force === "catch_up" || force === "full_sync") {
340 type = force === "catch_up" ? BackfillStatus.CatchUp : BackfillStatus.FullSync;
341 } else {
342 try {
343 const cursor = await new CursorManager(ctx.db, ctx.logger).load();
344 type = await backfillManager.checkIfNeeded(cursor);
345 } catch (error) {
346 if (isProgrammingError(error)) throw error;
347 ctx.logger.error("Failed to check backfill status", {
348 event: "backfill.admin_trigger.check_failed",
349 error: error instanceof Error ? error.message : String(error),
350 });
351 return c.json({ error: "Failed to check backfill status. Please try again later." }, 500);
352 }
353
354 if (type === BackfillStatus.NotNeeded) {
355 return c.json({
356 message: "No backfill needed. Use ?force=catch_up or ?force=full_sync to override.",
357 }, 200);
358 }
359 }
360
361 // Create progress row first so we can return the ID immediately in the 202 response
362 let progressId: bigint;
363 try {
364 progressId = await backfillManager.prepareBackfillRow(type);
365 } catch (error) {
366 if (isProgrammingError(error)) throw error;
367 ctx.logger.error("Failed to create backfill row", {
368 event: "backfill.admin_trigger.create_row_failed",
369 error: error instanceof Error ? error.message : String(error),
370 });
371 return c.json({ error: "Failed to start backfill. Please try again later." }, 500);
372 }
373
374 // Fire and forget — don't await so response is immediate
375 backfillManager.performBackfill(type, progressId).catch((err) => {
376 ctx.logger.error("Background backfill failed", {
377 event: "backfill.admin_trigger_failed",
378 backfillId: progressId.toString(),
379 error: err instanceof Error ? err.message : String(err),
380 });
381 });
382
383 return c.json({
384 message: "Backfill started",
385 type,
386 status: "in_progress",
387 id: progressId.toString(),
388 }, 202);
389 }
390 );
391
392 /**
393 * GET /api/admin/backfill/:id
394 *
395 * Get status and progress for a specific backfill by ID.
396 */
397 app.get(
398 "/backfill/:id",
399 requireAuth(ctx),
400 requirePermission(ctx, "space.atbb.permission.manageForum"),
401 async (c) => {
402 const id = c.req.param("id");
403 if (!/^\d+$/.test(id)) {
404 return c.json({ error: "Invalid backfill ID" }, 400);
405 }
406 const parsedId = BigInt(id);
407
408 try {
409 const [row] = await ctx.db
410 .select()
411 .from(backfillProgress)
412 .where(eq(backfillProgress.id, parsedId))
413 .limit(1);
414
415 if (!row) {
416 return c.json({ error: "Backfill not found" }, 404);
417 }
418
419 const [errorCount] = await ctx.db
420 .select({ count: count() })
421 .from(backfillErrors)
422 .where(eq(backfillErrors.backfillId, row.id));
423
424 return c.json({
425 id: row.id.toString(),
426 status: row.status,
427 type: row.backfillType,
428 didsTotal: row.didsTotal,
429 didsProcessed: row.didsProcessed,
430 recordsIndexed: row.recordsIndexed,
431 errorCount: errorCount?.count ?? 0,
432 startedAt: row.startedAt.toISOString(),
433 completedAt: row.completedAt?.toISOString() ?? null,
434 errorMessage: row.errorMessage,
435 });
436 } catch (error) {
437 return handleRouteError(c, error, "Failed to fetch backfill progress", {
438 operation: "GET /api/admin/backfill/:id",
439 logger: ctx.logger,
440 id,
441 });
442 }
443 }
444 );
445
446 /**
447 * GET /api/admin/backfill/:id/errors
448 *
449 * List per-DID errors for a specific backfill.
450 */
451 app.get(
452 "/backfill/:id/errors",
453 requireAuth(ctx),
454 requirePermission(ctx, "space.atbb.permission.manageForum"),
455 async (c) => {
456 const id = c.req.param("id");
457 if (!/^\d+$/.test(id)) {
458 return c.json({ error: "Invalid backfill ID" }, 400);
459 }
460 const parsedId = BigInt(id);
461
462 try {
463 const errors = await ctx.db
464 .select()
465 .from(backfillErrors)
466 .where(eq(backfillErrors.backfillId, parsedId))
467 .orderBy(asc(backfillErrors.createdAt))
468 .limit(1000);
469
470 return c.json({
471 errors: errors.map((e) => ({
472 id: e.id.toString(),
473 did: e.did,
474 collection: e.collection,
475 errorMessage: e.errorMessage,
476 createdAt: e.createdAt.toISOString(),
477 })),
478 });
479 } catch (error) {
480 return handleRouteError(c, error, "Failed to fetch backfill errors", {
481 operation: "GET /api/admin/backfill/:id/errors",
482 logger: ctx.logger,
483 id,
484 });
485 }
486 }
487 );
488
489 /**
490 * POST /api/admin/categories
491 *
492 * Create a new forum category. Writes space.atbb.forum.category to Forum DID's PDS.
493 * The firehose indexer creates the DB row asynchronously.
494 */
495 app.post(
496 "/categories",
497 requireAuth(ctx),
498 requirePermission(ctx, "space.atbb.permission.manageCategories"),
499 async (c) => {
500 const { body, error: parseError } = await safeParseJsonBody(c);
501 if (parseError) return parseError;
502
503 const { name, description, sortOrder } = body;
504
505 if (typeof name !== "string" || name.trim().length === 0) {
506 return c.json({ error: "name is required and must be a non-empty string" }, 400);
507 }
508
509 const { agent, error: agentError } = getForumAgentOrError(ctx, c, "POST /api/admin/categories");
510 if (agentError) return agentError;
511
512 const rkey = TID.nextStr();
513 const now = new Date().toISOString();
514
515 try {
516 const result = await agent.com.atproto.repo.putRecord({
517 repo: ctx.config.forumDid,
518 collection: "space.atbb.forum.category",
519 rkey,
520 record: {
521 $type: "space.atbb.forum.category",
522 name: name.trim(),
523 ...(typeof description === "string" && { description: description.trim() }),
524 ...(Number.isInteger(sortOrder) && sortOrder >= 0 && { sortOrder }),
525 createdAt: now,
526 },
527 });
528
529 return c.json({ uri: result.data.uri, cid: result.data.cid }, 201);
530 } catch (error) {
531 return handleRouteError(c, error, "Failed to create category", {
532 operation: "POST /api/admin/categories",
533 logger: ctx.logger,
534 });
535 }
536 }
537 );
538
539 /**
540 * PUT /api/admin/categories/:id
541 *
542 * Update an existing category. Fetches existing rkey from DB, calls putRecord
543 * with updated fields preserving the original createdAt.
544 * The firehose indexer updates the DB row asynchronously.
545 */
546 app.put(
547 "/categories/:id",
548 requireAuth(ctx),
549 requirePermission(ctx, "space.atbb.permission.manageCategories"),
550 async (c) => {
551 const idParam = c.req.param("id");
552 const id = parseBigIntParam(idParam);
553 if (id === null) {
554 return c.json({ error: "Invalid category ID" }, 400);
555 }
556
557 const { body, error: parseError } = await safeParseJsonBody(c);
558 if (parseError) return parseError;
559
560 const { name, description, sortOrder } = body;
561
562 if (typeof name !== "string" || name.trim().length === 0) {
563 return c.json({ error: "name is required and must be a non-empty string" }, 400);
564 }
565
566 let category: typeof categories.$inferSelect;
567 try {
568 const [row] = await ctx.db
569 .select()
570 .from(categories)
571 .where(and(eq(categories.id, id), eq(categories.did, ctx.config.forumDid)))
572 .limit(1);
573
574 if (!row) {
575 return c.json({ error: "Category not found" }, 404);
576 }
577 category = row;
578 } catch (error) {
579 return handleRouteError(c, error, "Failed to look up category", {
580 operation: "PUT /api/admin/categories/:id",
581 logger: ctx.logger,
582 id: idParam,
583 });
584 }
585
586 const { agent, error: agentError } = getForumAgentOrError(ctx, c, "PUT /api/admin/categories/:id");
587 if (agentError) return agentError;
588
589 // putRecord is a full replacement — fall back to existing values for
590 // optional fields not provided in the request body, to avoid data loss.
591 const resolvedDescription = typeof description === "string"
592 ? description.trim()
593 : category.description;
594 const resolvedSortOrder = (Number.isInteger(sortOrder) && sortOrder >= 0)
595 ? sortOrder
596 : category.sortOrder;
597
598 try {
599 const result = await agent.com.atproto.repo.putRecord({
600 repo: ctx.config.forumDid,
601 collection: "space.atbb.forum.category",
602 rkey: category.rkey,
603 record: {
604 $type: "space.atbb.forum.category",
605 name: name.trim(),
606 ...(resolvedDescription != null && { description: resolvedDescription }),
607 ...(resolvedSortOrder != null && { sortOrder: resolvedSortOrder }),
608 createdAt: category.createdAt.toISOString(),
609 },
610 });
611
612 return c.json({ uri: result.data.uri, cid: result.data.cid });
613 } catch (error) {
614 return handleRouteError(c, error, "Failed to update category", {
615 operation: "PUT /api/admin/categories/:id",
616 logger: ctx.logger,
617 id: idParam,
618 });
619 }
620 }
621 );
622
623 /**
624 * DELETE /api/admin/categories/:id
625 *
626 * Delete a category. Pre-flight: refuses with 409 if any boards reference this
627 * category in the DB. If clear, calls deleteRecord on the Forum DID's PDS.
628 * The firehose indexer removes the DB row asynchronously.
629 */
630 app.delete(
631 "/categories/:id",
632 requireAuth(ctx),
633 requirePermission(ctx, "space.atbb.permission.manageCategories"),
634 async (c) => {
635 const idParam = c.req.param("id");
636 const id = parseBigIntParam(idParam);
637 if (id === null) {
638 return c.json({ error: "Invalid category ID" }, 400);
639 }
640
641 let category: typeof categories.$inferSelect;
642 try {
643 const [row] = await ctx.db
644 .select()
645 .from(categories)
646 .where(and(eq(categories.id, id), eq(categories.did, ctx.config.forumDid)))
647 .limit(1);
648
649 if (!row) {
650 return c.json({ error: "Category not found" }, 404);
651 }
652 category = row;
653 } catch (error) {
654 return handleRouteError(c, error, "Failed to look up category", {
655 operation: "DELETE /api/admin/categories/:id",
656 logger: ctx.logger,
657 id: idParam,
658 });
659 }
660
661 // Pre-flight: refuse if any boards reference this category
662 try {
663 const [boardCount] = await ctx.db
664 .select({ count: count() })
665 .from(boards)
666 .where(eq(boards.categoryId, id));
667
668 if (boardCount && boardCount.count > 0) {
669 return c.json(
670 { error: "Cannot delete category with boards. Remove all boards first." },
671 409
672 );
673 }
674 } catch (error) {
675 return handleRouteError(c, error, "Failed to check category boards", {
676 operation: "DELETE /api/admin/categories/:id",
677 logger: ctx.logger,
678 id: idParam,
679 });
680 }
681
682 const { agent, error: agentError } = getForumAgentOrError(ctx, c, "DELETE /api/admin/categories/:id");
683 if (agentError) return agentError;
684
685 try {
686 await agent.com.atproto.repo.deleteRecord({
687 repo: ctx.config.forumDid,
688 collection: "space.atbb.forum.category",
689 rkey: category.rkey,
690 });
691
692 return c.json({ success: true });
693 } catch (error) {
694 return handleRouteError(c, error, "Failed to delete category", {
695 operation: "DELETE /api/admin/categories/:id",
696 logger: ctx.logger,
697 id: idParam,
698 });
699 }
700 }
701 );
702
703 /**
704 * POST /api/admin/boards
705 *
706 * Create a new forum board within a category. Fetches the category's CID from DB
707 * to build the categoryRef strongRef required by the lexicon. Writes
708 * space.atbb.forum.board to the Forum DID's PDS via putRecord.
709 * The firehose indexer creates the DB row asynchronously.
710 */
711 app.post(
712 "/boards",
713 requireAuth(ctx),
714 requirePermission(ctx, "space.atbb.permission.manageCategories"),
715 async (c) => {
716 const { body, error: parseError } = await safeParseJsonBody(c);
717 if (parseError) return parseError;
718
719 const { name, description, sortOrder, categoryUri } = body;
720
721 if (typeof name !== "string" || name.trim().length === 0) {
722 return c.json({ error: "name is required and must be a non-empty string" }, 400);
723 }
724
725 if (typeof categoryUri !== "string" || !categoryUri.startsWith("at://")) {
726 return c.json({ error: "categoryUri is required and must be a valid AT URI" }, 400);
727 }
728
729 // Derive rkey from the categoryUri to look up the category in the DB
730 const categoryRkey = categoryUri.split("/").pop();
731
732 let category: typeof categories.$inferSelect;
733 try {
734 const [row] = await ctx.db
735 .select()
736 .from(categories)
737 .where(
738 and(
739 eq(categories.did, ctx.config.forumDid),
740 eq(categories.rkey, categoryRkey ?? "")
741 )
742 )
743 .limit(1);
744
745 if (!row) {
746 return c.json({ error: "Category not found" }, 404);
747 }
748 category = row;
749 } catch (error) {
750 return handleRouteError(c, error, "Failed to look up category", {
751 operation: "POST /api/admin/boards",
752 logger: ctx.logger,
753 categoryUri,
754 });
755 }
756
757 const { agent, error: agentError } = getForumAgentOrError(ctx, c, "POST /api/admin/boards");
758 if (agentError) return agentError;
759
760 const rkey = TID.nextStr();
761 const now = new Date().toISOString();
762
763 try {
764 const result = await agent.com.atproto.repo.putRecord({
765 repo: ctx.config.forumDid,
766 collection: "space.atbb.forum.board",
767 rkey,
768 record: {
769 $type: "space.atbb.forum.board",
770 name: name.trim(),
771 ...(typeof description === "string" && { description: description.trim() }),
772 ...(Number.isInteger(sortOrder) && sortOrder >= 0 && { sortOrder }),
773 category: { category: { uri: categoryUri, cid: category.cid } },
774 createdAt: now,
775 },
776 });
777
778 return c.json({ uri: result.data.uri, cid: result.data.cid }, 201);
779 } catch (error) {
780 return handleRouteError(c, error, "Failed to create board", {
781 operation: "POST /api/admin/boards",
782 logger: ctx.logger,
783 categoryUri,
784 });
785 }
786 }
787 );
788
789 /**
790 * PUT /api/admin/boards/:id
791 *
792 * Update an existing board's name, description, and sortOrder.
793 * Fetches existing rkey + categoryUri from DB, then fetches category CID,
794 * then putRecord with updated fields preserving the original categoryRef and createdAt.
795 * Category cannot be changed on edit (no reparenting).
796 * The firehose indexer updates the DB row asynchronously.
797 */
798 app.put(
799 "/boards/:id",
800 requireAuth(ctx),
801 requirePermission(ctx, "space.atbb.permission.manageCategories"),
802 async (c) => {
803 const idParam = c.req.param("id");
804 const id = parseBigIntParam(idParam);
805 if (id === null) {
806 return c.json({ error: "Invalid board ID" }, 400);
807 }
808
809 const { body, error: parseError } = await safeParseJsonBody(c);
810 if (parseError) return parseError;
811
812 const { name, description, sortOrder } = body;
813
814 if (typeof name !== "string" || name.trim().length === 0) {
815 return c.json({ error: "name is required and must be a non-empty string" }, 400);
816 }
817
818 let board: typeof boards.$inferSelect;
819 try {
820 const [row] = await ctx.db
821 .select()
822 .from(boards)
823 .where(and(eq(boards.id, id), eq(boards.did, ctx.config.forumDid)))
824 .limit(1);
825
826 if (!row) {
827 return c.json({ error: "Board not found" }, 404);
828 }
829 board = row;
830 } catch (error) {
831 return handleRouteError(c, error, "Failed to look up board", {
832 operation: "PUT /api/admin/boards/:id",
833 logger: ctx.logger,
834 id: idParam,
835 });
836 }
837
838 // Fetch category CID to rebuild the categoryRef strongRef.
839 // Always fetch fresh — the category's CID can change after category edits.
840 let categoryCid: string;
841 try {
842 const categoryRkey = board.categoryUri.split("/").pop() ?? "";
843 const [cat] = await ctx.db
844 .select({ cid: categories.cid })
845 .from(categories)
846 .where(
847 and(
848 eq(categories.did, ctx.config.forumDid),
849 eq(categories.rkey, categoryRkey)
850 )
851 )
852 .limit(1);
853
854 if (!cat) {
855 return c.json({ error: "Category not found" }, 404);
856 }
857 categoryCid = cat.cid;
858 } catch (error) {
859 return handleRouteError(c, error, "Failed to look up category", {
860 operation: "PUT /api/admin/boards/:id",
861 logger: ctx.logger,
862 id: idParam,
863 });
864 }
865
866 const { agent, error: agentError } = getForumAgentOrError(ctx, c, "PUT /api/admin/boards/:id");
867 if (agentError) return agentError;
868
869 // putRecord is a full replacement — fall back to existing values for
870 // optional fields not provided in the request body, to avoid data loss.
871 const resolvedDescription = typeof description === "string"
872 ? description.trim()
873 : board.description;
874 const resolvedSortOrder = (Number.isInteger(sortOrder) && sortOrder >= 0)
875 ? sortOrder
876 : board.sortOrder;
877
878 try {
879 const result = await agent.com.atproto.repo.putRecord({
880 repo: ctx.config.forumDid,
881 collection: "space.atbb.forum.board",
882 rkey: board.rkey,
883 record: {
884 $type: "space.atbb.forum.board",
885 name: name.trim(),
886 ...(resolvedDescription != null && { description: resolvedDescription }),
887 ...(resolvedSortOrder != null && { sortOrder: resolvedSortOrder }),
888 category: { category: { uri: board.categoryUri, cid: categoryCid } },
889 createdAt: board.createdAt.toISOString(),
890 },
891 });
892
893 return c.json({ uri: result.data.uri, cid: result.data.cid });
894 } catch (error) {
895 return handleRouteError(c, error, "Failed to update board", {
896 operation: "PUT /api/admin/boards/:id",
897 logger: ctx.logger,
898 id: idParam,
899 });
900 }
901 }
902 );
903
904 /**
905 * DELETE /api/admin/boards/:id
906 *
907 * Delete a board. Pre-flight: refuses with 409 if any posts have boardId
908 * pointing to this board. If clear, calls deleteRecord on the Forum DID's PDS.
909 * The firehose indexer removes the DB row asynchronously.
910 */
911 app.delete(
912 "/boards/:id",
913 requireAuth(ctx),
914 requirePermission(ctx, "space.atbb.permission.manageCategories"),
915 async (c) => {
916 const idParam = c.req.param("id");
917 const id = parseBigIntParam(idParam);
918 if (id === null) {
919 return c.json({ error: "Invalid board ID" }, 400);
920 }
921
922 let board: typeof boards.$inferSelect;
923 try {
924 const [row] = await ctx.db
925 .select()
926 .from(boards)
927 .where(and(eq(boards.id, id), eq(boards.did, ctx.config.forumDid)))
928 .limit(1);
929
930 if (!row) {
931 return c.json({ error: "Board not found" }, 404);
932 }
933 board = row;
934 } catch (error) {
935 return handleRouteError(c, error, "Failed to look up board", {
936 operation: "DELETE /api/admin/boards/:id",
937 logger: ctx.logger,
938 id: idParam,
939 });
940 }
941
942 // Pre-flight: refuse if any posts reference this board
943 try {
944 const [postCount] = await ctx.db
945 .select({ count: count() })
946 .from(posts)
947 .where(eq(posts.boardId, id));
948
949 if (postCount && postCount.count > 0) {
950 return c.json(
951 { error: "Cannot delete board with posts. Remove all posts first." },
952 409
953 );
954 }
955 } catch (error) {
956 return handleRouteError(c, error, "Failed to check board posts", {
957 operation: "DELETE /api/admin/boards/:id",
958 logger: ctx.logger,
959 id: idParam,
960 });
961 }
962
963 const { agent, error: agentError } = getForumAgentOrError(ctx, c, "DELETE /api/admin/boards/:id");
964 if (agentError) return agentError;
965
966 try {
967 await agent.com.atproto.repo.deleteRecord({
968 repo: ctx.config.forumDid,
969 collection: "space.atbb.forum.board",
970 rkey: board.rkey,
971 });
972
973 return c.json({ success: true });
974 } catch (error) {
975 return handleRouteError(c, error, "Failed to delete board", {
976 operation: "DELETE /api/admin/boards/:id",
977 logger: ctx.logger,
978 id: idParam,
979 });
980 }
981 }
982 );
983
984 /**
985 * GET /api/admin/themes
986 *
987 * Returns all themes for this forum — no policy filtering.
988 * Admins need to see all themes, including drafts not yet in the policy.
989 */
990 app.get(
991 "/themes",
992 requireAuth(ctx),
993 requirePermission(ctx, "space.atbb.permission.manageThemes"),
994 async (c) => {
995 try {
996 const themeList = await ctx.db
997 .select()
998 .from(themes)
999 .where(eq(themes.did, ctx.config.forumDid))
1000 .limit(100);
1001
1002 return c.json({
1003 themes: themeList.map((theme) => ({
1004 id: serializeBigInt(theme.id),
1005 uri: `at://${theme.did}/space.atbb.forum.theme/${theme.rkey}`,
1006 name: theme.name,
1007 colorScheme: theme.colorScheme,
1008 tokens: theme.tokens,
1009 cssOverrides: theme.cssOverrides ?? null,
1010 fontUrls: (theme.fontUrls as string[] | null) ?? null,
1011 createdAt: serializeDate(theme.createdAt),
1012 indexedAt: serializeDate(theme.indexedAt),
1013 })),
1014 isTruncated: themeList.length === 100,
1015 });
1016 } catch (error) {
1017 return handleRouteError(c, error, "Failed to retrieve themes", {
1018 operation: "GET /api/admin/themes",
1019 logger: ctx.logger,
1020 });
1021 }
1022 }
1023 );
1024
1025 /**
1026 * POST /api/admin/themes
1027 *
1028 * Create a new theme record on Forum DID's PDS.
1029 * Writes space.atbb.forum.theme with a fresh TID rkey.
1030 * The firehose indexer creates the DB row asynchronously.
1031 */
1032 app.post(
1033 "/themes",
1034 requireAuth(ctx),
1035 requirePermission(ctx, "space.atbb.permission.manageThemes"),
1036 async (c) => {
1037 const { body, error: parseError } = await safeParseJsonBody(c);
1038 if (parseError) return parseError;
1039
1040 const { name, colorScheme, tokens, cssOverrides, fontUrls } = body;
1041
1042 if (typeof name !== "string" || name.trim().length === 0) {
1043 return c.json({ error: "name is required and must be a non-empty string" }, 400);
1044 }
1045 if (typeof colorScheme !== "string" || (colorScheme !== "light" && colorScheme !== "dark")) {
1046 return c.json({ error: 'colorScheme is required and must be "light" or "dark"' }, 400);
1047 }
1048 if (tokens === null || tokens === undefined || typeof tokens !== "object" || Array.isArray(tokens)) {
1049 return c.json({ error: "tokens is required and must be a plain object" }, 400);
1050 }
1051 for (const [key, val] of Object.entries(tokens as Record<string, unknown>)) {
1052 if (typeof val !== "string") {
1053 return c.json({ error: `tokens["${key}"] must be a string` }, 400);
1054 }
1055 }
1056 if (cssOverrides !== undefined && typeof cssOverrides !== "string") {
1057 return c.json({ error: "cssOverrides must be a string" }, 400);
1058 }
1059 if (fontUrls !== undefined) {
1060 if (!Array.isArray(fontUrls)) {
1061 return c.json({ error: "fontUrls must be an array of strings" }, 400);
1062 }
1063 for (const url of fontUrls as unknown[]) {
1064 if (typeof url !== "string" || !url.startsWith("https://")) {
1065 return c.json({ error: "fontUrls must contain only HTTPS URLs" }, 400);
1066 }
1067 }
1068 }
1069
1070 // Sanitize cssOverrides before writing to PDS. In its own try-catch
1071 // because sanitization failure has different semantics than a PDS write failure.
1072 let sanitizedCssOverrides: string | undefined;
1073 if (typeof cssOverrides === "string") {
1074 try {
1075 const { css, warnings } = sanitizeCssOverrides(cssOverrides);
1076 if (warnings.length > 0) {
1077 ctx.logger.warn("Stripped dangerous CSS constructs from theme on create", {
1078 operation: "POST /api/admin/themes",
1079 warnings,
1080 });
1081 }
1082 sanitizedCssOverrides = css;
1083 } catch (error) {
1084 if (isProgrammingError(error)) throw error;
1085 ctx.logger.error("CSS sanitization failed unexpectedly on create", {
1086 operation: "POST /api/admin/themes",
1087 error: error instanceof Error ? error.message : String(error),
1088 });
1089 return c.json({ error: "Failed to process CSS overrides" }, 500);
1090 }
1091 }
1092
1093 const { agent, error: agentError } = getForumAgentOrError(ctx, c, "POST /api/admin/themes");
1094 if (agentError) return agentError;
1095
1096 const rkey = TID.nextStr();
1097 const now = new Date().toISOString();
1098
1099 try {
1100 const result = await agent.com.atproto.repo.putRecord({
1101 repo: ctx.config.forumDid,
1102 collection: "space.atbb.forum.theme",
1103 rkey,
1104 record: {
1105 $type: "space.atbb.forum.theme",
1106 name: name.trim(),
1107 colorScheme,
1108 tokens,
1109 ...(typeof sanitizedCssOverrides === "string" && { cssOverrides: sanitizedCssOverrides }),
1110 ...(Array.isArray(fontUrls) && { fontUrls }),
1111 createdAt: now,
1112 },
1113 });
1114
1115 return c.json({ uri: result.data.uri, cid: result.data.cid }, 201);
1116 } catch (error) {
1117 return handleRouteError(c, error, "Failed to create theme", {
1118 operation: "POST /api/admin/themes",
1119 logger: ctx.logger,
1120 });
1121 }
1122 }
1123 );
1124
1125 /**
1126 * PUT /api/admin/themes/:rkey
1127 *
1128 * Update an existing theme. Fetches the existing row from DB to preserve
1129 * createdAt and fall back optional fields not in the request body.
1130 * The firehose indexer updates the DB row asynchronously.
1131 */
1132 app.put(
1133 "/themes/:rkey",
1134 requireAuth(ctx),
1135 requirePermission(ctx, "space.atbb.permission.manageThemes"),
1136 async (c) => {
1137 const themeRkey = c.req.param("rkey").trim();
1138
1139 const { body, error: parseError } = await safeParseJsonBody(c);
1140 if (parseError) return parseError;
1141
1142 const { name, colorScheme, tokens, cssOverrides, fontUrls } = body;
1143
1144 if (typeof name !== "string" || name.trim().length === 0) {
1145 return c.json({ error: "name is required and must be a non-empty string" }, 400);
1146 }
1147 if (typeof colorScheme !== "string" || (colorScheme !== "light" && colorScheme !== "dark")) {
1148 return c.json({ error: 'colorScheme is required and must be "light" or "dark"' }, 400);
1149 }
1150 if (tokens === null || tokens === undefined || typeof tokens !== "object" || Array.isArray(tokens)) {
1151 return c.json({ error: "tokens is required and must be a plain object" }, 400);
1152 }
1153 for (const [key, val] of Object.entries(tokens as Record<string, unknown>)) {
1154 if (typeof val !== "string") {
1155 return c.json({ error: `tokens["${key}"] must be a string` }, 400);
1156 }
1157 }
1158 if (cssOverrides !== undefined && typeof cssOverrides !== "string") {
1159 return c.json({ error: "cssOverrides must be a string" }, 400);
1160 }
1161 if (fontUrls !== undefined) {
1162 if (!Array.isArray(fontUrls)) {
1163 return c.json({ error: "fontUrls must be an array of strings" }, 400);
1164 }
1165 for (const url of fontUrls as unknown[]) {
1166 if (typeof url !== "string" || !url.startsWith("https://")) {
1167 return c.json({ error: "fontUrls must contain only HTTPS URLs" }, 400);
1168 }
1169 }
1170 }
1171
1172 let theme: typeof themes.$inferSelect;
1173 try {
1174 const [row] = await ctx.db
1175 .select()
1176 .from(themes)
1177 .where(and(eq(themes.did, ctx.config.forumDid), eq(themes.rkey, themeRkey)))
1178 .limit(1);
1179
1180 if (!row) {
1181 return c.json({ error: "Theme not found" }, 404);
1182 }
1183 theme = row;
1184 } catch (error) {
1185 return handleRouteError(c, error, "Failed to look up theme", {
1186 operation: "PUT /api/admin/themes/:rkey",
1187 logger: ctx.logger,
1188 themeRkey,
1189 });
1190 }
1191
1192 const { agent, error: agentError } = getForumAgentOrError(ctx, c, "PUT /api/admin/themes/:rkey");
1193 if (agentError) return agentError;
1194
1195 // putRecord is a full replacement — fall back to existing values for
1196 // optional fields not provided in the request body, to avoid data loss.
1197 const rawCssOverrides =
1198 typeof cssOverrides === "string" ? cssOverrides : theme.cssOverrides;
1199 let resolvedCssOverrides: string | null | undefined = rawCssOverrides;
1200 if (rawCssOverrides != null) {
1201 try {
1202 const { css, warnings } = sanitizeCssOverrides(rawCssOverrides);
1203 if (warnings.length > 0) {
1204 ctx.logger.warn("Stripped dangerous CSS constructs from theme on update", {
1205 operation: "PUT /api/admin/themes/:rkey",
1206 themeRkey,
1207 warnings,
1208 });
1209 }
1210 resolvedCssOverrides = css;
1211 } catch (error) {
1212 if (isProgrammingError(error)) throw error;
1213 ctx.logger.error("CSS sanitization failed unexpectedly on update", {
1214 operation: "PUT /api/admin/themes/:rkey",
1215 themeRkey,
1216 error: error instanceof Error ? error.message : String(error),
1217 });
1218 return c.json({ error: "Failed to process CSS overrides" }, 500);
1219 }
1220 }
1221 const resolvedFontUrls = Array.isArray(fontUrls) ? fontUrls : (theme.fontUrls as string[] | null);
1222
1223 try {
1224 const result = await agent.com.atproto.repo.putRecord({
1225 repo: ctx.config.forumDid,
1226 collection: "space.atbb.forum.theme",
1227 rkey: theme.rkey,
1228 record: {
1229 $type: "space.atbb.forum.theme",
1230 name: name.trim(),
1231 colorScheme,
1232 tokens,
1233 ...(resolvedCssOverrides != null && { cssOverrides: resolvedCssOverrides }),
1234 ...(resolvedFontUrls != null && { fontUrls: resolvedFontUrls }),
1235 createdAt: theme.createdAt.toISOString(),
1236 updatedAt: new Date().toISOString(),
1237 },
1238 });
1239
1240 return c.json({ uri: result.data.uri, cid: result.data.cid });
1241 } catch (error) {
1242 return handleRouteError(c, error, "Failed to update theme", {
1243 operation: "PUT /api/admin/themes/:rkey",
1244 logger: ctx.logger,
1245 themeRkey,
1246 });
1247 }
1248 }
1249 );
1250
1251 /**
1252 * DELETE /api/admin/themes/:rkey
1253 *
1254 * Delete a theme. Pre-flight: refuses with 409 if the theme is set as
1255 * defaultLightTheme or defaultDarkTheme in the theme policy.
1256 * The firehose indexer removes the DB row asynchronously.
1257 */
1258 app.delete(
1259 "/themes/:rkey",
1260 requireAuth(ctx),
1261 requirePermission(ctx, "space.atbb.permission.manageThemes"),
1262 async (c) => {
1263 const themeRkey = c.req.param("rkey").trim();
1264
1265 let theme: typeof themes.$inferSelect;
1266 try {
1267 const [row] = await ctx.db
1268 .select()
1269 .from(themes)
1270 .where(and(eq(themes.did, ctx.config.forumDid), eq(themes.rkey, themeRkey)))
1271 .limit(1);
1272
1273 if (!row) {
1274 return c.json({ error: "Theme not found" }, 404);
1275 }
1276 theme = row;
1277 } catch (error) {
1278 return handleRouteError(c, error, "Failed to look up theme", {
1279 operation: "DELETE /api/admin/themes/:rkey",
1280 logger: ctx.logger,
1281 themeRkey,
1282 });
1283 }
1284
1285 // Pre-flight conflict check: refuse if this theme is a policy default
1286 const themeUri = `at://${theme.did}/space.atbb.forum.theme/${theme.rkey}`;
1287 try {
1288 const [conflictingPolicy] = await ctx.db
1289 .select({ id: themePolicies.id })
1290 .from(themePolicies)
1291 .where(
1292 and(
1293 eq(themePolicies.did, ctx.config.forumDid),
1294 or(
1295 eq(themePolicies.defaultLightThemeUri, themeUri),
1296 eq(themePolicies.defaultDarkThemeUri, themeUri)
1297 )
1298 )
1299 )
1300 .limit(1);
1301
1302 if (conflictingPolicy) {
1303 return c.json(
1304 { error: "Cannot delete a theme that is currently set as a default. Update the theme policy first." },
1305 409
1306 );
1307 }
1308 } catch (error) {
1309 return handleRouteError(c, error, "Failed to check theme policy", {
1310 operation: "DELETE /api/admin/themes/:rkey",
1311 logger: ctx.logger,
1312 themeRkey,
1313 });
1314 }
1315
1316 const { agent, error: agentError } = getForumAgentOrError(ctx, c, "DELETE /api/admin/themes/:rkey");
1317 if (agentError) return agentError;
1318
1319 try {
1320 await agent.com.atproto.repo.deleteRecord({
1321 repo: ctx.config.forumDid,
1322 collection: "space.atbb.forum.theme",
1323 rkey: theme.rkey,
1324 });
1325
1326 return c.json({ success: true });
1327 } catch (error) {
1328 return handleRouteError(c, error, "Failed to delete theme", {
1329 operation: "DELETE /api/admin/themes/:rkey",
1330 logger: ctx.logger,
1331 themeRkey,
1332 });
1333 }
1334 }
1335 );
1336
1337 /**
1338 * POST /api/admin/themes/:rkey/duplicate
1339 *
1340 * Clones an existing theme record with " (Copy)" appended to the name.
1341 * Uses a fresh TID as the new record key.
1342 * The firehose indexer will create the DB row asynchronously.
1343 */
1344 app.post(
1345 "/themes/:rkey/duplicate",
1346 requireAuth(ctx),
1347 requirePermission(ctx, "space.atbb.permission.manageThemes"),
1348 async (c) => {
1349 const sourceRkey = c.req.param("rkey").trim();
1350
1351 let source: typeof themes.$inferSelect;
1352 try {
1353 const [row] = await ctx.db
1354 .select()
1355 .from(themes)
1356 .where(and(eq(themes.did, ctx.config.forumDid), eq(themes.rkey, sourceRkey)))
1357 .limit(1);
1358
1359 if (!row) {
1360 return c.json({ error: "Theme not found" }, 404);
1361 }
1362 source = row;
1363 } catch (error) {
1364 return handleRouteError(c, error, "Failed to look up source theme", {
1365 operation: "POST /api/admin/themes/:rkey/duplicate",
1366 logger: ctx.logger,
1367 sourceRkey,
1368 });
1369 }
1370
1371 const { agent, error: agentError } = getForumAgentOrError(
1372 ctx,
1373 c,
1374 "POST /api/admin/themes/:rkey/duplicate"
1375 );
1376 if (agentError) return agentError;
1377
1378 const newRkey = TID.nextStr();
1379 const newName = `${source.name} (Copy)`;
1380 const now = new Date().toISOString();
1381
1382 // Sanitize cssOverrides from source before writing to PDS so any
1383 // pre-sanitization records don't propagate dangerous CSS via duplication.
1384 let duplicateCssOverrides: string | null = null;
1385 if (source.cssOverrides != null) {
1386 try {
1387 const { css, warnings } = sanitizeCssOverrides(source.cssOverrides);
1388 if (warnings.length > 0) {
1389 ctx.logger.warn("Stripped dangerous CSS constructs from theme on duplicate", {
1390 operation: "POST /api/admin/themes/:rkey/duplicate",
1391 sourceRkey,
1392 warnings,
1393 });
1394 }
1395 duplicateCssOverrides = css;
1396 } catch (error) {
1397 if (isProgrammingError(error)) throw error;
1398 ctx.logger.error("CSS sanitization failed unexpectedly on duplicate", {
1399 operation: "POST /api/admin/themes/:rkey/duplicate",
1400 sourceRkey,
1401 error: error instanceof Error ? error.message : String(error),
1402 });
1403 return c.json({ error: "Failed to process CSS overrides" }, 500);
1404 }
1405 }
1406
1407 try {
1408 const result = await agent.com.atproto.repo.putRecord({
1409 repo: ctx.config.forumDid,
1410 collection: "space.atbb.forum.theme",
1411 rkey: newRkey,
1412 record: {
1413 $type: "space.atbb.forum.theme",
1414 name: newName,
1415 colorScheme: source.colorScheme,
1416 tokens: source.tokens,
1417 ...(duplicateCssOverrides != null && { cssOverrides: duplicateCssOverrides }),
1418 ...(source.fontUrls != null && { fontUrls: source.fontUrls }),
1419 createdAt: now,
1420 },
1421 });
1422
1423 return c.json({ uri: result.data.uri, rkey: newRkey, name: newName }, 201);
1424 } catch (error) {
1425 return handleRouteError(c, error, "Failed to duplicate theme", {
1426 operation: "POST /api/admin/themes/:rkey/duplicate",
1427 logger: ctx.logger,
1428 sourceRkey,
1429 newRkey,
1430 });
1431 }
1432 }
1433 );
1434
1435 /**
1436 * PUT /api/admin/theme-policy
1437 *
1438 * Create or update the themePolicy singleton (rkey: "self") on Forum DID's PDS.
1439 * Upsert semantics: works whether or not a policy record exists yet.
1440 * The firehose indexer creates/updates the DB row asynchronously.
1441 */
1442 app.put(
1443 "/theme-policy",
1444 requireAuth(ctx),
1445 requirePermission(ctx, "space.atbb.permission.manageThemes"),
1446 async (c) => {
1447 const { body, error: parseError } = await safeParseJsonBody(c);
1448 if (parseError) return parseError;
1449
1450 const { availableThemes, defaultLightThemeUri, defaultDarkThemeUri, allowUserChoice } = body;
1451
1452 if (!Array.isArray(availableThemes) || availableThemes.length === 0) {
1453 return c.json({ error: "availableThemes is required and must be a non-empty array" }, 400);
1454 }
1455 for (const t of availableThemes as unknown[]) {
1456 if (
1457 typeof t !== "object" ||
1458 t === null ||
1459 typeof (t as Record<string, unknown>).uri !== "string"
1460 ) {
1461 return c.json({ error: "Each availableThemes entry must have a uri string field" }, 400);
1462 }
1463 }
1464
1465 if (typeof defaultLightThemeUri !== "string" || !defaultLightThemeUri.startsWith("at://")) {
1466 return c.json({ error: "defaultLightThemeUri is required and must be an AT-URI" }, 400);
1467 }
1468 if (typeof defaultDarkThemeUri !== "string" || !defaultDarkThemeUri.startsWith("at://")) {
1469 return c.json({ error: "defaultDarkThemeUri is required and must be an AT-URI" }, 400);
1470 }
1471
1472 const typedAvailableThemes = availableThemes as Array<{ uri: string; cid?: string }>;
1473 const availableUris = typedAvailableThemes.map((t) => t.uri);
1474 if (!availableUris.includes(defaultLightThemeUri)) {
1475 return c.json({ error: "defaultLightThemeUri must be present in availableThemes" }, 400);
1476 }
1477 if (!availableUris.includes(defaultDarkThemeUri)) {
1478 return c.json({ error: "defaultDarkThemeUri must be present in availableThemes" }, 400);
1479 }
1480
1481 const resolvedAllowUserChoice = typeof allowUserChoice === "boolean" ? allowUserChoice : true;
1482
1483 // CID is optional — live refs (no cid) are valid for canonical atbb.space presets.
1484 // Pass cid through when provided; omit it when absent or empty string.
1485 const resolvedThemes = typedAvailableThemes.map((t) => ({
1486 uri: t.uri,
1487 cid: typeof t.cid === "string" && t.cid !== "" ? t.cid : undefined,
1488 }));
1489
1490 const lightTheme = resolvedThemes.find((t) => t.uri === defaultLightThemeUri);
1491 const darkTheme = resolvedThemes.find((t) => t.uri === defaultDarkThemeUri);
1492 if (!lightTheme || !darkTheme) {
1493 // Both URIs were validated as present in availableThemes above — this is unreachable.
1494 return c.json({ error: "Internal error: theme URIs not found in resolved themes" }, 500);
1495 }
1496
1497 const { agent, error: agentError } = getForumAgentOrError(ctx, c, "PUT /api/admin/theme-policy");
1498 if (agentError) return agentError;
1499
1500 try {
1501 const result = await agent.com.atproto.repo.putRecord({
1502 repo: ctx.config.forumDid,
1503 collection: "space.atbb.forum.themePolicy",
1504 rkey: "self",
1505 record: {
1506 $type: "space.atbb.forum.themePolicy",
1507 availableThemes: resolvedThemes.map((t) => ({
1508 uri: t.uri,
1509 ...(t.cid !== undefined ? { cid: t.cid } : {}),
1510 })),
1511 defaultLightTheme: { uri: lightTheme.uri, ...(lightTheme.cid !== undefined ? { cid: lightTheme.cid } : {}) },
1512 defaultDarkTheme: { uri: darkTheme.uri, ...(darkTheme.cid !== undefined ? { cid: darkTheme.cid } : {}) },
1513 allowUserChoice: resolvedAllowUserChoice,
1514 updatedAt: new Date().toISOString(),
1515 },
1516 });
1517
1518 return c.json({ uri: result.data.uri, cid: result.data.cid });
1519 } catch (error) {
1520 return handleRouteError(c, error, "Failed to update theme policy", {
1521 operation: "PUT /api/admin/theme-policy",
1522 logger: ctx.logger,
1523 });
1524 }
1525 }
1526 );
1527
1528 /**
1529 * GET /api/admin/modlog
1530 *
1531 * Paginated, reverse-chronological list of mod actions.
1532 * Joins users table twice: once for the moderator handle (via createdBy),
1533 * once for the subject handle (via subjectDid, nullable for post-targeting actions).
1534 *
1535 * Uses leftJoin for both users joins so actions are never dropped when a
1536 * moderator or subject DID has no indexed users row. moderatorHandle falls
1537 * back to moderatorDid in that case.
1538 *
1539 * Requires any of: moderatePosts, banUsers, lockTopics.
1540 */
1541 app.get(
1542 "/modlog",
1543 requireAuth(ctx),
1544 requireAnyPermission(ctx, [
1545 "space.atbb.permission.moderatePosts",
1546 "space.atbb.permission.banUsers",
1547 "space.atbb.permission.lockTopics",
1548 ]),
1549 async (c) => {
1550 const rawLimit = c.req.query("limit");
1551 const rawOffset = c.req.query("offset");
1552
1553 if (rawLimit !== undefined && (!/^\d+$/.test(rawLimit))) {
1554 return c.json({ error: "limit must be a positive integer" }, 400);
1555 }
1556 if (rawOffset !== undefined && (!/^\d+$/.test(rawOffset))) {
1557 return c.json({ error: "offset must be a non-negative integer" }, 400);
1558 }
1559
1560 const limitVal = rawLimit !== undefined ? parseInt(rawLimit, 10) : 50;
1561 const offsetVal = rawOffset !== undefined ? parseInt(rawOffset, 10) : 0;
1562
1563 if (rawLimit !== undefined && limitVal < 1) {
1564 return c.json({ error: "limit must be a positive integer" }, 400);
1565 }
1566 if (rawOffset !== undefined && offsetVal < 0) {
1567 return c.json({ error: "offset must be a non-negative integer" }, 400);
1568 }
1569
1570 const clampedLimit = Math.min(limitVal, 100);
1571
1572 try {
1573 const [countResult, actions] = await Promise.all([
1574 ctx.db
1575 .select({ total: count() })
1576 .from(modActions)
1577 .where(eq(modActions.did, ctx.config.forumDid)),
1578 ctx.db
1579 .select()
1580 .from(modActions)
1581 .where(eq(modActions.did, ctx.config.forumDid))
1582 .orderBy(desc(modActions.createdAt))
1583 .limit(clampedLimit)
1584 .offset(offsetVal),
1585 ]);
1586
1587 const total = Number(countResult[0]?.total ?? 0);
1588
1589 // Resolve handles in a single batch query instead of aliased self-joins
1590 // (drizzle-orm's alias() generates invalid SQL for SQLite)
1591 const dids = new Set<string>();
1592 for (const a of actions) {
1593 if (a.createdBy) dids.add(a.createdBy);
1594 if (a.subjectDid) dids.add(a.subjectDid);
1595 }
1596
1597 const handleMap = new Map<string, string>();
1598 if (dids.size > 0) {
1599 const userRows = await ctx.db
1600 .select({ did: users.did, handle: users.handle })
1601 .from(users)
1602 .where(inArray(users.did, [...dids]));
1603 for (const u of userRows) {
1604 if (u.handle) handleMap.set(u.did, u.handle);
1605 }
1606 }
1607
1608 return c.json({
1609 actions: actions.map((a) => ({
1610 id: a.id.toString(),
1611 action: a.action,
1612 moderatorDid: a.createdBy,
1613 moderatorHandle: handleMap.get(a.createdBy) ?? a.createdBy,
1614 subjectDid: a.subjectDid ?? null,
1615 subjectHandle: a.subjectDid ? (handleMap.get(a.subjectDid) ?? null) : null,
1616 subjectPostUri: a.subjectPostUri ?? null,
1617 reason: a.reason ?? null,
1618 createdAt: a.createdAt.toISOString(),
1619 })),
1620 total,
1621 offset: offsetVal,
1622 limit: clampedLimit,
1623 });
1624 } catch (error) {
1625 return handleRouteError(c, error, "Failed to retrieve mod action log", {
1626 operation: "GET /api/admin/modlog",
1627 logger: ctx.logger,
1628 });
1629 }
1630 }
1631 );
1632
1633 return app;
1634}