diff --git a/lith/server.mjs b/lith/server.mjs index c53aa6bdea..9c0107b4b6 100644 --- a/lith/server.mjs +++ b/lith/server.mjs @@ -157,7 +157,7 @@ function captureRawBody(req, _res, buf) { } // --- Body parsing --- -app.use(["/api/visit-track", "/.netlify/functions/visit-track"], +app.use(["/api/visit-track", "/.netlify/functions/visit-track", "/api/signup-track", "/.netlify/functions/signup-track"], express.text({ type: "*/*", limit: "2kb" })); app.use(express.json({ limit: "50mb", verify: captureRawBody })); app.use(express.urlencoded({ extended: true, limit: "50mb", verify: captureRawBody })); diff --git a/system/backend/authorization.mjs b/system/backend/authorization.mjs index acbfc4f327..071ad985ae 100644 --- a/system/backend/authorization.mjs +++ b/system/backend/authorization.mjs @@ -609,6 +609,16 @@ const tokenCache = { const TOKEN_CACHE_MS = 23 * 60 * 60 * 1000; // 23 hours // 📚 Library (Useful functions used throughout the file.) +// Resend verification without changing an address or touching a sister tenant. +export async function resendVerificationEmail(sub) { + const { got } = await import("got"); + const token = await getAccessToken(got, "aesthetic"); + await got(`${aestheticBaseURI}/api/v2/jobs/verification-email`, { + method: "POST", headers: { Authorization: `Bearer ${token}` }, + json: { user_id: sub }, timeout: { request: 10000 }, + }); +} + // Obtain an auth0 access token for our M2M API. async function getAccessToken(got, tenant = "aesthetic") { // 🚀 Check in-memory token cache first diff --git a/system/backend/signup-report.mjs b/system/backend/signup-report.mjs new file mode 100644 index 0000000000..dda5213357 --- /dev/null +++ b/system/backend/signup-report.mjs @@ -0,0 +1,98 @@ +// Auth0 account records are the authority; webhook completeness is diagnostic. +// Read-only. No email, IP, token or submitted handle is returned in this report. +export async function readSignupAccounts({ start, end, fetch: request = fetch, env = process.env }) { + const base = "https://aesthetic.us.auth0.com"; + async function json(url, options = {}) { + const response = await request(url, { ...options, signal: AbortSignal.timeout(15000) }); + if (!response.ok) throw new Error(`Auth0 ${new URL(url).pathname}: HTTP ${response.status}`); + return response.json(); + } + const token = await json(`${base}/oauth/token`, { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ client_id: env.AUTH0_M2M_CLIENT_ID, client_secret: env.AUTH0_M2M_SECRET, + audience: `${base}/api/v2/`, grant_type: "client_credentials" }), + }); + const headers = { Authorization: `Bearer ${token.access_token}` }; + async function window(from, until) { + const rows = []; + for (let page = 0; page < 10; page++) { + const url = new URL(`${base}/api/v2/users`); + url.search = new URLSearchParams({ q: `created_at:[${from.toISOString()} TO ${until.toISOString()}]`, + search_engine: "v3", sort: "created_at:1", include_totals: "true", per_page: "100", page: String(page), + fields: "user_id,created_at,email_verified", include_fields: "true" }); + const result = await json(url, { headers }); + // Auth0 caps search at 1000. Split the time window rather than silently + // calling that cap a count. Deduplicate inclusive split boundaries later. + if (page === 0 && result.total >= 1000) { + if (+until - +from <= 1000) throw new Error("Signup interval exceeds Auth0 search capacity"); + const middle = new Date(Math.floor((+from + +until) / 2)); + return [...await window(from, middle), ...await window(middle, until)]; + } + if (!Array.isArray(result.users) || !Number.isFinite(result.total)) throw new Error("Invalid Auth0 signup response"); + rows.push(...result.users); + if (rows.length >= result.total) return rows; + if (result.users.length === 0) throw new Error("Incomplete Auth0 signup response"); + } + throw new Error("Incomplete Auth0 signup search"); + } + const users = await window(start, end); + return [...new Map(users.filter(row => new Date(row.created_at) >= start && new Date(row.created_at) < end) + .map(row => [row.user_id, row])).values()]; +} + +export function summarizeSignupCohort(accounts, handleOwners, handleEvents, start, end) { + const cohort = accounts.filter(row => new Date(row.created_at) >= start && new Date(row.created_at) < end); + const completed = cohort.filter(row => handleOwners.has(row.user_id)).length; + return { start, end, accountsCreated: cohort.length, + emailVerifiedNow: cohort.filter(row => row.email_verified === true).length, + cohortWithHandleNow: completed, + accountToHandlePercent: cohort.length ? Math.round(completed / cohort.length * 1000) / 10 : null, + // Existing accounts can claim a first handle; this is deliberately separate. + handlesCreated: handleEvents.filter(row => row.when >= start && row.when < end).length }; +} + +export async function signupReport({ db, accounts, start, end }) { + const ids = accounts.map(row => row.user_id); + const owners = await db.collection("@handles").find({ _id: { $in: ids } }, { projection: { _id: 1 } }).toArray(); + const handles = await db.collection("logs").find({ action: "handle:create", when: { $gte: start, $lt: end } }, + { projection: { _id: 0, when: 1 } }).toArray(); + const ownerSet = new Set(owners.map(row => row._id)); + const summary = (from, until) => summarizeSignupCohort(accounts, ownerSet, handles, from, until); + const daily = []; + for (let from = +start; from < +end; from += 86400000) daily.push(summary(new Date(from), new Date(Math.min(from + 86400000, +end)))); + const latestHandle = await db.collection("logs").find({ action: "handle:create" }, + { projection: { _id: 0, value: 1, when: 1 } }).sort({ when: -1 }).limit(1).toArray(); + const latestWebhook = await db.collection("users").find({ when: { $exists: true } }, + { projection: { _id: 0, when: 1 } }).sort({ when: -1 }).limit(1).toArray(); + const earliestVisit = await db.collection("network-visits").find({ property: "aesthetic.computer" }, + { projection: { _id: 0, startedAt: 1 } }).sort({ startedAt: 1 }).limit(1).toArray(); + const traffic = await db.collection("network-visits").aggregate([ + { $match: { property: "aesthetic.computer", startedAt: { $gte: start, $lt: end }, automated: { $ne: true } } }, + { $group: { _id: null, visits: { $sum: 1 }, interacted: { $sum: { $cond: ["$interacted", 1, 0] } } } }, + { $project: { _id: 0 } }, + ]).toArray(); + const { SIGNUP_STAGES } = await import("../public/aesthetic.computer/lib/signup-model.mjs"); + const funnel = await db.collection("signup-attempts").aggregate([ + { $match: { startedAt: { $gte: start, $lt: end } } }, + { $group: { _id: { mode: "$mode", source: "$source", property: "$property", referrerHost: "$referrerHost" }, attempts: { $sum: 1 }, + ...Object.fromEntries(SIGNUP_STAGES.map(stage => [stage, { $sum: { $cond: [`$stages.${stage}`, 1, 0] } }])) } }, + { $sort: { attempts: -1 } }, + ]).toArray(); + const earliestAttempt = await db.collection("signup-attempts").find({}, { projection: { _id: 0, startedAt: 1 } }).sort({ startedAt: 1 }).limit(1).toArray(); + return { + window: { start, end }, source: "Auth0 aesthetic tenant, current account records; handle:create server logs", + total: summary(start, end), + last24Hours: +end - +start >= 86400000 ? summary(new Date(+end - 86400000), end) : null, + last7Days: +end - +start >= 7 * 86400000 ? summary(new Date(+end - 7 * 86400000), end) : null, + previous7Days: +end - +start >= 14 * 86400000 ? summary(new Date(+end - 14 * 86400000), new Date(+end - 7 * 86400000)) : null, + daily, latestHandle: latestHandle[0] || null, + diagnostics: { latestWebhookSignup: latestWebhook[0]?.when || null, + webhookBehindAccounts: accounts.some(row => new Date(row.created_at) > (latestWebhook[0]?.when || new Date(0))) }, + traffic: { ...(traffic[0] || { visits: 0, interacted: 0 }), earliestRetainedVisit: earliestVisit[0]?.startedAt || null }, + funnel: { earliestRetainedAttempt: earliestAttempt[0]?.startedAt || null, rows: funnel }, + notes: ["Account and handle completion reflect current records; deleted/merged accounts are not creation-event counts.", + "Anonymous funnel attempts are client-reported, not unique people or authoritative account creations. Login, signup and handle-only attempts stay separate.", + "Visits and attempts expire after 35 days; opt-outs, blocked requests and uninstrumented clients are absent. No pre-deployment funnel is inferred.", + "Traffic is page visits, not unique visitors. Do not divide unrelated account totals by visits to claim a conversion rate."], + }; +} diff --git a/system/backend/signup-status.mjs b/system/backend/signup-status.mjs new file mode 100644 index 0000000000..605f4f451b --- /dev/null +++ b/system/backend/signup-status.mjs @@ -0,0 +1,38 @@ +import { respond } from "./http.mjs"; + +// Only the caller's own account. Never select an account by submitted email/sub. +export function createSignupStatusHandler({ authorize, userEmailFromID, handleFor, forgetAuthorizations, resendVerificationEmail }) { + const rates = new Map(); + return async event => { + const reply = (status, body) => respond(status, 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" }); + if (event.httpMethod === "POST") { + if (event.isBase64Encoded || typeof event.body !== "string" || event.body.length > 128) return reply(400, { message: "Invalid request" }); + try { if (JSON.parse(event.body)?.action !== "resend") return reply(400, { message: "Invalid action" }); } + catch { return reply(400, { message: "Invalid JSON" }); } + } + try { + const user = await authorize(event.headers || {}, "aesthetic"); + if (!user?.sub) return reply(401, { message: "unauthorized" }); + const now = Date.now(); + for (const [key, rate] of rates) if (rate.until <= now) rates.delete(key); + if (!rates.has(user.sub) && rates.size >= 10000) return reply(429, { message: "Try again later" }); + const rate = rates.get(user.sub) || { until: now + 60000, count: 0, resent: false }; + rates.set(user.sub, rate); + if (++rate.count > 12 || (event.httpMethod === "POST" && rate.resent)) return reply(429, { message: "Try again in a minute" }); + // The login token can predate verification; check Auth0's current record. + const profile = await userEmailFromID(user.sub, "aesthetic"); + if (typeof profile?.email_verified !== "boolean") return reply(503, { message: "Account check unavailable" }); + if (event.httpMethod === "POST") { + if (profile.email_verified) return reply(400, { message: "Already verified" }); + rate.resent = true; + await resendVerificationEmail(user.sub); + return reply(200, { sent: true }); + } + if (profile.email_verified) forgetAuthorizations(user.sub); + const handle = await handleFor(user.sub); + return reply(200, { verified: profile.email_verified, handle: typeof handle === "string" ? handle : null }); + } catch { return reply(503, { message: "Account check unavailable" }); } + }; +} diff --git a/system/backend/signup-tracking.mjs b/system/backend/signup-tracking.mjs new file mode 100644 index 0000000000..9625944642 --- /dev/null +++ b/system/backend/signup-tracking.mjs @@ -0,0 +1,47 @@ +import { createHmac, randomBytes } from "node:crypto"; +import { respond } from "./http.mjs"; +import { automatedVisit } from "../public/aesthetic.computer/lib/visit-model.mjs"; +import { SIGNUP_COLLECTION, validateSignupEvent, signupUpdate } from "../public/aesthetic.computer/lib/signup-model.mjs"; + +export function createSignupTrackingHandler({ connect }) { + let indexes; + const rates = new Map(), salt = randomBytes(32); + return async event => { + const headers = { "Cache-Control": "no-store" }; + const reply = (status, body = "") => respond(status, body, headers); + if (event.httpMethod === "OPTIONS") return reply(204); + if (event.httpMethod !== "POST") return reply(405); + if (event.isBase64Encoded || typeof event.body !== "string" || event.body.length > 2048) return reply(400); + let body; + try { body = JSON.parse(event.body); } catch { return reply(400); } + const value = validateSignupEvent(body, event.headers?.origin); + if (!value) return reply(400); + if (event.headers?.dnt === "1" || event.headers?.["sec-gpc"] === "1" || value.automated || + automatedVisit({ userAgent: event.headers?.["user-agent"] })) return reply(204); + // Rate keys live only in memory. No address, UA, email, handle, URL or token + // enters this collection, and nothing is forwarded to third-party analytics. + const now = new Date(); + for (const [key, rate] of rates) if (rate.until <= +now) rates.delete(key); + const key = createHmac("sha256", salt).update(event.headers?.["cf-connecting-ip"] || event.headers?.["x-forwarded-for"] || "unknown").digest("hex"); + if (!rates.has(key) && rates.size >= 10000) return reply(429); + const rate = rates.get(key) || { count: 0, until: +now + 60000 }; + rates.set(key, rate); + if (++rate.count > 60) return reply(429); + try { + const { db } = await connect(); + const collection = db.collection(SIGNUP_COLLECTION); + indexes ||= Promise.all([ + collection.createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 }), + collection.createIndex({ startedAt: 1, mode: 1 }), + ]).catch(error => { indexes = null; throw error; }); + await indexes; + const filter = { _id: `${value.property}:${value.id}` }; + try { await collection.updateOne(filter, signupUpdate(value, now), { upsert: true }); } + catch (error) { + if (error.code !== 11000) throw error; + await collection.updateOne(filter, signupUpdate(value, now)); + } + return reply(204); + } catch { return reply(503); } + }; +} diff --git a/system/netlify/functions/handle.mjs b/system/netlify/functions/handle.mjs index 6bddc7426f..be8952793e 100644 --- a/system/netlify/functions/handle.mjs +++ b/system/netlify/functions/handle.mjs @@ -378,7 +378,7 @@ export async function handler(event, context) { return respond(400, { message: "same" }); } - await handles.updateOne({ _id: primarySub }, { $set: { handle } }); + await handles.updateOne({ _id: primarySub }, { $set: { handle, updatedAt: new Date() } }); if (!primarySub.startsWith("sotce-")) { await logger.log(`@${existingUser.handle} is now @${handle}`, { @@ -397,7 +397,7 @@ export async function handler(event, context) { if (await handleQuarantined(database.db, handle)) throw new Error("taken"); // Add a new `@handles` document for this user. - await handles.insertOne({ _id: primarySub, handle }); + await handles.insertOne({ _id: primarySub, handle, createdAt: new Date(), updatedAt: new Date() }); if (!primarySub.startsWith("sotce-")) { await logger.log(`hi @${handle}`, { diff --git a/system/netlify/functions/signup-status.mjs b/system/netlify/functions/signup-status.mjs new file mode 100644 index 0000000000..75078a21aa --- /dev/null +++ b/system/netlify/functions/signup-status.mjs @@ -0,0 +1,3 @@ +import { authorize, userEmailFromID, handleFor, forgetAuthorizations, resendVerificationEmail } from "../../backend/authorization.mjs"; +import { createSignupStatusHandler } from "../../backend/signup-status.mjs"; +export const handler = createSignupStatusHandler({ authorize, userEmailFromID, handleFor, forgetAuthorizations, resendVerificationEmail }); diff --git a/system/netlify/functions/signup-track.mjs b/system/netlify/functions/signup-track.mjs new file mode 100644 index 0000000000..50eef310b9 --- /dev/null +++ b/system/netlify/functions/signup-track.mjs @@ -0,0 +1,3 @@ +import { connect } from "../../backend/database.mjs"; +import { createSignupTrackingHandler } from "../../backend/signup-tracking.mjs"; +export const handler = createSignupTrackingHandler({ connect }); diff --git a/system/public/aesthetic.computer/bios.mjs b/system/public/aesthetic.computer/bios.mjs index 6bfe47c526..8b5b093937 100644 --- a/system/public/aesthetic.computer/bios.mjs +++ b/system/public/aesthetic.computer/bios.mjs @@ -48,6 +48,8 @@ import { createWebGLBlitter } from "./lib/webgl-blit.mjs"; import * as CaptureSession from "./lib/capture-session.mjs"; import * as SoundOnFilm from "./lib/sound-on-film.mjs"; +window.addEventListener("ac:handle-created", () => captureProductAction("handle_created")); + // import * as TwoD from "./lib/2d.mjs"; // 🆕 2D GPU Renderer. const TwoD = undefined; const { @@ -643,6 +645,7 @@ function preserveViewParams(path) { function performHistoryRewrite(path, historical) { if (checkPackMode()) return; + window.acSignup?.remember(window.location.href); path = preserveViewParams(path); if (historical) { console.log("Rewriting to:", path); @@ -13628,6 +13631,20 @@ async function boot(parsed, bpm = 60, resolution, debug) { return; } + if (type === "onboarding:open") { + if (window.acSignup && window.auth0Client && !window.acTOKEN) { + window.acSignup.resume(window.auth0Client, window.acUSER, true); + } else { + send({ type: "jump", content: { piece: "prompt~handle " } }); + } + return; + } + if (type === "onboarding:complete") { + if (window.acSignup && !window.acTOKEN) window.acSignup.complete(); + else send({ type: "jump", content: { piece: "chat" } }); + return; + } + // Authenticate / signup or login a user. if (type === "login") { if (window.self !== window.top) { diff --git a/system/public/aesthetic.computer/boot.mjs b/system/public/aesthetic.computer/boot.mjs index 01fbc05938..15eee7006b 100644 --- a/system/public/aesthetic.computer/boot.mjs +++ b/system/public/aesthetic.computer/boot.mjs @@ -1,4 +1,5 @@ import { createBootDiagnostics } from "./lib/boot-diagnostics.mjs"; +import { createSignupFlow } from "./lib/signup-flow.mjs"; import { NOPAINT_SESSION_SEED_KEY, noPaintStartingPiece } from "./lib/nopaint-navigation.mjs"; // `aesthetic.computer` Bootstrap, 23.02.16.19.23 @@ -1520,6 +1521,7 @@ const skipAuth = window.acNOAUTH || (!likelyLoggedIn && !sandboxed && !location. // 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 @@ -1528,18 +1530,24 @@ if (!sandboxed && !window.acNOAUTH) { if (typeof window.acDESKTOP?.login === "function") { return window.acDESKTOP.login(mode); } - // Lazy-load Auth0 if not already loaded - if (!window.auth0Client) { - console.log("🔐 Loading Auth0 on-demand for login..."); - await loadAuth0Script(); - await setupAuth0Client(); + window.acSignup.start(mode === "signup" ? "signup" : "login"); + try { + // Lazy-load Auth0 if not already loaded + if (!window.auth0Client) { + console.log("🔐 Loading Auth0 on-demand for login..."); + await loadAuth0Script(); + await setupAuth0Client(); + } + const opts = { prompt: "login" }; + if (mode === "signup") opts.screen_hint = mode; + // An explicit login replaces any session supplied by an embedding host. + // Otherwise an expired session-aesthetic masks the fresh Auth0 callback. + safeLocalStorageRemove("session-aesthetic"); + return await window.auth0Client.loginWithRedirect({ authorizationParams: opts }); + } catch (error) { + window.acSignup.failed(); + throw error; } - const opts = { prompt: "login" }; - if (mode === "signup") opts.screen_hint = mode; - // An explicit login replaces any session supplied by an embedding host. - // Otherwise an expired session-aesthetic masks the fresh Auth0 callback. - safeLocalStorageRemove("session-aesthetic"); - return window.auth0Client.loginWithRedirect({ authorizationParams: opts }); }; } @@ -1638,9 +1646,11 @@ if (!sandboxed && !skipAuth) { const redirectStart = performance.now(); bootLog("auth0 handling redirect callback"); try { - await auth0Client.handleRedirectCallback(); + await auth0Client.handleRedirectCallback(); + window.acSignup?.track("auth_returned"); bootLog(`auth0 redirect callback ok (${Math.round(performance.now() - redirectStart)}ms)`); } catch (e) { + window.acSignup?.failed(); console.error("🔐", e); bootLog( `auth0 redirect callback failed (${Math.round(performance.now() - redirectStart)}ms): ${e?.error || e?.name || "error"}: ${e?.error_description || e?.message || e}`, @@ -1847,7 +1857,12 @@ if (!sandboxed && !skipAuth) { } // 🔔 If the native iOS app handed us a push token before login, register it now. - window.iOSTryRegisterPushToken?.(); + window.iOSTryRegisterPushToken?.(); + + // Continue this tab's onboarding only; ordinary restored sessions stay put. + if (window.name !== "ac-login-popup") { + window.acSignup?.resume(auth0Client, userProfile); + } // Background: fetch handle from /user and refresh token if needed. // This runs after the disk already has auth — it just enriches data. diff --git a/system/public/aesthetic.computer/disks/get-handle.mjs b/system/public/aesthetic.computer/disks/get-handle.mjs index cb0d4dc5d6..5ef00e6fa7 100644 --- a/system/public/aesthetic.computer/disks/get-handle.mjs +++ b/system/public/aesthetic.computer/disks/get-handle.mjs @@ -71,9 +71,7 @@ function paint({ wipe, ink, write, box, screen, user, handle, help }) { ink(0, 255, 120).write("Ready!", { x: marginX, y }); ink(255).write("Set your handle", { x: marginX + 42, y }); y += isSmall ? 10 : 14; - ink(140).write("Type:", { x: marginX, y }); - ink(255, 220, 100).write("handle", { x: marginX + 34, y }); - ink(180).write("yourname", { x: marginX + 76, y }); + ink(140).write("Press Set handle to choose a name.", { x: marginX, y }); y += isSmall ? 14 : 20; } else { ink(0, 255, 120).write("You have:", { x: marginX, y }); @@ -124,7 +122,7 @@ function paint({ wipe, ink, write, box, screen, user, handle, help }) { openPromptBtn.paint({ ink, box, write }, [[64, 0, 64], 255, 255, [64, 0, 64]]); // Subtext under button (small pulse) - if (!isSmall) { + if (!isSmall && currentHandle) { const pulse = 0.65 + Math.sin(help.repeat * 0.07) * 0.2; ink(120, Math.floor(255 * pulse)).write( "(type: handle yourname)", @@ -134,7 +132,7 @@ function paint({ wipe, ink, write, box, screen, user, handle, help }) { } } -function act({ event: e, net, jump, screen, store, user }) { +function act({ event: e, net, jump, screen, store, user, send, handle }) { if (e.is("reframed")) { // Reposition in paint on next frame. } @@ -146,16 +144,21 @@ function act({ event: e, net, jump, screen, store, user }) { openPromptBtn?.btn?.act(e, { push: () => { - // Use prompt's prefill route used elsewhere (e.g. profile.mjs) - store["prompt:splash"] = true; - jump("prompt~handle "); + if (user && !handle() && !net.iframe) send({ type: "onboarding:open" }); + else { + store["prompt:splash"] = true; + jump("prompt~handle "); + } }, }); // Keyboard shortcut: enter opens prompt if (e.is("keyboard:down:enter")) { - store["prompt:splash"] = true; - jump("prompt~handle "); + if (user && !handle() && !net.iframe) send({ type: "onboarding:open" }); + else { + store["prompt:splash"] = true; + jump("prompt~handle "); + } } // Escape returns to prompt diff --git a/system/public/aesthetic.computer/disks/prompt.mjs b/system/public/aesthetic.computer/disks/prompt.mjs index 09f083e6e7..3446519c5d 100644 --- a/system/public/aesthetic.computer/disks/prompt.mjs +++ b/system/public/aesthetic.computer/disks/prompt.mjs @@ -3991,7 +3991,8 @@ async function halt($, text) { store["handle"] = res.handle; if (!previousHandle) { send({ type: "analytics", content: { action: "handle_created" } }); - jump("chat"); + if (net.iframe) jump("chat"); + else send({ type: "onboarding:complete" }); beep(); } // store.persist("handle"); @@ -8789,15 +8790,17 @@ function act({ } else if (profileAction === "profile") { jump(handle() || "profile"); } else if (profileAction === "set-handle") { - notice("ENTER HANDLE", ["yellow", "blue"]); - const text = "handle "; - system.prompt.input.text = text; - system.prompt.input.snap(); - system.prompt.input.runnable = true; - firstActivation = false; - send({ type: "keyboard:text:replace", content: { text } }); - // send({ type: "keyboard:unlock" }); - // send({ type: "keyboard:open" }); + if (!net.iframe) { + send({ type: "onboarding:open" }); + } else { + notice("ENTER HANDLE", ["yellow", "blue"]); + const text = "handle "; + system.prompt.input.text = text; + system.prompt.input.snap(); + system.prompt.input.runnable = true; + firstActivation = false; + send({ type: "keyboard:text:replace", content: { text } }); + } } }, cancel: () => { diff --git a/system/public/aesthetic.computer/lib/signup-flow.mjs b/system/public/aesthetic.computer/lib/signup-flow.mjs new file mode 100644 index 0000000000..c54c815b2f --- /dev/null +++ b/system/public/aesthetic.computer/lib/signup-flow.mjs @@ -0,0 +1,190 @@ +import { SIGNUP_TTL_MS, SIGNUP_ERRORS, signupReturnPath, signupSource, signupID } from "./signup-model.mjs"; +import { automatedVisit, visitReferrer } from "./visit-model.mjs"; +import { validateHandle } from "./text.mjs"; + +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) { + let attempt, previousPiece = signupReturnPath(win.location.href, win.location.origin); + let dialog, timer, auth, checking = false, generation = 0; + const disabled = () => win.navigator.doNotTrack === "1" || win.doNotTrack === "1" || + win.navigator.globalPrivacyControl === true || win.acVisitTrackingDisabled === true; + const supported = () => win === win.top && !win.acPACK_MODE; + const persist = () => { try { win.sessionStorage.setItem(KEY, JSON.stringify(attempt)); } catch {} }; + const read = () => { + if (!attempt) { + try { attempt = JSON.parse(win.sessionStorage.getItem(KEY)); } catch {} + } + if (!attempt || !Number.isFinite(attempt.at) || attempt.at > Date.now() || Date.now() - attempt.at > SIGNUP_TTL_MS || + !["signup", "login", "handle"].includes(attempt.mode) || !Array.isArray(attempt.stages)) { + attempt = null; + try { win.sessionStorage.removeItem(KEY); } catch {} + } + return attempt; + }; + const track = (stage, error = null) => { + if (!read()) return; + if (!attempt.stages.includes(stage)) attempt.stages.push(stage); + persist(); + if (disabled() || !signupID(attempt.id)) return; + const body = { version: 1, id: attempt.id, mode: attempt.mode, source: attempt.source, + stages: attempt.stages, automated: automatedVisit(win.navigator, win.location.search, win.acAutomation === true), + error: SIGNUP_ERRORS.includes(error) ? error : null, referrerHost: attempt.referrerHost }; + // Every snapshot contains prior milestones, so a dropped redirect-time + // request or an out-of-order retry does not erase the funnel. + try { Promise.resolve(win.fetch("/api/signup-track", { + method: "POST", headers: { "Content-Type": "text/plain" }, body: JSON.stringify(body), + credentials: "omit", referrerPolicy: "no-referrer", keepalive: true, + })).catch(() => {}); } catch {} + }; + function start(mode = "signup") { + if (!supported()) return; + close(); + const returnTo = signupReturnPath(win.location.href, win.location.origin) || previousPiece; + attempt = { at: Date.now(), id: disabled() ? null : win.crypto.randomUUID(), + mode: ["signup", "login", "handle"].includes(mode) ? mode : "login", + source: signupSource(returnTo || win.location.pathname), stages: [], + referrerHost: visitReferrer(doc.referrer), returnTo }; + track("started"); + } + function close() { + generation++; + win.clearInterval(timer); + timer = undefined; + dialog?.remove(); dialog = null; + } + function clear() { + attempt = null; + try { win.sessionStorage.removeItem(KEY); } catch {} + } + function complete(fallback = "/chat") { + const destination = signupReturnPath(read()?.returnTo, win.location.origin) || fallback; + track("completed"); + clear(); close(); + win.location.assign(destination); + } + async function request(path, options = {}) { + const token = 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" }); + const body = await response.json(); + if (!response.ok) throw Object.assign(new Error("Request failed"), { reason: body.message || "network" }); + return body; + } + function frame(title, description) { + close(); + dialog = doc.createElement("dialog"); + dialog.dataset.acNoTrack = ""; + dialog.setAttribute("aria-labelledby", "ac-signup-title"); + dialog.innerHTML = `
`; + dialog.className = "ac-signup"; + dialog.querySelector("h1").textContent = title; + dialog.querySelector(".description").textContent = description; + dialog.querySelector(".dismiss").onclick = close; + dialog.addEventListener("cancel", event => { event.preventDefault(); close(); }); + // Keep typing in the dialog out of the canvas prompt's keyboard handlers. + for (const type of ["keydown", "keyup", "keypress", "pointerdown", "pointerup", "pointermove", "mousedown", "mouseup", "click", "touchstart", "touchend", "touchmove", "wheel"]) + dialog.addEventListener(type, event => event.stopPropagation()); + doc.body.append(dialog); dialog.showModal(); + return dialog; + } + function handleForm() { + track("verified"); track("handle_shown"); + const view = frame("Choose your @handle", "Your name for sharing art and joining chat."); + view.querySelector(".content").innerHTML = ``; + const input = view.querySelector("input"), button = view.querySelector(".primary"), status = view.querySelector("[role=status]"), mine = generation; + input.addEventListener("input", () => { status.textContent = ""; }); + 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 an available name with 1–16 letters or numbers, dots or underscores."; track("handle_failed", "invalid"); return; } + button.disabled = true; status.textContent = "Saving…"; + try { + const result = await request("/handle", { method: "POST", body: JSON.stringify({ handle }) }); + 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); + status.textContent = reason === "taken" ? "That handle is taken. Try another name." : + reason === "unverified" ? "Verify your email, then try again." : "Couldn’t save your handle. Please try again."; + button.disabled = false; input.focus(); + } + }; + } + function verification() { + track("verification_shown"); + const view = frame("Check your email", "Open the verification link, then return here to choose your handle."); + view.querySelector(".content").innerHTML = ``; + const status = view.querySelector("[role=status]"), mine = generation; + const check = async (manual = false) => { + if (checking || mine !== generation || doc.visibilityState === "hidden") return; + checking = true; + try { + const current = await request("/api/signup-status"); + if (mine !== generation) return; + if (current.verified) { + await auth.getTokenSilently({ cacheMode: "off" }); + if (mine !== generation) return; + if (current.handle) complete(); else handleForm(); + } else if (manual) status.textContent = "Not verified yet. Open the link in your email, then try again."; + } catch { if (manual && mine === generation) status.textContent = "Couldn’t check right now. Please try again."; } + finally { checking = false; } + }; + view.querySelector(".primary").onclick = () => check(true); + view.querySelector(".secondary").onclick = async event => { + const button = event.currentTarget; button.disabled = true; + try { + await request("/api/signup-status", { method: "POST", body: JSON.stringify({ action: "resend" }) }); + if (mine !== generation) return; + track("verification_resent"); status.textContent = "Verification email sent. Check your inbox and spam folder."; + } catch { status.textContent = "Couldn’t resend yet. Please wait a minute and try again."; } + win.setTimeout(() => { button.disabled = false; }, 60000); + }; + timer = win.setInterval(() => check(), 15000); + } + async function resume(client, user, force = false) { + if (!supported() || (!read() && !force)) return; + if (!read()) start("handle"); + auth = client; + if (!auth || !user) return; + const mine = ++generation; + try { + const status = await request("/api/signup-status"); + if (mine !== generation) return; + if (status.handle && status.verified) { complete("/prompt"); return; } + if (status.verified) handleForm(); else verification(); + } catch { + if (mine !== generation) return; + const view = frame("Finish signing up", "Your account is signed in. We couldn’t check the next step yet."); + view.querySelector(".content").innerHTML = ``; + view.querySelector(".primary").onclick = () => resume(client, user); + } + } + return { + start, track, resume, complete, pending: () => !!read(), close, + remember(path) { const safe = signupReturnPath(path, win.location.origin); if (safe) previousPiece = safe; }, + failed() { + track("auth_failed", "auth"); + const view = frame("Sign-in didn’t finish", "Please try again. Your starting piece is saved in this tab."); + view.querySelector(".content").innerHTML = ``; + view.querySelector(".primary").onclick = () => { + const mode = read()?.mode === "signup" ? "signup" : undefined; + previousPiece = signupReturnPath(read()?.returnTo, win.location.origin) || previousPiece; + close(); win.acLOGIN?.(mode); + }; + }, + }; +} diff --git a/system/public/aesthetic.computer/lib/signup-model.mjs b/system/public/aesthetic.computer/lib/signup-model.mjs new file mode 100644 index 0000000000..b65c490e12 --- /dev/null +++ b/system/public/aesthetic.computer/lib/signup-model.mjs @@ -0,0 +1,56 @@ +// Signup attempts are anonymous and short-lived. Account totals come from Auth0. +import { visitReferrer } from "./visit-model.mjs"; +export const SIGNUP_COLLECTION = "signup-attempts"; +export const SIGNUP_RETENTION_DAYS = 35; +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", +]); +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 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, +// fragments, callback credentials or private-tool routes in browser storage. +export function signupReturnPath(value, origin) { + if (typeof value !== "string" || value.length > 256) return null; + try { + const url = new URL(value, origin); + if (url.origin !== origin || url.username || url.password) return null; + const path = url.pathname; + if (!/^\/(?:@[a-z0-9._-]+\/)?[$a-z0-9][a-z0-9._$-]*\/?$/i.test(path)) return null; + if (/^\/(?:prompt|hi|imnew|signup|get-handle|login|login-wait|email|mail|auth|callback|admin|desk|aa|wallet|account|device|machines|consent|help)\/?$/i.test(path)) return null; + return path; + } catch { return null; } +} + +export function signupSource(path) { + const name = String(path || "/").split(/[/?~:]/).filter(Boolean)[0] || "prompt"; + return SIGNUP_SOURCES.includes(name) ? name : "piece"; +} + +export function validateSignupEvent(body, origin) { + if (!["https://aesthetic.computer", "https://www.aesthetic.computer", "https://laklok.com", "https://www.laklok.com"].includes(origin) || + !body || body.version !== 1 || !signupID(body.id) || + !["signup", "login", "handle"].includes(body.mode) || + !SIGNUP_SOURCES.includes(body.source) || !Array.isArray(body.stages) || + !body.stages.length || body.stages.length > SIGNUP_STAGES.length || + body.stages.some(stage => !SIGNUP_STAGES.includes(stage)) || + typeof body.automated !== "boolean" || + (body.error !== null && body.error !== undefined && !SIGNUP_ERRORS.includes(body.error))) return null; + return { id: body.id.toLowerCase(), mode: body.mode, source: body.source, + stages: [...new Set(body.stages)], automated: body.automated, error: body.error || null, + property: new URL(origin).hostname.replace(/^www\./, ""), + referrerHost: typeof body.referrerHost === "string" ? visitReferrer(body.referrerHost) : null }; +} + +export function signupUpdate(event, now = new Date()) { + return { + $setOnInsert: { mode: event.mode, source: event.source, property: event.property, referrerHost: event.referrerHost, + expiresAt: new Date(+now + SIGNUP_RETENTION_DAYS * 86400000) }, + $min: { startedAt: now }, + $max: { updatedAt: now, ...Object.fromEntries(event.stages.map(stage => [`stages.${stage}`, true])), + ...(event.error ? { [`errors.${event.error}`]: true } : {}) }, + }; +} diff --git a/system/tests/signup-browser.test.mjs b/system/tests/signup-browser.test.mjs new file mode 100644 index 0000000000..8a315c5a60 --- /dev/null +++ b/system/tests/signup-browser.test.mjs @@ -0,0 +1,149 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFile, mkdir } from "node:fs/promises"; +import { chromium } from "playwright"; + +async function fixture(options = {}) { + const browser = await chromium.launch({ headless: true, channel: process.env.PLAYWRIGHT_CHANNEL }); + const context = await browser.newContext({ viewport: options.viewport || { width: 1280, height: 900 } }); + if (options.optOut) await context.addInitScript(() => Object.defineProperty(navigator, "globalPrivacyControl", { value: true })); + const state = { verified: false, handle: null, taken: true, statusFailure: false, requests: [], events: [], resends: 0 }; + await context.route("**/*", async route => { + const request = route.request(), url = new URL(request.url()); + if (url.pathname === "/style.css") return route.fulfill({ contentType: "text/css", body: await readFile(new URL("../public/aesthetic.computer/style.css", import.meta.url), "utf8") }); + if (url.pathname === "/api/signup-track") { + state.events.push(JSON.parse(request.postData())); + return route.fulfill({ status: 204 }); + } + if (url.pathname === "/api/signup-status") { + state.requests.push(url.pathname); + assert.equal(request.headers().authorization, "Bearer fixture-token"); + if (state.statusFailure) return route.fulfill({ status: 503, json: { message: "unavailable" } }); + if (request.method() === "POST") { state.resends++; return route.fulfill({ json: { sent: true } }); } + return route.fulfill({ json: { verified: state.verified, handle: state.handle } }); + } + if (url.pathname === "/handle" && request.method() === "POST") { + state.requests.push(JSON.parse(request.postData())); + if (state.taken) return route.fulfill({ status: 500, json: { message: "taken" } }); + state.handle = JSON.parse(request.postData()).handle; + return route.fulfill({ json: { handle: state.handle } }); + } + if (url.pathname.endsWith(".mjs")) { + const filename = url.pathname.split("/").at(-1); + assert.ok(["signup-flow.mjs", "signup-model.mjs", "visit-model.mjs", "text.mjs"].includes(filename)); + return route.fulfill({ contentType: "text/javascript", body: await readFile(new URL(`../public/aesthetic.computer/lib/${filename}`, import.meta.url), "utf8") }); + } + return route.fulfill({ contentType: "text/html", body: `