Implements the backend REST surface and WS wiring for
docs/superpowers/specs/2026-07-23-character-depth-ghost-log-design.md's
Workstream C:
- New models: UnlockRecord (unlocks), InventoryItem (inventory_items),
Sigil (sigils) — brand-new tables, picked up by main.py's existing
create_all.
- New app/inventory.py: unlock price table, item drop table/odds,
essence economy constants, sigil design validation, and an atomic
(row-locked) purchase_unlock() that guards against double-spend races.
- New app/routes/inventory.py: GET unlocks/items/sigils, POST sigils
(validates the placeholder {points, rune} shape, points capped at 12),
POST unlocks/{unlock_key} (402 on insufficient essence, 404 on unknown
key, idempotent re-buy).
- GET /auth/me now includes unlocks: list[str] and essence: int.
- ws.py: wires essence trickle + item_drop rolls into the one trigger
point that exists in this worktree today (_handle_summon, covering
every successful summon plus high-rarity summons); the other two
contract trigger points (correct judgment, successful ritual) belong
to Workstream B's not-yet-landed ritual/judgment WS handlers, which
should call app.inventory's same helpers once they land.
- User.essence: int added (Workstream B owns this column per the spec;
added here per orchestrator instruction so this workstream is
independently testable — merge controller reconciles the duplicate
edit).
Also fast-forwarded this worktree's branch onto master (it had fallen
behind several commits) so the files this workstream depends on
(shop.py, ws.py, entities.py, etc.) were actually present to build
against.
Tests: 109 passed (drop-roll statistical sanity with seeded RNG,
inventory/sigil CRUD, purchase success/insufficient-funds/idempotency/
unknown-key paths, /auth/me shape, ws summon-trickle and item-drop
wiring).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
First sub-project of the "make contact feel real" arc (spec:
docs/superpowers/specs/2026-07-23-possession-presentation-design.md).
Direct Contact replies now feel like a spirit fighting through static to
hold the channel rather than a plain chat bubble:
- backend/app/possession.py: compute_stability(rarity, magnitude, rng) —
a 0.05-0.98 score per reply (rarer entity + stronger triggering anomaly =
cleaner signal), rng injectable for a later quantum-RNG source.
- ws.py sends stability on reply_start; audio synthesis for that reply gets
noise/bitcrush scaled by instability (1 - stability) via a new
instability param on synthesize_spirit_voice — effects.py itself is
untouched, only the params fed into it.
- frontend/src/lib/possession.ts: renderPossessedText — pure, deterministic
(tick-seeded, no Math.random) text corruption with self-correcting
glitch bursts, wired into Transcript.tsx's streaming reply display.
Stored transcript/reply text is unaffected — this is presentation only.
78/78 backend, 137/137 frontend tests passing.
The gap-g-readme merge commit (57a8914) staged this fix but never
re-staged it after editing, so the merge landed with the pre-fix content —
the working tree had the correction but git didn't. No functional change,
just closing the gap between what was intended and what was committed.
websocket.client.host is always the Cloudflare Tunnel machine's LAN IP for
every internet-facing connection (the tunnel runs on a separate machine and
terminates TLS there), which collapsed per-IP rate limiting into a single
shared bucket for all remote visitors — the exact gap flagged in review.
Cloudflare's edge sets CF-Connecting-IP itself, overwriting any
client-supplied value, so it's safe to trust when present. Falls back to
the raw socket peer for direct LAN/local access.
auth_sessions rows were never deleted after expiry, only rejected
on read. Adds a background sweep (every 30 min) in the app lifespan,
plus a tested pure delete_expired_sessions() function.
Session security already comes from a cryptographically random
256-bit token (secrets.token_urlsafe) hashed before storage —
SESSION_SECRET was required config that nothing ever read.
StaticFiles defaults to check_dir=True, which raises at import time if
frontend/dist/assets is missing on restart — taking down /healthz and
/auth/* along with the frontend. Pass check_dir=False so the mount never
crashes the app, and make the SPA fallback return a clear 503 instead of
an unhandled 500 when index.html is absent.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013PphXq1s43DNRj1uWKGXof
Added test_hits_expire_after_window_elapses() which uses unittest.mock.patch
to deterministically advance time and verify that expired hits are evicted from
the rolling window. This exercises the while loop in RateLimiter.allow() that
was previously untested.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013PphXq1s43DNRj1uWKGXof
httpx's cookie jar only auto-attaches Secure cookies to https:// requests.
Switching the ASGITransport client fixture's base_url from http://test to
https://test (no real socket is opened either way) makes it behave like a
browser talking to the Cloudflare-Tunnel-terminated HTTPS edge in
production, eliminating the need for manual client.cookies.set(...)
re-injection workarounds in test_auth.py.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013PphXq1s43DNRj1uWKGXof
Addresses three Important-severity review findings inherited from Task 4's
plan reference code:
- login() now sets secure=True on the session cookie (safe behind the
Cloudflare Tunnel, which terminates TLS at the edge).
- logout() looks up and deletes the matching AuthSession row before
clearing the cookie, so a leaked raw token can no longer be replayed
after logout.
- login() always performs exactly one verify_password call regardless of
whether the username exists (against a module-level dummy hash for
nonexistent users), removing the timing oracle that let unauthenticated
requests distinguish registered from unregistered usernames.
Adds two tests: nonexistent-username login rejection, and logout revoking
the session server-side. Also adjusts two cookie-propagation touch points
in test_auth.py to manually re-inject the qm_session cookie, since
httpx's cookie jar won't auto-attach a Secure cookie to the test
transport's plain http://test base_url (a real browser talking to the
HTTPS tunnel edge wouldn't have this problem).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013PphXq1s43DNRj1uWKGXof
Production code shouldn't branch on `"pytest" in sys.modules` — it's
fragile and couples db.py to test tooling. Instead, conftest.py now
builds its own dedicated NullPool engine directly from settings, used
only for the drop_all/create_all reset and the get_db override. The
production engine in app/db.py is untouched and never exercised during
tests, so this fully preserves the event-loop fix while keeping prod
code test-agnostic.
Task 3's conftest.py (per plan) reuses the module-level app.db.engine
singleton across every test. pytest-asyncio 0.24 gives each test function
its own event loop by default, and asyncpg connections are bound to the
loop they were opened on. Pooling a connection from a prior test's loop
made subsequent tests fail with "got Future attached to a different loop"
as soon as more than one DB-touching test ran in the same session.
NullPool is applied only when running under pytest (detected via
sys.modules), so production keeps normal connection pooling and only the
test suite pays the cost of a fresh connection per checkout.
- Restored .worktrees/ line that was lost when .gitignore was overwritten in commit 1736dd0
- Added backend/tests/__init__.py as a package marker (was untracked on disk)
- Test suite verified: backend tests pass (1/1)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>