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

feat(appview): add requireAnyPermission middleware (ATB-46)

+226
+196
apps/appview/src/middleware/__tests__/permissions.test.ts
··· 1 1 import { describe, it, expect, beforeEach, afterEach } from "vitest"; 2 + import { Hono } from "hono"; 2 3 import { createTestContext, type TestContext } from "../../lib/__tests__/test-context.js"; 3 4 import { roles, rolePermissions, memberships, users } from "@atbb/db"; 4 5 import { 5 6 checkPermission, 6 7 checkMinRole, 7 8 canActOnUser, 9 + requireAnyPermission, 8 10 } from "../permissions.js"; 11 + import type { Variables } from "../../types.js"; 9 12 10 13 describe("Permission Helper Functions", () => { 11 14 let ctx: TestContext; ··· 463 466 const result = await canActOnUser(ctx, "did:plc:test-mod5", "did:plc:test-admin5"); 464 467 465 468 expect(result).toBe(false); // Moderator (20) cannot act on Admin (10) 469 + }); 470 + }); 471 + 472 + describe("requireAnyPermission", () => { 473 + it("returns 200 when user has one of the required permissions", async () => { 474 + // Create a role with moderatePosts permission 475 + const [modRole] = await ctx.db.insert(roles).values({ 476 + did: ctx.config.forumDid, 477 + rkey: "mod-role-anyperm-1", 478 + cid: "test-cid", 479 + name: "Moderator", 480 + description: "Moderator role", 481 + priority: 20, 482 + createdAt: new Date(), 483 + indexedAt: new Date(), 484 + }).returning({ id: roles.id }); 485 + 486 + await ctx.db.insert(rolePermissions).values([ 487 + { roleId: modRole.id, permission: "space.atbb.permission.moderatePosts" }, 488 + ]); 489 + 490 + await ctx.db.insert(users).values({ 491 + did: "did:plc:test-anyperm-1", 492 + handle: "anyperm1.bsky.social", 493 + indexedAt: new Date(), 494 + }); 495 + 496 + await ctx.db.insert(memberships).values({ 497 + did: "did:plc:test-anyperm-1", 498 + rkey: "membership-anyperm-1", 499 + cid: "test-cid", 500 + forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 501 + roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/mod-role-anyperm-1`, 502 + createdAt: new Date(), 503 + indexedAt: new Date(), 504 + }); 505 + 506 + const testApp = new Hono<{ Variables: Variables }>(); 507 + testApp.use("*", async (c, next) => { 508 + c.set("user", { 509 + did: "did:plc:test-anyperm-1", 510 + handle: "anyperm1.bsky.social", 511 + pdsUrl: "https://pds.example.com", 512 + agent: {} as any, 513 + }); 514 + await next(); 515 + }); 516 + testApp.get( 517 + "/test", 518 + requireAnyPermission(ctx, [ 519 + "space.atbb.permission.moderatePosts", 520 + "space.atbb.permission.banUsers", 521 + ]), 522 + (c) => c.json({ ok: true }) 523 + ); 524 + 525 + const res = await testApp.request("/test"); 526 + expect(res.status).toBe(200); 527 + const body = await res.json(); 528 + expect(body).toEqual({ ok: true }); 529 + }); 530 + 531 + it("returns 403 when user has none of the required permissions", async () => { 532 + // Create a role with only createTopics permission 533 + const [memberRole] = await ctx.db.insert(roles).values({ 534 + did: ctx.config.forumDid, 535 + rkey: "mod-role-anyperm-2", 536 + cid: "test-cid", 537 + name: "Member", 538 + description: "Member role", 539 + priority: 30, 540 + createdAt: new Date(), 541 + indexedAt: new Date(), 542 + }).returning({ id: roles.id }); 543 + 544 + await ctx.db.insert(rolePermissions).values([ 545 + { roleId: memberRole.id, permission: "space.atbb.permission.createTopics" }, 546 + ]); 547 + 548 + await ctx.db.insert(users).values({ 549 + did: "did:plc:test-anyperm-2", 550 + handle: "anyperm2.bsky.social", 551 + indexedAt: new Date(), 552 + }); 553 + 554 + await ctx.db.insert(memberships).values({ 555 + did: "did:plc:test-anyperm-2", 556 + rkey: "membership-anyperm-2", 557 + cid: "test-cid", 558 + forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 559 + roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/mod-role-anyperm-2`, 560 + createdAt: new Date(), 561 + indexedAt: new Date(), 562 + }); 563 + 564 + const testApp = new Hono<{ Variables: Variables }>(); 565 + testApp.use("*", async (c, next) => { 566 + c.set("user", { 567 + did: "did:plc:test-anyperm-2", 568 + handle: "anyperm2.bsky.social", 569 + pdsUrl: "https://pds.example.com", 570 + agent: {} as any, 571 + }); 572 + await next(); 573 + }); 574 + testApp.get( 575 + "/test", 576 + requireAnyPermission(ctx, [ 577 + "space.atbb.permission.moderatePosts", 578 + "space.atbb.permission.banUsers", 579 + ]), 580 + (c) => c.json({ ok: true }) 581 + ); 582 + 583 + const res = await testApp.request("/test"); 584 + expect(res.status).toBe(403); 585 + const body = await res.json(); 586 + expect(body).toEqual({ error: "Insufficient permissions" }); 587 + }); 588 + 589 + it("returns 401 when user is not authenticated", async () => { 590 + const testApp = new Hono<{ Variables: Variables }>(); 591 + // No auth middleware — user is not set 592 + testApp.get( 593 + "/test", 594 + requireAnyPermission(ctx, [ 595 + "space.atbb.permission.moderatePosts", 596 + "space.atbb.permission.banUsers", 597 + ]), 598 + (c) => c.json({ ok: true }) 599 + ); 600 + 601 + const res = await testApp.request("/test"); 602 + expect(res.status).toBe(401); 603 + }); 604 + 605 + it("short-circuits on second permission if first fails", async () => { 606 + // Create a role with banUsers but NOT moderatePosts 607 + const [banRole] = await ctx.db.insert(roles).values({ 608 + did: ctx.config.forumDid, 609 + rkey: "mod-role-anyperm-3", 610 + cid: "test-cid", 611 + name: "BanRole", 612 + description: "Role with banUsers only", 613 + priority: 15, 614 + createdAt: new Date(), 615 + indexedAt: new Date(), 616 + }).returning({ id: roles.id }); 617 + 618 + await ctx.db.insert(rolePermissions).values([ 619 + { roleId: banRole.id, permission: "space.atbb.permission.banUsers" }, 620 + ]); 621 + 622 + await ctx.db.insert(users).values({ 623 + did: "did:plc:test-anyperm-3", 624 + handle: "anyperm3.bsky.social", 625 + indexedAt: new Date(), 626 + }); 627 + 628 + await ctx.db.insert(memberships).values({ 629 + did: "did:plc:test-anyperm-3", 630 + rkey: "membership-anyperm-3", 631 + cid: "test-cid", 632 + forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 633 + roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/mod-role-anyperm-3`, 634 + createdAt: new Date(), 635 + indexedAt: new Date(), 636 + }); 637 + 638 + const testApp = new Hono<{ Variables: Variables }>(); 639 + testApp.use("*", async (c, next) => { 640 + c.set("user", { 641 + did: "did:plc:test-anyperm-3", 642 + handle: "anyperm3.bsky.social", 643 + pdsUrl: "https://pds.example.com", 644 + agent: {} as any, 645 + }); 646 + await next(); 647 + }); 648 + // First perm (moderatePosts) will fail, second (banUsers) will succeed 649 + testApp.get( 650 + "/test", 651 + requireAnyPermission(ctx, [ 652 + "space.atbb.permission.moderatePosts", 653 + "space.atbb.permission.banUsers", 654 + ]), 655 + (c) => c.json({ ok: true }) 656 + ); 657 + 658 + const res = await testApp.request("/test"); 659 + expect(res.status).toBe(200); 660 + const body = await res.json(); 661 + expect(body).toEqual({ ok: true }); 466 662 }); 467 663 }); 468 664 });
+30
apps/appview/src/middleware/permissions.ts
··· 240 240 } 241 241 242 242 /** 243 + * Require at least one of a list of permissions (OR logic). 244 + * 245 + * Iterates the permissions list in order, calling checkPermission for each. 246 + * Short-circuits and calls next() on the first match. 247 + * Returns 401 if not authenticated, 403 if none of the permissions match. 248 + */ 249 + export function requireAnyPermission( 250 + ctx: AppContext, 251 + permissions: string[] 252 + ) { 253 + return async (c: Context<{ Variables: Variables }>, next: Next) => { 254 + const user = c.get("user"); 255 + 256 + if (!user) { 257 + return c.json({ error: "Authentication required" }, 401); 258 + } 259 + 260 + for (const permission of permissions) { 261 + const hasPermission = await checkPermission(ctx, user.did, permission); 262 + if (hasPermission) { 263 + await next(); 264 + return; 265 + } 266 + } 267 + 268 + return c.json({ error: "Insufficient permissions" }, 403); 269 + }; 270 + } 271 + 272 + /** 243 273 * Require minimum role middleware. 244 274 * 245 275 * Validates that the authenticated user has a role with sufficient priority.