Stage Digger MVP: offline-first PWA + thin FastAPI backend (Hybrid unlock, creator, auth) - #1
Open
hstre wants to merge 19 commits into
Open
Stage Digger MVP: offline-first PWA + thin FastAPI backend (Hybrid unlock, creator, auth)#1hstre wants to merge 19 commits into
hstre wants to merge 19 commits into
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Ü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:
Offline schaltet das Gerät lokal frei; mit Netz bestätigt
/api/verifyzusä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
GET/PUT/DELETE /api/digs*, Paket-Re-Render.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:target/Klartext-Antwort/Zielkoordinate im Paket).Ehrliche offene Punkte
verify_tokenim Dev direkt zurück (kein Mailer) — Production braucht SMTP/Mail-Dienst, der den Token als Link verschickt.X-Forwarded-For).AUTH_SECRETin Production zwingend als Env-Var setzen.frontend/public/v1/u0yjj.jsonändert sich bei jedem Seed (zufällige Krypto-Salts).🤖 https://claude.ai/code/session_01CfgrUbTodCUH52bbJ8vCS7
Generated by Claude Code