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 = ``; + 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.
` : ""} + `; + 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