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

Configure Feed

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

at e749db1438a06bfdd8a9d9095f4d63a36cf9bf73 265 lines 6.9 kB view raw
1import type { AppContext } from "../lib/app-context.js"; 2import type { Context, Next } from "hono"; 3import type { Variables } from "../types.js"; 4import { memberships, roles } from "@atbb/db"; 5import { 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 */ 17async 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 ctx.logger.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 */ 88async 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 ctx.logger.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 */ 141async 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 */ 176export 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 */ 210export 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 */ 240export 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 265export { checkPermission, getUserRole, checkMinRole };