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