import { isProgrammingError } from "./errors.js"; import { logger } from "./logger.js"; export type WebSession = | { authenticated: false } | { authenticated: true; did: string; handle: string }; /** * Fetches the current session from AppView by forwarding the browser's * atbb_session cookie in a server-to-server call. * * Returns unauthenticated if no cookie is present, AppView is unreachable, * or the session is invalid. */ export async function getSession( appviewUrl: string, cookieHeader?: string ): Promise { if (!cookieHeader || !cookieHeader.includes("atbb_session=")) { return { authenticated: false }; } try { const res = await fetch(`${appviewUrl}/api/auth/session`, { headers: { Cookie: cookieHeader }, }); if (!res.ok) { if (res.status !== 401) { logger.error("getSession: unexpected non-ok status from AppView", { operation: "GET /api/auth/session", status: res.status, }); } return { authenticated: false }; } const data = (await res.json()) as Record; if ( data.authenticated === true && typeof data.did === "string" && typeof data.handle === "string" ) { return { authenticated: true, did: data.did, handle: data.handle }; } return { authenticated: false }; } catch (error) { if (isProgrammingError(error)) throw error; logger.error( "getSession: network error — treating as unauthenticated", { operation: "GET /api/auth/session", error: error instanceof Error ? error.message : String(error), } ); return { authenticated: false }; } } /** * Extended session type that includes the user's role permissions. * Used on pages that need to conditionally render moderation UI. */ export type WebSessionWithPermissions = | { authenticated: false; permissions: Set } | { authenticated: true; did: string; handle: string; permissions: Set }; /** * Like getSession(), but also fetches the user's role permissions from * GET /api/admin/members/me. Use on pages that need to render mod buttons. * * Returns empty permissions on network errors or when user has no membership. * Never throws — always returns a usable session. */ export async function getSessionWithPermissions( appviewUrl: string, cookieHeader?: string ): Promise { const session = await getSession(appviewUrl, cookieHeader); if (!session.authenticated) { return { authenticated: false, permissions: new Set() }; } let permissions = new Set(); try { const res = await fetch(`${appviewUrl}/api/admin/members/me`, { headers: { Cookie: cookieHeader! }, }); if (res.ok) { const data = (await res.json()) as Record; if (Array.isArray(data.permissions)) { permissions = new Set(data.permissions as string[]); } } else if (res.status !== 404) { // 404 = no membership = expected for guests, no log needed logger.error( "getSessionWithPermissions: unexpected status from members/me", { operation: "GET /api/admin/members/me", did: session.did, status: res.status, } ); } } catch (error) { if (isProgrammingError(error)) throw error; logger.error( "getSessionWithPermissions: network error — continuing with empty permissions", { operation: "GET /api/admin/members/me", did: session.did, error: error instanceof Error ? error.message : String(error), } ); } return { ...session, permissions }; } /** Returns true if the session grants permission to lock/unlock topics. */ export function canLockTopics(auth: WebSessionWithPermissions): boolean { return ( auth.authenticated && (auth.permissions.has("space.atbb.permission.lockTopics") || auth.permissions.has("*")) ); } /** Returns true if the session grants permission to hide/unhide posts. */ export function canModeratePosts(auth: WebSessionWithPermissions): boolean { return ( auth.authenticated && (auth.permissions.has("space.atbb.permission.moderatePosts") || auth.permissions.has("*")) ); } /** Returns true if the session grants permission to ban/unban users. */ export function canBanUsers(auth: WebSessionWithPermissions): boolean { return ( auth.authenticated && (auth.permissions.has("space.atbb.permission.banUsers") || auth.permissions.has("*")) ); } /** * Permission strings that constitute "any admin access". * Used to gate the /admin landing page. * * Note: `manageRoles` is intentionally absent. It is always exercised * through the /admin/members page, which requires `manageMembers` to access. * A user with only `manageRoles` would see the landing page but no nav cards, * which is confusing UX. `manageMembers` (already listed) covers that case. */ const ADMIN_PERMISSIONS = [ "space.atbb.permission.manageMembers", "space.atbb.permission.manageCategories", "space.atbb.permission.moderatePosts", "space.atbb.permission.banUsers", "space.atbb.permission.lockTopics", "space.atbb.permission.manageThemes", ] as const; /** * Returns true if the session grants at least one of the admin panel permissions * listed in ADMIN_PERMISSIONS, or the wildcard "*". Used to gate the /admin landing page. */ export function hasAnyAdminPermission( auth: WebSessionWithPermissions ): boolean { if (!auth.authenticated) return false; if (auth.permissions.has("*")) return true; return ADMIN_PERMISSIONS.some((p) => auth.permissions.has(p)); } /** Returns true if the session grants permission to manage forum members. */ export function canManageMembers(auth: WebSessionWithPermissions): boolean { return ( auth.authenticated && (auth.permissions.has("space.atbb.permission.manageMembers") || auth.permissions.has("*")) ); } /** Returns true if the session grants permission to manage forum categories and boards. */ export function canManageCategories(auth: WebSessionWithPermissions): boolean { return ( auth.authenticated && (auth.permissions.has("space.atbb.permission.manageCategories") || auth.permissions.has("*")) ); } /** Returns true if the session grants any moderation permission (view mod log). */ export function canViewModLog(auth: WebSessionWithPermissions): boolean { return ( auth.authenticated && (auth.permissions.has("space.atbb.permission.moderatePosts") || auth.permissions.has("space.atbb.permission.banUsers") || auth.permissions.has("space.atbb.permission.lockTopics") || auth.permissions.has("*")) ); } /** Returns true if the session grants permission to assign member roles. */ export function canManageRoles(auth: WebSessionWithPermissions): boolean { return ( auth.authenticated && (auth.permissions.has("space.atbb.permission.manageRoles") || auth.permissions.has("*")) ); } /** Returns true if the session grants permission to manage forum themes. */ export function canManageThemes(auth: WebSessionWithPermissions): boolean { return ( auth.authenticated && (auth.permissions.has("space.atbb.permission.manageThemes") || auth.permissions.has("*")) ); }