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 { AtpAgent } from "@atproto/api";
2import type { Database } from "@atbb/db";
3import { roles, rolePermissions } from "@atbb/db";
4import { eq } from "drizzle-orm";
5
6interface DefaultRole {
7 name: string;
8 description: string;
9 permissions: string[];
10 priority: number;
11}
12
13export const DEFAULT_ROLES: DefaultRole[] = [
14 {
15 name: "Owner",
16 description: "Forum owner with full control",
17 permissions: ["*"],
18 priority: 0,
19 },
20 {
21 name: "Admin",
22 description: "Can manage forum structure and users",
23 permissions: [
24 "space.atbb.permission.manageCategories",
25 "space.atbb.permission.manageRoles",
26 "space.atbb.permission.manageMembers",
27 "space.atbb.permission.moderatePosts",
28 "space.atbb.permission.banUsers",
29 "space.atbb.permission.pinTopics",
30 "space.atbb.permission.lockTopics",
31 "space.atbb.permission.createTopics",
32 "space.atbb.permission.createPosts",
33 ],
34 priority: 10,
35 },
36 {
37 name: "Moderator",
38 description: "Can moderate content and users",
39 permissions: [
40 "space.atbb.permission.moderatePosts",
41 "space.atbb.permission.banUsers",
42 "space.atbb.permission.pinTopics",
43 "space.atbb.permission.lockTopics",
44 "space.atbb.permission.createTopics",
45 "space.atbb.permission.createPosts",
46 ],
47 priority: 20,
48 },
49 {
50 name: "Member",
51 description: "Regular forum member",
52 permissions: [
53 "space.atbb.permission.createTopics",
54 "space.atbb.permission.createPosts",
55 ],
56 priority: 30,
57 },
58];
59
60export interface SeededRole {
61 name: string;
62 uri: string;
63 cid: string;
64}
65
66interface SeedRolesResult {
67 created: number;
68 skipped: number;
69 roles: SeededRole[];
70}
71
72/**
73 * Seed default roles to Forum DID's PDS and database.
74 * Idempotent: checks for existing roles by name before creating.
75 * Returns role data (URI + CID) for downstream steps.
76 */
77export async function seedDefaultRoles(
78 db: Database,
79 agent: AtpAgent,
80 forumDid: string
81): Promise<SeedRolesResult> {
82 let created = 0;
83 let skipped = 0;
84 const seededRoles: SeededRole[] = [];
85
86 for (const defaultRole of DEFAULT_ROLES) {
87 // Check if role already exists by name
88 const [existingRole] = await db
89 .select()
90 .from(roles)
91 .where(eq(roles.name, defaultRole.name))
92 .limit(1);
93
94 if (existingRole) {
95 skipped++;
96 seededRoles.push({
97 name: existingRole.name,
98 uri: `at://${existingRole.did}/space.atbb.forum.role/${existingRole.rkey}`,
99 cid: existingRole.cid,
100 });
101 continue;
102 }
103
104 // Create role record on Forum DID's PDS
105 const response = await agent.com.atproto.repo.createRecord({
106 repo: forumDid,
107 collection: "space.atbb.forum.role",
108 record: {
109 $type: "space.atbb.forum.role",
110 name: defaultRole.name,
111 description: defaultRole.description,
112 permissions: defaultRole.permissions,
113 priority: defaultRole.priority,
114 createdAt: new Date().toISOString(),
115 },
116 });
117
118 // Extract rkey from the returned URI (at://did/collection/rkey)
119 const rkey = response.data.uri.split("/").pop()!;
120
121 // Insert into database so downstream steps can query it
122 const [insertedRole] = await db.insert(roles).values({
123 did: forumDid,
124 rkey,
125 cid: response.data.cid,
126 name: defaultRole.name,
127 description: defaultRole.description,
128 priority: defaultRole.priority,
129 createdAt: new Date(),
130 indexedAt: new Date(),
131 }).returning({ id: roles.id });
132
133 if (defaultRole.permissions.length > 0) {
134 await db.insert(rolePermissions).values(
135 defaultRole.permissions.map((permission) => ({
136 roleId: insertedRole.id,
137 permission,
138 }))
139 );
140 }
141
142 seededRoles.push({
143 name: defaultRole.name,
144 uri: response.data.uri,
145 cid: response.data.cid,
146 });
147
148 created++;
149 }
150
151 return { created, skipped, roles: seededRoles };
152}