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
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 2000 lines 52 kB view raw view rendered
1# Role-Based Permission System Implementation Plan (ATB-17) 2 3> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 5**Goal:** Implement role-based access control with 4 default roles (Owner, Admin, Moderator, Member) enforced via middleware on all write operations. 6 7**Architecture:** Three-layer system - (1) Database + Indexer for role storage, (2) Permission middleware + helpers for enforcement, (3) Admin endpoints for role management. Follows existing patterns (factory functions, AppContext DI, TDD). 8 9**Tech Stack:** TypeScript, Hono, Drizzle ORM, PostgreSQL, Vitest, AT Protocol SDK 10 11**Design Document:** See `docs/plans/2026-02-14-permissions-design.md` for full design rationale. 12 13--- 14 15## Task 1: Database Schema - Roles Table 16 17**Files:** 18- Create: `packages/db/drizzle/migrations/0004_add_roles_table.sql` 19- Modify: `packages/db/src/schema.ts:178-210` (add roles table export) 20 21**Step 1: Create migration SQL** 22 23Create migration file with roles table definition: 24 25```bash 26# File: packages/db/drizzle/migrations/0004_add_roles_table.sql 27CREATE TABLE roles ( 28 id BIGSERIAL PRIMARY KEY, 29 did TEXT NOT NULL, 30 rkey TEXT NOT NULL, 31 cid TEXT NOT NULL, 32 name TEXT NOT NULL, 33 description TEXT, 34 permissions TEXT[] NOT NULL DEFAULT '{}', 35 priority INTEGER NOT NULL, 36 created_at TIMESTAMP WITH TIME ZONE NOT NULL, 37 indexed_at TIMESTAMP WITH TIME ZONE NOT NULL, 38 UNIQUE(did, rkey) 39); 40 41CREATE INDEX idx_roles_did ON roles(did); 42CREATE INDEX idx_roles_did_name ON roles(did, name); 43``` 44 45**Step 2: Add roles table to schema** 46 47Modify `packages/db/src/schema.ts` after `firehoseCursor` table (line 188): 48 49```typescript 50// ── roles ─────────────────────────────────────────────── 51// Role definitions, owned by Forum DID. 52export const roles = pgTable( 53 "roles", 54 { 55 id: bigserial("id", { mode: "bigint" }).primaryKey(), 56 did: text("did").notNull(), 57 rkey: text("rkey").notNull(), 58 cid: text("cid").notNull(), 59 name: text("name").notNull(), 60 description: text("description"), 61 permissions: text("permissions").array().notNull().default(sql`'{}'::text[]`), 62 priority: integer("priority").notNull(), 63 createdAt: timestamp("created_at", { withTimezone: true }).notNull(), 64 indexedAt: timestamp("indexed_at", { withTimezone: true }).notNull(), 65 }, 66 (table) => [ 67 uniqueIndex("roles_did_rkey_idx").on(table.did, table.rkey), 68 index("roles_did_idx").on(table.did), 69 index("roles_did_name_idx").on(table.did, table.name), 70 ] 71); 72``` 73 74Also add `sql` import at top: 75 76```typescript 77import { 78 pgTable, 79 bigserial, 80 text, 81 timestamp, 82 integer, 83 boolean, 84 bigint, 85 uniqueIndex, 86 index, 87 sql, // ADD THIS 88} from "drizzle-orm/pg-core"; 89``` 90 91**Step 3: Run migration** 92 93```bash 94cd packages/db 95pnpm db:migrate 96``` 97 98Expected: Migration runs successfully, `roles` table created in database. 99 100**Step 4: Verify schema in database** 101 102```bash 103psql $DATABASE_URL -c "\d roles" 104``` 105 106Expected: Shows table structure with all columns and indexes. 107 108**Step 5: Commit** 109 110```bash 111git add packages/db/drizzle/migrations/0004_add_roles_table.sql packages/db/src/schema.ts 112git commit -m "feat(db): add roles table for permission system 113 114- Create roles table with permissions array and priority field 115- Add indexes on did and did+name for efficient lookups 116- Migration 0004_add_roles_table.sql" 117``` 118 119--- 120 121## Task 2: Update Test Cleanup 122 123**Files:** 124- Modify: `apps/appview/src/lib/__tests__/test-context.ts:45-60` (add roles cleanup) 125 126**Step 1: Add roles to cleanup** 127 128In `test-context.ts`, add roles cleanup after `modActions` cleanup (around line 55): 129 130```typescript 131// Clean up test data 132await db.delete(modActions).where(eq(modActions.did, config.forumDid)); 133await db.delete(roles).where(eq(roles.did, config.forumDid)); // ADD THIS 134await db.delete(posts).where(eq(posts.forumUri, forumUri)); 135``` 136 137Also add roles import at top: 138 139```typescript 140import { 141 forums, 142 categories, 143 boards, 144 posts, 145 users, 146 memberships, 147 modActions, 148 roles, // ADD THIS 149 firehoseCursor, 150} from "@atbb/db"; 151``` 152 153**Step 2: Commit** 154 155```bash 156git add apps/appview/src/lib/__tests__/test-context.ts 157git commit -m "test: add roles table to test cleanup" 158``` 159 160--- 161 162## Task 3: Role Indexer 163 164**Files:** 165- Modify: `apps/appview/src/lib/indexer.ts:1-30` (add import) 166- Modify: `apps/appview/src/lib/indexer.ts:70-150` (add roleConfig) 167- Modify: `apps/appview/src/lib/indexer.ts:450-500` (add handler methods) 168- Modify: `apps/appview/src/lib/indexer.ts:600-650` (register handlers) 169 170**Step 1: Add Role import** 171 172At top of `indexer.ts`, add to lexicon imports (around line 23): 173 174```typescript 175import { 176 SpaceAtbbPost as Post, 177 SpaceAtbbForumForum as Forum, 178 SpaceAtbbForumCategory as Category, 179 SpaceAtbbForumBoard as Board, 180 SpaceAtbbMembership as Membership, 181 SpaceAtbbModAction as ModAction, 182 SpaceAtbbForumRole as Role, // ADD THIS 183} from "@atbb/lexicon"; 184``` 185 186**Step 2: Add roleConfig after boardConfig** 187 188After `boardConfig` definition (around line 250), add: 189 190```typescript 191private roleConfig: CollectionConfig<Role.Record> = { 192 name: "Role", 193 table: roles, 194 deleteStrategy: "hard", 195 toInsertValues: async (event, record) => ({ 196 did: event.did, 197 rkey: event.commit.rkey, 198 cid: event.commit.cid, 199 name: record.name, 200 description: record.description || null, 201 permissions: record.permissions, 202 priority: record.priority, 203 createdAt: new Date(record.createdAt), 204 indexedAt: new Date(), 205 }), 206 toUpdateValues: async (event, record) => ({ 207 cid: event.commit.cid, 208 name: record.name, 209 description: record.description || null, 210 permissions: record.permissions, 211 priority: record.priority, 212 indexedAt: new Date(), 213 }), 214}; 215``` 216 217**Step 3: Add handler methods** 218 219After other handler methods (around line 450), add: 220 221```typescript 222async handleRoleCreate(event: CommitCreateEvent<Role.Record>) { 223 await this.genericCreate(this.roleConfig, event); 224} 225 226async handleRoleUpdate(event: CommitUpdateEvent<Role.Record>) { 227 await this.genericUpdate(this.roleConfig, event); 228} 229 230async handleRoleDelete(event: CommitDeleteEvent) { 231 await this.genericDelete(this.roleConfig, event); 232} 233``` 234 235**Step 4: Register handlers** 236 237In `createHandlerRegistry()`, after board registration (around line 650), add: 238 239```typescript 240.register({ 241 collection: "space.atbb.forum.role", 242 onCreate: this.createWrappedHandler("handleRoleCreate"), 243 onUpdate: this.createWrappedHandler("handleRoleUpdate"), 244 onDelete: this.createWrappedHandler("handleRoleDelete"), 245}) 246``` 247 248**Step 5: Commit** 249 250```bash 251git add apps/appview/src/lib/indexer.ts 252git commit -m "feat(indexer): add role indexer for space.atbb.forum.role 253 254- Add roleConfig with hard delete strategy 255- Implement handleRoleCreate, handleRoleUpdate, handleRoleDelete 256- Register handlers in createHandlerRegistry" 257``` 258 259--- 260 261## Task 4: Permission Helper Functions - Unit Tests (Part 1) 262 263**Files:** 264- Create: `apps/appview/src/middleware/__tests__/permissions.test.ts` 265 266**Step 1: Write test file skeleton** 267 268Create test file with imports and test data setup: 269 270```typescript 271import { describe, it, expect, beforeEach } from "vitest"; 272import { createTestContext } from "../../lib/__tests__/test-context.js"; 273import type { AppContext } from "../../lib/app-context.js"; 274import { roles, memberships, users } from "@atbb/db"; 275 276describe("Permission Helper Functions", () => { 277 let ctx: AppContext; 278 279 beforeEach(async () => { 280 ctx = await createTestContext(); 281 }); 282 283 describe("checkPermission", () => { 284 it("returns true when user has required permission", async () => { 285 // Will implement after creating the function 286 expect(true).toBe(true); 287 }); 288 289 it("returns true for Owner role with wildcard permission", async () => { 290 expect(true).toBe(true); 291 }); 292 293 it("returns false when user has no role assigned", async () => { 294 expect(true).toBe(true); 295 }); 296 297 it("returns false when user's role is deleted (fail closed)", async () => { 298 expect(true).toBe(true); 299 }); 300 301 it("returns false when user has no membership", async () => { 302 expect(true).toBe(true); 303 }); 304 }); 305 306 describe("checkMinRole", () => { 307 it("returns true when user has exact role match", async () => { 308 expect(true).toBe(true); 309 }); 310 311 it("returns true when user has higher authority role", async () => { 312 expect(true).toBe(true); 313 }); 314 315 it("returns false when user has lower authority role", async () => { 316 expect(true).toBe(true); 317 }); 318 }); 319 320 describe("canActOnUser", () => { 321 it("returns true when actor is acting on themselves", async () => { 322 expect(true).toBe(true); 323 }); 324 325 it("returns true when actor has higher authority", async () => { 326 expect(true).toBe(true); 327 }); 328 329 it("returns false when actor has equal authority", async () => { 330 expect(true).toBe(true); 331 }); 332 333 it("returns false when actor has lower authority", async () => { 334 expect(true).toBe(true); 335 }); 336 }); 337}); 338``` 339 340**Step 2: Run tests to verify they pass (placeholder tests)** 341 342```bash 343cd apps/appview 344pnpm exec vitest run src/middleware/__tests__/permissions.test.ts 345``` 346 347Expected: All tests pass (placeholders). 348 349**Step 3: Commit** 350 351```bash 352git add apps/appview/src/middleware/__tests__/permissions.test.ts 353git commit -m "test: add permission helpers test skeleton" 354``` 355 356--- 357 358## Task 5: Permission Helper Functions - Implementation (Part 1) 359 360**Files:** 361- Create: `apps/appview/src/middleware/permissions.ts` 362 363**Step 1: Create permissions.ts with imports and types** 364 365```typescript 366import type { Context, Next } from "hono"; 367import type { AppContext } from "../lib/app-context.js"; 368import type { Variables } from "../types.js"; 369import { memberships, roles } from "@atbb/db"; 370import { eq, and } from "drizzle-orm"; 371 372/** 373 * Check if a user has a specific permission. 374 * 375 * @returns true if user has permission, false otherwise 376 * 377 * Returns false (fail closed) if: 378 * - User has no membership 379 * - User has no role assigned (roleUri is null) 380 * - Role not found in database (deleted or invalid) 381 */ 382async function checkPermission( 383 ctx: AppContext, 384 did: string, 385 permission: string 386): Promise<boolean> { 387 try { 388 // 1. Get user's membership (includes roleUri) 389 const [membership] = await ctx.db 390 .select() 391 .from(memberships) 392 .where(eq(memberships.did, did)) 393 .limit(1); 394 395 if (!membership || !membership.roleUri) { 396 return false; // No membership or no role assigned = Guest (no permissions) 397 } 398 399 // 2. Extract rkey from roleUri 400 const roleRkey = membership.roleUri.split("/").pop(); 401 if (!roleRkey) { 402 return false; 403 } 404 405 // 3. Fetch role definition from roles table 406 const [role] = await ctx.db 407 .select() 408 .from(roles) 409 .where( 410 and( 411 eq(roles.did, ctx.config.forumDid), 412 eq(roles.rkey, roleRkey) 413 ) 414 ) 415 .limit(1); 416 417 if (!role) { 418 return false; // Role not found = treat as Guest (fail closed) 419 } 420 421 // 4. Check for wildcard (Owner role) 422 if (role.permissions.includes("*")) { 423 return true; 424 } 425 426 // 5. Check if specific permission is in role's permissions array 427 return role.permissions.includes(permission); 428 } catch (error) { 429 console.error("Failed to check permissions", { 430 operation: "checkPermission", 431 did, 432 permission, 433 error: error instanceof Error ? error.message : String(error), 434 }); 435 436 // Fail closed: deny access on database errors 437 return false; 438 } 439} 440 441/** 442 * Get a user's role definition. 443 * 444 * @returns Role object or null if user has no role 445 */ 446async function getUserRole( 447 ctx: AppContext, 448 did: string 449): Promise<{ id: bigint; name: string; priority: number; permissions: string[] } | null> { 450 const [membership] = await ctx.db 451 .select() 452 .from(memberships) 453 .where(eq(memberships.did, did)) 454 .limit(1); 455 456 if (!membership || !membership.roleUri) { 457 return null; 458 } 459 460 const roleRkey = membership.roleUri.split("/").pop(); 461 if (!roleRkey) { 462 return null; 463 } 464 465 const [role] = await ctx.db 466 .select({ 467 id: roles.id, 468 name: roles.name, 469 priority: roles.priority, 470 permissions: roles.permissions, 471 }) 472 .from(roles) 473 .where( 474 and( 475 eq(roles.did, ctx.config.forumDid), 476 eq(roles.rkey, roleRkey) 477 ) 478 ) 479 .limit(1); 480 481 return role || null; 482} 483 484/** 485 * Check if a user has a minimum role level. 486 * 487 * @param minRole - Minimum required role name 488 * @returns true if user's role priority <= required priority (higher authority) 489 */ 490async function checkMinRole( 491 ctx: AppContext, 492 did: string, 493 minRole: string 494): Promise<boolean> { 495 const rolePriorities: Record<string, number> = { 496 owner: 0, 497 admin: 10, 498 moderator: 20, 499 member: 30, 500 }; 501 502 const userRole = await getUserRole(ctx, did); 503 504 if (!userRole) { 505 return false; // No role = Guest (fails all role checks) 506 } 507 508 const userPriority = userRole.priority; 509 const requiredPriority = rolePriorities[minRole]; 510 511 // Lower priority value = higher authority 512 return userPriority <= requiredPriority; 513} 514 515/** 516 * Check if an actor can perform moderation actions on a target user. 517 * 518 * Priority hierarchy enforcement: 519 * - Users can always act on themselves (self-action bypass) 520 * - Can only act on users with strictly lower authority (higher priority value) 521 * - Cannot act on users with equal or higher authority 522 * 523 * @returns true if actor can act on target, false otherwise 524 */ 525export async function canActOnUser( 526 ctx: AppContext, 527 actorDid: string, 528 targetDid: string 529): Promise<boolean> { 530 // Users can always act on themselves 531 if (actorDid === targetDid) { 532 return true; 533 } 534 535 const actorRole = await getUserRole(ctx, actorDid); 536 const targetRole = await getUserRole(ctx, targetDid); 537 538 // If actor has no role, they can't act on anyone else 539 if (!actorRole) { 540 return false; 541 } 542 543 // If target has no role (Guest), anyone with a role can act on them 544 if (!targetRole) { 545 return true; 546 } 547 548 // Lower priority = higher authority 549 // Can only act on users with strictly higher priority value (lower authority) 550 return actorRole.priority < targetRole.priority; 551} 552 553// Export helpers for testing 554export { checkPermission, getUserRole, checkMinRole }; 555``` 556 557**Step 2: Commit** 558 559```bash 560git add apps/appview/src/middleware/permissions.ts 561git commit -m "feat(middleware): add permission helper functions 562 563- checkPermission: lookup permission with wildcard support 564- getUserRole: shared role lookup helper 565- checkMinRole: priority-based role comparison 566- canActOnUser: priority hierarchy enforcement 567- All helpers fail closed on missing data" 568``` 569 570--- 571 572## Task 6: Permission Helper Functions - Unit Tests (Part 2) 573 574**Files:** 575- Modify: `apps/appview/src/middleware/__tests__/permissions.test.ts` 576 577**Step 1: Implement test for "returns true when user has required permission"** 578 579Replace the placeholder test: 580 581```typescript 582it("returns true when user has required permission", async () => { 583 // Import the helper (add at top of file) 584 const { checkPermission } = await import("../permissions.js"); 585 586 // Create a test role with createTopics permission 587 const [role] = await ctx.db 588 .insert(roles) 589 .values({ 590 did: ctx.config.forumDid, 591 rkey: "test-role-123", 592 cid: "test-cid", 593 name: "Member", 594 description: "Test member role", 595 permissions: ["space.atbb.permission.createTopics"], 596 priority: 30, 597 createdAt: new Date(), 598 indexedAt: new Date(), 599 }) 600 .returning(); 601 602 // Create a test user 603 await ctx.db.insert(users).values({ 604 did: "did:plc:testuser", 605 handle: "testuser.bsky.social", 606 indexedAt: new Date(), 607 }); 608 609 // Create membership with roleUri pointing to test role 610 await ctx.db.insert(memberships).values({ 611 did: "did:plc:testuser", 612 rkey: "membership-123", 613 cid: "test-cid", 614 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 615 roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/test-role-123`, 616 createdAt: new Date(), 617 indexedAt: new Date(), 618 }); 619 620 const result = await checkPermission( 621 ctx, 622 "did:plc:testuser", 623 "space.atbb.permission.createTopics" 624 ); 625 626 expect(result).toBe(true); 627}); 628``` 629 630**Step 2: Run test to verify it passes** 631 632```bash 633pnpm exec vitest run src/middleware/__tests__/permissions.test.ts -t "returns true when user has required permission" 634``` 635 636Expected: Test passes. 637 638**Step 3: Implement remaining checkPermission tests** 639 640Replace other placeholder tests in checkPermission describe block: 641 642```typescript 643it("returns true for Owner role with wildcard permission", async () => { 644 const { checkPermission } = await import("../permissions.js"); 645 646 // Create Owner role with wildcard 647 await ctx.db.insert(roles).values({ 648 did: ctx.config.forumDid, 649 rkey: "owner-role", 650 cid: "test-cid", 651 name: "Owner", 652 description: "Forum owner", 653 permissions: ["*"], // Wildcard 654 priority: 0, 655 createdAt: new Date(), 656 indexedAt: new Date(), 657 }); 658 659 await ctx.db.insert(users).values({ 660 did: "did:plc:owner", 661 handle: "owner.bsky.social", 662 indexedAt: new Date(), 663 }); 664 665 await ctx.db.insert(memberships).values({ 666 did: "did:plc:owner", 667 rkey: "membership-123", 668 cid: "test-cid", 669 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 670 roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/owner-role`, 671 createdAt: new Date(), 672 indexedAt: new Date(), 673 }); 674 675 // Should return true for ANY permission 676 const result = await checkPermission( 677 ctx, 678 "did:plc:owner", 679 "space.atbb.permission.someRandomPermission" 680 ); 681 682 expect(result).toBe(true); 683}); 684 685it("returns false when user has no role assigned", async () => { 686 const { checkPermission } = await import("../permissions.js"); 687 688 await ctx.db.insert(users).values({ 689 did: "did:plc:norole", 690 handle: "norole.bsky.social", 691 indexedAt: new Date(), 692 }); 693 694 // Create membership with roleUri = null 695 await ctx.db.insert(memberships).values({ 696 did: "did:plc:norole", 697 rkey: "membership-123", 698 cid: "test-cid", 699 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 700 roleUri: null, // No role assigned 701 createdAt: new Date(), 702 indexedAt: new Date(), 703 }); 704 705 const result = await checkPermission( 706 ctx, 707 "did:plc:norole", 708 "space.atbb.permission.createTopics" 709 ); 710 711 expect(result).toBe(false); 712}); 713 714it("returns false when user's role is deleted (fail closed)", async () => { 715 const { checkPermission } = await import("../permissions.js"); 716 717 await ctx.db.insert(users).values({ 718 did: "did:plc:deletedrole", 719 handle: "deletedrole.bsky.social", 720 indexedAt: new Date(), 721 }); 722 723 // Create membership with roleUri pointing to non-existent role 724 await ctx.db.insert(memberships).values({ 725 did: "did:plc:deletedrole", 726 rkey: "membership-123", 727 cid: "test-cid", 728 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 729 roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/deleted-role`, 730 createdAt: new Date(), 731 indexedAt: new Date(), 732 }); 733 734 const result = await checkPermission( 735 ctx, 736 "did:plc:deletedrole", 737 "space.atbb.permission.createTopics" 738 ); 739 740 expect(result).toBe(false); // Fail closed 741}); 742 743it("returns false when user has no membership", async () => { 744 const { checkPermission } = await import("../permissions.js"); 745 746 await ctx.db.insert(users).values({ 747 did: "did:plc:nomembership", 748 handle: "nomembership.bsky.social", 749 indexedAt: new Date(), 750 }); 751 752 // No membership record created 753 754 const result = await checkPermission( 755 ctx, 756 "did:plc:nomembership", 757 "space.atbb.permission.createTopics" 758 ); 759 760 expect(result).toBe(false); 761}); 762``` 763 764**Step 4: Implement checkMinRole tests** 765 766Replace checkMinRole placeholders: 767 768```typescript 769it("returns true when user has exact role match", async () => { 770 const { checkMinRole } = await import("../permissions.js"); 771 772 await ctx.db.insert(roles).values({ 773 did: ctx.config.forumDid, 774 rkey: "admin-role", 775 cid: "test-cid", 776 name: "Admin", 777 permissions: [], 778 priority: 10, 779 createdAt: new Date(), 780 indexedAt: new Date(), 781 }); 782 783 await ctx.db.insert(users).values({ 784 did: "did:plc:admin", 785 handle: "admin.bsky.social", 786 indexedAt: new Date(), 787 }); 788 789 await ctx.db.insert(memberships).values({ 790 did: "did:plc:admin", 791 rkey: "membership-123", 792 cid: "test-cid", 793 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 794 roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/admin-role`, 795 createdAt: new Date(), 796 indexedAt: new Date(), 797 }); 798 799 const result = await checkMinRole(ctx, "did:plc:admin", "admin"); 800 801 expect(result).toBe(true); 802}); 803 804it("returns true when user has higher authority role", async () => { 805 const { checkMinRole } = await import("../permissions.js"); 806 807 // Owner (priority 0) should pass admin check (priority 10) 808 await ctx.db.insert(roles).values({ 809 did: ctx.config.forumDid, 810 rkey: "owner-role", 811 cid: "test-cid", 812 name: "Owner", 813 permissions: ["*"], 814 priority: 0, 815 createdAt: new Date(), 816 indexedAt: new Date(), 817 }); 818 819 await ctx.db.insert(users).values({ 820 did: "did:plc:owner", 821 handle: "owner.bsky.social", 822 indexedAt: new Date(), 823 }); 824 825 await ctx.db.insert(memberships).values({ 826 did: "did:plc:owner", 827 rkey: "membership-123", 828 cid: "test-cid", 829 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 830 roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/owner-role`, 831 createdAt: new Date(), 832 indexedAt: new Date(), 833 }); 834 835 const result = await checkMinRole(ctx, "did:plc:owner", "admin"); 836 837 expect(result).toBe(true); // Owner > Admin 838}); 839 840it("returns false when user has lower authority role", async () => { 841 const { checkMinRole } = await import("../permissions.js"); 842 843 // Moderator (priority 20) should fail admin check (priority 10) 844 await ctx.db.insert(roles).values({ 845 did: ctx.config.forumDid, 846 rkey: "mod-role", 847 cid: "test-cid", 848 name: "Moderator", 849 permissions: [], 850 priority: 20, 851 createdAt: new Date(), 852 indexedAt: new Date(), 853 }); 854 855 await ctx.db.insert(users).values({ 856 did: "did:plc:mod", 857 handle: "mod.bsky.social", 858 indexedAt: new Date(), 859 }); 860 861 await ctx.db.insert(memberships).values({ 862 did: "did:plc:mod", 863 rkey: "membership-123", 864 cid: "test-cid", 865 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 866 roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/mod-role`, 867 createdAt: new Date(), 868 indexedAt: new Date(), 869 }); 870 871 const result = await checkMinRole(ctx, "did:plc:mod", "admin"); 872 873 expect(result).toBe(false); // Moderator < Admin 874}); 875``` 876 877**Step 5: Implement canActOnUser tests** 878 879Replace canActOnUser placeholders: 880 881```typescript 882it("returns true when actor is acting on themselves", async () => { 883 const { canActOnUser } = await import("../permissions.js"); 884 885 const result = await canActOnUser( 886 ctx, 887 "did:plc:testuser", 888 "did:plc:testuser" // Same DID 889 ); 890 891 expect(result).toBe(true); // Self-action bypass 892}); 893 894it("returns true when actor has higher authority", async () => { 895 const { canActOnUser } = await import("../permissions.js"); 896 897 // Create Admin role (priority 10) 898 await ctx.db.insert(roles).values({ 899 did: ctx.config.forumDid, 900 rkey: "admin-role", 901 cid: "test-cid", 902 name: "Admin", 903 permissions: [], 904 priority: 10, 905 createdAt: new Date(), 906 indexedAt: new Date(), 907 }); 908 909 // Create Moderator role (priority 20) 910 await ctx.db.insert(roles).values({ 911 did: ctx.config.forumDid, 912 rkey: "mod-role", 913 cid: "test-cid", 914 name: "Moderator", 915 permissions: [], 916 priority: 20, 917 createdAt: new Date(), 918 indexedAt: new Date(), 919 }); 920 921 // Admin user 922 await ctx.db.insert(users).values({ 923 did: "did:plc:admin", 924 handle: "admin.bsky.social", 925 indexedAt: new Date(), 926 }); 927 928 await ctx.db.insert(memberships).values({ 929 did: "did:plc:admin", 930 rkey: "membership-123", 931 cid: "test-cid", 932 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 933 roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/admin-role`, 934 createdAt: new Date(), 935 indexedAt: new Date(), 936 }); 937 938 // Moderator user 939 await ctx.db.insert(users).values({ 940 did: "did:plc:mod", 941 handle: "mod.bsky.social", 942 indexedAt: new Date(), 943 }); 944 945 await ctx.db.insert(memberships).values({ 946 did: "did:plc:mod", 947 rkey: "membership-456", 948 cid: "test-cid", 949 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 950 roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/mod-role`, 951 createdAt: new Date(), 952 indexedAt: new Date(), 953 }); 954 955 const result = await canActOnUser(ctx, "did:plc:admin", "did:plc:mod"); 956 957 expect(result).toBe(true); // Admin (10) can act on Moderator (20) 958}); 959 960it("returns false when actor has equal authority", async () => { 961 const { canActOnUser } = await import("../permissions.js"); 962 963 // Create Admin role 964 await ctx.db.insert(roles).values({ 965 did: ctx.config.forumDid, 966 rkey: "admin-role", 967 cid: "test-cid", 968 name: "Admin", 969 permissions: [], 970 priority: 10, 971 createdAt: new Date(), 972 indexedAt: new Date(), 973 }); 974 975 // Admin user 1 976 await ctx.db.insert(users).values({ 977 did: "did:plc:admin1", 978 handle: "admin1.bsky.social", 979 indexedAt: new Date(), 980 }); 981 982 await ctx.db.insert(memberships).values({ 983 did: "did:plc:admin1", 984 rkey: "membership-123", 985 cid: "test-cid", 986 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 987 roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/admin-role`, 988 createdAt: new Date(), 989 indexedAt: new Date(), 990 }); 991 992 // Admin user 2 993 await ctx.db.insert(users).values({ 994 did: "did:plc:admin2", 995 handle: "admin2.bsky.social", 996 indexedAt: new Date(), 997 }); 998 999 await ctx.db.insert(memberships).values({ 1000 did: "did:plc:admin2", 1001 rkey: "membership-456", 1002 cid: "test-cid", 1003 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 1004 roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/admin-role`, 1005 createdAt: new Date(), 1006 indexedAt: new Date(), 1007 }); 1008 1009 const result = await canActOnUser(ctx, "did:plc:admin1", "did:plc:admin2"); 1010 1011 expect(result).toBe(false); // Admin (10) cannot act on Admin (10) 1012}); 1013 1014it("returns false when actor has lower authority", async () => { 1015 const { canActOnUser } = await import("../permissions.js"); 1016 1017 // Create Admin role (priority 10) 1018 await ctx.db.insert(roles).values({ 1019 did: ctx.config.forumDid, 1020 rkey: "admin-role", 1021 cid: "test-cid", 1022 name: "Admin", 1023 permissions: [], 1024 priority: 10, 1025 createdAt: new Date(), 1026 indexedAt: new Date(), 1027 }); 1028 1029 // Create Moderator role (priority 20) 1030 await ctx.db.insert(roles).values({ 1031 did: ctx.config.forumDid, 1032 rkey: "mod-role", 1033 cid: "test-cid", 1034 name: "Moderator", 1035 permissions: [], 1036 priority: 20, 1037 createdAt: new Date(), 1038 indexedAt: new Date(), 1039 }); 1040 1041 // Admin user 1042 await ctx.db.insert(users).values({ 1043 did: "did:plc:admin", 1044 handle: "admin.bsky.social", 1045 indexedAt: new Date(), 1046 }); 1047 1048 await ctx.db.insert(memberships).values({ 1049 did: "did:plc:admin", 1050 rkey: "membership-123", 1051 cid: "test-cid", 1052 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 1053 roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/admin-role`, 1054 createdAt: new Date(), 1055 indexedAt: new Date(), 1056 }); 1057 1058 // Moderator user 1059 await ctx.db.insert(users).values({ 1060 did: "did:plc:mod", 1061 handle: "mod.bsky.social", 1062 indexedAt: new Date(), 1063 }); 1064 1065 await ctx.db.insert(memberships).values({ 1066 did: "did:plc:mod", 1067 rkey: "membership-456", 1068 cid: "test-cid", 1069 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 1070 roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/mod-role`, 1071 createdAt: new Date(), 1072 indexedAt: new Date(), 1073 }); 1074 1075 const result = await canActOnUser(ctx, "did:plc:mod", "did:plc:admin"); 1076 1077 expect(result).toBe(false); // Moderator (20) cannot act on Admin (10) 1078}); 1079``` 1080 1081**Step 6: Run all unit tests** 1082 1083```bash 1084pnpm exec vitest run src/middleware/__tests__/permissions.test.ts 1085``` 1086 1087Expected: All 13 tests pass. 1088 1089**Step 7: Commit** 1090 1091```bash 1092git add apps/appview/src/middleware/__tests__/permissions.test.ts 1093git commit -m "test: add unit tests for permission helper functions 1094 1095- 13 tests covering checkPermission, checkMinRole, canActOnUser 1096- Test wildcard permissions, fail-closed behavior, priority hierarchy 1097- All tests passing" 1098``` 1099 1100--- 1101 1102## Task 7: Permission Middleware Functions 1103 1104**Files:** 1105- Modify: `apps/appview/src/middleware/permissions.ts` (add middleware functions) 1106 1107**Step 1: Add middleware factory functions** 1108 1109Add to the top of `permissions.ts` after the helper functions: 1110 1111```typescript 1112/** 1113 * Require specific permission middleware. 1114 * 1115 * Validates that the authenticated user has the required permission token. 1116 * Returns 401 if not authenticated, 403 if authenticated but lacks permission. 1117 */ 1118export function requirePermission( 1119 ctx: AppContext, 1120 permission: string 1121) { 1122 return async (c: Context<{ Variables: Variables }>, next: Next) => { 1123 const user = c.get("user"); 1124 1125 if (!user) { 1126 return c.json({ error: "Authentication required" }, 401); 1127 } 1128 1129 const hasPermission = await checkPermission(ctx, user.did, permission); 1130 1131 if (!hasPermission) { 1132 return c.json({ 1133 error: "Insufficient permissions", 1134 required: permission 1135 }, 403); 1136 } 1137 1138 await next(); 1139 }; 1140} 1141 1142/** 1143 * Require minimum role middleware. 1144 * 1145 * Validates that the authenticated user has a role with sufficient priority. 1146 * Returns 401 if not authenticated, 403 if authenticated but insufficient role. 1147 */ 1148export function requireRole( 1149 ctx: AppContext, 1150 minRole: "owner" | "admin" | "moderator" | "member" 1151) { 1152 return async (c: Context<{ Variables: Variables }>, next: Next) => { 1153 const user = c.get("user"); 1154 1155 if (!user) { 1156 return c.json({ error: "Authentication required" }, 401); 1157 } 1158 1159 const hasRole = await checkMinRole(ctx, user.did, minRole); 1160 1161 if (!hasRole) { 1162 return c.json({ 1163 error: "Insufficient role", 1164 required: minRole 1165 }, 403); 1166 } 1167 1168 await next(); 1169 }; 1170} 1171``` 1172 1173**Step 2: Commit** 1174 1175```bash 1176git add apps/appview/src/middleware/permissions.ts 1177git commit -m "feat(middleware): add requirePermission and requireRole middleware 1178 1179- requirePermission: enforce specific permission tokens 1180- requireRole: enforce minimum role level 1181- Both return 401 for unauthenticated, 403 for insufficient permissions" 1182``` 1183 1184--- 1185 1186## Task 8: Admin Routes - Test Skeleton 1187 1188**Files:** 1189- Create: `apps/appview/src/routes/__tests__/admin.test.ts` 1190 1191**Step 1: Create admin routes test skeleton** 1192 1193```typescript 1194import { describe, it, expect, beforeEach } from "vitest"; 1195import { createTestContext } from "../../lib/__tests__/test-context.js"; 1196import type { AppContext } from "../../lib/app-context.js"; 1197import { createApp } from "../../lib/create-app.js"; 1198import { createAdminRoutes } from "../admin.js"; 1199import type { Hono } from "hono"; 1200import { roles, memberships, users } from "@atbb/db"; 1201 1202describe("Admin Routes", () => { 1203 let ctx: AppContext; 1204 let app: Hono; 1205 1206 beforeEach(async () => { 1207 ctx = await createTestContext(); 1208 const mainApp = createApp(ctx); 1209 mainApp.route("/api/admin", createAdminRoutes(ctx)); 1210 app = mainApp; 1211 }); 1212 1213 describe("POST /api/admin/members/:did/role", () => { 1214 it("assigns role successfully when admin has authority", async () => { 1215 expect(true).toBe(true); 1216 }); 1217 1218 it("returns 403 when assigning role with equal authority", async () => { 1219 expect(true).toBe(true); 1220 }); 1221 1222 it("returns 403 when assigning role with higher authority", async () => { 1223 expect(true).toBe(true); 1224 }); 1225 1226 it("returns 404 when role not found", async () => { 1227 expect(true).toBe(true); 1228 }); 1229 1230 it("returns 404 when target user not a member", async () => { 1231 expect(true).toBe(true); 1232 }); 1233 1234 it("returns 403 when user lacks manageRoles permission", async () => { 1235 expect(true).toBe(true); 1236 }); 1237 }); 1238 1239 describe("GET /api/admin/roles", () => { 1240 it("lists all roles sorted by priority", async () => { 1241 expect(true).toBe(true); 1242 }); 1243 1244 it("returns 403 for non-admin users", async () => { 1245 expect(true).toBe(true); 1246 }); 1247 }); 1248 1249 describe("GET /api/admin/members", () => { 1250 it("lists members with assigned roles", async () => { 1251 expect(true).toBe(true); 1252 }); 1253 1254 it("shows Guest for members with no role", async () => { 1255 expect(true).toBe(true); 1256 }); 1257 }); 1258}); 1259``` 1260 1261**Step 2: Run tests (will fail - routes don't exist yet)** 1262 1263```bash 1264pnpm exec vitest run src/routes/__tests__/admin.test.ts 1265``` 1266 1267Expected: Import error - `admin.js` doesn't exist. 1268 1269**Step 3: Commit** 1270 1271```bash 1272git add apps/appview/src/routes/__tests__/admin.test.ts 1273git commit -m "test: add admin routes test skeleton" 1274``` 1275 1276--- 1277 1278## Task 9: Admin Routes - Implementation 1279 1280**Files:** 1281- Create: `apps/appview/src/routes/admin.ts` 1282 1283**Step 1: Create admin routes file** 1284 1285Create `apps/appview/src/routes/admin.ts`: 1286 1287```typescript 1288import { Hono } from "hono"; 1289import type { AppContext } from "../lib/app-context.js"; 1290import type { Variables } from "../types.js"; 1291import { requirePermission, getUserRole } from "../middleware/permissions.js"; 1292import { memberships, roles, users } from "@atbb/db"; 1293import { eq, and, sql, asc } from "drizzle-orm"; 1294 1295export function createAdminRoutes(ctx: AppContext) { 1296 const app = new Hono<{ Variables: Variables }>(); 1297 1298 /** 1299 * POST /api/admin/members/:did/role 1300 * 1301 * Assign a role to a forum member. 1302 */ 1303 app.post( 1304 "/members/:did/role", 1305 requirePermission(ctx, "space.atbb.permission.manageRoles"), 1306 async (c) => { 1307 const targetDid = c.req.param("did"); 1308 const user = c.get("user")!; 1309 1310 // Parse and validate request body 1311 let body: any; 1312 try { 1313 body = await c.req.json(); 1314 } catch { 1315 return c.json({ error: "Invalid JSON in request body" }, 400); 1316 } 1317 1318 const { roleUri } = body; 1319 1320 if (typeof roleUri !== "string") { 1321 return c.json({ error: "roleUri is required and must be a string" }, 400); 1322 } 1323 1324 // Extract role rkey from roleUri 1325 const roleRkey = roleUri.split("/").pop(); 1326 if (!roleRkey) { 1327 return c.json({ error: "Invalid roleUri format" }, 400); 1328 } 1329 1330 // Validate role exists 1331 const [role] = await ctx.db 1332 .select() 1333 .from(roles) 1334 .where( 1335 and( 1336 eq(roles.did, ctx.config.forumDid), 1337 eq(roles.rkey, roleRkey) 1338 ) 1339 ) 1340 .limit(1); 1341 1342 if (!role) { 1343 return c.json({ error: "Role not found" }, 404); 1344 } 1345 1346 // Priority check: Can't assign role with equal or higher authority 1347 const assignerRole = await getUserRole(ctx, user.did); 1348 if (!assignerRole) { 1349 return c.json({ error: "You do not have a role assigned" }, 403); 1350 } 1351 1352 if (role.priority <= assignerRole.priority) { 1353 return c.json({ 1354 error: "Cannot assign role with equal or higher authority", 1355 yourPriority: assignerRole.priority, 1356 targetRolePriority: role.priority 1357 }, 403); 1358 } 1359 1360 // Get target user's membership 1361 const [membership] = await ctx.db 1362 .select() 1363 .from(memberships) 1364 .where(eq(memberships.did, targetDid)) 1365 .limit(1); 1366 1367 if (!membership) { 1368 return c.json({ error: "User is not a member of this forum" }, 404); 1369 } 1370 1371 try { 1372 // Update membership record on user's PDS using ForumAgent 1373 await ctx.forumAgent.agent.com.atproto.repo.putRecord({ 1374 repo: targetDid, 1375 collection: "space.atbb.membership", 1376 rkey: membership.rkey, 1377 record: { 1378 $type: "space.atbb.membership", 1379 forum: { forum: { uri: membership.forumUri, cid: "" } }, 1380 role: { role: { uri: roleUri, cid: role.cid } }, 1381 joinedAt: membership.joinedAt?.toISOString(), 1382 createdAt: membership.createdAt.toISOString(), 1383 }, 1384 }); 1385 1386 return c.json({ 1387 success: true, 1388 roleAssigned: role.name, 1389 targetDid, 1390 }); 1391 } catch (error) { 1392 console.error("Failed to assign role", { 1393 operation: "POST /api/admin/members/:did/role", 1394 targetDid, 1395 roleUri, 1396 error: error instanceof Error ? error.message : String(error), 1397 }); 1398 1399 return c.json({ 1400 error: "Failed to assign role. Please try again later.", 1401 }, 500); 1402 } 1403 } 1404 ); 1405 1406 /** 1407 * GET /api/admin/roles 1408 * 1409 * List all available roles for the forum. 1410 */ 1411 app.get( 1412 "/roles", 1413 requirePermission(ctx, "space.atbb.permission.manageRoles"), 1414 async (c) => { 1415 try { 1416 const rolesList = await ctx.db 1417 .select({ 1418 id: roles.id, 1419 name: roles.name, 1420 description: roles.description, 1421 permissions: roles.permissions, 1422 priority: roles.priority, 1423 }) 1424 .from(roles) 1425 .where(eq(roles.did, ctx.config.forumDid)) 1426 .orderBy(asc(roles.priority)); 1427 1428 return c.json({ 1429 roles: rolesList.map(role => ({ 1430 id: role.id.toString(), 1431 name: role.name, 1432 description: role.description, 1433 permissions: role.permissions, 1434 priority: role.priority, 1435 })), 1436 }); 1437 } catch (error) { 1438 console.error("Failed to list roles", { 1439 operation: "GET /api/admin/roles", 1440 error: error instanceof Error ? error.message : String(error), 1441 }); 1442 1443 return c.json({ 1444 error: "Failed to retrieve roles. Please try again later.", 1445 }, 500); 1446 } 1447 } 1448 ); 1449 1450 /** 1451 * GET /api/admin/members 1452 * 1453 * List all forum members with their assigned roles. 1454 */ 1455 app.get( 1456 "/members", 1457 requirePermission(ctx, "space.atbb.permission.manageMembers"), 1458 async (c) => { 1459 try { 1460 const membersList = await ctx.db 1461 .select({ 1462 did: memberships.did, 1463 handle: users.handle, 1464 role: roles.name, 1465 roleUri: memberships.roleUri, 1466 joinedAt: memberships.joinedAt, 1467 }) 1468 .from(memberships) 1469 .leftJoin(users, eq(memberships.did, users.did)) 1470 .leftJoin( 1471 roles, 1472 sql`${memberships.roleUri} LIKE 'at://' || ${roles.did} || '/space.atbb.forum.role/' || ${roles.rkey}` 1473 ) 1474 .where(eq(memberships.forumUri, `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`)) 1475 .orderBy(asc(roles.priority), asc(users.handle)) 1476 .limit(100); 1477 1478 return c.json({ 1479 members: membersList.map(member => ({ 1480 did: member.did, 1481 handle: member.handle || member.did, 1482 role: member.role || "Guest", 1483 roleUri: member.roleUri, 1484 joinedAt: member.joinedAt?.toISOString(), 1485 })), 1486 }); 1487 } catch (error) { 1488 console.error("Failed to list members", { 1489 operation: "GET /api/admin/members", 1490 error: error instanceof Error ? error.message : String(error), 1491 }); 1492 1493 return c.json({ 1494 error: "Failed to retrieve members. Please try again later.", 1495 }, 500); 1496 } 1497 } 1498 ); 1499 1500 return app; 1501} 1502``` 1503 1504**Step 2: Run tests (will fail - no auth setup yet)** 1505 1506```bash 1507pnpm exec vitest run src/routes/__tests__/admin.test.ts 1508``` 1509 1510Expected: Tests fail (routes exist but authentication not set up in tests). 1511 1512**Step 3: Commit** 1513 1514```bash 1515git add apps/appview/src/routes/admin.ts 1516git commit -m "feat(routes): add admin routes for role management 1517 1518- POST /api/admin/members/:did/role - assign roles 1519- GET /api/admin/roles - list available roles 1520- GET /api/admin/members - list members with roles 1521- All protected by permission middleware" 1522``` 1523 1524--- 1525 1526## Task 10: Role Seeding Script 1527 1528**Files:** 1529- Create: `apps/appview/src/lib/seed-roles.ts` 1530 1531**Step 1: Create seed-roles.ts** 1532 1533```typescript 1534import type { AppContext } from "./app-context.js"; 1535import { roles } from "@atbb/db"; 1536import { eq } from "drizzle-orm"; 1537 1538interface DefaultRole { 1539 name: string; 1540 description: string; 1541 permissions: string[]; 1542 priority: number; 1543} 1544 1545const DEFAULT_ROLES: DefaultRole[] = [ 1546 { 1547 name: "Owner", 1548 description: "Forum owner with full control", 1549 permissions: ["*"], 1550 priority: 0, 1551 }, 1552 { 1553 name: "Admin", 1554 description: "Can manage forum structure and users", 1555 permissions: [ 1556 "space.atbb.permission.manageCategories", 1557 "space.atbb.permission.manageRoles", 1558 "space.atbb.permission.manageMembers", 1559 "space.atbb.permission.moderatePosts", 1560 "space.atbb.permission.banUsers", 1561 "space.atbb.permission.pinTopics", 1562 "space.atbb.permission.lockTopics", 1563 "space.atbb.permission.createTopics", 1564 "space.atbb.permission.createPosts", 1565 ], 1566 priority: 10, 1567 }, 1568 { 1569 name: "Moderator", 1570 description: "Can moderate content and users", 1571 permissions: [ 1572 "space.atbb.permission.moderatePosts", 1573 "space.atbb.permission.banUsers", 1574 "space.atbb.permission.pinTopics", 1575 "space.atbb.permission.lockTopics", 1576 "space.atbb.permission.createTopics", 1577 "space.atbb.permission.createPosts", 1578 ], 1579 priority: 20, 1580 }, 1581 { 1582 name: "Member", 1583 description: "Regular forum member", 1584 permissions: [ 1585 "space.atbb.permission.createTopics", 1586 "space.atbb.permission.createPosts", 1587 ], 1588 priority: 30, 1589 }, 1590]; 1591 1592/** 1593 * Seed default roles to Forum DID's PDS. 1594 * 1595 * Idempotent: Checks for existing roles by name before creating. 1596 * Safe to run on every startup. 1597 */ 1598export async function seedDefaultRoles(ctx: AppContext): Promise<{ created: number; skipped: number }> { 1599 let created = 0; 1600 let skipped = 0; 1601 1602 for (const defaultRole of DEFAULT_ROLES) { 1603 try { 1604 // Check if role already exists by name 1605 const [existingRole] = await ctx.db 1606 .select() 1607 .from(roles) 1608 .where(eq(roles.name, defaultRole.name)) 1609 .limit(1); 1610 1611 if (existingRole) { 1612 console.log(`Role "${defaultRole.name}" already exists, skipping`, { 1613 operation: "seedDefaultRoles", 1614 roleName: defaultRole.name, 1615 }); 1616 skipped++; 1617 continue; 1618 } 1619 1620 // Create role record on Forum DID's PDS 1621 const result = await ctx.forumAgent.agent.com.atproto.repo.createRecord({ 1622 repo: ctx.config.forumDid, 1623 collection: "space.atbb.forum.role", 1624 record: { 1625 $type: "space.atbb.forum.role", 1626 name: defaultRole.name, 1627 description: defaultRole.description, 1628 permissions: defaultRole.permissions, 1629 priority: defaultRole.priority, 1630 createdAt: new Date().toISOString(), 1631 }, 1632 }); 1633 1634 console.log(`Created default role "${defaultRole.name}"`, { 1635 operation: "seedDefaultRoles", 1636 roleName: defaultRole.name, 1637 uri: result.uri, 1638 cid: result.cid, 1639 }); 1640 1641 created++; 1642 } catch (error) { 1643 console.error(`Failed to seed role "${defaultRole.name}"`, { 1644 operation: "seedDefaultRoles", 1645 roleName: defaultRole.name, 1646 error: error instanceof Error ? error.message : String(error), 1647 }); 1648 // Continue seeding other roles even if one fails 1649 } 1650 } 1651 1652 return { created, skipped }; 1653} 1654``` 1655 1656**Step 2: Commit** 1657 1658```bash 1659git add apps/appview/src/lib/seed-roles.ts 1660git commit -m "feat(lib): add role seeding script 1661 1662- Seed 4 default roles (Owner, Admin, Moderator, Member) 1663- Idempotent - checks for existing roles before creating 1664- Writes to Forum DID's PDS for proper firehose propagation" 1665``` 1666 1667--- 1668 1669## Task 11: Integrate Admin Routes and Seeding 1670 1671**Files:** 1672- Modify: `apps/appview/src/index.ts` (register admin routes, call seeding) 1673 1674**Step 1: Add admin routes import** 1675 1676Add to imports at top of `index.ts`: 1677 1678```typescript 1679import { createAdminRoutes } from "./routes/admin.js"; 1680import { seedDefaultRoles } from "./lib/seed-roles.js"; 1681``` 1682 1683**Step 2: Register admin routes** 1684 1685After other route registrations (around line 80), add: 1686 1687```typescript 1688app.route("/api/admin", createAdminRoutes(ctx)); 1689``` 1690 1691**Step 3: Add role seeding on startup** 1692 1693After ForumAgent initialization (around line 60), before starting the server: 1694 1695```typescript 1696// Seed default roles if enabled 1697if (process.env.SEED_DEFAULT_ROLES !== "false") { 1698 console.log("Seeding default roles..."); 1699 const result = await seedDefaultRoles(ctx); 1700 console.log("Default roles seeded", { 1701 created: result.created, 1702 skipped: result.skipped, 1703 }); 1704} else { 1705 console.log("Role seeding disabled via SEED_DEFAULT_ROLES=false"); 1706} 1707``` 1708 1709**Step 4: Commit** 1710 1711```bash 1712git add apps/appview/src/index.ts 1713git commit -m "feat(appview): integrate admin routes and role seeding 1714 1715- Register /api/admin routes 1716- Seed default roles on startup (configurable via env var) 1717- Runs after ForumAgent init, before server starts" 1718``` 1719 1720--- 1721 1722## Task 12: Update Existing Write Endpoints 1723 1724**Files:** 1725- Modify: `apps/appview/src/routes/topics.ts:13` (change requireAuth to requirePermission) 1726- Modify: `apps/appview/src/routes/posts.ts:13` (change requireAuth to requirePermission) 1727 1728**Step 1: Update topics.ts** 1729 1730Add import at top: 1731 1732```typescript 1733import { requirePermission } from "../middleware/permissions.js"; 1734``` 1735 1736Change line 13 from: 1737 1738```typescript 1739app.post("/", requireAuth(ctx), async (c) => { 1740``` 1741 1742To: 1743 1744```typescript 1745app.post("/", requirePermission(ctx, "space.atbb.permission.createTopics"), async (c) => { 1746``` 1747 1748**Step 2: Update posts.ts** 1749 1750Add import at top: 1751 1752```typescript 1753import { requirePermission } from "../middleware/permissions.js"; 1754``` 1755 1756Change line 13 from: 1757 1758```typescript 1759app.post("/", requireAuth(ctx), async (c) => { 1760``` 1761 1762To: 1763 1764```typescript 1765app.post("/", requirePermission(ctx, "space.atbb.permission.createPosts"), async (c) => { 1766``` 1767 1768**Step 3: Commit** 1769 1770```bash 1771git add apps/appview/src/routes/topics.ts apps/appview/src/routes/posts.ts 1772git commit -m "feat(routes): enforce permissions on topic/post creation 1773 1774- Replace requireAuth with requirePermission 1775- Require createTopics permission for POST /api/topics 1776- Require createPosts permission for POST /api/posts" 1777``` 1778 1779--- 1780 1781## Task 13: Environment Variables 1782 1783**Files:** 1784- Modify: `.env.example` (add SEED_DEFAULT_ROLES and DEFAULT_MEMBER_ROLE) 1785 1786**Step 1: Add env vars to .env.example** 1787 1788Add after existing env vars: 1789 1790```bash 1791# Role seeding 1792SEED_DEFAULT_ROLES=true # Set to "false" to disable auto-seeding on startup 1793DEFAULT_MEMBER_ROLE=Member # Role name to auto-assign to new memberships (empty for manual assignment) 1794``` 1795 1796**Step 2: Commit** 1797 1798```bash 1799git add .env.example 1800git commit -m "docs: add permission system env vars to .env.example 1801 1802- SEED_DEFAULT_ROLES: toggle role seeding on startup 1803- DEFAULT_MEMBER_ROLE: configurable default role for new members" 1804``` 1805 1806--- 1807 1808## Task 14: Run All Tests 1809 1810**Files:** 1811- Test all packages 1812 1813**Step 1: Run all tests** 1814 1815```bash 1816pnpm test 1817``` 1818 1819Expected: All tests pass (existing tests + new permission tests). 1820 1821**Step 2: If tests fail, fix issues** 1822 1823Common issues: 1824- Missing imports 1825- Type errors in test files 1826- Database cleanup issues 1827 1828**Step 3: Commit fixes if needed** 1829 1830```bash 1831git add <fixed-files> 1832git commit -m "fix: resolve test failures" 1833``` 1834 1835--- 1836 1837## Task 15: Manual Testing 1838 1839**Files:** 1840- None (manual testing via API) 1841 1842**Step 1: Start fresh database and run migrations** 1843 1844```bash 1845# Drop and recreate database 1846dropdb atbb_dev 1847createdb atbb_dev 1848cd packages/db 1849pnpm db:migrate 1850``` 1851 1852**Step 2: Start AppView** 1853 1854```bash 1855cd apps/appview 1856pnpm dev 1857``` 1858 1859Expected: Logs show "Created default role" for 4 roles. 1860 1861**Step 3: Verify roles in database** 1862 1863```bash 1864psql atbb_dev -c "SELECT name, priority, array_length(permissions, 1) as perm_count FROM roles ORDER BY priority;" 1865``` 1866 1867Expected: 1868``` 1869 name | priority | perm_count 1870----------+----------+------------ 1871 Owner | 0 | 1 1872 Admin | 10 | 9 1873 Moderator| 20 | 6 1874 Member | 30 | 2 1875``` 1876 1877**Step 4: Test permission enforcement** 1878 1879Try creating a topic without a role (should fail with 403): 1880 1881```bash 1882# This requires setting up OAuth first - defer to integration tests 1883``` 1884 1885**Step 5: Document manual testing completion** 1886 1887Manual testing checklist completed - basic seeding and database setup verified. 1888 1889--- 1890 1891## Task 16: Update Documentation 1892 1893**Files:** 1894- Modify: `docs/atproto-forum-plan.md:185-186` (mark ATB-17 complete) 1895 1896**Step 1: Mark ATB-17 complete in plan doc** 1897 1898In `docs/atproto-forum-plan.md`, update line 185-186: 1899 1900```markdown 1901- [x] Role assignment: admin can set roles via Forum DID records (ATB-17) — **Complete:** Full permission system implemented with 4 default roles, middleware enforcement, admin endpoints, and role seeding. Files: `apps/appview/src/middleware/permissions.ts`, `apps/appview/src/routes/admin.ts`, `apps/appview/src/lib/seed-roles.ts`, `packages/db/src/schema.ts:188-210` (2026-02-14) 1902- [x] Middleware: permission checks on write endpoints — **Complete:** `requirePermission()` and `requireRole()` middleware integrated on all write endpoints (`POST /api/topics`, `POST /api/posts`). Future mod endpoints will use `canActOnUser()` for priority hierarchy enforcement. 1903``` 1904 1905**Step 2: Commit** 1906 1907```bash 1908git add docs/atproto-forum-plan.md 1909git commit -m "docs: mark ATB-17 complete in project plan 1910 1911- Permission system fully implemented 1912- All acceptance criteria met 1913- 4 default roles seeded, middleware enforced, admin endpoints operational" 1914``` 1915 1916--- 1917 1918## Task 17: Final Verification 1919 1920**Files:** 1921- Run full test suite and manual checks 1922 1923**Step 1: Run full test suite** 1924 1925```bash 1926pnpm test 1927``` 1928 1929Expected: All tests pass. 1930 1931**Step 2: Run type checking** 1932 1933```bash 1934pnpm turbo lint 1935``` 1936 1937Expected: No type errors. 1938 1939**Step 3: Run linting** 1940 1941```bash 1942pnpm turbo lint:fix 1943``` 1944 1945Expected: No lint errors. 1946 1947**Step 4: Verify build** 1948 1949```bash 1950pnpm build 1951``` 1952 1953Expected: Clean build, no errors. 1954 1955**Step 5: Create final commit if fixes were needed** 1956 1957```bash 1958git add <any-fixed-files> 1959git commit -m "fix: final verification fixes" 1960``` 1961 1962--- 1963 1964## Success Criteria Checklist 1965 1966Verify all success criteria are met: 1967 1968- [ ] Only users with `createTopics` permission can create topics 1969- [ ] Only users with `createPosts` permission can create posts 1970- [ ] Admins can assign roles to members via `POST /api/admin/members/:did/role` 1971- [ ] Priority hierarchy prevents privilege escalation (Admin can't assign Admin/Owner roles) 1972- [ ] Permission checks complete in <10ms (database indexes in place) 1973- [ ] Default roles seeded automatically on fresh install 1974- [ ] All tests passing (100% coverage on permission logic) 1975- [ ] Error messages clear and actionable (401 vs 403 vs 404 vs 500) 1976 1977--- 1978 1979## Next Steps 1980 1981After completing this implementation: 1982 19831. **Update Linear:** Mark ATB-17 as Done with implementation notes 19842. **Test in production-like environment:** Deploy to staging, verify role assignment 19853. **Create PR:** Open PR with comprehensive description referencing design doc 19864. **Code review:** Request review from team (use @superpowers:requesting-code-review if needed) 19875. **Merge:** After approval, merge to main 19886. **Monitor:** Watch logs for permission check performance and errors 1989 1990--- 1991 1992## Future Enhancements (ATB-19+) 1993 1994This implementation provides the foundation for: 1995 1996- **ATB-19:** Moderation action write-path endpoints (will use `canActOnUser()` for priority enforcement) 1997- **ATB-20:** Moderation action indexing 1998- **ATB-21:** Ban enforcement 1999- **ATB-22:** Content visibility filtering 2000- **Post-MVP:** Permission caching, custom roles, per-category permissions