···11import { describe, it, expect, beforeEach, afterEach } from "vitest";
22+import { Hono } from "hono";
23import { createTestContext, type TestContext } from "../../lib/__tests__/test-context.js";
34import { roles, rolePermissions, memberships, users } from "@atbb/db";
45import {
56 checkPermission,
67 checkMinRole,
78 canActOnUser,
99+ requireAnyPermission,
810} from "../permissions.js";
1111+import type { Variables } from "../../types.js";
9121013describe("Permission Helper Functions", () => {
1114 let ctx: TestContext;
···463466 const result = await canActOnUser(ctx, "did:plc:test-mod5", "did:plc:test-admin5");
464467465468 expect(result).toBe(false); // Moderator (20) cannot act on Admin (10)
469469+ });
470470+ });
471471+472472+ describe("requireAnyPermission", () => {
473473+ it("returns 200 when user has one of the required permissions", async () => {
474474+ // Create a role with moderatePosts permission
475475+ const [modRole] = await ctx.db.insert(roles).values({
476476+ did: ctx.config.forumDid,
477477+ rkey: "mod-role-anyperm-1",
478478+ cid: "test-cid",
479479+ name: "Moderator",
480480+ description: "Moderator role",
481481+ priority: 20,
482482+ createdAt: new Date(),
483483+ indexedAt: new Date(),
484484+ }).returning({ id: roles.id });
485485+486486+ await ctx.db.insert(rolePermissions).values([
487487+ { roleId: modRole.id, permission: "space.atbb.permission.moderatePosts" },
488488+ ]);
489489+490490+ await ctx.db.insert(users).values({
491491+ did: "did:plc:test-anyperm-1",
492492+ handle: "anyperm1.bsky.social",
493493+ indexedAt: new Date(),
494494+ });
495495+496496+ await ctx.db.insert(memberships).values({
497497+ did: "did:plc:test-anyperm-1",
498498+ rkey: "membership-anyperm-1",
499499+ cid: "test-cid",
500500+ forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
501501+ roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/mod-role-anyperm-1`,
502502+ createdAt: new Date(),
503503+ indexedAt: new Date(),
504504+ });
505505+506506+ const testApp = new Hono<{ Variables: Variables }>();
507507+ testApp.use("*", async (c, next) => {
508508+ c.set("user", {
509509+ did: "did:plc:test-anyperm-1",
510510+ handle: "anyperm1.bsky.social",
511511+ pdsUrl: "https://pds.example.com",
512512+ agent: {} as any,
513513+ });
514514+ await next();
515515+ });
516516+ testApp.get(
517517+ "/test",
518518+ requireAnyPermission(ctx, [
519519+ "space.atbb.permission.moderatePosts",
520520+ "space.atbb.permission.banUsers",
521521+ ]),
522522+ (c) => c.json({ ok: true })
523523+ );
524524+525525+ const res = await testApp.request("/test");
526526+ expect(res.status).toBe(200);
527527+ const body = await res.json();
528528+ expect(body).toEqual({ ok: true });
529529+ });
530530+531531+ it("returns 403 when user has none of the required permissions", async () => {
532532+ // Create a role with only createTopics permission
533533+ const [memberRole] = await ctx.db.insert(roles).values({
534534+ did: ctx.config.forumDid,
535535+ rkey: "mod-role-anyperm-2",
536536+ cid: "test-cid",
537537+ name: "Member",
538538+ description: "Member role",
539539+ priority: 30,
540540+ createdAt: new Date(),
541541+ indexedAt: new Date(),
542542+ }).returning({ id: roles.id });
543543+544544+ await ctx.db.insert(rolePermissions).values([
545545+ { roleId: memberRole.id, permission: "space.atbb.permission.createTopics" },
546546+ ]);
547547+548548+ await ctx.db.insert(users).values({
549549+ did: "did:plc:test-anyperm-2",
550550+ handle: "anyperm2.bsky.social",
551551+ indexedAt: new Date(),
552552+ });
553553+554554+ await ctx.db.insert(memberships).values({
555555+ did: "did:plc:test-anyperm-2",
556556+ rkey: "membership-anyperm-2",
557557+ cid: "test-cid",
558558+ forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
559559+ roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/mod-role-anyperm-2`,
560560+ createdAt: new Date(),
561561+ indexedAt: new Date(),
562562+ });
563563+564564+ const testApp = new Hono<{ Variables: Variables }>();
565565+ testApp.use("*", async (c, next) => {
566566+ c.set("user", {
567567+ did: "did:plc:test-anyperm-2",
568568+ handle: "anyperm2.bsky.social",
569569+ pdsUrl: "https://pds.example.com",
570570+ agent: {} as any,
571571+ });
572572+ await next();
573573+ });
574574+ testApp.get(
575575+ "/test",
576576+ requireAnyPermission(ctx, [
577577+ "space.atbb.permission.moderatePosts",
578578+ "space.atbb.permission.banUsers",
579579+ ]),
580580+ (c) => c.json({ ok: true })
581581+ );
582582+583583+ const res = await testApp.request("/test");
584584+ expect(res.status).toBe(403);
585585+ const body = await res.json();
586586+ expect(body).toEqual({ error: "Insufficient permissions" });
587587+ });
588588+589589+ it("returns 401 when user is not authenticated", async () => {
590590+ const testApp = new Hono<{ Variables: Variables }>();
591591+ // No auth middleware — user is not set
592592+ testApp.get(
593593+ "/test",
594594+ requireAnyPermission(ctx, [
595595+ "space.atbb.permission.moderatePosts",
596596+ "space.atbb.permission.banUsers",
597597+ ]),
598598+ (c) => c.json({ ok: true })
599599+ );
600600+601601+ const res = await testApp.request("/test");
602602+ expect(res.status).toBe(401);
603603+ });
604604+605605+ it("short-circuits on second permission if first fails", async () => {
606606+ // Create a role with banUsers but NOT moderatePosts
607607+ const [banRole] = await ctx.db.insert(roles).values({
608608+ did: ctx.config.forumDid,
609609+ rkey: "mod-role-anyperm-3",
610610+ cid: "test-cid",
611611+ name: "BanRole",
612612+ description: "Role with banUsers only",
613613+ priority: 15,
614614+ createdAt: new Date(),
615615+ indexedAt: new Date(),
616616+ }).returning({ id: roles.id });
617617+618618+ await ctx.db.insert(rolePermissions).values([
619619+ { roleId: banRole.id, permission: "space.atbb.permission.banUsers" },
620620+ ]);
621621+622622+ await ctx.db.insert(users).values({
623623+ did: "did:plc:test-anyperm-3",
624624+ handle: "anyperm3.bsky.social",
625625+ indexedAt: new Date(),
626626+ });
627627+628628+ await ctx.db.insert(memberships).values({
629629+ did: "did:plc:test-anyperm-3",
630630+ rkey: "membership-anyperm-3",
631631+ cid: "test-cid",
632632+ forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
633633+ roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/mod-role-anyperm-3`,
634634+ createdAt: new Date(),
635635+ indexedAt: new Date(),
636636+ });
637637+638638+ const testApp = new Hono<{ Variables: Variables }>();
639639+ testApp.use("*", async (c, next) => {
640640+ c.set("user", {
641641+ did: "did:plc:test-anyperm-3",
642642+ handle: "anyperm3.bsky.social",
643643+ pdsUrl: "https://pds.example.com",
644644+ agent: {} as any,
645645+ });
646646+ await next();
647647+ });
648648+ // First perm (moderatePosts) will fail, second (banUsers) will succeed
649649+ testApp.get(
650650+ "/test",
651651+ requireAnyPermission(ctx, [
652652+ "space.atbb.permission.moderatePosts",
653653+ "space.atbb.permission.banUsers",
654654+ ]),
655655+ (c) => c.json({ ok: true })
656656+ );
657657+658658+ const res = await testApp.request("/test");
659659+ expect(res.status).toBe(200);
660660+ const body = await res.json();
661661+ expect(body).toEqual({ ok: true });
466662 });
467663 });
468664});
+30
apps/appview/src/middleware/permissions.ts
···240240}
241241242242/**
243243+ * Require at least one of a list of permissions (OR logic).
244244+ *
245245+ * Iterates the permissions list in order, calling checkPermission for each.
246246+ * Short-circuits and calls next() on the first match.
247247+ * Returns 401 if not authenticated, 403 if none of the permissions match.
248248+ */
249249+export function requireAnyPermission(
250250+ ctx: AppContext,
251251+ permissions: string[]
252252+) {
253253+ return async (c: Context<{ Variables: Variables }>, next: Next) => {
254254+ const user = c.get("user");
255255+256256+ if (!user) {
257257+ return c.json({ error: "Authentication required" }, 401);
258258+ }
259259+260260+ for (const permission of permissions) {
261261+ const hasPermission = await checkPermission(ctx, user.did, permission);
262262+ if (hasPermission) {
263263+ await next();
264264+ return;
265265+ }
266266+ }
267267+268268+ return c.json({ error: "Insufficient permissions" }, 403);
269269+ };
270270+}
271271+272272+/**
243273 * Require minimum role middleware.
244274 *
245275 * Validates that the authenticated user has a role with sufficient priority.