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
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}