Monorepo for Aesthetic.Computer aesthetic.computer
at main 591 lines 20 kB view raw
1// Authorization, 23.04.30.17.47 2 3// Authenticates a user to make sure they are logged in 4// and their local keys match the user database. 5// 🧠 (And so they can run authorized server functions.) 6 7import { connect } from "./database.mjs"; 8import * as KeyValue from "./kv.mjs"; 9import { shell } from "./shell.mjs"; 10const dev = process.env.CONTEXT === "dev"; 11 12const aestheticBaseURI = "https://aesthetic.us.auth0.com"; 13const sotceBaseURI = "https://sotce.us.auth0.com"; 14 15export async function authorize({ authorization }, tenant = "aesthetic") { 16 try { 17 const { got } = await import("got"); 18 const baseURI = tenant === "aesthetic" ? aestheticBaseURI : sotceBaseURI; 19 shell.log(`🔐 Attempting to authorize \`${tenant}\` user...`); 20 const result = ( 21 await got(`${baseURI}/userinfo`, { 22 headers: { Authorization: authorization }, 23 responseType: "json", 24 }) 25 ).body; 26 shell.log(`✅ Authorization successful for \`${tenant}\` user: ${result?.sub}`); 27 return result; 28 } catch (err) { 29 shell.error("❌ Authorization failed:", err?.message || err, err?.code); 30 return undefined; 31 } 32} 33 34export async function hasAdmin(user, tenant = "aesthetic") { 35 if (tenant === "aesthetic") { 36 const handle = await handleFor(user.sub); 37 return ( 38 user && 39 user.email_verified && 40 handle === "jeffrey" && 41 user.sub === process.env.ADMIN_SUB 42 ); 43 } else if (tenant === "sotce") { 44 const subs = process.env.SOTCE_ADMIN_SUBS?.split(","); 45 if (!subs || subs.length === 0) { 46 // Fallback: check if user email is in admin list 47 const adminEmails = ["me@jas.life", "sotce.net@gmail.com"]; 48 return user && user.email && adminEmails.includes(user.email.toLowerCase()); 49 } 50 const handle = await handleFor(user.sub, "sotce"); 51 return ( 52 user && 53 user.email_verified && 54 ((user.sub === subs[0] && handle === "jeffrey") || 55 (user.sub === subs[1] && (handle === "amelia" || handle === "sotce"))) 56 ); 57 } 58} 59 60// Get the user ID via their email, allowing a valid user ID as input also. 61export async function userIDFromEmail(email, tenant = "aesthetic", got, token) { 62 if (email.indexOf("|") !== -1 && email.indexOf("@") === -1) { 63 return email; // Simply return a user sub (the input) if no email is detected. 64 } 65 66 try { 67 if (!got) got = (await import("got")).got; 68 if (!token) token = await getAccessToken(got, tenant); 69 const baseURI = tenant === "aesthetic" ? aestheticBaseURI : sotceBaseURI; 70 71 const userResponse = await got(`${baseURI}/api/v2/users-by-email`, { 72 searchParams: { email }, 73 headers: { Authorization: `Bearer ${token}` }, 74 responseType: "json", 75 }); 76 77 const user = userResponse.body[0]; 78 const userID = user?.user_id; 79 return { userID, email_verified: user?.email_verified, tenant }; 80 } catch (error) { 81 shell.error(`Error retrieving user ID from Auth0: ${error}`); 82 return undefined; 83 } 84} 85 86// Pick between the below functions based on sub prefix. 87export async function findSisterSub(sub, options) { 88 if (sub.startsWith("sotce-")) { 89 return await aestheticSubFromSotceSub(sub); 90 } else { 91 return await sotceSubFromAestheticSub(sub, options); 92 } 93} 94 95// Get `aesthetic` user id from a sotce user, if it exists. 96// TODO: Cache this in redis to be faster? 24.09.01.00.40 97export async function aestheticSubFromSotceSub(sotceSub) { 98 const emailRes = await userEmailFromID(sotceSub, "sotce"); 99 if (emailRes?.email && emailRes?.email_verified) { 100 const idRes = await userIDFromEmail(emailRes.email, "aesthetic"); 101 if (idRes?.userID && idRes?.email_verified) { 102 return idRes.userID; 103 } 104 } 105 return undefined; 106} 107 108// Get `sotce` user id from an aesthetic user, if it exists. 109// TODO: Cache this in redis to be faster? 24.09.01.00.40 110export async function sotceSubFromAestheticSub(aestheticSub, options) { 111 const emailRes = await userEmailFromID(aestheticSub, "aesthetic"); 112 if (emailRes?.email && emailRes?.email_verified) { 113 const idRes = await userIDFromEmail(emailRes.email, "sotce"); 114 if (idRes?.userID && idRes?.email_verified) { 115 return (options?.prefixed ? "sotce-" : "") + idRes.userID; 116 } 117 } 118 return undefined; 119} 120 121// Get the user email via their user ID. 122export async function userEmailFromID(sub, tenant = "aesthetic", got, token) { 123 try { 124 if (!got) got = (await import("got")).got; 125 if (sub.startsWith("sotce-")) tenant = "sotce"; // Switch tenant based on prefix. 126 if (tenant === "sotce") sub = sub.replace("sotce-", ""); 127 const baseURI = tenant === "aesthetic" ? aestheticBaseURI : sotceBaseURI; 128 if (!token) token = await getAccessToken(got, tenant); 129 130 const userResponse = await got(`${baseURI}/api/v2/users/${sub}`, { 131 headers: { Authorization: `Bearer ${token}` }, 132 responseType: "json", 133 }); 134 135 const user = userResponse.body; 136 const email = user?.email; 137 return { email, email_verified: user?.email_verified }; 138 } catch (error) { 139 shell.error(`Error retrieving user email from Auth0 (${tenant}): ${error}`); 140 return undefined; 141 } 142} 143 144// Takes in a user ID (sub) and returns the user's @handle (preferred) or email. 145export async function getHandleOrEmail(sub) { 146 try { 147 // Attempt to get the user's handle. 148 const handle = await handleFor(sub); 149 if (handle) return "@" + handle; 150 151 // If no handle is found, fetch the user's email from Auth0. 152 const { got } = await import("got"); 153 const token = await getAccessToken(got); // Get access token for auth0. 154 const userResponse = await got( 155 `https://aesthetic.us.auth0.com/api/v2/users/${encodeURIComponent(sub)}`, 156 { headers: { Authorization: `Bearer ${token}` }, responseType: "json" }, 157 ); 158 159 return userResponse.body.email; 160 } catch (error) { 161 shell.error(`Error retrieving user handle or email: ${error}`); 162 return undefined; 163 } 164} 165 166// Connects to the Redis cache or MongoDB database to obtain a user's handle 167// from their ID (across tenants). 168export async function handleFor(id, tenant = "aesthetic") { 169 // const time = performance.now(); 170 171 if (id === "all") { 172 // 📖 Get an aggregate list of all handles. 173 const database = await connect(); 174 const collection = database.db.collection("@handles"); 175 const randomHandles = await collection 176 .aggregate([{ $sample: { size: 100 } }, { $project: { handle: 1 } }]) 177 .toArray(); 178 await database.disconnect(); 179 return randomHandles.map((doc) => "@" + doc.handle); 180 } else { 181 // 🙆 Get a specific user handle. 182 if (tenant === "sotce" && !id.startsWith("sotce-")) id = "sotce-" + id; 183 shell.log("Retrieving handle for...", id); 184 185 await KeyValue.connect(); 186 const cachedHandle = await KeyValue.get("userIDs", id); 187 188 if (cachedHandle) { 189 // await KeyValue.disconnect(); 190 return cachedHandle; 191 } 192 193 const database = await connect(); 194 const collection = database.db.collection("@handles"); 195 let existingUser = await collection.findOne({ _id: id }); 196 197 // If no handle was found then try again on the sister tenant. 198 if (!existingUser) { 199 const sisterSub = await findSisterSub(id, { prefixed: true }); 200 201 if (sisterSub) { 202 let foundHandle = await KeyValue.get("userIDs", sisterSub); 203 if (foundHandle) { 204 // Make sure to cache the original id for this handle. 205 await KeyValue.set("userIDs", id, foundHandle); 206 await KeyValue.disconnect(); 207 await database.disconnect(); 208 return foundHandle; 209 } else { 210 // Then in the database. 211 existingUser = await collection.findOne({ _id: sisterSub }); 212 id = sisterSub; 213 } 214 } 215 } 216 217 // Cache the handle in redis for quick look up. 218 if (existingUser?.handle) { 219 await KeyValue.set("userIDs", existingUser._id, existingUser.handle); 220 } 221 222 await database.disconnect(); 223 await KeyValue.disconnect(); 224 225 // console.log("Time taken...", performance.now() - time); 226 return existingUser?.handle; 227 } 228} 229 230// Connects to the MongoDB database to obtain a user ID from a handle. 231// Handle should not be prefixed with "@". 232 233// ❤️‍🔥 234// TODO: This could return a "sotce-" prefixed id which 235// would be incompatible with aesthetic computer if 236// an account does not exist / this function may need a 237// `tenant` parameter. 238export async function userIDFromHandle( 239 handle, 240 database, 241 keepKV, 242 tenant = "aesthetic", 243) { 244 // Read from redis, otherwise check the database, and store in redis after. 245 let userID; 246 // const time = performance.now(); 247 await KeyValue.connect(); 248 const cachedUserID = await KeyValue.get("@handles", handle); 249 250 if (tenant === "aesthetic" && cachedUserID?.startsWith("sotce-")) { 251 return await aestheticSubFromSotceSub(cachedUserID); 252 } 253 254 if (!cachedUserID) { 255 // Look in database. 256 // if (dev) console.log("Handle: Looking in database..."); 257 const keepOpen = database; // Keep the db connection if database is defined. 258 // if (dev) console.log("Handle: Connecting...", time); 259 if (!database) database = await connect(); 260 const collection = database.db.collection("@handles"); 261 const user = await collection.findOne({ handle }); 262 userID = user?._id; 263 if (!keepOpen) database.disconnect(); 264 if (tenant === "aesthetic" && userID?.startsWith("sotce-")) { 265 return await aestheticSubFromSotceSub(userID); 266 } 267 } else { 268 // if (dev) console.log("Handle: Found in redis..."); 269 userID = cachedUserID; 270 } 271 272 // Cache userID in redis... 273 if (!cachedUserID && userID) { 274 if (dev) shell.log("Caching primary handle key in redis...", handle); 275 await KeyValue.set("@handles", handle, userID); 276 if (!keepKV) await KeyValue.disconnect(); 277 } 278 279 // console.log("Time taken...", performance.now() - time); 280 return userID; 281} 282 283// Connects to MongoDB to find a user's handle from their permahandle (code). 284// Permahandle format: ac25xxxxx (9 characters) 285// Returns: { handle, sub } or undefined if not found 286export async function handleFromPermahandle(code, database) { 287 if (!code || typeof code !== "string") return undefined; 288 289 // Permahandles are exactly 9 chars and start with "ac" 290 if (code.length !== 9 || !code.startsWith("ac")) return undefined; 291 292 // Check redis cache first 293 await KeyValue.connect(); 294 const cachedHandle = await KeyValue.get("permahandles", code); 295 if (cachedHandle) { 296 await KeyValue.disconnect(); 297 return JSON.parse(cachedHandle); 298 } 299 300 // Look in database 301 const keepOpen = database; 302 if (!database) database = await connect(); 303 304 const usersCollection = database.db.collection("users"); 305 const user = await usersCollection.findOne({ code }); 306 307 if (!user) { 308 if (!keepOpen) await database.disconnect(); 309 await KeyValue.disconnect(); 310 return undefined; 311 } 312 313 // Get the handle from @handles collection using the user's _id 314 const handlesCollection = database.db.collection("@handles"); 315 const handleDoc = await handlesCollection.findOne({ _id: user._id }); 316 317 if (!keepOpen) await database.disconnect(); 318 319 const result = handleDoc ? { handle: handleDoc.handle, sub: user._id } : undefined; 320 321 // Cache the result in redis 322 if (result) { 323 await KeyValue.set("permahandles", code, JSON.stringify(result)); 324 } 325 await KeyValue.disconnect(); 326 327 return result; 328} 329 330// Assume prefixed handle. 331// ⚠️ TODO: Make sure we are knowing what id we want from what network... 24.08.31.01.21 332export async function userIDFromHandleOrEmail(handleOrEmail, database, tenant) { 333 if (!handleOrEmail) return; 334 if (handleOrEmail.startsWith("@") || handleOrEmail.indexOf("|") === -1) { 335 const sub = await userIDFromHandle( 336 handleOrEmail.startsWith("@") ? handleOrEmail.slice(1) : handleOrEmail, 337 database, 338 undefined, 339 tenant, 340 ); 341 return sub; 342 } else { 343 return await userIDFromEmail(handleOrEmail, tenant); // Assume email. 344 } 345} 346 347// Sets the user's email and triggers a re-verification email. 348export async function setEmailAndReverify( 349 id, 350 email, 351 name, 352 tenant = "aesthetic", 353) { 354 try { 355 const { got } = await import("got"); 356 const baseURI = tenant === "aesthetic" ? aestheticBaseURI : sotceBaseURI; 357 358 const token = await getAccessToken(got, tenant); 359 360 shell.log( 361 "👮 📧 Setting and re-verifying email for:", 362 email, 363 "on:", 364 tenant, 365 "via:", 366 id, 367 ); 368 369 // 1. Update the user's email and ('name' which is equivalent to email 370 // in auth0 but generally unused by Aesthetic Computer.) 371 let updateEmailResponse; 372 try { 373 updateEmailResponse = await got( 374 `${baseURI}/api/v2/users/${encodeURIComponent(id)}`, 375 { 376 method: "PATCH", 377 headers: { 378 Authorization: `Bearer ${token}`, 379 "Content-Type": "application/json", 380 }, 381 json: { name, email, email_verified: false }, 382 responseType: "json", 383 }, 384 ); 385 } catch (err) { 386 shell.error("🔴 Error:", err); 387 } 388 389 if (!updateEmailResponse.body) { 390 throw new Error("Failed to update user email"); 391 } 392 393 // 2. Trigger the verification email 394 const verificationResponse = await got( 395 `${baseURI}/api/v2/jobs/verification-email`, 396 { 397 method: "POST", 398 headers: { 399 Authorization: `Bearer ${token}`, 400 "Content-Type": "application/json", 401 }, 402 json: { user_id: id }, 403 responseType: "json", 404 }, 405 ); 406 407 if (!verificationResponse.body) { 408 throw new Error("Failed to send verification email"); 409 } 410 411 return { 412 success: true, 413 message: "Email updated and verification email sent successfully!", 414 }; 415 } catch (error) { 416 shell.error(`Error setting email and sending verification: ${error}`); 417 return { 418 success: false, 419 message: error.message, 420 }; 421 } 422} 423 424// Deletes a user from auth0. 425export async function deleteUser(userId, tenant = "aesthetic") { 426 try { 427 const { got } = await import("got"); 428 const token = await getAccessToken(got, tenant); 429 const baseURI = tenant === "aesthetic" ? aestheticBaseURI : sotceBaseURI; 430 431 await got(`${baseURI}/api/v2/users/${encodeURIComponent(userId)}`, { 432 method: "DELETE", 433 headers: { Authorization: `Bearer ${token}` }, 434 }); 435 436 shell.log( 437 `❌ User with ID ${userId} deleted from Auth0. Tenant: ${tenant}`, 438 ); 439 return { success: true, message: "User deleted successfully from Auth0." }; 440 } catch (error) { 441 shell.error(`⚠️ Error deleting user from Auth0: ${error}`); 442 return { success: false, message: error.message }; 443 } 444} 445 446// Queries the total number of signed-up users by including totals in the response. 447export async function querySignups(tenant = "aesthetic") { 448 try { 449 const { got } = await import("got"); 450 const baseURI = tenant === "aesthetic" ? aestheticBaseURI : sotceBaseURI; 451 const token = await getAccessToken(got, tenant); 452 453 const response = await got(`${baseURI}/api/v2/users`, { 454 searchParams: { 455 page: 0, 456 per_page: 1, // Fetch minimal data to reduce overhead. 457 include_totals: true, // Include the total user count. 458 }, 459 headers: { Authorization: `Bearer ${token}` }, 460 responseType: "json", 461 }); 462 463 return response.body.total || 0; // Return the total user count. 464 } catch (error) { 465 shell.error(`Error querying signups from Auth0: ${error}`); 466 return undefined; 467 } 468} 469 470// Retrieves daily stats for logins and signups from the Auth0 stats endpoint. 471// 472export async function activeUsers(tenant = "aesthetic") { 473 try { 474 const { got } = await import("got"); 475 const baseURI = tenant === "aesthetic" ? aestheticBaseURI : sotceBaseURI; 476 const token = await getAccessToken(got, tenant); 477 478 const response = await got(`${baseURI}/api/v2/stats/active-users`, { 479 headers: { Authorization: `Bearer ${token}` }, 480 responseType: "json", 481 }); 482 483 return response.body; // Returns an array of daily stats. 484 } catch (error) { 485 shell.error(`Error fetching daily stats from Auth0: ${error}`); 486 return undefined; 487 } 488} 489 490// Retrieves the total count of verified users in the specified tenant. 491// export async function verifiedUsers(tenant = "aesthetic") { 492// try { 493// const { got } = await import("got"); 494// const baseURI = tenant === "aesthetic" ? aestheticBaseURI : sotceBaseURI; 495// const token = await getAccessToken(got, tenant); 496// 497// let page = 0; 498// let perPage = 100; 499// let verifiedCount = 0; 500// 501// while (true) { 502// const response = await got(`${baseURI}/api/v2/users`, { 503// searchParams: { 504// q: "email_verified:true", 505// search_engine: "v3", 506// page, 507// per_page: perPage, 508// }, 509// headers: { Authorization: `Bearer ${token}` }, 510// responseType: "json", 511// }); 512// 513// const users = response.body; 514// verifiedCount += users.length; 515// 516// 517// if (users.length < perPage) break; // No more pages to fetch. 518// page++; 519// console.log("Verified users so far:", verifiedCount, "Page:", page); 520// } 521// 522// shell.log(`✅ Verified users count for tenant ${tenant}: ${verifiedCount}`); 523// return verifiedCount; 524// } catch (error) { 525// shell.error( 526// `Error retrieving verified users count from ${tenant}: ${error}`, 527// ); 528// return undefined; 529// } 530// } 531 532// Helper function to introduce a delay (in milliseconds). 533const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); 534 535// � Token cache for M2M tokens (added 2026.01.28 for boot speed) 536// Auth0 M2M tokens are valid for 24 hours, we cache for 23 hours to be safe 537const tokenCache = { 538 aesthetic: { token: null, expiry: 0 }, 539 sotce: { token: null, expiry: 0 } 540}; 541const TOKEN_CACHE_MS = 23 * 60 * 60 * 1000; // 23 hours 542 543// 📚 Library (Useful functions used throughout the file.) 544// Obtain an auth0 access token for our M2M API. 545async function getAccessToken(got, tenant = "aesthetic") { 546 // 🚀 Check in-memory token cache first 547 const cached = tokenCache[tenant]; 548 if (cached && cached.token && Date.now() < cached.expiry) { 549 shell.log(`🚀 Using cached M2M token for ${tenant} (expires in ${Math.round((cached.expiry - Date.now()) / 1000 / 60)} min)`); 550 return cached.token; 551 } 552 553 let baseURI, client_id, client_secret; 554 if (tenant === "aesthetic") { 555 baseURI = aestheticBaseURI; 556 client_id = process.env.AUTH0_M2M_CLIENT_ID; 557 client_secret = process.env.AUTH0_M2M_SECRET; 558 } else { 559 // assume tenant is `sotce`. 560 baseURI = sotceBaseURI; 561 client_id = process.env.SOTCE_AUTH0_M2M_CLIENT_ID; 562 client_secret = process.env.SOTCE_AUTH0_M2M_SECRET; 563 } 564 565 shell.log(`🔑 Getting fresh access token for ${tenant}`); 566 shell.log(` Client ID: ${client_id?.substring(0, 10)}...`); 567 shell.log(` Secret length: ${client_secret?.length || 0}`); 568 569 const tokenResponse = await got(`${baseURI}/oauth/token`, { 570 method: "POST", 571 headers: { "Content-Type": "application/json" }, 572 json: { 573 client_id, 574 client_secret, 575 audience: `${baseURI}/api/v2/`, 576 grant_type: "client_credentials", // Use "client_credentials" for M2M 577 }, 578 responseType: "json", 579 }); 580 581 const token = tokenResponse.body.access_token; 582 583 // 🚀 Cache the token 584 tokenCache[tenant] = { 585 token, 586 expiry: Date.now() + TOKEN_CACHE_MS 587 }; 588 shell.log(`💾 Cached M2M token for ${tenant}`); 589 590 return token; 591}