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 root/atb-56-theme-caching-layer 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 };