Rewrites the internal/api comment blocks to rule 32: task, milestone and
rule ids gone, the stories behind them restated as the constraint they
left, stale references corrected (the stream secret is wired from config,
the timezone handler already refreshes the scheduler, the Android app is
the native client, the design docs cited are not in the repo). Also
rewraps comment lines over 80 columns left by the earlier batches.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The client compares names while Android installs by versionCode, and the
wire had no way to close that gap: the sidecar was one positional line and
the endpoint returned a name only. This is the plumbing that makes the
ordering key decidable by the client at all.
The sidecar is now JSON rather than a grown positional string. That shape
was chosen against a specific failure: the obvious growth path was
"<name> <code>", which a first-space split silently mangles the moment a
third field appears — the code stops parsing as an integer and the reader
falls back to name comparison WITHOUT erroring. JSON cannot mistake a new
field for an old one.
code is a POINTER on both sides, and omitempty on the wire. Absent has to
stay distinguishable from zero: a build published before ordering keys were
recorded genuinely has no code, and zero would claim it is infinitely old
rather than unknown.
A malformed sidecar now fails loudly instead of serving a blank version.
If an unreadable file produced an empty name, every client would compare
against nothing, conclude it was current, and go quiet — "I cannot read
this" and "there is nothing newer" would return the same answer, which is
the failure mode nobody reports because nobody is offered anything to
report.
The non-tag :latest path no longer RECONSTRUCTS the bundled APK's version.
android-release now publishes the sidecar as a release asset beside the
APK, and the image build downloads it. The old reconstruction duplicated a
derivation formula across two files, and could only ever recover the name —
the ordering key is build-time minutes and exists nowhere once that build
ends. Releases predating the sidecar report their name with a null code,
which is the honest answer rather than a guessed one.
image-release also drops to a shallow checkout: it needed full history and
tags only to re-derive versions from the tagged commit, and now touches git
for nothing. MINSTREL_VERSION comes from GITHUB_REF.
Two things checked rather than assumed. The Android Json sets
ignoreUnknownKeys, so the added fields cannot break already-installed apps.
It also sets coerceInputValues, which will silently turn a null code into 0
if step 4 declares the field non-nullable — recorded on task #3811, because
reading the field declaration alone would never reveal it.
Also fixes a stale comment block describing "the Flutter client", deleted
in v2026.08.18.
Step 3 of 5 — Scribe task #3810, milestone #390.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
Closes the bandwidth-abuse vector on the in-app update flow. Both
endpoints now sit inside the authed.Group; APK additionally gets a
60s/user rate limit to suppress accidental hammering or scripted
abuse.
### Server
- internal/api/client_assets.go:
- clientAPKAllowDownload(): in-memory map[userID]time.Time under
a mutex. Returns 0 (allow) or wait duration (block).
- handleClientAPK reads user from context, checks the limit,
returns 429 + Retry-After header if blocked.
- testResetClientAPKRateLimit() lets tests start clean.
- internal/api/api.go: routes moved from the root /api group into
the authed.Group block (alongside /quarantine, /requests, etc.).
- Tests: added TestClientAPK_401WhenUnauthenticated and
TestClientAPK_RateLimit_429OnRapidSecondCall (also verifies
different user gets a fresh slot). Existing tests updated to use
authedRequest() helper.
### Web
- MobileAppDownload.svelte: switched from bare fetch (no credentials)
to api.get<>() which carries the session cookie. 404 / 401 /
network errors all silently hide the download row.
- Removed the login-page mount entirely — pre-auth surfaces should
never show this. Settings → Mobile app section keeps it for
logged-in users.
Flutter unaffected: dio's Bearer interceptor already attaches the
token, and the polling only fires once the post-login shell mounts
the banner widget.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- flutter analyze: Riverpod 3 dropped StateProvider; replaced
_dismissedVersionsProvider with a small Notifier<Set<String>>
+ NotifierProvider, mutating via an `add(version)` method.
Public API (shouldShowUpdateBannerProvider, dismissUpdateProvider)
unchanged.
- golangci errcheck: defer f.Close() now wrapped in func() { _ = f.Close() }();
os.Setenv calls in test helper switched to t.Setenv (cleaner — auto-restores
on test cleanup, no manual Unsetenv needed).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 1 of the in-app update flow — server side. Endpoints serve the
bundled Android APK + sidecar version file from /app/client/.
Returns 404 gracefully when files aren't present, so dev environments
and pre-CI-wiring images degrade cleanly to "no update available."
- internal/api/client_assets.go: handleClientVersion + handleClientAPK.
Both unauthenticated (matches /healthz) so install flow doesn't
depend on a live session. APK served with proper
application/vnd.android.package-archive Content-Type +
http.ServeContent so Range requests work for resumable downloads
on flaky networks.
- Path resolves to /app/client/ by default; MINSTREL_CLIENT_APK_DIR
env var overrides for dev.
- Dockerfile creates /app/client/ + commented COPY hooks for the CI
sequencing phase.
- Tests cover all four states: missing apk, apk-but-no-version,
both present (200 with correct shape), apk stream (200 with
correct Content-Type + body bytes).
Phases 2 (Flutter client provider + banner + install intent + Android
manifest changes) and 3 (CI sequencing to bake the APK into the image)
land in follow-up commits.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>