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.

fix(web): add manageRoles permission gate to POST proxy route (ATB-43)

Malpercio f3878fa4 d755bafb

+103 -1
+80 -1
apps/web/src/routes/__tests__/admin.test.tsx
··· 444 444 return createAdminRoutes("http://localhost:3000"); 445 445 } 446 446 447 + function setupPostSession(permissions: string[] = ["space.atbb.permission.manageRoles"]) { 448 + mockFetch.mockResolvedValueOnce( 449 + mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.bsky.social" }) 450 + ); 451 + mockFetch.mockResolvedValueOnce(mockResponse({ permissions })); 452 + } 453 + 447 454 it("returns updated <tr> with new role name on success", async () => { 455 + setupPostSession(); 448 456 mockFetch.mockResolvedValueOnce( 449 457 mockResponse({ success: true, roleAssigned: "Member", targetDid: "did:plc:bob" }) 450 458 ); ··· 467 475 }); 468 476 469 477 it("returns row with friendly error on AppView 403", async () => { 478 + setupPostSession(); 470 479 mockFetch.mockResolvedValueOnce(mockResponse({}, false, 403)); 471 480 472 481 const routes = await loadAdminRoutes(); ··· 487 496 }); 488 497 489 498 it("returns row with friendly error on AppView 404", async () => { 499 + setupPostSession(); 490 500 mockFetch.mockResolvedValueOnce(mockResponse({}, false, 404)); 491 501 492 502 const routes = await loadAdminRoutes(); ··· 506 516 }); 507 517 508 518 it("returns row with friendly error on AppView 500", async () => { 519 + setupPostSession(); 509 520 mockFetch.mockResolvedValueOnce(mockResponse({}, false, 500)); 510 521 511 522 const routes = await loadAdminRoutes(); ··· 525 536 }); 526 537 527 538 it("returns row with unavailable message on network error", async () => { 539 + setupPostSession(); 528 540 mockFetch.mockRejectedValueOnce(new Error("fetch failed")); 529 541 530 542 const routes = await loadAdminRoutes(); ··· 544 556 }); 545 557 546 558 it("returns row with error and makes no AppView call when roleUri is missing", async () => { 559 + setupPostSession(); 547 560 const routes = await loadAdminRoutes(); 548 561 const res = await routes.request("/admin/members/did:plc:bob/role", { 549 562 method: "POST", ··· 557 570 expect(res.status).toBe(200); 558 571 const html = await res.text(); 559 572 expect(html).toContain("member-row__error"); 560 - expect(mockFetch).not.toHaveBeenCalled(); 573 + expect(mockFetch).not.toHaveBeenCalledWith( 574 + expect.stringContaining("/api/admin/members/did:plc:bob/role"), 575 + expect.anything() 576 + ); 577 + }); 578 + 579 + it("re-renders form with new role pre-selected in dropdown on success", async () => { 580 + setupPostSession(); 581 + mockFetch.mockResolvedValueOnce( 582 + mockResponse({ success: true, roleAssigned: "Member", targetDid: "did:plc:bob" }) 583 + ); 584 + 585 + const routes = await loadAdminRoutes(); 586 + const res = await routes.request("/admin/members/did:plc:bob/role", { 587 + method: "POST", 588 + headers: { 589 + "Content-Type": "application/x-www-form-urlencoded", 590 + cookie: "atbb_session=token", 591 + }, 592 + body: makeFormBody({ 593 + roleUri: "at://did:plc:forum/space.atbb.forum.role/member", 594 + }), 595 + }); 596 + 597 + const html = await res.text(); 598 + // The newly assigned role URI should appear as the selected option value in the form 599 + expect(html).toContain("at://did:plc:forum/space.atbb.forum.role/member"); 600 + }); 601 + 602 + it("returns 401 error row for unauthenticated POST", async () => { 603 + // No session mock — no cookie 604 + const routes = await loadAdminRoutes(); 605 + const res = await routes.request("/admin/members/did:plc:bob/role", { 606 + method: "POST", 607 + headers: { "Content-Type": "application/x-www-form-urlencoded" }, 608 + body: makeFormBody(), 609 + }); 610 + 611 + expect(res.status).toBe(401); 612 + const html = await res.text(); 613 + expect(html).toContain("member-row__error"); 614 + expect(mockFetch).not.toHaveBeenCalledWith( 615 + expect.stringContaining("/api/admin/members/did:plc:bob/role"), 616 + expect.anything() 617 + ); 618 + }); 619 + 620 + it("returns 403 error row when user lacks manageRoles", async () => { 621 + setupPostSession(["space.atbb.permission.manageMembers"]); // has manageMembers but NOT manageRoles 622 + const routes = await loadAdminRoutes(); 623 + const res = await routes.request("/admin/members/did:plc:bob/role", { 624 + method: "POST", 625 + headers: { 626 + "Content-Type": "application/x-www-form-urlencoded", 627 + cookie: "atbb_session=token", 628 + }, 629 + body: makeFormBody(), 630 + }); 631 + 632 + expect(res.status).toBe(403); 633 + const html = await res.text(); 634 + expect(html).toContain("member-row__error"); 635 + // No AppView role assignment call should have been made 636 + expect(mockFetch).not.toHaveBeenCalledWith( 637 + expect.stringContaining("/api/admin/members/did:plc:bob/role"), 638 + expect.anything() 639 + ); 561 640 }); 562 641 });
+23
apps/web/src/routes/admin.tsx
··· 277 277 // ── POST /admin/members/:did/role (HTMX proxy) ──────────────────────────── 278 278 279 279 app.post("/admin/members/:did/role", async (c) => { 280 + // Permission gate — must come before body parsing 281 + const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 282 + if (!auth.authenticated) { 283 + return c.html( 284 + <tr> 285 + <td colspan={4}> 286 + <span class="member-row__error">You must be logged in to perform this action.</span> 287 + </td> 288 + </tr>, 289 + 401 290 + ); 291 + } 292 + if (!canManageRoles(auth)) { 293 + return c.html( 294 + <tr> 295 + <td colspan={4}> 296 + <span class="member-row__error">You don&apos;t have permission to assign roles.</span> 297 + </td> 298 + </tr>, 299 + 403 300 + ); 301 + } 302 + 280 303 const targetDid = c.req.param("did"); 281 304 const cookie = c.req.header("cookie") ?? ""; 282 305