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 type { AppContext } from "../lib/app-context.js";
2import type { Context, Next } from "hono";
3import type { Variables } from "../types.js";
4import { memberships, roles, rolePermissions } from "@atbb/db";
5import { eq, and, or } from "drizzle-orm";
6
7/**
8 * Check if a user has a specific permission.
9 *
10 * @returns true if user has permission, false otherwise
11 *
12 * Returns false (fail closed) if:
13 * - User has no membership
14 * - User has no role assigned (roleUri is null)
15 * - Role not found in database (deleted or invalid)
16 */
17async function checkPermission(
18 ctx: AppContext,
19 did: string,
20 permission: string
21): Promise<boolean> {
22 try {
23 // 1. Get user's membership (includes roleUri)
24 const [membership] = await ctx.db
25 .select()
26 .from(memberships)
27 .where(eq(memberships.did, did))
28 .limit(1);
29
30 if (!membership || !membership.roleUri) {
31 return false; // No membership or no role assigned = Guest (no permissions)
32 }
33
34 // 2. Extract rkey from roleUri
35 const roleRkey = membership.roleUri.split("/").pop();
36 if (!roleRkey) {
37 return false;
38 }
39
40 // 3. Fetch role definition from roles table
41 const [role] = await ctx.db
42 .select()
43 .from(roles)
44 .where(
45 and(
46 eq(roles.did, ctx.config.forumDid),
47 eq(roles.rkey, roleRkey)
48 )
49 )
50 .limit(1);
51
52 if (!role) {
53 return false; // Role not found = treat as Guest (fail closed)
54 }
55
56 // 4. Check if user has the permission (wildcard or specific)
57 const [match] = await ctx.db
58 .select()
59 .from(rolePermissions)
60 .where(
61 and(
62 eq(rolePermissions.roleId, role.id),
63 or(
64 eq(rolePermissions.permission, permission),
65 eq(rolePermissions.permission, "*")
66 )
67 )
68 )
69 .limit(1);
70
71 return !!match;
72 } catch (error) {
73 // Re-throw programming errors (typos, undefined variables, etc.)
74 // These should crash during development, not silently deny access
75 if (error instanceof TypeError || error instanceof ReferenceError || error instanceof SyntaxError) {
76 throw error;
77 }
78
79 // For expected errors (database connection, network, etc.):
80 // Log and fail closed (deny access)
81 ctx.logger.error("Failed to check permissions", {
82 operation: "checkPermission",
83 did,
84 permission,
85 error: error instanceof Error ? error.message : String(error),
86 });
87
88 return false;
89 }
90}
91
92/**
93 * Get a user's role definition.
94 *
95 * @returns Role object or null if user has no role (fail closed on error)
96 */
97async function getUserRole(
98 ctx: AppContext,
99 did: string
100): Promise<{ id: bigint; name: string; priority: number } | null> {
101 try {
102 const [membership] = await ctx.db
103 .select()
104 .from(memberships)
105 .where(eq(memberships.did, did))
106 .limit(1);
107
108 if (!membership || !membership.roleUri) {
109 return null;
110 }
111
112 const roleRkey = membership.roleUri.split("/").pop();
113 if (!roleRkey) {
114 return null;
115 }
116
117 const [role] = await ctx.db
118 .select({
119 id: roles.id,
120 name: roles.name,
121 priority: roles.priority,
122 })
123 .from(roles)
124 .where(
125 and(
126 eq(roles.did, ctx.config.forumDid),
127 eq(roles.rkey, roleRkey)
128 )
129 )
130 .limit(1);
131
132 return role || null;
133 } catch (error) {
134 // Fail closed: return null on any error to deny access
135 ctx.logger.error("Failed to query user role", {
136 did,
137 error: error instanceof Error ? error.message : String(error),
138 });
139 return null;
140 }
141}
142
143/**
144 * Check if a user has a minimum role level.
145 *
146 * @param minRole - Minimum required role name
147 * @returns true if user's role priority <= required priority (higher authority)
148 */
149async function checkMinRole(
150 ctx: AppContext,
151 did: string,
152 minRole: string
153): Promise<boolean> {
154 const rolePriorities: Record<string, number> = {
155 owner: 0,
156 admin: 10,
157 moderator: 20,
158 member: 30,
159 };
160
161 const userRole = await getUserRole(ctx, did);
162
163 if (!userRole) {
164 return false; // No role = Guest (fails all role checks)
165 }
166
167 const userPriority = userRole.priority;
168 const requiredPriority = rolePriorities[minRole];
169
170 // Lower priority value = higher authority
171 return userPriority <= requiredPriority;
172}
173
174/**
175 * Check if an actor can perform moderation actions on a target user.
176 *
177 * Priority hierarchy enforcement:
178 * - Users can always act on themselves (self-action bypass)
179 * - Can only act on users with strictly lower authority (higher priority value)
180 * - Cannot act on users with equal or higher authority
181 *
182 * @returns true if actor can act on target, false otherwise
183 */
184export async function canActOnUser(
185 ctx: AppContext,
186 actorDid: string,
187 targetDid: string
188): Promise<boolean> {
189 // Users can always act on themselves
190 if (actorDid === targetDid) {
191 return true;
192 }
193
194 const actorRole = await getUserRole(ctx, actorDid);
195 const targetRole = await getUserRole(ctx, targetDid);
196
197 // If actor has no role, they can't act on anyone else
198 if (!actorRole) {
199 return false;
200 }
201
202 // If target has no role (Guest), anyone with a role can act on them
203 if (!targetRole) {
204 return true;
205 }
206
207 // Lower priority = higher authority
208 // Can only act on users with strictly higher priority value (lower authority)
209 return actorRole.priority < targetRole.priority;
210}
211
212/**
213 * Require specific permission middleware.
214 *
215 * Validates that the authenticated user has the required permission token.
216 * Returns 401 if not authenticated, 403 if authenticated but lacks permission.
217 */
218export function requirePermission(
219 ctx: AppContext,
220 permission: string
221) {
222 return async (c: Context<{ Variables: Variables }>, next: Next) => {
223 const user = c.get("user");
224
225 if (!user) {
226 return c.json({ error: "Authentication required" }, 401);
227 }
228
229 const hasPermission = await checkPermission(ctx, user.did, permission);
230
231 if (!hasPermission) {
232 return c.json({
233 error: "Insufficient permissions",
234 required: permission
235 }, 403);
236 }
237
238 await next();
239 };
240}
241
242/**
243 * Require at least one of a list of permissions (OR logic).
244 *
245 * Iterates the permissions list in order, calling checkPermission for each.
246 * Short-circuits and calls next() on the first match.
247 * Returns 401 if not authenticated, 403 if none of the permissions match.
248 */
249export function requireAnyPermission(
250 ctx: AppContext,
251 permissions: string[]
252) {
253 return async (c: Context<{ Variables: Variables }>, next: Next) => {
254 const user = c.get("user");
255
256 if (!user) {
257 return c.json({ error: "Authentication required" }, 401);
258 }
259
260 for (const permission of permissions) {
261 const hasPermission = await checkPermission(ctx, user.did, permission);
262 if (hasPermission) {
263 await next();
264 return;
265 }
266 }
267
268 return c.json({ error: "Insufficient permissions" }, 403);
269 };
270}
271
272/**
273 * Require minimum role middleware.
274 *
275 * Validates that the authenticated user has a role with sufficient priority.
276 * Returns 401 if not authenticated, 403 if authenticated but insufficient role.
277 */
278export function requireRole(
279 ctx: AppContext,
280 minRole: "owner" | "admin" | "moderator" | "member"
281) {
282 return async (c: Context<{ Variables: Variables }>, next: Next) => {
283 const user = c.get("user");
284
285 if (!user) {
286 return c.json({ error: "Authentication required" }, 401);
287 }
288
289 const hasRole = await checkMinRole(ctx, user.did, minRole);
290
291 if (!hasRole) {
292 return c.json({
293 error: "Insufficient role",
294 required: minRole
295 }, 403);
296 }
297
298 await next();
299 };
300}
301
302// Export helpers for testing
303export { checkPermission, getUserRole, checkMinRole };