Monorepo for Aesthetic.Computer
aesthetic.computer
1import { AtpAgent } from "@atproto/api";
2import { shell } from "./shell.mjs";
3import { userEmailFromID } from "./authorization.mjs";
4import crypto from "crypto";
5
6const DEFAULT_PDS_URL = "https://at.aesthetic.computer";
7
8const FALLBACK_ERROR_MESSAGES = [
9 "Reserved handle",
10 "Invalid handle",
11 "must be a valid handle",
12 "Handle too short",
13 "Handle already taken",
14];
15
16function getPdsUrl() {
17 return process.env.PDS_URL || DEFAULT_PDS_URL;
18}
19
20function getAdminPassword() {
21 return process.env.PDS_ADMIN_PASSWORD;
22}
23
24export function sanitizeHandleForPds(handle) {
25 if (!handle) return handle;
26 return handle.replace(/[._]/g, "-");
27}
28
29function isRetryableHandleError(error) {
30 if (!error?.message) return false;
31 return FALLBACK_ERROR_MESSAGES.some((msg) => error.message.includes(msg));
32}
33
34export async function updateAtprotoHandle(database, sub, handle) {
35 const users = database.db.collection("users");
36 const userRecord = await users.findOne({ _id: sub });
37
38 if (!userRecord) {
39 shell.log("🪪 No user record for ATProto sync:", sub);
40 return { updated: false, reason: "user-missing" };
41 }
42
43 const atproto = userRecord.atproto;
44 if (!atproto?.password) {
45 shell.log("🪪 No ATProto credentials stored for:", sub);
46 return { updated: false, reason: "missing-credentials" };
47 }
48
49 const sanitizedHandle = sanitizeHandleForPds(handle);
50 const desiredHandle = `${sanitizedHandle}.at.aesthetic.computer`;
51 const currentHandle = atproto.handle;
52 const identifier = currentHandle || atproto.did;
53
54 if (!identifier) {
55 shell.log("🪪 No ATProto identifier available for:", sub);
56 return { updated: false, reason: "missing-identifier" };
57 }
58
59 if (desiredHandle === currentHandle) {
60 return {
61 updated: false,
62 reason: "already-synced",
63 handle: desiredHandle,
64 sanitized: sanitizedHandle,
65 };
66 }
67
68 const agent = new AtpAgent({ service: getPdsUrl() });
69
70 try {
71 await agent.login({ identifier, password: atproto.password });
72 } catch (error) {
73 shell.log("🪪 Failed to login to PDS for handle sync:", error);
74 return { updated: false, reason: "login-failed", error: error.message };
75 }
76
77 const performUpdate = async (targetHandle) => {
78 await agent.com.atproto.identity.updateHandle({ handle: targetHandle });
79 await users.updateOne(
80 { _id: sub },
81 {
82 $set: {
83 "atproto.handle": targetHandle,
84 "atproto.syncedAt": new Date().toISOString(),
85 },
86 },
87 );
88 shell.log("🪪 Updated PDS handle to:", targetHandle);
89 return targetHandle;
90 };
91
92 try {
93 const finalHandle = await performUpdate(desiredHandle);
94 return {
95 updated: true,
96 handle: finalHandle,
97 sanitized: sanitizedHandle,
98 };
99 } catch (error) {
100 const fallbackHandle = userRecord.code
101 ? `${userRecord.code}.at.aesthetic.computer`
102 : null;
103
104 if (fallbackHandle && isRetryableHandleError(error)) {
105 shell.log(
106 "🪪 Falling back to user code handle for PDS:",
107 fallbackHandle,
108 "Reason:",
109 error.message,
110 );
111
112 try {
113 const finalHandle = await performUpdate(fallbackHandle);
114 return {
115 updated: true,
116 handle: finalHandle,
117 sanitized: fallbackHandle.replace(/\.at\.aesthetic\.computer$/, ""),
118 fallback: true,
119 error: error.message,
120 };
121 } catch (fallbackError) {
122 shell.log("🪪 Fallback PDS handle update failed:", fallbackError);
123 return {
124 updated: false,
125 reason: "fallback-failed",
126 error: fallbackError.message,
127 };
128 }
129 }
130
131 shell.log("🪪 PDS handle update failed:", error);
132 return { updated: false, reason: "update-failed", error: error.message };
133 }
134}
135
136export async function deleteAtprotoAccount(database, sub) {
137 const adminPassword = getAdminPassword();
138 if (!adminPassword) {
139 shell.log("🪦 Skipping PDS deletion, missing admin password env.");
140 return { deleted: false, reason: "missing-admin-password" };
141 }
142
143 const users = database.db.collection("users");
144 const userRecord = await users.findOne({ _id: sub });
145
146 if (!userRecord?.atproto?.did) {
147 shell.log("🪦 No ATProto DID on record for:", sub);
148 return { deleted: false, reason: "missing-did" };
149 }
150
151 const did = userRecord.atproto.did;
152 const auth = Buffer.from(`admin:${adminPassword}`).toString("base64");
153
154 try {
155 const response = await fetch(
156 `${getPdsUrl()}/xrpc/com.atproto.admin.deleteAccount`,
157 {
158 method: "POST",
159 headers: {
160 "Content-Type": "application/json",
161 Authorization: `Basic ${auth}`,
162 },
163 body: JSON.stringify({ did }),
164 },
165 );
166
167 if (!response.ok) {
168 const errorText = await response.text();
169 throw new Error(`status ${response.status}: ${errorText}`);
170 }
171
172 await users.updateOne({ _id: sub }, { $unset: { atproto: "" } });
173 shell.log("🪦 Deleted ATProto account for:", sub);
174 return { deleted: true };
175 } catch (error) {
176 shell.log("🪦 Failed to delete ATProto account:", error.message);
177 return { deleted: false, reason: "request-failed", error: error.message };
178 }
179}
180
181async function generateInviteCode() {
182 const adminPassword = getAdminPassword();
183 if (!adminPassword) {
184 throw new Error("PDS_ADMIN_PASSWORD environment variable is required");
185 }
186
187 const auth = Buffer.from(`admin:${adminPassword}`).toString("base64");
188
189 const response = await fetch(
190 `${getPdsUrl()}/xrpc/com.atproto.server.createInviteCode`,
191 {
192 method: "POST",
193 headers: {
194 "Content-Type": "application/json",
195 Authorization: `Basic ${auth}`,
196 },
197 body: JSON.stringify({ useCount: 1 }),
198 },
199 );
200
201 if (!response.ok) {
202 throw new Error(`Failed to create invite code: ${response.statusText}`);
203 }
204
205 const data = await response.json();
206 return data.code;
207}
208
209function generatePassword() {
210 const chars =
211 "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789!@#$%&*";
212 let password = "";
213 for (let i = 0; i < 32; i++) {
214 const randomIndex = crypto.randomInt(0, chars.length);
215 password += chars[randomIndex];
216 }
217 return password;
218}
219
220export async function createAtprotoAccount(database, sub, email) {
221 try {
222 shell.log(`🦋 Creating ATProto account for: ${sub}`);
223
224 const users = database.db.collection("users");
225 const handles = database.db.collection("@handles");
226
227 // Check if account already exists
228 const existingUser = await users.findOne({ _id: sub });
229 if (existingUser?.atproto?.did) {
230 shell.log(`🦋 ATProto account already exists for: ${sub}`);
231 return {
232 created: false,
233 reason: "already-exists",
234 did: existingUser.atproto.did,
235 handle: existingUser.atproto.handle,
236 };
237 }
238
239 // Get email if not provided
240 if (!email) {
241 const tenant = sub.startsWith("sotce-") ? "sotce" : "aesthetic";
242 const result = await userEmailFromID(sub, tenant);
243 if (!result?.email) {
244 throw new Error(`No email found for user: ${sub}`);
245 }
246 email = result.email;
247 }
248
249 shell.log(`📧 Email: ${email}`);
250
251 // Generate password
252 const password = generatePassword();
253
254 // Determine handle from AC handle or user code
255 const handleRecord = await handles.findOne({ _id: sub });
256 let pdsHandle;
257 if (handleRecord?.handle) {
258 const sanitizedHandle = sanitizeHandleForPds(handleRecord.handle);
259 pdsHandle = `${sanitizedHandle}.at.aesthetic.computer`;
260 shell.log(`🏷️ Using AC handle: ${pdsHandle}`);
261 } else if (existingUser?.code) {
262 pdsHandle = `${existingUser.code}.at.aesthetic.computer`;
263 shell.log(`🏷️ Using user code: ${pdsHandle}`);
264 } else {
265 throw new Error(`No handle or user code found for: ${sub}`);
266 }
267
268 // Generate invite code
269 shell.log(`🎫 Generating invite code...`);
270 const inviteCode = await generateInviteCode();
271
272 // Create account on PDS
273 const agent = new AtpAgent({ service: getPdsUrl() });
274 const tenant = sub.startsWith("sotce-") ? "sotce" : "aesthetic";
275
276 let accountCreated = false;
277 let finalDid, finalHandle;
278 let currentEmail = email;
279 let attempts = 0;
280
281 while (!accountCreated && attempts < 3) {
282 attempts++;
283
284 try {
285 const response = await agent.createAccount({
286 email: currentEmail,
287 handle: pdsHandle,
288 password,
289 inviteCode,
290 });
291
292 finalDid = response.data.did;
293 finalHandle = response.data.handle;
294 accountCreated = true;
295 } catch (error) {
296 // Handle duplicate email by appending tenant
297 if (
298 error.message.includes("Email already taken") &&
299 attempts === 1 &&
300 tenant === "sotce"
301 ) {
302 shell.log(`⚠️ Email "${currentEmail}" already taken, adding +sotce`);
303 const [localPart, domain] = currentEmail.split("@");
304 currentEmail = `${localPart}+sotce@${domain}`;
305 }
306 // Try fallback to user code if handle fails
307 else if (
308 isRetryableHandleError(error) &&
309 attempts <= 2 &&
310 existingUser?.code
311 ) {
312 shell.log(`⚠️ Handle failed, falling back to user code`);
313 pdsHandle = `${existingUser.code}.at.aesthetic.computer`;
314 } else {
315 throw error;
316 }
317 }
318 }
319
320 if (!accountCreated) {
321 throw new Error("Failed to create account after all attempts");
322 }
323
324 shell.log(`✅ Created ATProto account: ${finalDid}`);
325
326 // Store atproto data in MongoDB
327 const atprotoData = {
328 did: finalDid,
329 handle: finalHandle,
330 password: password,
331 created: new Date().toISOString(),
332 };
333
334 await users.updateOne(
335 { _id: sub },
336 { $set: { atproto: atprotoData } },
337 { upsert: true },
338 );
339
340 return {
341 created: true,
342 did: finalDid,
343 handle: finalHandle,
344 email: currentEmail,
345 };
346 } catch (error) {
347 shell.log(`❌ Failed to create ATProto account: ${error.message}`);
348 return {
349 created: false,
350 reason: "creation-failed",
351 error: error.message,
352 };
353 }
354}