Monorepo for Aesthetic.Computer
aesthetic.computer
1#!/usr/bin/env node
2// create-account.mjs
3// Creates an ATProto PDS account for an AC user and stores their DID
4
5import { AtpAgent } from '@atproto/api';
6import { connect } from '../../../system/backend/database.mjs';
7import { userEmailFromID } from '../../../system/backend/authorization.mjs';
8import { shell } from '../../../system/backend/shell.mjs';
9import crypto from 'crypto';
10
11const PDS_URL = process.env.PDS_URL || 'https://at.aesthetic.computer';
12const PDS_ADMIN_PASSWORD = process.env.PDS_ADMIN_PASSWORD;
13
14if (!PDS_ADMIN_PASSWORD) {
15 console.error('❌ PDS_ADMIN_PASSWORD environment variable is required');
16 process.exit(1);
17}
18
19/**
20 * Generate an invite code via admin API
21 * @returns {Promise<string>}
22 */
23async function generateInviteCode() {
24 const auth = Buffer.from(`admin:${PDS_ADMIN_PASSWORD}`).toString('base64');
25
26 const response = await fetch(`${PDS_URL}/xrpc/com.atproto.server.createInviteCode`, {
27 method: 'POST',
28 headers: {
29 'Content-Type': 'application/json',
30 'Authorization': `Basic ${auth}`,
31 },
32 body: JSON.stringify({ useCount: 1 }),
33 });
34
35 if (!response.ok) {
36 throw new Error(`Failed to create invite code: ${response.statusText}`);
37 }
38
39 const data = await response.json();
40 return data.code;
41}
42
43/**
44 * Generate a secure random password
45 * @returns {Promise<string>}
46 */
47async function generatePassword() {
48 const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789!@#$%&*';
49 let password = '';
50 for (let i = 0; i < 32; i++) {
51 const randomIndex = crypto.randomInt(0, chars.length);
52 password += chars[randomIndex];
53 }
54 return password;
55}
56
57/**
58 * Creates an ATProto account for a user
59 * @param {string} sub - Auth0 user ID (e.g., "auth0|12345")
60 * @param {object} options - Optional parameters
61 * @param {string} options.email - Override email (if not in Auth0)
62 * @param {string} options.password - Custom password (otherwise auto-generated)
63 * @param {string} options.inviteCode - PDS invite code (optional for v0.4)
64 * @returns {Promise<object>} - { did, handle, email, password, success, error }
65 */
66export async function createAccount(sub, options = {}) {
67 try {
68 shell.log(`🦋 Creating ATProto account for: ${sub}`);
69
70 // 1. Get user data from database
71 const database = await connect();
72 const users = database.db.collection('users');
73 const handles = database.db.collection('@handles');
74
75 const userBefore = await users.findOne({ _id: sub });
76 const handleRecord = await handles.findOne({ _id: sub });
77
78 shell.log(`\n📋 User record BEFORE:`);
79 shell.log(JSON.stringify(userBefore, null, 2));
80
81 // 2. Get user email from Auth0
82 let email = options.email;
83 let tenant = 'aesthetic';
84
85 if (!email) {
86 shell.log(`🔍 Fetching email from Auth0 for: ${sub}`);
87
88 // Detect tenant from sub prefix
89 tenant = sub.startsWith('sotce-') ? 'sotce' : 'aesthetic';
90 shell.log(` Tenant: ${tenant}`);
91
92 const result = await userEmailFromID(sub, tenant);
93 shell.log(` Result:`, result);
94
95 if (!result?.email) {
96 throw new Error(`No email found for user: ${sub} (tenant: ${tenant})`);
97 }
98 email = result.email;
99 } else {
100 // If email is provided directly, still detect tenant for potential modification
101 tenant = sub.startsWith('sotce-') ? 'sotce' : 'aesthetic';
102 }
103
104 shell.log(`📧 Email: ${email}`);
105
106 // 3. Generate a secure random password (or use provided one)
107 const password = options.password || await generatePassword();
108
109 // 4. Determine handle: use AC handle if exists, otherwise use user code
110 // Format: {handle}.at.aesthetic.computer or {code}.at.aesthetic.computer
111 // Note: Sanitize handle for ATProto compatibility (dots and underscores → dashes)
112 let pdsHandle;
113 if (handleRecord?.handle) {
114 const sanitizedHandle = handleRecord.handle.replace(/[._]/g, '-');
115 pdsHandle = `${sanitizedHandle}.at.aesthetic.computer`;
116 if (sanitizedHandle !== handleRecord.handle) {
117 shell.log(`🏷️ Handle: ${pdsHandle} (from AC handle @${handleRecord.handle}, sanitized)`);
118 } else {
119 shell.log(`🏷️ Handle: ${pdsHandle} (from AC handle @${handleRecord.handle})`);
120 }
121 } else {
122 pdsHandle = `${userBefore.code}.at.aesthetic.computer`;
123 shell.log(`🏷️ Handle: ${pdsHandle} (from user code)`);
124 }
125
126 // 5. Generate invite code
127 let inviteCode = options.inviteCode;
128 if (!inviteCode) {
129 shell.log(`🎫 Generating invite code...`);
130 inviteCode = await generateInviteCode();
131 shell.log(` Code: ${inviteCode}`);
132 }
133
134 // 6. Create account on PDS
135 const agent = new AtpAgent({ service: PDS_URL });
136
137 let accountCreated = false;
138 let finalDid, finalHandle;
139 let attempts = 0;
140 let currentEmail = email;
141
142 while (!accountCreated && attempts < 3) {
143 attempts++;
144
145 try {
146 const response = await agent.createAccount({
147 email: currentEmail,
148 handle: pdsHandle,
149 password,
150 inviteCode,
151 });
152
153 finalDid = response.data.did;
154 finalHandle = response.data.handle;
155 accountCreated = true;
156
157 } catch (error) {
158 // Handle duplicate email by appending tenant
159 if (error.message.includes('Email already taken') &&
160 attempts === 1 &&
161 tenant === 'sotce') {
162
163 shell.log(`⚠️ Email "${currentEmail}" already taken`);
164 shell.log(`🔄 Trying with tenant suffix...`);
165
166 // Append +sotce to email (before @)
167 const [localPart, domain] = currentEmail.split('@');
168 currentEmail = `${localPart}+sotce@${domain}`;
169 shell.log(` New email: ${currentEmail}`);
170
171 }
172 // If handle is reserved/invalid/too short/taken and we haven't tried user code yet
173 else if ((error.message.includes('Reserved handle') ||
174 error.message.includes('Invalid handle') ||
175 error.message.includes('must be a valid handle') ||
176 error.message.includes('Handle too short') ||
177 error.message.includes('Handle already taken')) &&
178 attempts <= 2 &&
179 userBefore?.code) {
180
181 shell.log(`⚠️ Handle "${pdsHandle}" failed: ${error.message}`);
182 shell.log(`🔄 Falling back to user code...`);
183
184 // Try again with user code as handle
185 pdsHandle = `${userBefore.code}.at.aesthetic.computer`;
186 shell.log(` New handle: ${pdsHandle}`);
187
188 } else {
189 throw error;
190 }
191 }
192 }
193
194 if (!accountCreated) {
195 throw new Error('Failed to create account after all attempts');
196 }
197
198 shell.log(`\n✅ Account Created!`);
199 shell.log(` DID: ${finalDid}`);
200 shell.log(` Handle: ${finalHandle}`);
201 if (currentEmail !== email) {
202 shell.log(` Email: ${currentEmail} (modified from ${email})`);
203 }
204
205 // 6. Store atproto data in MongoDB (nested structure)
206 const atprotoData = {
207 did: finalDid,
208 handle: finalHandle,
209 password: password,
210 created: new Date().toISOString(),
211 };
212
213 await users.updateOne(
214 { _id: sub },
215 { $set: { atproto: atprotoData } },
216 { upsert: true }
217 );
218
219 shell.log(`\n💾 Stored in MongoDB`);
220 shell.log(` Collection: users`);
221 shell.log(` Document: ${sub}`);
222
223 // 7. Get updated record to show
224 const userAfter = await users.findOne({ _id: sub });
225 shell.log(`\n📋 User record AFTER:`);
226 shell.log(JSON.stringify(userAfter, null, 2));
227
228 await database.disconnect();
229
230 return {
231 success: true,
232 did: finalDid,
233 handle: finalHandle,
234 email: currentEmail, // Return the email that was actually used
235 originalEmail: currentEmail !== email ? email : undefined,
236 password,
237 };
238
239 } catch (error) {
240 shell.error('❌ Failed to create account:', error.message);
241 return {
242 success: false,
243 error: error.message,
244 };
245 }
246}
247
248// CLI usage
249if (import.meta.url === `file://${process.argv[1]}`) {
250 const sub = process.argv[2];
251
252 if (!sub) {
253 console.error('Usage: node create-account.mjs <auth0_sub> [invite_code]');
254 console.error('Example: node create-account.mjs "auth0|63effeeb2a7d55f8098d62f9"');
255 process.exit(1);
256 }
257
258 const inviteCode = process.argv[3];
259 const result = await createAccount(sub, { inviteCode });
260
261 if (!result.success) {
262 console.error('\n❌ Error:', result.error);
263 process.exit(1);
264 }
265
266 process.exit(0);
267}