WIP! A BB-style forum, on the ATmosphere! We're still working... we'll be back soon when we have something to show off!
node typescript hono htmx atproto
at main 303 lines 7.8 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, rolePermissions } from "@atbb/db"; 5import { eq, and, or } 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 if user has the permission (wildcard or specific) 57 const [match] = await ctx.db 58 .select() 59 .from(rolePermissions) 60 .where( 61 and( 62 eq(rolePermissions.roleId, role.id), 63 or( 64 eq(rolePermissions.permission, permission), 65 eq(rolePermissions.permission, "*") 66 ) 67 ) 68 ) 69 .limit(1); 70 71 return !!match; 72 } catch (error) { 73 // Re-throw programming errors (typos, undefined variables, etc.) 74 // These should crash during development, not silently deny access 75 if (error instanceof TypeError || error instanceof ReferenceError || error instanceof SyntaxError) { 76 throw error; 77 } 78 79 // For expected errors (database connection, network, etc.): 80 // Log and fail closed (deny access) 81 ctx.logger.error("Failed to check permissions", { 82 operation: "checkPermission", 83 did, 84 permission, 85 error: error instanceof Error ? error.message : String(error), 86 }); 87 88 return false; 89 } 90} 91 92/** 93 * Get a user's role definition. 94 * 95 * @returns Role object or null if user has no role (fail closed on error) 96 */ 97async function getUserRole( 98 ctx: AppContext, 99 did: string 100): Promise<{ id: bigint; name: string; priority: number } | null> { 101 try { 102 const [membership] = await ctx.db 103 .select() 104 .from(memberships) 105 .where(eq(memberships.did, did)) 106 .limit(1); 107 108 if (!membership || !membership.roleUri) { 109 return null; 110 } 111 112 const roleRkey = membership.roleUri.split("/").pop(); 113 if (!roleRkey) { 114 return null; 115 } 116 117 const [role] = await ctx.db 118 .select({ 119 id: roles.id, 120 name: roles.name, 121 priority: roles.priority, 122 }) 123 .from(roles) 124 .where( 125 and( 126 eq(roles.did, ctx.config.forumDid), 127 eq(roles.rkey, roleRkey) 128 ) 129 ) 130 .limit(1); 131 132 return role || null; 133 } catch (error) { 134 // Fail closed: return null on any error to deny access 135 ctx.logger.error("Failed to query user role", { 136 did, 137 error: error instanceof Error ? error.message : String(error), 138 }); 139 return null; 140 } 141} 142 143/** 144 * Check if a user has a minimum role level. 145 * 146 * @param minRole - Minimum required role name 147 * @returns true if user's role priority <= required priority (higher authority) 148 */ 149async function checkMinRole( 150 ctx: AppContext, 151 did: string, 152 minRole: string 153): Promise<boolean> { 154 const rolePriorities: Record<string, number> = { 155 owner: 0, 156 admin: 10, 157 moderator: 20, 158 member: 30, 159 }; 160 161 const userRole = await getUserRole(ctx, did); 162 163 if (!userRole) { 164 return false; // No role = Guest (fails all role checks) 165 } 166 167 const userPriority = userRole.priority; 168 const requiredPriority = rolePriorities[minRole]; 169 170 // Lower priority value = higher authority 171 return userPriority <= requiredPriority; 172} 173 174/** 175 * Check if an actor can perform moderation actions on a target user. 176 * 177 * Priority hierarchy enforcement: 178 * - Users can always act on themselves (self-action bypass) 179 * - Can only act on users with strictly lower authority (higher priority value) 180 * - Cannot act on users with equal or higher authority 181 * 182 * @returns true if actor can act on target, false otherwise 183 */ 184export async function canActOnUser( 185 ctx: AppContext, 186 actorDid: string, 187 targetDid: string 188): Promise<boolean> { 189 // Users can always act on themselves 190 if (actorDid === targetDid) { 191 return true; 192 } 193 194 const actorRole = await getUserRole(ctx, actorDid); 195 const targetRole = await getUserRole(ctx, targetDid); 196 197 // If actor has no role, they can't act on anyone else 198 if (!actorRole) { 199 return false; 200 } 201 202 // If target has no role (Guest), anyone with a role can act on them 203 if (!targetRole) { 204 return true; 205 } 206 207 // Lower priority = higher authority 208 // Can only act on users with strictly higher priority value (lower authority) 209 return actorRole.priority < targetRole.priority; 210} 211 212/** 213 * Require specific permission middleware. 214 * 215 * Validates that the authenticated user has the required permission token. 216 * Returns 401 if not authenticated, 403 if authenticated but lacks permission. 217 */ 218export function requirePermission( 219 ctx: AppContext, 220 permission: string 221) { 222 return async (c: Context<{ Variables: Variables }>, next: Next) => { 223 const user = c.get("user"); 224 225 if (!user) { 226 return c.json({ error: "Authentication required" }, 401); 227 } 228 229 const hasPermission = await checkPermission(ctx, user.did, permission); 230 231 if (!hasPermission) { 232 return c.json({ 233 error: "Insufficient permissions", 234 required: permission 235 }, 403); 236 } 237 238 await next(); 239 }; 240} 241 242/** 243 * Require at least one of a list of permissions (OR logic). 244 * 245 * Iterates the permissions list in order, calling checkPermission for each. 246 * Short-circuits and calls next() on the first match. 247 * Returns 401 if not authenticated, 403 if none of the permissions match. 248 */ 249export function requireAnyPermission( 250 ctx: AppContext, 251 permissions: string[] 252) { 253 return async (c: Context<{ Variables: Variables }>, next: Next) => { 254 const user = c.get("user"); 255 256 if (!user) { 257 return c.json({ error: "Authentication required" }, 401); 258 } 259 260 for (const permission of permissions) { 261 const hasPermission = await checkPermission(ctx, user.did, permission); 262 if (hasPermission) { 263 await next(); 264 return; 265 } 266 } 267 268 return c.json({ error: "Insufficient permissions" }, 403); 269 }; 270} 271 272/** 273 * Require minimum role middleware. 274 * 275 * Validates that the authenticated user has a role with sufficient priority. 276 * Returns 401 if not authenticated, 403 if authenticated but insufficient role. 277 */ 278export function requireRole( 279 ctx: AppContext, 280 minRole: "owner" | "admin" | "moderator" | "member" 281) { 282 return async (c: Context<{ Variables: Variables }>, next: Next) => { 283 const user = c.get("user"); 284 285 if (!user) { 286 return c.json({ error: "Authentication required" }, 401); 287 } 288 289 const hasRole = await checkMinRole(ctx, user.did, minRole); 290 291 if (!hasRole) { 292 return c.json({ 293 error: "Insufficient role", 294 required: minRole 295 }, 403); 296 } 297 298 await next(); 299 }; 300} 301 302// Export helpers for testing 303export { checkPermission, getUserRole, checkMinRole };