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

feat: implement role-based permission system (ATB-17) (#34)

* docs: design for role-based permission system (ATB-17)

- Complete design for implementing RBAC with 4 default roles
- Permission middleware with factory functions matching existing auth pattern
- Admin endpoints for role assignment and member management
- Default role seeding on startup with configurable auto-assignment
- Full priority hierarchy enforcement across all operations
- Comprehensive testing strategy with unit and integration tests
- Simple database queries (no caching for MVP)
- Fail-closed error handling (missing roles = Guest status)

* docs: implementation plan for permissions system (ATB-17)

- 17 bite-sized tasks with TDD approach
- Complete code for all components in plan
- Exact commands with expected outputs
- Follows existing patterns (factory functions, AppContext DI)
- Comprehensive test coverage (unit + integration tests)

* feat(db): add roles table for permission system

- Create roles table with permissions array and priority field
- Add indexes on did and did+name for efficient lookups
- Migration 0004_goofy_tigra.sql

* fix(db): add explicit default for roles.permissions and sql import

- Import sql from drizzle-orm for typed SQL literals
- Add .default(sql`'{}'::text[]`) to permissions array field
- Add missing newline at end of migration file

Addresses code review feedback for Task 1.

* test: add roles table to test cleanup

- Add roles import to test-context.ts
- Add roles cleanup in cleanDatabase function
- Add roles cleanup in cleanup function before forums deletion

Ensures test isolation when roles table has test data.

* feat(indexer): add role indexer for space.atbb.forum.role

- Add roleConfig with hard delete strategy
- Implement handleRoleCreate, handleRoleUpdate, handleRoleDelete
- Register handlers in firehose event registry
- Add roles import to database and lexicon imports

* fix(indexer): address code review feedback for role indexer

- Make priority field required in lexicon (needed for role hierarchy)
- Fix null coercion pattern: use ?? instead of || for consistency
- Add comprehensive test coverage for role indexer (4 tests)
- Test role creation with all fields
- Test role creation without optional description
- Test role updates
- Test role deletion

Addresses Important issues from code quality review.

* test: add permission helpers test skeleton

- Create test file with 12 placeholder tests
- Tests cover checkPermission, checkMinRole, canActOnUser
- Follows TDD: tests first, implementation next
- Imports will be added in Task 6 when real tests are written

* feat(middleware): add permission helper functions

- checkPermission: lookup permission with wildcard support
- getUserRole: shared role lookup helper
- checkMinRole: priority-based role comparison
- canActOnUser: priority hierarchy enforcement
- All helpers fail closed on missing data

* test: add comprehensive unit tests for permission helpers

- Tests checkPermission with role permissions and wildcard
- Tests checkMinRole for role hierarchy validation
- Tests canActOnUser for moderation authority checks
- All DIDs use did:plc:test-* pattern for cleanup compatibility
- 12 tests covering success and failure scenarios

* feat(middleware): add requirePermission and requireRole middleware

- requirePermission: enforce specific permission tokens
- requireRole: enforce minimum role level
- Both return 401 for unauthenticated, 403 for insufficient permissions

* test: add admin routes test skeleton

Tests will fail until admin.ts is implemented in Task 9

* feat(routes): add admin routes for role management

- POST /api/admin/members/:did/role - assign roles
- GET /api/admin/roles - list available roles
- GET /api/admin/members - list members with roles
- All protected by permission middleware
- Proper null checks for ForumAgent

* feat(lib): add role seeding script

- Seed 4 default roles (Owner, Admin, Moderator, Member)
- Idempotent - checks for existing roles before creating
- Writes to Forum DID's PDS for proper firehose propagation
- Includes null checks for ForumAgent availability

* feat(appview): integrate admin routes and role seeding

- Register /api/admin routes
- Seed default roles on startup (configurable via env var)
- Runs after app context init, before server starts

* feat: replace auth middleware with permission checks on write endpoints

- Replace requireAuth with requirePermission in topics.ts and posts.ts
- Topics require 'space.atbb.permission.createTopics' permission
- Posts require 'space.atbb.permission.createPosts' permission
- Add Variables type to Hono instances for type safety
- Update test mocks to use requirePermission instead of requireAuth
- All 36 route tests passing (topics: 22, posts: 14)

* docs: add permission system env vars to .env.example

- SEED_DEFAULT_ROLES: toggle role seeding on startup
- DEFAULT_MEMBER_ROLE: configurable default role for new members

* docs: mark ATB-17 complete in project plan

- Permission system fully implemented
- All acceptance criteria met
- 4 default roles seeded, middleware enforced, admin endpoints operational

* fix: regenerate roles migration with proper journal entry

The previous migration (0004_goofy_tigra.sql) was orphaned - the SQL file existed but wasn't registered in drizzle/meta/_journal.json. This caused drizzle-kit migrate to skip it in CI, resulting in 'relation roles does not exist' errors during tests.

Regenerated migration (0004_cute_bloodstorm.sql) is now properly tracked in the journal.

* fix: add requireAuth before requirePermission in write endpoints

Issue #1 from PR review: Authentication was completely broken because requirePermission assumed c.get('user') was set, but nothing was setting it after we removed requireAuth.

Fixed by restoring the middleware chain:
1. requireAuth(ctx) - restores OAuth session and sets c.get('user')
2. requirePermission(ctx, ...) - checks the user has required permission

Changes:
- topics.ts: Add requireAuth before requirePermission
- posts.ts: Add requireAuth before requirePermission
- topics.test.ts: Mock both auth and permission middleware
- posts.test.ts: Mock both auth and permission middleware

All route tests passing (36/36).

* fix: add error handling to getUserRole, fail closed

Issue #3 from PR review: getUserRole had no try-catch around database queries. DB errors would throw and bypass security checks in admin routes.

Fixed by wrapping entire function in try-catch that returns null (fail closed) on any error. This ensures that database failures deny access rather than bypass permission checks.

All permission tests passing (12/12).

* fix: throw error when ForumAgent unavailable during role seeding

Issue #4 from PR review: Role seeding returned success (created: 0, skipped: 0) when ForumAgent unavailable, but server started with zero roles. Permission system completely broken but appeared functional.

Fixed by throwing errors instead of silent returns:
- ForumAgent not available → throw Error with clear message
- ForumAgent not authenticated → throw Error with clear message

Now server startup fails fast with actionable error message rather than silently starting in broken state. Better to fail loudly than succeed silently with broken permissions.

* fix: narrow catch blocks to re-throw programming errors

Issue #6 from PR review: Broad catch blocks in checkPermission masked programming bugs (TypeError). A typo like role.permisions.includes() would be logged as 'permission denied' instead of crashing, making debugging impossible.

Fixed by checking error type before catching:
- TypeError, ReferenceError, SyntaxError → re-throw (programming bugs)
- Other errors (database, network) → catch and fail closed

Now programming errors crash immediately during development instead of silently denying access.

All permission tests passing (12/12).

* fix: fetch and use actual forum CID in role assignment

Issue #5 from PR review: Role assignment wrote to user repos with invalid CID (cid: '') which violates AT Protocol spec. Forum CID must be valid content hash.

Fixed by querying forum record from database before putRecord:
1. Fetch forum.cid from forums table
2. Return 500 if forum record not found (server misconfiguration)
3. Use actual CID in membership record: forum.cid instead of ''

Now membership records have valid AT Protocol references with proper content hashes.

All admin tests passing (10/10 - placeholders, will be replaced with real tests).

* fix: classify errors in admin role assignment endpoint

Issue #7 from PR review: Admin routes returned 500 for both network and server errors. Users couldn't distinguish retryable failures (PDS unreachable) from bugs needing investigation.

Fixed by classifying errors before returning response:
- Network errors (PDS connection failures) → 503 with message to retry
- Server errors (unexpected failures) → 500 with message to contact support

Uses isNetworkError() helper from routes/helpers.ts for consistent error classification across all endpoints.

All admin tests passing (10/10 - placeholders).

* docs: create Bruno API collections for admin endpoints

Issue #9 from PR review: CLAUDE.md requires Bruno collections for all API endpoints. Missing documentation for 3 admin endpoints.

Created Bruno collection structure:
- bruno/bruno.json - collection root
- bruno/environments/local.bru - local dev environment variables
- bruno/AppView API/Admin/Assign Role.bru - POST /api/admin/members/:did/role
- bruno/AppView API/Admin/List Roles.bru - GET /api/admin/roles
- bruno/AppView API/Admin/List Members.bru - GET /api/admin/members

Each .bru file includes:
- Full request details (method, URL, params, body)
- Response assertions for automated testing
- Comprehensive docs (params, returns, error codes, notes)
- Permission requirements
- Priority hierarchy rules (for role assignment)

Bruno collections serve dual purpose:
1. Interactive API testing during development
2. Version-controlled API documentation

* fix: add requireAuth middleware and comprehensive admin tests (ATB-17)

Fixes PR review issue #2: Replace placeholder admin tests with real assertions.

Changes:
- Add requireAuth middleware before requirePermission in all admin routes
(fixes "user undefined" error - admin routes need auth context like topics/posts)
- Add URI format validation for roleUri (validates at:// prefix and collection path)
- Replace 10 placeholder tests with 17 comprehensive tests:
* Privilege escalation prevention (priority hierarchy enforcement)
* Input validation (missing fields, invalid URIs, malformed JSON)
* Error classification (400/403/404/500/503 status codes)
* Edge cases (no role, missing forum, ForumAgent unavailable)
* Members list endpoint (role display, Guest fallback, DID fallback)
- Fix test data pollution by standardizing DIDs to "did:plc:test-*" pattern
- Update Bruno environment variable to match new test DID pattern
- Make tests run sequentially to prevent database conflicts

All 17 tests passing.

authored by

Malpercio and committed by
GitHub
816b38de 987cf811

+6638 -11
+4
.env.example
··· 32 32 # REDIS_URL=redis://localhost:6379 33 33 # If set, uses Redis for session storage (supports multi-instance deployment) 34 34 # If blank, uses in-memory storage (single-instance only) 35 + 36 + # Role seeding 37 + SEED_DEFAULT_ROLES=true # Set to "false" to disable auto-seeding on startup 38 + DEFAULT_MEMBER_ROLE=Member # Role name to auto-assign to new memberships (empty for manual assignment)
+56
apps/appview/bruno/AppView API/Admin/Assign Role.bru
··· 1 + meta { 2 + name: Assign Role 3 + type: http 4 + seq: 1 5 + } 6 + 7 + post { 8 + url: {{appview_url}}/api/admin/members/:did/role 9 + } 10 + 11 + params:path { 12 + did: {{target_user_did}} 13 + } 14 + 15 + body:json { 16 + { 17 + "roleUri": "at://{{forum_did}}/space.atbb.forum.role/{{role_rkey}}" 18 + } 19 + } 20 + 21 + assert { 22 + res.status: eq 200 23 + res.body.success: isDefined 24 + res.body.roleAssigned: isDefined 25 + } 26 + 27 + docs { 28 + Assign a role to a forum member. 29 + 30 + **Requires:** `space.atbb.permission.manageRoles` permission 31 + 32 + Path params: 33 + - did: Target user's DID (required) 34 + 35 + Body params: 36 + - roleUri: Full AT URI of the role to assign (required, format: at://FORUM_DID/space.atbb.forum.role/RKEY) 37 + 38 + Returns: 39 + { 40 + "success": true, 41 + "roleAssigned": "Moderator", 42 + "targetDid": "did:plc:user123" 43 + } 44 + 45 + Error codes: 46 + - 400: Invalid roleUri format 47 + - 401: Not authenticated 48 + - 403: Insufficient permissions or priority hierarchy violation 49 + - 404: Role or user membership not found 50 + - 500: Forum agent not available or forum record missing 51 + - 503: Unable to reach user's PDS 52 + 53 + Priority hierarchy: 54 + - Cannot assign role with equal or higher authority than your own 55 + - Lower priority value = higher authority (Owner=0, Admin=10, Moderator=20, Member=30) 56 + }
+58
apps/appview/bruno/AppView API/Admin/List Members.bru
··· 1 + meta { 2 + name: List Members 3 + type: http 4 + seq: 3 5 + } 6 + 7 + get { 8 + url: {{appview_url}}/api/admin/members 9 + } 10 + 11 + params:query { 12 + limit: 100 13 + offset: 0 14 + } 15 + 16 + assert { 17 + res.status: eq 200 18 + res.body.members: isArray 19 + res.body.total: isDefined 20 + } 21 + 22 + docs { 23 + List all forum members with their assigned roles. 24 + 25 + **Requires:** `space.atbb.permission.manageMembers` permission 26 + 27 + Query params: 28 + - limit: Max results per page (default: 100, max: 250) 29 + - offset: Number of records to skip for pagination (default: 0) 30 + 31 + Returns: 32 + { 33 + "members": [ 34 + { 35 + "did": "did:plc:user123", 36 + "handle": "alice.bsky.social", 37 + "role": "Moderator", 38 + "roleUri": "at://FORUM_DID/space.atbb.forum.role/RKEY", 39 + "joinedAt": "2026-02-15T00:00:00.000Z" 40 + }, 41 + ... 42 + ], 43 + "total": 42, 44 + "limit": 100, 45 + "offset": 0 46 + } 47 + 48 + Members are sorted by join date (newest first). 49 + 50 + Error codes: 51 + - 401: Not authenticated 52 + - 403: Insufficient permissions 53 + - 500: Database error 54 + 55 + Notes: 56 + - Members without assigned role will have role: null, roleUri: null 57 + - Pagination uses offset-based approach (offset + limit) 58 + }
+55
apps/appview/bruno/AppView API/Admin/List Roles.bru
··· 1 + meta { 2 + name: List Roles 3 + type: http 4 + seq: 2 5 + } 6 + 7 + get { 8 + url: {{appview_url}}/api/admin/roles 9 + } 10 + 11 + assert { 12 + res.status: eq 200 13 + res.body: isArray 14 + } 15 + 16 + docs { 17 + List all available roles for the forum. 18 + 19 + **Requires:** Authentication (any authenticated user can view roles) 20 + 21 + Query params: None 22 + 23 + Returns: 24 + [ 25 + { 26 + "id": "1", 27 + "name": "Owner", 28 + "description": "Forum owner with full control", 29 + "permissions": ["*"], 30 + "priority": 0 31 + }, 32 + { 33 + "id": "2", 34 + "name": "Admin", 35 + "description": "Can manage forum structure and users", 36 + "permissions": [ 37 + "space.atbb.permission.manageCategories", 38 + "space.atbb.permission.manageRoles", 39 + ... 40 + ], 41 + "priority": 10 42 + }, 43 + ... 44 + ] 45 + 46 + Roles are sorted by priority (Owner first, Member last). 47 + 48 + Error codes: 49 + - 401: Not authenticated 50 + - 500: Database error 51 + 52 + Notes: 53 + - Wildcard permission ("*") grants all permissions (Owner role) 54 + - Lower priority value = higher authority 55 + }
+9
apps/appview/bruno/bruno.json
··· 1 + { 2 + "version": "1", 3 + "name": "atBB AppView API", 4 + "type": "collection", 5 + "ignore": [ 6 + "node_modules", 7 + ".git" 8 + ] 9 + }
+6
apps/appview/bruno/environments/local.bru
··· 1 + vars { 2 + appview_url: http://localhost:3000 3 + forum_did: did:plc:test-forum 4 + target_user_did: did:plc:test-target 5 + role_rkey: 3xxxxxxxxx 6 + }
+16
apps/appview/drizzle/0004_cute_bloodstorm.sql
··· 1 + CREATE TABLE "roles" ( 2 + "id" bigserial PRIMARY KEY NOT NULL, 3 + "did" text NOT NULL, 4 + "rkey" text NOT NULL, 5 + "cid" text NOT NULL, 6 + "name" text NOT NULL, 7 + "description" text, 8 + "permissions" text[] DEFAULT '{}'::text[] NOT NULL, 9 + "priority" integer NOT NULL, 10 + "created_at" timestamp with time zone NOT NULL, 11 + "indexed_at" timestamp with time zone NOT NULL 12 + ); 13 + --> statement-breakpoint 14 + CREATE UNIQUE INDEX "roles_did_rkey_idx" ON "roles" USING btree ("did","rkey");--> statement-breakpoint 15 + CREATE INDEX "roles_did_idx" ON "roles" USING btree ("did");--> statement-breakpoint 16 + CREATE INDEX "roles_did_name_idx" ON "roles" USING btree ("did","name");
+1051
apps/appview/drizzle/meta/0004_snapshot.json
··· 1 + { 2 + "id": "0ca4512f-bb1b-47e3-abf9-2e1c2c83f0f6", 3 + "prevId": "d3c71ac8-7dbe-4314-913e-2bab776fc4fb", 4 + "version": "7", 5 + "dialect": "postgresql", 6 + "tables": { 7 + "public.boards": { 8 + "name": "boards", 9 + "schema": "", 10 + "columns": { 11 + "id": { 12 + "name": "id", 13 + "type": "bigserial", 14 + "primaryKey": true, 15 + "notNull": true 16 + }, 17 + "did": { 18 + "name": "did", 19 + "type": "text", 20 + "primaryKey": false, 21 + "notNull": true 22 + }, 23 + "rkey": { 24 + "name": "rkey", 25 + "type": "text", 26 + "primaryKey": false, 27 + "notNull": true 28 + }, 29 + "cid": { 30 + "name": "cid", 31 + "type": "text", 32 + "primaryKey": false, 33 + "notNull": true 34 + }, 35 + "name": { 36 + "name": "name", 37 + "type": "text", 38 + "primaryKey": false, 39 + "notNull": true 40 + }, 41 + "description": { 42 + "name": "description", 43 + "type": "text", 44 + "primaryKey": false, 45 + "notNull": false 46 + }, 47 + "slug": { 48 + "name": "slug", 49 + "type": "text", 50 + "primaryKey": false, 51 + "notNull": false 52 + }, 53 + "sort_order": { 54 + "name": "sort_order", 55 + "type": "integer", 56 + "primaryKey": false, 57 + "notNull": false 58 + }, 59 + "category_id": { 60 + "name": "category_id", 61 + "type": "bigint", 62 + "primaryKey": false, 63 + "notNull": false 64 + }, 65 + "category_uri": { 66 + "name": "category_uri", 67 + "type": "text", 68 + "primaryKey": false, 69 + "notNull": true 70 + }, 71 + "created_at": { 72 + "name": "created_at", 73 + "type": "timestamp with time zone", 74 + "primaryKey": false, 75 + "notNull": true 76 + }, 77 + "indexed_at": { 78 + "name": "indexed_at", 79 + "type": "timestamp with time zone", 80 + "primaryKey": false, 81 + "notNull": true 82 + } 83 + }, 84 + "indexes": { 85 + "boards_did_rkey_idx": { 86 + "name": "boards_did_rkey_idx", 87 + "columns": [ 88 + { 89 + "expression": "did", 90 + "isExpression": false, 91 + "asc": true, 92 + "nulls": "last" 93 + }, 94 + { 95 + "expression": "rkey", 96 + "isExpression": false, 97 + "asc": true, 98 + "nulls": "last" 99 + } 100 + ], 101 + "isUnique": true, 102 + "concurrently": false, 103 + "method": "btree", 104 + "with": {} 105 + }, 106 + "boards_category_id_idx": { 107 + "name": "boards_category_id_idx", 108 + "columns": [ 109 + { 110 + "expression": "category_id", 111 + "isExpression": false, 112 + "asc": true, 113 + "nulls": "last" 114 + } 115 + ], 116 + "isUnique": false, 117 + "concurrently": false, 118 + "method": "btree", 119 + "with": {} 120 + } 121 + }, 122 + "foreignKeys": { 123 + "boards_category_id_categories_id_fk": { 124 + "name": "boards_category_id_categories_id_fk", 125 + "tableFrom": "boards", 126 + "tableTo": "categories", 127 + "columnsFrom": [ 128 + "category_id" 129 + ], 130 + "columnsTo": [ 131 + "id" 132 + ], 133 + "onDelete": "no action", 134 + "onUpdate": "no action" 135 + } 136 + }, 137 + "compositePrimaryKeys": {}, 138 + "uniqueConstraints": {}, 139 + "policies": {}, 140 + "checkConstraints": {}, 141 + "isRLSEnabled": false 142 + }, 143 + "public.categories": { 144 + "name": "categories", 145 + "schema": "", 146 + "columns": { 147 + "id": { 148 + "name": "id", 149 + "type": "bigserial", 150 + "primaryKey": true, 151 + "notNull": true 152 + }, 153 + "did": { 154 + "name": "did", 155 + "type": "text", 156 + "primaryKey": false, 157 + "notNull": true 158 + }, 159 + "rkey": { 160 + "name": "rkey", 161 + "type": "text", 162 + "primaryKey": false, 163 + "notNull": true 164 + }, 165 + "cid": { 166 + "name": "cid", 167 + "type": "text", 168 + "primaryKey": false, 169 + "notNull": true 170 + }, 171 + "name": { 172 + "name": "name", 173 + "type": "text", 174 + "primaryKey": false, 175 + "notNull": true 176 + }, 177 + "description": { 178 + "name": "description", 179 + "type": "text", 180 + "primaryKey": false, 181 + "notNull": false 182 + }, 183 + "slug": { 184 + "name": "slug", 185 + "type": "text", 186 + "primaryKey": false, 187 + "notNull": false 188 + }, 189 + "sort_order": { 190 + "name": "sort_order", 191 + "type": "integer", 192 + "primaryKey": false, 193 + "notNull": false 194 + }, 195 + "forum_id": { 196 + "name": "forum_id", 197 + "type": "bigint", 198 + "primaryKey": false, 199 + "notNull": false 200 + }, 201 + "created_at": { 202 + "name": "created_at", 203 + "type": "timestamp with time zone", 204 + "primaryKey": false, 205 + "notNull": true 206 + }, 207 + "indexed_at": { 208 + "name": "indexed_at", 209 + "type": "timestamp with time zone", 210 + "primaryKey": false, 211 + "notNull": true 212 + } 213 + }, 214 + "indexes": { 215 + "categories_did_rkey_idx": { 216 + "name": "categories_did_rkey_idx", 217 + "columns": [ 218 + { 219 + "expression": "did", 220 + "isExpression": false, 221 + "asc": true, 222 + "nulls": "last" 223 + }, 224 + { 225 + "expression": "rkey", 226 + "isExpression": false, 227 + "asc": true, 228 + "nulls": "last" 229 + } 230 + ], 231 + "isUnique": true, 232 + "concurrently": false, 233 + "method": "btree", 234 + "with": {} 235 + } 236 + }, 237 + "foreignKeys": { 238 + "categories_forum_id_forums_id_fk": { 239 + "name": "categories_forum_id_forums_id_fk", 240 + "tableFrom": "categories", 241 + "tableTo": "forums", 242 + "columnsFrom": [ 243 + "forum_id" 244 + ], 245 + "columnsTo": [ 246 + "id" 247 + ], 248 + "onDelete": "no action", 249 + "onUpdate": "no action" 250 + } 251 + }, 252 + "compositePrimaryKeys": {}, 253 + "uniqueConstraints": {}, 254 + "policies": {}, 255 + "checkConstraints": {}, 256 + "isRLSEnabled": false 257 + }, 258 + "public.firehose_cursor": { 259 + "name": "firehose_cursor", 260 + "schema": "", 261 + "columns": { 262 + "service": { 263 + "name": "service", 264 + "type": "text", 265 + "primaryKey": true, 266 + "notNull": true, 267 + "default": "'jetstream'" 268 + }, 269 + "cursor": { 270 + "name": "cursor", 271 + "type": "bigint", 272 + "primaryKey": false, 273 + "notNull": true 274 + }, 275 + "updated_at": { 276 + "name": "updated_at", 277 + "type": "timestamp with time zone", 278 + "primaryKey": false, 279 + "notNull": true 280 + } 281 + }, 282 + "indexes": {}, 283 + "foreignKeys": {}, 284 + "compositePrimaryKeys": {}, 285 + "uniqueConstraints": {}, 286 + "policies": {}, 287 + "checkConstraints": {}, 288 + "isRLSEnabled": false 289 + }, 290 + "public.forums": { 291 + "name": "forums", 292 + "schema": "", 293 + "columns": { 294 + "id": { 295 + "name": "id", 296 + "type": "bigserial", 297 + "primaryKey": true, 298 + "notNull": true 299 + }, 300 + "did": { 301 + "name": "did", 302 + "type": "text", 303 + "primaryKey": false, 304 + "notNull": true 305 + }, 306 + "rkey": { 307 + "name": "rkey", 308 + "type": "text", 309 + "primaryKey": false, 310 + "notNull": true 311 + }, 312 + "cid": { 313 + "name": "cid", 314 + "type": "text", 315 + "primaryKey": false, 316 + "notNull": true 317 + }, 318 + "name": { 319 + "name": "name", 320 + "type": "text", 321 + "primaryKey": false, 322 + "notNull": true 323 + }, 324 + "description": { 325 + "name": "description", 326 + "type": "text", 327 + "primaryKey": false, 328 + "notNull": false 329 + }, 330 + "indexed_at": { 331 + "name": "indexed_at", 332 + "type": "timestamp with time zone", 333 + "primaryKey": false, 334 + "notNull": true 335 + } 336 + }, 337 + "indexes": { 338 + "forums_did_rkey_idx": { 339 + "name": "forums_did_rkey_idx", 340 + "columns": [ 341 + { 342 + "expression": "did", 343 + "isExpression": false, 344 + "asc": true, 345 + "nulls": "last" 346 + }, 347 + { 348 + "expression": "rkey", 349 + "isExpression": false, 350 + "asc": true, 351 + "nulls": "last" 352 + } 353 + ], 354 + "isUnique": true, 355 + "concurrently": false, 356 + "method": "btree", 357 + "with": {} 358 + } 359 + }, 360 + "foreignKeys": {}, 361 + "compositePrimaryKeys": {}, 362 + "uniqueConstraints": {}, 363 + "policies": {}, 364 + "checkConstraints": {}, 365 + "isRLSEnabled": false 366 + }, 367 + "public.memberships": { 368 + "name": "memberships", 369 + "schema": "", 370 + "columns": { 371 + "id": { 372 + "name": "id", 373 + "type": "bigserial", 374 + "primaryKey": true, 375 + "notNull": true 376 + }, 377 + "did": { 378 + "name": "did", 379 + "type": "text", 380 + "primaryKey": false, 381 + "notNull": true 382 + }, 383 + "rkey": { 384 + "name": "rkey", 385 + "type": "text", 386 + "primaryKey": false, 387 + "notNull": true 388 + }, 389 + "cid": { 390 + "name": "cid", 391 + "type": "text", 392 + "primaryKey": false, 393 + "notNull": true 394 + }, 395 + "forum_id": { 396 + "name": "forum_id", 397 + "type": "bigint", 398 + "primaryKey": false, 399 + "notNull": false 400 + }, 401 + "forum_uri": { 402 + "name": "forum_uri", 403 + "type": "text", 404 + "primaryKey": false, 405 + "notNull": true 406 + }, 407 + "role": { 408 + "name": "role", 409 + "type": "text", 410 + "primaryKey": false, 411 + "notNull": false 412 + }, 413 + "role_uri": { 414 + "name": "role_uri", 415 + "type": "text", 416 + "primaryKey": false, 417 + "notNull": false 418 + }, 419 + "joined_at": { 420 + "name": "joined_at", 421 + "type": "timestamp with time zone", 422 + "primaryKey": false, 423 + "notNull": false 424 + }, 425 + "created_at": { 426 + "name": "created_at", 427 + "type": "timestamp with time zone", 428 + "primaryKey": false, 429 + "notNull": true 430 + }, 431 + "indexed_at": { 432 + "name": "indexed_at", 433 + "type": "timestamp with time zone", 434 + "primaryKey": false, 435 + "notNull": true 436 + } 437 + }, 438 + "indexes": { 439 + "memberships_did_rkey_idx": { 440 + "name": "memberships_did_rkey_idx", 441 + "columns": [ 442 + { 443 + "expression": "did", 444 + "isExpression": false, 445 + "asc": true, 446 + "nulls": "last" 447 + }, 448 + { 449 + "expression": "rkey", 450 + "isExpression": false, 451 + "asc": true, 452 + "nulls": "last" 453 + } 454 + ], 455 + "isUnique": true, 456 + "concurrently": false, 457 + "method": "btree", 458 + "with": {} 459 + }, 460 + "memberships_did_idx": { 461 + "name": "memberships_did_idx", 462 + "columns": [ 463 + { 464 + "expression": "did", 465 + "isExpression": false, 466 + "asc": true, 467 + "nulls": "last" 468 + } 469 + ], 470 + "isUnique": false, 471 + "concurrently": false, 472 + "method": "btree", 473 + "with": {} 474 + } 475 + }, 476 + "foreignKeys": { 477 + "memberships_did_users_did_fk": { 478 + "name": "memberships_did_users_did_fk", 479 + "tableFrom": "memberships", 480 + "tableTo": "users", 481 + "columnsFrom": [ 482 + "did" 483 + ], 484 + "columnsTo": [ 485 + "did" 486 + ], 487 + "onDelete": "no action", 488 + "onUpdate": "no action" 489 + }, 490 + "memberships_forum_id_forums_id_fk": { 491 + "name": "memberships_forum_id_forums_id_fk", 492 + "tableFrom": "memberships", 493 + "tableTo": "forums", 494 + "columnsFrom": [ 495 + "forum_id" 496 + ], 497 + "columnsTo": [ 498 + "id" 499 + ], 500 + "onDelete": "no action", 501 + "onUpdate": "no action" 502 + } 503 + }, 504 + "compositePrimaryKeys": {}, 505 + "uniqueConstraints": {}, 506 + "policies": {}, 507 + "checkConstraints": {}, 508 + "isRLSEnabled": false 509 + }, 510 + "public.mod_actions": { 511 + "name": "mod_actions", 512 + "schema": "", 513 + "columns": { 514 + "id": { 515 + "name": "id", 516 + "type": "bigserial", 517 + "primaryKey": true, 518 + "notNull": true 519 + }, 520 + "did": { 521 + "name": "did", 522 + "type": "text", 523 + "primaryKey": false, 524 + "notNull": true 525 + }, 526 + "rkey": { 527 + "name": "rkey", 528 + "type": "text", 529 + "primaryKey": false, 530 + "notNull": true 531 + }, 532 + "cid": { 533 + "name": "cid", 534 + "type": "text", 535 + "primaryKey": false, 536 + "notNull": true 537 + }, 538 + "action": { 539 + "name": "action", 540 + "type": "text", 541 + "primaryKey": false, 542 + "notNull": true 543 + }, 544 + "subject_did": { 545 + "name": "subject_did", 546 + "type": "text", 547 + "primaryKey": false, 548 + "notNull": false 549 + }, 550 + "subject_post_uri": { 551 + "name": "subject_post_uri", 552 + "type": "text", 553 + "primaryKey": false, 554 + "notNull": false 555 + }, 556 + "forum_id": { 557 + "name": "forum_id", 558 + "type": "bigint", 559 + "primaryKey": false, 560 + "notNull": false 561 + }, 562 + "reason": { 563 + "name": "reason", 564 + "type": "text", 565 + "primaryKey": false, 566 + "notNull": false 567 + }, 568 + "created_by": { 569 + "name": "created_by", 570 + "type": "text", 571 + "primaryKey": false, 572 + "notNull": true 573 + }, 574 + "expires_at": { 575 + "name": "expires_at", 576 + "type": "timestamp with time zone", 577 + "primaryKey": false, 578 + "notNull": false 579 + }, 580 + "created_at": { 581 + "name": "created_at", 582 + "type": "timestamp with time zone", 583 + "primaryKey": false, 584 + "notNull": true 585 + }, 586 + "indexed_at": { 587 + "name": "indexed_at", 588 + "type": "timestamp with time zone", 589 + "primaryKey": false, 590 + "notNull": true 591 + } 592 + }, 593 + "indexes": { 594 + "mod_actions_did_rkey_idx": { 595 + "name": "mod_actions_did_rkey_idx", 596 + "columns": [ 597 + { 598 + "expression": "did", 599 + "isExpression": false, 600 + "asc": true, 601 + "nulls": "last" 602 + }, 603 + { 604 + "expression": "rkey", 605 + "isExpression": false, 606 + "asc": true, 607 + "nulls": "last" 608 + } 609 + ], 610 + "isUnique": true, 611 + "concurrently": false, 612 + "method": "btree", 613 + "with": {} 614 + } 615 + }, 616 + "foreignKeys": { 617 + "mod_actions_forum_id_forums_id_fk": { 618 + "name": "mod_actions_forum_id_forums_id_fk", 619 + "tableFrom": "mod_actions", 620 + "tableTo": "forums", 621 + "columnsFrom": [ 622 + "forum_id" 623 + ], 624 + "columnsTo": [ 625 + "id" 626 + ], 627 + "onDelete": "no action", 628 + "onUpdate": "no action" 629 + } 630 + }, 631 + "compositePrimaryKeys": {}, 632 + "uniqueConstraints": {}, 633 + "policies": {}, 634 + "checkConstraints": {}, 635 + "isRLSEnabled": false 636 + }, 637 + "public.posts": { 638 + "name": "posts", 639 + "schema": "", 640 + "columns": { 641 + "id": { 642 + "name": "id", 643 + "type": "bigserial", 644 + "primaryKey": true, 645 + "notNull": true 646 + }, 647 + "did": { 648 + "name": "did", 649 + "type": "text", 650 + "primaryKey": false, 651 + "notNull": true 652 + }, 653 + "rkey": { 654 + "name": "rkey", 655 + "type": "text", 656 + "primaryKey": false, 657 + "notNull": true 658 + }, 659 + "cid": { 660 + "name": "cid", 661 + "type": "text", 662 + "primaryKey": false, 663 + "notNull": true 664 + }, 665 + "text": { 666 + "name": "text", 667 + "type": "text", 668 + "primaryKey": false, 669 + "notNull": true 670 + }, 671 + "forum_uri": { 672 + "name": "forum_uri", 673 + "type": "text", 674 + "primaryKey": false, 675 + "notNull": false 676 + }, 677 + "board_uri": { 678 + "name": "board_uri", 679 + "type": "text", 680 + "primaryKey": false, 681 + "notNull": false 682 + }, 683 + "board_id": { 684 + "name": "board_id", 685 + "type": "bigint", 686 + "primaryKey": false, 687 + "notNull": false 688 + }, 689 + "root_post_id": { 690 + "name": "root_post_id", 691 + "type": "bigint", 692 + "primaryKey": false, 693 + "notNull": false 694 + }, 695 + "parent_post_id": { 696 + "name": "parent_post_id", 697 + "type": "bigint", 698 + "primaryKey": false, 699 + "notNull": false 700 + }, 701 + "root_uri": { 702 + "name": "root_uri", 703 + "type": "text", 704 + "primaryKey": false, 705 + "notNull": false 706 + }, 707 + "parent_uri": { 708 + "name": "parent_uri", 709 + "type": "text", 710 + "primaryKey": false, 711 + "notNull": false 712 + }, 713 + "created_at": { 714 + "name": "created_at", 715 + "type": "timestamp with time zone", 716 + "primaryKey": false, 717 + "notNull": true 718 + }, 719 + "indexed_at": { 720 + "name": "indexed_at", 721 + "type": "timestamp with time zone", 722 + "primaryKey": false, 723 + "notNull": true 724 + }, 725 + "deleted": { 726 + "name": "deleted", 727 + "type": "boolean", 728 + "primaryKey": false, 729 + "notNull": true, 730 + "default": false 731 + } 732 + }, 733 + "indexes": { 734 + "posts_did_rkey_idx": { 735 + "name": "posts_did_rkey_idx", 736 + "columns": [ 737 + { 738 + "expression": "did", 739 + "isExpression": false, 740 + "asc": true, 741 + "nulls": "last" 742 + }, 743 + { 744 + "expression": "rkey", 745 + "isExpression": false, 746 + "asc": true, 747 + "nulls": "last" 748 + } 749 + ], 750 + "isUnique": true, 751 + "concurrently": false, 752 + "method": "btree", 753 + "with": {} 754 + }, 755 + "posts_forum_uri_idx": { 756 + "name": "posts_forum_uri_idx", 757 + "columns": [ 758 + { 759 + "expression": "forum_uri", 760 + "isExpression": false, 761 + "asc": true, 762 + "nulls": "last" 763 + } 764 + ], 765 + "isUnique": false, 766 + "concurrently": false, 767 + "method": "btree", 768 + "with": {} 769 + }, 770 + "posts_board_id_idx": { 771 + "name": "posts_board_id_idx", 772 + "columns": [ 773 + { 774 + "expression": "board_id", 775 + "isExpression": false, 776 + "asc": true, 777 + "nulls": "last" 778 + } 779 + ], 780 + "isUnique": false, 781 + "concurrently": false, 782 + "method": "btree", 783 + "with": {} 784 + }, 785 + "posts_board_uri_idx": { 786 + "name": "posts_board_uri_idx", 787 + "columns": [ 788 + { 789 + "expression": "board_uri", 790 + "isExpression": false, 791 + "asc": true, 792 + "nulls": "last" 793 + } 794 + ], 795 + "isUnique": false, 796 + "concurrently": false, 797 + "method": "btree", 798 + "with": {} 799 + }, 800 + "posts_root_post_id_idx": { 801 + "name": "posts_root_post_id_idx", 802 + "columns": [ 803 + { 804 + "expression": "root_post_id", 805 + "isExpression": false, 806 + "asc": true, 807 + "nulls": "last" 808 + } 809 + ], 810 + "isUnique": false, 811 + "concurrently": false, 812 + "method": "btree", 813 + "with": {} 814 + } 815 + }, 816 + "foreignKeys": { 817 + "posts_did_users_did_fk": { 818 + "name": "posts_did_users_did_fk", 819 + "tableFrom": "posts", 820 + "tableTo": "users", 821 + "columnsFrom": [ 822 + "did" 823 + ], 824 + "columnsTo": [ 825 + "did" 826 + ], 827 + "onDelete": "no action", 828 + "onUpdate": "no action" 829 + }, 830 + "posts_board_id_boards_id_fk": { 831 + "name": "posts_board_id_boards_id_fk", 832 + "tableFrom": "posts", 833 + "tableTo": "boards", 834 + "columnsFrom": [ 835 + "board_id" 836 + ], 837 + "columnsTo": [ 838 + "id" 839 + ], 840 + "onDelete": "no action", 841 + "onUpdate": "no action" 842 + }, 843 + "posts_root_post_id_posts_id_fk": { 844 + "name": "posts_root_post_id_posts_id_fk", 845 + "tableFrom": "posts", 846 + "tableTo": "posts", 847 + "columnsFrom": [ 848 + "root_post_id" 849 + ], 850 + "columnsTo": [ 851 + "id" 852 + ], 853 + "onDelete": "no action", 854 + "onUpdate": "no action" 855 + }, 856 + "posts_parent_post_id_posts_id_fk": { 857 + "name": "posts_parent_post_id_posts_id_fk", 858 + "tableFrom": "posts", 859 + "tableTo": "posts", 860 + "columnsFrom": [ 861 + "parent_post_id" 862 + ], 863 + "columnsTo": [ 864 + "id" 865 + ], 866 + "onDelete": "no action", 867 + "onUpdate": "no action" 868 + } 869 + }, 870 + "compositePrimaryKeys": {}, 871 + "uniqueConstraints": {}, 872 + "policies": {}, 873 + "checkConstraints": {}, 874 + "isRLSEnabled": false 875 + }, 876 + "public.roles": { 877 + "name": "roles", 878 + "schema": "", 879 + "columns": { 880 + "id": { 881 + "name": "id", 882 + "type": "bigserial", 883 + "primaryKey": true, 884 + "notNull": true 885 + }, 886 + "did": { 887 + "name": "did", 888 + "type": "text", 889 + "primaryKey": false, 890 + "notNull": true 891 + }, 892 + "rkey": { 893 + "name": "rkey", 894 + "type": "text", 895 + "primaryKey": false, 896 + "notNull": true 897 + }, 898 + "cid": { 899 + "name": "cid", 900 + "type": "text", 901 + "primaryKey": false, 902 + "notNull": true 903 + }, 904 + "name": { 905 + "name": "name", 906 + "type": "text", 907 + "primaryKey": false, 908 + "notNull": true 909 + }, 910 + "description": { 911 + "name": "description", 912 + "type": "text", 913 + "primaryKey": false, 914 + "notNull": false 915 + }, 916 + "permissions": { 917 + "name": "permissions", 918 + "type": "text[]", 919 + "primaryKey": false, 920 + "notNull": true, 921 + "default": "'{}'::text[]" 922 + }, 923 + "priority": { 924 + "name": "priority", 925 + "type": "integer", 926 + "primaryKey": false, 927 + "notNull": true 928 + }, 929 + "created_at": { 930 + "name": "created_at", 931 + "type": "timestamp with time zone", 932 + "primaryKey": false, 933 + "notNull": true 934 + }, 935 + "indexed_at": { 936 + "name": "indexed_at", 937 + "type": "timestamp with time zone", 938 + "primaryKey": false, 939 + "notNull": true 940 + } 941 + }, 942 + "indexes": { 943 + "roles_did_rkey_idx": { 944 + "name": "roles_did_rkey_idx", 945 + "columns": [ 946 + { 947 + "expression": "did", 948 + "isExpression": false, 949 + "asc": true, 950 + "nulls": "last" 951 + }, 952 + { 953 + "expression": "rkey", 954 + "isExpression": false, 955 + "asc": true, 956 + "nulls": "last" 957 + } 958 + ], 959 + "isUnique": true, 960 + "concurrently": false, 961 + "method": "btree", 962 + "with": {} 963 + }, 964 + "roles_did_idx": { 965 + "name": "roles_did_idx", 966 + "columns": [ 967 + { 968 + "expression": "did", 969 + "isExpression": false, 970 + "asc": true, 971 + "nulls": "last" 972 + } 973 + ], 974 + "isUnique": false, 975 + "concurrently": false, 976 + "method": "btree", 977 + "with": {} 978 + }, 979 + "roles_did_name_idx": { 980 + "name": "roles_did_name_idx", 981 + "columns": [ 982 + { 983 + "expression": "did", 984 + "isExpression": false, 985 + "asc": true, 986 + "nulls": "last" 987 + }, 988 + { 989 + "expression": "name", 990 + "isExpression": false, 991 + "asc": true, 992 + "nulls": "last" 993 + } 994 + ], 995 + "isUnique": false, 996 + "concurrently": false, 997 + "method": "btree", 998 + "with": {} 999 + } 1000 + }, 1001 + "foreignKeys": {}, 1002 + "compositePrimaryKeys": {}, 1003 + "uniqueConstraints": {}, 1004 + "policies": {}, 1005 + "checkConstraints": {}, 1006 + "isRLSEnabled": false 1007 + }, 1008 + "public.users": { 1009 + "name": "users", 1010 + "schema": "", 1011 + "columns": { 1012 + "did": { 1013 + "name": "did", 1014 + "type": "text", 1015 + "primaryKey": true, 1016 + "notNull": true 1017 + }, 1018 + "handle": { 1019 + "name": "handle", 1020 + "type": "text", 1021 + "primaryKey": false, 1022 + "notNull": false 1023 + }, 1024 + "indexed_at": { 1025 + "name": "indexed_at", 1026 + "type": "timestamp with time zone", 1027 + "primaryKey": false, 1028 + "notNull": true 1029 + } 1030 + }, 1031 + "indexes": {}, 1032 + "foreignKeys": {}, 1033 + "compositePrimaryKeys": {}, 1034 + "uniqueConstraints": {}, 1035 + "policies": {}, 1036 + "checkConstraints": {}, 1037 + "isRLSEnabled": false 1038 + } 1039 + }, 1040 + "enums": {}, 1041 + "schemas": {}, 1042 + "sequences": {}, 1043 + "roles": {}, 1044 + "policies": {}, 1045 + "views": {}, 1046 + "_meta": { 1047 + "columns": {}, 1048 + "schemas": {}, 1049 + "tables": {} 1050 + } 1051 + }
+7
apps/appview/drizzle/meta/_journal.json
··· 29 29 "when": 1771027321303, 30 30 "tag": "0003_brief_mariko_yashida", 31 31 "breakpoints": true 32 + }, 33 + { 34 + "idx": 4, 35 + "version": "7", 36 + "when": 1771173166538, 37 + "tag": "0004_cute_bloodstorm", 38 + "breakpoints": true 32 39 } 33 40 ] 34 41 }
+13
apps/appview/src/index.ts
··· 2 2 import { loadConfig } from "./lib/config.js"; 3 3 import { createAppContext, destroyAppContext } from "./lib/app-context.js"; 4 4 import { createApp } from "./lib/create-app.js"; 5 + import { seedDefaultRoles } from "./lib/seed-roles.js"; 5 6 6 7 async function main() { 7 8 // Load configuration ··· 9 10 10 11 // Create application context with all dependencies 11 12 const ctx = await createAppContext(config); 13 + 14 + // Seed default roles if enabled 15 + if (process.env.SEED_DEFAULT_ROLES !== "false") { 16 + console.log("Seeding default roles..."); 17 + const result = await seedDefaultRoles(ctx); 18 + console.log("Default roles seeded", { 19 + created: result.created, 20 + skipped: result.skipped, 21 + }); 22 + } else { 23 + console.log("Role seeding disabled via SEED_DEFAULT_ROLES=false"); 24 + } 12 25 13 26 // Create Hono app 14 27 const app = createApp(ctx);
+184
apps/appview/src/lib/__tests__/indexer-roles.test.ts
··· 1 + import { describe, it, expect, beforeEach, afterEach } from "vitest"; 2 + import { Indexer } from "../indexer.js"; 3 + import { createTestContext, type TestContext } from "./test-context.js"; 4 + import { roles } from "@atbb/db"; 5 + import { eq } from "drizzle-orm"; 6 + import type { 7 + CommitCreateEvent, 8 + CommitUpdateEvent, 9 + CommitDeleteEvent, 10 + } from "@skyware/jetstream"; 11 + 12 + describe("Indexer - Role Handlers", () => { 13 + let ctx: TestContext; 14 + let indexer: Indexer; 15 + 16 + beforeEach(async () => { 17 + ctx = await createTestContext(); 18 + indexer = new Indexer(ctx.db); 19 + }); 20 + 21 + afterEach(async () => { 22 + await ctx.db.delete(roles).where(eq(roles.did, "did:plc:test-forum")); 23 + await ctx.cleanup(); 24 + }); 25 + 26 + it("handleRoleCreate indexes role record with all fields", async () => { 27 + const event: CommitCreateEvent<"space.atbb.forum.role"> = { 28 + kind: "commit", 29 + commit: { 30 + rev: "abc123", 31 + operation: "create", 32 + collection: "space.atbb.forum.role", 33 + rkey: "role1", 34 + record: { 35 + $type: "space.atbb.forum.role", 36 + name: "Moderator", 37 + description: "Can moderate posts", 38 + permissions: ["space.atbb.permission.moderatePosts"], 39 + priority: 10, 40 + createdAt: "2026-02-15T00:00:00Z", 41 + } as any, 42 + cid: "bafyrole", 43 + }, 44 + did: "did:plc:test-forum", 45 + time_us: 1000000, 46 + }; 47 + 48 + await indexer.handleRoleCreate(event); 49 + 50 + const [role] = await ctx.db 51 + .select() 52 + .from(roles) 53 + .where(eq(roles.rkey, "role1")) 54 + .limit(1); 55 + 56 + expect(role).toBeDefined(); 57 + expect(role.name).toBe("Moderator"); 58 + expect(role.description).toBe("Can moderate posts"); 59 + expect(role.permissions).toEqual(["space.atbb.permission.moderatePosts"]); 60 + expect(role.priority).toBe(10); 61 + }); 62 + 63 + it("handleRoleCreate indexes role without optional description", async () => { 64 + const event: CommitCreateEvent<"space.atbb.forum.role"> = { 65 + kind: "commit", 66 + commit: { 67 + rev: "abc123", 68 + operation: "create", 69 + collection: "space.atbb.forum.role", 70 + rkey: "role2", 71 + record: { 72 + $type: "space.atbb.forum.role", 73 + name: "Member", 74 + permissions: ["space.atbb.permission.createPosts"], 75 + priority: 50, 76 + createdAt: "2026-02-15T00:00:00Z", 77 + } as any, 78 + cid: "bafyrole2", 79 + }, 80 + did: "did:plc:test-forum", 81 + time_us: 1000000, 82 + }; 83 + 84 + await indexer.handleRoleCreate(event); 85 + 86 + const [role] = await ctx.db 87 + .select() 88 + .from(roles) 89 + .where(eq(roles.rkey, "role2")) 90 + .limit(1); 91 + 92 + expect(role).toBeDefined(); 93 + expect(role.description).toBeNull(); 94 + }); 95 + 96 + it("handleRoleUpdate updates role fields", async () => { 97 + // First create a role 98 + await ctx.db.insert(roles).values({ 99 + did: "did:plc:test-forum", 100 + rkey: "role3", 101 + cid: "bafyold", 102 + name: "Old Name", 103 + description: "Old description", 104 + permissions: ["space.atbb.permission.createPosts"], 105 + priority: 30, 106 + createdAt: new Date(), 107 + indexedAt: new Date(), 108 + }); 109 + 110 + const event: CommitUpdateEvent<"space.atbb.forum.role"> = { 111 + kind: "commit", 112 + commit: { 113 + rev: "def456", 114 + operation: "update", 115 + collection: "space.atbb.forum.role", 116 + rkey: "role3", 117 + record: { 118 + $type: "space.atbb.forum.role", 119 + name: "Updated Name", 120 + description: "Updated description", 121 + permissions: [ 122 + "space.atbb.permission.createPosts", 123 + "space.atbb.permission.moderatePosts", 124 + ], 125 + priority: 20, 126 + createdAt: "2026-02-15T00:00:00Z", 127 + } as any, 128 + cid: "bafynew", 129 + }, 130 + did: "did:plc:test-forum", 131 + time_us: 2000000, 132 + }; 133 + 134 + await indexer.handleRoleUpdate(event); 135 + 136 + const [role] = await ctx.db 137 + .select() 138 + .from(roles) 139 + .where(eq(roles.rkey, "role3")) 140 + .limit(1); 141 + 142 + expect(role.name).toBe("Updated Name"); 143 + expect(role.description).toBe("Updated description"); 144 + expect(role.permissions).toHaveLength(2); 145 + expect(role.priority).toBe(20); 146 + expect(role.cid).toBe("bafynew"); 147 + }); 148 + 149 + it("handleRoleDelete removes role record", async () => { 150 + // First create a role 151 + await ctx.db.insert(roles).values({ 152 + did: "did:plc:test-forum", 153 + rkey: "role4", 154 + cid: "bafyrole4", 155 + name: "To Delete", 156 + permissions: [], 157 + priority: 99, 158 + createdAt: new Date(), 159 + indexedAt: new Date(), 160 + }); 161 + 162 + const event: CommitDeleteEvent<"space.atbb.forum.role"> = { 163 + kind: "commit", 164 + commit: { 165 + rev: "ghi789", 166 + operation: "delete", 167 + collection: "space.atbb.forum.role", 168 + rkey: "role4", 169 + }, 170 + did: "did:plc:test-forum", 171 + time_us: 3000000, 172 + }; 173 + 174 + await indexer.handleRoleDelete(event); 175 + 176 + const result = await ctx.db 177 + .select() 178 + .from(roles) 179 + .where(eq(roles.rkey, "role4")) 180 + .limit(1); 181 + 182 + expect(result).toHaveLength(0); 183 + }); 184 + });
+4 -2
apps/appview/src/lib/__tests__/test-context.ts
··· 1 1 import { eq, or, like } from "drizzle-orm"; 2 2 import { drizzle } from "drizzle-orm/postgres-js"; 3 3 import postgres from "postgres"; 4 - import { forums, posts, users, categories, memberships, boards } from "@atbb/db"; 4 + import { forums, posts, users, categories, memberships, boards, roles } from "@atbb/db"; 5 5 import * as schema from "@atbb/db"; 6 6 import type { AppConfig } from "../config.js"; 7 7 import type { AppContext } from "../app-context.js"; ··· 58 58 await db.delete(users).where(like(users.did, "did:plc:test-%")).catch(() => {}); 59 59 await db.delete(boards).where(eq(boards.did, config.forumDid)).catch(() => {}); 60 60 await db.delete(categories).where(eq(categories.did, config.forumDid)).catch(() => {}); 61 + await db.delete(roles).where(eq(roles.did, config.forumDid)).catch(() => {}); 61 62 await db.delete(forums).where(eq(forums.did, config.forumDid)).catch(() => {}); 62 63 }; 63 64 ··· 120 121 ); 121 122 await db.delete(users).where(testUserPattern); 122 123 123 - // Delete boards, categories, and forums in order (FK constraints) 124 + // Delete boards, categories, roles, and forums in order (FK constraints) 124 125 await db.delete(boards).where(eq(boards.did, config.forumDid)); 125 126 await db.delete(categories).where(eq(categories.did, config.forumDid)); 127 + await db.delete(roles).where(eq(roles.did, config.forumDid)); 126 128 await db.delete(forums).where(eq(forums.did, config.forumDid)); 127 129 // Close postgres connection to prevent leaks 128 130 await sql.end();
+6
apps/appview/src/lib/firehose.ts
··· 110 110 onDelete: this.createWrappedHandler("handleBoardDelete"), 111 111 }) 112 112 .register({ 113 + collection: "space.atbb.forum.role", 114 + onCreate: this.createWrappedHandler("handleRoleCreate"), 115 + onUpdate: this.createWrappedHandler("handleRoleUpdate"), 116 + onDelete: this.createWrappedHandler("handleRoleDelete"), 117 + }) 118 + .register({ 113 119 collection: "space.atbb.membership", 114 120 onCreate: this.createWrappedHandler("handleMembershipCreate"), 115 121 onUpdate: this.createWrappedHandler("handleMembershipUpdate"),
+41
apps/appview/src/lib/indexer.ts
··· 12 12 users, 13 13 memberships, 14 14 modActions, 15 + roles, 15 16 } from "@atbb/db"; 16 17 import { eq, and } from "drizzle-orm"; 17 18 import { parseAtUri } from "./at-uri.js"; ··· 22 23 SpaceAtbbForumBoard as Board, 23 24 SpaceAtbbMembership as Membership, 24 25 SpaceAtbbModAction as ModAction, 26 + SpaceAtbbForumRole as Role, 25 27 } from "@atbb/lexicon"; 26 28 27 29 // ── Collection Config Types ───────────────────────────── ··· 281 283 indexedAt: new Date(), 282 284 }; 283 285 }, 286 + }; 287 + 288 + private roleConfig: CollectionConfig<Role.Record> = { 289 + name: "Role", 290 + table: roles, 291 + deleteStrategy: "hard", 292 + toInsertValues: async (event, record) => ({ 293 + did: event.did, 294 + rkey: event.commit.rkey, 295 + cid: event.commit.cid, 296 + name: record.name, 297 + description: record.description ?? null, 298 + permissions: record.permissions, 299 + priority: record.priority, 300 + createdAt: new Date(record.createdAt), 301 + indexedAt: new Date(), 302 + }), 303 + toUpdateValues: async (event, record) => ({ 304 + cid: event.commit.cid, 305 + name: record.name, 306 + description: record.description ?? null, 307 + permissions: record.permissions, 308 + priority: record.priority, 309 + indexedAt: new Date(), 310 + }), 284 311 }; 285 312 286 313 private membershipConfig: CollectionConfig<Membership.Record> = { ··· 604 631 605 632 async handleBoardDelete(event: CommitDeleteEvent<"space.atbb.forum.board">) { 606 633 await this.genericDelete(this.boardConfig, event); 634 + } 635 + 636 + // ── Role Handlers ─────────────────────────────────────── 637 + 638 + async handleRoleCreate(event: CommitCreateEvent<"space.atbb.forum.role">) { 639 + await this.genericCreate(this.roleConfig, event); 640 + } 641 + 642 + async handleRoleUpdate(event: CommitUpdateEvent<"space.atbb.forum.role">) { 643 + await this.genericUpdate(this.roleConfig, event); 644 + } 645 + 646 + async handleRoleDelete(event: CommitDeleteEvent<"space.atbb.forum.role">) { 647 + await this.genericDelete(this.roleConfig, event); 607 648 } 608 649 609 650 // ── Membership Handlers ─────────────────────────────────
+132
apps/appview/src/lib/seed-roles.ts
··· 1 + import type { AppContext } from "./app-context.js"; 2 + import { roles } from "@atbb/db"; 3 + import { eq } from "drizzle-orm"; 4 + 5 + interface DefaultRole { 6 + name: string; 7 + description: string; 8 + permissions: string[]; 9 + priority: number; 10 + } 11 + 12 + const DEFAULT_ROLES: DefaultRole[] = [ 13 + { 14 + name: "Owner", 15 + description: "Forum owner with full control", 16 + permissions: ["*"], 17 + priority: 0, 18 + }, 19 + { 20 + name: "Admin", 21 + description: "Can manage forum structure and users", 22 + permissions: [ 23 + "space.atbb.permission.manageCategories", 24 + "space.atbb.permission.manageRoles", 25 + "space.atbb.permission.manageMembers", 26 + "space.atbb.permission.moderatePosts", 27 + "space.atbb.permission.banUsers", 28 + "space.atbb.permission.pinTopics", 29 + "space.atbb.permission.lockTopics", 30 + "space.atbb.permission.createTopics", 31 + "space.atbb.permission.createPosts", 32 + ], 33 + priority: 10, 34 + }, 35 + { 36 + name: "Moderator", 37 + description: "Can moderate content and users", 38 + permissions: [ 39 + "space.atbb.permission.moderatePosts", 40 + "space.atbb.permission.banUsers", 41 + "space.atbb.permission.pinTopics", 42 + "space.atbb.permission.lockTopics", 43 + "space.atbb.permission.createTopics", 44 + "space.atbb.permission.createPosts", 45 + ], 46 + priority: 20, 47 + }, 48 + { 49 + name: "Member", 50 + description: "Regular forum member", 51 + permissions: [ 52 + "space.atbb.permission.createTopics", 53 + "space.atbb.permission.createPosts", 54 + ], 55 + priority: 30, 56 + }, 57 + ]; 58 + 59 + /** 60 + * Seed default roles to Forum DID's PDS. 61 + * 62 + * Idempotent: Checks for existing roles by name before creating. 63 + * Safe to run on every startup. 64 + * 65 + * @throws Error if ForumAgent is unavailable - permission system cannot function without roles 66 + */ 67 + export async function seedDefaultRoles(ctx: AppContext): Promise<{ created: number; skipped: number }> { 68 + // Check ForumAgent availability 69 + if (!ctx.forumAgent) { 70 + throw new Error("ForumAgent not available - permission system cannot function without roles. Check FORUM_HANDLE and FORUM_PASSWORD environment variables."); 71 + } 72 + 73 + const agent = ctx.forumAgent.getAgent(); 74 + if (!agent) { 75 + throw new Error("ForumAgent not authenticated - permission system cannot function without roles. Check FORUM_HANDLE and FORUM_PASSWORD are valid."); 76 + } 77 + 78 + let created = 0; 79 + let skipped = 0; 80 + 81 + for (const defaultRole of DEFAULT_ROLES) { 82 + try { 83 + // Check if role already exists by name 84 + const [existingRole] = await ctx.db 85 + .select() 86 + .from(roles) 87 + .where(eq(roles.name, defaultRole.name)) 88 + .limit(1); 89 + 90 + if (existingRole) { 91 + console.log(`Role "${defaultRole.name}" already exists, skipping`, { 92 + operation: "seedDefaultRoles", 93 + roleName: defaultRole.name, 94 + }); 95 + skipped++; 96 + continue; 97 + } 98 + 99 + // Create role record on Forum DID's PDS 100 + const response = await agent.com.atproto.repo.createRecord({ 101 + repo: ctx.config.forumDid, 102 + collection: "space.atbb.forum.role", 103 + record: { 104 + $type: "space.atbb.forum.role", 105 + name: defaultRole.name, 106 + description: defaultRole.description, 107 + permissions: defaultRole.permissions, 108 + priority: defaultRole.priority, 109 + createdAt: new Date().toISOString(), 110 + }, 111 + }); 112 + 113 + console.log(`Created default role "${defaultRole.name}"`, { 114 + operation: "seedDefaultRoles", 115 + roleName: defaultRole.name, 116 + uri: response.data.uri, 117 + cid: response.data.cid, 118 + }); 119 + 120 + created++; 121 + } catch (error) { 122 + console.error(`Failed to seed role "${defaultRole.name}"`, { 123 + operation: "seedDefaultRoles", 124 + roleName: defaultRole.name, 125 + error: error instanceof Error ? error.message : String(error), 126 + }); 127 + // Continue seeding other roles even if one fails 128 + } 129 + } 130 + 131 + return { created, skipped }; 132 + }
+470
apps/appview/src/middleware/__tests__/permissions.test.ts
··· 1 + import { describe, it, expect, beforeEach, afterEach } from "vitest"; 2 + import { createTestContext, type TestContext } from "../../lib/__tests__/test-context.js"; 3 + import { roles, memberships, users } from "@atbb/db"; 4 + import { 5 + checkPermission, 6 + checkMinRole, 7 + canActOnUser, 8 + } from "../permissions.js"; 9 + 10 + describe("Permission Helper Functions", () => { 11 + let ctx: TestContext; 12 + 13 + beforeEach(async () => { 14 + ctx = await createTestContext(); 15 + }); 16 + 17 + afterEach(async () => { 18 + await ctx.cleanup(); 19 + }); 20 + 21 + describe("checkPermission", () => { 22 + it("returns true when user has required permission", async () => { 23 + // Create a test role with createTopics permission 24 + await ctx.db.insert(roles).values({ 25 + did: ctx.config.forumDid, 26 + rkey: "test-role-123", 27 + cid: "test-cid", 28 + name: "Member", 29 + description: "Test member role", 30 + permissions: ["space.atbb.permission.createTopics"], 31 + priority: 30, 32 + createdAt: new Date(), 33 + indexedAt: new Date(), 34 + }); 35 + 36 + // Create a test user 37 + await ctx.db.insert(users).values({ 38 + did: "did:plc:test-testuser", 39 + handle: "testuser.bsky.social", 40 + indexedAt: new Date(), 41 + }); 42 + 43 + // Create membership with roleUri pointing to test role 44 + await ctx.db.insert(memberships).values({ 45 + did: "did:plc:test-testuser", 46 + rkey: "membership-123", 47 + cid: "test-cid", 48 + forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 49 + roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/test-role-123`, 50 + createdAt: new Date(), 51 + indexedAt: new Date(), 52 + }); 53 + 54 + const result = await checkPermission( 55 + ctx, 56 + "did:plc:test-testuser", 57 + "space.atbb.permission.createTopics" 58 + ); 59 + 60 + expect(result).toBe(true); 61 + }); 62 + 63 + it("returns true for Owner role with wildcard permission", async () => { 64 + // Create Owner role with wildcard 65 + await ctx.db.insert(roles).values({ 66 + did: ctx.config.forumDid, 67 + rkey: "owner-role", 68 + cid: "test-cid", 69 + name: "Owner", 70 + description: "Forum owner", 71 + permissions: ["*"], // Wildcard 72 + priority: 0, 73 + createdAt: new Date(), 74 + indexedAt: new Date(), 75 + }); 76 + 77 + await ctx.db.insert(users).values({ 78 + did: "did:plc:test-owner", 79 + handle: "owner.bsky.social", 80 + indexedAt: new Date(), 81 + }); 82 + 83 + await ctx.db.insert(memberships).values({ 84 + did: "did:plc:test-owner", 85 + rkey: "membership-123", 86 + cid: "test-cid", 87 + forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 88 + roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/owner-role`, 89 + createdAt: new Date(), 90 + indexedAt: new Date(), 91 + }); 92 + 93 + // Should return true for ANY permission 94 + const result = await checkPermission( 95 + ctx, 96 + "did:plc:test-owner", 97 + "space.atbb.permission.someRandomPermission" 98 + ); 99 + 100 + expect(result).toBe(true); 101 + }); 102 + 103 + it("returns false when user has no role assigned", async () => { 104 + await ctx.db.insert(users).values({ 105 + did: "did:plc:test-norole", 106 + handle: "norole.bsky.social", 107 + indexedAt: new Date(), 108 + }); 109 + 110 + // Create membership with roleUri = null 111 + await ctx.db.insert(memberships).values({ 112 + did: "did:plc:test-norole", 113 + rkey: "membership-123", 114 + cid: "test-cid", 115 + forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 116 + roleUri: null, // No role assigned 117 + createdAt: new Date(), 118 + indexedAt: new Date(), 119 + }); 120 + 121 + const result = await checkPermission( 122 + ctx, 123 + "did:plc:test-norole", 124 + "space.atbb.permission.createTopics" 125 + ); 126 + 127 + expect(result).toBe(false); 128 + }); 129 + 130 + it("returns false when user's role is deleted (fail closed)", async () => { 131 + await ctx.db.insert(users).values({ 132 + did: "did:plc:test-deletedrole", 133 + handle: "deletedrole.bsky.social", 134 + indexedAt: new Date(), 135 + }); 136 + 137 + // Create membership with roleUri pointing to non-existent role 138 + await ctx.db.insert(memberships).values({ 139 + did: "did:plc:test-deletedrole", 140 + rkey: "membership-123", 141 + cid: "test-cid", 142 + forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 143 + roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/deleted-role`, 144 + createdAt: new Date(), 145 + indexedAt: new Date(), 146 + }); 147 + 148 + const result = await checkPermission( 149 + ctx, 150 + "did:plc:test-deletedrole", 151 + "space.atbb.permission.createTopics" 152 + ); 153 + 154 + expect(result).toBe(false); // Fail closed 155 + }); 156 + 157 + it("returns false when user has no membership", async () => { 158 + await ctx.db.insert(users).values({ 159 + did: "did:plc:test-nomembership", 160 + handle: "nomembership.bsky.social", 161 + indexedAt: new Date(), 162 + }); 163 + 164 + // No membership record created 165 + 166 + const result = await checkPermission( 167 + ctx, 168 + "did:plc:test-nomembership", 169 + "space.atbb.permission.createTopics" 170 + ); 171 + 172 + expect(result).toBe(false); 173 + }); 174 + }); 175 + 176 + describe("checkMinRole", () => { 177 + it("returns true when user has exact role match", async () => { 178 + await ctx.db.insert(roles).values({ 179 + did: ctx.config.forumDid, 180 + rkey: "admin-role", 181 + cid: "test-cid", 182 + name: "Admin", 183 + permissions: [], 184 + priority: 10, 185 + createdAt: new Date(), 186 + indexedAt: new Date(), 187 + }); 188 + 189 + await ctx.db.insert(users).values({ 190 + did: "did:plc:test-admin", 191 + handle: "admin.bsky.social", 192 + indexedAt: new Date(), 193 + }); 194 + 195 + await ctx.db.insert(memberships).values({ 196 + did: "did:plc:test-admin", 197 + rkey: "membership-123", 198 + cid: "test-cid", 199 + forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 200 + roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/admin-role`, 201 + createdAt: new Date(), 202 + indexedAt: new Date(), 203 + }); 204 + 205 + const result = await checkMinRole(ctx, "did:plc:test-admin", "admin"); 206 + 207 + expect(result).toBe(true); 208 + }); 209 + 210 + it("returns true when user has higher authority role", async () => { 211 + // Owner (priority 0) should pass admin check (priority 10) 212 + await ctx.db.insert(roles).values({ 213 + did: ctx.config.forumDid, 214 + rkey: "owner-role-2", 215 + cid: "test-cid", 216 + name: "Owner", 217 + permissions: ["*"], 218 + priority: 0, 219 + createdAt: new Date(), 220 + indexedAt: new Date(), 221 + }); 222 + 223 + await ctx.db.insert(users).values({ 224 + did: "did:plc:test-owner2", 225 + handle: "owner2.bsky.social", 226 + indexedAt: new Date(), 227 + }); 228 + 229 + await ctx.db.insert(memberships).values({ 230 + did: "did:plc:test-owner2", 231 + rkey: "membership-owner2", 232 + cid: "test-cid", 233 + forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 234 + roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/owner-role-2`, 235 + createdAt: new Date(), 236 + indexedAt: new Date(), 237 + }); 238 + 239 + const result = await checkMinRole(ctx, "did:plc:test-owner2", "admin"); 240 + 241 + expect(result).toBe(true); // Owner > Admin 242 + }); 243 + 244 + it("returns false when user has lower authority role", async () => { 245 + // Moderator (priority 20) should fail admin check (priority 10) 246 + await ctx.db.insert(roles).values({ 247 + did: ctx.config.forumDid, 248 + rkey: "mod-role", 249 + cid: "test-cid", 250 + name: "Moderator", 251 + permissions: [], 252 + priority: 20, 253 + createdAt: new Date(), 254 + indexedAt: new Date(), 255 + }); 256 + 257 + await ctx.db.insert(users).values({ 258 + did: "did:plc:test-mod", 259 + handle: "mod.bsky.social", 260 + indexedAt: new Date(), 261 + }); 262 + 263 + await ctx.db.insert(memberships).values({ 264 + did: "did:plc:test-mod", 265 + rkey: "membership-123", 266 + cid: "test-cid", 267 + forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 268 + roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/mod-role`, 269 + createdAt: new Date(), 270 + indexedAt: new Date(), 271 + }); 272 + 273 + const result = await checkMinRole(ctx, "did:plc:test-mod", "admin"); 274 + 275 + expect(result).toBe(false); // Moderator < Admin 276 + }); 277 + }); 278 + 279 + describe("canActOnUser", () => { 280 + it("returns true when actor is acting on themselves", async () => { 281 + const result = await canActOnUser( 282 + ctx, 283 + "did:plc:test-testuser", 284 + "did:plc:test-testuser" // Same DID 285 + ); 286 + 287 + expect(result).toBe(true); // Self-action bypass 288 + }); 289 + 290 + it("returns true when actor has higher authority", async () => { 291 + // Create Admin role (priority 10) 292 + await ctx.db.insert(roles).values({ 293 + did: ctx.config.forumDid, 294 + rkey: "admin-role-2", 295 + cid: "test-cid", 296 + name: "Admin", 297 + permissions: [], 298 + priority: 10, 299 + createdAt: new Date(), 300 + indexedAt: new Date(), 301 + }); 302 + 303 + // Create Moderator role (priority 20) 304 + await ctx.db.insert(roles).values({ 305 + did: ctx.config.forumDid, 306 + rkey: "mod-role-2", 307 + cid: "test-cid", 308 + name: "Moderator", 309 + permissions: [], 310 + priority: 20, 311 + createdAt: new Date(), 312 + indexedAt: new Date(), 313 + }); 314 + 315 + // Admin user 316 + await ctx.db.insert(users).values({ 317 + did: "did:plc:test-admin2", 318 + handle: "admin2.bsky.social", 319 + indexedAt: new Date(), 320 + }); 321 + 322 + await ctx.db.insert(memberships).values({ 323 + did: "did:plc:test-admin2", 324 + rkey: "membership-admin2", 325 + cid: "test-cid", 326 + forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 327 + roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/admin-role-2`, 328 + createdAt: new Date(), 329 + indexedAt: new Date(), 330 + }); 331 + 332 + // Moderator user 333 + await ctx.db.insert(users).values({ 334 + did: "did:plc:test-mod2", 335 + handle: "mod2.bsky.social", 336 + indexedAt: new Date(), 337 + }); 338 + 339 + await ctx.db.insert(memberships).values({ 340 + did: "did:plc:test-mod2", 341 + rkey: "membership-mod2", 342 + cid: "test-cid", 343 + forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 344 + roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/mod-role-2`, 345 + createdAt: new Date(), 346 + indexedAt: new Date(), 347 + }); 348 + 349 + const result = await canActOnUser(ctx, "did:plc:test-admin2", "did:plc:test-mod2"); 350 + 351 + expect(result).toBe(true); // Admin (10) can act on Moderator (20) 352 + }); 353 + 354 + it("returns false when actor has equal authority", async () => { 355 + // Create Admin role 356 + await ctx.db.insert(roles).values({ 357 + did: ctx.config.forumDid, 358 + rkey: "admin-role-3", 359 + cid: "test-cid", 360 + name: "Admin", 361 + permissions: [], 362 + priority: 10, 363 + createdAt: new Date(), 364 + indexedAt: new Date(), 365 + }); 366 + 367 + // Admin user 1 368 + await ctx.db.insert(users).values({ 369 + did: "did:plc:test-admin3", 370 + handle: "admin3.bsky.social", 371 + indexedAt: new Date(), 372 + }); 373 + 374 + await ctx.db.insert(memberships).values({ 375 + did: "did:plc:test-admin3", 376 + rkey: "membership-admin3", 377 + cid: "test-cid", 378 + forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 379 + roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/admin-role-3`, 380 + createdAt: new Date(), 381 + indexedAt: new Date(), 382 + }); 383 + 384 + // Admin user 2 385 + await ctx.db.insert(users).values({ 386 + did: "did:plc:test-admin4", 387 + handle: "admin4.bsky.social", 388 + indexedAt: new Date(), 389 + }); 390 + 391 + await ctx.db.insert(memberships).values({ 392 + did: "did:plc:test-admin4", 393 + rkey: "membership-admin4", 394 + cid: "test-cid", 395 + forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 396 + roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/admin-role-3`, 397 + createdAt: new Date(), 398 + indexedAt: new Date(), 399 + }); 400 + 401 + const result = await canActOnUser(ctx, "did:plc:test-admin3", "did:plc:test-admin4"); 402 + 403 + expect(result).toBe(false); // Admin (10) cannot act on Admin (10) 404 + }); 405 + 406 + it("returns false when actor has lower authority", async () => { 407 + // Create Admin role (priority 10) 408 + await ctx.db.insert(roles).values({ 409 + did: ctx.config.forumDid, 410 + rkey: "admin-role-4", 411 + cid: "test-cid", 412 + name: "Admin", 413 + permissions: [], 414 + priority: 10, 415 + createdAt: new Date(), 416 + indexedAt: new Date(), 417 + }); 418 + 419 + // Create Moderator role (priority 20) 420 + await ctx.db.insert(roles).values({ 421 + did: ctx.config.forumDid, 422 + rkey: "mod-role-4", 423 + cid: "test-cid", 424 + name: "Moderator", 425 + permissions: [], 426 + priority: 20, 427 + createdAt: new Date(), 428 + indexedAt: new Date(), 429 + }); 430 + 431 + // Admin user 432 + await ctx.db.insert(users).values({ 433 + did: "did:plc:test-admin5", 434 + handle: "admin5.bsky.social", 435 + indexedAt: new Date(), 436 + }); 437 + 438 + await ctx.db.insert(memberships).values({ 439 + did: "did:plc:test-admin5", 440 + rkey: "membership-admin5", 441 + cid: "test-cid", 442 + forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 443 + roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/admin-role-4`, 444 + createdAt: new Date(), 445 + indexedAt: new Date(), 446 + }); 447 + 448 + // Moderator user 449 + await ctx.db.insert(users).values({ 450 + did: "did:plc:test-mod5", 451 + handle: "mod5.bsky.social", 452 + indexedAt: new Date(), 453 + }); 454 + 455 + await ctx.db.insert(memberships).values({ 456 + did: "did:plc:test-mod5", 457 + rkey: "membership-mod5", 458 + cid: "test-cid", 459 + forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 460 + roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/mod-role-4`, 461 + createdAt: new Date(), 462 + indexedAt: new Date(), 463 + }); 464 + 465 + const result = await canActOnUser(ctx, "did:plc:test-mod5", "did:plc:test-admin5"); 466 + 467 + expect(result).toBe(false); // Moderator (20) cannot act on Admin (10) 468 + }); 469 + }); 470 + });
+265
apps/appview/src/middleware/permissions.ts
··· 1 + import type { AppContext } from "../lib/app-context.js"; 2 + import type { Context, Next } from "hono"; 3 + import type { Variables } from "../types.js"; 4 + import { memberships, roles } from "@atbb/db"; 5 + import { eq, and } from "drizzle-orm"; 6 + 7 + /** 8 + * Check if a user has a specific permission. 9 + * 10 + * @returns true if user has permission, false otherwise 11 + * 12 + * Returns false (fail closed) if: 13 + * - User has no membership 14 + * - User has no role assigned (roleUri is null) 15 + * - Role not found in database (deleted or invalid) 16 + */ 17 + async function checkPermission( 18 + ctx: AppContext, 19 + did: string, 20 + permission: string 21 + ): Promise<boolean> { 22 + try { 23 + // 1. Get user's membership (includes roleUri) 24 + const [membership] = await ctx.db 25 + .select() 26 + .from(memberships) 27 + .where(eq(memberships.did, did)) 28 + .limit(1); 29 + 30 + if (!membership || !membership.roleUri) { 31 + return false; // No membership or no role assigned = Guest (no permissions) 32 + } 33 + 34 + // 2. Extract rkey from roleUri 35 + const roleRkey = membership.roleUri.split("/").pop(); 36 + if (!roleRkey) { 37 + return false; 38 + } 39 + 40 + // 3. Fetch role definition from roles table 41 + const [role] = await ctx.db 42 + .select() 43 + .from(roles) 44 + .where( 45 + and( 46 + eq(roles.did, ctx.config.forumDid), 47 + eq(roles.rkey, roleRkey) 48 + ) 49 + ) 50 + .limit(1); 51 + 52 + if (!role) { 53 + return false; // Role not found = treat as Guest (fail closed) 54 + } 55 + 56 + // 4. Check for wildcard (Owner role) 57 + if (role.permissions.includes("*")) { 58 + return true; 59 + } 60 + 61 + // 5. Check if specific permission is in role's permissions array 62 + return role.permissions.includes(permission); 63 + } catch (error) { 64 + // Re-throw programming errors (typos, undefined variables, etc.) 65 + // These should crash during development, not silently deny access 66 + if (error instanceof TypeError || error instanceof ReferenceError || error instanceof SyntaxError) { 67 + throw error; 68 + } 69 + 70 + // For expected errors (database connection, network, etc.): 71 + // Log and fail closed (deny access) 72 + console.error("Failed to check permissions", { 73 + operation: "checkPermission", 74 + did, 75 + permission, 76 + error: error instanceof Error ? error.message : String(error), 77 + }); 78 + 79 + return false; 80 + } 81 + } 82 + 83 + /** 84 + * Get a user's role definition. 85 + * 86 + * @returns Role object or null if user has no role (fail closed on error) 87 + */ 88 + async function getUserRole( 89 + ctx: AppContext, 90 + did: string 91 + ): Promise<{ id: bigint; name: string; priority: number; permissions: string[] } | null> { 92 + try { 93 + const [membership] = await ctx.db 94 + .select() 95 + .from(memberships) 96 + .where(eq(memberships.did, did)) 97 + .limit(1); 98 + 99 + if (!membership || !membership.roleUri) { 100 + return null; 101 + } 102 + 103 + const roleRkey = membership.roleUri.split("/").pop(); 104 + if (!roleRkey) { 105 + return null; 106 + } 107 + 108 + const [role] = await ctx.db 109 + .select({ 110 + id: roles.id, 111 + name: roles.name, 112 + priority: roles.priority, 113 + permissions: roles.permissions, 114 + }) 115 + .from(roles) 116 + .where( 117 + and( 118 + eq(roles.did, ctx.config.forumDid), 119 + eq(roles.rkey, roleRkey) 120 + ) 121 + ) 122 + .limit(1); 123 + 124 + return role || null; 125 + } catch (error) { 126 + // Fail closed: return null on any error to deny access 127 + console.error("Failed to query user role", { 128 + did, 129 + error: error instanceof Error ? error.message : String(error), 130 + }); 131 + return null; 132 + } 133 + } 134 + 135 + /** 136 + * Check if a user has a minimum role level. 137 + * 138 + * @param minRole - Minimum required role name 139 + * @returns true if user's role priority <= required priority (higher authority) 140 + */ 141 + async function checkMinRole( 142 + ctx: AppContext, 143 + did: string, 144 + minRole: string 145 + ): Promise<boolean> { 146 + const rolePriorities: Record<string, number> = { 147 + owner: 0, 148 + admin: 10, 149 + moderator: 20, 150 + member: 30, 151 + }; 152 + 153 + const userRole = await getUserRole(ctx, did); 154 + 155 + if (!userRole) { 156 + return false; // No role = Guest (fails all role checks) 157 + } 158 + 159 + const userPriority = userRole.priority; 160 + const requiredPriority = rolePriorities[minRole]; 161 + 162 + // Lower priority value = higher authority 163 + return userPriority <= requiredPriority; 164 + } 165 + 166 + /** 167 + * Check if an actor can perform moderation actions on a target user. 168 + * 169 + * Priority hierarchy enforcement: 170 + * - Users can always act on themselves (self-action bypass) 171 + * - Can only act on users with strictly lower authority (higher priority value) 172 + * - Cannot act on users with equal or higher authority 173 + * 174 + * @returns true if actor can act on target, false otherwise 175 + */ 176 + export async function canActOnUser( 177 + ctx: AppContext, 178 + actorDid: string, 179 + targetDid: string 180 + ): Promise<boolean> { 181 + // Users can always act on themselves 182 + if (actorDid === targetDid) { 183 + return true; 184 + } 185 + 186 + const actorRole = await getUserRole(ctx, actorDid); 187 + const targetRole = await getUserRole(ctx, targetDid); 188 + 189 + // If actor has no role, they can't act on anyone else 190 + if (!actorRole) { 191 + return false; 192 + } 193 + 194 + // If target has no role (Guest), anyone with a role can act on them 195 + if (!targetRole) { 196 + return true; 197 + } 198 + 199 + // Lower priority = higher authority 200 + // Can only act on users with strictly higher priority value (lower authority) 201 + return actorRole.priority < targetRole.priority; 202 + } 203 + 204 + /** 205 + * Require specific permission middleware. 206 + * 207 + * Validates that the authenticated user has the required permission token. 208 + * Returns 401 if not authenticated, 403 if authenticated but lacks permission. 209 + */ 210 + export function requirePermission( 211 + ctx: AppContext, 212 + permission: string 213 + ) { 214 + return async (c: Context<{ Variables: Variables }>, next: Next) => { 215 + const user = c.get("user"); 216 + 217 + if (!user) { 218 + return c.json({ error: "Authentication required" }, 401); 219 + } 220 + 221 + const hasPermission = await checkPermission(ctx, user.did, permission); 222 + 223 + if (!hasPermission) { 224 + return c.json({ 225 + error: "Insufficient permissions", 226 + required: permission 227 + }, 403); 228 + } 229 + 230 + await next(); 231 + }; 232 + } 233 + 234 + /** 235 + * Require minimum role middleware. 236 + * 237 + * Validates that the authenticated user has a role with sufficient priority. 238 + * Returns 401 if not authenticated, 403 if authenticated but insufficient role. 239 + */ 240 + export function requireRole( 241 + ctx: AppContext, 242 + minRole: "owner" | "admin" | "moderator" | "member" 243 + ) { 244 + return async (c: Context<{ Variables: Variables }>, next: Next) => { 245 + const user = c.get("user"); 246 + 247 + if (!user) { 248 + return c.json({ error: "Authentication required" }, 401); 249 + } 250 + 251 + const hasRole = await checkMinRole(ctx, user.did, minRole); 252 + 253 + if (!hasRole) { 254 + return c.json({ 255 + error: "Insufficient role", 256 + required: minRole 257 + }, 403); 258 + } 259 + 260 + await next(); 261 + }; 262 + } 263 + 264 + // Export helpers for testing 265 + export { checkPermission, getUserRole, checkMinRole };
+577
apps/appview/src/routes/__tests__/admin.test.ts
··· 1 + import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; 2 + import { createTestContext, type TestContext } from "../../lib/__tests__/test-context.js"; 3 + import { Hono } from "hono"; 4 + import type { Variables } from "../../types.js"; 5 + import { memberships, roles, users, forums } from "@atbb/db"; 6 + 7 + // Mock middleware at module level 8 + let mockUser: any; 9 + let mockGetUserRole: ReturnType<typeof vi.fn>; 10 + let mockPutRecord: ReturnType<typeof vi.fn>; 11 + 12 + // Create the mock function at module level 13 + mockGetUserRole = vi.fn(); 14 + 15 + vi.mock("../../middleware/auth.js", () => ({ 16 + requireAuth: vi.fn(() => async (c: any, next: any) => { 17 + c.set("user", mockUser); 18 + await next(); 19 + }), 20 + })); 21 + 22 + vi.mock("../../middleware/permissions.js", () => ({ 23 + requirePermission: vi.fn(() => async (_c: any, next: any) => { 24 + await next(); 25 + }), 26 + getUserRole: (...args: any[]) => mockGetUserRole(...args), 27 + checkPermission: vi.fn().mockResolvedValue(true), 28 + })); 29 + 30 + // Import after mocking 31 + const { createAdminRoutes } = await import("../admin.js"); 32 + 33 + describe.sequential("Admin Routes", () => { 34 + let ctx: TestContext; 35 + let app: Hono<{ Variables: Variables }>; 36 + 37 + beforeEach(async () => { 38 + ctx = await createTestContext(); 39 + app = new Hono<{ Variables: Variables }>().route("/api/admin", createAdminRoutes(ctx)); 40 + 41 + // Set up mock user for auth middleware 42 + mockUser = { did: "did:plc:test-admin" }; 43 + mockGetUserRole.mockClear(); 44 + 45 + // Mock putRecord 46 + mockPutRecord = vi.fn().mockResolvedValue({ uri: "at://...", cid: "bafytest" }); 47 + 48 + // Mock ForumAgent 49 + ctx.forumAgent = { 50 + getAgent: () => ({ 51 + com: { 52 + atproto: { 53 + repo: { 54 + putRecord: mockPutRecord, 55 + }, 56 + }, 57 + }, 58 + }), 59 + } as any; 60 + }); 61 + 62 + afterEach(async () => { 63 + await ctx.cleanup(); 64 + }); 65 + 66 + describe("POST /api/admin/members/:did/role", () => { 67 + beforeEach(async () => { 68 + // Create test roles: Owner (priority 0), Admin (priority 10), Moderator (priority 20) 69 + await ctx.db.insert(roles).values([ 70 + { 71 + did: ctx.config.forumDid, 72 + rkey: "owner", 73 + cid: "bafyowner", 74 + name: "Owner", 75 + description: "Forum owner", 76 + permissions: ["*"], 77 + priority: 0, 78 + createdAt: new Date(), 79 + indexedAt: new Date(), 80 + }, 81 + { 82 + did: ctx.config.forumDid, 83 + rkey: "admin", 84 + cid: "bafyadmin", 85 + name: "Admin", 86 + description: "Administrator", 87 + permissions: ["space.atbb.permission.manageRoles"], 88 + priority: 10, 89 + createdAt: new Date(), 90 + indexedAt: new Date(), 91 + }, 92 + { 93 + did: ctx.config.forumDid, 94 + rkey: "moderator", 95 + cid: "bafymoderator", 96 + name: "Moderator", 97 + description: "Moderator", 98 + permissions: ["space.atbb.permission.createPosts"], 99 + priority: 20, 100 + createdAt: new Date(), 101 + indexedAt: new Date(), 102 + }, 103 + ]); 104 + 105 + // Create target user and membership (use onConflictDoNothing to handle test re-runs) 106 + await ctx.db.insert(users).values({ 107 + did: "did:plc:test-target", 108 + handle: "target.test", 109 + indexedAt: new Date(), 110 + }).onConflictDoNothing(); 111 + 112 + await ctx.db.insert(memberships).values({ 113 + did: "did:plc:test-target", 114 + rkey: "self", 115 + cid: "bafymember", 116 + forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 117 + joinedAt: new Date(), 118 + createdAt: new Date(), 119 + indexedAt: new Date(), 120 + }).onConflictDoNothing(); 121 + }); 122 + 123 + it("assigns role successfully when admin has authority", async () => { 124 + // Admin (priority 10) assigns Moderator (priority 20) - allowed 125 + mockGetUserRole.mockResolvedValue({ 126 + id: 2n, 127 + name: "Admin", 128 + priority: 10, 129 + permissions: ["space.atbb.permission.manageRoles"], 130 + }); 131 + 132 + const res = await app.request("/api/admin/members/did:plc:test-target/role", { 133 + method: "POST", 134 + headers: { "Content-Type": "application/json" }, 135 + body: JSON.stringify({ 136 + roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/moderator`, 137 + }), 138 + }); 139 + 140 + expect(res.status).toBe(200); 141 + const data = await res.json(); 142 + expect(data).toMatchObject({ 143 + success: true, 144 + roleAssigned: "Moderator", 145 + targetDid: "did:plc:test-target", 146 + }); 147 + expect(mockPutRecord).toHaveBeenCalledWith( 148 + expect.objectContaining({ 149 + repo: "did:plc:test-target", 150 + collection: "space.atbb.membership", 151 + record: expect.objectContaining({ 152 + role: expect.objectContaining({ 153 + role: expect.objectContaining({ 154 + uri: `at://${ctx.config.forumDid}/space.atbb.forum.role/moderator`, 155 + cid: "bafymoderator", 156 + }), 157 + }), 158 + }), 159 + }) 160 + ); 161 + }); 162 + 163 + it("returns 403 when assigning role with equal authority", async () => { 164 + // Admin (priority 10) tries to assign Admin (priority 10) - blocked 165 + mockGetUserRole.mockResolvedValue({ 166 + id: 2n, 167 + name: "Admin", 168 + priority: 10, 169 + permissions: ["space.atbb.permission.manageRoles"], 170 + }); 171 + 172 + const res = await app.request("/api/admin/members/did:plc:test-target/role", { 173 + method: "POST", 174 + headers: { "Content-Type": "application/json" }, 175 + body: JSON.stringify({ 176 + roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/admin`, 177 + }), 178 + }); 179 + 180 + expect(res.status).toBe(403); 181 + const data = await res.json(); 182 + expect(data.error).toContain("equal or higher authority"); 183 + expect(data.yourPriority).toBe(10); 184 + expect(data.targetRolePriority).toBe(10); 185 + expect(mockPutRecord).not.toHaveBeenCalled(); 186 + }); 187 + 188 + it("returns 403 when assigning role with higher authority", async () => { 189 + // Admin (priority 10) tries to assign Owner (priority 0) - blocked 190 + mockGetUserRole.mockResolvedValue({ 191 + id: 2n, 192 + name: "Admin", 193 + priority: 10, 194 + permissions: ["space.atbb.permission.manageRoles"], 195 + }); 196 + 197 + const res = await app.request("/api/admin/members/did:plc:test-target/role", { 198 + method: "POST", 199 + headers: { "Content-Type": "application/json" }, 200 + body: JSON.stringify({ 201 + roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/owner`, 202 + }), 203 + }); 204 + 205 + expect(res.status).toBe(403); 206 + const data = await res.json(); 207 + expect(data.error).toContain("equal or higher authority"); 208 + expect(data.yourPriority).toBe(10); 209 + expect(data.targetRolePriority).toBe(0); 210 + expect(mockPutRecord).not.toHaveBeenCalled(); 211 + }); 212 + 213 + it("returns 404 when role not found", async () => { 214 + mockGetUserRole.mockResolvedValue({ 215 + id: 2n, 216 + name: "Admin", 217 + priority: 10, 218 + permissions: ["space.atbb.permission.manageRoles"], 219 + }); 220 + 221 + const res = await app.request("/api/admin/members/did:plc:test-target/role", { 222 + method: "POST", 223 + headers: { "Content-Type": "application/json" }, 224 + body: JSON.stringify({ 225 + roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/nonexistent`, 226 + }), 227 + }); 228 + 229 + expect(res.status).toBe(404); 230 + const data = await res.json(); 231 + expect(data.error).toBe("Role not found"); 232 + expect(mockPutRecord).not.toHaveBeenCalled(); 233 + }); 234 + 235 + it("returns 404 when target user not a member", async () => { 236 + mockGetUserRole.mockResolvedValue({ 237 + id: 2n, 238 + name: "Admin", 239 + priority: 10, 240 + permissions: ["space.atbb.permission.manageRoles"], 241 + }); 242 + 243 + const res = await app.request("/api/admin/members/did:plc:nonmember/role", { 244 + method: "POST", 245 + headers: { "Content-Type": "application/json" }, 246 + body: JSON.stringify({ 247 + roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/moderator`, 248 + }), 249 + }); 250 + 251 + expect(res.status).toBe(404); 252 + const data = await res.json(); 253 + expect(data.error).toBe("User is not a member of this forum"); 254 + expect(mockPutRecord).not.toHaveBeenCalled(); 255 + }); 256 + 257 + it("returns 403 when user has no role assigned", async () => { 258 + // getUserRole returns null (user has no role) 259 + mockGetUserRole.mockResolvedValue(null); 260 + 261 + const res = await app.request("/api/admin/members/did:plc:test-target/role", { 262 + method: "POST", 263 + headers: { "Content-Type": "application/json" }, 264 + body: JSON.stringify({ 265 + roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/moderator`, 266 + }), 267 + }); 268 + 269 + expect(res.status).toBe(403); 270 + const data = await res.json(); 271 + expect(data.error).toBe("You do not have a role assigned"); 272 + expect(mockPutRecord).not.toHaveBeenCalled(); 273 + }); 274 + 275 + it("returns 400 for missing roleUri field", async () => { 276 + const res = await app.request("/api/admin/members/did:plc:test-target/role", { 277 + method: "POST", 278 + headers: { "Content-Type": "application/json" }, 279 + body: JSON.stringify({}), 280 + }); 281 + 282 + expect(res.status).toBe(400); 283 + const data = await res.json(); 284 + expect(data.error).toContain("roleUri is required"); 285 + }); 286 + 287 + it("returns 400 for invalid roleUri format", async () => { 288 + const res = await app.request("/api/admin/members/did:plc:test-target/role", { 289 + method: "POST", 290 + headers: { "Content-Type": "application/json" }, 291 + body: JSON.stringify({ roleUri: "invalid-uri" }), 292 + }); 293 + 294 + expect(res.status).toBe(400); 295 + const data = await res.json(); 296 + expect(data.error).toBe("Invalid roleUri format"); 297 + }); 298 + 299 + it("returns 400 for malformed JSON", async () => { 300 + const res = await app.request("/api/admin/members/did:plc:test-target/role", { 301 + method: "POST", 302 + headers: { "Content-Type": "application/json" }, 303 + body: "{ invalid json }", 304 + }); 305 + 306 + expect(res.status).toBe(400); 307 + const data = await res.json(); 308 + expect(data.error).toContain("Invalid JSON"); 309 + }); 310 + 311 + it("returns 503 when PDS connection fails (network error)", async () => { 312 + mockGetUserRole.mockResolvedValue({ 313 + id: 2n, 314 + name: "Admin", 315 + priority: 10, 316 + permissions: ["space.atbb.permission.manageRoles"], 317 + }); 318 + 319 + mockPutRecord.mockRejectedValue(new Error("fetch failed")); 320 + 321 + const res = await app.request("/api/admin/members/did:plc:test-target/role", { 322 + method: "POST", 323 + headers: { "Content-Type": "application/json" }, 324 + body: JSON.stringify({ 325 + roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/moderator`, 326 + }), 327 + }); 328 + 329 + expect(res.status).toBe(503); 330 + const data = await res.json(); 331 + expect(data.error).toContain("Unable to reach user's PDS"); 332 + }); 333 + 334 + it("returns 500 when ForumAgent unavailable", async () => { 335 + mockGetUserRole.mockResolvedValue({ 336 + id: 2n, 337 + name: "Admin", 338 + priority: 10, 339 + permissions: ["space.atbb.permission.manageRoles"], 340 + }); 341 + 342 + ctx.forumAgent = null; 343 + 344 + const res = await app.request("/api/admin/members/did:plc:test-target/role", { 345 + method: "POST", 346 + headers: { "Content-Type": "application/json" }, 347 + body: JSON.stringify({ 348 + roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/moderator`, 349 + }), 350 + }); 351 + 352 + expect(res.status).toBe(500); 353 + const data = await res.json(); 354 + expect(data.error).toContain("Forum agent not available"); 355 + }); 356 + 357 + it("returns 500 for unexpected server errors", async () => { 358 + mockGetUserRole.mockResolvedValue({ 359 + id: 2n, 360 + name: "Admin", 361 + priority: 10, 362 + permissions: ["space.atbb.permission.manageRoles"], 363 + }); 364 + 365 + mockPutRecord.mockRejectedValue(new Error("Database connection lost")); 366 + 367 + const res = await app.request("/api/admin/members/did:plc:test-target/role", { 368 + method: "POST", 369 + headers: { "Content-Type": "application/json" }, 370 + body: JSON.stringify({ 371 + roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/moderator`, 372 + }), 373 + }); 374 + 375 + expect(res.status).toBe(500); 376 + const data = await res.json(); 377 + expect(data.error).toContain("server error"); 378 + expect(data.error).not.toContain("PDS"); 379 + }); 380 + }); 381 + 382 + describe("GET /api/admin/roles", () => { 383 + it("lists all roles sorted by priority", async () => { 384 + // Create test roles 385 + await ctx.db.insert(roles).values([ 386 + { 387 + did: ctx.config.forumDid, 388 + rkey: "owner", 389 + cid: "bafyowner", 390 + name: "Owner", 391 + description: "Forum owner", 392 + permissions: ["*"], 393 + priority: 0, 394 + createdAt: new Date(), 395 + indexedAt: new Date(), 396 + }, 397 + { 398 + did: ctx.config.forumDid, 399 + rkey: "moderator", 400 + cid: "bafymoderator", 401 + name: "Moderator", 402 + description: "Moderator", 403 + permissions: ["space.atbb.permission.createPosts"], 404 + priority: 20, 405 + createdAt: new Date(), 406 + indexedAt: new Date(), 407 + }, 408 + { 409 + did: ctx.config.forumDid, 410 + rkey: "admin", 411 + cid: "bafyadmin", 412 + name: "Admin", 413 + description: "Administrator", 414 + permissions: ["space.atbb.permission.manageRoles"], 415 + priority: 10, 416 + createdAt: new Date(), 417 + indexedAt: new Date(), 418 + }, 419 + ]); 420 + 421 + const res = await app.request("/api/admin/roles"); 422 + 423 + expect(res.status).toBe(200); 424 + const data = await res.json(); 425 + expect(data.roles).toHaveLength(3); 426 + 427 + // Verify sorted by priority (Owner first, Moderator last) 428 + expect(data.roles[0].name).toBe("Owner"); 429 + expect(data.roles[0].priority).toBe(0); 430 + expect(data.roles[0].permissions).toEqual(["*"]); 431 + 432 + expect(data.roles[1].name).toBe("Admin"); 433 + expect(data.roles[1].priority).toBe(10); 434 + 435 + expect(data.roles[2].name).toBe("Moderator"); 436 + expect(data.roles[2].priority).toBe(20); 437 + 438 + // Verify BigInt serialization 439 + expect(typeof data.roles[0].id).toBe("string"); 440 + }); 441 + 442 + it("returns empty array when no roles exist", async () => { 443 + const res = await app.request("/api/admin/roles"); 444 + 445 + expect(res.status).toBe(200); 446 + const data = await res.json(); 447 + expect(data.roles).toEqual([]); 448 + }); 449 + }); 450 + 451 + describe.sequential("GET /api/admin/members", () => { 452 + beforeEach(async () => { 453 + // Clean database to ensure no data pollution from other tests 454 + await ctx.cleanDatabase(); 455 + 456 + // Re-insert forum (deleted by cleanDatabase) 457 + await ctx.db.insert(forums).values({ 458 + did: ctx.config.forumDid, 459 + rkey: "self", 460 + cid: "bafytest", 461 + name: "Test Forum", 462 + description: "A test forum", 463 + indexedAt: new Date(), 464 + }); 465 + 466 + // Create test role 467 + await ctx.db.insert(roles).values({ 468 + did: ctx.config.forumDid, 469 + rkey: "moderator", 470 + cid: "bafymoderator", 471 + name: "Moderator", 472 + description: "Moderator", 473 + permissions: ["space.atbb.permission.createPosts"], 474 + priority: 20, 475 + createdAt: new Date(), 476 + indexedAt: new Date(), 477 + }); 478 + }); 479 + 480 + it("lists members with assigned roles", async () => { 481 + // Create user and membership with role 482 + await ctx.db.insert(users).values({ 483 + did: "did:plc:test-member-role", 484 + handle: "member.test", 485 + indexedAt: new Date(), 486 + }).onConflictDoNothing(); 487 + 488 + await ctx.db.insert(memberships).values({ 489 + did: "did:plc:test-member-role", 490 + rkey: "self", 491 + cid: "bafymember", 492 + forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 493 + roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/moderator`, 494 + joinedAt: new Date("2026-01-15T00:00:00.000Z"), 495 + createdAt: new Date(), 496 + indexedAt: new Date(), 497 + }).onConflictDoNothing(); 498 + 499 + const res = await app.request("/api/admin/members"); 500 + 501 + expect(res.status).toBe(200); 502 + const data = await res.json(); 503 + expect(data.members).toHaveLength(1); 504 + expect(data.members[0]).toMatchObject({ 505 + did: "did:plc:test-member-role", 506 + handle: "member.test", 507 + role: "Moderator", 508 + roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/moderator`, 509 + joinedAt: "2026-01-15T00:00:00.000Z", 510 + }); 511 + }); 512 + 513 + it("shows Guest for members with no role", async () => { 514 + // Create user and membership without role 515 + await ctx.db.insert(users).values({ 516 + did: "did:plc:test-guest", 517 + handle: "guest.test", 518 + indexedAt: new Date(), 519 + }).onConflictDoNothing(); 520 + 521 + await ctx.db.insert(memberships).values({ 522 + did: "did:plc:test-guest", 523 + rkey: "self", 524 + cid: "bafymember", 525 + forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 526 + roleUri: null, 527 + joinedAt: new Date("2026-01-15T00:00:00.000Z"), 528 + createdAt: new Date(), 529 + indexedAt: new Date(), 530 + }).onConflictDoNothing(); 531 + 532 + const res = await app.request("/api/admin/members"); 533 + 534 + expect(res.status).toBe(200); 535 + const data = await res.json(); 536 + expect(data.members).toHaveLength(1); 537 + expect(data.members[0]).toMatchObject({ 538 + did: "did:plc:test-guest", 539 + handle: "guest.test", 540 + role: "Guest", 541 + roleUri: null, 542 + }); 543 + }); 544 + 545 + it("shows DID as handle fallback when handle not found", async () => { 546 + // Create user without handle (to test DID fallback) 547 + await ctx.db.insert(users).values({ 548 + did: "did:plc:test-unknown", 549 + handle: null, // No handle to test fallback 550 + indexedAt: new Date(), 551 + }).onConflictDoNothing(); 552 + 553 + // Create membership for this user 554 + await ctx.db.insert(memberships).values({ 555 + did: "did:plc:test-unknown", 556 + rkey: "self", 557 + cid: "bafymember", 558 + forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 559 + roleUri: null, 560 + joinedAt: new Date(), 561 + createdAt: new Date(), 562 + indexedAt: new Date(), 563 + }).onConflictDoNothing(); 564 + 565 + const res = await app.request("/api/admin/members"); 566 + 567 + expect(res.status).toBe(200); 568 + const data = await res.json(); 569 + expect(data.members).toHaveLength(1); 570 + expect(data.members[0]).toMatchObject({ 571 + did: "did:plc:test-unknown", 572 + handle: "did:plc:test-unknown", // DID used as fallback 573 + role: "Guest", 574 + }); 575 + }); 576 + }); 577 + });
+8 -1
apps/appview/src/routes/__tests__/posts.test.ts
··· 4 4 import type { Variables } from "../../types.js"; 5 5 import { posts, users } from "@atbb/db"; 6 6 7 - // Mock requireAuth at the module level 7 + // Mock auth and permission middleware at the module level 8 8 let mockPutRecord: ReturnType<typeof vi.fn>; 9 9 let mockUser: any; 10 10 11 11 vi.mock("../../middleware/auth.js", () => ({ 12 12 requireAuth: vi.fn(() => async (c: any, next: any) => { 13 13 c.set("user", mockUser); 14 + await next(); 15 + }), 16 + })); 17 + 18 + vi.mock("../../middleware/permissions.js", () => ({ 19 + requirePermission: vi.fn(() => async (c: any, next: any) => { 20 + // User already set by requireAuth mock 14 21 await next(); 15 22 }), 16 23 }));
+8 -1
apps/appview/src/routes/__tests__/topics.test.ts
··· 5 5 import { forums, categories, boards } from "@atbb/db"; 6 6 import { eq } from "drizzle-orm"; 7 7 8 - // Mock requireAuth at the module level 8 + // Mock auth and permission middleware at the module level 9 9 let mockPutRecord: ReturnType<typeof vi.fn>; 10 10 let mockUser: any; 11 11 12 12 vi.mock("../../middleware/auth.js", () => ({ 13 13 requireAuth: vi.fn(() => async (c: any, next: any) => { 14 14 c.set("user", mockUser); 15 + await next(); 16 + }), 17 + })); 18 + 19 + vi.mock("../../middleware/permissions.js", () => ({ 20 + requirePermission: vi.fn(() => async (c: any, next: any) => { 21 + // User already set by requireAuth mock 15 22 await next(); 16 23 }), 17 24 }));
+256
apps/appview/src/routes/admin.ts
··· 1 + import { Hono } from "hono"; 2 + import type { AppContext } from "../lib/app-context.js"; 3 + import type { Variables } from "../types.js"; 4 + import { requireAuth } from "../middleware/auth.js"; 5 + import { requirePermission, getUserRole } from "../middleware/permissions.js"; 6 + import { memberships, roles, users, forums } from "@atbb/db"; 7 + import { eq, and, sql, asc } from "drizzle-orm"; 8 + import { isNetworkError } from "./helpers.js"; 9 + 10 + export function createAdminRoutes(ctx: AppContext) { 11 + const app = new Hono<{ Variables: Variables }>(); 12 + 13 + /** 14 + * POST /api/admin/members/:did/role 15 + * 16 + * Assign a role to a forum member. 17 + */ 18 + app.post( 19 + "/members/:did/role", 20 + requireAuth(ctx), 21 + requirePermission(ctx, "space.atbb.permission.manageRoles"), 22 + async (c) => { 23 + const targetDid = c.req.param("did"); 24 + const user = c.get("user")!; 25 + 26 + // Parse and validate request body 27 + let body: any; 28 + try { 29 + body = await c.req.json(); 30 + } catch { 31 + return c.json({ error: "Invalid JSON in request body" }, 400); 32 + } 33 + 34 + const { roleUri } = body; 35 + 36 + if (typeof roleUri !== "string") { 37 + return c.json({ error: "roleUri is required and must be a string" }, 400); 38 + } 39 + 40 + // Validate roleUri format 41 + if (!roleUri.startsWith("at://") || !roleUri.includes("/space.atbb.forum.role/")) { 42 + return c.json({ error: "Invalid roleUri format" }, 400); 43 + } 44 + 45 + // Extract role rkey from roleUri 46 + const roleRkey = roleUri.split("/").pop(); 47 + if (!roleRkey) { 48 + return c.json({ error: "Invalid roleUri format" }, 400); 49 + } 50 + 51 + // Validate role exists 52 + const [role] = await ctx.db 53 + .select() 54 + .from(roles) 55 + .where( 56 + and( 57 + eq(roles.did, ctx.config.forumDid), 58 + eq(roles.rkey, roleRkey) 59 + ) 60 + ) 61 + .limit(1); 62 + 63 + if (!role) { 64 + return c.json({ error: "Role not found" }, 404); 65 + } 66 + 67 + // Priority check: Can't assign role with equal or higher authority 68 + const assignerRole = await getUserRole(ctx, user.did); 69 + if (!assignerRole) { 70 + return c.json({ error: "You do not have a role assigned" }, 403); 71 + } 72 + 73 + if (role.priority <= assignerRole.priority) { 74 + return c.json({ 75 + error: "Cannot assign role with equal or higher authority", 76 + yourPriority: assignerRole.priority, 77 + targetRolePriority: role.priority 78 + }, 403); 79 + } 80 + 81 + // Get target user's membership 82 + const [membership] = await ctx.db 83 + .select() 84 + .from(memberships) 85 + .where(eq(memberships.did, targetDid)) 86 + .limit(1); 87 + 88 + if (!membership) { 89 + return c.json({ error: "User is not a member of this forum" }, 404); 90 + } 91 + 92 + // Fetch forum CID for membership record 93 + const [forum] = await ctx.db 94 + .select({ cid: forums.cid }) 95 + .from(forums) 96 + .where(eq(forums.did, ctx.config.forumDid)) 97 + .limit(1); 98 + 99 + if (!forum) { 100 + return c.json({ error: "Forum record not found in database" }, 500); 101 + } 102 + 103 + // Get ForumAgent for PDS write operations 104 + if (!ctx.forumAgent) { 105 + return c.json({ 106 + error: "Forum agent not available. Server configuration issue.", 107 + }, 500); 108 + } 109 + 110 + const agent = ctx.forumAgent.getAgent(); 111 + if (!agent) { 112 + return c.json({ 113 + error: "Forum agent not authenticated. Please try again later.", 114 + }, 503); 115 + } 116 + 117 + try { 118 + // Update membership record on user's PDS using ForumAgent 119 + await agent.com.atproto.repo.putRecord({ 120 + repo: targetDid, 121 + collection: "space.atbb.membership", 122 + rkey: membership.rkey, 123 + record: { 124 + $type: "space.atbb.membership", 125 + forum: { forum: { uri: membership.forumUri, cid: forum.cid } }, 126 + role: { role: { uri: roleUri, cid: role.cid } }, 127 + joinedAt: membership.joinedAt?.toISOString(), 128 + createdAt: membership.createdAt.toISOString(), 129 + }, 130 + }); 131 + 132 + return c.json({ 133 + success: true, 134 + roleAssigned: role.name, 135 + targetDid, 136 + }); 137 + } catch (error) { 138 + console.error("Failed to assign role", { 139 + operation: "POST /api/admin/members/:did/role", 140 + targetDid, 141 + roleUri, 142 + error: error instanceof Error ? error.message : String(error), 143 + }); 144 + 145 + // Classify error: network errors (503) vs server errors (500) 146 + if (error instanceof Error && isNetworkError(error)) { 147 + return c.json({ 148 + error: "Unable to reach user's PDS to update role. Please try again later.", 149 + }, 503); 150 + } 151 + 152 + return c.json({ 153 + error: "Failed to assign role due to server error. Please contact support.", 154 + }, 500); 155 + } 156 + } 157 + ); 158 + 159 + /** 160 + * GET /api/admin/roles 161 + * 162 + * List all available roles for the forum. 163 + */ 164 + app.get( 165 + "/roles", 166 + requireAuth(ctx), 167 + requirePermission(ctx, "space.atbb.permission.manageRoles"), 168 + async (c) => { 169 + try { 170 + const rolesList = await ctx.db 171 + .select({ 172 + id: roles.id, 173 + name: roles.name, 174 + description: roles.description, 175 + permissions: roles.permissions, 176 + priority: roles.priority, 177 + }) 178 + .from(roles) 179 + .where(eq(roles.did, ctx.config.forumDid)) 180 + .orderBy(asc(roles.priority)); 181 + 182 + return c.json({ 183 + roles: rolesList.map(role => ({ 184 + id: role.id.toString(), 185 + name: role.name, 186 + description: role.description, 187 + permissions: role.permissions, 188 + priority: role.priority, 189 + })), 190 + }); 191 + } catch (error) { 192 + console.error("Failed to list roles", { 193 + operation: "GET /api/admin/roles", 194 + error: error instanceof Error ? error.message : String(error), 195 + }); 196 + 197 + return c.json({ 198 + error: "Failed to retrieve roles. Please try again later.", 199 + }, 500); 200 + } 201 + } 202 + ); 203 + 204 + /** 205 + * GET /api/admin/members 206 + * 207 + * List all forum members with their assigned roles. 208 + */ 209 + app.get( 210 + "/members", 211 + requireAuth(ctx), 212 + requirePermission(ctx, "space.atbb.permission.manageMembers"), 213 + async (c) => { 214 + try { 215 + const membersList = await ctx.db 216 + .select({ 217 + did: memberships.did, 218 + handle: users.handle, 219 + role: roles.name, 220 + roleUri: memberships.roleUri, 221 + joinedAt: memberships.joinedAt, 222 + }) 223 + .from(memberships) 224 + .leftJoin(users, eq(memberships.did, users.did)) 225 + .leftJoin( 226 + roles, 227 + sql`${memberships.roleUri} LIKE 'at://' || ${roles.did} || '/space.atbb.forum.role/' || ${roles.rkey}` 228 + ) 229 + .where(eq(memberships.forumUri, `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`)) 230 + .orderBy(asc(roles.priority), asc(users.handle)) 231 + .limit(100); 232 + 233 + return c.json({ 234 + members: membersList.map(member => ({ 235 + did: member.did, 236 + handle: member.handle || member.did, 237 + role: member.role || "Guest", 238 + roleUri: member.roleUri, 239 + joinedAt: member.joinedAt?.toISOString(), 240 + })), 241 + }); 242 + } catch (error) { 243 + console.error("Failed to list members", { 244 + operation: "GET /api/admin/members", 245 + error: error instanceof Error ? error.message : String(error), 246 + }); 247 + 248 + return c.json({ 249 + error: "Failed to retrieve members. Please try again later.", 250 + }, 500); 251 + } 252 + } 253 + ); 254 + 255 + return app; 256 + }
+3 -1
apps/appview/src/routes/index.ts
··· 7 7 import { createTopicsRoutes } from "./topics.js"; 8 8 import { createPostsRoutes } from "./posts.js"; 9 9 import { createAuthRoutes } from "./auth.js"; 10 + import { createAdminRoutes } from "./admin.js"; 10 11 11 12 /** 12 13 * Factory function that creates all API routes with access to app context. ··· 20 21 .route("/categories", createCategoriesRoutes(ctx)) 21 22 .route("/boards", createBoardsRoutes(ctx)) 22 23 .route("/topics", createTopicsRoutes(ctx)) 23 - .route("/posts", createPostsRoutes(ctx)); 24 + .route("/posts", createPostsRoutes(ctx)) 25 + .route("/admin", createAdminRoutes(ctx)); 24 26 } 25 27 26 28 // Export stub routes for tests that don't need database access
+3 -1
apps/appview/src/routes/posts.ts
··· 1 1 import { Hono } from "hono"; 2 2 import { TID } from "@atproto/common-web"; 3 3 import type { AppContext } from "../lib/app-context.js"; 4 + import type { Variables } from "../types.js"; 4 5 import { requireAuth } from "../middleware/auth.js"; 6 + import { requirePermission } from "../middleware/permissions.js"; 5 7 import { 6 8 validatePostText, 7 9 parseBigIntParam, ··· 12 14 } from "./helpers.js"; 13 15 14 16 export function createPostsRoutes(ctx: AppContext) { 15 - return new Hono().post("/", requireAuth(ctx), async (c) => { 17 + return new Hono<{ Variables: Variables }>().post("/", requireAuth(ctx), requirePermission(ctx, "space.atbb.permission.createPosts"), async (c) => { 16 18 const user = c.get("user")!; 17 19 18 20 // Parse and validate request body
+5 -3
apps/appview/src/routes/topics.ts
··· 1 1 import { Hono } from "hono"; 2 2 import type { AppContext } from "../lib/app-context.js"; 3 + import type { Variables } from "../types.js"; 3 4 import { posts, users } from "@atbb/db"; 4 5 import { eq, and, asc } from "drizzle-orm"; 5 6 import { TID } from "@atproto/common-web"; 6 7 import { requireAuth } from "../middleware/auth.js"; 8 + import { requirePermission } from "../middleware/permissions.js"; 7 9 import { parseAtUri } from "../lib/at-uri.js"; 8 10 import { 9 11 parseBigIntParam, ··· 20 22 * Factory function that creates topic routes with access to app context. 21 23 */ 22 24 export function createTopicsRoutes(ctx: AppContext) { 23 - return new Hono() 25 + return new Hono<{ Variables: Variables }>() 24 26 .get("/:id", async (c) => { 25 27 const { id } = c.req.param(); 26 28 ··· 82 84 ); 83 85 } 84 86 }) 85 - .post("/", requireAuth(ctx), async (c) => { 86 - // user is guaranteed to exist after requireAuth middleware 87 + .post("/", requireAuth(ctx), requirePermission(ctx, "space.atbb.permission.createTopics"), async (c) => { 88 + // user is guaranteed to exist after requireAuth and requirePermission middleware 87 89 const user = c.get("user")!; 88 90 89 91 // Parse and validate request body
+2 -2
docs/atproto-forum-plan.md
··· 182 182 - [x] On first login: create `membership` record on user's PDS — **Complete:** Fire-and-forget membership creation integrated into OAuth callback. Helper function `createMembershipForUser()` checks for duplicates, writes `space.atbb.membership` record to user's PDS. Login succeeds even if membership creation fails (graceful degradation). 9 tests (5 unit + 4 integration) verify architectural contract. Implementation in `apps/appview/src/lib/membership.ts` and `apps/appview/src/routes/auth.ts:163-188` (ATB-15, PR #27) 183 183 - [x] Session management (JWT or similar, backed by DID verification) — **Complete:** Three-layer session architecture using `@atproto/oauth-client-node` library with OAuth session store (`oauth-stores.ts`), cookie-to-DID mapping (`cookie-session-store.ts`), and HTTP-only cookies. Sessions include DID, handle, PDS URL, access tokens with automatic refresh, expiry. Automatic cleanup every 5 minutes. Authentication middleware (`requireAuth`, `optionalAuth`) implemented in `apps/appview/src/middleware/auth.ts` (ATB-14) 184 184 - [x] Forum DID authenticated agent for server-side PDS writes — **Complete:** `ForumAgent` service authenticates as Forum DID on startup with smart retry logic (network errors retry with exponential backoff, auth errors fail permanently). Integrated into `AppContext` with proactive session refresh every 30 minutes. Graceful degradation (server starts even if auth fails). Health endpoint (`GET /api/health`) exposes granular ForumAgent status. Implementation in `apps/appview/src/lib/forum-agent.ts`, health endpoint in `apps/appview/src/routes/health.ts` (ATB-18) 185 - - [ ] Role assignment: admin can set roles via Forum DID records (ATB-17) 186 - - [ ] Middleware: permission checks on write endpoints 185 + - [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) 186 + - [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. 187 187 188 188 #### Phase 3: Moderation Basics (Week 6–7) 189 189 - [ ] Admin UI: ban user, lock topic, hide post
+1374
docs/plans/2026-02-14-permissions-design.md
··· 1 + # Role-Based Permission System Design (ATB-17) 2 + 3 + **Date:** 2026-02-14 4 + **Author:** Claude (with user approval) 5 + **Linear Issue:** [ATB-17](https://linear.app/atbb/issue/ATB-17/role-based-permission-middleware) 6 + **Status:** Approved, ready for implementation 7 + 8 + --- 9 + 10 + ## Overview 11 + 12 + This design document describes the implementation of role-based access control (RBAC) for the atBB forum. The permission system restricts admin and moderation actions to users with appropriate roles, enforced at the AppView layer. 13 + 14 + ### Goals 15 + 16 + - Enable role-based access control with 4 default roles (Owner, Admin, Moderator, Member) 17 + - Enforce permissions on write operations (create topics/posts, manage roles, moderate content) 18 + - Support priority hierarchy (prevents lower-authority users from acting on higher-authority users) 19 + - Maintain the MVP trust model (AppView holds Forum DID keys for role assignment) 20 + - Keep implementation simple (no caching, direct database queries) 21 + 22 + ### Non-Goals (Post-MVP) 23 + 24 + - Permission caching (Redis/in-memory) 25 + - Custom roles (beyond the 4 defaults) 26 + - Per-category permissions 27 + - Audit logging for permission checks 28 + - AT Protocol privilege delegation (will replace MVP trust model later) 29 + 30 + --- 31 + 32 + ## Design Decisions 33 + 34 + Based on design discussions, the following architectural choices were made: 35 + 36 + 1. **Performance vs Simplicity:** Simple database queries (no caching) - acceptable for MVP with <1000 users 37 + 2. **Default Role Assignment:** Configurable via `DEFAULT_MEMBER_ROLE` env var - supports both open and approval-required forums 38 + 3. **Role Deletion Behavior:** Treat missing roles as Guest (fail closed) - prevents unintended privilege escalation 39 + 4. **Priority Hierarchy:** Full enforcement across all operations - not just role assignment, but all moderation actions 40 + 5. **Wildcard Permission:** True wildcard (`"*"`) for Owner role - grants all current and future permissions 41 + 6. **Self-Action Bypass:** Users can edit/delete their own content without `moderatePosts` permission 42 + 43 + --- 44 + 45 + ## Architecture Overview 46 + 47 + ### High-Level Structure 48 + 49 + The permission system adds three new layers to the existing AppView architecture: 50 + 51 + **1. Data Layer (Database + Indexer)** 52 + - New `roles` table stores role definitions (name, permissions array, priority) 53 + - Role indexer watches `space.atbb.forum.role` records from firehose 54 + - Existing `memberships` table already has `roleUri` column (points to user's assigned role) 55 + 56 + **2. Permission Layer (Middleware + Helpers)** 57 + - `requirePermission(permission)` - Middleware that enforces specific permission tokens 58 + - `requireRole(minRole)` - Middleware that enforces role hierarchy 59 + - Helper functions (`checkPermission`, `checkMinRole`, `getUserRole`) perform database lookups 60 + - Integrates with existing `requireAuth()` - authentication happens first, then permission check 61 + 62 + **3. Admin Layer (API Endpoints)** 63 + - `POST /api/admin/members/:did/role` - Assign role to a member (uses ForumAgent to update membership on user's PDS) 64 + - `GET /api/admin/roles` - List available roles 65 + - `GET /api/admin/members` - List members with their assigned roles 66 + - Protected by `requirePermission("space.atbb.permission.manageRoles")` 67 + 68 + ### Request Flow Example 69 + 70 + ``` 71 + User creates topic: 72 + → POST /api/topics 73 + → requireAuth(ctx) validates session → sets c.get("user") 74 + → requirePermission(ctx, "createTopics") checks permission: 75 + 1. Query memberships table for user's roleUri 76 + 2. Query roles table for role definition 77 + 3. Check if "space.atbb.permission.createTopics" in permissions array 78 + 4. Check for wildcard "*" (Owner role) 79 + 5. Return 403 if no permission, continue if authorized 80 + → Handler writes post to user's PDS 81 + → Return 201 82 + ``` 83 + 84 + ### Key Technical Choices 85 + 86 + - **No caching** - Simple database queries on every request (acceptable for MVP <1000 users) 87 + - **Factory functions** - Middleware follows existing `requireAuth()` pattern, not class-based 88 + - **Fail closed** - Missing role = Guest (no permissions) 89 + - **Priority enforcement** - All mod actions check priority hierarchy, not just role assignment 90 + - **Self-action bypass** - Users can edit/delete own content without `moderatePosts` permission 91 + - **Configurable defaults** - `DEFAULT_MEMBER_ROLE` env var controls auto-assignment on membership creation 92 + 93 + --- 94 + 95 + ## Database Schema 96 + 97 + ### Roles Table 98 + 99 + The `roles` table stores role definitions owned by the Forum DID. Each role defines a set of permissions and a priority level for hierarchy enforcement. 100 + 101 + **Schema Definition (Drizzle ORM):** 102 + 103 + ```typescript 104 + // packages/db/src/schema.ts 105 + export const roles = pgTable( 106 + "roles", 107 + { 108 + id: bigserial("id", { mode: "bigint" }).primaryKey(), 109 + did: text("did").notNull(), // Forum DID (owner) 110 + rkey: text("rkey").notNull(), // Record key (TID) 111 + cid: text("cid").notNull(), // Content hash 112 + name: text("name").notNull(), // "Admin", "Moderator", etc. 113 + description: text("description"), // Optional description 114 + permissions: text("permissions").array().notNull().default(sql`'{}'::text[]`), // Permission tokens 115 + priority: integer("priority").notNull(), // Lower = higher authority 116 + createdAt: timestamp("created_at", { withTimezone: true }).notNull(), 117 + indexedAt: timestamp("indexed_at", { withTimezone: true }).notNull(), 118 + }, 119 + (table) => [ 120 + uniqueIndex("roles_did_rkey_idx").on(table.did, table.rkey), 121 + index("roles_did_idx").on(table.did), 122 + index("roles_did_name_idx").on(table.did, table.name), // Lookup by name 123 + ] 124 + ); 125 + ``` 126 + 127 + **Migration SQL:** 128 + 129 + ```sql 130 + -- packages/db/drizzle/migrations/XXXX_add_roles_table.sql 131 + CREATE TABLE roles ( 132 + id BIGSERIAL PRIMARY KEY, 133 + did TEXT NOT NULL, 134 + rkey TEXT NOT NULL, 135 + cid TEXT NOT NULL, 136 + name TEXT NOT NULL, 137 + description TEXT, 138 + permissions TEXT[] NOT NULL DEFAULT '{}', 139 + priority INTEGER NOT NULL, 140 + created_at TIMESTAMP WITH TIME ZONE NOT NULL, 141 + indexed_at TIMESTAMP WITH TIME ZONE NOT NULL, 142 + UNIQUE(did, rkey) 143 + ); 144 + 145 + CREATE INDEX idx_roles_did ON roles(did); 146 + CREATE INDEX idx_roles_did_name ON roles(did, name); 147 + ``` 148 + 149 + ### Design Rationale 150 + 151 + **Why `permissions` as `TEXT[]` (PostgreSQL array)?** 152 + - Allows efficient containment checks: `'createTopics' = ANY(permissions)` 153 + - Easier to query than JSONB for simple string lists 154 + - Matches the lexicon's `permissions` field structure 155 + 156 + **Why `did + name` index?** 157 + - Roles are often looked up by name ("Admin", "Moderator") rather than rkey 158 + - Combined index supports queries like `WHERE did = ? AND name = ?` 159 + - Used when assigning roles by name in admin UI 160 + 161 + **No foreign key from `memberships.roleUri` to `roles`?** 162 + - Correct - roleUri is an AT-URI string, not a foreign key 163 + - Roles can be deleted; we handle missing roles with "fail closed" (treat as Guest) 164 + - Foreign key would prevent role deletion when members exist 165 + 166 + --- 167 + 168 + ## Role Indexer 169 + 170 + ### Indexer Integration 171 + 172 + The role indexer follows the established `CollectionConfig` pattern used for posts, forums, categories, etc. It watches for `space.atbb.forum.role` records from the firehose and indexes them into the `roles` table. 173 + 174 + **Collection Configuration:** 175 + 176 + ```typescript 177 + // apps/appview/src/lib/indexer.ts 178 + import { SpaceAtbbForumRole as Role } from "@atbb/lexicon"; 179 + 180 + private roleConfig: CollectionConfig<Role.Record> = { 181 + name: "Role", 182 + table: roles, 183 + deleteStrategy: "hard", // DELETE FROM roles (not soft delete) 184 + toInsertValues: async (event, record) => ({ 185 + did: event.did, 186 + rkey: event.commit.rkey, 187 + cid: event.commit.cid, 188 + name: record.name, 189 + description: record.description || null, 190 + permissions: record.permissions, // Array of permission tokens 191 + priority: record.priority, 192 + createdAt: new Date(record.createdAt), 193 + indexedAt: new Date(), 194 + }), 195 + toUpdateValues: async (event, record) => ({ 196 + cid: event.commit.cid, 197 + name: record.name, 198 + description: record.description || null, 199 + permissions: record.permissions, // Can update permissions array 200 + priority: record.priority, // Can change hierarchy 201 + indexedAt: new Date(), 202 + }), 203 + }; 204 + ``` 205 + 206 + **Handler Methods:** 207 + 208 + ```typescript 209 + async handleRoleCreate(event: CommitCreateEvent<Role.Record>) { 210 + await this.genericCreate(this.roleConfig, event); 211 + } 212 + 213 + async handleRoleUpdate(event: CommitUpdateEvent<Role.Record>) { 214 + await this.genericUpdate(this.roleConfig, event); 215 + } 216 + 217 + async handleRoleDelete(event: CommitDeleteEvent) { 218 + await this.genericDelete(this.roleConfig, event); 219 + } 220 + ``` 221 + 222 + **Handler Registration:** 223 + 224 + ```typescript 225 + // In createHandlerRegistry() 226 + .register({ 227 + collection: "space.atbb.forum.role", 228 + onCreate: this.createWrappedHandler("handleRoleCreate"), 229 + onUpdate: this.createWrappedHandler("handleRoleUpdate"), 230 + onDelete: this.createWrappedHandler("handleRoleDelete"), 231 + }) 232 + ``` 233 + 234 + ### Design Rationale 235 + 236 + **Why hard delete instead of soft delete?** 237 + - Role definitions are metadata, not user content 238 + - When a role is deleted, it's intentional (admin removing a deprecated role) 239 + - Soft delete would leave orphaned records cluttering the database 240 + - Members with deleted roles are handled by "fail closed" logic in permission checks 241 + 242 + **What happens when permissions array is updated?** 243 + - Existing members keep their `roleUri` unchanged 244 + - Next permission check reads the updated permissions array 245 + - Changes take effect immediately (no cache to invalidate) 246 + - Example: Admin updates "Moderator" role to remove `banUsers` permission - all moderators immediately lose ban ability 247 + 248 + --- 249 + 250 + ## Permission Middleware & Helpers 251 + 252 + ### Middleware Factory Functions 253 + 254 + Two middleware functions enforce permissions, following the same pattern as `requireAuth()`: 255 + 256 + **File:** `apps/appview/src/middleware/permissions.ts` 257 + 258 + ```typescript 259 + import type { Context, Next } from "hono"; 260 + import type { AppContext } from "../lib/app-context.js"; 261 + import type { Variables } from "../types.js"; 262 + import { memberships, roles } from "@atbb/db"; 263 + import { eq, and } from "drizzle-orm"; 264 + 265 + /** 266 + * Require specific permission middleware. 267 + * 268 + * Validates that the authenticated user has the required permission token. 269 + * Returns 401 if not authenticated, 403 if authenticated but lacks permission. 270 + */ 271 + export function requirePermission( 272 + ctx: AppContext, 273 + permission: string 274 + ) { 275 + return async (c: Context<{ Variables: Variables }>, next: Next) => { 276 + const user = c.get("user"); 277 + 278 + if (!user) { 279 + return c.json({ error: "Authentication required" }, 401); 280 + } 281 + 282 + const hasPermission = await checkPermission(ctx, user.did, permission); 283 + 284 + if (!hasPermission) { 285 + return c.json({ 286 + error: "Insufficient permissions", 287 + required: permission 288 + }, 403); 289 + } 290 + 291 + await next(); 292 + }; 293 + } 294 + 295 + /** 296 + * Require minimum role middleware. 297 + * 298 + * Validates that the authenticated user has a role with sufficient priority. 299 + * Returns 401 if not authenticated, 403 if authenticated but insufficient role. 300 + */ 301 + export function requireRole( 302 + ctx: AppContext, 303 + minRole: "owner" | "admin" | "moderator" | "member" 304 + ) { 305 + return async (c: Context<{ Variables: Variables }>, next: Next) => { 306 + const user = c.get("user"); 307 + 308 + if (!user) { 309 + return c.json({ error: "Authentication required" }, 401); 310 + } 311 + 312 + const hasRole = await checkMinRole(ctx, user.did, minRole); 313 + 314 + if (!hasRole) { 315 + return c.json({ 316 + error: "Insufficient role", 317 + required: minRole 318 + }, 403); 319 + } 320 + 321 + await next(); 322 + }; 323 + } 324 + ``` 325 + 326 + ### Helper Functions 327 + 328 + **Permission lookup with wildcard support:** 329 + 330 + ```typescript 331 + /** 332 + * Check if a user has a specific permission. 333 + * 334 + * @returns true if user has permission, false otherwise 335 + * 336 + * Returns false (fail closed) if: 337 + * - User has no membership 338 + * - User has no role assigned (roleUri is null) 339 + * - Role not found in database (deleted or invalid) 340 + */ 341 + async function checkPermission( 342 + ctx: AppContext, 343 + did: string, 344 + permission: string 345 + ): Promise<boolean> { 346 + try { 347 + // 1. Get user's membership (includes roleUri) 348 + const [membership] = await ctx.db 349 + .select() 350 + .from(memberships) 351 + .where(eq(memberships.did, did)) 352 + .limit(1); 353 + 354 + if (!membership || !membership.roleUri) { 355 + return false; // No membership or no role assigned = Guest (no permissions) 356 + } 357 + 358 + // 2. Extract rkey from roleUri (e.g., "at://did:plc:xyz/space.atbb.forum.role/abc123" -> "abc123") 359 + const roleRkey = membership.roleUri.split("/").pop(); 360 + if (!roleRkey) { 361 + return false; 362 + } 363 + 364 + // 3. Fetch role definition from roles table 365 + const [role] = await ctx.db 366 + .select() 367 + .from(roles) 368 + .where( 369 + and( 370 + eq(roles.did, ctx.config.forumDid), 371 + eq(roles.rkey, roleRkey) 372 + ) 373 + ) 374 + .limit(1); 375 + 376 + if (!role) { 377 + return false; // Role not found = treat as Guest (fail closed) 378 + } 379 + 380 + // 4. Check for wildcard (Owner role) 381 + if (role.permissions.includes("*")) { 382 + return true; 383 + } 384 + 385 + // 5. Check if specific permission is in role's permissions array 386 + return role.permissions.includes(permission); 387 + } catch (error) { 388 + console.error("Failed to check permissions", { 389 + operation: "checkPermission", 390 + did, 391 + permission, 392 + error: error instanceof Error ? error.message : String(error), 393 + }); 394 + 395 + // Fail closed: deny access on database errors 396 + return false; 397 + } 398 + } 399 + ``` 400 + 401 + **Priority-based role comparison:** 402 + 403 + ```typescript 404 + /** 405 + * Check if a user has a minimum role level. 406 + * 407 + * @param minRole - Minimum required role name 408 + * @returns true if user's role priority <= required priority (higher authority) 409 + */ 410 + async function checkMinRole( 411 + ctx: AppContext, 412 + did: string, 413 + minRole: string 414 + ): Promise<boolean> { 415 + const rolePriorities = { 416 + owner: 0, 417 + admin: 10, 418 + moderator: 20, 419 + member: 30, 420 + }; 421 + 422 + const userRole = await getUserRole(ctx, did); 423 + 424 + if (!userRole) { 425 + return false; // No role = Guest (fails all role checks) 426 + } 427 + 428 + const userPriority = userRole.priority; 429 + const requiredPriority = rolePriorities[minRole]; 430 + 431 + // Lower priority value = higher authority 432 + // Owner (0) passes owner/admin/moderator/member checks 433 + // Admin (10) passes admin/moderator/member checks but not owner 434 + return userPriority <= requiredPriority; 435 + } 436 + ``` 437 + 438 + **Shared helper for role lookup:** 439 + 440 + ```typescript 441 + /** 442 + * Get a user's role definition. 443 + * 444 + * @returns Role object or null if user has no role 445 + */ 446 + async 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 + **Priority hierarchy check for moderation actions:** 486 + 487 + ```typescript 488 + /** 489 + * Check if an actor can perform moderation actions on a target user. 490 + * 491 + * Priority hierarchy enforcement: 492 + * - Users can always act on themselves (self-action bypass) 493 + * - Can only act on users with strictly lower authority (higher priority value) 494 + * - Cannot act on users with equal or higher authority 495 + * 496 + * @returns true if actor can act on target, false otherwise 497 + */ 498 + export async function canActOnUser( 499 + ctx: AppContext, 500 + actorDid: string, 501 + targetDid: string 502 + ): Promise<boolean> { 503 + // Users can always act on themselves 504 + if (actorDid === targetDid) { 505 + return true; 506 + } 507 + 508 + const actorRole = await getUserRole(ctx, actorDid); 509 + const targetRole = await getUserRole(ctx, targetDid); 510 + 511 + // If actor has no role, they can't act on anyone else 512 + if (!actorRole) { 513 + return false; 514 + } 515 + 516 + // If target has no role (Guest), anyone with a role can act on them 517 + if (!targetRole) { 518 + return true; 519 + } 520 + 521 + // Lower priority = higher authority 522 + // Can only act on users with strictly higher priority value (lower authority) 523 + return actorRole.priority < targetRole.priority; 524 + } 525 + ``` 526 + 527 + ### Design Rationale 528 + 529 + **Self-action bypass implementation:** 530 + - `canActOnUser()` returns `true` when `actorDid === targetDid` 531 + - Moderation endpoints check this first before checking permissions 532 + - Example: User can delete their own post even without `moderatePosts` permission 533 + 534 + **Query efficiency:** 535 + - Each permission check = 2 queries (membership + role) 536 + - With proper indexes (`memberships.did`, `roles.did + roles.rkey`), completes in ~5-10ms 537 + - No N+1 problem since we always limit to 1 result 538 + 539 + **Error handling:** 540 + - Missing membership → `false` (not an error, just Guest status) 541 + - Missing role → `false` (fail closed) 542 + - Database errors bubble up to global error handler 543 + 544 + --- 545 + 546 + ## Admin Endpoints 547 + 548 + ### API Routes 549 + 550 + Three new endpoints under the `/api/admin/*` namespace for role management: 551 + 552 + **File:** `apps/appview/src/routes/admin.ts` 553 + 554 + ```typescript 555 + import { Hono } from "hono"; 556 + import type { AppContext } from "../lib/app-context.js"; 557 + import type { Variables } from "../types.js"; 558 + import { requirePermission } from "../middleware/permissions.js"; 559 + import { memberships, roles, users } from "@atbb/db"; 560 + import { eq, and, sql, asc } from "drizzle-orm"; 561 + import { getUserRole, canActOnUser } from "../middleware/permissions.js"; 562 + 563 + export function createAdminRoutes(ctx: AppContext) { 564 + const app = new Hono<{ Variables: Variables }>(); 565 + 566 + /** 567 + * POST /api/admin/members/:did/role 568 + * 569 + * Assign a role to a forum member. 570 + * 571 + * Requirements: 572 + * - User must have manageRoles permission 573 + * - Cannot assign roles with equal or higher authority than your own 574 + * - Target user must be a member of the forum 575 + * - Role must exist 576 + */ 577 + app.post( 578 + "/members/:did/role", 579 + requirePermission(ctx, "space.atbb.permission.manageRoles"), 580 + async (c) => { 581 + const targetDid = c.req.param("did"); 582 + const user = c.get("user")!; 583 + 584 + // Parse and validate request body 585 + let body: any; 586 + try { 587 + body = await c.req.json(); 588 + } catch { 589 + return c.json({ error: "Invalid JSON in request body" }, 400); 590 + } 591 + 592 + const { roleUri } = body; 593 + 594 + if (typeof roleUri !== "string") { 595 + return c.json({ error: "roleUri is required and must be a string" }, 400); 596 + } 597 + 598 + // Extract role rkey from roleUri 599 + const roleRkey = roleUri.split("/").pop(); 600 + if (!roleRkey) { 601 + return c.json({ error: "Invalid roleUri format" }, 400); 602 + } 603 + 604 + // Validate role exists 605 + const [role] = await ctx.db 606 + .select() 607 + .from(roles) 608 + .where( 609 + and( 610 + eq(roles.did, ctx.config.forumDid), 611 + eq(roles.rkey, roleRkey) 612 + ) 613 + ) 614 + .limit(1); 615 + 616 + if (!role) { 617 + return c.json({ error: "Role not found" }, 404); 618 + } 619 + 620 + // Priority check: Can't assign role with equal or higher authority 621 + const assignerRole = await getUserRole(ctx, user.did); 622 + if (!assignerRole) { 623 + return c.json({ error: "You do not have a role assigned" }, 403); 624 + } 625 + 626 + if (role.priority <= assignerRole.priority) { 627 + return c.json({ 628 + error: "Cannot assign role with equal or higher authority", 629 + yourPriority: assignerRole.priority, 630 + targetRolePriority: role.priority 631 + }, 403); 632 + } 633 + 634 + // Get target user's membership 635 + const [membership] = await ctx.db 636 + .select() 637 + .from(memberships) 638 + .where(eq(memberships.did, targetDid)) 639 + .limit(1); 640 + 641 + if (!membership) { 642 + return c.json({ error: "User is not a member of this forum" }, 404); 643 + } 644 + 645 + try { 646 + // Update membership record on user's PDS using ForumAgent 647 + await ctx.forumAgent.agent.com.atproto.repo.putRecord({ 648 + repo: targetDid, 649 + collection: "space.atbb.membership", 650 + rkey: membership.rkey, 651 + record: { 652 + $type: "space.atbb.membership", 653 + forum: { forum: { uri: membership.forumUri, cid: "" } }, // CID will be updated by PDS 654 + role: { role: { uri: roleUri, cid: role.cid } }, 655 + joinedAt: membership.joinedAt?.toISOString(), 656 + createdAt: membership.createdAt.toISOString(), 657 + }, 658 + }); 659 + 660 + return c.json({ 661 + success: true, 662 + roleAssigned: role.name, 663 + targetDid, 664 + }); 665 + } catch (error) { 666 + console.error("Failed to assign role", { 667 + operation: "POST /api/admin/members/:did/role", 668 + targetDid, 669 + roleUri, 670 + error: error instanceof Error ? error.message : String(error), 671 + }); 672 + 673 + return c.json({ 674 + error: "Failed to assign role. Please try again later.", 675 + }, 500); 676 + } 677 + } 678 + ); 679 + 680 + /** 681 + * GET /api/admin/roles 682 + * 683 + * List all available roles for the forum. 684 + * Sorted by priority (Owner first, Member last). 685 + */ 686 + app.get( 687 + "/roles", 688 + requirePermission(ctx, "space.atbb.permission.manageRoles"), 689 + async (c) => { 690 + try { 691 + const rolesList = await ctx.db 692 + .select({ 693 + id: roles.id, 694 + name: roles.name, 695 + description: roles.description, 696 + permissions: roles.permissions, 697 + priority: roles.priority, 698 + }) 699 + .from(roles) 700 + .where(eq(roles.did, ctx.config.forumDid)) 701 + .orderBy(asc(roles.priority)); // Owner first, Member last 702 + 703 + return c.json({ 704 + roles: rolesList.map(role => ({ 705 + id: role.id.toString(), 706 + name: role.name, 707 + description: role.description, 708 + permissions: role.permissions, 709 + priority: role.priority, 710 + })), 711 + }); 712 + } catch (error) { 713 + console.error("Failed to list roles", { 714 + operation: "GET /api/admin/roles", 715 + error: error instanceof Error ? error.message : String(error), 716 + }); 717 + 718 + return c.json({ 719 + error: "Failed to retrieve roles. Please try again later.", 720 + }, 500); 721 + } 722 + } 723 + ); 724 + 725 + /** 726 + * GET /api/admin/members 727 + * 728 + * List all forum members with their assigned roles. 729 + * Includes DID, handle, role name, and join date. 730 + * Paginated to 100 members (pagination not implemented in MVP). 731 + */ 732 + app.get( 733 + "/members", 734 + requirePermission(ctx, "space.atbb.permission.manageMembers"), 735 + async (c) => { 736 + try { 737 + const membersList = await ctx.db 738 + .select({ 739 + did: memberships.did, 740 + handle: users.handle, 741 + role: roles.name, 742 + roleUri: memberships.roleUri, 743 + joinedAt: memberships.joinedAt, 744 + }) 745 + .from(memberships) 746 + .leftJoin(users, eq(memberships.did, users.did)) 747 + .leftJoin( 748 + roles, 749 + sql`${memberships.roleUri} LIKE 'at://' || ${roles.did} || '/space.atbb.forum.role/' || ${roles.rkey}` 750 + ) 751 + .where(eq(memberships.forumUri, `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`)) 752 + .orderBy(asc(roles.priority), asc(users.handle)) 753 + .limit(100); // Pagination not implemented in MVP 754 + 755 + return c.json({ 756 + members: membersList.map(member => ({ 757 + did: member.did, 758 + handle: member.handle || member.did, 759 + role: member.role || "Guest", 760 + roleUri: member.roleUri, 761 + joinedAt: member.joinedAt?.toISOString(), 762 + })), 763 + }); 764 + } catch (error) { 765 + console.error("Failed to list members", { 766 + operation: "GET /api/admin/members", 767 + error: error instanceof Error ? error.message : String(error), 768 + }); 769 + 770 + return c.json({ 771 + error: "Failed to retrieve members. Please try again later.", 772 + }, 500); 773 + } 774 + } 775 + ); 776 + 777 + return app; 778 + } 779 + ``` 780 + 781 + ### Design Rationale 782 + 783 + **Why use ForumAgent for role assignment?** 784 + - Membership records live on the **user's PDS**, not the Forum's PDS 785 + - Only the Forum DID has authority to assign roles 786 + - ForumAgent authenticates as Forum DID and writes to user's PDS on behalf of admin 787 + - This is the MVP trust model (AppView holds Forum DID keys) 788 + 789 + **Priority enforcement in role assignment:** 790 + - Admins (priority 10) can assign Moderator (20) and Member (30) roles 791 + - Admins cannot assign Admin (10) or Owner (0) roles (prevents privilege escalation) 792 + - This prevents a compromised admin account from promoting themselves or others 793 + 794 + **Member listing JOIN logic:** 795 + - LEFT JOIN on roles allows showing members with no role (Guests) 796 + - Custom SQL JOIN condition matches roleUri to role's AT-URI pattern 797 + - Ordered by priority (Admins first) then handle (alphabetical) 798 + 799 + --- 800 + 801 + ## Default Role Seeding 802 + 803 + ### Seed Script 804 + 805 + A startup script creates the 4 default roles on the Forum DID's PDS if they don't already exist. 806 + 807 + **File:** `apps/appview/src/lib/seed-roles.ts` 808 + 809 + ```typescript 810 + import type { AppContext } from "./app-context.js"; 811 + import { roles } from "@atbb/db"; 812 + import { eq } from "drizzle-orm"; 813 + 814 + interface DefaultRole { 815 + name: string; 816 + description: string; 817 + permissions: string[]; 818 + priority: number; 819 + } 820 + 821 + const DEFAULT_ROLES: DefaultRole[] = [ 822 + { 823 + name: "Owner", 824 + description: "Forum owner with full control", 825 + permissions: ["*"], // Wildcard grants all permissions 826 + priority: 0, 827 + }, 828 + { 829 + name: "Admin", 830 + description: "Can manage forum structure and users", 831 + permissions: [ 832 + "space.atbb.permission.manageCategories", 833 + "space.atbb.permission.manageRoles", 834 + "space.atbb.permission.manageMembers", 835 + "space.atbb.permission.moderatePosts", 836 + "space.atbb.permission.banUsers", 837 + "space.atbb.permission.pinTopics", 838 + "space.atbb.permission.lockTopics", 839 + "space.atbb.permission.createTopics", 840 + "space.atbb.permission.createPosts", 841 + ], 842 + priority: 10, 843 + }, 844 + { 845 + name: "Moderator", 846 + description: "Can moderate content and users", 847 + permissions: [ 848 + "space.atbb.permission.moderatePosts", 849 + "space.atbb.permission.banUsers", 850 + "space.atbb.permission.pinTopics", 851 + "space.atbb.permission.lockTopics", 852 + "space.atbb.permission.createTopics", 853 + "space.atbb.permission.createPosts", 854 + ], 855 + priority: 20, 856 + }, 857 + { 858 + name: "Member", 859 + description: "Regular forum member", 860 + permissions: [ 861 + "space.atbb.permission.createTopics", 862 + "space.atbb.permission.createPosts", 863 + ], 864 + priority: 30, 865 + }, 866 + ]; 867 + 868 + /** 869 + * Seed default roles to Forum DID's PDS. 870 + * 871 + * Idempotent: Checks for existing roles by name before creating. 872 + * Safe to run on every startup. 873 + * 874 + * @returns Summary of created and skipped roles 875 + */ 876 + export async function seedDefaultRoles(ctx: AppContext): Promise<{ created: number; skipped: number }> { 877 + let created = 0; 878 + let skipped = 0; 879 + 880 + for (const defaultRole of DEFAULT_ROLES) { 881 + try { 882 + // Check if role already exists by name 883 + const [existingRole] = await ctx.db 884 + .select() 885 + .from(roles) 886 + .where(eq(roles.name, defaultRole.name)) 887 + .limit(1); 888 + 889 + if (existingRole) { 890 + console.log(`Role "${defaultRole.name}" already exists, skipping`, { 891 + operation: "seedDefaultRoles", 892 + roleName: defaultRole.name, 893 + }); 894 + skipped++; 895 + continue; 896 + } 897 + 898 + // Create role record on Forum DID's PDS 899 + const result = await ctx.forumAgent.agent.com.atproto.repo.createRecord({ 900 + repo: ctx.config.forumDid, 901 + collection: "space.atbb.forum.role", 902 + record: { 903 + $type: "space.atbb.forum.role", 904 + name: defaultRole.name, 905 + description: defaultRole.description, 906 + permissions: defaultRole.permissions, 907 + priority: defaultRole.priority, 908 + createdAt: new Date().toISOString(), 909 + }, 910 + }); 911 + 912 + console.log(`Created default role "${defaultRole.name}"`, { 913 + operation: "seedDefaultRoles", 914 + roleName: defaultRole.name, 915 + uri: result.uri, 916 + cid: result.cid, 917 + }); 918 + 919 + created++; 920 + } catch (error) { 921 + console.error(`Failed to seed role "${defaultRole.name}"`, { 922 + operation: "seedDefaultRoles", 923 + roleName: defaultRole.name, 924 + error: error instanceof Error ? error.message : String(error), 925 + }); 926 + // Continue seeding other roles even if one fails 927 + } 928 + } 929 + 930 + return { created, skipped }; 931 + } 932 + ``` 933 + 934 + ### Startup Integration 935 + 936 + ```typescript 937 + // apps/appview/src/index.ts (in startup sequence, after ForumAgent is initialized) 938 + 939 + // Seed default roles if enabled 940 + if (process.env.SEED_DEFAULT_ROLES !== "false") { 941 + console.log("Seeding default roles..."); 942 + const result = await seedDefaultRoles(ctx); 943 + console.log("Default roles seeded", { 944 + created: result.created, 945 + skipped: result.skipped, 946 + }); 947 + } else { 948 + console.log("Role seeding disabled via SEED_DEFAULT_ROLES=false"); 949 + } 950 + ``` 951 + 952 + ### Environment Variables 953 + 954 + ```bash 955 + # .env 956 + SEED_DEFAULT_ROLES=true # Set to "false" to disable auto-seeding 957 + DEFAULT_MEMBER_ROLE=Member # Role name to auto-assign to new memberships (or empty for manual assignment) 958 + ``` 959 + 960 + ### Design Rationale 961 + 962 + **Idempotent seeding:** 963 + - Checks database for existing roles by name before creating 964 + - Safe to run on every startup (skips existing roles) 965 + - Allows manual role deletion without preventing re-seeding 966 + 967 + **Why write to PDS instead of database directly?** 968 + - Roles are AT Protocol records, not just database entries 969 + - Writing to PDS ensures proper AT-URI, CID, and firehose propagation 970 + - Indexer picks up the records and populates the database 971 + - Maintains consistency with the "PDS is source of truth" architecture 972 + 973 + **Failure handling:** 974 + - If one role fails to seed, continue with remaining roles 975 + - Logs errors but doesn't crash startup 976 + - Partial seeding is acceptable (admin can manually create missing roles) 977 + 978 + **Integration with membership auto-creation:** 979 + - When `DEFAULT_MEMBER_ROLE` is set, the membership creation code (ATB-15) will look up this role by name 980 + - If found, set `roleUri` on new membership record 981 + - If not found or empty, leave `roleUri` as null (Guest status) 982 + 983 + --- 984 + 985 + ## Integration with Existing Endpoints 986 + 987 + ### Updating Write Endpoints 988 + 989 + Replace `requireAuth()` with `requirePermission()` on existing write endpoints: 990 + 991 + **File:** `apps/appview/src/routes/topics.ts` 992 + 993 + ```typescript 994 + // BEFORE: 995 + app.post("/", requireAuth(ctx), async (c) => { 996 + // ... 997 + }); 998 + 999 + // AFTER: 1000 + app.post("/", requirePermission(ctx, "space.atbb.permission.createTopics"), async (c) => { 1001 + // Handler code stays the same - permission check happens before reaching here 1002 + }); 1003 + ``` 1004 + 1005 + **File:** `apps/appview/src/routes/posts.ts` 1006 + 1007 + ```typescript 1008 + // BEFORE: 1009 + app.post("/", requireAuth(ctx), async (c) => { 1010 + // ... 1011 + }); 1012 + 1013 + // AFTER: 1014 + app.post("/", requirePermission(ctx, "space.atbb.permission.createPosts"), async (c) => { 1015 + // Handler code stays the same 1016 + }); 1017 + ``` 1018 + 1019 + ### Future Moderation Endpoints (Not in ATB-17 Scope) 1020 + 1021 + When implementing moderation endpoints (ATB-19+), they'll use both permission AND priority checks: 1022 + 1023 + ```typescript 1024 + // Example future endpoint structure (NOT implemented in ATB-17) 1025 + app.post( 1026 + "/posts/:id/delete", 1027 + requirePermission(ctx, "space.atbb.permission.moderatePosts"), 1028 + async (c) => { 1029 + const postId = c.req.param("id"); 1030 + const user = c.get("user")!; 1031 + 1032 + // Get post author 1033 + const post = await getPostById(ctx, postId); 1034 + if (!post) { 1035 + return c.json({ error: "Post not found" }, 404); 1036 + } 1037 + 1038 + // Self-action bypass: users can always delete their own posts 1039 + if (post.did === user.did) { 1040 + // Delete logic... 1041 + return c.json({ success: true }); 1042 + } 1043 + 1044 + // Priority check: can't moderate users with equal/higher authority 1045 + const canAct = await canActOnUser(ctx, user.did, post.did); 1046 + if (!canAct) { 1047 + return c.json({ 1048 + error: "Cannot moderate posts from users with equal or higher authority" 1049 + }, 403); 1050 + } 1051 + 1052 + // Delete logic... 1053 + } 1054 + ); 1055 + ``` 1056 + 1057 + ### Design Rationale 1058 + 1059 + **Why `requirePermission()` instead of `requireAuth()`?** 1060 + - `requirePermission()` internally calls the same authentication logic 1061 + - It checks session validity first (returns 401 if not authenticated) 1062 + - Then checks permission (returns 403 if authenticated but no permission) 1063 + - Single middleware replaces the auth check with a permission check 1064 + 1065 + **Backward compatibility:** 1066 + - Before ATB-17: All authenticated users can post (no role required) 1067 + - After ATB-17: Only users with `createTopics`/`createPosts` permission can post 1068 + - **Breaking change** - requires roles to be seeded and members to be assigned roles 1069 + - Migration path: Seed roles → auto-assign "Member" role to existing memberships → deploy permission checks 1070 + 1071 + **Self-action bypass implementation:** 1072 + - Not needed for create endpoints (users are always acting on their own behalf) 1073 + - Only needed for moderation endpoints (delete/edit other users' content) 1074 + - Future moderation endpoints check `post.did === user.did` before priority check 1075 + 1076 + **Error messages:** 1077 + - 401 "Authentication required" - not logged in 1078 + - 403 "Insufficient permissions" - logged in but no permission token 1079 + - 403 "Insufficient role" - logged in but wrong role level 1080 + - 403 "Cannot moderate..." - logged in with permission but blocked by priority hierarchy 1081 + 1082 + --- 1083 + 1084 + ## Error Handling 1085 + 1086 + ### Error Categories 1087 + 1088 + The permission system introduces new error scenarios that need clear, actionable messages: 1089 + 1090 + **Authentication Errors (401):** 1091 + - No session cookie present 1092 + - Session expired or invalid 1093 + - Returned BEFORE permission checks 1094 + 1095 + **Permission Errors (403):** 1096 + - User authenticated but lacks required permission token 1097 + - User authenticated but role priority insufficient 1098 + - User authenticated but trying to act on higher-authority user 1099 + 1100 + **Not Found Errors (404):** 1101 + - Target role doesn't exist (role assignment) 1102 + - Target user not a member (role assignment) 1103 + 1104 + **Server Errors (500):** 1105 + - Database query failures 1106 + - PDS write failures (ForumAgent) 1107 + - Unexpected errors in helper functions 1108 + 1109 + ### Logging Standards 1110 + 1111 + **Structured logging with context:** 1112 + 1113 + ```typescript 1114 + console.error("Failed to check permissions", { 1115 + operation: "checkPermission", 1116 + did: did, 1117 + permission: permission, 1118 + error: error instanceof Error ? error.message : String(error), 1119 + errorId: "PERMISSION_CHECK_FAILED", // Optional: for tracking specific error types 1120 + }); 1121 + ``` 1122 + 1123 + **What to log:** 1124 + - ✅ Database errors in helper functions 1125 + - ✅ PDS write failures in admin endpoints 1126 + - ✅ Missing roles during permission checks (info level, not error) 1127 + - ❌ Don't log successful permission checks (too noisy) 1128 + - ❌ Don't log 403 responses (expected behavior, not errors) 1129 + 1130 + ### Helper Function Error Handling 1131 + 1132 + **Fail closed on errors:** 1133 + 1134 + ```typescript 1135 + async function checkPermission( 1136 + ctx: AppContext, 1137 + did: string, 1138 + permission: string 1139 + ): Promise<boolean> { 1140 + try { 1141 + // Database queries... 1142 + } catch (error) { 1143 + console.error("Failed to check permissions", { 1144 + operation: "checkPermission", 1145 + did, 1146 + permission, 1147 + error: error instanceof Error ? error.message : String(error), 1148 + }); 1149 + 1150 + // Fail closed: deny access on database errors 1151 + return false; 1152 + } 1153 + } 1154 + ``` 1155 + 1156 + **Why fail closed?** 1157 + - Database outage should not grant universal access 1158 + - Temporary denial of service is better than temporary privilege escalation 1159 + - Admin can investigate logs to diagnose issues 1160 + 1161 + ### Error Message Guidelines 1162 + 1163 + **Client-facing error messages:** 1164 + - Generic for server errors: "Please try again later" 1165 + - Specific for client errors: "Role not found", "Insufficient permissions" 1166 + - Include helpful context: `required: permission` shows what they need 1167 + - Never expose stack traces or internal details in production 1168 + 1169 + **Distinguishing 401 vs 403:** 1170 + - 401 = "You need to log in" → redirect to login 1171 + - 403 = "You're logged in but can't do this" → show "contact admin" message 1172 + - Clear distinction helps users understand if they need to re-auth or escalate 1173 + 1174 + --- 1175 + 1176 + ## Testing Strategy 1177 + 1178 + ### Unit Tests 1179 + 1180 + **File:** `apps/appview/src/middleware/__tests__/permissions.test.ts` 1181 + 1182 + ```typescript 1183 + describe("checkPermission", () => { 1184 + it("returns true when user has required permission"); 1185 + it("returns true for Owner role with wildcard permission"); 1186 + it("returns false when user has no role assigned"); 1187 + it("returns false when user's role is deleted (fail closed)"); 1188 + it("returns false when user has no membership"); 1189 + }); 1190 + 1191 + describe("checkMinRole", () => { 1192 + it("returns true when user has exact role match"); 1193 + it("returns true when user has higher authority role"); 1194 + it("returns false when user has lower authority role"); 1195 + }); 1196 + 1197 + describe("canActOnUser", () => { 1198 + it("returns true when actor is acting on themselves"); 1199 + it("returns true when actor has higher authority"); 1200 + it("returns false when actor has equal authority"); 1201 + it("returns false when actor has lower authority"); 1202 + }); 1203 + ``` 1204 + 1205 + ### Integration Tests 1206 + 1207 + **File:** `apps/appview/src/middleware/__tests__/permissions.integration.test.ts` 1208 + 1209 + ```typescript 1210 + describe("requirePermission middleware", () => { 1211 + it("allows authenticated user with permission"); 1212 + it("returns 401 for unauthenticated user"); 1213 + it("returns 403 for authenticated user without permission"); 1214 + }); 1215 + 1216 + describe("requireRole middleware", () => { 1217 + it("allows user with sufficient role level"); 1218 + it("returns 403 for user with insufficient role level"); 1219 + }); 1220 + ``` 1221 + 1222 + **File:** `apps/appview/src/routes/__tests__/admin.test.ts` 1223 + 1224 + ```typescript 1225 + describe("POST /api/admin/members/:did/role", () => { 1226 + it("assigns role successfully when admin has authority"); 1227 + it("returns 403 when assigning role with equal authority"); 1228 + it("returns 403 when assigning role with higher authority"); 1229 + it("returns 404 when role not found"); 1230 + it("returns 404 when target user not a member"); 1231 + it("returns 403 when user lacks manageRoles permission"); 1232 + }); 1233 + 1234 + describe("GET /api/admin/roles", () => { 1235 + it("lists all roles sorted by priority"); 1236 + it("returns 403 for non-admin users"); 1237 + }); 1238 + 1239 + describe("GET /api/admin/members", () => { 1240 + it("lists members with assigned roles"); 1241 + it("shows Guest for members with no role"); 1242 + }); 1243 + ``` 1244 + 1245 + **File:** `apps/appview/src/lib/__tests__/indexer.test.ts` 1246 + 1247 + ```typescript 1248 + describe("Role indexer", () => { 1249 + it("indexes role create event"); 1250 + it("updates role on update event"); 1251 + it("deletes role on delete event"); 1252 + }); 1253 + ``` 1254 + 1255 + ### Manual Testing Checklist 1256 + 1257 + **Initial Setup:** 1258 + - [ ] Start fresh database, seed roles on startup 1259 + - [ ] Verify 4 default roles created (Owner, Admin, Moderator, Member) 1260 + - [ ] Check role priorities and permissions in database 1261 + 1262 + **Role Assignment:** 1263 + - [ ] Assign "Member" role to test user via `POST /api/admin/members/:did/role` 1264 + - [ ] Verify user can create topics and posts 1265 + - [ ] Assign "Moderator" role to test user 1266 + - [ ] Verify user can create but cannot manage roles (403) 1267 + 1268 + **Priority Hierarchy:** 1269 + - [ ] As Admin, try to assign Admin role to another user (should fail with 403) 1270 + - [ ] As Admin, assign Moderator role to user (should succeed) 1271 + - [ ] As Admin, try to assign Owner role (should fail with 403) 1272 + 1273 + **Permission Enforcement:** 1274 + - [ ] Create user with no role (Guest) 1275 + - [ ] Try to create topic as Guest (should fail with 403) 1276 + - [ ] Assign Member role, retry (should succeed) 1277 + 1278 + **Wildcard Permission:** 1279 + - [ ] Assign Owner role to test user 1280 + - [ ] Verify user can access all admin endpoints 1281 + - [ ] Verify `checkPermission()` returns true for any permission 1282 + 1283 + ### Test Coverage Goals 1284 + 1285 + - **Helper functions:** 100% coverage (critical permission logic) 1286 + - **Middleware:** 100% coverage (all error paths) 1287 + - **Admin endpoints:** All success + error cases 1288 + - **Role indexer:** Create, update, delete events 1289 + - **Integration:** End-to-end permission enforcement on real routes 1290 + 1291 + --- 1292 + 1293 + ## Implementation Checklist 1294 + 1295 + This checklist matches the Linear issue's acceptance criteria: 1296 + 1297 + ### 1. Database Schema: Roles Table 1298 + - [ ] Create migration `packages/db/drizzle/migrations/XXX_add_roles_table.sql` 1299 + - [ ] Define `roles` table in `packages/db/src/schema.ts` 1300 + - [ ] Export `roles` for use in indexer and queries 1301 + - [ ] Add to cleanup in `test-context.ts` 1302 + 1303 + ### 2. Role Indexer 1304 + - [ ] Add `roleConfig: CollectionConfig<Role.Record>` to `Indexer` class 1305 + - [ ] Implement `handleRoleCreate()`, `handleRoleUpdate()`, `handleRoleDelete()` 1306 + - [ ] Register handlers in `createHandlerRegistry()` 1307 + - [ ] Add import for `SpaceAtbbForumRole` 1308 + 1309 + ### 3. Permission Middleware 1310 + - [ ] Create `apps/appview/src/middleware/permissions.ts` 1311 + - [ ] Implement `requirePermission()` middleware 1312 + - [ ] Implement `requireRole()` middleware 1313 + - [ ] Implement `checkPermission()` helper 1314 + - [ ] Implement `checkMinRole()` helper 1315 + - [ ] Implement `getUserRole()` helper 1316 + - [ ] Implement `canActOnUser()` helper 1317 + 1318 + ### 4. Role Assignment Endpoint 1319 + - [ ] Create `apps/appview/src/routes/admin.ts` 1320 + - [ ] Implement `POST /api/admin/members/:did/role` 1321 + - [ ] Implement `GET /api/admin/roles` 1322 + - [ ] Implement `GET /api/admin/members` 1323 + - [ ] Register admin routes in `apps/appview/src/index.ts` 1324 + 1325 + ### 5. Default Roles Seeding 1326 + - [ ] Create `apps/appview/src/lib/seed-roles.ts` 1327 + - [ ] Implement `seedDefaultRoles()` function 1328 + - [ ] Define `DEFAULT_ROLES` array (Owner, Admin, Moderator, Member) 1329 + - [ ] Integrate seeding in AppView startup 1330 + - [ ] Add `SEED_DEFAULT_ROLES` env var to `.env.example` 1331 + - [ ] Add `DEFAULT_MEMBER_ROLE` env var to `.env.example` 1332 + 1333 + ### 6. Integration with Existing Routes 1334 + - [ ] Update `POST /api/topics` to use `requirePermission()` 1335 + - [ ] Update `POST /api/posts` to use `requirePermission()` 1336 + 1337 + ### 7. Testing Requirements 1338 + - [ ] Unit tests for `checkPermission()`, `checkMinRole()`, `canActOnUser()` 1339 + - [ ] Integration tests for `requirePermission` and `requireRole` middleware 1340 + - [ ] Integration tests for admin endpoints 1341 + - [ ] Unit tests for role indexer 1342 + - [ ] Manual testing checklist completion 1343 + 1344 + ### 8. Documentation 1345 + - [ ] Update `docs/atproto-forum-plan.md` with ATB-17 completion 1346 + - [ ] Mark Linear issue ATB-17 as Done 1347 + - [ ] Update CLAUDE.md if patterns change 1348 + 1349 + --- 1350 + 1351 + ## Success Criteria 1352 + 1353 + The permission system is successful when: 1354 + 1355 + 1. Only users with `createTopics` permission can create topics 1356 + 2. Admins can assign roles to members via API 1357 + 3. Priority hierarchy prevents privilege escalation (Admin can't assign Admin role) 1358 + 4. Permission checks complete in <10ms (no performance impact) 1359 + 5. Default roles are seeded automatically on fresh install 1360 + 6. All tests passing (100% coverage on permission logic) 1361 + 7. Error messages are clear and actionable 1362 + 1363 + --- 1364 + 1365 + ## Future Enhancements (Post-MVP) 1366 + 1367 + - **Permission caching** - Redis or in-memory cache for role lookups 1368 + - **Custom roles** - Allow admins to create new roles beyond the 4 defaults 1369 + - **Per-category permissions** - Different permissions for different categories 1370 + - **Audit logging** - Track all permission checks and role changes 1371 + - **AT Protocol delegation** - Replace ForumAgent key holding with proper delegation 1372 + - **Role templates** - Pre-defined role configurations for common forum types 1373 + - **Bulk role assignment** - Assign roles to multiple users at once 1374 + - **Role inheritance** - Roles that inherit permissions from other roles
+2000
docs/plans/2026-02-14-permissions-implementation.md
··· 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 + 23 + Create migration file with roles table definition: 24 + 25 + ```bash 26 + # File: packages/db/drizzle/migrations/0004_add_roles_table.sql 27 + CREATE 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 + 41 + CREATE INDEX idx_roles_did ON roles(did); 42 + CREATE INDEX idx_roles_did_name ON roles(did, name); 43 + ``` 44 + 45 + **Step 2: Add roles table to schema** 46 + 47 + Modify `packages/db/src/schema.ts` after `firehoseCursor` table (line 188): 48 + 49 + ```typescript 50 + // ── roles ─────────────────────────────────────────────── 51 + // Role definitions, owned by Forum DID. 52 + export 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 + 74 + Also add `sql` import at top: 75 + 76 + ```typescript 77 + import { 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 94 + cd packages/db 95 + pnpm db:migrate 96 + ``` 97 + 98 + Expected: Migration runs successfully, `roles` table created in database. 99 + 100 + **Step 4: Verify schema in database** 101 + 102 + ```bash 103 + psql $DATABASE_URL -c "\d roles" 104 + ``` 105 + 106 + Expected: Shows table structure with all columns and indexes. 107 + 108 + **Step 5: Commit** 109 + 110 + ```bash 111 + git add packages/db/drizzle/migrations/0004_add_roles_table.sql packages/db/src/schema.ts 112 + git 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 + 128 + In `test-context.ts`, add roles cleanup after `modActions` cleanup (around line 55): 129 + 130 + ```typescript 131 + // Clean up test data 132 + await db.delete(modActions).where(eq(modActions.did, config.forumDid)); 133 + await db.delete(roles).where(eq(roles.did, config.forumDid)); // ADD THIS 134 + await db.delete(posts).where(eq(posts.forumUri, forumUri)); 135 + ``` 136 + 137 + Also add roles import at top: 138 + 139 + ```typescript 140 + import { 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 156 + git add apps/appview/src/lib/__tests__/test-context.ts 157 + git 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 + 172 + At top of `indexer.ts`, add to lexicon imports (around line 23): 173 + 174 + ```typescript 175 + import { 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 + 188 + After `boardConfig` definition (around line 250), add: 189 + 190 + ```typescript 191 + private 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 + 219 + After other handler methods (around line 450), add: 220 + 221 + ```typescript 222 + async handleRoleCreate(event: CommitCreateEvent<Role.Record>) { 223 + await this.genericCreate(this.roleConfig, event); 224 + } 225 + 226 + async handleRoleUpdate(event: CommitUpdateEvent<Role.Record>) { 227 + await this.genericUpdate(this.roleConfig, event); 228 + } 229 + 230 + async handleRoleDelete(event: CommitDeleteEvent) { 231 + await this.genericDelete(this.roleConfig, event); 232 + } 233 + ``` 234 + 235 + **Step 4: Register handlers** 236 + 237 + In `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 251 + git add apps/appview/src/lib/indexer.ts 252 + git 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 + 268 + Create test file with imports and test data setup: 269 + 270 + ```typescript 271 + import { describe, it, expect, beforeEach } from "vitest"; 272 + import { createTestContext } from "../../lib/__tests__/test-context.js"; 273 + import type { AppContext } from "../../lib/app-context.js"; 274 + import { roles, memberships, users } from "@atbb/db"; 275 + 276 + describe("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 343 + cd apps/appview 344 + pnpm exec vitest run src/middleware/__tests__/permissions.test.ts 345 + ``` 346 + 347 + Expected: All tests pass (placeholders). 348 + 349 + **Step 3: Commit** 350 + 351 + ```bash 352 + git add apps/appview/src/middleware/__tests__/permissions.test.ts 353 + git 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 366 + import type { Context, Next } from "hono"; 367 + import type { AppContext } from "../lib/app-context.js"; 368 + import type { Variables } from "../types.js"; 369 + import { memberships, roles } from "@atbb/db"; 370 + import { 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 + */ 382 + async 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 + */ 446 + async 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 + */ 490 + async 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 + */ 525 + export 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 554 + export { checkPermission, getUserRole, checkMinRole }; 555 + ``` 556 + 557 + **Step 2: Commit** 558 + 559 + ```bash 560 + git add apps/appview/src/middleware/permissions.ts 561 + git 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 + 579 + Replace the placeholder test: 580 + 581 + ```typescript 582 + it("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 633 + pnpm exec vitest run src/middleware/__tests__/permissions.test.ts -t "returns true when user has required permission" 634 + ``` 635 + 636 + Expected: Test passes. 637 + 638 + **Step 3: Implement remaining checkPermission tests** 639 + 640 + Replace other placeholder tests in checkPermission describe block: 641 + 642 + ```typescript 643 + it("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 + 685 + it("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 + 714 + it("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 + 743 + it("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 + 766 + Replace checkMinRole placeholders: 767 + 768 + ```typescript 769 + it("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 + 804 + it("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 + 840 + it("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 + 879 + Replace canActOnUser placeholders: 880 + 881 + ```typescript 882 + it("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 + 894 + it("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 + 960 + it("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 + 1014 + it("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 1084 + pnpm exec vitest run src/middleware/__tests__/permissions.test.ts 1085 + ``` 1086 + 1087 + Expected: All 13 tests pass. 1088 + 1089 + **Step 7: Commit** 1090 + 1091 + ```bash 1092 + git add apps/appview/src/middleware/__tests__/permissions.test.ts 1093 + git 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 + 1109 + Add 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 + */ 1118 + export 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 + */ 1148 + export 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 1176 + git add apps/appview/src/middleware/permissions.ts 1177 + git 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 1194 + import { describe, it, expect, beforeEach } from "vitest"; 1195 + import { createTestContext } from "../../lib/__tests__/test-context.js"; 1196 + import type { AppContext } from "../../lib/app-context.js"; 1197 + import { createApp } from "../../lib/create-app.js"; 1198 + import { createAdminRoutes } from "../admin.js"; 1199 + import type { Hono } from "hono"; 1200 + import { roles, memberships, users } from "@atbb/db"; 1201 + 1202 + describe("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 1264 + pnpm exec vitest run src/routes/__tests__/admin.test.ts 1265 + ``` 1266 + 1267 + Expected: Import error - `admin.js` doesn't exist. 1268 + 1269 + **Step 3: Commit** 1270 + 1271 + ```bash 1272 + git add apps/appview/src/routes/__tests__/admin.test.ts 1273 + git 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 + 1285 + Create `apps/appview/src/routes/admin.ts`: 1286 + 1287 + ```typescript 1288 + import { Hono } from "hono"; 1289 + import type { AppContext } from "../lib/app-context.js"; 1290 + import type { Variables } from "../types.js"; 1291 + import { requirePermission, getUserRole } from "../middleware/permissions.js"; 1292 + import { memberships, roles, users } from "@atbb/db"; 1293 + import { eq, and, sql, asc } from "drizzle-orm"; 1294 + 1295 + export 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 1507 + pnpm exec vitest run src/routes/__tests__/admin.test.ts 1508 + ``` 1509 + 1510 + Expected: Tests fail (routes exist but authentication not set up in tests). 1511 + 1512 + **Step 3: Commit** 1513 + 1514 + ```bash 1515 + git add apps/appview/src/routes/admin.ts 1516 + git 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 1534 + import type { AppContext } from "./app-context.js"; 1535 + import { roles } from "@atbb/db"; 1536 + import { eq } from "drizzle-orm"; 1537 + 1538 + interface DefaultRole { 1539 + name: string; 1540 + description: string; 1541 + permissions: string[]; 1542 + priority: number; 1543 + } 1544 + 1545 + const 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 + */ 1598 + export 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 1659 + git add apps/appview/src/lib/seed-roles.ts 1660 + git 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 + 1676 + Add to imports at top of `index.ts`: 1677 + 1678 + ```typescript 1679 + import { createAdminRoutes } from "./routes/admin.js"; 1680 + import { seedDefaultRoles } from "./lib/seed-roles.js"; 1681 + ``` 1682 + 1683 + **Step 2: Register admin routes** 1684 + 1685 + After other route registrations (around line 80), add: 1686 + 1687 + ```typescript 1688 + app.route("/api/admin", createAdminRoutes(ctx)); 1689 + ``` 1690 + 1691 + **Step 3: Add role seeding on startup** 1692 + 1693 + After ForumAgent initialization (around line 60), before starting the server: 1694 + 1695 + ```typescript 1696 + // Seed default roles if enabled 1697 + if (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 1712 + git add apps/appview/src/index.ts 1713 + git 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 + 1730 + Add import at top: 1731 + 1732 + ```typescript 1733 + import { requirePermission } from "../middleware/permissions.js"; 1734 + ``` 1735 + 1736 + Change line 13 from: 1737 + 1738 + ```typescript 1739 + app.post("/", requireAuth(ctx), async (c) => { 1740 + ``` 1741 + 1742 + To: 1743 + 1744 + ```typescript 1745 + app.post("/", requirePermission(ctx, "space.atbb.permission.createTopics"), async (c) => { 1746 + ``` 1747 + 1748 + **Step 2: Update posts.ts** 1749 + 1750 + Add import at top: 1751 + 1752 + ```typescript 1753 + import { requirePermission } from "../middleware/permissions.js"; 1754 + ``` 1755 + 1756 + Change line 13 from: 1757 + 1758 + ```typescript 1759 + app.post("/", requireAuth(ctx), async (c) => { 1760 + ``` 1761 + 1762 + To: 1763 + 1764 + ```typescript 1765 + app.post("/", requirePermission(ctx, "space.atbb.permission.createPosts"), async (c) => { 1766 + ``` 1767 + 1768 + **Step 3: Commit** 1769 + 1770 + ```bash 1771 + git add apps/appview/src/routes/topics.ts apps/appview/src/routes/posts.ts 1772 + git 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 + 1788 + Add after existing env vars: 1789 + 1790 + ```bash 1791 + # Role seeding 1792 + SEED_DEFAULT_ROLES=true # Set to "false" to disable auto-seeding on startup 1793 + DEFAULT_MEMBER_ROLE=Member # Role name to auto-assign to new memberships (empty for manual assignment) 1794 + ``` 1795 + 1796 + **Step 2: Commit** 1797 + 1798 + ```bash 1799 + git add .env.example 1800 + git 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 1816 + pnpm test 1817 + ``` 1818 + 1819 + Expected: All tests pass (existing tests + new permission tests). 1820 + 1821 + **Step 2: If tests fail, fix issues** 1822 + 1823 + Common 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 1831 + git add <fixed-files> 1832 + git 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 1846 + dropdb atbb_dev 1847 + createdb atbb_dev 1848 + cd packages/db 1849 + pnpm db:migrate 1850 + ``` 1851 + 1852 + **Step 2: Start AppView** 1853 + 1854 + ```bash 1855 + cd apps/appview 1856 + pnpm dev 1857 + ``` 1858 + 1859 + Expected: Logs show "Created default role" for 4 roles. 1860 + 1861 + **Step 3: Verify roles in database** 1862 + 1863 + ```bash 1864 + psql atbb_dev -c "SELECT name, priority, array_length(permissions, 1) as perm_count FROM roles ORDER BY priority;" 1865 + ``` 1866 + 1867 + Expected: 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 + 1879 + Try 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 + 1887 + Manual 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 + 1898 + In `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 1908 + git add docs/atproto-forum-plan.md 1909 + git 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 1926 + pnpm test 1927 + ``` 1928 + 1929 + Expected: All tests pass. 1930 + 1931 + **Step 2: Run type checking** 1932 + 1933 + ```bash 1934 + pnpm turbo lint 1935 + ``` 1936 + 1937 + Expected: No type errors. 1938 + 1939 + **Step 3: Run linting** 1940 + 1941 + ```bash 1942 + pnpm turbo lint:fix 1943 + ``` 1944 + 1945 + Expected: No lint errors. 1946 + 1947 + **Step 4: Verify build** 1948 + 1949 + ```bash 1950 + pnpm build 1951 + ``` 1952 + 1953 + Expected: Clean build, no errors. 1954 + 1955 + **Step 5: Create final commit if fixes were needed** 1956 + 1957 + ```bash 1958 + git add <any-fixed-files> 1959 + git commit -m "fix: final verification fixes" 1960 + ``` 1961 + 1962 + --- 1963 + 1964 + ## Success Criteria Checklist 1965 + 1966 + Verify 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 + 1981 + After completing this implementation: 1982 + 1983 + 1. **Update Linear:** Mark ATB-17 as Done with implementation notes 1984 + 2. **Test in production-like environment:** Deploy to staging, verify role assignment 1985 + 3. **Create PR:** Open PR with comprehensive description referencing design doc 1986 + 4. **Code review:** Request review from team (use @superpowers:requesting-code-review if needed) 1987 + 5. **Merge:** After approval, merge to main 1988 + 6. **Monitor:** Watch logs for permission check performance and errors 1989 + 1990 + --- 1991 + 1992 + ## Future Enhancements (ATB-19+) 1993 + 1994 + This 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
+24
packages/db/src/schema.ts
··· 9 9 uniqueIndex, 10 10 index, 11 11 } from "drizzle-orm/pg-core"; 12 + import { sql } from "drizzle-orm"; 12 13 13 14 // ── forums ────────────────────────────────────────────── 14 15 // Singleton forum metadata record, owned by Forum DID. ··· 185 186 cursor: bigint("cursor", { mode: "bigint" }).notNull(), // time_us value from Jetstream 186 187 updatedAt: timestamp("updated_at", { withTimezone: true }).notNull(), 187 188 }); 189 + 190 + // ── roles ─────────────────────────────────────────────── 191 + // Role definitions, owned by Forum DID. 192 + export const roles = pgTable( 193 + "roles", 194 + { 195 + id: bigserial("id", { mode: "bigint" }).primaryKey(), 196 + did: text("did").notNull(), 197 + rkey: text("rkey").notNull(), 198 + cid: text("cid").notNull(), 199 + name: text("name").notNull(), 200 + description: text("description"), 201 + permissions: text("permissions").array().notNull().default(sql`'{}'::text[]`), 202 + priority: integer("priority").notNull(), 203 + createdAt: timestamp("created_at", { withTimezone: true }).notNull(), 204 + indexedAt: timestamp("indexed_at", { withTimezone: true }).notNull(), 205 + }, 206 + (table) => [ 207 + uniqueIndex("roles_did_rkey_idx").on(table.did, table.rkey), 208 + index("roles_did_idx").on(table.did), 209 + index("roles_did_name_idx").on(table.did, table.name), 210 + ] 211 + );
+1
packages/lexicon/lexicons/space/atbb/forum/role.yaml
··· 15 15 required: 16 16 - name 17 17 - permissions 18 + - priority 18 19 - createdAt 19 20 properties: 20 21 name: