import type { AppContext } from "./app-context.js"; import type { Agent } from "@atproto/api"; import { memberships, forums, roles } from "@atbb/db"; import { eq, and, asc } from "drizzle-orm"; import { TID } from "@atproto/common-web"; import { parseAtUri } from "./at-uri.js"; export async function createMembershipForUser( ctx: AppContext, agent: Agent, did: string ): Promise<{ created: boolean; uri?: string; cid?: string }> { // Fetch forum metadata (need URI and CID for strongRef) const [forum] = await ctx.db .select() .from(forums) .where(and(eq(forums.rkey, "self"), eq(forums.did, ctx.config.forumDid))) .limit(1); if (!forum) { throw new Error("Forum not found"); } const forumUri = `at://${forum.did}/space.atbb.forum.forum/${forum.rkey}`; // Check if membership already exists const existing = await ctx.db .select() .from(memberships) .where(and(eq(memberships.did, did), eq(memberships.forumUri, forumUri))) .limit(1); if (existing.length > 0) { const [membership] = existing; // Bootstrap memberships (created by `atbb init`) have no backing PDS // record. Upgrade them by writing a real record to the user's PDS and // updating the DB row with the actual rkey/cid. if (membership.cid === "bootstrap") { return upgradeBootstrapMembership(ctx, agent, did, forumUri, forum.cid, membership.id, membership.roleUri); } return { created: false }; } // Look up the default "Member" role to assign on first login. // Wrapped in try-catch so a transient DB error does not prevent membership creation. let defaultRoleRef: { uri: string; cid: string } | null = null; try { const [memberRole] = await ctx.db .select({ rkey: roles.rkey, cid: roles.cid }) .from(roles) .where(and(eq(roles.did, ctx.config.forumDid), eq(roles.name, "Member"))) .orderBy(asc(roles.indexedAt)) .limit(1); if (memberRole) { defaultRoleRef = { uri: `at://${ctx.config.forumDid}/space.atbb.forum.role/${memberRole.rkey}`, cid: memberRole.cid, }; } else { ctx.logger.error("Member role not found in DB — creating membership without role. User will have no permissions. Run seedDefaultRoles to fix.", { operation: "createMembershipForUser", did, forumDid: ctx.config.forumDid, }); } } catch (error) { if (error instanceof TypeError || error instanceof ReferenceError || error instanceof SyntaxError) { throw error; } ctx.logger.warn("Member role lookup failed — creating membership without role", { operation: "createMembershipForUser", did, error: error instanceof Error ? error.message : String(error), }); } return writeMembershipRecord(agent, did, forumUri, forum.cid, defaultRoleRef); } async function writeMembershipRecord( agent: Agent, did: string, forumUri: string, forumCid: string, defaultRoleRef: { uri: string; cid: string } | null = null ): Promise<{ created: boolean; uri?: string; cid?: string }> { const rkey = TID.nextStr(); const now = new Date().toISOString(); const record: Record = { $type: "space.atbb.membership", forum: { forum: { uri: forumUri, cid: forumCid }, }, createdAt: now, joinedAt: now, }; if (defaultRoleRef) { record.role = { role: { uri: defaultRoleRef.uri, cid: defaultRoleRef.cid } }; } const result = await agent.com.atproto.repo.putRecord({ repo: did, collection: "space.atbb.membership", rkey, record, }); return { created: true, uri: result.data.uri, cid: result.data.cid }; } async function upgradeBootstrapMembership( ctx: AppContext, agent: Agent, did: string, forumUri: string, forumCid: string, membershipId: bigint, roleUri: string | null ): Promise<{ created: boolean; uri?: string; cid?: string }> { const rkey = TID.nextStr(); const now = new Date().toISOString(); // Look up the role so we can include it as a strongRef in the PDS record. // Without this, the firehose will re-index the event and set roleUri = null // (record.role?.role.uri ?? null), stripping the member's role. let roleRef: { uri: string; cid: string } | null = null; if (roleUri) { const parsed = parseAtUri(roleUri, ctx.logger); if (!parsed) { ctx.logger.error("roleUri failed to parse — role omitted from PDS record", { operation: "upgradeBootstrapMembership", did, roleUri, }); } else { try { const [role] = await ctx.db .select({ cid: roles.cid }) .from(roles) .where(and(eq(roles.did, parsed.did), eq(roles.rkey, parsed.rkey))) .limit(1); if (role) { roleRef = { uri: roleUri, cid: role.cid }; } } catch (error) { if (error instanceof TypeError || error instanceof ReferenceError || error instanceof SyntaxError) { throw error; } ctx.logger.error("Role lookup failed during bootstrap upgrade — omitting role from PDS record", { operation: "upgradeBootstrapMembership", did, roleUri, error: error instanceof Error ? error.message : String(error), }); } } } const record: Record = { $type: "space.atbb.membership", forum: { forum: { uri: forumUri, cid: forumCid }, }, createdAt: now, joinedAt: now, }; if (roleRef) { record.role = { role: { uri: roleRef.uri, cid: roleRef.cid } }; } const result = await agent.com.atproto.repo.putRecord({ repo: did, collection: "space.atbb.membership", rkey, record, }); // Update the bootstrap row with PDS-backed values, preserving roleUri await ctx.db .update(memberships) .set({ rkey, cid: result.data.cid, indexedAt: new Date(), }) .where(eq(memberships.id, membershipId)); return { created: true, uri: result.data.uri, cid: result.data.cid }; }