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:
co-authored by
Claude Opus 5
parent
c500203270
commit
bbcb07c6be
+16
-1
@@ -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),
|
||||
|
||||
+17
-1
@@ -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.<user_id>.<HMAC-SHA256></code>, no expiry — long-lived guest sessions are the point.</td></tr>
|
||||
<tr><td>Sessions</td><td><code>v1.<user_id>.<HMAC-SHA256></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>
|
||||
|
||||
Reference in New Issue
Block a user