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