Skip to content

feat(birth): transactional birth, single-flight ceremony hub, signup abuse gates (S3)#301

Merged
oratis merged 1 commit into
mainfrom
feat/s3-birth-hardening
Jul 25, 2026
Merged

feat(birth): transactional birth, single-flight ceremony hub, signup abuse gates (S3)#301
oratis merged 1 commit into
mainfrom
feat/s3-birth-hardening

Conversation

@oratis

@oratis oratis commented Jul 24, 2026

Copy link
Copy Markdown
Owner

Part of PLAN_WEB_SIGNUP_v1.0 — milestone S3. Stacked on #299 (S2) → #297 (S1).

What

Makes "注册完成自动进行 birth 流程" production-grade — the ceremony is visible, atomic, deduplicated, and bot-guarded.

Birth transactionality

The LLM dream now runs before any durable write, with one retry. Previously writeSeed + initSoulRepo ran first — a failed/unparseable LLM call left isBorn()=true with no name or identity, and lisa birth refused to re-run ("already born"): a half-born soul with no way out short of hand-deleting seed.json. Now a failed dream leaves nothing behind; a persist failure after the seed rolls it back. Covered by tests via an injectable dreamFn seam.

Single-flight ceremony (birth-hub.ts)

The background lazy path (first authed request) and the visible SSE ceremony (POST /api/birth) used to each run their own LLM call and race on writeSeed. They now share one run per uid; a watcher that attaches late replays the step transcript before streaming live — the typewriter ceremony stays complete even when the lazy path started dreaming first. With Firestore (B9) on, a birth-<uid> lease dedupes across instances.

Signup abuse gates

Every registration ignites an LLM call and mints a free window (–5/12h) — a direct cost target:

  • Cloudflare Turnstile on register (default-OFF via LISA_TURNSTILE_SITE_KEY/SECRET; fails closed when Cloudflare is unreachable)
  • Disposable-email blocklist (short static list of high-volume providers + LISA_EMAIL_BLOCKLIST deploy-time extension)
  • 5/hour/IP registration cap on top of the shared auth bucket

Tenancy debt from the plan review

  • SOUL_LOCK_PATH and the soul git lock were import-time constants frozen to the global home — every cloud tenant serialized on one shared lock outside its own repo. Both are functions now (paths.ts doctrine).
  • Soul git history defaults OFF on cloud (gcsfuse + git = slow, fragile locks); LISA_SOUL_GIT force-overrides both ways. Mac edition unchanged.

Tests

npm test: 1319 pass / 0 fail. New: birth transactionality (3), birth hub (5), turnstile + blocklist (6).

Operator steps (not in this PR)

Create a Turnstile widget for cloud.meetlisa.ai in the Cloudflare dash, then deploy with the two LISA_TURNSTILE_* vars.

🤖 Generated with Claude Code

@oratis

oratis commented Jul 25, 2026

Copy link
Copy Markdown
Owner Author

Review — merge after fixes 🔴 (adversarial pass)

The headline claims hold: single-flight birth is genuinely atomic in-process (synchronous runs.getruns.set, no await between), cross-uid isolation is correct (scopedUid() key + homeScope sticks across awaits + all LLM-supplied slugs pass assertSafeSlug), and Turnstile/global-cap fail closed. But two real issues:

MUST-FIX 1 — birth crash-window still wedges a half-born soul (Med-High). birth.ts writes seed.json first (that's the isBorn() flip), then name/identity/purpose/…/lock. The rollback only runs on a thrown error — a hard crash (Cloud Run eviction, OOM, SIGKILL, deploy mid-request) after writeSeed leaves seed.json with no identity.md, so isBorn() returns true forever and re-birth is refused — unrecoverable without an operator hand-deleting the file. This is exactly the wedged state the PR says it fixes; it just moved the window from "LLM failure" to "crash after writeSeed." Fix I'll push: write seed.json last (after the rest + lock), so atomicWrite's tmp+rename makes the isBorn flip atomic and a crash leaves isBorn()=false → re-runnable.

MUST-FIX 2 — birth LLM inference bypasses the billing preflight entirely (policy call for you). ensureUserBirth + POST /api/birth call birth()dreamSoulrunTurn with no preflightLimits (no kill-switch, no global-daily-cap, no per-uid-RPM) and no recordUsage, so birth spend is unmetered, uncapped, and LISA_BILLING_KILL=1 can't stop it; the retry doubles it. Combined with weak signup gates (Turnstile default-OFF, XFF-spoofable per-IP reg cap, +tag/dot email aliasing), the default deploy has no ceiling on registration→birth inference. Minimum safety floor (kill-switch + count toward the $200/day cap) I can add unambiguously; whether free/unverified users get free birth is a product call — see my summary comment.

Non-blocking: Turnstile default-off, disposable-list is a tiny exact-match set, runs map uncapped, rolling-deploy multi-instance chimera when Firestore off.

…abuse gates (S3)

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 <noreply@anthropic.com>
@oratis

oratis commented Jul 25, 2026

Copy link
Copy Markdown
Owner Author

Adversarial review + rebase onto main (S1/S2 superseded by A-series #305)

Reviewed, optimized, rebased onto current main, and re-based off main (the old base feat/s2-email-otp is dead — S1/S2 were closed as superseded by the A-series auth #305). typecheck clean; full suite 1377 pass / 0 fail.

Findings & resolutions

1. Birth crash-window (correctness, high). S3 wrote seed.json first under a try { … } catch { fsp.rm(seed) } rollback. seed.json is the isBorn() flip, and a catch-only rollback does not cover a hard crash (Cloud Run eviction / OOM / SIGKILL) between the seed write and the rest of the soul landing — leaving a half-born soul that re-runs are refused as "already born". Rewrote to seed-last: name/identity/purpose/constitution/value/desire/emotions/lock all persist first, then writeSeed() + initSoulRepo() as the final atomic flip. No rollback needed — any crash or throw before the flip leaves isBorn()=false and the birth simply re-runs. (Same crash-safety that landed on main via #306, kept here.)

2. Testability preserved. Kept S3's dreamFn injection seam + birth.test.ts (the on-main birth has no seam), so the transactional guarantee is now covered by tests: dream fails twice → no seed, retry succeeds → born, second birth refused once born.

3. Auth wiring reconciled with A-series. S3's login page and register handler were written against the pre-A-series auth API. Resolved by keeping main's shipped A-series login page + mailer/OTP API, and grafting only S3's net-new value onto it:

  • login.ts: Cloudflare Turnstile widget rendered lazily on the password/register pane (no CSP to allowlist), token posted with /api/auth/register; friendly turnstile_failed / disposable_email messages.
  • server.ts: register-path abuse gates (verifyTurnstile when configured, isDisposableEmail, per-IP cap) coexist with A-series sendVerificationEmail/sendSignInCodeEmail; birth-hub startBirthOnce single-flight wired at both call sites.
  • deploy.sh: kept A-series LISA_GOOGLE_{WEB,IOS}_CLIENT_ID, added S3's LISA_TURNSTILE_* / LISA_EMAIL_BLOCKLIST, dropped S1's dead LISA_CLOUD_GOOGLE_* (0 readers).

4. Turnstile fails closed — verified by test (empty token / HTTP error / network error all → reject), and enforced only when both halves are configured (ts.enabled), so unconfigured instances degrade gracefully.

Deferred to S4 (#302)

The birth billing floor (#301-M2 — birth spends premium inference outside a global kill-switch/meter) is deferred to S4, where the per-uid autonomy kill-switch + recordUsage floor is the central concern. Birth is once-per-account and now fully gated (single-flight + Turnstile + disposable + IP-cap), so its residual exposure is bounded; S4 will wire birth to the same kill-switch it introduces.

@oratis
oratis force-pushed the feat/s3-birth-hardening branch from 1b40dae to d1718e9 Compare July 25, 2026 05:06
@oratis
oratis changed the base branch from feat/s2-email-otp to main July 25, 2026 05:06
@oratis
oratis merged commit 32a6a5b into main Jul 25, 2026
@oratis
oratis deleted the feat/s3-birth-hardening branch July 25, 2026 05:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant