diff --git a/package.json b/package.json index f07779c26c..e44cf1d27f 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "pop:ddex": "node pop/bin/ddex.mjs", "pop:site": "node pop/bin/publish-release-records.mjs", "doctor": "node toolchain/doctor.mjs", + "pulse": "node toolchain/analytics/pulse.mjs", "push:subscribers": "node toolchain/push-subscribers.mjs", "menubar:parity": "node slab/bin/menubar-parity.mjs", "domain": "node toolchain/domains/domain.mjs", @@ -66,6 +67,8 @@ "test:nopaint:e2e:local": "node tests/browser/run-nopaint-local.mjs", "test:nopaint:e2e:film": "AC_HEADED=1 AC_SLOWMO=120 AC_FRAME_RECEIPTS=1 node tests/browser/nopaint-journey.test.mjs", "test:mail:e2e": "node tests/browser/mail-journey.test.mjs", + "test:signup:e2e": "node tests/browser/signup-journey.test.mjs", + "test:signup:e2e:password": "node tests/browser/signup-password-journey.test.mjs", "test:mail:e2e:local": "AC_TEST_URL=https://localhost:8888 node tests/browser/mail-journey.test.mjs", "test:flair-fps": "AC_TEST_URL=https://localhost:8888 node tests/browser/flair-fps.test.mjs", "test:flair-fps:headed": "AC_HEADED=1 AC_TEST_URL=https://localhost:8888 node tests/browser/flair-fps.test.mjs", diff --git a/session-server/chat-manager.mjs b/session-server/chat-manager.mjs index 07fbf52537..669ed68dd3 100644 --- a/session-server/chat-manager.mjs +++ b/session-server/chat-manager.mjs @@ -182,7 +182,8 @@ export class ChatManager { { $unionWith: { coll: "logs", - pipeline: [{ $match: {} }], + // New handles are recorded but no longer greeted in chat. + pipeline: [{ $match: { action: { $ne: "handle:create" } } }], }, }, { $sort: { when: -1 } }, diff --git a/system/backend/auth0-actions/README.md b/system/backend/auth0-actions/README.md index 2784e170c1..68be83e7ea 100644 --- a/system/backend/auth0-actions/README.md +++ b/system/backend/auth0-actions/README.md @@ -29,6 +29,15 @@ to claim a handle they already own — and cannot, because it is taken, by them. ### Install +**Use the script:** `bash system/backend/auth0-actions/install.sh` — Auth0 CLI, +browser device login, then update + deploy + bind to post-login (keeping other +bindings), secrets read from `vault/lith/.env` and never printed. Installed +this way on 2026-10-08 (action `d90e7eb3…`); the dashboard's Action editor +hung twice that day. It now also links first-time `google-oauth2` / `apple` +logins, preferring the password account as primary. + +The manual dashboard route, for reference: + 1. **Auth0 Dashboard → Actions → Library → Build Custom**, name it `link-email-identity`, trigger **Login / Post Login**. 2. Paste the contents of [`link-email-identity.js`](./link-email-identity.js). diff --git a/system/backend/auth0-actions/install.sh b/system/backend/auth0-actions/install.sh new file mode 100755 index 0000000000..cceeed19cb --- /dev/null +++ b/system/backend/auth0-actions/install.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# install.sh — put link-email-identity live in the `aesthetic` tenant without +# the dashboard's Action editor (which hung twice on 2026-10-08). +# +# bash system/backend/auth0-actions/install.sh +# +# Uses the Auth0 CLI (installed with Homebrew if missing). `auth0 login` opens +# a browser for you to approve; nothing is typed here. The three secrets are +# read straight from aesthetic-computer-vault/lith/.env — the copy production +# uses — and are never printed. Then: update the Action, deploy it, and add it +# to the post-login flow, keeping any bindings already there. + +set -euo pipefail +# The CLI turns on a prompt-free "agent mode" when it thinks an AI is driving; +# the browser login below needs prompts. +export AUTH0_AGENT_MODE=false +cd "$(git rev-parse --show-toplevel)" + +ACTION_NAME="link-email-identity" +CODE="system/backend/auth0-actions/link-email-identity.js" +ENV_FILE="aesthetic-computer-vault/lith/.env" +TENANT="aesthetic.us.auth0.com" +AUTH0_SDK="auth0=4.37.1" # the code calls the v4 API (usersByEmail.getByEmail, users.link) + +command -v auth0 >/dev/null || brew install auth0/auth0-cli/auth0 +auth0 tenants use "$TENANT" >/dev/null 2>&1 || auth0 login --domain "$TENANT" +auth0 tenants use "$TENANT" + +value() { grep -E "^$1=" "$ENV_FILE" | head -1 | cut -d= -f2- | sed -e 's/^"//' -e 's/"$//'; } +CLIENT_ID="$(value AUTH0_M2M_CLIENT_ID)" +CLIENT_SECRET="$(value AUTH0_M2M_SECRET)" +[ -n "$CLIENT_ID" ] && [ -n "$CLIENT_SECRET" ] || { echo "✗ M2M credentials missing from $ENV_FILE"; exit 1; } + +ID="$(auth0 actions list --json | node -e ' + let s = ""; process.stdin.on("data", d => s += d).on("end", () => { + const list = JSON.parse(s); const hit = (Array.isArray(list) ? list : list.actions || []).find(a => a.name === process.argv[1]); + process.stdout.write(hit?.id || ""); + });' "$ACTION_NAME")" + +common=(--code "$(cat "$CODE")" --dependency "$AUTH0_SDK" + --secret "AUTH0_DOMAIN=$TENANT" + --secret "AUTH0_M2M_CLIENT_ID=$CLIENT_ID" + --secret "AUTH0_M2M_SECRET=$CLIENT_SECRET") + +if [ -n "$ID" ]; then + echo "→ updating $ACTION_NAME ($ID)" + auth0 actions update "$ID" "${common[@]}" --force --no-input >/dev/null +else + echo "→ creating $ACTION_NAME" + ID="$(auth0 actions create --name "$ACTION_NAME" --trigger post-login "${common[@]}" --no-input --json | node -e ' + let s = ""; process.stdin.on("data", d => s += d).on("end", () => process.stdout.write(JSON.parse(s).id));')" +fi + +echo "→ waiting for the build" +for _ in $(seq 1 30); do + STATUS="$(auth0 actions show "$ID" --json | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>process.stdout.write(JSON.parse(s).status||""))')" + [ "$STATUS" = "built" ] && break + [ "$STATUS" = "failed" ] && { echo "✗ build failed"; exit 1; } + sleep 2 +done + +echo "→ deploying" +auth0 actions deploy "$ID" --no-input >/dev/null + +echo "→ binding to post-login (keeping existing bindings)" +BINDINGS="$(auth0 api get "actions/triggers/post-login/bindings" | node -e ' + let s = ""; process.stdin.on("data", d => s += d).on("end", () => { + const id = process.argv[1], name = process.argv[2]; + const current = (JSON.parse(s).bindings || []).map(b => ({ ref: { type: "action_id", value: b.action.id }, display_name: b.display_name })); + if (!current.some(b => b.ref.value === id)) current.push({ ref: { type: "action_id", value: id }, display_name: name }); + process.stdout.write(JSON.stringify({ bindings: current })); + });' "$ID" "$ACTION_NAME")" +auth0 api patch "actions/triggers/post-login/bindings" --data "$BINDINGS" >/dev/null + +echo "→ post-login flow now:" +auth0 api get "actions/triggers/post-login/bindings" | node -e ' + let s = ""; process.stdin.on("data", d => s += d).on("end", () => { + for (const b of JSON.parse(s).bindings || []) console.log(" •", b.display_name, "→", b.action?.id); + });' +echo "✓ $ACTION_NAME deployed and bound" diff --git a/system/backend/auth0-actions/link-email-identity.js b/system/backend/auth0-actions/link-email-identity.js index 369678b1e2..fb02779391 100644 --- a/system/backend/auth0-actions/link-email-identity.js +++ b/system/backend/auth0-actions/link-email-identity.js @@ -39,13 +39,15 @@ const ManagementClient = require("auth0").ManagementClient; -// The connection the emailed codes authenticate against. Anything else — the -// database connection, a social provider — is a login that already knows who -// it is and must be left alone. -const PASSWORDLESS_CONNECTION = "email"; +// The connections a new identity can arrive on and be folded into an account +// that already exists: the emailed codes, and the social providers the +// sign-in dialog offers (signup-flow.mjs). Google and Apple hand over an +// address they have already verified. The database connection is never +// folded — it is where nearly every existing account lives. +const LINKABLE_CONNECTIONS = new Set(["email", "google-oauth2", "apple"]); exports.onExecutePostLogin = async (event, api) => { - if (event.connection?.name !== PASSWORDLESS_CONNECTION) return; + if (!LINKABLE_CONNECTIONS.has(event.connection?.name)) return; // The code was accepted, so Auth0 has marked the address verified. Belt and // braces: a future connection setting must not quietly turn this into a // takeover. @@ -80,13 +82,15 @@ exports.onExecutePostLogin = async (event, api) => { return; } - // The account they already had: same address, verified, and NOT the - // passwordless identity we just logged in as. - const primary = (candidates || []).find((user) => - user.user_id !== event.user.user_id && - user.email_verified === true && - (user.identities || []).some((identity) => - identity.connection !== PASSWORDLESS_CONNECTION)); + // The account they already had: same address, verified, and not the + // identity we just logged in as. A password account wins (that is where + // handles were made for years); otherwise the oldest — say, someone who + // joined by code and now taps "Continue with Google". + const others = (candidates || []).filter((user) => + user.user_id !== event.user.user_id && user.email_verified === true); + const primary = + others.find((user) => (user.identities || []).some((identity) => identity.provider === "auth0")) || + others.sort((a, b) => Date.parse(a.created_at) - Date.parse(b.created_at))[0]; // Nobody to be. This is a genuinely new person, and the passwordless // identity they just made is the right one to keep. diff --git a/system/backend/handle-hold.mjs b/system/backend/handle-hold.mjs new file mode 100644 index 0000000000..a93b6a010a --- /dev/null +++ b/system/backend/handle-hold.mjs @@ -0,0 +1,102 @@ +// handle-hold, 26.10.08 +// A newcomer picks their @handle before they have an account, so the name has +// to be kept for them while they go and fetch an email code. A hold is ten +// minutes on one anonymous attempt id (the signup funnel's UUID): nobody else +// can claim the name in that window, and `/handle` honours it. +// +// GET /api/handle-hold?handle=x[&attempt=uuid] → { status: free|taken|held|yours|invalid, reason? } +// POST /api/handle-hold { handle, attempt } → { held: true, until } +// +// Holds live in `handle-holds` keyed by the lowercased handle, with a TTL +// index, so an abandoned attempt frees the name on its own. + +import { respond } from "./http.mjs"; + +export const HOLDS = "handle-holds"; +export const HOLD_MS = 10 * 60 * 1000; +const attemptID = (value) => typeof value === "string" && /^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i.test(value); +const exactly = (handle) => new RegExp(`^${handle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`, "i"); + +// True when someone else's live hold covers this handle. `/handle` calls this +// before it creates or renames, passing the attempt id the claimant brought. +export async function heldByOther(db, handle, attempt, now = new Date()) { + const hold = await db.collection(HOLDS).findOne({ _id: String(handle).toLowerCase(), until: { $gt: now } }); + return !!hold && hold.attempt !== attempt; +} + +// Called after a successful claim so the record does not linger. +export async function releaseHold(db, handle) { + await db.collection(HOLDS).deleteOne({ _id: String(handle).toLowerCase() }); +} + +export function createHandleHoldHandler({ connect, validateHandle, filter, handleQuarantined, now = () => new Date() }) { + const rates = new Map(); + let indexed = false; + + async function status(db, handle, attempt) { + const valid = validateHandle(handle); + if (valid !== "valid") return { status: "invalid", reason: valid }; + if (filter(handle) !== handle) return { status: "invalid", reason: "naughty" }; + if (await db.collection("@handles").findOne({ handle: exactly(handle) }, { projection: { _id: 1 } })) return { status: "taken" }; + if (await handleQuarantined(db, handle, now())) return { status: "taken" }; + const hold = await db.collection(HOLDS).findOne({ _id: handle.toLowerCase(), until: { $gt: now() } }); + if (hold) return { status: hold.attempt === attempt ? "yours" : "held" }; + return { status: "free" }; + } + + return async (event) => { + const reply = (code, body) => respond(code, body, { "Cache-Control": "no-store" }); + if (event.httpMethod === "OPTIONS") return reply(204, ""); + if (!["GET", "POST"].includes(event.httpMethod)) return reply(405, { message: "Method not allowed" }); + + // A live availability check fires on every pause in typing; keep a + // generous ceiling per address so it stays a check, not an enumerator. + const ip = String(event.headers?.["x-forwarded-for"] || event.headers?.["x-real-ip"] || "").split(",")[0].trim() || "?"; + const t = Date.now(); + for (const [key, rate] of rates) if (rate.until <= t) rates.delete(key); + const rate = rates.get(ip) || { until: t + 60000, count: 0 }; + rates.set(ip, rate); + if (++rate.count > 90) return reply(429, { message: "Slow down a little" }); + + let handle, attempt; + if (event.httpMethod === "GET") { + handle = event.queryStringParameters?.handle; + attempt = event.queryStringParameters?.attempt; + } else { + try { ({ handle, attempt } = JSON.parse(event.body || "{}")); } catch { return reply(400, { message: "Invalid JSON" }); } + if (!attemptID(attempt)) return reply(400, { message: "Invalid attempt" }); + } + handle = String(handle || "").trim().replace(/^@/, ""); + if (!handle || handle.length > 32) return reply(400, { status: "invalid", reason: "empty" }); + + let database; + try { + database = await connect(); + const { db } = database; + const current = await status(db, handle, attempt); + if (event.httpMethod === "GET") return reply(200, current); + if (current.status !== "free" && current.status !== "yours") return reply(409, current); + + const holds = db.collection(HOLDS); + if (!indexed) { await holds.createIndex({ until: 1 }, { expireAfterSeconds: 0 }); indexed = true; } + const until = new Date(+now() + HOLD_MS); + // One name per attempt: changing your mind releases the last one. + await holds.deleteMany({ attempt, _id: { $ne: handle.toLowerCase() } }); + try { + await holds.updateOne( + { _id: handle.toLowerCase(), $or: [{ until: { $lte: now() } }, { attempt }] }, + { $set: { attempt, handle, until } }, + { upsert: true }, + ); + } catch (error) { + if (error?.code === 11000) return reply(409, { status: "held" }); // raced: someone else's live hold + throw error; + } + return reply(200, { held: true, until: until.toISOString() }); + } catch { + return reply(503, { message: "Handle check unavailable" }); + } finally { + await database?.disconnect?.(); + } + }; +} diff --git a/system/backend/logger.mjs b/system/backend/logger.mjs index 0b05a39029..7fd5d495a2 100644 --- a/system/backend/logger.mjs +++ b/system/backend/logger.mjs @@ -70,6 +70,10 @@ async function log(text, data, from = "log") { await logs.createIndex({ when: 1 }); // Index for `when`. await logs.insertOne({ ...msg }); // Add to database, + // A quiet log is kept for the record (reports read `logs`) but not posted + // to chat — e.g. `handle:create`, whose public "hi @handle" was retired. + if (data.quiet) return; + // Alert all chat instances directly through HTTP calls with `LOGGER_KEY`. // For mute/unmute and handle actions (AC users only), we need to notify all AC chat servers // Note: Sotce users' handle changes are never logged (filtered in handle.mjs), so they won't reach here diff --git a/system/netlify/functions/handle-hold.mjs b/system/netlify/functions/handle-hold.mjs new file mode 100644 index 0000000000..4bc608f1e8 --- /dev/null +++ b/system/netlify/functions/handle-hold.mjs @@ -0,0 +1,6 @@ +import { connect } from "../../backend/database.mjs"; +import { filter } from "../../backend/filter.mjs"; +import { handleQuarantined } from "../../backend/account-deletion.mjs"; +import { validateHandle } from "../../public/aesthetic.computer/lib/text.mjs"; +import { createHandleHoldHandler } from "../../backend/handle-hold.mjs"; +export const handler = createHandleHoldHandler({ connect, filter, validateHandle, handleQuarantined }); diff --git a/system/netlify/functions/handle.mjs b/system/netlify/functions/handle.mjs index be8952793e..e1a3db213e 100644 --- a/system/netlify/functions/handle.mjs +++ b/system/netlify/functions/handle.mjs @@ -24,6 +24,7 @@ import * as logger from "../../backend/logger.mjs"; import { shell } from "../../backend/shell.mjs"; import { updateAtprotoHandle } from "../../backend/at.mjs"; import { handleQuarantined } from "../../backend/account-deletion.mjs"; +import { heldByOther, releaseHold } from "../../backend/handle-hold.mjs"; const dev = process.env.CONTEXT === "dev"; @@ -373,6 +374,10 @@ export async function handler(event, context) { if (!existingHandle && (await handleQuarantined(database.db, handle))) { throw new Error("taken"); } + // A newcomer may be holding it while they fetch an email code. + if (!existingHandle && (await heldByOther(database.db, handle, body.hold))) { + throw new Error("taken"); + } if (existingHandle && existingHandle.handle.toLowerCase() === handle.toLowerCase()) { return respond(400, { message: "same" }); @@ -395,6 +400,7 @@ export async function handler(event, context) { if (existingHandle) throw new Error("taken"); // Recently deleted accounts' handles are held (account-deletion.mjs). if (await handleQuarantined(database.db, handle)) throw new Error("taken"); + if (await heldByOther(database.db, handle, body.hold)) throw new Error("taken"); // Add a new `@handles` document for this user. await handles.insertOne({ _id: primarySub, handle, createdAt: new Date(), updatedAt: new Date() }); @@ -404,9 +410,11 @@ export async function handler(event, context) { user: primarySub, action: "handle:create", value: handle, + quiet: true, // recorded, no longer announced in chat }); // 🪵 Log initial handle creation. } } + await releaseHold(database.db, handle); atprotoSync = await updateAtprotoHandle(database, primarySub, handle); // Update the redis handle <-> userID cache... if (existingUser?.handle) diff --git a/system/public/aesthetic.computer/bios.mjs b/system/public/aesthetic.computer/bios.mjs index 22925d79dd..03ee9adff1 100644 --- a/system/public/aesthetic.computer/bios.mjs +++ b/system/public/aesthetic.computer/bios.mjs @@ -13675,6 +13675,15 @@ async function boot(parsed, bpm = 60, resolution, debug) { if (type === "logout") { accountActivity.stop(); if (window.acTOKEN) { + // 🚪 Signed in through the in-page email-code door (signup-flow.mjs): + // there is no host to defer to and no hosted session to end, so + // forgetting the tokens is the whole of signing out. + if (window.self === window.top && window.safeLocalStorageGet?.("ac-otp-session")) { + window.safeLocalStorageRemove("ac-otp-session"); + window.safeLocalStorageRemove("session-aesthetic"); + location.reload(); + return; + } if (window.parent) { window.parent.postMessage({ type: "logout" }, "*"); window.safeLocalStorageRemove("session-aesthetic"); diff --git a/system/public/aesthetic.computer/boot.mjs b/system/public/aesthetic.computer/boot.mjs index 15eee7006b..ad1627e18a 100644 --- a/system/public/aesthetic.computer/boot.mjs +++ b/system/public/aesthetic.computer/boot.mjs @@ -1515,21 +1515,22 @@ if (!sandboxed && !localStorageBlocked) { if (handed || safeLocalStorageGet("session-aesthetic")) likelyLoggedIn = true; } +const AUTH0_CLIENT_ID = "LVdZaMbyXctkGfZDnpzDATB5nR0ZhmMt"; // the aesthetic SPA +// Social providers offered in the sign-in dialog, by Auth0 connection name. +const SOCIAL_CONNECTIONS = [ + { connection: "google-oauth2", label: "Google" }, + { connection: "apple", label: "Apple" }, +]; + // If noauth mode OR no Auth0 cache found, skip auth entirely const skipAuth = window.acNOAUTH || (!likelyLoggedIn && !sandboxed && !location.search.includes('code=') && !location.search.includes('state=')); // Login must survive a failed or expired saved session, including early returns // from the restore flow below. Install it before attempting authentication. if (!sandboxed && !window.acNOAUTH) { - window.acSignup = createSignupFlow(window, document); - window.acLOGIN = async (mode) => { - // 🖥️ The desktop app signs in the way every AC Mac app does — the - // system browser and the shared ~/.ac-token — and hands the session back - // (see ac-electron's `ac:desktop-login`). An in-app Auth0 redirect can - // only return to an allow-listed origin, which a local page isn't. - if (typeof window.acDESKTOP?.login === "function") { - return window.acDESKTOP.login(mode); - } + // The hosted Universal Login redirect: the fallback door, and the only one + // where the in-page email-code door can't run (embedded, or a tenant fault). + const redirectLogin = async (mode) => { window.acSignup.start(mode === "signup" ? "signup" : "login"); try { // Lazy-load Auth0 if not already loaded @@ -1548,6 +1549,52 @@ if (!sandboxed && !window.acNOAUTH) { window.acSignup.failed(); throw error; } + }; + // 🌐 Google, Apple and other providers show their own sign-in page, so the + // in-page door opens them in a popup and the piece stays put. A blocked + // popup falls back to the full redirect. Resolves to the signed-in Auth0 + // client, or null when the redirect took over. + const socialLogin = async (connection) => { + if (!window.auth0Client) { + await loadAuth0Script(); + await setupAuth0Client(); + } + safeLocalStorageRemove("session-aesthetic"); + safeLocalStorageRemove("ac-otp-session"); + try { + await window.auth0Client.loginWithPopup({ authorizationParams: { connection, prompt: "login" } }); + return window.auth0Client; + } catch (error) { + if (/popup/i.test(error?.message || "") && /null/.test(error?.message || "")) { + await window.auth0Client.loginWithRedirect({ authorizationParams: { connection } }); + return null; + } + throw error; + } + }; + window.acSignup = createSignupFlow(window, document, { + clientId: AUTH0_CLIENT_ID, + redirect: redirectLogin, + social: socialLogin, + // Each provider must be switched on for the aesthetic app in the Auth0 + // dashboard (Authentication → Social) before its button shows. Until + // then `localStorage["ac:social"] = "on"` previews the buttons. + socials: safeLocalStorageGet("ac:social") === "on" ? SOCIAL_CONNECTIONS : [], + }); + window.acLOGIN = async (mode, { redirect = false } = {}) => { + // 🖥️ The desktop app signs in the way every AC Mac app does — the + // system browser and the shared ~/.ac-token — and hands the session back + // (see ac-electron's `ac:desktop-login`). An in-app Auth0 redirect can + // only return to an allow-listed origin, which a local page isn't. + if (typeof window.acDESKTOP?.login === "function") { + return window.acDESKTOP.login(mode); + } + // 🚪 Handle first, then an emailed code, without leaving the page + // (signup-flow.mjs). `?login=password` keeps the hosted page reachable. + if (redirect || new URLSearchParams(location.search).get("login") === "password") { + return redirectLogin(mode); + } + window.acSignup.open(mode === "signup" ? "signup" : "login"); }; } @@ -1596,7 +1643,7 @@ function loadAuth0Script() { async function setupAuth0Client() { if (window.auth0Client) return window.auth0Client; // Already set up - const clientId = "LVdZaMbyXctkGfZDnpzDATB5nR0ZhmMt"; + const clientId = AUTH0_CLIENT_ID; window.acAuthTiming.auth0ClientCreateStart = performance.now(); bootLog("initializing auth0 client"); @@ -1666,6 +1713,24 @@ if (!sandboxed && !skipAuth) { window.history.replaceState({}, document.title, cleanUrl); } catch (e) { /* Ignore in restricted context */ } } + } + + // 🚪 A session from the in-page email-code door carries its own refresh + // token (auth0-otp.mjs); renew the access token before it is checked + // below. Only while that same account still holds session-aesthetic — a + // later redirect login clears it and must not be overridden. + if (safeLocalStorageGet("ac-otp-session") && safeLocalStorageGet("session-aesthetic")) { + try { + const { otpSignIn } = await import("./lib/auth0-otp.mjs"); + const otpDoor = otpSignIn({ clientId: AUTH0_CLIENT_ID }); + const held = otpDoor.session(); + const current = JSON.parse(atob(decodeURIComponent(safeLocalStorageGet("session-aesthetic")))); + if (held && current?.account?.id === held.sub) { + const access = await otpDoor.token(); + if (access) safeLocalStorageSet("session-aesthetic", btoa(JSON.stringify({ accessToken: access, account: { id: held.sub, label: held.email } }))); + else safeLocalStorageRemove("session-aesthetic"); + } + } catch (e) { bootLog(`otp session refresh skipped: ${e?.message || e}`); } } const params = extractLegitimateParams(window.location.href); diff --git a/system/public/aesthetic.computer/lib/signup-flow.mjs b/system/public/aesthetic.computer/lib/signup-flow.mjs index c54c815b2f..604d19e87e 100644 --- a/system/public/aesthetic.computer/lib/signup-flow.mjs +++ b/system/public/aesthetic.computer/lib/signup-flow.mjs @@ -6,9 +6,17 @@ const KEY = "ac:signup:v1"; // Authentication, verification and a normal handle form; content stays local. // The collector receives only a per-attempt UUID and allowlisted milestones. -export function createSignupFlow(win, doc) { +// +// Two doors. `open()` is the in-page one: pick a @handle (held for ten minutes +// by /api/handle-hold), then an emailed six-digit code through auth0-otp.mjs — +// no redirect, no password, and none of the hosted page's Turnstile check, +// which loops forever on some networks. The resulting tokens become a hosted +// `session-aesthetic`, the same shape an embedding host hands boot. `redirect` +// is the old Universal Login door; the in-page one falls back to it whenever +// auth0-otp reports a tenant fault rather than a person's mistake. +export function createSignupFlow(win, doc, { clientId, redirect, social, socials = [] } = {}) { let attempt, previousPiece = signupReturnPath(win.location.href, win.location.origin); - let dialog, timer, auth, checking = false, generation = 0; + let dialog, timer, auth, checking = false, generation = 0, otp, otpModule, signedIn = null; const disabled = () => win.navigator.doNotTrack === "1" || win.doNotTrack === "1" || win.navigator.globalPrivacyControl === true || win.acVisitTrackingDisabled === true; const supported = () => win === win.top && !win.acPACK_MODE; @@ -66,7 +74,7 @@ export function createSignupFlow(win, doc) { win.location.assign(destination); } async function request(path, options = {}) { - const token = await auth.getTokenSilently(); + const token = signedIn ? signedIn.access : await auth.getTokenSilently(); const response = await win.fetch(path, { ...options, headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(15000), cache: "no-store" }); @@ -83,6 +91,12 @@ export function createSignupFlow(win, doc) { .ac-signup form,.ac-signup input,.ac-signup button{pointer-events:auto}.ac-signup input{user-select:text;-webkit-user-select:text} .ac-signup{box-sizing:border-box;width:min(420px,calc(100% - 32px));max-height:calc(100dvh - 32px);overflow:auto;border:2px solid #ff71bf;border-radius:12px;padding:28px;background:#171321;color:#fff;font:17px/1.5 system-ui,sans-serif;box-shadow:0 12px 60px #0009} .ac-signup::backdrop{background:#0c0719bd}.ac-signup h1{font-size:28px;line-height:1.15;margin:0 28px 16px 0}.ac-signup p{margin:0 0 20px;color:#dfd6e9}.ac-signup label{display:block;margin:0 0 6px}.ac-signup input{box-sizing:border-box;width:100%;background:#292235;color:white;border:2px solid #a79aae;border-radius:6px;font:inherit;padding:12px;margin:0 0 16px}.ac-signup button{font:inherit;border:0;border-radius:6px;padding:12px 16px;cursor:pointer}.ac-signup button:focus-visible,.ac-signup input:focus-visible{outline:3px solid #ffafdb;outline-offset:3px}.ac-signup .primary{width:100%;background:#ff71bf;color:#211026;font-weight:650}.ac-signup .secondary{background:transparent;color:#e0d6ec;margin-top:10px}.ac-signup .dismiss{position:absolute;right:12px;top:8px;background:transparent;color:white;font-size:24px;padding:0 8px}.ac-signup button:disabled{opacity:.55;cursor:wait}.ac-signup [role=status]{display:block;min-height:1.5em;color:#ffe19c;margin:12px 0 0;font-size:15px} + .ac-signup .links{display:flex;flex-wrap:wrap;justify-content:space-between;gap:4px 16px;margin-top:6px}.ac-signup .links button{background:transparent;color:#e0d6ec;padding:8px 0;text-decoration:underline;text-underline-offset:3px;text-decoration-color:#6d6080}.ac-signup .links button:disabled{text-decoration:none;color:#9b90a8;cursor:default} + .ac-signup .at{display:flex;align-items:center;gap:0;background:#292235;border:2px solid #a79aae;border-radius:6px;margin:0 0 6px}.ac-signup .at:focus-within{outline:3px solid #ffafdb;outline-offset:3px}.ac-signup .at span{padding-left:12px;color:#ff71bf;font-weight:650}.ac-signup .at input{border:0;margin:0;background:transparent;outline:none}.ac-signup .at input:focus-visible{outline:none} + .ac-signup .check{min-height:1.5em;margin:0 0 14px;font-size:15px;color:#9b90a8}.ac-signup .check.free{color:#8ee6a8}.ac-signup .check.no{color:#ff8f9c}.ac-signup .hold{font-size:14px;color:#ffe19c;margin:-8px 0 16px} + .ac-signup .or{display:flex;align-items:center;gap:10px;color:#9b90a8;font-size:14px;margin:18px 0 12px}.ac-signup .or::before,.ac-signup .or::after{content:"";flex:1;border-top:1px solid #3d3450} + .ac-signup .providers{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:10px}.ac-signup .providers button{background:transparent;color:#fff;border:2px solid #4a4058} + .ac-signup input.code{font:600 28px/1 ui-monospace,Menlo,monospace;letter-spacing:.45em;text-align:center;padding:14px 0 14px .45em}

`; dialog.className = "ac-signup"; dialog.querySelector("h1").textContent = title; @@ -173,8 +187,262 @@ export function createSignupFlow(win, doc) { view.querySelector(".primary").onclick = () => resume(client, user); } } + // 🚪 The in-page door + + const holdMinutes = () => Math.max(0, Math.ceil(((read()?.holdUntil || 0) - Date.now()) / 60000)); + const escape = (text) => String(text).replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`); + + async function door() { + if (!otp) { + otpModule = await import("./auth0-otp.mjs"); + otp = otpModule.otpSignIn({ clientId }); + } + return otp; + } + + // Leave for the hosted page: the tenant, not the person, refused the code door. + function fallback(mode = read()?.mode) { + track("fallback"); + close(); + redirect?.(mode === "signup" ? "signup" : "login"); + } + + function open(mode = "signup") { + if (!supported() || !clientId) return fallback(mode); + start(mode === "signup" ? "signup" : "login"); + attempt.hold = win.crypto.randomUUID(); // holds need an id even when tracking is off + persist(); + if (attempt.mode === "signup") handleStep(); else emailStep(); + } + + // 1. The @handle, checked as it is typed and held before anything else is asked. + function handleStep({ message = "", claimNow = false } = {}) { + const view = frame(claimNow ? "Choose your @handle" : "Pick your @handle", + "It’s how you show up in chat and on everything you make."); + view.querySelector(".content").innerHTML = `
+
+

1–16 letters or numbers. Dots and underscores can go between them.

+ + ${claimNow ? "" : ``}
`; + const input = view.querySelector("input"), check = view.querySelector(".check"), status = view.querySelector("[role=status]"); + const button = view.querySelector(".primary"), mine = generation; + if (read()?.handle) input.value = attempt.handle; + status.textContent = message; + let pause, asked = 0; + const say = (text, tone = "") => { check.textContent = text; check.className = `check ${tone}`; }; + const look = async () => { + const handle = input.value.trim().replace(/^@/, ""); + if (!handle) return say("1–16 letters or numbers. Dots and underscores can go between them."); + if (validateHandle(handle) !== "valid") return say("Letters and numbers, with dots or underscores only between them.", "no"); + const ask = ++asked; + say("Checking…"); + try { + const res = await win.fetch(`/api/handle-hold?handle=${encodeURIComponent(handle)}&attempt=${read()?.hold || ""}`, { cache: "no-store" }); + const body = await res.json(); + if (ask !== asked || mine !== generation) return; + if (body.status === "free" || body.status === "yours") say(`@${handle} is free.`, "free"); + else if (body.status === "invalid") say(body.reason === "naughty" ? "Try a different name." : "That name won’t work as a handle.", "no"); + else say(`@${handle} is taken. Try another.`, "no"); + } catch { if (ask === asked) say(""); } + }; + input.addEventListener("input", () => { status.textContent = ""; win.clearTimeout(pause); pause = win.setTimeout(look, 280); }); + view.querySelector("[data-login]")?.addEventListener("click", () => { attempt.mode = "login"; persist(); emailStep(); }); + if (input.value) look(); + input.focus(); + view.querySelector("form").onsubmit = async (event) => { + event.preventDefault(); + if (button.disabled) return; + const handle = input.value.trim().replace(/^@/, ""); + if (validateHandle(handle) !== "valid") { status.textContent = "Use 1–16 letters or numbers, dots or underscores."; track("handle_failed", "invalid"); return; } + button.disabled = true; status.textContent = "Saving…"; + if (claimNow) return claim(handle); + try { + const res = await win.fetch("/api/handle-hold", { method: "POST", cache: "no-store", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ handle, attempt: read().hold }) }); + const body = await res.json().catch(() => ({})); + if (mine !== generation) return; + if (!res.ok) { + const taken = body.status === "taken" || body.status === "held"; + track("handle_failed", taken ? "taken" : body.status === "invalid" ? "invalid" : "network"); + status.textContent = taken ? `@${handle} is taken. Try another.` : body.status === "invalid" ? "That name won’t work as a handle." : "Couldn’t check that name. Please try again."; + button.disabled = false; input.focus(); return; + } + attempt.handle = handle; attempt.holdUntil = Date.parse(body.until) || Date.now() + 600000; persist(); + track("handle_held"); + emailStep(); + } catch { + if (mine !== generation) return; + status.textContent = "Couldn’t check that name. Please try again."; button.disabled = false; + } + }; + } + + // 2. Where to send the code. + function emailStep(prefill = "") { + const signup = read()?.mode === "signup"; + const view = frame(signup ? "Where should we send a code?" : "Log in", + signup ? "We’ll email you six digits. No password." : "We’ll email you a six-digit code."); + view.querySelector(".content").innerHTML = `${signup && attempt.handle ? `

@${escape(attempt.handle)} is yours for ${holdMinutes()} minutes.

` : ""} +
+ + + ${socials.length && social ? `
or
${socials.map((p) => ``).join("")}
` : ""} +
`; + const input = view.querySelector("input"), status = view.querySelector("[role=status]"), button = view.querySelector(".primary"), mine = generation; + input.value = prefill; + input.focus(); + view.querySelector("[data-back]").onclick = () => { attempt.mode = "signup"; persist(); handleStep(); }; + view.querySelector("[data-password]").onclick = () => fallback(); + for (const button of view.querySelectorAll("[data-provider]")) button.onclick = () => viaProvider(button); + view.querySelector("form").onsubmit = async (event) => { + event.preventDefault(); + if (button.disabled) return; + button.disabled = true; status.textContent = "Sending…"; + try { + const gate = await door(); + const email = await gate.sendCode(input.value); + if (mine !== generation) return; + track("code_sent"); + codeStep(email); + } catch (error) { + if (mine !== generation) return; + if (otpModule?.tenantFaults?.includes(error.code)) return fallback(); + status.textContent = error.message || "Couldn’t send a code. Please try again."; + button.disabled = false; input.focus(); + } + }; + } + + // 3. The six digits. Spending them proves the address, so there is no letter + // with a link and no "I’ve verified" button in this door. + function codeStep(email) { + const view = frame("Enter the code", `Sent to ${email}. It can take a minute; check spam too.`); + view.querySelector(".content").innerHTML = `
+ + +
`; + const input = view.querySelector("input"), status = view.querySelector("[role=status]"), button = view.querySelector(".primary"); + const resend = view.querySelector("[data-resend]"), mine = generation; + const form = view.querySelector("form"); + input.focus(); + win.setTimeout(() => { if (mine === generation) resend.disabled = false; }, 30000); + resend.onclick = async () => { + resend.disabled = true; + try { await (await door()).sendCode(email); track("code_sent"); status.textContent = "New code sent."; } + catch (error) { status.textContent = error.message || "Couldn’t resend yet."; } + win.setTimeout(() => { if (mine === generation) resend.disabled = false; }, 30000); + }; + view.querySelector("[data-back]").onclick = () => emailStep(email); + input.addEventListener("input", () => { + input.value = input.value.replace(/\D/g, "").slice(0, 6); + status.textContent = ""; + if (input.value.length === 6) form.requestSubmit(); + }); + form.onsubmit = async (event) => { + event.preventDefault(); + if (button.disabled || input.value.length !== 6) return; + button.disabled = true; status.textContent = "Checking…"; + try { + const session = await (await door()).verify(email, input.value); + if (mine !== generation) return; + signedIn = session; + adopt(session); + track("verified"); + await signedInNext(); + } catch (error) { + if (mine !== generation) return; + if (otpModule?.tenantFaults?.includes(error.code)) return fallback(); + track("code_failed", "code"); + status.textContent = error.message || "That code didn’t work."; + button.disabled = false; input.value = ""; input.focus(); + } + }; + } + + // A provider's own sign-in (Google, Apple, …) in a popup. Its email arrives + // verified, so it lands exactly where a spent code does. The session lives + // in auth0-spa-js's cache, which boot reads on the next load. + async function viaProvider(button) { + const connection = button.dataset.provider, label = button.textContent; + const status = dialog?.querySelector("[role=status]"), mine = generation; + button.disabled = true; + if (status) status.textContent = `Opening ${label}…`; + track("social_started"); + try { + const client = await social(connection); + if (!client || mine !== generation) return; // the redirect took over + signedIn = null; otp?.forget(); + auth = client; + const user = await client.getUser(); + if (!user?.sub) throw new Error("no user"); + track("verified"); + await signedInNext(user.sub); + } catch (error) { + if (mine !== generation) return; + button.disabled = false; + const off = /connection|not enabled|disabled/i.test(`${error?.error_description || ""} ${error?.message || ""}`); + if (status) status.textContent = /cancel|closed/i.test(error?.message || error?.error || "") ? "" : + off ? `${label} sign-in isn’t switched on yet.` : `Couldn’t sign in with ${label}. Please try again.`; + } + } + + // Boot reads this on the next load exactly as it reads a session handed over + // by an embedding host; auth0-otp keeps the refresh token beside it. + function adopt(session) { + const encoded = win.btoa(JSON.stringify({ accessToken: session.access, account: { id: session.sub, label: session.email } })); + try { win.localStorage.setItem("session-aesthetic", encoded); } catch {} + } + + // Signed in: a returning account (its handle came through the linking + // Action) goes straight back; a newcomer claims the handle they held. + async function signedInNext(sub = signedIn?.sub) { + const mine = generation; + let existing = null; + try { + const res = await win.fetch(`/handle?for=${encodeURIComponent(sub)}`, { cache: "no-store" }); + if (res.ok) existing = (await res.json()).handle || null; + } catch {} + if (mine !== generation) return; + if (existing) return complete("/prompt"); + if (read()?.handle) return claim(attempt.handle); + if (read()?.mode === "login") return noHandleYet(); + track("handle_shown"); + handleStep({ claimNow: true }); + } + + // Logging in by code found no handle. Either this email is new here, or its + // account predates codes and Auth0 hasn't linked the two — then the password + // door still reaches the old account, and a second handle would be wrong. + function noHandleYet() { + const view = frame("No handle on this email yet", "If you already have an account, log in with your password to reach it. New here? Pick a handle."); + view.querySelector(".content").innerHTML = ``; + view.querySelector("[data-new]").onclick = () => { track("handle_shown"); handleStep({ claimNow: true }); }; + view.querySelector("[data-password]").onclick = () => { + signedIn = null; otp?.forget(); + try { win.localStorage.removeItem("session-aesthetic"); } catch {} + fallback("login"); + }; + } + + async function claim(handle) { + const mine = generation; + try { + const result = await request("/handle", { method: "POST", body: JSON.stringify({ handle, hold: read()?.hold }) }); + if (mine !== generation) return; + if (!result.handle) throw new Error("Missing handle"); + win.dispatchEvent(new win.Event("ac:handle-created")); + complete(); + } catch (error) { + if (mine !== generation) return; + const reason = SIGNUP_ERRORS.includes(error.reason) ? error.reason : "network"; + track("handle_failed", reason); + if (attempt) attempt.handle = null; + handleStep({ claimNow: true, message: reason === "taken" ? `@${handle} was taken while you were away. Pick another.` : "Couldn’t save your handle. Please try again." }); + } + } + return { - start, track, resume, complete, pending: () => !!read(), close, + start, track, resume, complete, pending: () => !!read(), close, open, remember(path) { const safe = signupReturnPath(path, win.location.origin); if (safe) previousPiece = safe; }, failed() { track("auth_failed", "auth"); diff --git a/system/public/aesthetic.computer/lib/signup-model.mjs b/system/public/aesthetic.computer/lib/signup-model.mjs index b65c490e12..152ed82b7a 100644 --- a/system/public/aesthetic.computer/lib/signup-model.mjs +++ b/system/public/aesthetic.computer/lib/signup-model.mjs @@ -6,9 +6,11 @@ export const SIGNUP_TTL_MS = 24 * 60 * 60 * 1000; export const SIGNUP_STAGES = Object.freeze([ "started", "auth_returned", "auth_failed", "verification_shown", "verification_resent", "verified", "handle_shown", "handle_failed", "completed", + // In-page email-code door (signup-flow.mjs): handle first, then a mailed code. + "handle_held", "code_sent", "code_failed", "fallback", "social_started", ]); export const SIGNUP_SOURCES = Object.freeze(["prompt", "get-handle", "chat", "laer-klokken", "piece"]); -export const SIGNUP_ERRORS = Object.freeze(["network", "auth", "taken", "invalid", "unverified", "other"]); +export const SIGNUP_ERRORS = Object.freeze(["network", "auth", "taken", "invalid", "unverified", "code", "other"]); export const signupID = value => typeof value === "string" && /^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i.test(value); // Return navigation stays on this origin. Never retain prompt text, searches, diff --git a/system/tests/handle-hold.test.mjs b/system/tests/handle-hold.test.mjs new file mode 100644 index 0000000000..4170072a36 --- /dev/null +++ b/system/tests/handle-hold.test.mjs @@ -0,0 +1,94 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createHandleHoldHandler, heldByOther, HOLDS } from "../backend/handle-hold.mjs"; +import { validateHandle } from "../public/aesthetic.computer/lib/text.mjs"; + +// Just enough of a Mongo collection for the queries handle-hold makes. +function memoryDB(seed = {}) { + const tables = { "@handles": [], [HOLDS]: [], ...seed }; + const matches = (doc, query) => Object.entries(query).every(([key, want]) => { + if (key === "$or") return want.some((q) => matches(doc, q)); + const have = doc[key]; + if (want instanceof RegExp) return want.test(have); + if (want && typeof want === "object" && !(want instanceof Date)) { + if ("$gt" in want) return have > want.$gt; + if ("$lte" in want) return have <= want.$lte; + if ("$ne" in want) return have !== want.$ne; + } + return have === want; + }); + const collection = (name) => { + const rows = (tables[name] ??= []); + return { + createIndex: async () => {}, + findOne: async (query) => rows.find((doc) => matches(doc, query)) || null, + deleteOne: async (query) => { const i = rows.findIndex((doc) => matches(doc, query)); if (i >= 0) rows.splice(i, 1); }, + deleteMany: async (query) => { for (let i = rows.length - 1; i >= 0; i--) if (matches(rows[i], query)) rows.splice(i, 1); }, + updateOne: async (query, { $set }, { upsert } = {}) => { + const doc = rows.find((d) => matches(d, query)); + if (doc) return Object.assign(doc, $set); + if (!upsert) return; + if (rows.some((d) => d._id === query._id)) throw Object.assign(new Error("dup"), { code: 11000 }); + rows.push({ _id: query._id, ...$set }); + }, + }; + }; + return { tables, db: { collection } }; +} + +const A = "11111111-1111-4111-8111-111111111111"; +const B = "22222222-2222-4222-8222-222222222222"; + +function setup(seed) { + const store = memoryDB(seed); + let clock = new Date("2026-10-08T12:00:00Z"); + const handler = createHandleHoldHandler({ + connect: async () => ({ db: store.db, disconnect: async () => {} }), + validateHandle, + filter: (text) => text.replace(/heck/gi, "****"), + handleQuarantined: async (_db, handle) => handle.toLowerCase() === "gone", + now: () => clock, + }); + const get = async (handle, attempt) => { + const res = await handler({ httpMethod: "GET", headers: {}, queryStringParameters: { handle, attempt } }); + return { code: res.statusCode, ...JSON.parse(res.body) }; + }; + const hold = async (handle, attempt) => { + const res = await handler({ httpMethod: "POST", headers: {}, body: JSON.stringify({ handle, attempt }) }); + return { code: res.statusCode, ...JSON.parse(res.body) }; + }; + return { store, get, hold, later: (ms) => { clock = new Date(+clock + ms); } }; +} + +test("availability names taken, quarantined, invalid and filtered handles", async () => { + const { get } = setup({ "@handles": [{ _id: "auth0|1", handle: "Jeffrey" }, { _id: "auth0|2", handle: "axb" }] }); + assert.equal((await get("jeffrey")).status, "taken"); // case-insensitive + assert.equal((await get("gone")).status, "taken"); + assert.equal((await get("a..b")).status, "invalid"); + assert.equal((await get("oheck")).reason, "naughty"); + assert.equal((await get("pinkfrog")).status, "free"); + // A dot in a handle is literal, not a regex wildcard. + assert.equal((await get("a.b")).status, "free"); +}); + +test("a hold keeps the name for its attempt only, and lapses after ten minutes", async () => { + const { get, hold, later, store } = setup(); + assert.equal((await hold("pinkfrog", A)).held, true); + assert.equal((await get("pinkfrog", A)).status, "yours"); + assert.equal((await get("pinkfrog", B)).status, "held"); + assert.equal((await hold("pinkfrog", B)).code, 409); + assert.equal(await heldByOther(store.db, "PinkFrog", B, new Date("2026-10-08T12:05:00Z")), true); + assert.equal(await heldByOther(store.db, "pinkfrog", A, new Date("2026-10-08T12:05:00Z")), false); + later(10 * 60 * 1000 + 1); + assert.equal((await get("pinkfrog", B)).status, "free"); + assert.equal((await hold("pinkfrog", B)).held, true); +}); + +test("changing your mind releases the earlier name; holds need a real attempt id", async () => { + const { get, hold } = setup(); + await hold("first", A); + await hold("second", A); + assert.equal((await get("first", B)).status, "free"); + assert.equal((await get("second", B)).status, "held"); + assert.equal((await hold("third", "not-a-uuid")).code, 400); +}); diff --git a/tests/browser/jasellite-mail.mjs b/tests/browser/jasellite-mail.mjs new file mode 100644 index 0000000000..3ea8575094 --- /dev/null +++ b/tests/browser/jasellite-mail.mjs @@ -0,0 +1,37 @@ +// jasellite-mail, 2026.10.08 +// Read a letter sent to a mail+tag@aesthetic.computer address, for the signup +// journeys. mail@aesthetic.computer's maildir and mu index live on jasellite +// (toolchain/macos/SCORE.md, "Mail lives on jasellite"), so each lap is one ssh +// hop: mbsync ac-mail, mu index, then the newest matching letter's text. + +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +const pexec = promisify(execFile); +const MAIL_HOST = process.env.AC_MAIL_HOST ?? "jas@24.144.92.66"; +// jasellite's login shell is fish; single quotes are literal in fish and sh. +const quote = (s) => `'${String(s).replaceAll("'", `'\\''`)}'`; + +const onJasellite = async (cmd) => + (await pexec("ssh", ["-o", "BatchMode=yes", "-o", "ConnectTimeout=10", MAIL_HOST, cmd], + { timeout: 120_000, maxBuffer: 8 * 1024 * 1024 })).stdout; + +// Waits for a letter to `to` that arrived after `since` (ms epoch) and returns +// the first match of `pattern` in it (capture group 1 when there is one). The +// index is shared with jasellite's mail-sync timer; a busy lock just means try +// again on the next lap. +export async function waitForLetter(to, pattern, { since = 0, timeout = 150_000 } = {}) { + const deadline = Date.now() + timeout; + const after = Math.floor(since / 1000) - 5; + while (Date.now() < deadline) { + const text = await onJasellite( + `mbsync ac-mail >/dev/null 2>&1; mu index --quiet >/dev/null 2>&1; ` + + `for f in (mu find ${quote(`to:${to} maildir:/ac-mail/INBOX`)} --fields=l --sortfield=date --reverse 2>/dev/null); ` + + `if test (stat -c %Y "$f") -ge ${after}; mu view "$f"; break; end; end`, + ).catch(() => ""); + const found = text.match(pattern); + if (found) return found[1] ?? found[0]; + await new Promise((r) => setTimeout(r, 6000)); + } + throw new Error(`no letter to ${to} matching ${pattern} within ${timeout / 1000}s`); +} diff --git a/tests/browser/signup-journey.test.mjs b/tests/browser/signup-journey.test.mjs new file mode 100644 index 0000000000..f2039f5cdd --- /dev/null +++ b/tests/browser/signup-journey.test.mjs @@ -0,0 +1,227 @@ +// signup-journey.test, 2026.10.08 +// End-to-end proof of the in-page door (lib/signup-flow.mjs): a fresh browser +// types `signup` at the prompt, picks a @handle (held by /api/handle-hold), +// gives mail+signuptest@aesthetic.computer, types the six digits Auth0 +// mails, and lands signed in with that handle — without ever leaving +// aesthetic.computer. Then it logs out and back in by code (the returning-user +// path), and finally puts the account through delete-erase-and-forget-me. +// +// npm run test:signup:e2e # headless, production +// AC_HEADED=1 npm run test:signup:e2e # watch it +// AC_SIGNUP_OVERLAY=1 npm run test:signup:e2e # production, but with this +// checkout's boot/bios/signup files swapped in and /api/handle-hold +// answered locally — proves the client before it ships +// AC_SIGNUP_KEEP=1 ... # leave the test account alive +// +// The code is read off jasellite (./jasellite-mail.mjs). The browser counts as +// automated, so the funnel files these attempts under automated. +// +// Puppeteer needs a Chrome; with none downloaded, point it at the installed +// one: PUPPETEER_EXECUTABLE_PATH="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome". + +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { ACSession, CONFIG, scenario, report } from "./ac-harness.mjs"; +import { waitForLetter } from "./jasellite-mail.mjs"; + +const KEEP = process.env.AC_SIGNUP_KEEP === "1"; +const OVERLAY = process.env.AC_SIGNUP_OVERLAY === "1"; +const ROOT = join(dirname(fileURLToPath(import.meta.url)), "../.."); + +const stamp = Date.now().toString(36); +const email = `mail+signuptest${stamp}@aesthetic.computer`; +const handle = `sut${stamp}`; // ≤ 16 characters, letters and numbers +const CODE = /verification code is:\s*(\d{6})/; + +// ─── the browser ───────────────────────────────────────────────────────── +const ac = await ACSession.open(); // a fresh profile: the incognito case +const page = ac.page; + +if (OVERLAY) { + // Serve this checkout's copies of the files the door lives in, and stand in + // for the hold endpoint that isn't deployed yet. Auth0, the mailed code and + // /handle stay real. + const local = { + "/aesthetic.computer/boot.mjs": "system/public/aesthetic.computer/boot.mjs", + "/aesthetic.computer/bios.mjs": "system/public/aesthetic.computer/bios.mjs", + "/aesthetic.computer/lib/signup-flow.mjs": "system/public/aesthetic.computer/lib/signup-flow.mjs", + "/aesthetic.computer/lib/signup-model.mjs": "system/public/aesthetic.computer/lib/signup-model.mjs", + "/aesthetic.computer/lib/auth0-otp.mjs": "system/public/aesthetic.computer/lib/auth0-otp.mjs", + }; + await page.setBypassServiceWorker(true); + await page.setRequestInterception(true); + page.on("request", (request) => { + const url = new URL(request.url()); + if (url.host !== new URL(CONFIG.baseURL).host) return request.continue(); + if (local[url.pathname]) { + return request.respond({ status: 200, contentType: "text/javascript; charset=utf-8", + headers: { "cache-control": "no-store" }, body: readFileSync(join(ROOT, local[url.pathname]), "utf8") }); + } + if (url.pathname === "/api/handle-hold") { + const body = request.method() === "POST" ? { held: true, until: new Date(Date.now() + 600000).toISOString() } : { status: "free" }; + return request.respond({ status: 200, contentType: "application/json", body: JSON.stringify(body) }); + } + request.continue(); + }); +} + +const stages = []; +page.on("request", (request) => { + if (!request.url().endsWith("/api/signup-track")) return; + try { + const event = JSON.parse(request.postData()); + for (const stage of event.stages) if (!stages.includes(stage)) stages.push(stage); + } catch {} +}); +async function reached(stage, timeout = 30_000) { + const deadline = Date.now() + timeout; + while (!stages.includes(stage)) { + if (Date.now() > deadline) throw new Error(`signup never reached "${stage}" (got ${stages.join(" → ") || "nothing"})`); + await ac.wait(250); + } +} +const dialog = () => page.evaluate(() => { + const d = document.querySelector("dialog.ac-signup"); + return d?.open ? { title: d.querySelector("h1")?.textContent || "", text: d.innerText, status: d.querySelector("[role=status]")?.textContent || "" } : null; +}); +async function dialogTitled(pattern, timeout = 20_000) { + const deadline = Date.now() + timeout; + let last; + while (Date.now() < deadline) { + last = await dialog().catch(() => null); + if (last && pattern.test(last.title)) return last; + await ac.wait(200); + } + throw new Error(`expected a dialog titled ${pattern}, saw ${last ? `"${last.title}" (${last.status})` : "none"}`); +} +async function typeInto(selector, text) { + await page.waitForSelector(selector, { visible: true, timeout: 10_000 }); + await page.click(selector, { clickCount: 3 }); + await page.type(selector, text, { delay: 25 }); +} +const recent = []; +page.on("console", (m) => { recent.push(m.text().slice(0, 140)); if (recent.length > 40) recent.shift(); }); +const otpToken = () => page.evaluate(() => { try { return JSON.parse(localStorage.getItem("ac-otp-session"))?.access || null; } catch { return null; } }); +let token = null; +const signedInAs = () => page.evaluate(() => window.acUSER?.sub || null); +// The prompt can swallow keys typed while it is still settling, so give a +// command a few tries before deciding the door didn't open. +async function command(text, title) { + for (let tries = 0; tries < 3; tries++) { + // A stalled boot ignores typing, and a half-typed command can't be cleared + // with Backspace (on an empty prompt it navigates), so retries reload. + if (tries) await ac.boot("prompt"); + // bios sets `preloaded` once a real piece (not the blank init disk) boots. + await page.waitForFunction(() => window.preloaded === true, { timeout: 60_000 }).catch(() => {}); + await ac.wait(1200); + await ac.focusPrompt(); + await ac.type(text); + await ac.wait(300); + await ac.press("Enter"); + try { return await dialogTitled(title, 8000); } catch {} + } + await ac.shot(`signup-code-${text}-missing`); + console.log(" last console:", recent.slice(-6).join(" ‖ ")); + return dialogTitled(title, 1); +} + +// Each step depends on the one before, so the first failure skips the rest. +let broken = false; +const step = (name, fn) => scenario(name, async (expect) => { + if (broken) return expect(false, "skipped — an earlier step failed"); + try { expect(true, await fn()); } catch (error) { broken = true; throw error; } +}); + +let sub = null, codeAsked = 0; +const t0 = Date.now(); +const elapsed = () => `${((Date.now() - t0) / 1000).toFixed(1)}s`; +console.log(`\n🧪 signup journey (email code${OVERLAY ? ", local overlay" : ""}) · ${email} · @${handle}\n`); + +await step("prompt `signup` opens the handle step in the page", async () => { + await ac.boot("prompt"); + await command("signup", /Pick your @handle/); + if (new URL(page.url()).host !== new URL(CONFIG.baseURL).host) throw new Error(`left the site for ${page.url()}`); + await reached("started", 5000); + await ac.shot("signup-code-handle"); + return `in-page at ${elapsed()}`; +}); + +await step(`@${handle} checks free and is held`, async () => { + await typeInto("#ac-signup-handle", handle); + await page.waitForFunction(() => /is free/.test(document.querySelector("#ac-signup-check")?.textContent || ""), { timeout: 10_000 }); + await page.click("dialog.ac-signup .primary"); + await dialogTitled(/Where should we send a code/); + await reached("handle_held", 5000); + const hold = await page.evaluate(() => document.querySelector("dialog.ac-signup .hold")?.textContent); + return hold || "held"; +}); + +await step("the email step sends a six-digit code", async () => { + await typeInto("#ac-signup-email", email); + codeAsked = Date.now(); + await page.click("dialog.ac-signup .primary"); + const view = await dialogTitled(/Enter the code/, 20_000); + await reached("code_sent", 5000); + await ac.shot("signup-code-enter"); + return view.text.split("\n").find((l) => l.includes("Sent to")) || "sent"; +}); + +await step("typing the mailed code signs in and claims the handle", async () => { + const code = await waitForLetter(email, CODE, { since: codeAsked }); + await typeInto("#ac-signup-code", code); // six digits submit on their own + await reached("verified", 30_000); + await reached("completed", 30_000); + await page.waitForFunction(() => !document.querySelector("dialog.ac-signup")?.open, { timeout: 20_000 }); + await page.waitForFunction(() => window.acUSER?.sub, { timeout: 30_000 }); + sub = await signedInAs(); + token = await otpToken(); + await ac.wait(1500); + await ac.shot("signup-code-done"); + return `signed in as ${sub} at ${elapsed()}`; +}); + +await step("the handle API knows the new account", async () => { + const res = await fetch(`${CONFIG.baseURL}/handle?for=${encodeURIComponent(sub)}`); + const json = await res.json().catch(() => ({})); + if (json.handle !== handle) throw new Error(`/handle?for= said ${res.status} ${JSON.stringify(json)}`); + return `@${json.handle}`; +}); + +await step("logging out and back in by code returns the same account", async () => { + // What the logout command does for a code session (bios.mjs). + await page.evaluate(() => { localStorage.removeItem("ac-otp-session"); localStorage.removeItem("session-aesthetic"); }); + await ac.boot("prompt"); + if (await signedInAs()) throw new Error("still signed in after forgetting the session"); + await command("login", /Log in/); + await typeInto("#ac-signup-email", email); + codeAsked = Date.now(); + await page.click("dialog.ac-signup .primary"); + await dialogTitled(/Enter the code/, 20_000); + const code = await waitForLetter(email, CODE, { since: codeAsked }); + await typeInto("#ac-signup-code", code); + await page.waitForFunction(() => window.acUSER?.sub, { timeout: 45_000 }); + const again = await signedInAs(); + token = (await otpToken()) || token; + if (again !== sub) throw new Error(`came back as ${again}, not ${sub}`); + return `${again} at ${elapsed()}`; +}); + +// Cleanup runs whenever an account exists, even after a failed step. +if (!KEEP && sub) { + broken = false; + await step("delete-erase-and-forget-me locks the test account", async () => { + if (!token) throw new Error("no token to delete with — account left alive"); + const res = await fetch(`${CONFIG.baseURL}/api/delete-erase-and-forget-me`, { + method: "POST", headers: { Authorization: `Bearer ${token}` }, + }); + const json = await res.json().catch(() => ({})); + if (res.status !== 200) throw new Error(`${res.status} ${JSON.stringify(json)}`); + return `purge after ${json.purgeAfter || "?"}`; + }); +} + +await ac.browser.close(); +console.log(`\n stages ${stages.join(" → ")}`); +console.log(` account ${email} · @${handle}${KEEP ? " (kept)" : ""}\n`); +process.exit(report()); diff --git a/tests/browser/signup-password-journey.test.mjs b/tests/browser/signup-password-journey.test.mjs new file mode 100644 index 0000000000..fbd5f2ef92 --- /dev/null +++ b/tests/browser/signup-password-journey.test.mjs @@ -0,0 +1,168 @@ +// signup-password-journey.test, 2026.10.08 +// The fallback door (`?login=password`): Universal Login with a password. +// End-to-end proof that a stranger can join: a fresh browser (no cookies, +// no ~/.ac-token) types `signup` at the prompt, creates an Auth0 account as +// mail+signuptest@aesthetic.computer, gets back to AC, receives the +// verification letter, opens its link, picks a handle, and then the handle +// API answers for the new account. Afterwards the account is put through +// delete-erase-and-forget-me (locked now, purged after the grace period). +// +// npm run test:signup:e2e:password # headless, production +// AC_HEADED=1 npm run test:signup:e2e:password # watch it +// AC_SIGNUP_KEEP=1 ... # leave the test account alive +// +// The letter is read off jasellite, where mail@aesthetic.computer's maildir +// lives (toolchain/macos/SCORE.md, "Mail lives on jasellite"): mbsync ac-mail, +// mu index, mu find. Gmail delivers plus-addresses to mail@, so every run gets +// its own address and its own handle without touching anyone else's inbox. +// +// The browser counts as automated (navigator.webdriver), so the signup funnel +// files these attempts under automated and the human numbers stay clean. +// +// Puppeteer needs a Chrome; with none downloaded, point it at the installed +// one: PUPPETEER_EXECUTABLE_PATH="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome". + +import { randomBytes } from "node:crypto"; +import { ACSession, CONFIG, scenario, report } from "./ac-harness.mjs"; +import { waitForLetter } from "./jasellite-mail.mjs"; + +const KEEP = process.env.AC_SIGNUP_KEEP === "1"; + +const stamp = Date.now().toString(36); +const email = `mail+signuptest${stamp}@aesthetic.computer`; +const handle = `sut${stamp}`; // ≤ 16 characters, letters and numbers +const password = `Ac-${randomBytes(12).toString("base64url")}!9`; +const base = new URL(CONFIG.baseURL); + +// ─── the browser ───────────────────────────────────────────────────────── +const ac = await ACSession.open(); // a fresh profile: the incognito case +const page = ac.page; +const stages = []; +page.on("request", (request) => { + if (!request.url().endsWith("/api/signup-track")) return; + try { + const event = JSON.parse(request.postData()); + for (const stage of event.stages) if (!stages.includes(stage)) stages.push(stage); + } catch {} +}); +async function reached(stage, timeout = 30_000) { + const deadline = Date.now() + timeout; + while (!stages.includes(stage)) { + if (Date.now() > deadline) throw new Error(`signup never reached "${stage}" (got ${stages.join(" → ") || "nothing"})`); + await ac.wait(250); + } +} +const dialogText = () => page.evaluate(() => document.querySelector("dialog.ac-signup")?.innerText || ""); +const clickDialogButton = (label) => page.evaluate((label) => { + const button = [...document.querySelectorAll("dialog.ac-signup button")].find((b) => b.textContent.trim() === label); + button?.click(); + return !!button; +}, label); + +// Each step depends on the one before, so the first failure skips the rest. +let broken = false; +const step = (name, fn) => scenario(name, async (expect) => { + if (broken) return expect(false, "skipped — an earlier step failed"); + try { expect(true, await fn()); } catch (error) { broken = true; throw error; } +}); + +let sub = null, ticket = null; +const t0 = Date.now(); +const elapsed = () => `${((Date.now() - t0) / 1000).toFixed(1)}s`; +console.log(`\n🧪 signup journey · ${email} · @${handle}\n`); + +await step("prompt `signup` opens Auth0's create-account form", async () => { + await ac.boot("prompt?login=password"); + await ac.focusPrompt(); + await ac.type("signup"); + await ac.press("Enter"); + await page.waitForSelector("#email", { timeout: 30_000 }); + const host = new URL(page.url()).host; + if (!/\/u\/signup/.test(page.url())) throw new Error(`landed on ${page.url()} instead of the signup screen`); + await reached("started", 5000); + await ac.shot("signup-auth0-form"); + return `${host}/u/signup at ${elapsed()}`; +}); + +await step("a new email + password gets back to AC and asks for verification", async () => { + await page.type("#email", email, { delay: 15 }); + await page.type("#password", password, { delay: 15 }); + await Promise.all([ + page.waitForFunction((host) => location.host === host, { timeout: 45_000 }, base.host), + page.click("button[type=submit]"), + ]).catch(async (error) => { + const why = await page.evaluate(() => + [...document.querySelectorAll("[id*=error],.ulp-input-error-message,[class*=error]")].map((e) => e.innerText).filter(Boolean).join(" | ")); + throw new Error(why ? `Auth0 refused: ${why}` : error.message); + }); + await reached("auth_returned"); + await reached("verification_shown"); + await page.waitForFunction(() => document.querySelector("dialog.ac-signup")?.open, { timeout: 15_000 }); + const text = await dialogText(); + if (!/Check your email/i.test(text)) throw new Error(`verification dialog says: ${text.slice(0, 120)}`); + sub = (await page.evaluate(async () => (await window.auth0Client?.getUser())?.sub)) || null; + await ac.shot("signup-check-email"); + return `${sub || "?"} at ${elapsed()}`; +}); + +await step("the verification letter arrives and its link verifies", async () => { + ticket = await waitForLetter(email, /https:\/\/[^\s"'<>]+email-verification\?ticket=[^\s"'<>]+/); + const tab = await ac.browser.newPage(); + // Auth0 verifies the ticket and redirects to AC with the outcome in the + // query: ?success=true&message=Your email was verified… + const outcomes = []; + tab.on("framenavigated", (frame) => { if (frame === tab.mainFrame()) outcomes.push(frame.url()); }); + await tab.goto(ticket, { waitUntil: "domcontentloaded", timeout: 45_000 }); + await ac.wait(2000); + await tab.close(); + const landed = outcomes.map((u) => new URL(u)).find((u) => u.searchParams.has("success")); + if (landed?.searchParams.get("success") !== "true") + throw new Error(`verification did not succeed: ${landed?.searchParams.get("message") || outcomes.at(-1)}`); + return `letter at ${elapsed()} · "${landed.searchParams.get("message")}"`; +}); + +await step("“I’ve verified my email” leads to the handle form", async () => { + await page.bringToFront(); + if (!(await clickDialogButton("I’ve verified my email"))) throw new Error("no verified button in the dialog"); + await reached("handle_shown", 20_000); + await page.waitForSelector("#ac-signup-handle", { visible: true, timeout: 10_000 }); + await ac.shot("signup-handle-form"); + return `at ${elapsed()}`; +}); + +await step(`choosing @${handle} completes the signup`, async () => { + await page.type("#ac-signup-handle", handle, { delay: 20 }); + await page.evaluate(() => document.querySelector("dialog.ac-signup .primary")?.click()); + await reached("completed", 30_000); + await ac.wait(1500); + await ac.shot("signup-complete"); + return `at ${elapsed()}`; +}); + +await step("the handle API knows the new account", async () => { + if (!sub) throw new Error("no Auth0 sub was read from the page"); + const res = await fetch(`${CONFIG.baseURL}/handle?for=${encodeURIComponent(sub)}`); + const json = await res.json().catch(() => ({})); + if (json.handle !== handle) throw new Error(`/handle?for= said ${res.status} ${JSON.stringify(json)}`); + return `@${json.handle}`; +}); + +// Cleanup runs whenever an account exists, even after a failed step. +if (!KEEP && sub) { + broken = false; + await step("delete-erase-and-forget-me locks the test account", async () => { + const token = await page.evaluate(() => window.auth0Client?.getTokenSilently()); + if (!token) throw new Error("no token to delete with — account left alive"); + const res = await fetch(`${CONFIG.baseURL}/api/delete-erase-and-forget-me`, { + method: "POST", headers: { Authorization: `Bearer ${token}` }, + }); + const json = await res.json().catch(() => ({})); + if (res.status !== 200) throw new Error(`${res.status} ${JSON.stringify(json)}`); + return `purge after ${json.purgeAfter || "?"}`; + }); +} + +await ac.browser.close(); +console.log(`\n stages ${stages.join(" → ")}`); +console.log(` account ${email} · @${handle}${KEEP ? " (kept)" : ""}\n`); +process.exit(report()); diff --git a/toolchain/analytics/pulse.mjs b/toolchain/analytics/pulse.mjs new file mode 100644 index 0000000000..03cb53a3b4 --- /dev/null +++ b/toolchain/analytics/pulse.mjs @@ -0,0 +1,233 @@ +#!/usr/bin/env node +// pulse.mjs — the Aesthetic network on one card: the last 24 hours plus a daily chart. +// +// npm run pulse # 24h card + 14-day chart +// npm run pulse -- --days 30 # longer chart (max 35) +// +// Reads the same numbers as the analytics MCP: it asks the shared daemon on +// :7788 when it is up and otherwise spawns toolchain/mcp/analytics-mcp.mjs over +// stdio. Sign-ups come from signup-report.mjs on lith over ssh. Read-only. +// Colour only on a TTY (and never with NO_COLOR), so the card pastes cleanly. + +import { execFile, spawn } from "node:child_process"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), "../.."); +const DAEMON = "http://127.0.0.1:7788/mcp/analytics"; +const LITH = "root@lith.aesthetic.computer"; +const SSH_KEY = join(ROOT, "aesthetic-computer-vault/home/.ssh/id_rsa"); +const pexec = promisify(execFile); + +const args = process.argv.slice(2); +let days = 14; +for (let i = 0; i < args.length; i++) { + if (args[i] === "--days") days = Number(args[++i]); + else throw new Error("Usage: pulse.mjs [--days 2..35]"); +} +if (!Number.isInteger(days) || days < 2 || days > 35) throw new Error("--days must be 2..35"); + +// 🎨 Ink + +const color = process.stdout.isTTY && !process.env.NO_COLOR; +const ink = (code) => (s) => color ? `\x1b[${code}m${s}\x1b[0m` : String(s); +const dim = ink("2"), bold = ink("1"), cyan = ink("36"), pink = ink("35"), green = ink("32"), yellow = ink("33"); + +const BLOCKS = " ▏▎▍▌▋▊▉█"; +const SPARK = "▁▂▃▄▅▆▇█"; +const bar = (n, max, width) => { + const eighths = max ? Math.round((n / max) * width * 8) : 0; + const full = Math.floor(eighths / 8), part = eighths % 8; + return ("█".repeat(full) + (part ? BLOCKS[part] : "") || (n ? "▏" : "")).padEnd(width); +}; +const spark = (values) => { + const max = Math.max(...values); + return values.map((v) => v == null ? " " : !max || !v ? dim("·") : SPARK[Math.min(7, Math.floor((v / max) * 7.999))]).join(""); +}; +const num = (n) => Number(n || 0).toLocaleString("en-US"); +const pad = (s, w) => String(s).padStart(w); + +// 🔌 Data + +let stdioServer; +async function tool(name, toolArgs = {}) { + const message = { jsonrpc: "2.0", id: Math.random(), method: "tools/call", params: { name, arguments: toolArgs } }; + let response; + try { + const res = await fetch(DAEMON, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(message) }); + response = await res.json(); + } catch { + response = await viaStdio(message); + } + const result = response.result; + if (!result || result.isError) throw new Error(`${name}: ${result?.content?.[0]?.text || response.error?.message}`); + return JSON.parse(result.content[0].text); +} + +// No daemon: one stdio child serves every call, matched by id. +function viaStdio(message) { + if (!stdioServer) { + const child = spawn("node", [join(ROOT, "toolchain/mcp/analytics-mcp.mjs")], { stdio: ["pipe", "pipe", "ignore"] }); + const waiting = new Map(); + let buffer = ""; + child.stdout.on("data", (chunk) => { + buffer += chunk; + let newline; + while ((newline = buffer.indexOf("\n")) >= 0) { + const line = buffer.slice(0, newline); buffer = buffer.slice(newline + 1); + try { const reply = JSON.parse(line); waiting.get(reply.id)?.(reply); waiting.delete(reply.id); } catch {} + } + }); + stdioServer = { child, waiting }; + } + return new Promise((resolve) => { + stdioServer.waiting.set(message.id, resolve); + stdioServer.child.stdin.write(JSON.stringify(message) + "\n"); + }); +} + +async function signups(reportDays) { + const remote = `cd /opt/ac/system && node --env-file=.env ../toolchain/analytics/signup-report.mjs --days ${reportDays}`; + const { stdout } = await pexec("ssh", ["-i", SSH_KEY, "-o", "BatchMode=yes", "-o", "ConnectTimeout=10", LITH, remote], + { timeout: 90_000, maxBuffer: 8 * 1024 * 1024 }); + return JSON.parse(stdout.slice(stdout.indexOf("{"))); // database.mjs logs a line first +} + +const soft = (promise) => promise.catch((error) => ({ error: error.message })); +const [visits, daily, refs, activity, downloads, opens, signupDays, signupToday] = await Promise.all([ + tool("visits_report", { hours: 24 }), + tool("daily_metrics", { days, appStore: true }), + soft(tool("network_referrers", { hours: 24, limit: 40 })), + soft(tool("account_activity", { hours: 24, limit: 500 })), + soft(tool("direct_downloads", { days: 1 })), + soft(tool("app_opens", { days: 1 })), + soft(signups(days)), + soft(signups(1)), +]); +stdioServer?.child.kill(); + +// 📅 Days, oldest first, ending yesterday (the rollup only holds finished UTC days). + +const dayKeys = []; +for (let i = days; i >= 1; i--) dayKeys.push(new Date(Date.now() - i * 86400000).toISOString().slice(0, 10)); +const dayRow = (key) => daily.days[key] || {}; +const siteVisits = (key) => Object.values(dayRow(key).visits || {}).reduce((n, s) => n + (s.visits || 0), 0); +const siteEngaged = (key) => Object.values(dayRow(key).visits || {}).reduce((n, s) => n + (s.engaged || 0), 0); +const perSite = (site) => dayKeys.map((k) => dayRow(k).visits?.[site]?.visits || 0); +const appOpens = (key) => Object.values(dayRow(key).opens || {}).reduce((n, a) => n + (a.opens || 0), 0); +const dmgs = (key) => Object.values(dayRow(key).downloads || {}).reduce((n, a) => n + (a.downloads || 0), 0); +const storeFirst = (key) => Object.values(dayRow(key).appStore || {}) + .reduce((n, kinds) => n + Object.entries(kinds).filter(([k]) => k.startsWith("First-time")).reduce((m, [, v]) => m + v, 0), 0); +const signupsOn = (key) => signupDays.daily?.find((d) => d.end.slice(0, 10) === key)?.accountsCreated ?? 0; + +// 🖨 Card + +const out = []; +const line = (s = "") => out.push(` ${dim("│")} ${s}`); +const label = (s) => bold(s.padEnd(6)); +const now = new Date(); +const stamp = `${now.toISOString().slice(5, 10).replace("-", "/")} ${now.toISOString().slice(11, 16)} utc`; +out.push("", ` ${dim("╭─")} ${bold("aesthetic network")} ${dim("· 24h ─────────────────────────────")} ${dim(stamp)} ${dim("─")}`); +line(); + +// WEB +const t = visits.totals; +line(`${label("WEB")} ${bold(num(t.visits))} visits ${green(num(t.engaged))} engaged ${dim(`${num(t.automated)} bots dropped`)}`); +line(` ${dim(`${"site".padEnd(19)} ${"24h".padStart(28)} ${days}d trend`)}`); +const sites = Object.entries(visits.sites); +const top = sites.slice(0, 5), rest = sites.slice(5); +const maxSite = top[0]?.[1].visits || 0; +for (const [site, s] of top) { + const extra = [s.actions.painting_edited && `${s.actions.painting_edited} edits`, s.actions.painting_saved && `${s.actions.painting_saved} saves`, + s.actions.media_started && `${s.actions.media_started} plays`].filter(Boolean).join(" · "); + line(` ${site.padEnd(19)} ${cyan(bar(s.visits, maxSite, 24))} ${pad(s.visits, 4)} ${spark(perSite(site))}${extra ? ` ${dim(extra)}` : ""}`); +} +if (rest.length) { + const n = rest.reduce((m, [, s]) => m + s.visits, 0); + line(` ${`${rest.length} others`.padEnd(19)} ${cyan(bar(n, maxSite, 24))} ${pad(n, 4)} ${dim(rest.map(([s]) => s).join(" "))}`); +} +line(); + +// DAILY — a column chart of human visits, engaged visits in brighter ink. +const totals = dayKeys.map(siteVisits), engaged = dayKeys.map(siteEngaged); +const peak = Math.max(...totals, 1), rows = 7; +line(`${label("DAILY")} human visits per UTC day ${dim(`· peak ${num(peak)} · ${color ? "bright = engaged" : "█ visits"}`)}`); +for (let r = rows; r >= 1; r--) { + const cells = totals.map((v, i) => { + const h = (v / peak) * rows, e = (engaged[i] / peak) * rows; + const glyph = h >= r ? "█" : h > r - 1 ? SPARK[Math.max(0, Math.floor((h - (r - 1)) * 8) - 1)] : " "; + return (e >= r - 0.5 ? green : cyan)(glyph.repeat(2)); + }); + const axis = r === rows ? pad(num(peak), 5) : r === 1 ? pad(0, 5) : " "; + line(`${dim(axis)} ${dim("┤")}${cells.join(" ")}`); +} +line(` ${dim("└" + dayKeys.map(() => "──").join("─"))}`); +line(` ${dim(dayKeys.map((k, i) => i % 2 === (days - 1) % 2 ? k.slice(8) : " ").join(" "))}`); +line(); + +// LIVE — signed-in accounts and what they opened. +if (!activity.error) { + const byAccount = {}, byPiece = {}; + for (const e of activity.events) { + byAccount[e.account] = (byAccount[e.account] || 0) + 1; + if (e.action === "piece_opened") byPiece[e.piece] = (byPiece[e.piece] || 0) + 1; + } + const ranked = (o, n) => Object.entries(o).sort((a, b) => b[1] - a[1]).slice(0, n); + line(`${label("LIVE")} ${bold(activity.totals.accounts)} signed-in accounts · ${num(activity.totals.events)} events`); + line(` ${dim("loudest")} ${ranked(byAccount, 6).map(([a]) => a).join(" ")}`); + line(` ${dim("pieces ")} ${ranked(byPiece, 6).map(([p, n]) => `${p} ${dim(n)}`).join(" ")}`); + if (activity.truncated) line(` ${dim(`(accounts & pieces from the latest ${activity.events.length} events)`)}`); + line(); +} + +// SIGNUP — accounts made, and how far today's attempts got. +if (!signupToday.error) { + const f = { signup: { att: 0, auth: 0, done: 0 }, login: { att: 0, auth: 0, done: 0 } }; + for (const row of signupToday.funnel?.rows || []) { + const m = f[row._id.mode]; if (!m) continue; + m.att += row.attempts; m.auth += row.auth_returned; m.done += row.completed; + } + const made = signupToday.last24Hours?.accountsCreated ?? 0; + const latest = signupDays.latestHandle; + line(`${label("SIGNUP")} ${bold(made)} new accounts ${spark(dayKeys.map(signupsOn))} ${dim(`${days}d`)}${latest ? ` ${dim(`latest @${latest.value} ${latest.when.slice(5, 10)}`)}` : ""}`); + const flow = (m) => `${m.att} tried → ${m.auth} back from auth → ${m.done} done`; + line(` ${dim("sign up")} ${f.signup.att && !f.signup.done ? yellow(flow(f.signup)) : flow(f.signup)}`); + line(` ${dim("log in ")} ${flow(f.login)}`); + const give = visits.sites["give.aesthetic.computer"]?.visits || 0; + const support = sites.reduce((n, [, s]) => n + (s.surfaces.support || 0), 0); + line(` ${dim("support")} give.aesthetic.computer ${give} visits · support pages ${support}`); + line(); +} + +// APPS +const appLines = []; +if (!downloads.error) for (const [app, d] of Object.entries(downloads.apps)) { + const plats = Object.entries(d.platforms).map(([p, n]) => `${n} ${p}`).join(" · "); + appLines.push(`${app.padEnd(9)} ${bold(d.downloads)} dmg ${dim(`(${plats || "—"})`)}`); +} +if (!opens.error) { + const today = now.toISOString().slice(0, 10); + const opened = Object.entries(opens.apps).map(([app, a]) => [app, a.daily[today]?.opens || 0]).filter(([, n]) => n); + if (opened.length) appLines.push(`opens ${opened.map(([a, n]) => `${a} ${n}`).join(" · ")} ${dim("(today so far)")}`); +} +line(`${label("APPS")} ${appLines[0] || dim("no direct downloads")}`); +for (const l of appLines.slice(1)) line(` ${l}`); +line(` ${dim("opens ")} ${spark(dayKeys.map(appOpens))} ${dim("dmg")} ${spark(dayKeys.map(dmgs))} ${dim("app store new")} ${spark(dayKeys.map(storeFirst))}`); +line(); + +// REFS +if (!refs.error) { + const byHost = {}; + for (const r of refs.visits) { + const host = (r.referrerHost || "direct").replace(/^(www|m|l)\./, ""); + if (visits.sites[host] || host.endsWith("aesthetic.computer") || host.endsWith("sotce.net") || host === "sotce.com") continue; + byHost[host] = (byHost[host] || 0) + r.visits; + } + const ranked = Object.entries(byHost).sort((a, b) => b[1] - a[1]).slice(0, 7); + line(`${label("REFS")} ${ranked.map(([h, n]) => `${h === "direct" ? dim(h) : pink(h)} ${n}`).join(dim(" · "))}`); +} +const failed = [refs, activity, downloads, opens, signupToday].filter((r) => r.error).map((r) => r.error.split(":")[0]); +if (failed.length) line(dim(`(unavailable: ${failed.join(", ")})`)); +out.push(` ${dim("╰─")}`, ""); +console.log(out.join("\n"));