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 } from "@atbb/db";
5import { eq, and } 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 for wildcard (Owner role)
57 if (role.permissions.includes("*")) {
58 return true;
59 }
60
61 // 5. Check if specific permission is in role's permissions array
62 return role.permissions.includes(permission);
63 } catch (error) {
64 // Re-throw programming errors (typos, undefined variables, etc.)
65 // These should crash during development, not silently deny access
66 if (error instanceof TypeError || error instanceof ReferenceError || error instanceof SyntaxError) {
67 throw error;
68 }
69
70 // For expected errors (database connection, network, etc.):
71 // Log and fail closed (deny access)
72 ctx.logger.error("Failed to check permissions", {
73 operation: "checkPermission",
74 did,
75 permission,
76 error: error instanceof Error ? error.message : String(error),
77 });
78
79 return false;
80 }
81}
82
83/**
84 * Get a user's role definition.
85 *
86 * @returns Role object or null if user has no role (fail closed on error)
87 */
88async function getUserRole(
89 ctx: AppContext,
90 did: string
91): Promise<{ id: bigint; name: string; priority: number; permissions: string[] } | null> {
92 try {
93 const [membership] = await ctx.db
94 .select()
95 .from(memberships)
96 .where(eq(memberships.did, did))
97 .limit(1);
98
99 if (!membership || !membership.roleUri) {
100 return null;
101 }
102
103 const roleRkey = membership.roleUri.split("/").pop();
104 if (!roleRkey) {
105 return null;
106 }
107
108 const [role] = await ctx.db
109 .select({
110 id: roles.id,
111 name: roles.name,
112 priority: roles.priority,
113 permissions: roles.permissions,
114 })
115 .from(roles)
116 .where(
117 and(
118 eq(roles.did, ctx.config.forumDid),
119 eq(roles.rkey, roleRkey)
120 )
121 )
122 .limit(1);
123
124 return role || null;
125 } catch (error) {
126 // Fail closed: return null on any error to deny access
127 ctx.logger.error("Failed to query user role", {
128 did,
129 error: error instanceof Error ? error.message : String(error),
130 });
131 return null;
132 }
133}
134
135/**
136 * Check if a user has a minimum role level.
137 *
138 * @param minRole - Minimum required role name
139 * @returns true if user's role priority <= required priority (higher authority)
140 */
141async function checkMinRole(
142 ctx: AppContext,
143 did: string,
144 minRole: string
145): Promise<boolean> {
146 const rolePriorities: Record<string, number> = {
147 owner: 0,
148 admin: 10,
149 moderator: 20,
150 member: 30,
151 };
152
153 const userRole = await getUserRole(ctx, did);
154
155 if (!userRole) {
156 return false; // No role = Guest (fails all role checks)
157 }
158
159 const userPriority = userRole.priority;
160 const requiredPriority = rolePriorities[minRole];
161
162 // Lower priority value = higher authority
163 return userPriority <= requiredPriority;
164}
165
166/**
167 * Check if an actor can perform moderation actions on a target user.
168 *
169 * Priority hierarchy enforcement:
170 * - Users can always act on themselves (self-action bypass)
171 * - Can only act on users with strictly lower authority (higher priority value)
172 * - Cannot act on users with equal or higher authority
173 *
174 * @returns true if actor can act on target, false otherwise
175 */
176export async function canActOnUser(
177 ctx: AppContext,
178 actorDid: string,
179 targetDid: string
180): Promise<boolean> {
181 // Users can always act on themselves
182 if (actorDid === targetDid) {
183 return true;
184 }
185
186 const actorRole = await getUserRole(ctx, actorDid);
187 const targetRole = await getUserRole(ctx, targetDid);
188
189 // If actor has no role, they can't act on anyone else
190 if (!actorRole) {
191 return false;
192 }
193
194 // If target has no role (Guest), anyone with a role can act on them
195 if (!targetRole) {
196 return true;
197 }
198
199 // Lower priority = higher authority
200 // Can only act on users with strictly higher priority value (lower authority)
201 return actorRole.priority < targetRole.priority;
202}
203
204/**
205 * Require specific permission middleware.
206 *
207 * Validates that the authenticated user has the required permission token.
208 * Returns 401 if not authenticated, 403 if authenticated but lacks permission.
209 */
210export function requirePermission(
211 ctx: AppContext,
212 permission: string
213) {
214 return async (c: Context<{ Variables: Variables }>, next: Next) => {
215 const user = c.get("user");
216
217 if (!user) {
218 return c.json({ error: "Authentication required" }, 401);
219 }
220
221 const hasPermission = await checkPermission(ctx, user.did, permission);
222
223 if (!hasPermission) {
224 return c.json({
225 error: "Insufficient permissions",
226 required: permission
227 }, 403);
228 }
229
230 await next();
231 };
232}
233
234/**
235 * Require minimum role middleware.
236 *
237 * Validates that the authenticated user has a role with sufficient priority.
238 * Returns 401 if not authenticated, 403 if authenticated but insufficient role.
239 */
240export function requireRole(
241 ctx: AppContext,
242 minRole: "owner" | "admin" | "moderator" | "member"
243) {
244 return async (c: Context<{ Variables: Variables }>, next: Next) => {
245 const user = c.get("user");
246
247 if (!user) {
248 return c.json({ error: "Authentication required" }, 401);
249 }
250
251 const hasRole = await checkMinRole(ctx, user.did, minRole);
252
253 if (!hasRole) {
254 return c.json({
255 error: "Insufficient role",
256 required: minRole
257 }, 403);
258 }
259
260 await next();
261 };
262}
263
264// Export helpers for testing
265export { checkPermission, getUserRole, checkMinRole };