Skip to content

Stage Digger MVP: offline-first PWA + thin FastAPI backend (Hybrid unlock, creator, auth) - #1

Open
hstre wants to merge 19 commits into
mainfrom
claude/stage-digger-purpose-nk3m3h
Open

Stage Digger MVP: offline-first PWA + thin FastAPI backend (Hybrid unlock, creator, auth)#1
hstre wants to merge 19 commits into
mainfrom
claude/stage-digger-purpose-nk3m3h

Conversation

@hstre

@hstre hstre commented Jun 10, 2026

Copy link
Copy Markdown
Owner

Überblick

Baut Stage Digger vom Konzept zu einem lauffähigen MVP aus: eine offline-first PWA (React/Vite/Leaflet/Dexie) plus ein dünnes FastAPI-Backend (SQLite + R-tree). Lesen ist statisch (vorgerenderte Regionalpakete), nur Schreiben/Verifizieren geht an den Server.

Der Diff geht gegen main (= Konzept-Commit) und enthält daher die gesamte Implementierungsarbeit.

Kernstück: Hybrid-Offline-Unlock (funkloch-tauglich)

Blind/Mystery-Digs liefern die ganze, verschlüsselte Stage-Kette ans Gerät. Folge-Stages sind AES-256-GCM-verschlüsselt, Schlüssel via PBKDF2-HMAC-SHA256 aus dem Lösungsgeheimnis:

  • text → exakte Antwort (stark)
  • coord → Geohash der Zielkoordinate; das Gerät rekonstruiert den Schlüssel aus dem eigenen GPS-Fix + 8 Nachbarzellen → man muss physisch dort sein. Ziel verlässt nie den Server.
  • img → Referenz-dHash (Honor-System; der Hash wird zum Abgleich ohnehin mitgeliefert)

Offline schaltet das Gerät lokal frei; mit Netz bestätigt /api/verify zusätzlich (Schummelschutz). Krypto-Interop Python ↔ Browser-WebCrypto verifiziert (richtige Lösung entschlüsselt, falsche wird abgewiesen, weit-weg bleibt gesperrt). Lösung/Zielkoordinate steht nie im Klartext-Paket.

Was drin ist

  • Dig-Erstellung im Browser — Foto-dHash wird lokal berechnet (Rohfoto verlässt das Gerät nie), GPS-Ziele per Standort oder Karten-Picker (Leaflet, ohne vor Ort zu sein).
  • Meine Digs verwalten — auflisten / bearbeiten / löschen mit Ownership-Schutz (403); GET/PUT/DELETE /api/digs*, Paket-Re-Render.
  • Auth (JWT/HS256, stdlib) — Register/Login/Me/Profil; PBKDF2-Passwort-Hash. Gehärtet: Access- + Refresh-Token (mit Kind-Claim), Rate-Limiting (429) auf login/register, E-Mail-Verifizierung (Flag + verify-Endpoint). Frontend refresht den Access-Token transparent bei 401.
  • On-Device-Foto-Verifikation (dHash/Hamming), Leaflet-Karte, Dexie-Offline-Store, PWA/Service-Worker.

Verifikation

Kein Test-Framework im Repo (MVP) — Gate ist tsc --noEmit + vite build (grün) plus manuelle End-to-End-Checks gegen ein laufendes Backend:

  • Krypto-Round-Trip Python↔Node-WebCrypto (text & geohash), Leak-Checks (kein target/Klartext-Antwort/Zielkoordinate im Paket).
  • Creator-Loop: erstellter Dig rendert verschlüsselte Folge-Stage, entschlüsselt offline mit gewählter Lösung.
  • Dig-Management: create → mine → get → fremder User 403 → owner edit → delete → 404.
  • Auth: register liefert access+refresh+verify_token; access/refresh nicht vertauschbar (401); refresh→neuer access; verify-email setzt Flag; 11./12. Schnell-Login → 429.

Ehrliche offene Punkte

  • E-Mail-Verifizierung gibt den verify_token im Dev direkt zurück (kein Mailer) — Production braucht SMTP/Mail-Dienst, der den Token als Link verschickt.
  • Rate-Limiting ist prozesslokal (hinter mehreren Workern → Redis o. Ä. + X-Forwarded-For).
  • Refresh-Token werden nicht rotiert/widerrufen. AUTH_SECRET in Production zwingend als Env-Var setzen.
  • Foto-Offline-Unlock ist Honor-System (fuzzy Hash → kein starker Schlüssel); echter Schummelschutz dort ist der Online-Check.
  • Demo-Paket frontend/public/v1/u0yjj.json ändert sich bei jedem Seed (zufällige Krypto-Salts).

🤖 https://claude.ai/code/session_01CfgrUbTodCUH52bbJ8vCS7


Generated by Claude Code

claude added 19 commits June 10, 2026 12:40
ARCHITECTURE.md: pins the MVP stack driven by the concept's guardrails.
- Core lever: reads are static (pre-rendered regional packages served as
  static files), only writes hit the dynamic backend
- Frontend: React + Vite + TypeScript PWA, Workbox service worker,
  IndexedDB (Dexie) for the offline regional package, Leaflet + OSM maps
- Backend: Python + FastAPI (thin, writes only), SQLite + R-tree spatial index
- Single small VPS hosting; media stored as hash-referenced files, cached on demand
- Online/offline split and Blind/Mystery reveal security note
- Geocaching base modelled as a single-stage Dig

DATA_FORMAT.md: the compact data format and regional package.
- Brotli-compressed JSON, readable keys, geohash-cell partitioning
- Full package schema (users lookup, digs, stages with loc/verify/next)
- Security: solutions and next stages for Blind/Mystery never shipped offline
- Delta-sync forward-compatibility
- SQLite persistence schema mirroring the package
Frontend (frontend/): React + Vite + TypeScript PWA
- Service worker (vite-plugin-pwa/Workbox) caches app shell, OSM tiles and
  the static regional packages for offline use
- IndexedDB store (Dexie) holds the downloaded region; browsing is local
- Geohash-based region sync: fetch current cell + 8 neighbours
- Leaflet/OSM map view + dig list, accordion menu matching the mockups
  (Find your Dig / Backpack / Statistik / Config + Chat rail)
- Typecheck (tsc) and production build verified

Backend (backend/): FastAPI, deliberately thin
- SQLite + R-tree spatial index (db.py), schema per docs/DATA_FORMAT.md
- Regional-package renderer (packages.py) enforces offline-safety: secret
  verify targets never shipped; Blind/Mystery ship only the entry stage
- Dig create + dynamic region read (routers/digs.py)
- Stage verify + reveal, incl. haversine coord check (routers/verify.py)
- seed.py creates demo digs (Simple/Blind/Mystery) and renders packages;
  verified the security stripping holds

Plus README, Makefile, .gitignore, ruff config.
The reference photo is reduced to a non-reversible 64-bit dHash so it can
ride in the offline package without leaking the scene.

Frontend:
- phash.ts: dHash (9x8 grayscale diff) + Hamming distance, pure core split
  from the canvas step so it is testable
- PhotoVerify: pick/take a photo, hash on-device, compare to shipped phash,
  show match + Hamming distance (with a dev helper to set a reference)
- DigDetail + clickable DigList: open a dig, see its stages, run photo verify
  for img stages; shows how many stages stay hidden for Blind/Mystery

Backend:
- StageVerify gains phash/tol_bits
- Renderer ships phash ONLY for Simple img stages (on-device match);
  withheld for Blind/Mystery (server-side reveal)
- verify router compares dHash server-side (Hamming) for Blind/Mystery
- seed: added a Simple photo-mission dig

Docs updated (ARCHITECTURE online/offline table + DATA_FORMAT §4).
Verified: leak check (no secret target shipped), phash present only on the
Simple photo stage, Hamming distances correct, frontend typecheck + build,
PWA service worker generated.
Blind/Mystery digs now ship the whole (encrypted) chain so they play in dead
zones: each follow-up stage is AES-256-GCM encrypted with a key derived
(PBKDF2-HMAC-SHA256) from the current stage's answer. Offline the device
unlocks the next stage locally; when online, /api/verify additionally confirms
the solve (anti-cheat / progress).

Backend:
- crypto.py: PBKDF2 + AES-GCM encrypt, params matched to WebCrypto
- packages.py: renderer builds the encrypted chain for text-gated transitions;
  fuzzy gates (photo/GPS) stop the offline chain (online reveal). No raw target
  / plaintext answer ever shipped.

Frontend:
- unlock.ts: WebCrypto PBKDF2 + AES-GCM decrypt (tryUnlock)
- StageSolve: answer input -> local decrypt; best-effort online confirm
- DigDetail: reveals locally-unlocked stages inline
- api.verifyOnline; Stage.enc / EncBlob types

Docs updated (ARCHITECTURE Hybrid section + online/offline table, DATA_FORMAT enc).

Verified: no raw target/answer leaks in package, enc present on the text-gated
Mystery stage, and Python<->Node WebCrypto round-trip (correct answer decrypts,
wrong answer rejected). Frontend typecheck + build green; backend imports.

Note: photo/GPS offline unlock (the fuzzy case) is intentionally not yet built.
Extends the Hybrid model from text-only to all gate types, so Blind/Mystery
play fully offline:

- coord: next stage is AES-GCM encrypted under the geohash of the *target* at a
  tolerance-derived precision (verify.gp). The target never ships; the device
  reconstructs the key from its own GPS fix + 8 neighbour cells, so one must be
  physically present. Online /api/verify still enforces the exact tol_m.
- img: encrypted under the reference dHash. Honor-system only (the hash is also
  shipped for local matching) — documented as such; real anti-cheat is online.

Backend: _offline_key() + _coord_precision() in packages.py; _build_chain now
embeds enc for text/coord/img gates.
Frontend: tryUnlockCoord (geohash candidates) in unlock.ts; new StageCoordUnlock
component; PhotoVerify reveals the next stage on local match + best-effort
online confirm; DigDetail wires both; Stage.verify.gp type.

Verified end-to-end cross-language (Node WebCrypto <-> Python): standing at the
target decrypts via geohash candidates, far away stays locked; no target /
answer / target-coordinate leaks in the package. Build green.
New 'Create your Dig' panel posts to the existing POST /api/digs. The creator's
browser computes photo hashes locally (phash.ts) so the raw photo never leaves
the device; GPS targets are set from the device location. Secrets (answer /
target coord / phash) go to the server for the online check and flow into the
encrypted offline chain, never into the cleartext package.

- types.ts: NewDig/NewStage/NewVerify/NewLoc (mirror backend DigIn, carry target)
- api.ts: createDig()
- CreateDig.tsx: form for name/art/auspr/cost/desc + n stages (gps|desc loc;
  text|coord|img verify with on-device phash; optional follow-up message);
  client-side validation
- App.tsx: menu section + panel + region refresh after create
- styles for the form

Verified end-to-end against a running backend: a Mystery dig created via the API
renders a package whose follow-up stage is encrypted, unlocks offline with the
chosen answer (Node WebCrypto), rejects a wrong answer, and leaks no secret.
Build green.
Backend (no new deps):
- auth.py: PBKDF2 password hashing + HS256 JWT (signed with AUTH_SECRET),
  current_user dependency, public_user (strips pwd_hash)
- db.py: user gains email + pwd_hash with a lightweight ALTER migration and a
  unique email index
- models.py: RegisterIn / LoginIn / ProfileIn; DigIn.user_id now optional/ignored
- routers/auth.py: POST /register, POST /login, GET/PATCH /me
- POST /api/digs now requires a valid token; the owner comes from sub

Frontend:
- api.ts: token storage (localStorage) + register/login/fetchMe/updateProfile;
  createDig sends the Authorization header
- Auth.tsx: login/register tabs + profile editor (nick/clan/bio) + logout
- CreateDig: login gate (NewDig no longer carries user_id)
- App.tsx: restores session via fetchMe on boot; Config panel hosts Auth;
  CreateDig receives loggedIn

Verified against a running backend: register issues a token, duplicate email ->
409, login + wrong-password -> 401, /me round-trips and never returns pwd_hash,
profile PATCH persists, and create is 401 without a token / 201 with one. Fresh
DB exercises the schema; build green.
Manage 'my digs':
- backend: GET /api/digs/mine, GET /api/digs/{id} (owner-only, returns secret
  targets for editing), PUT /api/digs/{id} (replace stages wholesale), DELETE
  /api/digs/{id}; all enforce ownership (403) and re-render the region package
- frontend: MyDigs panel (list + Bearbeiten/Löschen with confirm); CreateDig
  gains an edit mode (prefilled from the owner view, submits PUT); App wires the
  panel, edit state and menu items

Map target picker:
- LocationPicker (Leaflet): click or drag a marker to set a coordinate, so a
  creator can place GPS targets without being on-site
- wired into CreateDig's GPS location controls alongside 'Mein Standort'

Verified end-to-end against a running backend: create -> mine -> get (sees
secret) -> a foreign user is blocked 403 on get/delete -> owner edit (PUT 200,
rename + changed answer/tolerance persisted) -> delete (204) -> get 404. Build
green.
Backend (still stdlib-only):
- tokens carry a 'knd' claim; short-lived access (30 min) + long-lived refresh
  (30 days). decode_token enforces the kind, so a refresh token can't be used as
  an access token and vice-versa. POST /api/auth/refresh mints a new access token.
- in-memory per-IP sliding-window rate limiting on login (10/5min) and register
  (5/10min) -> 429.
- email verification: email_verified column (+ migration) and
  POST /api/auth/verify-email consuming a signed 'verify' token. register returns
  the token directly in dev (no mailer) — documented as a follow-up to wire SMTP.

Frontend:
- store access + refresh; authFetch transparently refreshes the access token
  once on 401 and retries; clears tokens when refresh fails.
- register surfaces the verify token; Profile shows verification status and a
  confirm control (prefilled in dev); friendly 429 messaging.

Verified against a running backend: register returns access+refresh+verify_token
(email_verified=0); access works on /me; refresh token rejected as access and
vice-versa (401); refresh mints a working access token; verify-email flips the
flag to 1; 11th/12th rapid bad login -> 429. Fresh DB exercises the migration;
build green.

Honest gaps (documented): dev exposes verify_token (needs a mailer); rate limit
is process-local; refresh tokens are not rotated/revoked; set AUTH_SECRET in prod.
The geocaching basic that was missing: the log table existed but had no
endpoints/UI. A cache is already a single-stage coord-find dig (map + coordinates
exist); this adds the social logbook layer.

Backend:
- log gains a 'type' column (found|dnf|note) with migration + schema default
- models.LogIn (type, optional text, optional 1-5 stars)
- routers/logs.py: GET /api/digs/{id}/logs (public: find count + entries with
  author nick) and POST /api/digs/{id}/logs (auth). A rating feeds the existing
  dig.stars_sum/nstars aggregate that the package renders.

Frontend:
- api: getLogs/addLog (+ LogEntry/LogType); addLog via authFetch
- Logbook component: find count, entry list, and an add form (type + optional
  rating + comment) gated on login; degrades to an 'online only' hint offline
- DigDetail renders the Logbook and receives loggedIn from App

Verified against a running backend: log without auth -> 401; found+rating, dnf
and note all persist; list returns finds count + entries with nick; missing dig
-> 404; out-of-range stars -> 422. Build green.

Note: attributes (dig.attrs) are modelled but still have no UI — the next small
geocaching piece.
Sources real geocaching data the open way (decided: OKAPI now, Groundspeak
partner API once big enough). A cache = a single-stage coord-find Dig.

Backend:
- app/okapi.py: stdlib-only OKAPI client (bbox search + batch geocache details)
  keyed to our geohash cells; pure map_okapi_cache() -> dig fields with a
  CC-compliant attribution string. Config via OKAPI_CONSUMER_KEY (+ optional
  OKAPI_BASE, default opencaching.de). Public reads need only a consumer key.
- dig gains source + ext_id columns (migration) and a unique (source, ext_id)
  index for dedup.
- routers/import_okapi.py: POST /api/import/okapi {cell, limit} (auth) ->
  fetch + map + persist coord-find digs, skip already-imported, re-render the
  region package. 503 if no key configured, 502 if OKAPI unreachable.

Frontend:
- api.importOkapi(cell) via authFetch (friendly 503 messaging)
- ImportOkapi panel: imports the current region's cell, shows imported/skipped
  counts + the CC BY-NC-ND attribution; wired into the Create section + App.

Licence handled honestly (docs): opencaching.de is CC BY-NC-ND (attribution,
non-commercial, no-derivatives) — imported as plain attributed coord-finds, not
folded into encrypted multi-stage chains; off-limits if the app goes commercial.

Verified offline (no key/network needed): bbox_param + map_okapi_cache pure
functions; 503 without key; end-to-end import with a stubbed OKAPI -> 2 imported
/1 skipped (bad loc), re-run dedups to 0 imported/3 skipped, stage verify and
attribution persisted correctly. Build green.
Completes the geocaching basics (map, coordinates, logbook were done):
- frontend: a curated 16-entry attribute catalog (GEO_ATTRS, stable ids + icon +
  German label) in types.ts, with ATTR_BY_ID lookup
- CreateDig: a chip multi-select for attributes; included in the create/update
  payload and prefilled in edit mode
- DigDetail: renders the dig's attributes as chips
- backend: dig.attrs already flowed through DigIn -> store -> package render;
  get_dig now also returns attrs so the editor can prefill

Verified against a running backend: create with attrs [1,3,12] -> get_dig and
the rendered region package both return them -> edit to [5,6] persists. Build
green.

(The catalog is self-defined for the MVP; if we later align with OKAPI/Groundspeak
attribute acodes, only the id mapping changes.)
QR-Code and Clipboard were placeholder stubs and the filter was a plain list;
this implements the concept's finding/filtering/sharing, all over the offline
region package (no extra server roundtrips):

- Filtersuche (DigList): full-text (name/desc/Kultur), Art, Ausprägung, min
  rating, age (8/12/16/18+), party size (vs pmin/pmax), radius (haversine to the
  user's location), and attributes — client-side over the loaded digs, with a
  shown/total count and per-card distance.
- QR-Code (QrScan): scans via the browser-native BarcodeDetector (no dep);
  graceful fallback to manual link/code entry where unsupported.
- Link/Clipboard (OpenDig): open a dig from a pasted link/code; parseDigRef
  understands ?dig=/#dig=//dig/ and bare/SD codes.
- Deep link ?dig=<id> opens the dig once after the region loads; DigDetail gains
  a Teilen button (navigator.share or clipboard copy of the share link).
- App.openRef(id) resolves from the loaded package then the IndexedDB store,
  with a clear 'not in this region' status otherwise.

parseDigRef verified across forms (bare/#/SD/?dig=/#dig=//dig//garbage/padded).
Build green. Removed the now-unused Placeholder.
…-gated

Stored auspr now drives access + payment (concept §3), enforced server-side:
- packages renderer: skips privat (unlisted, never in the public package) and
  renders premium/pay as metadata only (locked, no stages) so they're listed but
  withheld — mirrors how Blind/Mystery hide later stages.
- render_dig_play(id, viewer): single player-facing dig for the online open path;
  full for free/privat, and for premium/pay only when the viewer is the owner or
  has an unlock row.
- GET /api/digs/{id}/play (optional auth) + POST /api/digs/{id}/unlock (auth):
  unlock spends  nuggets, credits the creator for premium, records an
  idempotent unlock; 402 when short on nuggets, 400 for free digs.
- new unlock table; auth.optional_user dependency.

Frontend:
- Dig.locked; playDig/unlockDig in api
- DigDetail: lock card for premium/pay with a 'Für N Nuggets freischalten' button
  (login required); on success re-fetches /play and refreshes the user's balance
- openRef falls back to /play online, so unlisted (privat) and out-of-region
  shares open; DigList + map show a 🔒 cost badge
- App passes onUnlocked to update the selected dig and re-fetch the user

Verified end-to-end against a running backend: privat excluded from the package
but full via /play; premium/pay listed locked (0 stages); owner sees full; a
buyer with 0 nuggets -> 402, after granting -> unlock pays 10 (balance 40),
creator earns +10, /play then returns full stages, re-unlock idempotent. Build
green; fresh DB exercises the new table.
Extends Simple/Blind/Mystery with two more arts (concept lists these as 'later';
pulled forward on request):

- Event (art 'e'): a scheduled guided tour. Delivered like Simple (full trail);
  the dig carries event_at (ISO datetime, new column + migration), editable in the
  creator (datetime-local), rendered into the package, and shown in the list and
  detail (de-DE formatted).
- Build (art 'd'): an ordered build-plan. Delivered like Simple, but the detail
  view sequences stages as 'Bauschritte/Teile' — only the current step is active,
  earlier ones ✓, later ones 🔒, with 'Teil X von N' progress. Ordering is tracked
  client-side ('Teil erhalten'); server-enforced sequencing is a follow-up.

Backend: DigArt adds e/d; renderer treats s/e/d alike (full trail, only b/m use
the encrypted chain); event_at stored/rendered/returned; bad art still 422.
Frontend: ART_LABEL + creator options + event input + build progress UI; event
date in DigList. Seed gains a sample Event and Build so the demo shows both.

Verified against a running backend: event with event_at + full stages, build full
stages, get_dig returns event_at, invalid art -> 422; both appear in the rendered
packages. Build green.
Rating (Bewertung) as its own channel, decoupled from the logbook so a score is
one-per-user and editable (the old logbook stars double-counted):
- rating table; GET /api/digs/{id}/rating -> {avg,count,mine,planned};
  POST /api/digs/{id}/rate {stars 1-5} upserts and keeps dig.stars_sum/nstars in
  sync incrementally (sits on top of a seeded baseline, no double counting).
- logbook is now a pure visit record (found/dnf/note); stars removed from LogIn,
  add_log and the Logbook UI.
- DigDetail shows avg + count and a clickable 1-5 star control for your rating.

Sidebar (concept §7):
- plan (bookmark) table; POST/DELETE /api/digs/{id}/plan; planned status surfaced
  in the rating summary and toggled via a '+ Merken / ✓ Geplant' button.
- me router: GET /api/me/stats (level, liga, nuggets + made/created/planned
  counts) and GET /api/me/backpack (planned / made[found logs] / own digs).
- Statistik and Backpack panels replace the placeholders; Backpack items open
  via openRef.

Verified against a running backend: baseline 4.6 (23/5) -> rate 5 (count 6,
4.7) -> change to 1 (count stays 6, 4.0, no double count) -> 0 stars 422; plan
add/remove reflected in planned; stats made/created/planned correct; backpack
lists right; /me/* require auth (401). Build green; fresh DB exercises the new
tables.
Online/social chat, polling-based (no WebSocket):
- message table (scope dig|clan + scope_key); GET returns messages with id>after
  for incremental polling, POST appends.
- dig chat: GET /api/chat/dig/{id} public, POST requires auth (everyone in the
  same adventure). clan chat: GET/POST /api/chat/clan uses the user's clan, 400
  without one.
- frontend: a shared polled ChatBox (5s, after-cursor, auto-scroll); DigChat in
  the dig detail view, ClanChat in a new 'Kommunikation' sidebar panel (prompts
  to log in / set a clan as needed).

Verified against a running backend: dig posts + anonymous read; after-cursor
returns only newer messages; empty text 422; posting unauthenticated 401; clan
members see each other's messages; no-clan -> 400. Build green; fresh DB
exercises the message table.
Completes the wallet that premium unlock already used (concept section 6):
- app/economy.py: grant() moves nuggets and writes a ledger row in one step, so
  every balance change is auditable. Tunable amounts in one place.
- earning rules: create a dig +20 (create); first 'found' on someone else's dig
  +10 (find; self-finds and repeats don't pay); premium unlock -cost (unlock)
  and the creator +cost (earn), both now ledgered.
- new ledger table; GET /api/me/ledger returns the statement with dig names.
- frontend: Statistik panel shows balance + a Kontoauszug list (signed,
  reason-labelled); the user is re-fetched after creating a dig so the reward
  shows immediately.

Verified against a running backend: create +20; logging found on your own dig
pays nothing; another user's first found +10, second found +0; ledger lists the
entries with dig names; unlock writes -cost for the buyer and +cost for the
creator. Build green; fresh DB exercises the ledger table.

Real nugget purchase (buy) still needs a payment provider — left as a follow-up.
docs/BEDIENUNGSANLEITUNG.md: a German, screenshot-driven guide covering start &
map, finding/filtering, opening & playing a dig, the dig types (incl. Build
steps and Event date), rating/logbook/chat, unlocking premium/pay with nuggets,
creating digs, account/profile, backpack & statistics, clan chat, QR/link/
clipboard opening & sharing, and earning Stagenuggets.

Screenshots captured from the running app (mobile viewport, real seed data) live
in docs/screenshots/ and are embedded throughout. Linked from the README.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants