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
at root/atb-56-theme-caching-layer 195 lines 6.0 kB view raw
1import type { AppContext } from "./app-context.js"; 2import type { Agent } from "@atproto/api"; 3import { memberships, forums, roles } from "@atbb/db"; 4import { eq, and, asc } from "drizzle-orm"; 5import { TID } from "@atproto/common-web"; 6import { parseAtUri } from "./at-uri.js"; 7 8export async function createMembershipForUser( 9 ctx: AppContext, 10 agent: Agent, 11 did: string 12): Promise<{ created: boolean; uri?: string; cid?: string }> { 13 // Fetch forum metadata (need URI and CID for strongRef) 14 const [forum] = await ctx.db 15 .select() 16 .from(forums) 17 .where(and(eq(forums.rkey, "self"), eq(forums.did, ctx.config.forumDid))) 18 .limit(1); 19 20 if (!forum) { 21 throw new Error("Forum not found"); 22 } 23 24 const forumUri = `at://${forum.did}/space.atbb.forum.forum/${forum.rkey}`; 25 26 // Check if membership already exists 27 const existing = await ctx.db 28 .select() 29 .from(memberships) 30 .where(and(eq(memberships.did, did), eq(memberships.forumUri, forumUri))) 31 .limit(1); 32 33 if (existing.length > 0) { 34 const [membership] = existing; 35 36 // Bootstrap memberships (created by `atbb init`) have no backing PDS 37 // record. Upgrade them by writing a real record to the user's PDS and 38 // updating the DB row with the actual rkey/cid. 39 if (membership.cid === "bootstrap") { 40 return upgradeBootstrapMembership(ctx, agent, did, forumUri, forum.cid, membership.id, membership.roleUri); 41 } 42 43 return { created: false }; 44 } 45 46 // Look up the default "Member" role to assign on first login. 47 // Wrapped in try-catch so a transient DB error does not prevent membership creation. 48 let defaultRoleRef: { uri: string; cid: string } | null = null; 49 try { 50 const [memberRole] = await ctx.db 51 .select({ rkey: roles.rkey, cid: roles.cid }) 52 .from(roles) 53 .where(and(eq(roles.did, ctx.config.forumDid), eq(roles.name, "Member"))) 54 .orderBy(asc(roles.indexedAt)) 55 .limit(1); 56 57 if (memberRole) { 58 defaultRoleRef = { 59 uri: `at://${ctx.config.forumDid}/space.atbb.forum.role/${memberRole.rkey}`, 60 cid: memberRole.cid, 61 }; 62 } else { 63 ctx.logger.error("Member role not found in DB — creating membership without role. User will have no permissions. Run seedDefaultRoles to fix.", { 64 operation: "createMembershipForUser", 65 did, 66 forumDid: ctx.config.forumDid, 67 }); 68 } 69 } catch (error) { 70 if (error instanceof TypeError || error instanceof ReferenceError || error instanceof SyntaxError) { 71 throw error; 72 } 73 ctx.logger.warn("Member role lookup failed — creating membership without role", { 74 operation: "createMembershipForUser", 75 did, 76 error: error instanceof Error ? error.message : String(error), 77 }); 78 } 79 80 return writeMembershipRecord(agent, did, forumUri, forum.cid, defaultRoleRef); 81} 82 83async function writeMembershipRecord( 84 agent: Agent, 85 did: string, 86 forumUri: string, 87 forumCid: string, 88 defaultRoleRef: { uri: string; cid: string } | null = null 89): Promise<{ created: boolean; uri?: string; cid?: string }> { 90 const rkey = TID.nextStr(); 91 const now = new Date().toISOString(); 92 93 const record: Record<string, unknown> = { 94 $type: "space.atbb.membership", 95 forum: { 96 forum: { uri: forumUri, cid: forumCid }, 97 }, 98 createdAt: now, 99 joinedAt: now, 100 }; 101 102 if (defaultRoleRef) { 103 record.role = { role: { uri: defaultRoleRef.uri, cid: defaultRoleRef.cid } }; 104 } 105 106 const result = await agent.com.atproto.repo.putRecord({ 107 repo: did, 108 collection: "space.atbb.membership", 109 rkey, 110 record, 111 }); 112 113 return { created: true, uri: result.data.uri, cid: result.data.cid }; 114} 115 116async function upgradeBootstrapMembership( 117 ctx: AppContext, 118 agent: Agent, 119 did: string, 120 forumUri: string, 121 forumCid: string, 122 membershipId: bigint, 123 roleUri: string | null 124): Promise<{ created: boolean; uri?: string; cid?: string }> { 125 const rkey = TID.nextStr(); 126 const now = new Date().toISOString(); 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 177 const result = await agent.com.atproto.repo.putRecord({ 178 repo: did, 179 collection: "space.atbb.membership", 180 rkey, 181 record, 182 }); 183 184 // Update the bootstrap row with PDS-backed values, preserving roleUri 185 await ctx.db 186 .update(memberships) 187 .set({ 188 rkey, 189 cid: result.data.cid, 190 indexedAt: new Date(), 191 }) 192 .where(eq(memberships.id, membershipId)); 193 194 return { created: true, uri: result.data.uri, cid: result.data.cid }; 195}