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 user-theme-preferences 248 lines 7.9 kB view raw
1import { logger } from "./logger.js"; 2 3export type WebSession = 4 | { authenticated: false } 5 | { authenticated: true; did: string; handle: string }; 6 7/** 8 * Fetches the current session from AppView by forwarding the browser's 9 * atbb_session cookie in a server-to-server call. 10 * 11 * Returns unauthenticated if no cookie is present, AppView is unreachable, 12 * or the session is invalid. 13 */ 14export async function getSession( 15 appviewUrl: string, 16 cookieHeader?: string 17): Promise<WebSession> { 18 if (!cookieHeader || !cookieHeader.includes("atbb_session=")) { 19 return { authenticated: false }; 20 } 21 22 let res: Response; 23 try { 24 res = await fetch(`${appviewUrl}/api/auth/session`, { 25 headers: { Cookie: cookieHeader }, 26 }); 27 } catch (error) { 28 logger.error( 29 "getSession: network error — treating as unauthenticated", 30 { 31 operation: "GET /api/auth/session", 32 error: error instanceof Error ? error.message : String(error), 33 } 34 ); 35 return { authenticated: false }; 36 } 37 38 if (!res.ok) { 39 if (res.status !== 401) { 40 logger.error("getSession: unexpected non-ok status from AppView", { 41 operation: "GET /api/auth/session", 42 status: res.status, 43 }); 44 } 45 return { authenticated: false }; 46 } 47 48 let data: Record<string, unknown>; 49 try { 50 data = (await res.json()) as Record<string, unknown>; 51 } catch { 52 logger.error("getSession: AppView returned invalid JSON — treating as unauthenticated", { 53 operation: "GET /api/auth/session", 54 status: res.status, 55 }); 56 return { authenticated: false }; 57 } 58 59 if ( 60 data.authenticated === true && 61 typeof data.did === "string" && 62 typeof data.handle === "string" 63 ) { 64 return { authenticated: true, did: data.did, handle: data.handle }; 65 } 66 67 return { authenticated: false }; 68} 69 70/** 71 * Extended session type that includes the user's role permissions. 72 * Used on pages that need to conditionally render moderation UI. 73 */ 74export type WebSessionWithPermissions = 75 | { authenticated: false; permissions: Set<string> } 76 | { authenticated: true; did: string; handle: string; permissions: Set<string> }; 77 78/** 79 * Like getSession(), but also fetches the user's role permissions from 80 * GET /api/admin/members/me. Use on pages that need to render mod buttons. 81 * 82 * Returns empty permissions on network errors or when user has no membership. 83 * Never throws — always returns a usable session. 84 */ 85export async function getSessionWithPermissions( 86 appviewUrl: string, 87 cookieHeader?: string 88): Promise<WebSessionWithPermissions> { 89 const session = await getSession(appviewUrl, cookieHeader); 90 91 if (!session.authenticated) { 92 return { authenticated: false, permissions: new Set() }; 93 } 94 95 let permissions = new Set<string>(); 96 let permRes: Response; 97 try { 98 permRes = await fetch(`${appviewUrl}/api/admin/members/me`, { 99 headers: { Cookie: cookieHeader! }, 100 }); 101 } catch (error) { 102 logger.error( 103 "getSessionWithPermissions: network error — continuing with empty permissions", 104 { 105 operation: "GET /api/admin/members/me", 106 did: session.did, 107 error: error instanceof Error ? error.message : String(error), 108 } 109 ); 110 return { ...session, permissions }; 111 } 112 113 if (permRes.ok) { 114 let data: Record<string, unknown>; 115 try { 116 data = (await permRes.json()) as Record<string, unknown>; 117 } catch { 118 logger.error( 119 "getSessionWithPermissions: members/me returned invalid JSON — continuing with empty permissions", 120 { 121 operation: "GET /api/admin/members/me", 122 did: session.did, 123 status: permRes.status, 124 } 125 ); 126 return { ...session, permissions }; 127 } 128 if (Array.isArray(data.permissions)) { 129 permissions = new Set(data.permissions as string[]); 130 } 131 } else if (permRes.status !== 404) { 132 // 404 = no membership = expected for guests, no log needed 133 logger.error( 134 "getSessionWithPermissions: unexpected status from members/me", 135 { 136 operation: "GET /api/admin/members/me", 137 did: session.did, 138 status: permRes.status, 139 } 140 ); 141 } 142 143 return { ...session, permissions }; 144} 145 146/** Returns true if the session grants permission to lock/unlock topics. */ 147export function canLockTopics(auth: WebSessionWithPermissions): boolean { 148 return ( 149 auth.authenticated && 150 (auth.permissions.has("space.atbb.permission.lockTopics") || 151 auth.permissions.has("*")) 152 ); 153} 154 155/** Returns true if the session grants permission to hide/unhide posts. */ 156export function canModeratePosts(auth: WebSessionWithPermissions): boolean { 157 return ( 158 auth.authenticated && 159 (auth.permissions.has("space.atbb.permission.moderatePosts") || 160 auth.permissions.has("*")) 161 ); 162} 163 164/** Returns true if the session grants permission to ban/unban users. */ 165export function canBanUsers(auth: WebSessionWithPermissions): boolean { 166 return ( 167 auth.authenticated && 168 (auth.permissions.has("space.atbb.permission.banUsers") || 169 auth.permissions.has("*")) 170 ); 171} 172 173/** 174 * Permission strings that constitute "any admin access". 175 * Used to gate the /admin landing page. 176 * 177 * Note: `manageRoles` is intentionally absent. It is always exercised 178 * through the /admin/members page, which requires `manageMembers` to access. 179 * A user with only `manageRoles` would see the landing page but no nav cards, 180 * which is confusing UX. `manageMembers` (already listed) covers that case. 181 */ 182const ADMIN_PERMISSIONS = [ 183 "space.atbb.permission.manageMembers", 184 "space.atbb.permission.manageCategories", 185 "space.atbb.permission.moderatePosts", 186 "space.atbb.permission.banUsers", 187 "space.atbb.permission.lockTopics", 188 "space.atbb.permission.manageThemes", 189] as const; 190 191/** 192 * Returns true if the session grants at least one of the admin panel permissions 193 * listed in ADMIN_PERMISSIONS, or the wildcard "*". Used to gate the /admin landing page. 194 */ 195export function hasAnyAdminPermission( 196 auth: WebSessionWithPermissions 197): boolean { 198 if (!auth.authenticated) return false; 199 if (auth.permissions.has("*")) return true; 200 return ADMIN_PERMISSIONS.some((p) => auth.permissions.has(p)); 201} 202 203/** Returns true if the session grants permission to manage forum members. */ 204export function canManageMembers(auth: WebSessionWithPermissions): boolean { 205 return ( 206 auth.authenticated && 207 (auth.permissions.has("space.atbb.permission.manageMembers") || 208 auth.permissions.has("*")) 209 ); 210} 211 212/** Returns true if the session grants permission to manage forum categories and boards. */ 213export function canManageCategories(auth: WebSessionWithPermissions): boolean { 214 return ( 215 auth.authenticated && 216 (auth.permissions.has("space.atbb.permission.manageCategories") || 217 auth.permissions.has("*")) 218 ); 219} 220 221/** Returns true if the session grants any moderation permission (view mod log). */ 222export function canViewModLog(auth: WebSessionWithPermissions): boolean { 223 return ( 224 auth.authenticated && 225 (auth.permissions.has("space.atbb.permission.moderatePosts") || 226 auth.permissions.has("space.atbb.permission.banUsers") || 227 auth.permissions.has("space.atbb.permission.lockTopics") || 228 auth.permissions.has("*")) 229 ); 230} 231 232/** Returns true if the session grants permission to assign member roles. */ 233export function canManageRoles(auth: WebSessionWithPermissions): boolean { 234 return ( 235 auth.authenticated && 236 (auth.permissions.has("space.atbb.permission.manageRoles") || 237 auth.permissions.has("*")) 238 ); 239} 240 241/** Returns true if the session grants permission to manage forum themes. */ 242export function canManageThemes(auth: WebSessionWithPermissions): boolean { 243 return ( 244 auth.authenticated && 245 (auth.permissions.has("space.atbb.permission.manageThemes") || 246 auth.permissions.has("*")) 247 ); 248}