// Sotce Net, 24.06.13.06.38 // A paid diary network by Sotce & Aesthetic Computer. /* #region 🟒 TODO + Now - [] Add custom meta descriptions for specific paths like '/chat'. - [] Add sound (to chat) and other buttons. - [] Pictures in pages. - [] Custom pictures or preset clip art from @amelia? - [] Pictures in line with text or like an overlay scrapbook style? - [] Add notifications to chat. + Later - [] Add "snippet" endpoint to get @amelia's latest page so it can be rendered on the login screen. *** πŸ“§ Email Notifications for Pages *** - [] email new pages to each subscriber, and include the contents? - [] make an 'eblast' endpoint for this - [] add the checkbox under the main page for whether to receive them or not *** 'Think' *** - [] Add meditation timer via AC sound engine, and sounds to buttons. *** πŸ”Š Sounds *** - [] Soft sine clicks and beeps. *** πŸ“Ÿ Page Feed *** - [] Add multi-user page feed. *** πŸ›‚ Page Controls *** - [] Automatic Dark Theme - [c] Patreon linkage. *** Accessibility *** - [] Accurate Tab Index in Modes/ different screens. - [] Login - [] Cookie Menu - [] Editor - [] Cleaner Ctrl +/- zoom logic / layout fixes. - [] Relational scrolling. - [] Better routing / slash urls for the editor and cookie-menu. - [] Shouldn't resolve if no access. *** User Info Rate Limiting *** - [] Try to reduce the authorize() call rate limiting on ac. + Done - [x] Test on mobile. - [x] Deploy production chat. - [x] Delete any chat messages owned by a user when their account gets deleted. - [x] Test guest chat interface with logged in (unsubscribed) user. - [x] Display chatter count in the chat interface. - [x] Add an 'editor' route and an unsaved changes confirmation? - [x] Add proper url route support for `chat` on both the client title and server, in splash screen and in main ui. - [x] Finish bottom bar design. - [x] Show character limit / enforce on the text input. - [x] Limit scrollback to 100 chats. - [x] Handle chat disconnection / reconnection UI. - [x] Fix gate reload. / Add 'gate' route. - [x] Send a new message to chat and auto-scroll. - [x] Shouldn't be able to send blank messages. - [x] `chat` needs a special URL route that automatically opens to it. - [x] Test chat interface with subscribed user. - [x] Hide chat until pink spinner dot disappears. - [c] Add exporting of PNG images per pages. - [x] Test and enable printing on iOS. #endregion */ // ♻️ Environment const AUTH0_CLIENT_ID_SPA = "3SvAbUDFLIFZCc1lV7e4fAAGKWXwl2B0"; const AUTH0_DOMAIN = "https://hi.sotce.net"; const dev = process.env.NETLIFY_DEV; // πŸ’³ Payment import { SOTCE_STRIPE_API_PRIV_KEY, SOTCE_STRIPE_API_PUB_KEY, SOTCE_STRIPE_API_TEST_PRIV_KEY, SOTCE_STRIPE_API_TEST_PUB_KEY, SOTCE_STRIPE_ENDPOINT_DEV_SECRET, SOTCE_STRIPE_ENDPOINT_SECRET, priceId, productId, prodProductId, } from "../../backend/sotce-net-constants.mjs"; import { defaultTemplateStringProcessor as html } from "../../public/aesthetic.computer/lib/helpers.mjs"; import { respond } from "../../backend/http.mjs"; import { connect, ObjectId } from "../../backend/database.mjs"; import { shell } from "../../backend/shell.mjs"; import { authorize, deleteUser, hasAdmin, handleFor, getHandleOrEmail, userIDFromEmail, } from "../../backend/authorization.mjs"; import * as KeyValue from "../../backend/kv.mjs"; import Stripe from "stripe"; const dateOptions = { weekday: "long", year: "numeric", month: "long", day: "numeric", }; export const handler = async (event, context) => { // console.log("Event:", event, "Context:", context, "Path:", event.path); // πŸš™ Router const method = event.httpMethod.toLowerCase(); let path = event.path; if (path.startsWith("/api/sotce-net")) path = path.replace("/api/sotce-net", "/").replace("//", "/"); if (path.startsWith("/sotce-net")) path = path.replace("/sotce-net", "/").replace("//", "/"); if (path.startsWith("/sotce.net")) path = path.replace("/sotce.net", "/").replace("//", "/"); const key = dev ? SOTCE_STRIPE_API_TEST_PRIV_KEY : SOTCE_STRIPE_API_PRIV_KEY; const assetPath = dev ? "/assets/sotce-net/" : "https://assets.aesthetic.computer/sotce-net/"; const baseHost = event.headers["host"] || "localhost"; const HOST = dev ? `https://${baseHost}/sotce-net` : `https://${baseHost}`; // πŸ‘‘ Admin emails that get subscriber access without Stripe const ADMIN_EMAILS = ["me@jas.life", "sotce.net@gmail.com"]; // Check to see if a user sub is subscribed. async function subscribed(user) { // πŸ‘‘ Admin bypass - grant subscriber access without checking Stripe if (user.email && ADMIN_EMAILS.includes(user.email.toLowerCase())) { shell.log("πŸ‘‘ Admin bypass granted for:", user.email); return { status: "active", current_period_end: Math.floor(Date.now() / 1000) + 365 * 24 * 60 * 60, // 1 year from now admin_bypass: true, }; } // TODO: 🟠 Add redis caching in here. try { // 🩷 First look to see if we have a subscribed entry in the redis cache. shell.log( "Checking subscription for:", user.sub, user.email, performance.now(), ); await KeyValue.connect(); const cachedSubscription = await KeyValue.get( "sotce-subscribed", user.sub, ); if (cachedSubscription) { const parsed = JSON.parse(cachedSubscription); // Check to see if the time on parses has passed, and if it has // then invalidate the cache and continue to checking it with Stripe. const currentTime = Math.floor(Date.now() / 1000); const subscriptionEndTime = parsed.current_period_end; // Assume unix timestamp. if (subscriptionEndTime > currentTime) { shell.log("πŸ“° Subscription active from cache!", performance.now()); await KeyValue.disconnect(); return parsed; } else { shell.log( "βŒ› Subscription expired, invalidating cache and checking...", ); await KeyValue.del("sotce-subscribed", user.sub); } } shell.log("πŸ” Checking Stripe for subscription..."); // 🩷 Then query it from Stripe, const stripe = Stripe(key); // Fetch customer by user ID (sub) from subscription metadata field. const customers = await stripe.customers.search({ query: "metadata['sub']:'" + user.sub + "'", }); if (!customers.data.length) { shell.log("❌ No customer found in Stripe"); await KeyValue.disconnect(); return { subscribed: false }; } const customer = customers.data[0]; shell.log("βœ… Customer found:", customer.id); // Fetch subscriptions for the customer const subscriptions = await stripe.subscriptions.list({ customer: customer.id, status: "active", // Only find the first active subscription. limit: 5, }); shell.log("πŸ“Š Found subscriptions:", subscriptions.data.length); const subscription = subscriptions.data.find((sub) => sub.items.data.some((item) => item.price.product === productId), ); if (subscription) { shell.log("βœ… Active subscription found:", subscription.status); // 🩷 And serialize it into redis. await KeyValue.set( "sotce-subscribed", user.sub, JSON.stringify({ status: subscription.status, current_period_end: subscription.current_period_end, }), ); await KeyValue.disconnect(); return subscription; } else { shell.log("❌ No active subscription found"); await KeyValue.disconnect(); return { subscribed: false }; } } catch (err) { shell.error("Error fetching subscription status:", err); return null; } } // Get the count of all active subscriptions for the given productId // TODO: Put this behind a redis cache... 24.10.14.01.23 async function getActiveSubscriptionCount(productId) { try { const stripe = Stripe(key); // Fetch all subscriptions with the active status let hasMore = true; let totalSubscriptions = 0; let startingAfter = undefined; while (hasMore) { const subscriptions = await stripe.subscriptions.list({ status: "active", limit: 100, // Maximum allowed per request starting_after: startingAfter, }); // Filter subscriptions by the productId const matchingSubscriptions = subscriptions.data.filter((sub) => sub.items.data.some((item) => item.price.product === productId), ); totalSubscriptions += matchingSubscriptions.length; // Check if more pages of subscriptions exist hasMore = subscriptions.has_more; if (hasMore) { startingAfter = subscriptions.data[subscriptions.data.length - 1].id; } } return totalSubscriptions; } catch (err) { shell.error("Error fetching subscription count:", err); } } // Get the cumulative count of all subscriptions ever created for the given // productId (any status β€” active, canceled, etc.). // TODO: Put this behind a redis cache... 24.10.14.01.23 async function getCumulativeSubscriptionCount(productId) { try { const stripe = Stripe(key); // Fetch all subscriptions regardless of status. let hasMore = true; let totalSubscriptions = 0; let startingAfter = undefined; while (hasMore) { const subscriptions = await stripe.subscriptions.list({ status: "all", limit: 100, // Maximum allowed per request starting_after: startingAfter, }); // Filter subscriptions by the productId const matchingSubscriptions = subscriptions.data.filter((sub) => sub.items.data.some((item) => item.price.product === productId), ); totalSubscriptions += matchingSubscriptions.length; // Check if more pages of subscriptions exist hasMore = subscriptions.has_more; if (hasMore) { startingAfter = subscriptions.data[subscriptions.data.length - 1].id; } } return totalSubscriptions; } catch (err) { shell.error("Error fetching cumulative subscription count:", err); } } const MAX_LINES = 19; // 🏠 Home, Chat, Page Routes if ( (path === "/" || path === "/chat" || path === "/gate" || path === "/write" || path === "/ask" || path === "/respond" || path.match(/^\/\d+$/) || path.match(/^\/q\d+$/)) && method === "get" ) { const miniBreakpoint = 245; let title = "Sotce Net"; if (path !== "/") { title = path.replace("/", "") + " Β· " + title; } const body = html` ${title} ${dev ? reloadScript : ""} ${!dev ? analyticsScript : ""}
`; return respond(200, body, { "Content-Type": "text/html; charset=utf-8" }); } else if (path === "/subscribers" && method === "get") { const subscribers = await getCumulativeSubscriptionCount(productId); if (subscribers !== undefined && subscribers !== null) { return respond(200, { subscribers }); } else { return respond(500, { message: "Could not get subscriber count." }); } } else if (path === "/subscribe" && method === "post") { try { const stripe = Stripe(key); const redirectPath = event.headers.origin === "https://sotce.net" ? "" : "sotce-net"; shell.log( "πŸ—ΊοΈ Origin:", event.headers.origin, "redirectPath:", redirectPath, ); const { email, sub } = JSON.parse(event.body); // Search for the customer by the metadata field 'sub' const customers = await stripe.customers.search({ query: `metadata['sub']:'${sub}'`, }); let customer; if (customers.data.length > 0) { // Customer found, optionally update metadata // customer = await stripe.customers.update(customers.data[0].id, { // metadata: { sub }, // }); customer = customers.data[0]; // πŸ›‘οΈ Check if the customer already has an active sotce-net subscription // This prevents duplicate subscriptions from being created (bug fix 2025.12.03) const existingSubscriptions = await stripe.subscriptions.list({ customer: customer.id, status: "active", limit: 10, }); const hasActiveSotceNetSub = existingSubscriptions.data.some((sub) => sub.items.data.some((item) => item.price.product === productId), ); if (hasActiveSotceNetSub) { shell.log( "⚠️ Customer already has an active sotce-net subscription:", customer.id, ); return respond(400, { message: "You already have an active subscription.", alreadySubscribed: true, }); } } else { // Customer doesn't exist, create a new one customer = await stripe.customers.create({ email: email, metadata: { sub }, }); } const session = await stripe.checkout.sessions.create({ payment_method_types: ["card"], mode: "subscription", line_items: [{ price: priceId, quantity: 1 }], customer: customer.id, // Attach the existing or newly created customer success_url: `${event.headers.origin}/${redirectPath}?notice=success`, cancel_url: `${event.headers.origin}/${redirectPath}?notice=cancel`, // metadata: { sub }, }); return respond(200, { id: session.id }); } catch (error) { shell.log("⚠️", error); return respond(500, { message: `Error: ${error.message}` }); } } else if (path === "/subscribed" && method === "post") { // First validate that the user has an active session via auth0. // TODO: To properly handle rate limits on the `/userinfo` endpoint // I should probably be sending the user info up in the POST // request here and then the `authorize` function should // be rewritten to use a different endpoint which has // a more apt rate limit? // Also it's possible that finding the subscription information here // via Stripe should / could be cached in redis for faster // retrieval later? 24.08.24.19.22 const body = JSON.parse(event.body); // Make sure we can parse the body. const retrieve = body.retrieve || "subscription"; const user = await authorize(event.headers, "sotce"); if (!user) { return respond(401, { message: "Unauthorized." }); } const subscription = await subscribed(user); if (subscription === null) { return respond(500, { error: "Failed to fetch subscription status" }); } if (subscription?.subscribed === false || !subscription) { return respond(200, { subscribed: false }); } if (subscription?.status === "active") { // What did we need the subscription for? const out = { subscribed: true }; // Include both pages and the subscription state if retrieving 'everything'. if (retrieve === "everything") { shell.log("🫐 Retrieving pages...", performance.now()); // πŸ“† Subscription status. (until, renews) out.until = subscription.cancel_at_period_end ? new Date(subscription.cancel_at * 1000).toLocaleDateString( "en-US", dateOptions, ) : "recurring"; if (out.until === "recurring") { out.renews = new Date(subscription.current_period_end * 1000).toLocaleDateString("en-US", dateOptions); } // πŸ‘Έ Administrator status. // Skip hasAdmin call if using admin_bypass (already verified admin) const isAdmin = subscription.admin_bypass ? true : await hasAdmin(user, "sotce"); if (isAdmin) out.admin = isAdmin; shell.log("πŸ”΄ Admin:", isAdmin); // πŸ““ Recent Pages (with pagination support) const database = await connect(); const pages = database.db.collection("sotce-pages"); // Pagination parameters const requestedPage = body.pageNumber; // Specific page number (1-indexed) const offset = body.offset || 0; // For loading older pages const metaOnly = body.metaOnly; // Only return page count and last modified // Always get total count and last modified for cache validation // Exclude Q&A pages (isQA: true) β€” those are shown as question cards from sotce-asks const pageFilter = { state: "published", isQA: { $ne: true } }; const totalCount = await pages.countDocuments(pageFilter); // When loadAll is set, return the full history so scrollback reaches the very // first page; otherwise fall back to a bounded window. const limit = body.loadAll ? Math.max(totalCount, 1) // $limit must be positive : Math.min(body.limit || 5, 500); // Default to 5, max 500 pages per request shell.log("πŸ“„ Pagination: requestedPage=", requestedPage, "limit=", limit, "offset=", offset, "loadAll=", !!body.loadAll); const lastModifiedDoc = await pages.findOne( pageFilter, { sort: { updatedAt: -1 }, projection: { updatedAt: 1, when: 1 } } ); out.totalPages = totalCount; out.lastModified = lastModifiedDoc?.updatedAt || lastModifiedDoc?.when || null; if (metaOnly) { await database.disconnect(); return respond(200, out); } let retrievedPages; if (requestedPage !== undefined) { // Fetch a specific page by its index (1-indexed) retrievedPages = await pages .aggregate([ { $match: pageFilter }, { $sort: { when: 1 } }, { $skip: requestedPage - 1 }, { $limit: 1 }, ]) .toArray(); out.pageIndex = requestedPage; } else { // Fetch latest pages (from the end), with optional offset for loading older retrievedPages = await pages .aggregate([ { $match: pageFilter }, { $sort: { when: -1 } }, // Newest first { $skip: offset }, { $limit: limit }, ]) .toArray(); // Reverse to maintain chronological order retrievedPages.reverse(); out.hasMore = offset + limit < totalCount; } // Add a 'handle' field to each page record. const subsToHandles = {}; // Cache handles on this go around. for (const [index, page] of retrievedPages.entries()) { let handle = subsToHandles[page.user]; if (!handle) { handle = await handleFor(page.user, "sotce"); if (handle) subsToHandles[page.user] = handle; } page.handle = handle; } out.pages = retrievedPages; // ❓ Also fetch answered questions to mix into the feed const asks = database.db.collection("sotce-asks"); const answeredQuestions = await asks .find({ state: "answered" }) .sort({ answeredAt: -1 }) .limit(body.loadAll ? 0 : 50) // 0 = no limit (full history) when loading all .project({ draftAnswer: 0, draftStartedAt: 0, draftLastEditedAt: 0 }) .toArray(); // Add handles to questions for (const q of answeredQuestions) { let handle = subsToHandles[q.user]; if (!handle) { handle = await handleFor(q.user, "sotce"); if (handle) subsToHandles[q.user] = handle; } q.handle = handle; q.type = "question"; // Mark as question for client-side rendering } out.questions = answeredQuestions; out.totalQuestions = await asks.countDocuments({ state: "answered" }); await database.disconnect(); // TODO: πŸ‘€ 'Handled' pages filtered by user.. shell.log("🫐 Retrieved:", retrievedPages.length, "pages,", answeredQuestions.length, "questions", performance.now()); } return respond(200, out); } else { return respond(200, { subscribed: false }); } } else if (path === "/cancel" && method === "post") { const user = await authorize(event.headers, "sotce"); if (!user) return respond(401, { message: "Unauthorized." }); shell.log("User authorized. Cancelling subscription..."); const cancelResult = await cancelSubscription(user, key); return respond(cancelResult.status, cancelResult.body); } else if (path === "/write-a-page" && method === "post") { // πŸͺ§ write-a-page - Submission endpoint. // TODO: Make this path RESTful with alternate methods to represent the resource. 24.10.05.23.33 const user = await authorize(event.headers, "sotce"); const isAdmin = await hasAdmin(user, "sotce"); if (!user || !isAdmin) return respond(401, { message: "Unauthorized." }); // TODO: 🟠 Add support for creating a draft. const body = JSON.parse(event.body); shell.log("πŸͺ§ Page to post:", body); if (body.draft === "retrieve-or-create") { const database = await connect(); const pages = database.db.collection("sotce-pages"); await pages.createIndex({ user: 1, state: 1 }); // Ensure 'user' and 'state' index. // Try to get the last page from this user.sub where 'state' is 'draft'. let page = await pages.findOne( { user: user.sub, state: "draft" }, { sort: { when: -1 } }, ); // If that page does not exist, then insert a new one with the 'draft' state. if (!page) { const insertion = await pages.insertOne({ user: user.sub, words: "", when: new Date(), state: "draft", }); page = await pages.findOne({ _id: insertion.insertedId }); } await database.disconnect(); return respond(200, { page }); } else if (body.draft === "keep") { const database = await connect(); const pages = database.db.collection("sotce-pages"); // Try to get the last page from this user.sub where 'state' is 'draft'. let page = await pages.findOne( { user: user.sub, state: "draft" }, { sort: { when: -1 } }, ); // If the page exists, update the draft with the new content. if (page) { await pages.updateOne( { _id: page._id }, { $set: { words: body.words } }, ); page = await pages.findOne({ _id: page._id }); } // If no draft exists, create a new draft with the content. else { const insertion = await pages.insertOne({ user: user.sub, words: body.words || "", // Use provided content or default to an empty string when: new Date(), state: "draft", }); page = await pages.findOne({ _id: insertion.insertedId }); } await database.disconnect(); return respond(200, { page }); } else if (body.draft === "crumple") { // πŸͺ“️ Delete (crumple) the current draft. const database = await connect(); const pages = database.db.collection("sotce-pages"); // See if there is a page id attached, otherwise look for the most // recent draft... let page; if (body._id) { page = await pages.findOne({ _id: new ObjectId(body._id) }); } else { // Try to get the last page from this user.sub where 'state' is 'draft'. page = await pages.findOne( { user: user.sub, state: "draft" }, { sort: { when: -1 } }, ); } // If the page exists, update its state to 'crumpled' or delete it if body.words is empty/undefined. if (page) { if ( page.state === "draft" && (!body.words || body.words.length === 0) ) { shell.log("❌ Permanently deleting page:", page._id); await pages.deleteOne({ _id: page._id }); } else { const updates = { state: "crumpled" }; if (body.words) updates.words = body.words; await pages.updateOne({ _id: page._id }, { $set: updates }); } await database.disconnect(); return respond(200, { message: "Draft crumpled successfully." }); } else { await database.disconnect(); return respond(500, { message: "No page found to crumple." }); } // Actually just set the state to 'crumpled' here. } else if (body.draft) { return respond(500, { message: "Invalid drafting option." }); } // πŸ’‘ TODO: Eventually create a 'books' abstraction so users can have // multiple books that capture groupings of pages. 24.09.13.01.41 const words = body.words; if (words) { const database = await connect(); const pages = database.db.collection("sotce-pages"); // Find the existing draft for the user. let page = await pages.findOne( { user: user.sub, state: "draft" }, { sort: { when: -1 } }, ); // If a draft exists, update it with the new words and set the state to 'published'. if (page) { await pages.updateOne( { _id: page._id }, { $set: { words, state: "published" } }, ); } else { // If no draft exists, insert a new page. (Edge case where the // date would be updated in a new page on a double save / overwrite) const insertion = await pages.insertOne({ user: user.sub, words, when: new Date(), state: "published", }); page = await pages.findOne({ _id: insertion.insertedId }); } await database.disconnect(); return respond(200, { page }); } else { return respond(500, { message: "No words written." }); } } else if (path === "/touch-a-page" && method === "post") { const user = await authorize(event.headers, "sotce"); if (!user) return respond(401, { message: "Unauthorized." }); // Make sure the user is subscribed before they can touch a page. const subscription = await subscribed(user); if (!subscription || subscription.status !== "active") { return respond(500, { message: "User not subscribed." }); } const body = JSON.parse(event.body); shell.log("πŸ’ Page to touch:", body); const id = new ObjectId(body._id); const database = await connect(); const pages = database.db.collection("sotce-pages"); const page = await pages.findOne({ _id: id }); // console.log("πŸ“ƒ Page:", page, id); if (page) { const touches = database.db.collection("sotce-touches"); // Try to touch the page. if (page.user !== user.sub) { // Don't let users touch pages they created. await touches.createIndex({ user: 1, page: 1 }, { unique: true }); try { // Insert touch, assuming 'user.sub' contains the user's sub identifier await touches.insertOne({ user: user.sub, // User's sub identifier page: id, // Page ID from the request body when: new Date(), // Current date and time }); } catch (error) { if (error.code === 11000) { // Duplicate key error, meaning the user has already touched this page console.log("User has already touched this page."); } else { console.error( "An error occurred while touching the page:", error.message, ); } } } // Fetch all touches for the page, even if an error occurred ot a touch did not happen. const pageTouches = await touches.find({ page: id }).toArray(); // Add a 'handle' field to each touch record. const handles = []; for (const [index, touch] of pageTouches.entries()) { // if (touch.user === user.sub) continue; const handle = await handleFor(touch.user, "sotce"); // Cross-network handle request. if (handle) { handles.push("@" + handle); } // else { // TODO: Eventually track other touches? // } } await database.disconnect(); return respond(200, { touches: handles }); } else { await database.disconnect(); return respond(404, { message: "No page found to touch." }); } } else if (path === "/delete-account" && method === "post") { // See also the 'delete-erase-and-forget-me.js' function for aesthetic users. const user = await authorize(event.headers, "sotce"); if (!user) return respond(401, { message: "Authorization failure..." }); shell.log("πŸ”΄ Deleting user:", user.sub); const sub = user.sub; const sotceSub = "sotce-" + sub; // 1. Unsubscribe the user if they have an active subscription. try { const cancelResult = await cancelSubscription(user, key); shell.log( "❌ Cancelled subscription?", cancelResult.status, cancelResult.body, ); } catch (err) { shell.error("πŸ”΄ Subscription cancellation error:", err); } // 2. Delete any user data, like posts. const database = await connect(); // πŸ—¨οΈ Clear any chat messages owned by the user. // Rewrite the "text" field to be null / empty and rewrite the user field to be empty // rather than simply deleting the records associated with the user sub. await database.db .collection("chat-sotce") .updateMany({ user: sub }, { $set: { text: "", user: "" } }); console.log("🧠 Erased chats."); // Remove the user's handle cache from redis. const handle = await getHandleOrEmail(sotceSub); if (handle?.startsWith("@")) { await KeyValue.connect(); await KeyValue.del("@handles", handle); await KeyValue.del("userIDs", sotceSub); await KeyValue.disconnect(); } // 3. Delete the user's handle if it exists and the user does not have // an aesthetic computer account, otherwise re-associate the key. if (handle) { shell.log( "πŸ“š Checking for any `aesthetic` user with the same email and handle:", handle, ); const bareHandle = handle.slice(1); // Remove the "@" from the handle. const idRes = await userIDFromEmail(user.email, "aesthetic"); if (idRes?.userID && idRes?.email_verified) { const handles = database.db.collection("@handles"); const aestheticSub = idRes.userID; // Check if an entry with the same _id already exists const existingHandle = await handles.findOne({ _id: aestheticSub }); if (!existingHandle) { // If no existing entry, proceed with deletion and insertion await handles.deleteOne({ _id: sotceSub }); await handles.insertOne({ _id: aestheticSub, handle: bareHandle }); shell.log( "πŸ§” Changed primary handle key of 'sotce' user:", sub, "to 'aesthetic' user:", aestheticSub, ); } else { // If an entry already exists, skip deletion and insertion shell.log( "🩹 Handle already native to `aesthetic`, skipping reassignment.", ); } } else { await database.db.collection("@handles").deleteOne({ _id: sotceSub }); shell.log("πŸ§” Deleted user handle for:", sotceSub); } } shell.log("❌ Deleted database data."); await database.disconnect(); // 3. Delete the user's auth0 account. const deleted = await deleteUser(sub, "sotce"); shell.log("❌ Deleted user registration:", deleted, user.email); return respond(200, { result: "Deleted!" }); // Successful account deletion. } else if (path === "/ask" && method === "post") { // ❓ Submit a question const user = await authorize(event.headers, "sotce"); if (!user) return respond(401, { message: "Unauthorized." }); const subscription = await subscribed(user); if (!subscription || subscription.status !== "active") { return respond(403, { message: "Subscription required." }); } const body = JSON.parse(event.body); const question = body.question?.trim(); if (!question || question.length === 0) { return respond(400, { message: "Question cannot be empty." }); } if (question.length > 500) { return respond(400, { message: "Question too long (max 500 chars)." }); } const database = await connect(); const asks = database.db.collection("sotce-asks"); // Look up the user's handle to store with the question const askerHandle = await handleFor(user.sub, "sotce"); const insertion = await asks.insertOne({ user: user.sub, handle: askerHandle || null, question, when: new Date(), state: "pending", }); await database.disconnect(); shell.log("❓ Question submitted:", insertion.insertedId); return respond(200, { _id: insertion.insertedId }); } else if (path === "/asks" && method === "get") { // ❓ Get user's own questions const user = await authorize(event.headers, "sotce"); if (!user) return respond(401, { message: "Unauthorized." }); const database = await connect(); const asks = database.db.collection("sotce-asks"); const userAsks = await asks.find({ user: user.sub }) .sort({ when: -1 }) .limit(50) .project({ draftAnswer: 0, answer: 0, answeredBy: 0 }) // Don't expose answers to users yet .toArray(); await database.disconnect(); return respond(200, { asks: userAsks }); } else if (path === "/asks/pending" && method === "get") { // ❓ Get pending questions (admin only) const user = await authorize(event.headers, "sotce"); const isAdmin = await hasAdmin(user, "sotce"); if (!user || !isAdmin) return respond(401, { message: "Unauthorized." }); const database = await connect(); const asks = database.db.collection("sotce-asks"); const pending = await asks.find({ state: "pending" }) .sort({ when: 1 }) .limit(100) .toArray(); // Resolve handles for questions that don't have one stored for (const q of pending) { if (!q.handle && q.user) { const h = await handleFor(q.user, "sotce"); if (h) q.handle = h; } } await database.disconnect(); return respond(200, { asks: pending }); } else if (path.match(/^\/ask\/[a-f0-9]+\/respond$/) && method === "post") { // ❓ Respond to a question (admin only) const user = await authorize(event.headers, "sotce"); const isAdmin = await hasAdmin(user, "sotce"); if (!user || !isAdmin) return respond(401, { message: "Unauthorized." }); const askId = path.split("/")[2]; if (!askId) return respond(400, { message: "Missing question ID." }); const { answer } = JSON.parse(event.body || "{}"); if (!answer || !answer.trim()) { return respond(400, { message: "Response cannot be empty." }); } if (answer.length > 2000) { return respond(400, { message: "Response too long (max 2000 chars)." }); } const database = await connect(); const asks = database.db.collection("sotce-asks"); // Find the question const question = await asks.findOne({ _id: new ObjectId(askId) }); if (!question) { await database.disconnect(); return respond(404, { message: "Question not found." }); } // Update the question with the answer const result = await asks.updateOne( { _id: new ObjectId(askId) }, { $set: { state: "answered", answer: answer.trim(), answeredBy: user.sub, answeredAt: new Date().toISOString(), }, } ); if (result.modifiedCount === 0) { await database.disconnect(); return respond(500, { message: "Could not save response." }); } // NOTE: No separate page is created β€” answered questions live only in // sotce-asks and get swizzled into the feed client-side alongside diary pages. await database.disconnect(); shell.log("❓ Question answered:", askId, "by", user.email); return respond(200, { success: true, askId }); } else if (path.match(/^\/ask\/[a-f0-9]+\/save-draft$/) && method === "post") { // ❓ Save a draft response (admin only) - also marks draftStartedAt const user = await authorize(event.headers, "sotce"); const isAdmin = await hasAdmin(user, "sotce"); if (!user || !isAdmin) return respond(401, { message: "Unauthorized." }); const askId = path.split("/")[2]; if (!askId) return respond(400, { message: "Missing question ID." }); const { draft } = JSON.parse(event.body || "{}"); const database = await connect(); const asks = database.db.collection("sotce-asks"); const question = await asks.findOne({ _id: new ObjectId(askId) }); if (!question) { await database.disconnect(); return respond(404, { message: "Question not found." }); } const updateFields = { draftLastEditedAt: new Date().toISOString(), }; if (!question.draftStartedAt) { updateFields.draftStartedAt = new Date().toISOString(); } if (draft !== undefined) { updateFields.draftAnswer = draft; } await asks.updateOne( { _id: new ObjectId(askId) }, { $set: updateFields } ); await database.disconnect(); shell.log("❓ Draft saved for:", askId, "by", user.email); return respond(200, { success: true, askId }); } else if (path.match(/^\/ask\/[a-f0-9]+\/reject$/) && method === "post") { // ❓ Reject a question (admin only) const user = await authorize(event.headers, "sotce"); const isAdmin = await hasAdmin(user, "sotce"); if (!user || !isAdmin) return respond(401, { message: "Unauthorized." }); const askId = path.split("/")[2]; if (!askId) return respond(400, { message: "Missing question ID." }); const database = await connect(); const asks = database.db.collection("sotce-asks"); // Find the question const question = await asks.findOne({ _id: new ObjectId(askId) }); if (!question) { await database.disconnect(); return respond(404, { message: "Question not found." }); } // Update the question state to rejected const result = await asks.updateOne( { _id: new ObjectId(askId) }, { $set: { state: "rejected", rejectedBy: user.sub, rejectedAt: new Date().toISOString(), }, } ); await database.disconnect(); if (result.modifiedCount === 0) { return respond(500, { message: "Could not reject question." }); } shell.log("❓ Question rejected:", askId, "by", user.email); return respond(200, { success: true, askId }); } else if (path === "/asks/clear-all" && method === "delete") { // ❓ Clear all questions (admin only) - for development/reset const user = await authorize(event.headers, "sotce"); const isAdmin = await hasAdmin(user, "sotce"); if (!user || !isAdmin) return respond(401, { message: "Unauthorized." }); const database = await connect(); const asks = database.db.collection("sotce-asks"); const result = await asks.deleteMany({}); await database.disconnect(); shell.log("❓ All questions cleared:", result.deletedCount, "by", user.email); return respond(200, { success: true, deletedCount: result.deletedCount }); } else if (path.match(/^\/ask\/[a-f0-9]+$/) && method === "delete") { // ❓ Delete own pending question (only if no draft started by @amelia) const user = await authorize(event.headers, "sotce"); if (!user) return respond(401, { message: "Unauthorized." }); const askId = path.replace("/ask/", ""); if (!askId) return respond(400, { message: "Missing question ID." }); const database = await connect(); const asks = database.db.collection("sotce-asks"); // Only allow deletion if: owned by user, still pending, and no draft started const question = await asks.findOne({ _id: new ObjectId(askId) }); if (!question) { await database.disconnect(); return respond(404, { message: "Question not found." }); } if (question.user !== user.sub) { await database.disconnect(); return respond(403, { message: "Not your question." }); } if (question.state !== "pending") { await database.disconnect(); return respond(400, { message: "Cannot delete β€” already answered." }); } if (question.draftStartedAt) { await database.disconnect(); return respond(400, { message: "Cannot delete β€” @amelia has started drafting a response." }); } const result = await asks.deleteOne({ _id: new ObjectId(askId) }); await database.disconnect(); shell.log("❓ Question deleted:", askId, "by", user.sub); return respond(200, { deleted: true }); } else if (path === "/privacy-policy" && method === "get") { const subscribers = await getActiveSubscriptionCount(productId); const body = html` Sotce Net's Privacy Policy

Sotce Net's Privacy Policy

Sotce Net keeps pages on a server for subscribers to read.

You can associate an email with a @handle to represent your identity.

We use cookies and third-party services for login, analytics, and payments.

We federate handles with Aesthetic Computer β€” same email means shared @handle.

We do not sell your data.

Delete your account from the settings page. Write to mail@sotce.net with questions.

Brought to you by Sotce and Aesthetic Computer.

${subscribers > 0 ? "

Sotce Net has " + subscribers + " active subscriber" + (subscribers > 1 ? "s" : "") + ".

" : ""}

February 2026 `; return respond(200, body, { "Content-Type": "text/html; charset=utf-8" }); } }; async function cancelSubscription(user, key) { const email = user.email; const result = { status: undefined, body: undefined }; try { const stripe = Stripe(key); // Fetch customer by email const customers = await stripe.customers.list({ email, limit: 1 }); if (customers.data.length === 0) { result.status = 404; result.body = { message: "Customer not found." }; return result; } const customer = customers.data[0]; // Fetch subscriptions for the customer const subscriptions = await stripe.subscriptions.list({ customer: customer.id, status: "all", limit: 10, }); if (subscriptions.data.length === 0) { result.status = 404; result.body = { message: "Subscription not found." }; return result; } // Find the subscription matching the productId const subscription = subscriptions.data.find((sub) => sub.items.data.some((item) => item.price.product === productId), ); if (!subscription) { result.status = 404; result.body = { message: "Subscription not found." }; return result; } // Cancel the subscription const cancelled = await stripe.subscriptions.update(subscription.id, { cancel_at_period_end: true, }); // Clear the redis cache for this subscriber. await KeyValue.connect(); await KeyValue.del("sotce-subscribed", user.sub); await KeyValue.disconnect(); result.status = 200; result.body = { message: `Your subscription ends on ${new Date( cancelled.cancel_at * 1000, ).toLocaleDateString( "en-US", dateOptions, )}. You will not be billed again.`, subscription: cancelled, }; return result; } catch (error) { console.error("Error cancelling subscription:", error); result.status = 500; result.body = { message: `Error: ${error.message}` }; return result; } } const analyticsScript = html` `; // Inserted in `dev` mode for live reloading. const reloadScript = html` `.trim();