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
+8 -2
View File
@@ -68,7 +68,12 @@ export default function App() {
<div className="nav-account">
{me.is_guest ? (
<>
<span className="guest-nudge">Playing as guest — sign up to keep your adventures</span>
<span className="guest-nudge"
title={me.guest_retention_days
? `Guest adventures are deleted after ${me.guest_retention_days} days without a visit.`
: undefined}>
Playing as guest — sign up to keep your adventures
</span>
<button onClick={() => setAuthMode('login')}>Log in</button>
<button className="primary" onClick={() => setAuthMode('register')}>Sign up</button>
</>
@@ -84,7 +89,8 @@ export default function App() {
</nav>
<Outlet context={{ me, setMe }} />
{authMode && (
<AuthModal mode={authMode} onClose={() => setAuthMode(null)} onAuthed={onAuthed} />
<AuthModal mode={authMode} onClose={() => setAuthMode(null)} onAuthed={onAuthed}
retentionDays={me?.guest_retention_days} />
)}
</ToastHost>
)
+7 -1
View File
@@ -234,7 +234,7 @@ export function PlaceholderModal({ title, names, onSubmit, onCancel }) {
// Phase 8: register/login for the hosted multi-user mode. `onAuthed(me)` gets
// the fresh /auth/me payload after success.
export function AuthModal({ mode: initialMode, onClose, onAuthed }) {
export function AuthModal({ mode: initialMode, onClose, onAuthed, retentionDays }) {
const [mode, setMode] = useState(initialMode || 'register')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
@@ -283,6 +283,12 @@ export function AuthModal({ mode: initialMode, onClose, onAuthed }) {
{registering
? 'Everything you’ve played as a guest stays with your new account, and you can pick it up from any device.'
: 'Log in to reach your adventures.'}
{/* Guest data really is deleted, so say so where the decision is
being made. The window comes from the server (see cleanup.py) so
it can't drift from what's enforced. */}
{registering && retentionDays ? (
<> Guest adventures are deleted after {retentionDays} days without a visit.</>
) : null}
</p>
<div className="auth-tabs" role="tablist">