Monorepo for Aesthetic.Computer
aesthetic.computer
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}