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(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

Malpercio 4d0b11c3 12cdce52

+207 -10
+159
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 + await createMembershipForUser(ctx, mockAgent, testDid); 532 + 533 + // The PDS record must include the role strongRef so the firehose 534 + // preserves the roleUri when it re-indexes the upgrade event. 535 + expect(mockAgent.com.atproto.repo.putRecord).toHaveBeenCalledWith( 536 + expect.objectContaining({ 537 + record: expect.objectContaining({ 538 + role: { 539 + role: { 540 + uri: ownerRoleUri, 541 + cid: ownerRoleCid, 542 + }, 543 + }, 544 + }), 545 + }) 546 + ); 547 + }); 548 + 549 + it("omits role from PDS record when upgrading bootstrap membership without a roleUri", async () => { 550 + const testDid = `did:plc:test-bootstrap-norole-${Date.now()}`; 551 + const forumUri = `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`; 552 + 553 + await ctx.db.insert(users).values({ 554 + did: testDid, 555 + handle: "bootstrap.norole", 556 + indexedAt: new Date(), 557 + }); 558 + 559 + // Bootstrap membership with no roleUri 560 + await ctx.db.insert(memberships).values({ 561 + did: testDid, 562 + rkey: "bootstrap", 563 + cid: "bootstrap", 564 + forumUri, 565 + createdAt: new Date(), 566 + indexedAt: new Date(), 567 + }); 568 + 569 + const mockAgent = { 570 + com: { 571 + atproto: { 572 + repo: { 573 + putRecord: vi.fn().mockResolvedValue({ 574 + data: { 575 + uri: `at://${testDid}/space.atbb.membership/tiddef`, 576 + cid: "bafynoroledef", 577 + }, 578 + }), 579 + }, 580 + }, 581 + }, 582 + } as any; 583 + 584 + await createMembershipForUser(ctx, mockAgent, testDid); 585 + 586 + const callArg = mockAgent.com.atproto.repo.putRecord.mock.calls[0][0]; 587 + expect(callArg.record.role).toBeUndefined(); 588 + }); 589 + 590 + it("upgrades bootstrap membership without role when roleUri references a role not in DB", async () => { 591 + const testDid = `did:plc:test-bootstrap-missingrole-${Date.now()}`; 592 + const forumUri = `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`; 593 + // A roleUri that has no matching row in the roles table 594 + const danglingRoleUri = `at://${ctx.config.forumDid}/space.atbb.forum.role/nonexistent`; 595 + 596 + await ctx.db.insert(users).values({ 597 + did: testDid, 598 + handle: "bootstrap.missingrole", 599 + indexedAt: new Date(), 600 + }); 601 + 602 + await ctx.db.insert(memberships).values({ 603 + did: testDid, 604 + rkey: "bootstrap", 605 + cid: "bootstrap", 606 + forumUri, 607 + roleUri: danglingRoleUri, 608 + createdAt: new Date(), 609 + indexedAt: new Date(), 610 + }); 611 + 612 + const mockAgent = { 613 + com: { 614 + atproto: { 615 + repo: { 616 + putRecord: vi.fn().mockResolvedValue({ 617 + data: { 618 + uri: `at://${testDid}/space.atbb.membership/tidghi`, 619 + cid: "bafymissingghi", 620 + }, 621 + }), 622 + }, 623 + }, 624 + }, 625 + } as any; 626 + 627 + // Upgrade should still succeed even if role lookup finds nothing 628 + const result = await createMembershipForUser(ctx, mockAgent, testDid); 629 + expect(result.created).toBe(true); 630 + 631 + const callArg = mockAgent.com.atproto.repo.putRecord.mock.calls[0][0]; 632 + expect(callArg.record.role).toBeUndefined(); 633 + }); 475 634 });
+48 -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); 134 + if (parsed) { 135 + try { 136 + const [role] = await ctx.db 137 + .select({ cid: roles.cid }) 138 + .from(roles) 139 + .where(and(eq(roles.did, parsed.did), eq(roles.rkey, parsed.rkey))) 140 + .limit(1); 141 + if (role) { 142 + roleRef = { uri: roleUri, cid: role.cid }; 143 + } 144 + } catch (error) { 145 + if (error instanceof TypeError || error instanceof ReferenceError || error instanceof SyntaxError) { 146 + throw error; 147 + } 148 + ctx.logger.warn("Role lookup failed during bootstrap upgrade — omitting role from PDS record", { 149 + operation: "upgradeBootstrapMembership", 150 + did, 151 + roleUri, 152 + error: error instanceof Error ? error.message : String(error), 153 + }); 154 + } 155 + } 156 + } 157 + 158 + const record: Record<string, unknown> = { 159 + $type: "space.atbb.membership", 160 + forum: { 161 + forum: { uri: forumUri, cid: forumCid }, 162 + }, 163 + createdAt: now, 164 + joinedAt: now, 165 + }; 166 + 167 + if (roleRef) { 168 + record.role = { role: { uri: roleRef.uri, cid: roleRef.cid } }; 169 + } 170 + 126 171 const result = await agent.com.atproto.repo.putRecord({ 127 172 repo: did, 128 173 collection: "space.atbb.membership", 129 174 rkey, 130 - record: { 131 - $type: "space.atbb.membership", 132 - forum: { 133 - forum: { uri: forumUri, cid: forumCid }, 134 - }, 135 - createdAt: now, 136 - joinedAt: now, 137 - }, 175 + record, 138 176 }); 139 177 140 178 // Update the bootstrap row with PDS-backed values, preserving roleUri