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 atb-52-css-token-extraction 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}