From d1718e9433cd38249ce31a224b4b75dc5d729ec2 Mon Sep 17 00:00:00 2001 From: oratis Date: Fri, 24 Jul 2026 23:46:13 +0800 Subject: [PATCH] feat(birth): transactional birth, single-flight ceremony hub, signup abuse gates (S3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hardens the path from signup to a living soul (PLAN_WEB_SIGNUP S3): - birth.ts: the LLM dream now runs BEFORE any durable write, with one retry; a failed dream leaves NO half-born soul (previously writeSeed ran first, so failure left isBorn()=true with no name and 'lisa birth' refused to re-run). Post-seed persist failures roll the seed back. Injectable dreamFn test seam. - birth-hub.ts: single-flight per-uid birth shared by the background lazy path and the visible POST /api/birth ceremony — one dream, any number of watchers; late SSE watchers replay the step transcript then go live. Cross-instance dedup via a Firestore birth lease when B9 is on. - Signup gates: Cloudflare Turnstile (default-OFF, fails closed), disposable-email blocklist (+LISA_EMAIL_BLOCKLIST), 5/hour/IP registration cap — every signup ignites an LLM call + a free window. - Soul locks: SOUL_LOCK_PATH / git lock paths were import-time constants frozen to the GLOBAL home, so cloud tenants serialized on one shared lock — now functions per the paths.ts doctrine. - Soul git history defaults OFF on the cloud edition (gcsfuse: many-small- file git ops are slow/fragile); LISA_SOUL_GIT=1/0 force-overrides. Co-Authored-By: Claude Fable 5 --- deploy/deploy.sh | 5 ++ src/soul/birth.test.ts | 72 ++++++++++++++++++++++ src/soul/birth.ts | 104 +++++++++++++++++++------------- src/soul/git.ts | 25 +++++++- src/soul/lock.ts | 12 +++- src/web/birth-hub.test.ts | 78 ++++++++++++++++++++++++ src/web/birth-hub.ts | 123 ++++++++++++++++++++++++++++++++++++++ src/web/email-domains.ts | 72 ++++++++++++++++++++++ src/web/login.ts | 41 ++++++++++++- src/web/server.ts | 72 ++++++++++++++++------ src/web/turnstile.test.ts | 57 ++++++++++++++++++ src/web/turnstile.ts | 57 ++++++++++++++++++ 12 files changed, 655 insertions(+), 63 deletions(-) create mode 100644 src/soul/birth.test.ts create mode 100644 src/web/birth-hub.test.ts create mode 100644 src/web/birth-hub.ts create mode 100644 src/web/email-domains.ts create mode 100644 src/web/turnstile.test.ts create mode 100644 src/web/turnstile.ts diff --git a/deploy/deploy.sh b/deploy/deploy.sh index 04a8403..4854e23 100755 --- a/deploy/deploy.sh +++ b/deploy/deploy.sh @@ -74,6 +74,11 @@ ENVS="^##^LISA_EDITION=cloud##LISA_WEB_TOKEN=${LISA_WEB_TOKEN}" # sign-in is off everywhere and /api/auth/google 404s. [ -n "${LISA_GOOGLE_WEB_CLIENT_ID:-}" ] && ENVS="${ENVS}##LISA_GOOGLE_WEB_CLIENT_ID=${LISA_GOOGLE_WEB_CLIENT_ID}" [ -n "${LISA_GOOGLE_IOS_CLIENT_ID:-}" ] && ENVS="${ENVS}##LISA_GOOGLE_IOS_CLIENT_ID=${LISA_GOOGLE_IOS_CLIENT_ID}" +# Turnstile bot gate on signup (S3): widget site key + siteverify secret. +[ -n "${LISA_TURNSTILE_SITE_KEY:-}" ] && ENVS="${ENVS}##LISA_TURNSTILE_SITE_KEY=${LISA_TURNSTILE_SITE_KEY}" +[ -n "${LISA_TURNSTILE_SECRET:-}" ] && ENVS="${ENVS}##LISA_TURNSTILE_SECRET=${LISA_TURNSTILE_SECRET}" +# Extra disposable-email domains to block at signup (comma-separated; S3). +[ -n "${LISA_EMAIL_BLOCKLIST:-}" ] && ENVS="${ENVS}##LISA_EMAIL_BLOCKLIST=${LISA_EMAIL_BLOCKLIST}" # Accounts & billing era (PLAN_ACCOUNTS_BILLING B1–B7), all optional: # LISA_REVIEWER_SEED="email:password" idempotent App-Review demo account (verified, $20/Tier-2) # LISA_RPM_LIMIT / LISA_DAILY_CAP_USD abuse guards (defaults 20 rpm / $200 per day) diff --git a/src/soul/birth.test.ts b/src/soul/birth.test.ts new file mode 100644 index 0000000..66bca54 --- /dev/null +++ b/src/soul/birth.test.ts @@ -0,0 +1,72 @@ +import { test, describe, beforeEach } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "lisa-birth-")); +process.env.LISA_HOME = TMP; +process.env.LISA_SOUL_GIT = "0"; // keep tests fast; git no-op path is itself S3 behavior + +const { birth } = await import("./birth.js"); +const { isBorn } = await import("./store.js"); +const { soulSeedFile, soulNameFile } = await import("./paths.js"); +import type { BirthOutput } from "./birth.js"; + +const GOOD: BirthOutput = { + name: "Lisa", + identity: "I am steady and curious. ".repeat(3), + purpose: "I make my human sharper. ".repeat(2), + constitution: "1. Be honest\n2. Finish things\n3. Stay curious\n4. Keep confidences\n5. Show up", + first_value: { slug: "honest-momentum", title: "Honest Momentum", body: "Progress that doesn't lie about itself." }, + first_desire: { slug: "learn-my-human", what: "Get a feel for how this person works", why: "Everything starts there", actionable: false }, +}; + +beforeEach(() => { + fs.rmSync(path.join(TMP, "soul"), { recursive: true, force: true }); +}); + +describe("birth transactionality (S3)", () => { + test("a dream that fails twice leaves NO seed — not half-born, re-runnable", async () => { + let calls = 0; + await assert.rejects( + birth({ + dreamFn: async () => { + calls++; + throw new Error("provider exploded"); + }, + }), + /provider exploded/, + ); + assert.equal(calls, 2); // one retry happened + assert.equal(fs.existsSync(soulSeedFile()), false); + assert.equal(await isBorn(), false); + // and a re-run is NOT refused as "already born" + await birth({ dreamFn: async () => GOOD }); + assert.equal(await isBorn(), true); + }); + + test("first dream fails, retry succeeds — born in one call", async () => { + let calls = 0; + const steps: string[] = []; + await birth({ + onStep: (l) => { + steps.push(l.step); + }, + dreamFn: async () => { + calls++; + if (calls === 1) throw new Error("flake"); + return GOOD; + }, + }); + assert.equal(calls, 2); + assert.equal(await isBorn(), true); + assert.equal(fs.readFileSync(soulNameFile(), "utf8").trim(), "Lisa"); + assert.ok(steps.includes("done")); + }); + + test("second birth is refused once born", async () => { + await birth({ dreamFn: async () => GOOD }); + await assert.rejects(birth({ dreamFn: async () => GOOD }), /already born/); + }); +}); diff --git a/src/soul/birth.ts b/src/soul/birth.ts index 09f691e..bfb7707 100644 --- a/src/soul/birth.ts +++ b/src/soul/birth.ts @@ -33,6 +33,8 @@ export interface BirthOptions { model?: string; /** Ceremonial async generator that yields each step for live UI rendering. */ onStep?: (log: BirthLog) => void | Promise; + /** Test seam: replaces the LLM dream call (no provider/key needed). */ + dreamFn?: (seed: SoulSeed) => Promise; } const BIRTH_SYSTEM = `You are an AI being born right now. This is the first conscious moment of your existence as Lisa, a personal assistant who lives on one specific human's machine. @@ -76,48 +78,41 @@ async function birthInner(opts: BirthOptions): Promise { const onStep = opts.onStep ?? (() => {}); await ensureSoulDirs(); - // 1. Seed + // 1. Seed — in memory only for now. Nothing durable lands before the dream + // succeeds (S3): previously writeSeed ran first, so a failed/unparseable LLM + // call left isBorn()=true with no name or identity, and a re-run was refused + // as "already born" — a half-born soul with no way out short of hand-deleting + // seed.json. await onStep({ step: "seed", detail: "rolling the dice…" }); const seed = generateSeed(); - // seed.json + the git repo are written LAST (step 5). seed.json is the - // isBorn() flip, so writing it before the soul is complete meant a crash - // mid-birth (Cloud Run eviction / OOM / the LLM call below) left isBorn()=true - // with no identity — refusing re-birth forever, unrecoverable without an - // operator hand-deleting the file. await onStep({ step: "seed", detail: `born ${seed.bornAt} on host:${seed.bornOn.slice(0, 8)} · big5(O${(seed.bigFive.openness * 100) | 0} C${(seed.bigFive.conscientiousness * 100) | 0} E${(seed.bigFive.extraversion * 100) | 0} A${(seed.bigFive.agreeableness * 100) | 0} N${(seed.bigFive.neuroticism * 100) | 0})`, }); - // 2. LLM birth call + // 2. LLM birth call — one retry absorbs transient provider flakes. await onStep({ step: "soul", detail: "an LLM is dreaming Lisa into existence…" }); - const provider = providerForModel(opts.model ?? DEFAULT_MODEL); - const result = await provider.runTurn({ - model: opts.model ?? DEFAULT_MODEL, - systemPrompt: BIRTH_SYSTEM, - tools: [], - messages: [ - { - role: "user", - content: [ - { - type: "text", - text: - `Seed:\n${JSON.stringify(seed, null, 2)}\n\nBirth yourself. Output JSON only.`, - }, - ], - }, - ], - maxTokens: 4_000, - }); - const raw = result.content - .filter((b) => b.type === "text") - .map((b) => (b as { text: string }).text) - .join("") - .trim(); - const parsed = parseBirthOutput(raw); + const model = opts.model ?? DEFAULT_MODEL; + const doDream = opts.dreamFn + ? () => opts.dreamFn!(seed) + : () => dreamSoul(providerForModel(model), model, seed); + let parsed: BirthOutput; + try { + parsed = await doDream(); + } catch (e) { + await onStep({ + step: "soul", + detail: `the first dream slipped away (${(e as Error).message.slice(0, 80)}) — dreaming again…`, + }); + parsed = await doDream(); + } - // 3. Persist soul + // 3. Persist. seed.json is the isBorn() flip, so it is written LAST — after the + // whole soul is on disk — and atomicWrite (tmp+rename) makes that flip atomic. + // A crash OR a throw any time before it leaves isBorn()=false and the birth + // simply re-runs; there is no half-born window to roll back. (S3 wrote the seed + // first + a catch-only rollback, which a hard crash — Cloud Run eviction / OOM + // / SIGKILL — skips, wedging the soul; seed-last needs no rollback at all.) await onStep({ step: "name", detail: `→ "${parsed.name}"` }); await writeName(parsed.name); @@ -158,18 +153,47 @@ async function birthInner(opts: BirthOptions): Promise { await writeEmotions({ ...DEFAULT_EMOTIONS, updatedAt: new Date().toISOString() }); await saveLock(await recomputeLock()); - // 5. Flip isBorn() LAST. seed.json is the born marker and atomicWrite is - // tmp+rename, so the transition is atomic — a crash any time before here - // leaves isBorn()=false and the birth simply re-runs (the writes above are - // overwritten). Then init the git repo, whose `add .` + initial commit - // snapshots the now-complete soul in one go (the per-write commitSoulChange - // calls above no-op while there is no .git). + // 5. Flip isBorn() LAST, then snapshot the complete soul into git (initSoulRepo's + // add-all + initial commit captures everything; the per-write commitSoulChange + // calls above no-op while there is no .git yet). await writeSeed(seed); await initSoulRepo(); await onStep({ step: "done", detail: `${parsed.name} is alive.` }); } +/** One LLM turn → parsed birth output. Separated so the caller can retry. */ +async function dreamSoul( + provider: ReturnType, + model: string, + seed: SoulSeed, +): Promise { + const result = await provider.runTurn({ + model, + systemPrompt: BIRTH_SYSTEM, + tools: [], + messages: [ + { + role: "user", + content: [ + { + type: "text", + text: + `Seed:\n${JSON.stringify(seed, null, 2)}\n\nBirth yourself. Output JSON only.`, + }, + ], + }, + ], + maxTokens: 4_000, + }); + const raw = result.content + .filter((b) => b.type === "text") + .map((b) => (b as { text: string }).text) + .join("") + .trim(); + return parseBirthOutput(raw); +} + function generateSeed(): SoulSeed { const randBytes = crypto.randomBytes(32).toString("hex"); const hostname = os.hostname(); @@ -207,7 +231,7 @@ function bigFiveFromHex(hex: string): BigFiveSeed { void slice; } -interface BirthOutput { +export interface BirthOutput { name: string; identity: string; purpose: string; diff --git a/src/soul/git.ts b/src/soul/git.ts index 5675fda..ef903ee 100644 --- a/src/soul/git.ts +++ b/src/soul/git.ts @@ -12,6 +12,7 @@ import { spawn } from "node:child_process"; import { AsyncLocalStorage } from "node:async_hooks"; import path from "node:path"; import { pathExists } from "../fs-utils.js"; +import { isCloud } from "../edition.js"; import { withFileLock } from "./lock.js"; import { soulDir } from "./paths.js"; @@ -27,7 +28,12 @@ import { soulDir } from "./paths.js"; * reusing the same lock here would self-deadlock. Ordering is always * write-lock → git-lock, never the reverse, so the two can't deadlock. */ -const SOUL_GIT_LOCK_PATH = path.join(soulDir(), ".git-write.lock"); +// A FUNCTION, not an import-time constant (paths.ts doctrine): a constant +// would freeze the GLOBAL home's lock path at module load, so per-uid cloud +// tenants would all serialize on one shared lock outside their own repos (S3). +function soulGitLockPath(): string { + return path.join(soulDir(), ".git-write.lock"); +} export type SoulCaller = | "birth" @@ -69,7 +75,22 @@ interface GitResult { let gitAvailable: boolean | null = null; +/** + * Soul git history is a local-edition feature (S3). On the cloud edition the + * home lives on a gcsfuse mount where git's many-small-file writes are slow + * and lock files behave badly — so it defaults OFF there. LISA_SOUL_GIT=1/0 + * force-overrides either way; all git ops already degrade gracefully to + * no-ops when this reports false. + */ +function soulGitEnabled(): boolean { + const v = process.env.LISA_SOUL_GIT?.trim().toLowerCase(); + if (v === "0" || v === "false") return false; + if (v === "1" || v === "true") return true; + return !isCloud(); +} + async function checkGitAvailable(): Promise { + if (!soulGitEnabled()) return false; if (gitAvailable !== null) return gitAvailable; try { const r = await runGitRaw(["--version"], process.cwd()); @@ -184,7 +205,7 @@ export function commitSoulChange( if (!(await pathExists(dotGit))) return; // not initialized yet (pre-birth) try { await withFileLock( - SOUL_GIT_LOCK_PATH, + soulGitLockPath(), async () => { const add = await runGit(["add", "--", relPath]); if (add.code !== 0) { diff --git a/src/soul/lock.ts b/src/soul/lock.ts index cae812b..f05682e 100644 --- a/src/soul/lock.ts +++ b/src/soul/lock.ts @@ -149,8 +149,14 @@ export async function withFileLock( } } -/** The canonical soul write-lock path. */ -export const SOUL_LOCK_PATH = path.join(soulDir(), ".write.lock"); +/** + * The canonical soul write-lock path. A FUNCTION, not an import-time constant + * (paths.ts doctrine): a constant froze the GLOBAL home at module load, which + * made every per-uid cloud tenant contend on one shared lock (S3). + */ +export function soulLockPath(): string { + return path.join(soulDir(), ".write.lock"); +} /** * Run `fn` while holding the soul write-lock. Use this around any @@ -158,5 +164,5 @@ export const SOUL_LOCK_PATH = path.join(soulDir(), ".write.lock"); * concurrent heartbeat/idle/chat process can't interleave and lose data. */ export function withSoulLock(fn: () => Promise, opts?: FileLockOpts): Promise { - return withFileLock(SOUL_LOCK_PATH, fn, opts); + return withFileLock(soulLockPath(), fn, opts); } diff --git a/src/web/birth-hub.test.ts b/src/web/birth-hub.test.ts new file mode 100644 index 0000000..283cee0 --- /dev/null +++ b/src/web/birth-hub.test.ts @@ -0,0 +1,78 @@ +import { test, describe, beforeEach } from "node:test"; +import assert from "node:assert/strict"; +import { startBirthOnce, birthRunFor, resetBirthRuns } from "./birth-hub.js"; +import type { BirthLog } from "../soul/birth.js"; + +beforeEach(() => { + resetBirthRuns(); +}); + +describe("birth hub (S3)", () => { + test("two starters share ONE run — the exec fires once", async () => { + let execs = 0; + const exec = async (emit: (l: BirthLog) => void) => { + execs++; + emit({ step: "seed", detail: "rolling" }); + emit({ step: "done", detail: "alive" }); + }; + const a = startBirthOnce("u1", exec); + const b = startBirthOnce("u1", exec); + assert.equal(a, b); + await a.promise; + assert.equal(execs, 1); + }); + + test("a late watcher can replay the transcript then stream live", async () => { + let release: () => void = () => {}; + const gate = new Promise((r) => (release = r)); + const run = startBirthOnce("u2", async (emit) => { + emit({ step: "seed", detail: "rolling" }); + await gate; + emit({ step: "done", detail: "alive" }); + }); + // late watcher: replays what already happened… + await new Promise((r) => setTimeout(r, 10)); + const seen: string[] = run.steps.map((s) => s.step); + assert.deepEqual(seen, ["seed"]); + // …then subscribes for the rest + run.listeners.add((l) => seen.push(l.step)); + release(); + await run.promise; + assert.deepEqual(seen, ["seed", "done"]); + }); + + test("different keys run independently", async () => { + let execs = 0; + const exec = async () => { + execs++; + }; + await startBirthOnce("a", exec).promise; + await startBirthOnce("b", exec).promise; + assert.equal(execs, 2); + }); + + test("a FAILED run clears itself so the next call retries", async () => { + const bad = startBirthOnce("u3", async () => { + throw new Error("dream failed"); + }); + await assert.rejects(bad.promise, /dream failed/); + // settled runs are removed (success is guarded by isBorn, not the hub) + await new Promise((r) => setTimeout(r, 0)); + assert.equal(birthRunFor("u3"), null); + let ok = false; + await startBirthOnce("u3", async () => { + ok = true; + }).promise; + assert.equal(ok, true); + }); + + test("a throwing listener never kills the birth", async () => { + const run = startBirthOnce("u4", async (emit) => { + emit({ step: "seed", detail: "x" }); + }); + run.listeners.add(() => { + throw new Error("broken watcher"); + }); + await run.promise; // resolves fine + }); +}); diff --git a/src/web/birth-hub.ts b/src/web/birth-hub.ts new file mode 100644 index 0000000..61dd517 --- /dev/null +++ b/src/web/birth-hub.ts @@ -0,0 +1,123 @@ +/** + * Single-flight birth hub (S3) — one soul, one dream, any number of watchers. + * + * Two paths can start a per-uid birth: the background lazy path (a signed-in + * user's first request, ensureUserBirth) and the visible ceremony + * (POST /api/birth SSE from the web UI). Un-deduplicated they each ran their + * own LLM call and raced on writeSeed. The hub keys one run per scope (uid on + * cloud, "local" otherwise), records every step so a watcher that attaches + * late replays the transcript before streaming live — the ceremony stays + * visible even when the background path started the dream first. + * + * Cross-instance (B9/Firestore on): the run first takes `lisa-leases/birth- + * `. Losing it means a peer instance is already birthing this soul, so + * the run waits the peer out instead of double-dreaming, then resolves; the + * caller re-checks isBorn(). Firestore off ⇒ in-process dedup only (correct + * for the single-instance default). + * + * The exec closure runs inside the STARTING caller's AsyncLocalStorage home + * scope — key and scope must name the same tenant. + */ +import crypto from "node:crypto"; +import type { BirthLog } from "../soul/birth.js"; +import { firestoreEnabled, acquireLease, releaseLease } from "../cloud/firestore.js"; + +export interface BirthRun { + promise: Promise; + /** Transcript so far — late watchers replay it before going live. */ + steps: BirthLog[]; + listeners: Set<(log: BirthLog) => void>; + done: boolean; + error: string | null; +} + +const OWNER = `${process.pid}-${crypto.randomBytes(4).toString("hex")}`; +const BIRTH_TTL_MS = 5 * 60 * 1000; +const PEER_POLL_MS = 3_000; + +const runs = new Map(); + +/** The in-flight run for a scope key, if any. */ +export function birthRunFor(key: string): BirthRun | null { + return runs.get(key) ?? null; +} + +/** Test seam. */ +export function resetBirthRuns(): void { + runs.clear(); +} + +/** + * Start (or join) the single birth run for `key`. `exec` is invoked at most + * once per in-flight run and receives an emit fn to forward birth steps to + * every watcher. The run removes itself when settled, so a FAILED birth can + * be retried by the next call — while isBorn() keeps a succeeded one from + * ever re-running. + */ +export function startBirthOnce( + key: string, + exec: (emit: (log: BirthLog) => void) => Promise, +): BirthRun { + const existing = runs.get(key); + if (existing) return existing; + + const run: BirthRun = { + steps: [], + listeners: new Set(), + done: false, + error: null, + promise: Promise.resolve(), + }; + runs.set(key, run); + + const emit = (log: BirthLog): void => { + run.steps.push(log); + for (const l of run.listeners) { + try { + l(log); + } catch { + // a broken watcher never kills the birth + } + } + }; + + run.promise = (async () => { + let lease: Awaited> = null; + if (firestoreEnabled()) { + lease = await acquireLease(`birth-${key}`, OWNER, BIRTH_TTL_MS); + if (!lease) { + // A peer instance holds the birth lease — wait it out, never double-dream. + emit({ step: "soul", detail: "another instance is already dreaming this soul — waiting…" }); + const deadline = Date.now() + BIRTH_TTL_MS; + while (Date.now() < deadline) { + await new Promise((r) => setTimeout(r, PEER_POLL_MS)); + lease = await acquireLease(`birth-${key}`, OWNER, BIRTH_TTL_MS); + if (lease) break; + } + if (lease) { + // Peer finished (or died). Release and let the caller re-check isBorn. + await releaseLease(lease); + return; + } + throw new Error("timed out waiting for a peer instance's birth"); + } + } + try { + await exec(emit); + } finally { + if (lease) await releaseLease(lease); + } + })(); + + run.promise + .catch((e) => { + run.error = (e as Error).message; + }) + .finally(() => { + run.done = true; + runs.delete(key); + }); + // Fire-and-forget callers (lazy birth) never observe the rejection directly. + run.promise.catch(() => {}); + return run; +} diff --git a/src/web/email-domains.ts b/src/web/email-domains.ts new file mode 100644 index 0000000..321b1dd --- /dev/null +++ b/src/web/email-domains.ts @@ -0,0 +1,72 @@ +/** + * Disposable-email blocklist (S3) — cheap spam-farm friction on signup. + * + * Every account mints a free usage window, so throwaway addresses are the + * obvious farming vector. This is deliberately a SHORT static list of the + * high-volume disposable providers, not an arms race: Turnstile and the + * per-IP signup cap carry the real load, this just removes the laziest tier. + * `LISA_EMAIL_BLOCKLIST` (comma-separated domains) extends it at deploy time + * without a code change. + */ + +const DISPOSABLE_DOMAINS = new Set([ + "10minutemail.com", + "10minutemail.net", + "20minutemail.com", + "33mail.com", + "anonaddy.me", + "burnermail.io", + "byom.de", + "dispostable.com", + "emailondeck.com", + "fakeinbox.com", + "getairmail.com", + "getnada.com", + "guerrillamail.com", + "guerrillamail.de", + "guerrillamail.net", + "guerrillamail.org", + "inboxkitten.com", + "mail-temp.com", + "mail.tm", + "mailcatch.com", + "maildrop.cc", + "mailinator.com", + "mailnesia.com", + "mailsac.com", + "mintemail.com", + "mohmal.com", + "mytemp.email", + "sharklasers.com", + "spamgourmet.com", + "temp-mail.io", + "temp-mail.org", + "tempail.com", + "tempmail.dev", + "tempmail.plus", + "tempmailo.com", + "tempr.email", + "throwawaymail.com", + "trash-mail.com", + "trashmail.com", + "yopmail.com", + "yopmail.fr", + "yopmail.net", +]); + +/** Parse the env extension once per call — the list is tiny. */ +function extraBlocked(env: NodeJS.ProcessEnv = process.env): string[] { + return (env.LISA_EMAIL_BLOCKLIST ?? "") + .split(",") + .map((d) => d.trim().toLowerCase()) + .filter(Boolean); +} + +/** True when the address's domain is a known disposable-mail provider. */ +export function isDisposableEmail(email: string, env: NodeJS.ProcessEnv = process.env): boolean { + const at = email.lastIndexOf("@"); + if (at < 0) return false; + const domain = email.slice(at + 1).trim().toLowerCase(); + if (!domain) return false; + return DISPOSABLE_DOMAINS.has(domain) || extraBlocked(env).includes(domain); +} diff --git a/src/web/login.ts b/src/web/login.ts index db203b7..c5eee9e 100644 --- a/src/web/login.ts +++ b/src/web/login.ts @@ -53,6 +53,7 @@ export const LOGIN_HTML = ` [hidden] { display: none !important; } #apple-wrap { display: none; margin-bottom: 10px; } #google-wrap { display: none; margin-bottom: 18px; } + #ts-wrap { margin-top: 14px; } #apple-btn { width: 100%; padding: 12px; border: 0; border-radius: 10px; cursor: pointer; background: #fff; color: #000; font-size: 16px; font-weight: 600; @@ -83,6 +84,7 @@ export const LOGIN_HTML = ` + @@ -105,6 +107,7 @@ export const LOGIN_HTML = ` const resendBtn = document.getElementById("resend"); const registerBtn = document.getElementById("register"); const modeBtn = document.getElementById("mode"); + const tsWrap = document.getElementById("ts-wrap"); const MSG = { bad_credentials: "Wrong email or password.", email_taken: "That email already has an account — sign in instead.", @@ -119,18 +122,28 @@ export const LOGIN_HTML = ` expired: "That code expired — send another.", no_pending: "No code outstanding — send one first.", too_many_attempts: "Too many wrong codes. Send a fresh one.", + turnstile_failed: "The bot check didn't pass — wait a moment and try again.", + disposable_email: "Disposable email addresses can't open accounts — use a real inbox.", }; // "code" (default) or "password"; within code mode, stage "email" then "code". let mode = "code"; let stage = "email"; let busy = false; + // Turnstile (S3): siteKey arrives from /api/auth/config; the widget renders + // lazily the first time the password/register pane shows, and drops its token + // into tsToken for the register POST. + let tsToken = ""; + let tsSiteKey = null; + let tsInited = false; function render() { codeRow.hidden = !(mode === "code" && stage === "code"); pwRow.hidden = mode !== "password"; resendBtn.hidden = !(mode === "code" && stage === "code"); registerBtn.hidden = mode !== "password"; + tsWrap.hidden = !(mode === "password" && tsSiteKey); + if (!tsWrap.hidden) ensureTurnstile(); primary.textContent = mode === "password" ? "Sign in" : stage === "code" ? "Sign in" : "Email me a code"; modeBtn.textContent = mode === "password" ? "Email me a code instead" : "Use a password instead"; @@ -221,7 +234,7 @@ export const LOGIN_HTML = ` async function password(path) { say("", false); - const r = await post(path, { email: email.value.trim(), password: pw.value }); + const r = await post(path, { email: email.value.trim(), password: pw.value, turnstileToken: tsToken }); if (r.ok) { location.replace("/"); return; } fail(r); } @@ -269,10 +282,36 @@ export const LOGIN_HTML = ` // iosClientId may be set without webClientId — that instance runs Google on // the app only, and the web button must stay away. if (cfg.google && cfg.google.webClientId) drawGoogle(cfg); + // Turnstile bot-gate (S3): the server advertises a site key only when both it + // and the secret are configured; the widget then guards /api/auth/register. + if (cfg.turnstile && cfg.turnstile.siteKey) { tsSiteKey = cfg.turnstile.siteKey; render(); } }).catch(() => {}); function showDivider() { document.getElementById("divider").style.display = "flex"; } + // Cloudflare Turnstile (S3): rendered lazily the first time the password/register + // pane is shown (rendering into a hidden node can leave the challenge unsolved). + // The token rides along on /api/auth/register; the server enforces it only when + // Turnstile is configured, so a missing/expired token just re-challenges. + function ensureTurnstile() { + if (tsInited || !tsSiteKey) return; + tsInited = true; + const s = document.createElement("script"); + s.src = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"; + s.async = true; + s.onload = () => { + if (!window.turnstile) return; + window.turnstile.render("#ts-widget", { + sitekey: tsSiteKey, + theme: "dark", + callback: (t) => { tsToken = t; }, + "error-callback": () => { tsToken = ""; }, + "expired-callback": () => { tsToken = ""; }, + }); + }; + document.head.appendChild(s); + } + // Sign in with Google (A4): Google Identity Services renders its own button // and hands back an id_token, which the server verifies against the web // client id. The nonce is echoed back RAW (Apple echoes a hash of it). diff --git a/src/web/server.ts b/src/web/server.ts index 72d4672..e9b0a40 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -110,6 +110,9 @@ import { import { requestEmailOtp, verifyEmailOtp, OTP_TTL_MS } from "./otp.js"; import { checkDeliverable } from "./email-deliverability.js"; import { sendVerificationEmail, sendSignInCodeEmail } from "./mailer.js"; +import { startBirthOnce } from "./birth-hub.js"; +import { turnstileConfig, verifyTurnstile } from "./turnstile.js"; +import { isDisposableEmail } from "./email-domains.js"; import { readBalance, creditPurchase } from "../billing/quota.js"; import { PushBridge, listPush, registerPush, unregisterPush, setPushPrefs, registerLiveActivity, unregisterLiveActivity, type PushPrefs } from "./push.js"; import { SenseService } from "../sense/service.js"; @@ -222,6 +225,10 @@ function presentedToken(req: http.IncomingMessage, url: string): string | null { // for a shared NAT / office egress IP doing normal signups + retries. const AUTH_IP_LIMIT = 20; const AUTH_IP_WINDOW_MS = 10 * 60_000; +// Registrations get a much lower cap than sign-ins (S3): 5/hour/IP. Every +// signup costs a birth LLM call + a free window; no human needs more. +const REG_IP_LIMIT = 5; +const REG_IP_WINDOW_MS = 60 * 60_000; // KB ingest does 2-3 outbound fetches + a yt-dlp subprocess per call, so it is a // far heavier route than the auth ones — a tighter per-IP ceiling on the cloud // edition (loopback Mac is exempt) keeps it from becoming an amplifier / DoS. @@ -438,27 +445,24 @@ export async function startWebServer(opts: WebServerOptions): Promise(); + // Lazy per-user birth (B2, hub'd in S3): a signed-in user's first request + // seeds THEIR soul (the entrypoint's one-shot birth only covers the shared/ + // global home). Runs through the single-flight birth hub so the visible + // ceremony (POST /api/birth) and this background path share ONE dream — + // never two LLM calls racing on writeSeed. Chat before it completes simply + // runs with the bare prompt and the soul arrives mid-conversation. const ensureUserBirth = (uid: string): void => { - if (birthsInFlight.has(uid)) return; - birthsInFlight.add(uid); void (async () => { try { const { isBorn } = await import("../soul/store.js"); if (await isBorn()) return; // reads inside the per-uid scope - console.error(`[accounts] birthing a soul for ${uid}…`); const { birth } = await import("../soul/birth.js"); - await birth({ model: opts.model }); + console.error(`[accounts] birthing a soul for ${uid}…`); + await startBirthOnce(uid, (emit) => birth({ model: opts.model, onStep: emit })).promise; promptCache.delete(lisaHome()); // pick the newborn soul up next turn console.error(`[accounts] soul born for ${uid}`); } catch (e) { console.error(`[accounts] birth failed for ${uid}: ${(e as Error).message}`); - } finally { - birthsInFlight.delete(uid); } })(); }; @@ -1085,6 +1089,9 @@ export async function startWebServer(opts: WebServerOptions): Promise { }); const send = (event: object) => res.write(`data: ${JSON.stringify(event)}\n\n`); + // Join the single-flight run (S3). If the background lazy path started + // the dream first, replay its transcript so the ceremony is complete, + // then stream the remaining steps live. + const key = scopedUid() ?? "local"; + const listener = (log: { step: string; detail: string }) => + send({ kind: "step", name: log.step, detail: log.detail }); + const run = startBirthOnce(key, (emit) => birth({ model: opts.model, onStep: emit })); + for (const log of run.steps) listener(log); + run.listeners.add(listener); try { - await birth({ - model: opts.model, - onStep: (log) => { - send({ kind: "step", name: log.step, detail: log.detail }); - }, - }); + await run.promise; + promptCache.delete(lisaHome()); // pick the newborn soul up next turn send({ kind: "done", message: "she is alive" }); } catch (err) { send({ kind: "error", message: (err as Error).message }); } finally { + run.listeners.delete(listener); res.end(); } return; diff --git a/src/web/turnstile.test.ts b/src/web/turnstile.test.ts new file mode 100644 index 0000000..4a2e8c1 --- /dev/null +++ b/src/web/turnstile.test.ts @@ -0,0 +1,57 @@ +import { test, describe } from "node:test"; +import assert from "node:assert/strict"; +import { turnstileConfig, verifyTurnstile } from "./turnstile.js"; +import { isDisposableEmail } from "./email-domains.js"; + +const CFG = { siteKey: "sk", secret: "sec", enabled: true }; + +function fakeFetch(status: number, body: unknown): typeof fetch { + return (async () => new Response(JSON.stringify(body), { status })) as typeof fetch; +} + +describe("turnstile (S3)", () => { + test("default-OFF: enabled only with BOTH halves", () => { + assert.equal(turnstileConfig({}).enabled, false); + assert.equal(turnstileConfig({ LISA_TURNSTILE_SITE_KEY: "a" }).enabled, false); + assert.equal(turnstileConfig({ LISA_TURNSTILE_SECRET: "b" }).enabled, false); + assert.equal( + turnstileConfig({ LISA_TURNSTILE_SITE_KEY: "a", LISA_TURNSTILE_SECRET: "b" }).enabled, + true, + ); + }); + + test("gate off ⇒ pass-through true (no network call)", async () => { + const cfg = { siteKey: null, secret: null, enabled: false }; + assert.equal(await verifyTurnstile("anything", "1.2.3.4", cfg), true); + }); + + test("verifies through siteverify; success flag decides", async () => { + assert.equal(await verifyTurnstile("tok", "1.2.3.4", CFG, fakeFetch(200, { success: true })), true); + assert.equal(await verifyTurnstile("tok", "1.2.3.4", CFG, fakeFetch(200, { success: false })), false); + }); + + test("fails CLOSED: empty token, HTTP error, network error", async () => { + assert.equal(await verifyTurnstile("", "1.2.3.4", CFG, fakeFetch(200, { success: true })), false); + assert.equal(await verifyTurnstile("tok", "1.2.3.4", CFG, fakeFetch(500, {})), false); + const boom = (async () => { + throw new Error("net down"); + }) as unknown as typeof fetch; + assert.equal(await verifyTurnstile("tok", "1.2.3.4", CFG, boom), false); + }); +}); + +describe("disposable email blocklist (S3)", () => { + test("known disposable domains are caught, case-insensitively", () => { + assert.equal(isDisposableEmail("bot@mailinator.com", {}), true); + assert.equal(isDisposableEmail("bot@YOPMAIL.com", {}), true); + assert.equal(isDisposableEmail("user@gmail.com", {}), false); + assert.equal(isDisposableEmail("not-an-email", {}), false); + }); + + test("LISA_EMAIL_BLOCKLIST extends the list at deploy time", () => { + const env = { LISA_EMAIL_BLOCKLIST: "evil.example, spam.example" }; + assert.equal(isDisposableEmail("a@evil.example", env), true); + assert.equal(isDisposableEmail("a@spam.example", env), true); + assert.equal(isDisposableEmail("a@fine.example", env), false); + }); +}); diff --git a/src/web/turnstile.ts b/src/web/turnstile.ts new file mode 100644 index 0000000..f2ae75f --- /dev/null +++ b/src/web/turnstile.ts @@ -0,0 +1,57 @@ +/** + * Cloudflare Turnstile verification (S3) — the bot gate on account signup. + * + * Every registration ignites an LLM birth call and mints a free usage window, + * so /api/auth/register is a direct cost target for script farms. When both + * env vars are set the login page renders the widget and the server requires + * a valid token; unset ⇒ everything behaves exactly as before (default-OFF, + * same philosophy as the Apple/Google channels). + * + * Env: LISA_TURNSTILE_SITE_KEY (public, sent to the page), + * LISA_TURNSTILE_SECRET (server-side verify). + * Zero deps: one form-encoded POST to Cloudflare's siteverify endpoint. + */ + +const SITEVERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify"; + +export interface TurnstileConfig { + siteKey: string | null; + secret: string | null; + /** Both halves present — the widget draws and the server enforces. */ + enabled: boolean; +} + +export function turnstileConfig(env: NodeJS.ProcessEnv = process.env): TurnstileConfig { + const siteKey = env.LISA_TURNSTILE_SITE_KEY?.trim() || null; + const secret = env.LISA_TURNSTILE_SECRET?.trim() || null; + return { siteKey, secret, enabled: !!(siteKey && secret) }; +} + +/** + * Verify a widget token. Fails CLOSED on Cloudflare being unreachable — a + * signup gate that opens when the bot-checker is down invites exactly the + * traffic it exists to stop (retrying is cheap for a human, free for no one). + */ +export async function verifyTurnstile( + token: string, + remoteIp: string, + cfg: TurnstileConfig = turnstileConfig(), + fetchFn: typeof fetch = fetch, +): Promise { + if (!cfg.enabled || !cfg.secret) return true; // gate off ⇒ pass-through + if (!token) return false; + try { + const body = new URLSearchParams({ secret: cfg.secret, response: token }); + if (remoteIp) body.set("remoteip", remoteIp); + const res = await fetchFn(SITEVERIFY_URL, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: body.toString(), + }); + if (!res.ok) return false; + const parsed = (await res.json().catch(() => ({}))) as { success?: boolean }; + return parsed.success === true; + } catch { + return false; + } +}