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

Configure Feed

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

Role-Based Permission System Implementation Plan (ATB-17)#

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Implement role-based access control with 4 default roles (Owner, Admin, Moderator, Member) enforced via middleware on all write operations.

Architecture: Three-layer system - (1) Database + Indexer for role storage, (2) Permission middleware + helpers for enforcement, (3) Admin endpoints for role management. Follows existing patterns (factory functions, AppContext DI, TDD).

Tech Stack: TypeScript, Hono, Drizzle ORM, PostgreSQL, Vitest, AT Protocol SDK

Design Document: See docs/plans/2026-02-14-permissions-design.md for full design rationale.


Task 1: Database Schema - Roles Table#

Files:

  • Create: packages/db/drizzle/migrations/0004_add_roles_table.sql
  • Modify: packages/db/src/schema.ts:178-210 (add roles table export)

Step 1: Create migration SQL

Create migration file with roles table definition:

# File: packages/db/drizzle/migrations/0004_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);

Step 2: Add roles table to schema

Modify packages/db/src/schema.ts after firehoseCursor table (line 188):

// ── roles ───────────────────────────────────────────────
// Role definitions, owned by Forum DID.
export const roles = pgTable(
  "roles",
  {
    id: bigserial("id", { mode: "bigint" }).primaryKey(),
    did: text("did").notNull(),
    rkey: text("rkey").notNull(),
    cid: text("cid").notNull(),
    name: text("name").notNull(),
    description: text("description"),
    permissions: text("permissions").array().notNull().default(sql`'{}'::text[]`),
    priority: integer("priority").notNull(),
    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),
  ]
);

Also add sql import at top:

import {
  pgTable,
  bigserial,
  text,
  timestamp,
  integer,
  boolean,
  bigint,
  uniqueIndex,
  index,
  sql,  // ADD THIS
} from "drizzle-orm/pg-core";

Step 3: Run migration

cd packages/db
pnpm db:migrate

Expected: Migration runs successfully, roles table created in database.

Step 4: Verify schema in database

psql $DATABASE_URL -c "\d roles"

Expected: Shows table structure with all columns and indexes.

Step 5: Commit

git add packages/db/drizzle/migrations/0004_add_roles_table.sql packages/db/src/schema.ts
git commit -m "feat(db): add roles table for permission system

- Create roles table with permissions array and priority field
- Add indexes on did and did+name for efficient lookups
- Migration 0004_add_roles_table.sql"

Task 2: Update Test Cleanup#

Files:

  • Modify: apps/appview/src/lib/__tests__/test-context.ts:45-60 (add roles cleanup)

Step 1: Add roles to cleanup

In test-context.ts, add roles cleanup after modActions cleanup (around line 55):

// Clean up test data
await db.delete(modActions).where(eq(modActions.did, config.forumDid));
await db.delete(roles).where(eq(roles.did, config.forumDid));  // ADD THIS
await db.delete(posts).where(eq(posts.forumUri, forumUri));

Also add roles import at top:

import {
  forums,
  categories,
  boards,
  posts,
  users,
  memberships,
  modActions,
  roles,  // ADD THIS
  firehoseCursor,
} from "@atbb/db";

Step 2: Commit

git add apps/appview/src/lib/__tests__/test-context.ts
git commit -m "test: add roles table to test cleanup"

Task 3: Role Indexer#

Files:

  • Modify: apps/appview/src/lib/indexer.ts:1-30 (add import)
  • Modify: apps/appview/src/lib/indexer.ts:70-150 (add roleConfig)
  • Modify: apps/appview/src/lib/indexer.ts:450-500 (add handler methods)
  • Modify: apps/appview/src/lib/indexer.ts:600-650 (register handlers)

Step 1: Add Role import

At top of indexer.ts, add to lexicon imports (around line 23):

import {
  SpaceAtbbPost as Post,
  SpaceAtbbForumForum as Forum,
  SpaceAtbbForumCategory as Category,
  SpaceAtbbForumBoard as Board,
  SpaceAtbbMembership as Membership,
  SpaceAtbbModAction as ModAction,
  SpaceAtbbForumRole as Role,  // ADD THIS
} from "@atbb/lexicon";

Step 2: Add roleConfig after boardConfig

After boardConfig definition (around line 250), add:

private roleConfig: CollectionConfig<Role.Record> = {
  name: "Role",
  table: roles,
  deleteStrategy: "hard",
  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,
    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,
    priority: record.priority,
    indexedAt: new Date(),
  }),
};

Step 3: Add handler methods

After other handler methods (around line 450), add:

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

Step 4: Register handlers

In createHandlerRegistry(), after board registration (around line 650), add:

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

Step 5: Commit

git add apps/appview/src/lib/indexer.ts
git commit -m "feat(indexer): add role indexer for space.atbb.forum.role

- Add roleConfig with hard delete strategy
- Implement handleRoleCreate, handleRoleUpdate, handleRoleDelete
- Register handlers in createHandlerRegistry"

Task 4: Permission Helper Functions - Unit Tests (Part 1)#

Files:

  • Create: apps/appview/src/middleware/__tests__/permissions.test.ts

Step 1: Write test file skeleton

Create test file with imports and test data setup:

import { describe, it, expect, beforeEach } from "vitest";
import { createTestContext } from "../../lib/__tests__/test-context.js";
import type { AppContext } from "../../lib/app-context.js";
import { roles, memberships, users } from "@atbb/db";

describe("Permission Helper Functions", () => {
  let ctx: AppContext;

  beforeEach(async () => {
    ctx = await createTestContext();
  });

  describe("checkPermission", () => {
    it("returns true when user has required permission", async () => {
      // Will implement after creating the function
      expect(true).toBe(true);
    });

    it("returns true for Owner role with wildcard permission", async () => {
      expect(true).toBe(true);
    });

    it("returns false when user has no role assigned", async () => {
      expect(true).toBe(true);
    });

    it("returns false when user's role is deleted (fail closed)", async () => {
      expect(true).toBe(true);
    });

    it("returns false when user has no membership", async () => {
      expect(true).toBe(true);
    });
  });

  describe("checkMinRole", () => {
    it("returns true when user has exact role match", async () => {
      expect(true).toBe(true);
    });

    it("returns true when user has higher authority role", async () => {
      expect(true).toBe(true);
    });

    it("returns false when user has lower authority role", async () => {
      expect(true).toBe(true);
    });
  });

  describe("canActOnUser", () => {
    it("returns true when actor is acting on themselves", async () => {
      expect(true).toBe(true);
    });

    it("returns true when actor has higher authority", async () => {
      expect(true).toBe(true);
    });

    it("returns false when actor has equal authority", async () => {
      expect(true).toBe(true);
    });

    it("returns false when actor has lower authority", async () => {
      expect(true).toBe(true);
    });
  });
});

Step 2: Run tests to verify they pass (placeholder tests)

cd apps/appview
pnpm exec vitest run src/middleware/__tests__/permissions.test.ts

Expected: All tests pass (placeholders).

Step 3: Commit

git add apps/appview/src/middleware/__tests__/permissions.test.ts
git commit -m "test: add permission helpers test skeleton"

Task 5: Permission Helper Functions - Implementation (Part 1)#

Files:

  • Create: apps/appview/src/middleware/permissions.ts

Step 1: Create permissions.ts with imports and types

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";

/**
 * 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
    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;
  }
}

/**
 * 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;
}

/**
 * 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: Record<string, number> = {
    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
  return userPriority <= requiredPriority;
}

/**
 * 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;
}

// Export helpers for testing
export { checkPermission, getUserRole, checkMinRole };

Step 2: Commit

git add apps/appview/src/middleware/permissions.ts
git commit -m "feat(middleware): add permission helper functions

- checkPermission: lookup permission with wildcard support
- getUserRole: shared role lookup helper
- checkMinRole: priority-based role comparison
- canActOnUser: priority hierarchy enforcement
- All helpers fail closed on missing data"

Task 6: Permission Helper Functions - Unit Tests (Part 2)#

Files:

  • Modify: apps/appview/src/middleware/__tests__/permissions.test.ts

Step 1: Implement test for "returns true when user has required permission"

Replace the placeholder test:

it("returns true when user has required permission", async () => {
  // Import the helper (add at top of file)
  const { checkPermission } = await import("../permissions.js");

  // Create a test role with createTopics permission
  const [role] = await ctx.db
    .insert(roles)
    .values({
      did: ctx.config.forumDid,
      rkey: "test-role-123",
      cid: "test-cid",
      name: "Member",
      description: "Test member role",
      permissions: ["space.atbb.permission.createTopics"],
      priority: 30,
      createdAt: new Date(),
      indexedAt: new Date(),
    })
    .returning();

  // Create a test user
  await ctx.db.insert(users).values({
    did: "did:plc:testuser",
    handle: "testuser.bsky.social",
    indexedAt: new Date(),
  });

  // Create membership with roleUri pointing to test role
  await ctx.db.insert(memberships).values({
    did: "did:plc:testuser",
    rkey: "membership-123",
    cid: "test-cid",
    forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
    roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/test-role-123`,
    createdAt: new Date(),
    indexedAt: new Date(),
  });

  const result = await checkPermission(
    ctx,
    "did:plc:testuser",
    "space.atbb.permission.createTopics"
  );

  expect(result).toBe(true);
});

Step 2: Run test to verify it passes

pnpm exec vitest run src/middleware/__tests__/permissions.test.ts -t "returns true when user has required permission"

Expected: Test passes.

Step 3: Implement remaining checkPermission tests

Replace other placeholder tests in checkPermission describe block:

it("returns true for Owner role with wildcard permission", async () => {
  const { checkPermission } = await import("../permissions.js");

  // Create Owner role with wildcard
  await ctx.db.insert(roles).values({
    did: ctx.config.forumDid,
    rkey: "owner-role",
    cid: "test-cid",
    name: "Owner",
    description: "Forum owner",
    permissions: ["*"],  // Wildcard
    priority: 0,
    createdAt: new Date(),
    indexedAt: new Date(),
  });

  await ctx.db.insert(users).values({
    did: "did:plc:owner",
    handle: "owner.bsky.social",
    indexedAt: new Date(),
  });

  await ctx.db.insert(memberships).values({
    did: "did:plc:owner",
    rkey: "membership-123",
    cid: "test-cid",
    forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
    roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/owner-role`,
    createdAt: new Date(),
    indexedAt: new Date(),
  });

  // Should return true for ANY permission
  const result = await checkPermission(
    ctx,
    "did:plc:owner",
    "space.atbb.permission.someRandomPermission"
  );

  expect(result).toBe(true);
});

it("returns false when user has no role assigned", async () => {
  const { checkPermission } = await import("../permissions.js");

  await ctx.db.insert(users).values({
    did: "did:plc:norole",
    handle: "norole.bsky.social",
    indexedAt: new Date(),
  });

  // Create membership with roleUri = null
  await ctx.db.insert(memberships).values({
    did: "did:plc:norole",
    rkey: "membership-123",
    cid: "test-cid",
    forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
    roleUri: null,  // No role assigned
    createdAt: new Date(),
    indexedAt: new Date(),
  });

  const result = await checkPermission(
    ctx,
    "did:plc:norole",
    "space.atbb.permission.createTopics"
  );

  expect(result).toBe(false);
});

it("returns false when user's role is deleted (fail closed)", async () => {
  const { checkPermission } = await import("../permissions.js");

  await ctx.db.insert(users).values({
    did: "did:plc:deletedrole",
    handle: "deletedrole.bsky.social",
    indexedAt: new Date(),
  });

  // Create membership with roleUri pointing to non-existent role
  await ctx.db.insert(memberships).values({
    did: "did:plc:deletedrole",
    rkey: "membership-123",
    cid: "test-cid",
    forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
    roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/deleted-role`,
    createdAt: new Date(),
    indexedAt: new Date(),
  });

  const result = await checkPermission(
    ctx,
    "did:plc:deletedrole",
    "space.atbb.permission.createTopics"
  );

  expect(result).toBe(false);  // Fail closed
});

it("returns false when user has no membership", async () => {
  const { checkPermission } = await import("../permissions.js");

  await ctx.db.insert(users).values({
    did: "did:plc:nomembership",
    handle: "nomembership.bsky.social",
    indexedAt: new Date(),
  });

  // No membership record created

  const result = await checkPermission(
    ctx,
    "did:plc:nomembership",
    "space.atbb.permission.createTopics"
  );

  expect(result).toBe(false);
});

Step 4: Implement checkMinRole tests

Replace checkMinRole placeholders:

it("returns true when user has exact role match", async () => {
  const { checkMinRole } = await import("../permissions.js");

  await ctx.db.insert(roles).values({
    did: ctx.config.forumDid,
    rkey: "admin-role",
    cid: "test-cid",
    name: "Admin",
    permissions: [],
    priority: 10,
    createdAt: new Date(),
    indexedAt: new Date(),
  });

  await ctx.db.insert(users).values({
    did: "did:plc:admin",
    handle: "admin.bsky.social",
    indexedAt: new Date(),
  });

  await ctx.db.insert(memberships).values({
    did: "did:plc:admin",
    rkey: "membership-123",
    cid: "test-cid",
    forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
    roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/admin-role`,
    createdAt: new Date(),
    indexedAt: new Date(),
  });

  const result = await checkMinRole(ctx, "did:plc:admin", "admin");

  expect(result).toBe(true);
});

it("returns true when user has higher authority role", async () => {
  const { checkMinRole } = await import("../permissions.js");

  // Owner (priority 0) should pass admin check (priority 10)
  await ctx.db.insert(roles).values({
    did: ctx.config.forumDid,
    rkey: "owner-role",
    cid: "test-cid",
    name: "Owner",
    permissions: ["*"],
    priority: 0,
    createdAt: new Date(),
    indexedAt: new Date(),
  });

  await ctx.db.insert(users).values({
    did: "did:plc:owner",
    handle: "owner.bsky.social",
    indexedAt: new Date(),
  });

  await ctx.db.insert(memberships).values({
    did: "did:plc:owner",
    rkey: "membership-123",
    cid: "test-cid",
    forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
    roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/owner-role`,
    createdAt: new Date(),
    indexedAt: new Date(),
  });

  const result = await checkMinRole(ctx, "did:plc:owner", "admin");

  expect(result).toBe(true);  // Owner > Admin
});

it("returns false when user has lower authority role", async () => {
  const { checkMinRole } = await import("../permissions.js");

  // Moderator (priority 20) should fail admin check (priority 10)
  await ctx.db.insert(roles).values({
    did: ctx.config.forumDid,
    rkey: "mod-role",
    cid: "test-cid",
    name: "Moderator",
    permissions: [],
    priority: 20,
    createdAt: new Date(),
    indexedAt: new Date(),
  });

  await ctx.db.insert(users).values({
    did: "did:plc:mod",
    handle: "mod.bsky.social",
    indexedAt: new Date(),
  });

  await ctx.db.insert(memberships).values({
    did: "did:plc:mod",
    rkey: "membership-123",
    cid: "test-cid",
    forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
    roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/mod-role`,
    createdAt: new Date(),
    indexedAt: new Date(),
  });

  const result = await checkMinRole(ctx, "did:plc:mod", "admin");

  expect(result).toBe(false);  // Moderator < Admin
});

Step 5: Implement canActOnUser tests

Replace canActOnUser placeholders:

it("returns true when actor is acting on themselves", async () => {
  const { canActOnUser } = await import("../permissions.js");

  const result = await canActOnUser(
    ctx,
    "did:plc:testuser",
    "did:plc:testuser"  // Same DID
  );

  expect(result).toBe(true);  // Self-action bypass
});

it("returns true when actor has higher authority", async () => {
  const { canActOnUser } = await import("../permissions.js");

  // Create Admin role (priority 10)
  await ctx.db.insert(roles).values({
    did: ctx.config.forumDid,
    rkey: "admin-role",
    cid: "test-cid",
    name: "Admin",
    permissions: [],
    priority: 10,
    createdAt: new Date(),
    indexedAt: new Date(),
  });

  // Create Moderator role (priority 20)
  await ctx.db.insert(roles).values({
    did: ctx.config.forumDid,
    rkey: "mod-role",
    cid: "test-cid",
    name: "Moderator",
    permissions: [],
    priority: 20,
    createdAt: new Date(),
    indexedAt: new Date(),
  });

  // Admin user
  await ctx.db.insert(users).values({
    did: "did:plc:admin",
    handle: "admin.bsky.social",
    indexedAt: new Date(),
  });

  await ctx.db.insert(memberships).values({
    did: "did:plc:admin",
    rkey: "membership-123",
    cid: "test-cid",
    forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
    roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/admin-role`,
    createdAt: new Date(),
    indexedAt: new Date(),
  });

  // Moderator user
  await ctx.db.insert(users).values({
    did: "did:plc:mod",
    handle: "mod.bsky.social",
    indexedAt: new Date(),
  });

  await ctx.db.insert(memberships).values({
    did: "did:plc:mod",
    rkey: "membership-456",
    cid: "test-cid",
    forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
    roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/mod-role`,
    createdAt: new Date(),
    indexedAt: new Date(),
  });

  const result = await canActOnUser(ctx, "did:plc:admin", "did:plc:mod");

  expect(result).toBe(true);  // Admin (10) can act on Moderator (20)
});

it("returns false when actor has equal authority", async () => {
  const { canActOnUser } = await import("../permissions.js");

  // Create Admin role
  await ctx.db.insert(roles).values({
    did: ctx.config.forumDid,
    rkey: "admin-role",
    cid: "test-cid",
    name: "Admin",
    permissions: [],
    priority: 10,
    createdAt: new Date(),
    indexedAt: new Date(),
  });

  // Admin user 1
  await ctx.db.insert(users).values({
    did: "did:plc:admin1",
    handle: "admin1.bsky.social",
    indexedAt: new Date(),
  });

  await ctx.db.insert(memberships).values({
    did: "did:plc:admin1",
    rkey: "membership-123",
    cid: "test-cid",
    forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
    roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/admin-role`,
    createdAt: new Date(),
    indexedAt: new Date(),
  });

  // Admin user 2
  await ctx.db.insert(users).values({
    did: "did:plc:admin2",
    handle: "admin2.bsky.social",
    indexedAt: new Date(),
  });

  await ctx.db.insert(memberships).values({
    did: "did:plc:admin2",
    rkey: "membership-456",
    cid: "test-cid",
    forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
    roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/admin-role`,
    createdAt: new Date(),
    indexedAt: new Date(),
  });

  const result = await canActOnUser(ctx, "did:plc:admin1", "did:plc:admin2");

  expect(result).toBe(false);  // Admin (10) cannot act on Admin (10)
});

it("returns false when actor has lower authority", async () => {
  const { canActOnUser } = await import("../permissions.js");

  // Create Admin role (priority 10)
  await ctx.db.insert(roles).values({
    did: ctx.config.forumDid,
    rkey: "admin-role",
    cid: "test-cid",
    name: "Admin",
    permissions: [],
    priority: 10,
    createdAt: new Date(),
    indexedAt: new Date(),
  });

  // Create Moderator role (priority 20)
  await ctx.db.insert(roles).values({
    did: ctx.config.forumDid,
    rkey: "mod-role",
    cid: "test-cid",
    name: "Moderator",
    permissions: [],
    priority: 20,
    createdAt: new Date(),
    indexedAt: new Date(),
  });

  // Admin user
  await ctx.db.insert(users).values({
    did: "did:plc:admin",
    handle: "admin.bsky.social",
    indexedAt: new Date(),
  });

  await ctx.db.insert(memberships).values({
    did: "did:plc:admin",
    rkey: "membership-123",
    cid: "test-cid",
    forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
    roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/admin-role`,
    createdAt: new Date(),
    indexedAt: new Date(),
  });

  // Moderator user
  await ctx.db.insert(users).values({
    did: "did:plc:mod",
    handle: "mod.bsky.social",
    indexedAt: new Date(),
  });

  await ctx.db.insert(memberships).values({
    did: "did:plc:mod",
    rkey: "membership-456",
    cid: "test-cid",
    forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
    roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/mod-role`,
    createdAt: new Date(),
    indexedAt: new Date(),
  });

  const result = await canActOnUser(ctx, "did:plc:mod", "did:plc:admin");

  expect(result).toBe(false);  // Moderator (20) cannot act on Admin (10)
});

Step 6: Run all unit tests

pnpm exec vitest run src/middleware/__tests__/permissions.test.ts

Expected: All 13 tests pass.

Step 7: Commit

git add apps/appview/src/middleware/__tests__/permissions.test.ts
git commit -m "test: add unit tests for permission helper functions

- 13 tests covering checkPermission, checkMinRole, canActOnUser
- Test wildcard permissions, fail-closed behavior, priority hierarchy
- All tests passing"

Task 7: Permission Middleware Functions#

Files:

  • Modify: apps/appview/src/middleware/permissions.ts (add middleware functions)

Step 1: Add middleware factory functions

Add to the top of permissions.ts after the helper functions:

/**
 * 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();
  };
}

Step 2: Commit

git add apps/appview/src/middleware/permissions.ts
git commit -m "feat(middleware): add requirePermission and requireRole middleware

- requirePermission: enforce specific permission tokens
- requireRole: enforce minimum role level
- Both return 401 for unauthenticated, 403 for insufficient permissions"

Task 8: Admin Routes - Test Skeleton#

Files:

  • Create: apps/appview/src/routes/__tests__/admin.test.ts

Step 1: Create admin routes test skeleton

import { describe, it, expect, beforeEach } from "vitest";
import { createTestContext } from "../../lib/__tests__/test-context.js";
import type { AppContext } from "../../lib/app-context.js";
import { createApp } from "../../lib/create-app.js";
import { createAdminRoutes } from "../admin.js";
import type { Hono } from "hono";
import { roles, memberships, users } from "@atbb/db";

describe("Admin Routes", () => {
  let ctx: AppContext;
  let app: Hono;

  beforeEach(async () => {
    ctx = await createTestContext();
    const mainApp = createApp(ctx);
    mainApp.route("/api/admin", createAdminRoutes(ctx));
    app = mainApp;
  });

  describe("POST /api/admin/members/:did/role", () => {
    it("assigns role successfully when admin has authority", async () => {
      expect(true).toBe(true);
    });

    it("returns 403 when assigning role with equal authority", async () => {
      expect(true).toBe(true);
    });

    it("returns 403 when assigning role with higher authority", async () => {
      expect(true).toBe(true);
    });

    it("returns 404 when role not found", async () => {
      expect(true).toBe(true);
    });

    it("returns 404 when target user not a member", async () => {
      expect(true).toBe(true);
    });

    it("returns 403 when user lacks manageRoles permission", async () => {
      expect(true).toBe(true);
    });
  });

  describe("GET /api/admin/roles", () => {
    it("lists all roles sorted by priority", async () => {
      expect(true).toBe(true);
    });

    it("returns 403 for non-admin users", async () => {
      expect(true).toBe(true);
    });
  });

  describe("GET /api/admin/members", () => {
    it("lists members with assigned roles", async () => {
      expect(true).toBe(true);
    });

    it("shows Guest for members with no role", async () => {
      expect(true).toBe(true);
    });
  });
});

Step 2: Run tests (will fail - routes don't exist yet)

pnpm exec vitest run src/routes/__tests__/admin.test.ts

Expected: Import error - admin.js doesn't exist.

Step 3: Commit

git add apps/appview/src/routes/__tests__/admin.test.ts
git commit -m "test: add admin routes test skeleton"

Task 9: Admin Routes - Implementation#

Files:

  • Create: apps/appview/src/routes/admin.ts

Step 1: Create admin routes file

Create 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, getUserRole } from "../middleware/permissions.js";
import { memberships, roles, users } from "@atbb/db";
import { eq, and, sql, asc } from "drizzle-orm";

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

  /**
   * POST /api/admin/members/:did/role
   *
   * Assign a role to a forum member.
   */
  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: "" } },
            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.
   */
  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));

        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.
   */
  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);

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

Step 2: Run tests (will fail - no auth setup yet)

pnpm exec vitest run src/routes/__tests__/admin.test.ts

Expected: Tests fail (routes exist but authentication not set up in tests).

Step 3: Commit

git add apps/appview/src/routes/admin.ts
git commit -m "feat(routes): add admin routes for role management

- POST /api/admin/members/:did/role - assign roles
- GET /api/admin/roles - list available roles
- GET /api/admin/members - list members with roles
- All protected by permission middleware"

Task 10: Role Seeding Script#

Files:

  • Create: apps/appview/src/lib/seed-roles.ts

Step 1: Create 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: ["*"],
    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.
 */
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 };
}

Step 2: Commit

git add apps/appview/src/lib/seed-roles.ts
git commit -m "feat(lib): add role seeding script

- Seed 4 default roles (Owner, Admin, Moderator, Member)
- Idempotent - checks for existing roles before creating
- Writes to Forum DID's PDS for proper firehose propagation"

Task 11: Integrate Admin Routes and Seeding#

Files:

  • Modify: apps/appview/src/index.ts (register admin routes, call seeding)

Step 1: Add admin routes import

Add to imports at top of index.ts:

import { createAdminRoutes } from "./routes/admin.js";
import { seedDefaultRoles } from "./lib/seed-roles.js";

Step 2: Register admin routes

After other route registrations (around line 80), add:

app.route("/api/admin", createAdminRoutes(ctx));

Step 3: Add role seeding on startup

After ForumAgent initialization (around line 60), before starting the server:

// 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");
}

Step 4: Commit

git add apps/appview/src/index.ts
git commit -m "feat(appview): integrate admin routes and role seeding

- Register /api/admin routes
- Seed default roles on startup (configurable via env var)
- Runs after ForumAgent init, before server starts"

Task 12: Update Existing Write Endpoints#

Files:

  • Modify: apps/appview/src/routes/topics.ts:13 (change requireAuth to requirePermission)
  • Modify: apps/appview/src/routes/posts.ts:13 (change requireAuth to requirePermission)

Step 1: Update topics.ts

Add import at top:

import { requirePermission } from "../middleware/permissions.js";

Change line 13 from:

app.post("/", requireAuth(ctx), async (c) => {

To:

app.post("/", requirePermission(ctx, "space.atbb.permission.createTopics"), async (c) => {

Step 2: Update posts.ts

Add import at top:

import { requirePermission } from "../middleware/permissions.js";

Change line 13 from:

app.post("/", requireAuth(ctx), async (c) => {

To:

app.post("/", requirePermission(ctx, "space.atbb.permission.createPosts"), async (c) => {

Step 3: Commit

git add apps/appview/src/routes/topics.ts apps/appview/src/routes/posts.ts
git commit -m "feat(routes): enforce permissions on topic/post creation

- Replace requireAuth with requirePermission
- Require createTopics permission for POST /api/topics
- Require createPosts permission for POST /api/posts"

Task 13: Environment Variables#

Files:

  • Modify: .env.example (add SEED_DEFAULT_ROLES and DEFAULT_MEMBER_ROLE)

Step 1: Add env vars to .env.example

Add after existing env vars:

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

Step 2: Commit

git add .env.example
git commit -m "docs: add permission system env vars to .env.example

- SEED_DEFAULT_ROLES: toggle role seeding on startup
- DEFAULT_MEMBER_ROLE: configurable default role for new members"

Task 14: Run All Tests#

Files:

  • Test all packages

Step 1: Run all tests

pnpm test

Expected: All tests pass (existing tests + new permission tests).

Step 2: If tests fail, fix issues

Common issues:

  • Missing imports
  • Type errors in test files
  • Database cleanup issues

Step 3: Commit fixes if needed

git add <fixed-files>
git commit -m "fix: resolve test failures"

Task 15: Manual Testing#

Files:

  • None (manual testing via API)

Step 1: Start fresh database and run migrations

# Drop and recreate database
dropdb atbb_dev
createdb atbb_dev
cd packages/db
pnpm db:migrate

Step 2: Start AppView

cd apps/appview
pnpm dev

Expected: Logs show "Created default role" for 4 roles.

Step 3: Verify roles in database

psql atbb_dev -c "SELECT name, priority, array_length(permissions, 1) as perm_count FROM roles ORDER BY priority;"

Expected:

  name    | priority | perm_count
----------+----------+------------
 Owner    |        0 |          1
 Admin    |       10 |          9
 Moderator|       20 |          6
 Member   |       30 |          2

Step 4: Test permission enforcement

Try creating a topic without a role (should fail with 403):

# This requires setting up OAuth first - defer to integration tests

Step 5: Document manual testing completion

Manual testing checklist completed - basic seeding and database setup verified.


Task 16: Update Documentation#

Files:

  • Modify: docs/atproto-forum-plan.md:185-186 (mark ATB-17 complete)

Step 1: Mark ATB-17 complete in plan doc

In docs/atproto-forum-plan.md, update line 185-186:

- [x] Role assignment: admin can set roles via Forum DID records (ATB-17) — **Complete:** Full permission system implemented with 4 default roles, middleware enforcement, admin endpoints, and role seeding. Files: `apps/appview/src/middleware/permissions.ts`, `apps/appview/src/routes/admin.ts`, `apps/appview/src/lib/seed-roles.ts`, `packages/db/src/schema.ts:188-210` (2026-02-14)
- [x] Middleware: permission checks on write endpoints — **Complete:** `requirePermission()` and `requireRole()` middleware integrated on all write endpoints (`POST /api/topics`, `POST /api/posts`). Future mod endpoints will use `canActOnUser()` for priority hierarchy enforcement.

Step 2: Commit

git add docs/atproto-forum-plan.md
git commit -m "docs: mark ATB-17 complete in project plan

- Permission system fully implemented
- All acceptance criteria met
- 4 default roles seeded, middleware enforced, admin endpoints operational"

Task 17: Final Verification#

Files:

  • Run full test suite and manual checks

Step 1: Run full test suite

pnpm test

Expected: All tests pass.

Step 2: Run type checking

pnpm turbo lint

Expected: No type errors.

Step 3: Run linting

pnpm turbo lint:fix

Expected: No lint errors.

Step 4: Verify build

pnpm build

Expected: Clean build, no errors.

Step 5: Create final commit if fixes were needed

git add <any-fixed-files>
git commit -m "fix: final verification fixes"

Success Criteria Checklist#

Verify all success criteria are met:

  • Only users with createTopics permission can create topics
  • Only users with createPosts permission can create posts
  • Admins can assign roles to members via POST /api/admin/members/:did/role
  • Priority hierarchy prevents privilege escalation (Admin can't assign Admin/Owner roles)
  • Permission checks complete in <10ms (database indexes in place)
  • Default roles seeded automatically on fresh install
  • All tests passing (100% coverage on permission logic)
  • Error messages clear and actionable (401 vs 403 vs 404 vs 500)

Next Steps#

After completing this implementation:

  1. Update Linear: Mark ATB-17 as Done with implementation notes
  2. Test in production-like environment: Deploy to staging, verify role assignment
  3. Create PR: Open PR with comprehensive description referencing design doc
  4. Code review: Request review from team (use @superpowers:requesting-code-review if needed)
  5. Merge: After approval, merge to main
  6. Monitor: Watch logs for permission check performance and errors

Future Enhancements (ATB-19+)#

This implementation provides the foundation for:

  • ATB-19: Moderation action write-path endpoints (will use canActOnUser() for priority enforcement)
  • ATB-20: Moderation action indexing
  • ATB-21: Ban enforcement
  • ATB-22: Content visibility filtering
  • Post-MVP: Permission caching, custom roles, per-category permissions