testsAndMisc/docs/superpowers/evidence/endurain-runnerup-webdav-2026-08-23.json
Krzysztof kuhy Rudnicki 558883f281 feat(endurain): self-hosted run viewer fed by RunnerUp WebDAV exports
Stands up Endurain behind the existing Caddy edge and imports RunnerUp
activities into it. Endurain is a VIEWER: screen-locker's workout gate still
reads the phone over adb and is untouched here, because a WebDAV drop folder is
spoofable by anything that can write to ~/cloud and must not become a
screen-unlock authority.

Primary path is phone -> WebDAV (existing dufs) -> ~/cloud/RunnerUp/ ->
importer -> Endurain, with an adb pull as fallback when a run has not been
uploaded yet. A systemd user timer runs the importer every 15 min.

Findings that shaped the implementation, each verified against the running
system rather than assumed:

- Endurain moved to Codeberg; the GitHub repo is a read-only mirror stale at
  v0.17.7 against v0.19.0. The image is pulled from Codeberg.
- postgres:18 moved PGDATA to /var/lib/postgresql/18/docker. Upstream's compose
  example still mounts the pre-18 path, which fails as an unexplained
  "container is unhealthy"; the real cause is only visible in docker logs.
- Endurain seeds a hardcoded admin/admin via an Alembic migration with no env
  override, and endurain.kuhy.duckdns.org already resolved publicly. Publishing
  is therefore a separate script that refuses to install the Caddy site while
  those credentials still work.
- Endurain performs NO duplicate detection, so the importer owns dedupe on two
  axes: a content hash, and an activity key that collapses the .gpx/.tcx pair
  RunnerUp writes for a single run (different bytes, same run).
- Ambiguous uploads (5xx/timeout) are never re-queued, since the activity may
  already have been committed; only definite 4xx rejections are staged for
  retry. The importer exits non-zero so the timer surfaces the problem.
- RunnerUp has no background sync at all, so every upload is a manual tap.
- Its WebDAV synchronizer needs HTTPS with a real cert (Android blocks
  cleartext at targetSdk 36; self-signed is broken upstream) and a URL with no
  trailing slash, and cannot MKCOL against dufs - so the inbox must pre-exist.

The phone gets a dufs credential scoped to /RunnerUp, verified unable to read
Keepass or gdrive_backups.

Verified: 52 tests at 100% branch coverage; the timer imports 4 real runs and
re-runs create zero duplicates; https://endurain.kuhy.duckdns.org serves 200
with a valid cert and the other edge sites are unaffected; screen-locker's
suite still passes 1090 tests at 100% coverage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GbYEnfGnf1988cdFdGebot
2026-08-23 13:09:18 +02:00

65 lines
4.6 KiB
JSON

{
"intent": "Give RunnerUp runs a self-hosted dashboard (Endurain) fed over WebDAV from the phone, without touching the adb-based workout gate that unlocks the PC.",
"scope": [
"endurain/ - compose, setup and publish scripts, systemd units, README",
"python_pkg/endurain_import/ - importer package and its tests",
"~/.config/dufs/dufs.yaml - one added scoped auth entry (not in this repo)",
"~/gitea/sites/endurain.caddy - new edge site (not in this repo)",
"NON-GOAL: screen-locker remains byte-for-byte unmodified"
],
"changes": [
"docker-compose.yml pinned to codeberg.org (the GitHub repo is a stale read-only mirror at v0.17.7 vs v0.19.0), bound to 127.0.0.1:8085 so the LAN cannot reach it unfronted, redis renamed endurain-redis to avoid a container name collision",
"postgres volume mounted at /var/lib/postgresql/18/docker instead of upstream's pre-18 /var/lib/postgresql/data; upstream's example fails as an unexplained 'container is unhealthy' because mounting over that path leaves it root-owned",
"setup_endurain.sh generates secrets, chowns each bind mount to the uid its own image runs as (1000 app, 999 postgres, 999 redis), and gates on the seeded admin/admin login",
"publish_endurain.sh installs the Caddy site only after that gate passes, and validates the config before reloading so a bad snippet cannot take down gitea/cloud/website",
"importer dedupes on TWO axes: a SHA-256 content hash, and an activity key that collapses the .gpx/.tcx pair RunnerUp writes for one run (different bytes, same run) and strips the device model RunnerUp's WebDAV names embed",
"ambiguous upload outcomes (5xx/timeout) are never re-queued to bulk_import, because Endurain has no duplicate detection and the activity may already be committed; only definite 4xx rejections are staged",
"second dufs auth entry scoped to /RunnerUp so the phone credential cannot read Keepass or gdrive_backups; setup_dufs_cloud.sh patched to preserve it across re-runs"
],
"verification": [
{
"command": "pre-commit run --files <staged>",
"result": "pass",
"evidence": "ruff, ruff format, mypy, pylint, bandit, pytest-with-coverage, shellcheck, leaked-secrets and python_pkg-location hooks all Passed"
},
{
"command": "pytest python_pkg/endurain_import/tests/",
"result": "pass",
"evidence": "52 passed; 100% branch coverage on all 5 modules"
},
{
"command": "systemctl --user start endurain-import.service (real deploy path)",
"result": "pass",
"evidence": "first run: 4 imported, 1 already known (the .gpx twin correctly skipped); re-run: 0 imported, 5 already known; DB count stayed 4 with no duplicate start_times"
},
{
"command": "curl https://endurain.kuhy.duckdns.org/",
"result": "pass",
"evidence": "HTTP 200, ssl_verify_result=0, issuer Let's Encrypt; gitea/cloud/dice on the same edge still 200/401/200"
},
{
"command": "curl -u runnerup:*** -X PROPFIND https://kuhy-cloud.duckdns.org/{RunnerUp,Keepass}",
"result": "pass",
"evidence": "207 on /RunnerUp, PUT 201, 403 on /Keepass and on a direct Keepass file GET; root PROPFIND lists only / and /RunnerUp"
},
{
"command": "pytest screen_locker/tests (regression guard)",
"result": "pass",
"evidence": "1090 passed, 100% coverage; _runnerup_*.py unmodified per git status; all 5 phone files intact after the adb pulls"
}
],
"risks": [
"RunnerUp has NO background sync (no WorkManager/JobScheduler/AlarmManager anywhere in its source), so every upload needs a manual tap after a run. Not fixable without patching the app.",
"The ledger JSON is the only thing preventing duplicates; deleting it and re-importing WILL duplicate every activity, since Endurain does not deduplicate.",
"Endurain's OpenAPI schema mislabels the API-key header as X-Client-Type; the working header is X-API-Key, established empirically. A future version could change this.",
"The service is now internet-reachable. Its admin password is the only thing protecting it, and Endurain seeds admin/admin with no env override on a fresh database."
],
"rollback": [
"systemctl --user disable --now endurain-import.timer",
"rm ~/gitea/sites/endurain.caddy && docker exec gitea-caddy caddy reload --config /etc/caddy/Caddyfile # unpublishes immediately",
"docker compose -f endurain/docker-compose.yml down; sudo rm -rf /var/opt/endurain # destroys all imported activities",
"Remove the runnerup line from ~/.config/dufs/dufs.yaml and restart dufs",
"After rollback, confirm screen-locker still verifies a workout: pytest screen_locker/tests -k runnerup"
]
}