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