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
+17 -1
View File
@@ -1094,6 +1094,22 @@ load. Registering sets <code>email</code> and <code>password_hash</code> on that
— so every adventure they played as a guest survives with no re-parenting and no migration step.
Three kinds of row share the users table: local, guest, and registered.</p>
<p><strong>Guests expire; accounts don't.</strong> One row per curious visitor adds up, so
<code>cleanup.py</code> deletes guests idle for <code>AIDND_GUEST_RETENTION_DAYS</code>
(default 5) — measured as <code>COALESCE(last_seen_at, created_at)</code>, since
<code>_touch</code> only writes <code>last_seen_at</code> hourly and a freshly minted guest
has NULL until its second request. The filter requires both <code>is_guest</code>
<em>and</em> <code>email IS NULL</code>, so upgrading in place is also how you opt out of
expiry. It sweeps once at startup — the reliable trigger on a host that sleeps — and then
every few hours.</p>
<p>It's a single Core <code>DELETE</code>, not <code>db.delete(user)</code>: the ORM path
would SELECT every adventure, action and memory into Python purely to delete them, and the
foreign keys are <code>ON DELETE CASCADE</code> from <code>users</code> all the way down, so
the database does the whole graph in one statement. Nothing a guest owns is visible to
anyone else either — <code>is_public</code> is output-only, so shared content is exactly the
seeded scenarios, which have <code>user_id NULL</code> and never match the filter.</p>
<h3><span class="h-num">3.2</span>The shared demo key</h3>
<p>The demo lets people play with no signup and no API key, on a key the server pays for. That is a
@@ -1133,7 +1149,7 @@ successful turn.</p>
<thead><tr><th>Thing</th><th>Mechanism</th></tr></thead>
<tbody>
<tr><td>Passwords</td><td><code>hashlib.scrypt</code>, N=2¹⁴, r=8, p=1, per-password salt, constant-time compare. Stdlib, so no extra dependency.</td></tr>
<tr><td>Sessions</td><td><code>v1.&lt;user_id&gt;.&lt;HMAC-SHA256&gt;</code>, no expiry — long-lived guest sessions are the point.</td></tr>
<tr><td>Sessions</td><td><code>v1.&lt;user_id&gt;.&lt;HMAC-SHA256&gt;</code>, 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.</td></tr>
<tr><td>Stored LLM API keys</td><td>Fernet encryption at rest, key derived from the secret, <code>enc:</code> prefix so legacy plaintext rows are recognisable and migratable.</td></tr>
</tbody>
</table>