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
at main 1634 lines 56 kB view raw
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}