screen-locker/docs/DOCS-workout-poke-contract.md
Krzysztof kuhy Rudnicki b37b7559d8
Some checks failed
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (flutter, flutter-analyze, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,flutter-analyze) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (0) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (1) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (2) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (3) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (4) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (5) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (6) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (7) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (8) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (9) (push) Waiting to run
Tests / flutter-coverage (push) Blocked by required conditions
Tests / web-coverage (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
release-apk / release (APK) (push) Has been cancelled
workout poke moves to tcp/8774: 8773 is book-guard's desktop wrapper, whose window the poke server had been answering since 2026-10-09 (phone app rebuilt, selftest default, contract doc)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KrBEzjPRdx5oyp8NtTLW5F
2026-10-10 09:38:21 +02:00

3.4 KiB

Workout poke: phone -> PC wire contract (v1)

The phone sends a finished StrongLifts session straight to the PC over the home LAN, so the credit (shutdown time, gaming budget, notification) lands in under a second instead of waiting for the GitHub/Firebase round trip. The normal sync push still runs in parallel; the PC dedups by record id, so a session that arrives both ways is credited once.

Transport

  • POST http://<pc-host>:8774/v1/workout, Content-Type: application/json. (8773 until 2026-10-10: that is book-guard's desktop wrapper's port.)
  • <pc-host> is a phone setting, default 192.168.1.43 (static on the PC). LAN only: the PC firewall accepts tcp/8774 from 192.168.1.0/24 and nothing else. No WireGuard. Off the home LAN the poke fails fast and the regular sync is the only path.
  • Header X-Poke-Signature: <hex HMAC-SHA256(key, raw body bytes)>. The PC verifies the MAC over the bytes as received and only then parses them. Neither side may re-serialize JSON to compute or check the MAC.
  • Key: 32 random bytes, hex, PC copy at ~/.config/workout_poke/key (mode 600); phone copy in the Android keystore, put there by scripts/pair_phone.sh over adb.

Request body

{"v":1,"sent_at_ms":<int epoch ms>,"nonce":"<uuid4>","sandbox":<bool>,
 "record_id":"<sync record id>","payload":{<session exactly as synced>}}
  • payload is the same map the phone puts in its sync log for the session; the PC feeds (record_id, payload) to ingest_session_records, which re-validates it (never trusts succeeded).
  • sandbox is inside the signed body. A sandbox request is verified, timed and answered, but never written anywhere and never credited.

PC checks, in order (each rejection is logged at warning)

  1. Signature matches -> else 401.
  2. |now - sent_at_ms| <= 120 s -> else 401 (replay window).
  3. nonce not seen within the window -> else 409.
  4. v == 1 and fields present -> else 400.

Response (always JSON)

{"ok":<bool>,"credited":<bool>,"duplicate":<bool>,"sandbox":<bool>,
 "shutdown":"HH:MM"|null,"gaming_budget_minutes":<int|null>,"pc_ms":<int>,
 "reason":"<sentence a human can act on>"}

The phone must treat shutdown and gaming_budget_minutes as nullable.

Outcomes of a verified (200) request:

case ok credited duplicate
newly credited true true false
already in log.json (e.g. the sync got there first) true false true
logged, but today's credit slot was already paid by another copy of the same workout true false false
signed but does not count (too short, no date): reason says why false false false
sandbox true false false

Refusals are never 200. Besides the four checks above, the PC may answer 404 (wrong path), 405 (not POST), 411 (no Content-Length), 413 (body over 64 KiB) and 500 (internal error; the regular sync still credits). All of them carry the same JSON shape with ok:false and shutdown and gaming_budget_minutes both null. The PC answers a 413 without reading the body, so the phone may see a connection reset instead of the reply.

Shared fixture

contracts/workout_poke_v1.json holds a literal key, body and expected signature (with a float and non-ASCII text, the usual cross-language traps). The Python receiver and the Dart sender both test against it, so a drift on either side fails that side's tests.