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