WIP! A BB-style forum, on the ATmosphere! We're still working... we'll be back soon when we have something to show off!
node typescript hono htmx atproto
4
fork

Configure Feed

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

feat(appview): update admin routes to return permissions from role_permissions table

- GET /api/admin/roles: removed roles.permissions from select, enriches each
role with permissions fetched from role_permissions join table via Promise.all
- GET /api/admin/members/me: removed roles.permissions from join select, adds
roleId to select, then fetches permissions separately from role_permissions
- Updated all test files to remove permissions field from db.insert(roles).values()
calls, replacing with separate db.insert(rolePermissions).values() calls after
capturing the returned role id via .returning({ id: roles.id })
- Fixed admin-backfill.test.ts mock count: checkPermission now makes 3 DB
selects (membership + role + role_permissions) instead of 2
- Updated seed-roles.ts CLI step to insert permissions into role_permissions
table separately instead of the removed permissions column

Malpercio 5cfcfd2b 57badb14

+154 -116
+7 -4
apps/appview/src/lib/__tests__/membership.test.ts
··· 1 1 import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; 2 2 import { createMembershipForUser } from "../membership.js"; 3 3 import { createTestContext, type TestContext } from "./test-context.js"; 4 - import { memberships, users, roles } from "@atbb/db"; 4 + import { memberships, users, roles, rolePermissions } from "@atbb/db"; 5 5 import { eq, and } from "drizzle-orm"; 6 6 7 7 describe("createMembershipForUser", () => { ··· 243 243 const memberRoleRkey = "memberrole123"; 244 244 const memberRoleCid = "bafymemberrole456"; 245 245 246 - await ctx.db.insert(roles).values({ 246 + const [memberRole] = await ctx.db.insert(roles).values({ 247 247 did: ctx.config.forumDid, 248 248 rkey: memberRoleRkey, 249 249 cid: memberRoleCid, 250 250 name: "Member", 251 251 description: "Regular forum member", 252 - permissions: ["space.atbb.permission.createTopics", "space.atbb.permission.createPosts"], 253 252 priority: 30, 254 253 createdAt: new Date(), 255 254 indexedAt: new Date(), 256 - }); 255 + }).returning({ id: roles.id }); 256 + await ctx.db.insert(rolePermissions).values([ 257 + { roleId: memberRole.id, permission: "space.atbb.permission.createTopics" }, 258 + { roleId: memberRole.id, permission: "space.atbb.permission.createPosts" }, 259 + ]); 257 260 258 261 const mockAgent = { 259 262 com: {
+8 -6
apps/appview/src/routes/__tests__/admin-backfill.test.ts
··· 2 2 import { Hono } from "hono"; 3 3 import { createAdminRoutes } from "../admin.js"; 4 4 import { createTestContext, type TestContext } from "../../lib/__tests__/test-context.js"; 5 - import { roles, memberships, users, backfillProgress, backfillErrors } from "@atbb/db"; 5 + import { roles, rolePermissions, memberships, users, backfillProgress, backfillErrors } from "@atbb/db"; 6 6 import { BackfillStatus } from "../../lib/backfill-manager.js"; 7 7 8 8 // Mock restoreOAuthSession so tests control auth without real OAuth ··· 76 76 77 77 // Helper: insert admin user with manageForum permission in DB 78 78 async function setupAdminUser() { 79 - await ctx.db.insert(roles).values({ 79 + const [ownerRole] = await ctx.db.insert(roles).values({ 80 80 did: ctx.config.forumDid, 81 81 rkey: ROLE_RKEY, 82 82 cid: "test-cid", 83 83 name: "Owner", 84 84 description: "Forum owner", 85 - permissions: ["*"], 86 85 priority: 0, 87 86 createdAt: new Date(), 88 87 indexedAt: new Date(), 89 - }); 88 + }).returning({ id: roles.id }); 89 + await ctx.db.insert(rolePermissions).values([{ roleId: ownerRole.id, permission: "*" }]); 90 90 91 91 await ctx.db.insert(users).values({ 92 92 did: ADMIN_DID, ··· 272 272 await setupAdminUser(); 273 273 const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); 274 274 275 - // requirePermission makes 2 DB selects (membership + role); let them pass, 276 - // then fail on the handler's backfill_progress query (call 3). 275 + // requirePermission makes 3 DB selects (membership + role + role_permissions); let them pass, 276 + // then fail on the handler's backfill_progress query (call 4). 277 277 const origSelect = ctx.db.select.bind(ctx.db); 278 278 vi.spyOn(ctx.db, "select") 279 279 .mockImplementationOnce(() => origSelect() as any) // permissions: membership 280 280 .mockImplementationOnce(() => origSelect() as any) // permissions: role 281 + .mockImplementationOnce(() => origSelect() as any) // permissions: role_permissions 281 282 .mockReturnValueOnce({ // handler: backfill_progress 282 283 from: vi.fn().mockReturnValue({ 283 284 where: vi.fn().mockReturnValue({ ··· 393 394 vi.spyOn(ctx.db, "select") 394 395 .mockImplementationOnce(() => origSelect() as any) // permissions: membership 395 396 .mockImplementationOnce(() => origSelect() as any) // permissions: role 397 + .mockImplementationOnce(() => origSelect() as any) // permissions: role_permissions 396 398 .mockReturnValueOnce({ // handler: backfill_errors query 397 399 from: vi.fn().mockReturnValue({ 398 400 where: vi.fn().mockReturnValue({
+84 -81
apps/appview/src/routes/__tests__/admin.test.ts
··· 2 2 import { createTestContext, type TestContext } from "../../lib/__tests__/test-context.js"; 3 3 import { Hono } from "hono"; 4 4 import type { Variables } from "../../types.js"; 5 - import { memberships, roles, users, forums } from "@atbb/db"; 5 + import { memberships, roles, rolePermissions, users, forums } from "@atbb/db"; 6 6 7 7 // Mock middleware at module level 8 8 let mockUser: any; ··· 69 69 describe("POST /api/admin/members/:did/role", () => { 70 70 beforeEach(async () => { 71 71 // Create test roles: Owner (priority 0), Admin (priority 10), Moderator (priority 20) 72 - await ctx.db.insert(roles).values([ 73 - { 74 - did: ctx.config.forumDid, 75 - rkey: "owner", 76 - cid: "bafyowner", 77 - name: "Owner", 78 - description: "Forum owner", 79 - permissions: ["*"], 80 - priority: 0, 81 - createdAt: new Date(), 82 - indexedAt: new Date(), 83 - }, 84 - { 85 - did: ctx.config.forumDid, 86 - rkey: "admin", 87 - cid: "bafyadmin", 88 - name: "Admin", 89 - description: "Administrator", 90 - permissions: ["space.atbb.permission.manageRoles"], 91 - priority: 10, 92 - createdAt: new Date(), 93 - indexedAt: new Date(), 94 - }, 95 - { 96 - did: ctx.config.forumDid, 97 - rkey: "moderator", 98 - cid: "bafymoderator", 99 - name: "Moderator", 100 - description: "Moderator", 101 - permissions: ["space.atbb.permission.createPosts"], 102 - priority: 20, 103 - createdAt: new Date(), 104 - indexedAt: new Date(), 105 - }, 106 - ]); 72 + const [ownerRole] = await ctx.db.insert(roles).values({ 73 + did: ctx.config.forumDid, 74 + rkey: "owner", 75 + cid: "bafyowner", 76 + name: "Owner", 77 + description: "Forum owner", 78 + priority: 0, 79 + createdAt: new Date(), 80 + indexedAt: new Date(), 81 + }).returning({ id: roles.id }); 82 + await ctx.db.insert(rolePermissions).values([{ roleId: ownerRole.id, permission: "*" }]); 83 + 84 + const [adminRole] = await ctx.db.insert(roles).values({ 85 + did: ctx.config.forumDid, 86 + rkey: "admin", 87 + cid: "bafyadmin", 88 + name: "Admin", 89 + description: "Administrator", 90 + priority: 10, 91 + createdAt: new Date(), 92 + indexedAt: new Date(), 93 + }).returning({ id: roles.id }); 94 + await ctx.db.insert(rolePermissions).values([{ roleId: adminRole.id, permission: "space.atbb.permission.manageRoles" }]); 95 + 96 + const [moderatorRole] = await ctx.db.insert(roles).values({ 97 + did: ctx.config.forumDid, 98 + rkey: "moderator", 99 + cid: "bafymoderator", 100 + name: "Moderator", 101 + description: "Moderator", 102 + priority: 20, 103 + createdAt: new Date(), 104 + indexedAt: new Date(), 105 + }).returning({ id: roles.id }); 106 + await ctx.db.insert(rolePermissions).values([{ roleId: moderatorRole.id, permission: "space.atbb.permission.createPosts" }]); 107 107 108 108 // Create target user and membership (use onConflictDoNothing to handle test re-runs) 109 109 await ctx.db.insert(users).values({ ··· 387 387 describe("GET /api/admin/roles", () => { 388 388 it("lists all roles sorted by priority", async () => { 389 389 // Create test roles 390 - await ctx.db.insert(roles).values([ 391 - { 392 - did: ctx.config.forumDid, 393 - rkey: "owner", 394 - cid: "bafyowner", 395 - name: "Owner", 396 - description: "Forum owner", 397 - permissions: ["*"], 398 - priority: 0, 399 - createdAt: new Date(), 400 - indexedAt: new Date(), 401 - }, 402 - { 403 - did: ctx.config.forumDid, 404 - rkey: "moderator", 405 - cid: "bafymoderator", 406 - name: "Moderator", 407 - description: "Moderator", 408 - permissions: ["space.atbb.permission.createPosts"], 409 - priority: 20, 410 - createdAt: new Date(), 411 - indexedAt: new Date(), 412 - }, 413 - { 414 - did: ctx.config.forumDid, 415 - rkey: "admin", 416 - cid: "bafyadmin", 417 - name: "Admin", 418 - description: "Administrator", 419 - permissions: ["space.atbb.permission.manageRoles"], 420 - priority: 10, 421 - createdAt: new Date(), 422 - indexedAt: new Date(), 423 - }, 424 - ]); 390 + const [ownerRole] = await ctx.db.insert(roles).values({ 391 + did: ctx.config.forumDid, 392 + rkey: "owner", 393 + cid: "bafyowner", 394 + name: "Owner", 395 + description: "Forum owner", 396 + priority: 0, 397 + createdAt: new Date(), 398 + indexedAt: new Date(), 399 + }).returning({ id: roles.id }); 400 + await ctx.db.insert(rolePermissions).values([{ roleId: ownerRole.id, permission: "*" }]); 401 + 402 + const [moderatorRole] = await ctx.db.insert(roles).values({ 403 + did: ctx.config.forumDid, 404 + rkey: "moderator", 405 + cid: "bafymoderator", 406 + name: "Moderator", 407 + description: "Moderator", 408 + priority: 20, 409 + createdAt: new Date(), 410 + indexedAt: new Date(), 411 + }).returning({ id: roles.id }); 412 + await ctx.db.insert(rolePermissions).values([{ roleId: moderatorRole.id, permission: "space.atbb.permission.createPosts" }]); 413 + 414 + const [adminRole] = await ctx.db.insert(roles).values({ 415 + did: ctx.config.forumDid, 416 + rkey: "admin", 417 + cid: "bafyadmin", 418 + name: "Admin", 419 + description: "Administrator", 420 + priority: 10, 421 + createdAt: new Date(), 422 + indexedAt: new Date(), 423 + }).returning({ id: roles.id }); 424 + await ctx.db.insert(rolePermissions).values([{ roleId: adminRole.id, permission: "space.atbb.permission.manageRoles" }]); 425 425 426 426 const res = await app.request("/api/admin/roles"); 427 427 ··· 469 469 }); 470 470 471 471 // Create test role 472 - await ctx.db.insert(roles).values({ 472 + const [moderatorRole] = await ctx.db.insert(roles).values({ 473 473 did: ctx.config.forumDid, 474 474 rkey: "moderator", 475 475 cid: "bafymoderator", 476 476 name: "Moderator", 477 477 description: "Moderator", 478 - permissions: ["space.atbb.permission.createPosts"], 479 478 priority: 20, 480 479 createdAt: new Date(), 481 480 indexedAt: new Date(), 482 - }); 481 + }).returning({ id: roles.id }); 482 + await ctx.db.insert(rolePermissions).values([{ roleId: moderatorRole.id, permission: "space.atbb.permission.createPosts" }]); 483 483 }); 484 484 485 485 it("lists members with assigned roles", async () => { ··· 615 615 616 616 it("returns 200 with membership, role, and permissions for a user with a linked role", async () => { 617 617 // Insert role 618 - await ctx.db.insert(roles).values({ 618 + const [moderatorRole] = await ctx.db.insert(roles).values({ 619 619 did: ctx.config.forumDid, 620 620 rkey: "moderator", 621 621 cid: "bafymoderator", 622 622 name: "Moderator", 623 623 description: "Moderator role", 624 - permissions: ["space.atbb.permission.createPosts", "space.atbb.permission.editPosts"], 625 624 priority: 20, 626 625 createdAt: new Date(), 627 626 indexedAt: new Date(), 628 - }); 627 + }).returning({ id: roles.id }); 628 + await ctx.db.insert(rolePermissions).values([ 629 + { roleId: moderatorRole.id, permission: "space.atbb.permission.createPosts" }, 630 + { roleId: moderatorRole.id, permission: "space.atbb.permission.editPosts" }, 631 + ]); 629 632 630 633 // Insert user 631 634 await ctx.db.insert(users).values({ ··· 667 670 cid: "bafyguestrole", 668 671 name: "Guest Role", 669 672 description: "Role with no permissions", 670 - permissions: [], 671 673 priority: 100, 672 674 createdAt: new Date(), 673 675 indexedAt: new Date(), 674 676 }); 677 + // No rolePermissions inserted — role has no permissions 675 678 676 679 // Insert user 677 680 await ctx.db.insert(users).values({ ··· 702 705 703 706 it("only returns the current user's membership, not other users'", async () => { 704 707 // Insert role 705 - await ctx.db.insert(roles).values({ 708 + const [adminRole] = await ctx.db.insert(roles).values({ 706 709 did: ctx.config.forumDid, 707 710 rkey: "admin", 708 711 cid: "bafyadmin", 709 712 name: "Admin", 710 713 description: "Admin role", 711 - permissions: ["*"], 712 714 priority: 10, 713 715 createdAt: new Date(), 714 716 indexedAt: new Date(), 715 - }); 717 + }).returning({ id: roles.id }); 718 + await ctx.db.insert(rolePermissions).values([{ roleId: adminRole.id, permission: "*" }]); 716 719 717 720 // Insert current user with membership 718 721 await ctx.db.insert(users).values({
-4
apps/appview/src/routes/__tests__/mod.test.ts
··· 92 92 rkey: "admin-role", 93 93 cid: "bafyadmin", 94 94 name: "Admin", 95 - permissions: ["space.atbb.permission.banUsers"], 96 95 priority: 10, 97 96 createdAt: new Date(), 98 97 indexedAt: new Date(), ··· 661 660 rkey: "unban-admin-role", 662 661 cid: "bafyunbanadmin", 663 662 name: "Admin", 664 - permissions: ["space.atbb.permission.banUsers"], 665 663 priority: 10, 666 664 createdAt: new Date(), 667 665 indexedAt: new Date(), ··· 1222 1220 rkey: "lock-mod-role", 1223 1221 cid: "bafylockmod", 1224 1222 name: "Moderator", 1225 - permissions: ["space.atbb.permission.lockTopics"], 1226 1223 priority: 20, 1227 1224 createdAt: new Date(), 1228 1225 indexedAt: new Date(), ··· 1934 1931 rkey: "unlock-mod-role", 1935 1932 cid: "bafyunlockmod", 1936 1933 name: "Moderator", 1937 - permissions: ["space.atbb.permission.lockTopics"], 1938 1934 priority: 20, 1939 1935 createdAt: new Date(), 1940 1936 indexedAt: new Date(),
+29 -13
apps/appview/src/routes/admin.ts
··· 3 3 import type { Variables } from "../types.js"; 4 4 import { requireAuth } from "../middleware/auth.js"; 5 5 import { requirePermission, getUserRole } from "../middleware/permissions.js"; 6 - import { memberships, roles, users, forums, backfillProgress, backfillErrors } from "@atbb/db"; 6 + import { memberships, roles, rolePermissions, users, forums, backfillProgress, backfillErrors } from "@atbb/db"; 7 7 import { eq, and, sql, asc } from "drizzle-orm"; 8 8 import { isProgrammingError } from "../lib/errors.js"; 9 9 import { BackfillStatus } from "../lib/backfill-manager.js"; ··· 162 162 id: roles.id, 163 163 name: roles.name, 164 164 description: roles.description, 165 - permissions: roles.permissions, 166 165 priority: roles.priority, 167 166 }) 168 167 .from(roles) 169 168 .where(eq(roles.did, ctx.config.forumDid)) 170 169 .orderBy(asc(roles.priority)); 171 170 172 - return c.json({ 173 - roles: rolesList.map(role => ({ 174 - id: role.id.toString(), 175 - name: role.name, 176 - description: role.description, 177 - permissions: role.permissions, 178 - priority: role.priority, 179 - })), 180 - }); 171 + const rolesWithPermissions = await Promise.all( 172 + rolesList.map(async (role) => { 173 + const perms = await ctx.db 174 + .select({ permission: rolePermissions.permission }) 175 + .from(rolePermissions) 176 + .where(eq(rolePermissions.roleId, role.id)); 177 + return { 178 + id: role.id.toString(), 179 + name: role.name, 180 + description: role.description, 181 + permissions: perms.map((p) => p.permission), 182 + priority: role.priority, 183 + }; 184 + }) 185 + ); 186 + 187 + return c.json({ roles: rolesWithPermissions }); 181 188 } catch (error) { 182 189 return handleReadError(c, error, "Failed to retrieve roles", { 183 190 operation: "GET /api/admin/roles", ··· 254 261 handle: users.handle, 255 262 roleUri: memberships.roleUri, 256 263 roleName: roles.name, 257 - permissions: roles.permissions, 264 + roleId: roles.id, 258 265 }) 259 266 .from(memberships) 260 267 .leftJoin(users, eq(memberships.did, users.did)) ··· 274 281 return c.json({ error: "Membership not found" }, 404); 275 282 } 276 283 284 + let permissions: string[] = []; 285 + if (member.roleId) { 286 + const perms = await ctx.db 287 + .select({ permission: rolePermissions.permission }) 288 + .from(rolePermissions) 289 + .where(eq(rolePermissions.roleId, member.roleId)); 290 + permissions = perms.map((p) => p.permission); 291 + } 292 + 277 293 return c.json({ 278 294 did: member.did, 279 295 handle: member.handle || user.did, 280 296 role: member.roleName || "Guest", 281 297 roleUri: member.roleUri, 282 - permissions: member.permissions || [], 298 + permissions, 283 299 }); 284 300 } catch (error) { 285 301 return handleReadError(c, error, "Failed to retrieve your membership", {
+14 -4
packages/cli/src/__tests__/seed-roles.test.ts
··· 6 6 7 7 function mockDb(existingRoleNames: string[] = []) { 8 8 const existingQueue = [...existingRoleNames]; 9 + let roleIdCounter = 1n; 9 10 10 11 return { 11 12 select: vi.fn().mockReturnValue({ ··· 26 27 }), 27 28 }), 28 29 }), 29 - insert: vi.fn().mockReturnValue({ 30 - values: vi.fn().mockResolvedValue(undefined), 30 + insert: vi.fn().mockImplementation(() => { 31 + const id = roleIdCounter++; 32 + // values() returns a Promise (for rolePermissions inserts that are awaited directly) 33 + // but also has a .returning() method (for role inserts) 34 + const resolvedPromise = Promise.resolve(undefined); 35 + const valuesResult = Object.assign(resolvedPromise, { 36 + returning: vi.fn().mockResolvedValue([{ id }]), 37 + }); 38 + return { 39 + values: vi.fn().mockReturnValue(valuesResult), 40 + }; 31 41 }), 32 42 } as any; 33 43 } ··· 70 80 expect(result.created).toBe(4); 71 81 expect(result.skipped).toBe(0); 72 82 expect(agent.com.atproto.repo.createRecord).toHaveBeenCalledTimes(4); 73 - // Verify DB insertions 74 - expect(db.insert).toHaveBeenCalledTimes(4); 83 + // Verify DB insertions: 4 role inserts + 4 rolePermissions inserts (one per role) 84 + expect(db.insert).toHaveBeenCalledTimes(8); 75 85 // Verify returned role data 76 86 expect(result.roles).toHaveLength(4); 77 87 expect(result.roles[0].name).toBe("Owner");
+12 -4
packages/cli/src/lib/steps/seed-roles.ts
··· 1 1 import type { AtpAgent } from "@atproto/api"; 2 2 import type { Database } from "@atbb/db"; 3 - import { roles } from "@atbb/db"; 3 + import { roles, rolePermissions } from "@atbb/db"; 4 4 import { eq } from "drizzle-orm"; 5 5 6 6 interface DefaultRole { ··· 119 119 const rkey = response.data.uri.split("/").pop()!; 120 120 121 121 // Insert into database so downstream steps can query it 122 - await db.insert(roles).values({ 122 + const [insertedRole] = await db.insert(roles).values({ 123 123 did: forumDid, 124 124 rkey, 125 125 cid: response.data.cid, 126 126 name: defaultRole.name, 127 127 description: defaultRole.description, 128 - permissions: defaultRole.permissions, 129 128 priority: defaultRole.priority, 130 129 createdAt: new Date(), 131 130 indexedAt: new Date(), 132 - }); 131 + }).returning({ id: roles.id }); 132 + 133 + if (defaultRole.permissions.length > 0) { 134 + await db.insert(rolePermissions).values( 135 + defaultRole.permissions.map((permission) => ({ 136 + roleId: insertedRole.id, 137 + permission, 138 + })) 139 + ); 140 + } 133 141 134 142 seededRoles.push({ 135 143 name: defaultRole.name,