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 main 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}