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

fix(appview): include role strongRef when upgrading bootstrap membership (ATB-37) (#68)

* fix(appview): include role strongRef when upgrading bootstrap membership

upgradeBootstrapMembership was writing a PDS record without a role field.
When the firehose re-indexed the event, membershipConfig.toUpdateValues()
set roleUri = record.role?.role.uri ?? null, overwriting the Owner's
roleUri with null and stripping their permissions on first login.

Fix: look up the existing roleUri in the roles table (parsing the AT URI
to extract did/rkey), then include it as a strongRef in the PDS record —
same pattern used by writeMembershipRecord for new members.

Closes ATB-37

* fix(appview): address review feedback on ATB-37 bootstrap upgrade

- Pass ctx.logger to parseAtUri so malformed roleUris emit structured
warnings at parse time
- Add explicit error log when parseAtUri returns null (data integrity
signal — a stored roleUri that can't be parsed is unexpected)
- Upgrade warn → error in DB failure catch: a failed role lookup during
bootstrap upgrade has the same consequence as the role-not-found path
(firehose will null out roleUri), so both warrant error level
- Add DB failure test for the role lookup catch block
- Add malformed roleUri test (parseAtUri returns null path)
- Add result.created assertions to tests 1 and 2
- Add DB state assertions to all three new bootstrap upgrade tests

authored by

Malpercio and committed by
GitHub
c29907da 12cdce52

+360 -10
+306
apps/appview/src/lib/__tests__/membership.test.ts
··· 472 472 expect(updated.roleUri).toBe(ownerRoleUri); 473 473 expect(updated.role).toBe("Owner"); 474 474 }); 475 + 476 + it("includes role strongRef in PDS record when upgrading bootstrap membership with a known role", async () => { 477 + // This is the ATB-37 regression test. When upgradeBootstrapMembership writes the 478 + // PDS record without a role field, the firehose re-indexes the event and sets 479 + // roleUri = null (record.role?.role.uri ?? null), stripping the Owner's role. 480 + const testDid = `did:plc:test-bootstrap-roleref-${Date.now()}`; 481 + const forumUri = `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`; 482 + const ownerRoleRkey = "ownerrole789"; 483 + const ownerRoleCid = "bafyowner789"; 484 + 485 + // Insert the Owner role so upgradeBootstrapMembership can look it up 486 + await ctx.db.insert(roles).values({ 487 + did: ctx.config.forumDid, 488 + rkey: ownerRoleRkey, 489 + cid: ownerRoleCid, 490 + name: "Owner", 491 + description: "Forum owner", 492 + priority: 10, 493 + createdAt: new Date(), 494 + indexedAt: new Date(), 495 + }); 496 + 497 + const ownerRoleUri = `at://${ctx.config.forumDid}/space.atbb.forum.role/${ownerRoleRkey}`; 498 + 499 + await ctx.db.insert(users).values({ 500 + did: testDid, 501 + handle: "bootstrap.roleref", 502 + indexedAt: new Date(), 503 + }); 504 + 505 + await ctx.db.insert(memberships).values({ 506 + did: testDid, 507 + rkey: "bootstrap", 508 + cid: "bootstrap", 509 + forumUri, 510 + roleUri: ownerRoleUri, 511 + role: "Owner", 512 + createdAt: new Date(), 513 + indexedAt: new Date(), 514 + }); 515 + 516 + const mockAgent = { 517 + com: { 518 + atproto: { 519 + repo: { 520 + putRecord: vi.fn().mockResolvedValue({ 521 + data: { 522 + uri: `at://${testDid}/space.atbb.membership/tidabc`, 523 + cid: "bafyupgradedabc", 524 + }, 525 + }), 526 + }, 527 + }, 528 + }, 529 + } as any; 530 + 531 + const result = await createMembershipForUser(ctx, mockAgent, testDid); 532 + 533 + expect(result.created).toBe(true); 534 + 535 + // The PDS record must include the role strongRef so the firehose 536 + // preserves the roleUri when it re-indexes the upgrade event. 537 + expect(mockAgent.com.atproto.repo.putRecord).toHaveBeenCalledWith( 538 + expect.objectContaining({ 539 + record: expect.objectContaining({ 540 + role: { 541 + role: { 542 + uri: ownerRoleUri, 543 + cid: ownerRoleCid, 544 + }, 545 + }, 546 + }), 547 + }) 548 + ); 549 + 550 + // DB row must reflect the upgrade: real rkey/cid, roleUri preserved 551 + const [updated] = await ctx.db 552 + .select() 553 + .from(memberships) 554 + .where(and(eq(memberships.did, testDid), eq(memberships.forumUri, forumUri))) 555 + .limit(1); 556 + expect(updated.cid).toBe("bafyupgradedabc"); 557 + expect(updated.rkey).not.toBe("bootstrap"); 558 + expect(updated.roleUri).toBe(ownerRoleUri); 559 + }); 560 + 561 + it("omits role from PDS record when upgrading bootstrap membership without a roleUri", async () => { 562 + const testDid = `did:plc:test-bootstrap-norole-${Date.now()}`; 563 + const forumUri = `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`; 564 + 565 + await ctx.db.insert(users).values({ 566 + did: testDid, 567 + handle: "bootstrap.norole", 568 + indexedAt: new Date(), 569 + }); 570 + 571 + // Bootstrap membership with no roleUri 572 + await ctx.db.insert(memberships).values({ 573 + did: testDid, 574 + rkey: "bootstrap", 575 + cid: "bootstrap", 576 + forumUri, 577 + createdAt: new Date(), 578 + indexedAt: new Date(), 579 + }); 580 + 581 + const mockAgent = { 582 + com: { 583 + atproto: { 584 + repo: { 585 + putRecord: vi.fn().mockResolvedValue({ 586 + data: { 587 + uri: `at://${testDid}/space.atbb.membership/tiddef`, 588 + cid: "bafynoroledef", 589 + }, 590 + }), 591 + }, 592 + }, 593 + }, 594 + } as any; 595 + 596 + const result = await createMembershipForUser(ctx, mockAgent, testDid); 597 + 598 + expect(result.created).toBe(true); 599 + const callArg = mockAgent.com.atproto.repo.putRecord.mock.calls[0][0]; 600 + expect(callArg.record.role).toBeUndefined(); 601 + 602 + // DB row must reflect the upgrade: real rkey/cid, roleUri stays null 603 + const [updated] = await ctx.db 604 + .select() 605 + .from(memberships) 606 + .where(and(eq(memberships.did, testDid), eq(memberships.forumUri, forumUri))) 607 + .limit(1); 608 + expect(updated.cid).toBe("bafynoroledef"); 609 + expect(updated.rkey).not.toBe("bootstrap"); 610 + expect(updated.roleUri).toBeNull(); 611 + }); 612 + 613 + it("upgrades bootstrap membership without role when roleUri references a role not in DB", async () => { 614 + const testDid = `did:plc:test-bootstrap-missingrole-${Date.now()}`; 615 + const forumUri = `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`; 616 + // A roleUri that has no matching row in the roles table 617 + const danglingRoleUri = `at://${ctx.config.forumDid}/space.atbb.forum.role/nonexistent`; 618 + 619 + await ctx.db.insert(users).values({ 620 + did: testDid, 621 + handle: "bootstrap.missingrole", 622 + indexedAt: new Date(), 623 + }); 624 + 625 + await ctx.db.insert(memberships).values({ 626 + did: testDid, 627 + rkey: "bootstrap", 628 + cid: "bootstrap", 629 + forumUri, 630 + roleUri: danglingRoleUri, 631 + createdAt: new Date(), 632 + indexedAt: new Date(), 633 + }); 634 + 635 + const mockAgent = { 636 + com: { 637 + atproto: { 638 + repo: { 639 + putRecord: vi.fn().mockResolvedValue({ 640 + data: { 641 + uri: `at://${testDid}/space.atbb.membership/tidghi`, 642 + cid: "bafymissingghi", 643 + }, 644 + }), 645 + }, 646 + }, 647 + }, 648 + } as any; 649 + 650 + // Upgrade should still succeed even if role lookup finds nothing 651 + const result = await createMembershipForUser(ctx, mockAgent, testDid); 652 + expect(result.created).toBe(true); 653 + 654 + const callArg = mockAgent.com.atproto.repo.putRecord.mock.calls[0][0]; 655 + expect(callArg.record.role).toBeUndefined(); 656 + 657 + // DB row must reflect the upgrade: real rkey/cid, dangling roleUri preserved 658 + const [updated] = await ctx.db 659 + .select() 660 + .from(memberships) 661 + .where(and(eq(memberships.did, testDid), eq(memberships.forumUri, forumUri))) 662 + .limit(1); 663 + expect(updated.cid).toBe("bafymissingghi"); 664 + expect(updated.rkey).not.toBe("bootstrap"); 665 + expect(updated.roleUri).toBe(danglingRoleUri); 666 + }); 667 + 668 + it("logs error and continues upgrade when role DB lookup fails during bootstrap upgrade", async () => { 669 + const testDid = `did:plc:test-bootstrap-dberr-${Date.now()}`; 670 + const forumUri = `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`; 671 + const ownerRoleUri = `at://${ctx.config.forumDid}/space.atbb.forum.role/ownerrole`; 672 + 673 + await ctx.db.insert(users).values({ 674 + did: testDid, 675 + handle: "bootstrap.dberr", 676 + indexedAt: new Date(), 677 + }); 678 + 679 + await ctx.db.insert(memberships).values({ 680 + did: testDid, 681 + rkey: "bootstrap", 682 + cid: "bootstrap", 683 + forumUri, 684 + roleUri: ownerRoleUri, 685 + role: "Owner", 686 + createdAt: new Date(), 687 + indexedAt: new Date(), 688 + }); 689 + 690 + const origSelect = ctx.db.select.bind(ctx.db); 691 + vi.spyOn(ctx.db, "select") 692 + .mockImplementationOnce(() => origSelect() as any) // forums lookup 693 + .mockImplementationOnce(() => origSelect() as any) // memberships check (bootstrap found) 694 + .mockReturnValueOnce({ // roles query in upgradeBootstrapMembership 695 + from: vi.fn().mockReturnValue({ 696 + where: vi.fn().mockReturnValue({ 697 + limit: vi.fn().mockRejectedValue(new Error("DB connection lost")), 698 + }), 699 + }), 700 + } as any); 701 + 702 + const errorSpy = vi.spyOn(ctx.logger, "error"); 703 + 704 + const mockAgent = { 705 + com: { 706 + atproto: { 707 + repo: { 708 + putRecord: vi.fn().mockResolvedValue({ 709 + data: { 710 + uri: `at://${testDid}/space.atbb.membership/tidjkl`, 711 + cid: "bafydberrjkl", 712 + }, 713 + }), 714 + }, 715 + }, 716 + }, 717 + } as any; 718 + 719 + const result = await createMembershipForUser(ctx, mockAgent, testDid); 720 + 721 + expect(result.created).toBe(true); 722 + const callArg = mockAgent.com.atproto.repo.putRecord.mock.calls[0][0]; 723 + expect(callArg.record.role).toBeUndefined(); 724 + expect(errorSpy).toHaveBeenCalledWith( 725 + expect.stringContaining("Role lookup failed during bootstrap upgrade"), 726 + expect.objectContaining({ operation: "upgradeBootstrapMembership" }) 727 + ); 728 + 729 + vi.restoreAllMocks(); 730 + }); 731 + 732 + it("logs error and omits role when bootstrap membership has a malformed roleUri", async () => { 733 + const testDid = `did:plc:test-bootstrap-malformed-${Date.now()}`; 734 + const forumUri = `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`; 735 + // Syntactically invalid AT URI — parseAtUri will return null 736 + const malformedRoleUri = "not-a-valid-at-uri"; 737 + 738 + await ctx.db.insert(users).values({ 739 + did: testDid, 740 + handle: "bootstrap.malformed", 741 + indexedAt: new Date(), 742 + }); 743 + 744 + await ctx.db.insert(memberships).values({ 745 + did: testDid, 746 + rkey: "bootstrap", 747 + cid: "bootstrap", 748 + forumUri, 749 + roleUri: malformedRoleUri, 750 + createdAt: new Date(), 751 + indexedAt: new Date(), 752 + }); 753 + 754 + const errorSpy = vi.spyOn(ctx.logger, "error"); 755 + 756 + const mockAgent = { 757 + com: { 758 + atproto: { 759 + repo: { 760 + putRecord: vi.fn().mockResolvedValue({ 761 + data: { 762 + uri: `at://${testDid}/space.atbb.membership/tidmno`, 763 + cid: "bafymalformedmno", 764 + }, 765 + }), 766 + }, 767 + }, 768 + }, 769 + } as any; 770 + 771 + const result = await createMembershipForUser(ctx, mockAgent, testDid); 772 + 773 + expect(result.created).toBe(true); 774 + const callArg = mockAgent.com.atproto.repo.putRecord.mock.calls[0][0]; 775 + expect(callArg.record.role).toBeUndefined(); 776 + expect(errorSpy).toHaveBeenCalledWith( 777 + expect.stringContaining("roleUri failed to parse"), 778 + expect.objectContaining({ operation: "upgradeBootstrapMembership", roleUri: malformedRoleUri }) 779 + ); 780 + }); 475 781 });
+54 -10
apps/appview/src/lib/membership.ts
··· 3 3 import { memberships, forums, roles } from "@atbb/db"; 4 4 import { eq, and, asc } from "drizzle-orm"; 5 5 import { TID } from "@atproto/common-web"; 6 + import { parseAtUri } from "./at-uri.js"; 6 7 7 8 export async function createMembershipForUser( 8 9 ctx: AppContext, ··· 36 37 // record. Upgrade them by writing a real record to the user's PDS and 37 38 // updating the DB row with the actual rkey/cid. 38 39 if (membership.cid === "bootstrap") { 39 - return upgradeBootstrapMembership(ctx, agent, did, forumUri, forum.cid, membership.id); 40 + return upgradeBootstrapMembership(ctx, agent, did, forumUri, forum.cid, membership.id, membership.roleUri); 40 41 } 41 42 42 43 return { created: false }; ··· 118 119 did: string, 119 120 forumUri: string, 120 121 forumCid: string, 121 - membershipId: bigint 122 + membershipId: bigint, 123 + roleUri: string | null 122 124 ): Promise<{ created: boolean; uri?: string; cid?: string }> { 123 125 const rkey = TID.nextStr(); 124 126 const now = new Date().toISOString(); 125 127 128 + // Look up the role so we can include it as a strongRef in the PDS record. 129 + // Without this, the firehose will re-index the event and set roleUri = null 130 + // (record.role?.role.uri ?? null), stripping the member's role. 131 + let roleRef: { uri: string; cid: string } | null = null; 132 + if (roleUri) { 133 + const parsed = parseAtUri(roleUri, ctx.logger); 134 + if (!parsed) { 135 + ctx.logger.error("roleUri failed to parse — role omitted from PDS record", { 136 + operation: "upgradeBootstrapMembership", 137 + did, 138 + roleUri, 139 + }); 140 + } else { 141 + try { 142 + const [role] = await ctx.db 143 + .select({ cid: roles.cid }) 144 + .from(roles) 145 + .where(and(eq(roles.did, parsed.did), eq(roles.rkey, parsed.rkey))) 146 + .limit(1); 147 + if (role) { 148 + roleRef = { uri: roleUri, cid: role.cid }; 149 + } 150 + } catch (error) { 151 + if (error instanceof TypeError || error instanceof ReferenceError || error instanceof SyntaxError) { 152 + throw error; 153 + } 154 + ctx.logger.error("Role lookup failed during bootstrap upgrade — omitting role from PDS record", { 155 + operation: "upgradeBootstrapMembership", 156 + did, 157 + roleUri, 158 + error: error instanceof Error ? error.message : String(error), 159 + }); 160 + } 161 + } 162 + } 163 + 164 + const record: Record<string, unknown> = { 165 + $type: "space.atbb.membership", 166 + forum: { 167 + forum: { uri: forumUri, cid: forumCid }, 168 + }, 169 + createdAt: now, 170 + joinedAt: now, 171 + }; 172 + 173 + if (roleRef) { 174 + record.role = { role: { uri: roleRef.uri, cid: roleRef.cid } }; 175 + } 176 + 126 177 const result = await agent.com.atproto.repo.putRecord({ 127 178 repo: did, 128 179 collection: "space.atbb.membership", 129 180 rkey, 130 - record: { 131 - $type: "space.atbb.membership", 132 - forum: { 133 - forum: { uri: forumUri, cid: forumCid }, 134 - }, 135 - createdAt: now, 136 - joinedAt: now, 137 - }, 181 + record, 138 182 }); 139 183 140 184 // Update the bootstrap row with PDS-backed values, preserving roleUri