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
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

Role-Based Permission System Design (ATB-17)#

Date: 2026-02-14 Author: Claude (with user approval) Linear Issue: ATB-17 Status: Approved, ready for implementation


Overview#

This design document describes the implementation of role-based access control (RBAC) for the atBB forum. The permission system restricts admin and moderation actions to users with appropriate roles, enforced at the AppView layer.

Goals#

  • Enable role-based access control with 4 default roles (Owner, Admin, Moderator, Member)
  • Enforce permissions on write operations (create topics/posts, manage roles, moderate content)
  • Support priority hierarchy (prevents lower-authority users from acting on higher-authority users)
  • Maintain the MVP trust model (AppView holds Forum DID keys for role assignment)
  • Keep implementation simple (no caching, direct database queries)

Non-Goals (Post-MVP)#

  • Permission caching (Redis/in-memory)
  • Custom roles (beyond the 4 defaults)
  • Per-category permissions
  • Audit logging for permission checks
  • AT Protocol privilege delegation (will replace MVP trust model later)

Design Decisions#

Based on design discussions, the following architectural choices were made:

  1. Performance vs Simplicity: Simple database queries (no caching) - acceptable for MVP with <1000 users
  2. Default Role Assignment: Configurable via DEFAULT_MEMBER_ROLE env var - supports both open and approval-required forums
  3. Role Deletion Behavior: Treat missing roles as Guest (fail closed) - prevents unintended privilege escalation
  4. Priority Hierarchy: Full enforcement across all operations - not just role assignment, but all moderation actions
  5. Wildcard Permission: True wildcard ("*") for Owner role - grants all current and future permissions
  6. Self-Action Bypass: Users can edit/delete their own content without moderatePosts permission

Architecture Overview#

High-Level Structure#

The permission system adds three new layers to the existing AppView architecture:

1. Data Layer (Database + Indexer)

  • New roles table stores role definitions (name, permissions array, priority)
  • Role indexer watches space.atbb.forum.role records from firehose
  • Existing memberships table already has roleUri column (points to user's assigned role)

2. Permission Layer (Middleware + Helpers)

  • requirePermission(permission) - Middleware that enforces specific permission tokens
  • requireRole(minRole) - Middleware that enforces role hierarchy
  • Helper functions (checkPermission, checkMinRole, getUserRole) perform database lookups
  • Integrates with existing requireAuth() - authentication happens first, then permission check

3. Admin Layer (API Endpoints)

  • POST /api/admin/members/:did/role - Assign role to a member (uses ForumAgent to update membership on user's PDS)
  • GET /api/admin/roles - List available roles
  • GET /api/admin/members - List members with their assigned roles
  • Protected by requirePermission("space.atbb.permission.manageRoles")

Request Flow Example#

User creates topic:
  → POST /api/topics
  → requireAuth(ctx) validates session → sets c.get("user")
  → requirePermission(ctx, "createTopics") checks permission:
      1. Query memberships table for user's roleUri
      2. Query roles table for role definition
      3. Check if "space.atbb.permission.createTopics" in permissions array
      4. Check for wildcard "*" (Owner role)
      5. Return 403 if no permission, continue if authorized
  → Handler writes post to user's PDS
  → Return 201

Key Technical Choices#

  • No caching - Simple database queries on every request (acceptable for MVP <1000 users)
  • Factory functions - Middleware follows existing requireAuth() pattern, not class-based
  • Fail closed - Missing role = Guest (no permissions)
  • Priority enforcement - All mod actions check priority hierarchy, not just role assignment
  • Self-action bypass - Users can edit/delete own content without moderatePosts permission
  • Configurable defaults - DEFAULT_MEMBER_ROLE env var controls auto-assignment on membership creation

Database Schema#

Roles Table#

The roles table stores role definitions owned by the Forum DID. Each role defines a set of permissions and a priority level for hierarchy enforcement.

Schema Definition (Drizzle ORM):

// packages/db/src/schema.ts
export const roles = pgTable(
  "roles",
  {
    id: bigserial("id", { mode: "bigint" }).primaryKey(),
    did: text("did").notNull(),                     // Forum DID (owner)
    rkey: text("rkey").notNull(),                   // Record key (TID)
    cid: text("cid").notNull(),                     // Content hash
    name: text("name").notNull(),                   // "Admin", "Moderator", etc.
    description: text("description"),               // Optional description
    permissions: text("permissions").array().notNull().default(sql`'{}'::text[]`), // Permission tokens
    priority: integer("priority").notNull(),        // Lower = higher authority
    createdAt: timestamp("created_at", { withTimezone: true }).notNull(),
    indexedAt: timestamp("indexed_at", { withTimezone: true }).notNull(),
  },
  (table) => [
    uniqueIndex("roles_did_rkey_idx").on(table.did, table.rkey),
    index("roles_did_idx").on(table.did),
    index("roles_did_name_idx").on(table.did, table.name), // Lookup by name
  ]
);

Migration SQL:

-- packages/db/drizzle/migrations/XXXX_add_roles_table.sql
CREATE TABLE roles (
  id BIGSERIAL PRIMARY KEY,
  did TEXT NOT NULL,
  rkey TEXT NOT NULL,
  cid TEXT NOT NULL,
  name TEXT NOT NULL,
  description TEXT,
  permissions TEXT[] NOT NULL DEFAULT '{}',
  priority INTEGER NOT NULL,
  created_at TIMESTAMP WITH TIME ZONE NOT NULL,
  indexed_at TIMESTAMP WITH TIME ZONE NOT NULL,
  UNIQUE(did, rkey)
);

CREATE INDEX idx_roles_did ON roles(did);
CREATE INDEX idx_roles_did_name ON roles(did, name);

Design Rationale#

Why permissions as TEXT[] (PostgreSQL array)?

  • Allows efficient containment checks: 'createTopics' = ANY(permissions)
  • Easier to query than JSONB for simple string lists
  • Matches the lexicon's permissions field structure

Why did + name index?

  • Roles are often looked up by name ("Admin", "Moderator") rather than rkey
  • Combined index supports queries like WHERE did = ? AND name = ?
  • Used when assigning roles by name in admin UI

No foreign key from memberships.roleUri to roles?

  • Correct - roleUri is an AT-URI string, not a foreign key
  • Roles can be deleted; we handle missing roles with "fail closed" (treat as Guest)
  • Foreign key would prevent role deletion when members exist

Role Indexer#

Indexer Integration#

The role indexer follows the established CollectionConfig pattern used for posts, forums, categories, etc. It watches for space.atbb.forum.role records from the firehose and indexes them into the roles table.

Collection Configuration:

// apps/appview/src/lib/indexer.ts
import { SpaceAtbbForumRole as Role } from "@atbb/lexicon";

private roleConfig: CollectionConfig<Role.Record> = {
  name: "Role",
  table: roles,
  deleteStrategy: "hard",  // DELETE FROM roles (not soft delete)
  toInsertValues: async (event, record) => ({
    did: event.did,
    rkey: event.commit.rkey,
    cid: event.commit.cid,
    name: record.name,
    description: record.description || null,
    permissions: record.permissions,  // Array of permission tokens
    priority: record.priority,
    createdAt: new Date(record.createdAt),
    indexedAt: new Date(),
  }),
  toUpdateValues: async (event, record) => ({
    cid: event.commit.cid,
    name: record.name,
    description: record.description || null,
    permissions: record.permissions,  // Can update permissions array
    priority: record.priority,         // Can change hierarchy
    indexedAt: new Date(),
  }),
};

Handler Methods:

async handleRoleCreate(event: CommitCreateEvent<Role.Record>) {
  await this.genericCreate(this.roleConfig, event);
}

async handleRoleUpdate(event: CommitUpdateEvent<Role.Record>) {
  await this.genericUpdate(this.roleConfig, event);
}

async handleRoleDelete(event: CommitDeleteEvent) {
  await this.genericDelete(this.roleConfig, event);
}

Handler Registration:

// In createHandlerRegistry()
.register({
  collection: "space.atbb.forum.role",
  onCreate: this.createWrappedHandler("handleRoleCreate"),
  onUpdate: this.createWrappedHandler("handleRoleUpdate"),
  onDelete: this.createWrappedHandler("handleRoleDelete"),
})

Design Rationale#

Why hard delete instead of soft delete?

  • Role definitions are metadata, not user content
  • When a role is deleted, it's intentional (admin removing a deprecated role)
  • Soft delete would leave orphaned records cluttering the database
  • Members with deleted roles are handled by "fail closed" logic in permission checks

What happens when permissions array is updated?

  • Existing members keep their roleUri unchanged
  • Next permission check reads the updated permissions array
  • Changes take effect immediately (no cache to invalidate)
  • Example: Admin updates "Moderator" role to remove banUsers permission - all moderators immediately lose ban ability

Permission Middleware & Helpers#

Middleware Factory Functions#

Two middleware functions enforce permissions, following the same pattern as requireAuth():

File: apps/appview/src/middleware/permissions.ts

import type { Context, Next } from "hono";
import type { AppContext } from "../lib/app-context.js";
import type { Variables } from "../types.js";
import { memberships, roles } from "@atbb/db";
import { eq, and } from "drizzle-orm";

/**
 * Require specific permission middleware.
 *
 * Validates that the authenticated user has the required permission token.
 * Returns 401 if not authenticated, 403 if authenticated but lacks permission.
 */
export function requirePermission(
  ctx: AppContext,
  permission: string
) {
  return async (c: Context<{ Variables: Variables }>, next: Next) => {
    const user = c.get("user");

    if (!user) {
      return c.json({ error: "Authentication required" }, 401);
    }

    const hasPermission = await checkPermission(ctx, user.did, permission);

    if (!hasPermission) {
      return c.json({
        error: "Insufficient permissions",
        required: permission
      }, 403);
    }

    await next();
  };
}

/**
 * Require minimum role middleware.
 *
 * Validates that the authenticated user has a role with sufficient priority.
 * Returns 401 if not authenticated, 403 if authenticated but insufficient role.
 */
export function requireRole(
  ctx: AppContext,
  minRole: "owner" | "admin" | "moderator" | "member"
) {
  return async (c: Context<{ Variables: Variables }>, next: Next) => {
    const user = c.get("user");

    if (!user) {
      return c.json({ error: "Authentication required" }, 401);
    }

    const hasRole = await checkMinRole(ctx, user.did, minRole);

    if (!hasRole) {
      return c.json({
        error: "Insufficient role",
        required: minRole
      }, 403);
    }

    await next();
  };
}

Helper Functions#

Permission lookup with wildcard support:

/**
 * Check if a user has a specific permission.
 *
 * @returns true if user has permission, false otherwise
 *
 * Returns false (fail closed) if:
 * - User has no membership
 * - User has no role assigned (roleUri is null)
 * - Role not found in database (deleted or invalid)
 */
async function checkPermission(
  ctx: AppContext,
  did: string,
  permission: string
): Promise<boolean> {
  try {
    // 1. Get user's membership (includes roleUri)
    const [membership] = await ctx.db
      .select()
      .from(memberships)
      .where(eq(memberships.did, did))
      .limit(1);

    if (!membership || !membership.roleUri) {
      return false; // No membership or no role assigned = Guest (no permissions)
    }

    // 2. Extract rkey from roleUri (e.g., "at://did:plc:xyz/space.atbb.forum.role/abc123" -> "abc123")
    const roleRkey = membership.roleUri.split("/").pop();
    if (!roleRkey) {
      return false;
    }

    // 3. Fetch role definition from roles table
    const [role] = await ctx.db
      .select()
      .from(roles)
      .where(
        and(
          eq(roles.did, ctx.config.forumDid),
          eq(roles.rkey, roleRkey)
        )
      )
      .limit(1);

    if (!role) {
      return false; // Role not found = treat as Guest (fail closed)
    }

    // 4. Check for wildcard (Owner role)
    if (role.permissions.includes("*")) {
      return true;
    }

    // 5. Check if specific permission is in role's permissions array
    return role.permissions.includes(permission);
  } catch (error) {
    console.error("Failed to check permissions", {
      operation: "checkPermission",
      did,
      permission,
      error: error instanceof Error ? error.message : String(error),
    });

    // Fail closed: deny access on database errors
    return false;
  }
}

Priority-based role comparison:

/**
 * Check if a user has a minimum role level.
 *
 * @param minRole - Minimum required role name
 * @returns true if user's role priority <= required priority (higher authority)
 */
async function checkMinRole(
  ctx: AppContext,
  did: string,
  minRole: string
): Promise<boolean> {
  const rolePriorities = {
    owner: 0,
    admin: 10,
    moderator: 20,
    member: 30,
  };

  const userRole = await getUserRole(ctx, did);

  if (!userRole) {
    return false; // No role = Guest (fails all role checks)
  }

  const userPriority = userRole.priority;
  const requiredPriority = rolePriorities[minRole];

  // Lower priority value = higher authority
  // Owner (0) passes owner/admin/moderator/member checks
  // Admin (10) passes admin/moderator/member checks but not owner
  return userPriority <= requiredPriority;
}

Shared helper for role lookup:

/**
 * Get a user's role definition.
 *
 * @returns Role object or null if user has no role
 */
async function getUserRole(
  ctx: AppContext,
  did: string
): Promise<{ id: bigint; name: string; priority: number; permissions: string[] } | null> {
  const [membership] = await ctx.db
    .select()
    .from(memberships)
    .where(eq(memberships.did, did))
    .limit(1);

  if (!membership || !membership.roleUri) {
    return null;
  }

  const roleRkey = membership.roleUri.split("/").pop();
  if (!roleRkey) {
    return null;
  }

  const [role] = await ctx.db
    .select({
      id: roles.id,
      name: roles.name,
      priority: roles.priority,
      permissions: roles.permissions,
    })
    .from(roles)
    .where(
      and(
        eq(roles.did, ctx.config.forumDid),
        eq(roles.rkey, roleRkey)
      )
    )
    .limit(1);

  return role || null;
}

Priority hierarchy check for moderation actions:

/**
 * Check if an actor can perform moderation actions on a target user.
 *
 * Priority hierarchy enforcement:
 * - Users can always act on themselves (self-action bypass)
 * - Can only act on users with strictly lower authority (higher priority value)
 * - Cannot act on users with equal or higher authority
 *
 * @returns true if actor can act on target, false otherwise
 */
export async function canActOnUser(
  ctx: AppContext,
  actorDid: string,
  targetDid: string
): Promise<boolean> {
  // Users can always act on themselves
  if (actorDid === targetDid) {
    return true;
  }

  const actorRole = await getUserRole(ctx, actorDid);
  const targetRole = await getUserRole(ctx, targetDid);

  // If actor has no role, they can't act on anyone else
  if (!actorRole) {
    return false;
  }

  // If target has no role (Guest), anyone with a role can act on them
  if (!targetRole) {
    return true;
  }

  // Lower priority = higher authority
  // Can only act on users with strictly higher priority value (lower authority)
  return actorRole.priority < targetRole.priority;
}

Design Rationale#

Self-action bypass implementation:

  • canActOnUser() returns true when actorDid === targetDid
  • Moderation endpoints check this first before checking permissions
  • Example: User can delete their own post even without moderatePosts permission

Query efficiency:

  • Each permission check = 2 queries (membership + role)
  • With proper indexes (memberships.did, roles.did + roles.rkey), completes in ~5-10ms
  • No N+1 problem since we always limit to 1 result

Error handling:

  • Missing membership → false (not an error, just Guest status)
  • Missing role → false (fail closed)
  • Database errors bubble up to global error handler

Admin Endpoints#

API Routes#

Three new endpoints under the /api/admin/* namespace for role management:

File: apps/appview/src/routes/admin.ts

import { Hono } from "hono";
import type { AppContext } from "../lib/app-context.js";
import type { Variables } from "../types.js";
import { requirePermission } from "../middleware/permissions.js";
import { memberships, roles, users } from "@atbb/db";
import { eq, and, sql, asc } from "drizzle-orm";
import { getUserRole, canActOnUser } from "../middleware/permissions.js";

export function createAdminRoutes(ctx: AppContext) {
  const app = new Hono<{ Variables: Variables }>();

  /**
   * POST /api/admin/members/:did/role
   *
   * Assign a role to a forum member.
   *
   * Requirements:
   * - User must have manageRoles permission
   * - Cannot assign roles with equal or higher authority than your own
   * - Target user must be a member of the forum
   * - Role must exist
   */
  app.post(
    "/members/:did/role",
    requirePermission(ctx, "space.atbb.permission.manageRoles"),
    async (c) => {
      const targetDid = c.req.param("did");
      const user = c.get("user")!;

      // Parse and validate request body
      let body: any;
      try {
        body = await c.req.json();
      } catch {
        return c.json({ error: "Invalid JSON in request body" }, 400);
      }

      const { roleUri } = body;

      if (typeof roleUri !== "string") {
        return c.json({ error: "roleUri is required and must be a string" }, 400);
      }

      // Extract role rkey from roleUri
      const roleRkey = roleUri.split("/").pop();
      if (!roleRkey) {
        return c.json({ error: "Invalid roleUri format" }, 400);
      }

      // Validate role exists
      const [role] = await ctx.db
        .select()
        .from(roles)
        .where(
          and(
            eq(roles.did, ctx.config.forumDid),
            eq(roles.rkey, roleRkey)
          )
        )
        .limit(1);

      if (!role) {
        return c.json({ error: "Role not found" }, 404);
      }

      // Priority check: Can't assign role with equal or higher authority
      const assignerRole = await getUserRole(ctx, user.did);
      if (!assignerRole) {
        return c.json({ error: "You do not have a role assigned" }, 403);
      }

      if (role.priority <= assignerRole.priority) {
        return c.json({
          error: "Cannot assign role with equal or higher authority",
          yourPriority: assignerRole.priority,
          targetRolePriority: role.priority
        }, 403);
      }

      // Get target user's membership
      const [membership] = await ctx.db
        .select()
        .from(memberships)
        .where(eq(memberships.did, targetDid))
        .limit(1);

      if (!membership) {
        return c.json({ error: "User is not a member of this forum" }, 404);
      }

      try {
        // Update membership record on user's PDS using ForumAgent
        await ctx.forumAgent.agent.com.atproto.repo.putRecord({
          repo: targetDid,
          collection: "space.atbb.membership",
          rkey: membership.rkey,
          record: {
            $type: "space.atbb.membership",
            forum: { forum: { uri: membership.forumUri, cid: "" } }, // CID will be updated by PDS
            role: { role: { uri: roleUri, cid: role.cid } },
            joinedAt: membership.joinedAt?.toISOString(),
            createdAt: membership.createdAt.toISOString(),
          },
        });

        return c.json({
          success: true,
          roleAssigned: role.name,
          targetDid,
        });
      } catch (error) {
        console.error("Failed to assign role", {
          operation: "POST /api/admin/members/:did/role",
          targetDid,
          roleUri,
          error: error instanceof Error ? error.message : String(error),
        });

        return c.json({
          error: "Failed to assign role. Please try again later.",
        }, 500);
      }
    }
  );

  /**
   * GET /api/admin/roles
   *
   * List all available roles for the forum.
   * Sorted by priority (Owner first, Member last).
   */
  app.get(
    "/roles",
    requirePermission(ctx, "space.atbb.permission.manageRoles"),
    async (c) => {
      try {
        const rolesList = await ctx.db
          .select({
            id: roles.id,
            name: roles.name,
            description: roles.description,
            permissions: roles.permissions,
            priority: roles.priority,
          })
          .from(roles)
          .where(eq(roles.did, ctx.config.forumDid))
          .orderBy(asc(roles.priority)); // Owner first, Member last

        return c.json({
          roles: rolesList.map(role => ({
            id: role.id.toString(),
            name: role.name,
            description: role.description,
            permissions: role.permissions,
            priority: role.priority,
          })),
        });
      } catch (error) {
        console.error("Failed to list roles", {
          operation: "GET /api/admin/roles",
          error: error instanceof Error ? error.message : String(error),
        });

        return c.json({
          error: "Failed to retrieve roles. Please try again later.",
        }, 500);
      }
    }
  );

  /**
   * GET /api/admin/members
   *
   * List all forum members with their assigned roles.
   * Includes DID, handle, role name, and join date.
   * Paginated to 100 members (pagination not implemented in MVP).
   */
  app.get(
    "/members",
    requirePermission(ctx, "space.atbb.permission.manageMembers"),
    async (c) => {
      try {
        const membersList = await ctx.db
          .select({
            did: memberships.did,
            handle: users.handle,
            role: roles.name,
            roleUri: memberships.roleUri,
            joinedAt: memberships.joinedAt,
          })
          .from(memberships)
          .leftJoin(users, eq(memberships.did, users.did))
          .leftJoin(
            roles,
            sql`${memberships.roleUri} LIKE 'at://' || ${roles.did} || '/space.atbb.forum.role/' || ${roles.rkey}`
          )
          .where(eq(memberships.forumUri, `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`))
          .orderBy(asc(roles.priority), asc(users.handle))
          .limit(100); // Pagination not implemented in MVP

        return c.json({
          members: membersList.map(member => ({
            did: member.did,
            handle: member.handle || member.did,
            role: member.role || "Guest",
            roleUri: member.roleUri,
            joinedAt: member.joinedAt?.toISOString(),
          })),
        });
      } catch (error) {
        console.error("Failed to list members", {
          operation: "GET /api/admin/members",
          error: error instanceof Error ? error.message : String(error),
        });

        return c.json({
          error: "Failed to retrieve members. Please try again later.",
        }, 500);
      }
    }
  );

  return app;
}

Design Rationale#

Why use ForumAgent for role assignment?

  • Membership records live on the user's PDS, not the Forum's PDS
  • Only the Forum DID has authority to assign roles
  • ForumAgent authenticates as Forum DID and writes to user's PDS on behalf of admin
  • This is the MVP trust model (AppView holds Forum DID keys)

Priority enforcement in role assignment:

  • Admins (priority 10) can assign Moderator (20) and Member (30) roles
  • Admins cannot assign Admin (10) or Owner (0) roles (prevents privilege escalation)
  • This prevents a compromised admin account from promoting themselves or others

Member listing JOIN logic:

  • LEFT JOIN on roles allows showing members with no role (Guests)
  • Custom SQL JOIN condition matches roleUri to role's AT-URI pattern
  • Ordered by priority (Admins first) then handle (alphabetical)

Default Role Seeding#

Seed Script#

A startup script creates the 4 default roles on the Forum DID's PDS if they don't already exist.

File: apps/appview/src/lib/seed-roles.ts

import type { AppContext } from "./app-context.js";
import { roles } from "@atbb/db";
import { eq } from "drizzle-orm";

interface DefaultRole {
  name: string;
  description: string;
  permissions: string[];
  priority: number;
}

const DEFAULT_ROLES: DefaultRole[] = [
  {
    name: "Owner",
    description: "Forum owner with full control",
    permissions: ["*"], // Wildcard grants all permissions
    priority: 0,
  },
  {
    name: "Admin",
    description: "Can manage forum structure and users",
    permissions: [
      "space.atbb.permission.manageCategories",
      "space.atbb.permission.manageRoles",
      "space.atbb.permission.manageMembers",
      "space.atbb.permission.moderatePosts",
      "space.atbb.permission.banUsers",
      "space.atbb.permission.pinTopics",
      "space.atbb.permission.lockTopics",
      "space.atbb.permission.createTopics",
      "space.atbb.permission.createPosts",
    ],
    priority: 10,
  },
  {
    name: "Moderator",
    description: "Can moderate content and users",
    permissions: [
      "space.atbb.permission.moderatePosts",
      "space.atbb.permission.banUsers",
      "space.atbb.permission.pinTopics",
      "space.atbb.permission.lockTopics",
      "space.atbb.permission.createTopics",
      "space.atbb.permission.createPosts",
    ],
    priority: 20,
  },
  {
    name: "Member",
    description: "Regular forum member",
    permissions: [
      "space.atbb.permission.createTopics",
      "space.atbb.permission.createPosts",
    ],
    priority: 30,
  },
];

/**
 * Seed default roles to Forum DID's PDS.
 *
 * Idempotent: Checks for existing roles by name before creating.
 * Safe to run on every startup.
 *
 * @returns Summary of created and skipped roles
 */
export async function seedDefaultRoles(ctx: AppContext): Promise<{ created: number; skipped: number }> {
  let created = 0;
  let skipped = 0;

  for (const defaultRole of DEFAULT_ROLES) {
    try {
      // Check if role already exists by name
      const [existingRole] = await ctx.db
        .select()
        .from(roles)
        .where(eq(roles.name, defaultRole.name))
        .limit(1);

      if (existingRole) {
        console.log(`Role "${defaultRole.name}" already exists, skipping`, {
          operation: "seedDefaultRoles",
          roleName: defaultRole.name,
        });
        skipped++;
        continue;
      }

      // Create role record on Forum DID's PDS
      const result = await ctx.forumAgent.agent.com.atproto.repo.createRecord({
        repo: ctx.config.forumDid,
        collection: "space.atbb.forum.role",
        record: {
          $type: "space.atbb.forum.role",
          name: defaultRole.name,
          description: defaultRole.description,
          permissions: defaultRole.permissions,
          priority: defaultRole.priority,
          createdAt: new Date().toISOString(),
        },
      });

      console.log(`Created default role "${defaultRole.name}"`, {
        operation: "seedDefaultRoles",
        roleName: defaultRole.name,
        uri: result.uri,
        cid: result.cid,
      });

      created++;
    } catch (error) {
      console.error(`Failed to seed role "${defaultRole.name}"`, {
        operation: "seedDefaultRoles",
        roleName: defaultRole.name,
        error: error instanceof Error ? error.message : String(error),
      });
      // Continue seeding other roles even if one fails
    }
  }

  return { created, skipped };
}

Startup Integration#

// apps/appview/src/index.ts (in startup sequence, after ForumAgent is initialized)

// Seed default roles if enabled
if (process.env.SEED_DEFAULT_ROLES !== "false") {
  console.log("Seeding default roles...");
  const result = await seedDefaultRoles(ctx);
  console.log("Default roles seeded", {
    created: result.created,
    skipped: result.skipped,
  });
} else {
  console.log("Role seeding disabled via SEED_DEFAULT_ROLES=false");
}

Environment Variables#

# .env
SEED_DEFAULT_ROLES=true  # Set to "false" to disable auto-seeding
DEFAULT_MEMBER_ROLE=Member  # Role name to auto-assign to new memberships (or empty for manual assignment)

Design Rationale#

Idempotent seeding:

  • Checks database for existing roles by name before creating
  • Safe to run on every startup (skips existing roles)
  • Allows manual role deletion without preventing re-seeding

Why write to PDS instead of database directly?

  • Roles are AT Protocol records, not just database entries
  • Writing to PDS ensures proper AT-URI, CID, and firehose propagation
  • Indexer picks up the records and populates the database
  • Maintains consistency with the "PDS is source of truth" architecture

Failure handling:

  • If one role fails to seed, continue with remaining roles
  • Logs errors but doesn't crash startup
  • Partial seeding is acceptable (admin can manually create missing roles)

Integration with membership auto-creation:

  • When DEFAULT_MEMBER_ROLE is set, the membership creation code (ATB-15) will look up this role by name
  • If found, set roleUri on new membership record
  • If not found or empty, leave roleUri as null (Guest status)

Integration with Existing Endpoints#

Updating Write Endpoints#

Replace requireAuth() with requirePermission() on existing write endpoints:

File: apps/appview/src/routes/topics.ts

// BEFORE:
app.post("/", requireAuth(ctx), async (c) => {
  // ...
});

// AFTER:
app.post("/", requirePermission(ctx, "space.atbb.permission.createTopics"), async (c) => {
  // Handler code stays the same - permission check happens before reaching here
});

File: apps/appview/src/routes/posts.ts

// BEFORE:
app.post("/", requireAuth(ctx), async (c) => {
  // ...
});

// AFTER:
app.post("/", requirePermission(ctx, "space.atbb.permission.createPosts"), async (c) => {
  // Handler code stays the same
});

Future Moderation Endpoints (Not in ATB-17 Scope)#

When implementing moderation endpoints (ATB-19+), they'll use both permission AND priority checks:

// Example future endpoint structure (NOT implemented in ATB-17)
app.post(
  "/posts/:id/delete",
  requirePermission(ctx, "space.atbb.permission.moderatePosts"),
  async (c) => {
    const postId = c.req.param("id");
    const user = c.get("user")!;

    // Get post author
    const post = await getPostById(ctx, postId);
    if (!post) {
      return c.json({ error: "Post not found" }, 404);
    }

    // Self-action bypass: users can always delete their own posts
    if (post.did === user.did) {
      // Delete logic...
      return c.json({ success: true });
    }

    // Priority check: can't moderate users with equal/higher authority
    const canAct = await canActOnUser(ctx, user.did, post.did);
    if (!canAct) {
      return c.json({
        error: "Cannot moderate posts from users with equal or higher authority"
      }, 403);
    }

    // Delete logic...
  }
);

Design Rationale#

Why requirePermission() instead of requireAuth()?

  • requirePermission() internally calls the same authentication logic
  • It checks session validity first (returns 401 if not authenticated)
  • Then checks permission (returns 403 if authenticated but no permission)
  • Single middleware replaces the auth check with a permission check

Backward compatibility:

  • Before ATB-17: All authenticated users can post (no role required)
  • After ATB-17: Only users with createTopics/createPosts permission can post
  • Breaking change - requires roles to be seeded and members to be assigned roles
  • Migration path: Seed roles → auto-assign "Member" role to existing memberships → deploy permission checks

Self-action bypass implementation:

  • Not needed for create endpoints (users are always acting on their own behalf)
  • Only needed for moderation endpoints (delete/edit other users' content)
  • Future moderation endpoints check post.did === user.did before priority check

Error messages:

  • 401 "Authentication required" - not logged in
  • 403 "Insufficient permissions" - logged in but no permission token
  • 403 "Insufficient role" - logged in but wrong role level
  • 403 "Cannot moderate..." - logged in with permission but blocked by priority hierarchy

Error Handling#

Error Categories#

The permission system introduces new error scenarios that need clear, actionable messages:

Authentication Errors (401):

  • No session cookie present
  • Session expired or invalid
  • Returned BEFORE permission checks

Permission Errors (403):

  • User authenticated but lacks required permission token
  • User authenticated but role priority insufficient
  • User authenticated but trying to act on higher-authority user

Not Found Errors (404):

  • Target role doesn't exist (role assignment)
  • Target user not a member (role assignment)

Server Errors (500):

  • Database query failures
  • PDS write failures (ForumAgent)
  • Unexpected errors in helper functions

Logging Standards#

Structured logging with context:

console.error("Failed to check permissions", {
  operation: "checkPermission",
  did: did,
  permission: permission,
  error: error instanceof Error ? error.message : String(error),
  errorId: "PERMISSION_CHECK_FAILED", // Optional: for tracking specific error types
});

What to log:

  • ✅ Database errors in helper functions
  • ✅ PDS write failures in admin endpoints
  • ✅ Missing roles during permission checks (info level, not error)
  • ❌ Don't log successful permission checks (too noisy)
  • ❌ Don't log 403 responses (expected behavior, not errors)

Helper Function Error Handling#

Fail closed on errors:

async function checkPermission(
  ctx: AppContext,
  did: string,
  permission: string
): Promise<boolean> {
  try {
    // Database queries...
  } catch (error) {
    console.error("Failed to check permissions", {
      operation: "checkPermission",
      did,
      permission,
      error: error instanceof Error ? error.message : String(error),
    });

    // Fail closed: deny access on database errors
    return false;
  }
}

Why fail closed?

  • Database outage should not grant universal access
  • Temporary denial of service is better than temporary privilege escalation
  • Admin can investigate logs to diagnose issues

Error Message Guidelines#

Client-facing error messages:

  • Generic for server errors: "Please try again later"
  • Specific for client errors: "Role not found", "Insufficient permissions"
  • Include helpful context: required: permission shows what they need
  • Never expose stack traces or internal details in production

Distinguishing 401 vs 403:

  • 401 = "You need to log in" → redirect to login
  • 403 = "You're logged in but can't do this" → show "contact admin" message
  • Clear distinction helps users understand if they need to re-auth or escalate

Testing Strategy#

Unit Tests#

File: apps/appview/src/middleware/__tests__/permissions.test.ts

describe("checkPermission", () => {
  it("returns true when user has required permission");
  it("returns true for Owner role with wildcard permission");
  it("returns false when user has no role assigned");
  it("returns false when user's role is deleted (fail closed)");
  it("returns false when user has no membership");
});

describe("checkMinRole", () => {
  it("returns true when user has exact role match");
  it("returns true when user has higher authority role");
  it("returns false when user has lower authority role");
});

describe("canActOnUser", () => {
  it("returns true when actor is acting on themselves");
  it("returns true when actor has higher authority");
  it("returns false when actor has equal authority");
  it("returns false when actor has lower authority");
});

Integration Tests#

File: apps/appview/src/middleware/__tests__/permissions.integration.test.ts

describe("requirePermission middleware", () => {
  it("allows authenticated user with permission");
  it("returns 401 for unauthenticated user");
  it("returns 403 for authenticated user without permission");
});

describe("requireRole middleware", () => {
  it("allows user with sufficient role level");
  it("returns 403 for user with insufficient role level");
});

File: apps/appview/src/routes/__tests__/admin.test.ts

describe("POST /api/admin/members/:did/role", () => {
  it("assigns role successfully when admin has authority");
  it("returns 403 when assigning role with equal authority");
  it("returns 403 when assigning role with higher authority");
  it("returns 404 when role not found");
  it("returns 404 when target user not a member");
  it("returns 403 when user lacks manageRoles permission");
});

describe("GET /api/admin/roles", () => {
  it("lists all roles sorted by priority");
  it("returns 403 for non-admin users");
});

describe("GET /api/admin/members", () => {
  it("lists members with assigned roles");
  it("shows Guest for members with no role");
});

File: apps/appview/src/lib/__tests__/indexer.test.ts

describe("Role indexer", () => {
  it("indexes role create event");
  it("updates role on update event");
  it("deletes role on delete event");
});

Manual Testing Checklist#

Initial Setup:

  • Start fresh database, seed roles on startup
  • Verify 4 default roles created (Owner, Admin, Moderator, Member)
  • Check role priorities and permissions in database

Role Assignment:

  • Assign "Member" role to test user via POST /api/admin/members/:did/role
  • Verify user can create topics and posts
  • Assign "Moderator" role to test user
  • Verify user can create but cannot manage roles (403)

Priority Hierarchy:

  • As Admin, try to assign Admin role to another user (should fail with 403)
  • As Admin, assign Moderator role to user (should succeed)
  • As Admin, try to assign Owner role (should fail with 403)

Permission Enforcement:

  • Create user with no role (Guest)
  • Try to create topic as Guest (should fail with 403)
  • Assign Member role, retry (should succeed)

Wildcard Permission:

  • Assign Owner role to test user
  • Verify user can access all admin endpoints
  • Verify checkPermission() returns true for any permission

Test Coverage Goals#

  • Helper functions: 100% coverage (critical permission logic)
  • Middleware: 100% coverage (all error paths)
  • Admin endpoints: All success + error cases
  • Role indexer: Create, update, delete events
  • Integration: End-to-end permission enforcement on real routes

Implementation Checklist#

This checklist matches the Linear issue's acceptance criteria:

1. Database Schema: Roles Table#

  • Create migration packages/db/drizzle/migrations/XXX_add_roles_table.sql
  • Define roles table in packages/db/src/schema.ts
  • Export roles for use in indexer and queries
  • Add to cleanup in test-context.ts

2. Role Indexer#

  • Add roleConfig: CollectionConfig<Role.Record> to Indexer class
  • Implement handleRoleCreate(), handleRoleUpdate(), handleRoleDelete()
  • Register handlers in createHandlerRegistry()
  • Add import for SpaceAtbbForumRole

3. Permission Middleware#

  • Create apps/appview/src/middleware/permissions.ts
  • Implement requirePermission() middleware
  • Implement requireRole() middleware
  • Implement checkPermission() helper
  • Implement checkMinRole() helper
  • Implement getUserRole() helper
  • Implement canActOnUser() helper

4. Role Assignment Endpoint#

  • Create apps/appview/src/routes/admin.ts
  • Implement POST /api/admin/members/:did/role
  • Implement GET /api/admin/roles
  • Implement GET /api/admin/members
  • Register admin routes in apps/appview/src/index.ts

5. Default Roles Seeding#

  • Create apps/appview/src/lib/seed-roles.ts
  • Implement seedDefaultRoles() function
  • Define DEFAULT_ROLES array (Owner, Admin, Moderator, Member)
  • Integrate seeding in AppView startup
  • Add SEED_DEFAULT_ROLES env var to .env.example
  • Add DEFAULT_MEMBER_ROLE env var to .env.example

6. Integration with Existing Routes#

  • Update POST /api/topics to use requirePermission()
  • Update POST /api/posts to use requirePermission()

7. Testing Requirements#

  • Unit tests for checkPermission(), checkMinRole(), canActOnUser()
  • Integration tests for requirePermission and requireRole middleware
  • Integration tests for admin endpoints
  • Unit tests for role indexer
  • Manual testing checklist completion

8. Documentation#

  • Update docs/atproto-forum-plan.md with ATB-17 completion
  • Mark Linear issue ATB-17 as Done
  • Update CLAUDE.md if patterns change

Success Criteria#

The permission system is successful when:

  1. Only users with createTopics permission can create topics
  2. Admins can assign roles to members via API
  3. Priority hierarchy prevents privilege escalation (Admin can't assign Admin role)
  4. Permission checks complete in <10ms (no performance impact)
  5. Default roles are seeded automatically on fresh install
  6. All tests passing (100% coverage on permission logic)
  7. Error messages are clear and actionable

Future Enhancements (Post-MVP)#

  • Permission caching - Redis or in-memory cache for role lookups
  • Custom roles - Allow admins to create new roles beyond the 4 defaults
  • Per-category permissions - Different permissions for different categories
  • Audit logging - Track all permission checks and role changes
  • AT Protocol delegation - Replace ForumAgent key holding with proper delegation
  • Role templates - Pre-defined role configurations for common forum types
  • Bulk role assignment - Assign roles to multiple users at once
  • Role inheritance - Roles that inherit permissions from other roles