Monorepo for Aesthetic.Computer aesthetic.computer
at main 267 lines 8.7 kB view raw
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}