Endurain API keys are upload-only by design: the scope allow-list is
frozenset({"activities:upload"}), enforced in both validate_api_key_scopes
and the create schema, and the activities read route is mounted with
validate_access_token rather than validate_access_token_or_api_key. A key
therefore fails at authentication before any scope check, so the HTTP
dedupe check added in 325d5a8b could never succeed and every pass fell
back to the ledger alone. Verified against v0.19.0; upstream documents no
plan to widen the allow-list.
Read the start times out of postgres instead. The container publishes no
host port, so this goes through docker exec -- no new dependency and no
port opened just to read four rows. The HTTP path is still tried first so
the supported route resumes working on its own if the allow-list ever
grows.
None and [] are kept strictly apart: a dead container, timeout or non-zero
exit means "could not ask" and returns to the ledger, while an empty result
set is a real answer. Conflating them would re-upload everything on any
docker hiccup, which is the bug the dedupe check exists to prevent.
Proven with an empty ledger: the known-duplicate TCX was skipped and the
activity count held at 4, and the same file WAS imported with
ENDURAIN_NO_DB=1, so the check is what blocks it. The duplicate that
negative control created was removed afterwards.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GbYEnfGnf1988cdFdGebot
RunnerUp can now upload to Endurain directly as well as through the WebDAV
inbox, and the two paths cannot see each other: the same run landed twice
(activities 14 and 15, identical start time, distances differing only by
tcx/gpx rounding). The ledger only knows what this importer handled, so it
could not catch it.
Add a third dedupe check that asks Endurain what it already holds, queried
once per run. Endurain exposes no query-by-start-time endpoint, so a page of
recent activities is fetched and compared client-side, with a +/-120s
tolerance: the file for activity 15 embeds 21:51:04Z while the server stored
21:51:05Z, so exact equality misses the very duplicate that motivated this.
A failed or unauthorised query returns None, never an empty list, so it can
never be read as "nothing matches" and upload on the strength of it.
Export filenames are parsed as LOCAL wall-clock time. RunnerUp_2026-08-14-
00-58-45_Running is 2026-08-13 22:58:45 UTC; reading it as UTC would move a
late-evening run onto the wrong day and defeat the comparison.
Also fix the adb fallback, which broke the moment a second device attached:
phone_attached() returned true if ANY device was in the 'device' state, then
every bare adb call failed with "more than one device/emulator" as a warning
on a path nobody watches. The serial is now resolved once and threaded into
every call as -s. Ambiguity is settled by configuration, never by guessing --
several devices and no ENDURAIN_ADB_SERIAL means the fallback is skipped.
The remote check stays inert until the API key is granted activities:read;
until then it falls back to the ledger, which is the previous behaviour.
85 tests, 100% branch coverage on all four modules, no suppressions. The
oversized test modules are split by concern to stay under the 250-line cap.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GbYEnfGnf1988cdFdGebot