Delete guest accounts left idle for five days

The app had no cleanup of any kind: in multi-user mode every first visit
mints a users row, so the demo has been accumulating one permanent account
per visitor along with everything they generated.

cleanup.py sweeps guests idle for AIDND_GUEST_RETENTION_DAYS (default 5),
once at startup and then every few hours. Startup is the load-bearing
trigger — the free tier sleeps after ~15 minutes, so a long timer rarely
gets to fire.

Idle is COALESCE(last_seen_at, created_at), not last_seen_at: _touch only
writes that column hourly, and a guest minted by /auth/me has it NULL until
its second request, so the simpler query would have deleted brand-new
visitors mid-session.

It's one Core DELETE rather than db.delete(user), which would SELECT every
adventure, action and memory into Python purely to delete them — the same
egress pattern as the 189x fix. Every FK from users down is ON DELETE
CASCADE, so the database does the whole graph and returns a count.

The filter requires is_guest AND email IS NULL, so registered users (who
upgrade in place) and local mode's implicit user are both out of reach, and
is_public is output-only so a guest can never own content another user can
see. Session cookies have no expiry and can outlive a swept row; that path
401s and the frontend's existing retry re-mints a session.

Guests are told: /auth/me serves guest_retention_days and the signup modal
states the window, sourced from the server so it can't drift from what is
enforced.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CYEJKobJ2Re4Dv7qUoSA7
This commit is contained in:
parththakkar106
2026-08-15 20:14:12 +05:30
co-authored by Claude Opus 5
parent c500203270
commit bbcb07c6be
10 changed files with 453 additions and 6 deletions
+16 -1
View File
@@ -732,6 +732,21 @@ guest survives with no re-parenting and no migration step. Three kinds of row sh
users table: local (email NULL, not guest), guest (email NULL, guest), registered (email
set).
**Guests expire; accounts don't.** One row per curious visitor adds up, so `cleanup.py`
deletes guests idle for `AIDND_GUEST_RETENTION_DAYS` (default 5) — measured as
`COALESCE(last_seen_at, created_at)`, because `_touch` only writes `last_seen_at` hourly
and a guest minted by `/auth/me` has NULL until its second request. The filter requires
both `is_guest` *and* `email IS NULL`, so upgrading in place is also how you opt out of
expiry. It runs once at startup (the reliable trigger on a host that sleeps) and then
every few hours.
It's a single Core `DELETE`, not `db.delete(user)`: the ORM path would SELECT every
adventure, action and memory into Python purely to delete them, and the FK graph is
`ON DELETE CASCADE` from `users` all the way down, so the database can do the whole graph
in one statement. Nothing a guest owns is visible to anyone else either — `is_public` is
output-only, so shared content is exactly the seeded scenarios, which have `user_id NULL`
and never match the filter.
## 3.2 The shared demo key
The demo lets people play with no signup and no API key, on a key the server pays for. That
@@ -768,7 +783,7 @@ Everything derives from one server-side secret (`AIDND_SECRET_KEY`).
| Thing | Mechanism |
|---|---|
| Passwords | `hashlib.scrypt`, N=2^14, r=8, p=1, per-password salt, constant-time compare. Stdlib, so no extra dependency. |
| Sessions | `v1.<user_id>.<HMAC-SHA256>`, no expiry — long-lived guest sessions are the point. |
| Sessions | `v1.<user_id>.<HMAC-SHA256>`, no expiry — long-lived guest sessions are the point. A cookie can outlive a swept guest row; that resolves to a 401, which the frontend already turns into a fresh session. |
| Stored LLM API keys | Fernet (AES) encryption at rest, key derived from the secret, `enc:` prefix so legacy plaintext rows are recognisable and migratable. |
The secret auto-generates into a file next to the database for local installs (zero config),