Gate Stale OTA Clients Behind a Minimum Bundle Version - #494
Conversation
Silent background OTA swaps mean a device running a bad bundle keeps running it until the user happens to background the app. This adds a kill switch: the backend publishes a minVersion per channel and clients below it are held at a blocking update screen until they download the fix. Backend: - minVersion persisted in latest.json, returned by /ota/check and /ota/latest - POST /ota/min-version sets or clears the gate with no rebuild or republish - /ota/publish accepts &minVersion= and carries the existing value over when omitted, so a routine release can't silently un-gate held-back clients - minVersion is rejected above the latest published version, which would otherwise lock every client out with no bundle to climb to - extract writeLatest() for the atomic rename shared by both writers Frontend: - checkForcedUpdate() fails open on an unreachable backend and fails closed only on a confirmed-stale answer; a device that can't reach the server can't download the fix either, so blocking it offline helps nobody - OtaUpdateGate renders children immediately and overlays once staleness is confirmed, rather than delaying every cold start behind a network round-trip on the slow connections this gate exists to serve - channel derives from the Vite build mode, so dev and web are inert
🗑️ Preview Environment Cleaned UpThe preview container for this PR has been deleted. |
…g switcher - autoUpdate: off — OtaUpdateGate handles all downloads, no silent swaps - checkPendingUpdate: gate fires on any newer bundle, not just minVersion - Remove update toast; the gate is the notification - VITE_APP_VERSION baked from package.json via vite.config.ts define - Version label in BottomNav More sheet (mobile) - Version label in OrgTeamSwitcher modal footer
There was a problem hiding this comment.
Pull request overview
Adds a minimum-bundle-version (“minVersion”) gate to the OTA update system so the backend can force stale clients onto a fixed bundle without requiring a native rebuild, plus client-side UI/tests/tooling to enforce and observe the gate.
Changes:
- Backend: persists/serves
minVersion, adds an authenticated/ota/min-versionendpoint, and ensures/ota/publishpreserves an existing gate unless explicitly overridden. - Frontend: introduces an OTA gate overlay + OTA version-check/download helpers, and wires the gate into app startup.
- Tooling/tests: adds scripts to publish bundles with
minVersionand to set/clear the gate, plus E2E + unit tests and UI version labels.
Reviewed changes
Copilot reviewed 13 out of 13 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| vite.config.ts | Injects VITE_APP_VERSION at build time from package.json for UI/version visibility. |
| tests/e2e/ota/ota.spec.ts | Adds Playwright coverage for OTA API shape/auth, version label visibility, and “no gate on web” regression guard. |
| src/ui/OtaUpdateGate.tsx | New blocking overlay that triggers OTA download + reload when an update is required. |
| src/ui/OrgTeamSwitcher.tsx | Displays the app version in the org/team switcher modal. |
| src/ui/BottomNav.tsx | Displays the app version in the “More” bottom-nav panel. |
| src/main.tsx | Wraps the app with OtaUpdateGate so gating can occur at startup. |
| src/lib/ota.ts | Adds OTA gate logic: version comparison, “forced update”/“pending update” checks, and download+activate helper. |
| src/lib/ota.test.ts | Adds unit tests for “fail open” behavior and download/activation flow (including progress listener cleanup). |
| scripts/publish-ota.mjs | Extends OTA publish script to optionally send minVersion and print it after publish. |
| scripts/ota-min-version.mjs | Adds a CLI script to set/clear minVersion on a channel via the backend endpoint. |
| package.json | Bumps app version and adds ota:min-version npm script. |
| meteor-backend/server/ota.js | Persists/returns minVersion, adds /ota/min-version, validates minVersion, and refactors atomic manifest write. |
| capacitor.config.ts | Disables plugin autoUpdate in favor of the frontend gate-driven update flow. |
Suppressed comments (1)
src/ui/OtaUpdateGate.tsx:29
- This gate calls
checkPendingUpdate(), which blocks whenever a newer bundle exists (even ifminVersionis unset). If the intended behavior is to block only when running < minVersion, callcheckForcedUpdate()instead.
void checkPendingUpdate().then((pending) => {
if (!cancelled && pending) setUpdate(pending);
});
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
- OtaUpdateGate: use checkForcedUpdate (minVersion kill-switch) instead of checkPendingUpdate (blocks on any newer bundle) — fixes the gate from force-updating on every launch to only blocking stale clients - OtaUpdateGate: fix z-100 to z-[100] (Tailwind arbitrary value) - ota.ts: replace AbortSignal.timeout() with AbortController + setTimeout for iOS 15 WKWebView compatibility in both checkPendingUpdate and checkForcedUpdate - BottomNav, OrgTeamSwitcher: add || '1.0.0' fallback for VITE_APP_VERSION to prevent vundefined in non-Vite / test contexts - meteor-backend ota.js: use randomBytes(8) for unique tmp filename in writeLatest() to prevent concurrent-write collisions on the same path
Dharp02
left a comment
There was a problem hiding this comment.
All 7 Copilot review comments addressed in c408b37:
Fixed:
-
OtaUpdateGateusedcheckPendingUpdateinstead ofcheckForcedUpdate— changed tocheckForcedUpdateso the gate only blocks clients belowminVersion, not on every launch with any newer bundle. This restores the intended kill-switch behavior. -
z-100not in Tailwind scale — changed toz-[100](arbitrary value) so the overlay actually sits above other fixed UI. -
AbortSignal.timeout()iOS 15 incompatibility — replaced withAbortController+setTimeoutin bothcheckPendingUpdateandcheckForcedUpdate. The 8-second timeout now works across all supported iOS WKWebView versions. -
VITE_APP_VERSIONmissing|| '1.0.0'fallback — added toBottomNav.tsxandOrgTeamSwitcher.tsxto match the pattern used elsewhere and preventvundefinedin non-Vite contexts. -
writeLatest()tmp file collision — replacedprocess.pidwithrandomBytes(8).toString('hex')for a per-call unique tmp path, preventing concurrent publish/min-version writes from colliding.
Not changed:
autoUpdate: 'off'— already conditional in the current code (liveReloadUrl ? 'off' : 'atBackground'); native builds keep background auto-update enabled. No change needed.
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Follow-up to the review on #494. - Remove `checkPendingUpdate`, dead since the gate moved to `checkForcedUpdate`. It was a near-verbatim clone differing only in which version it compared, and its docblock still claimed the gate used it. - Correct the OtaUpdateGate docblock, which still described the block-on-every-launch behaviour that was removed. - Build the unit-test bundle URL from METEOR_BASE_URL instead of pinning timecore-dev, so the fixture follows the base the module under test actually talks to. - Cover the gate itself. The channel is read from import.meta.env.MODE at module load, so vitest's "test" mode made every check return null and left the whole feature uncovered. Stubbing MODE and re-importing exercises the real paths: gates below minVersion, holds at the boundary, handles a "builtin" store install, and stays inert on web, off-channel and when the backend is unreachable. 6 tests -> 13. - Make two e2e assertions mean something. The minVersion-above-latest test sent a bad token, so it 401'd before reaching the validation it named; it now skips unless a publish token is present and asserts the 400 invalid_min_version. The version labels asserted a loose regex that the `|| '1.0.0'` fallback also satisfies, so they could not catch the Vite define regressing; they now assert the real version.
… the gate Addresses the remaining review findings on #494. Shared version logic (@timehuddle/ota-version) - versionTuple/isNewer existed twice — once in the client gate, once in the Meteor backend — and the semver regex four times. The backend decides who is too old and the client decides whether it is one of them, so a drift between those two copies either strands a device behind a gate it can never clear or lets one slip past. Now one module, consumed by the frontend, the backend and both CLI scripts. - Shipped as plain JS with hand-written .d.ts because Meteor does not compile TypeScript inside node_modules; meteor-backend takes it as a file: dependency since it is not part of the root workspace. Shared CLI plumbing (scripts/ota-cli.mjs) - arg(), fail() and loadEnvFile() were byte-identical between publish-ota and ota-min-version, alongside a duplicated DEFAULT_BACKENDS. Channel resolution, env loading and token lookup now live in one place. Gate hardening - applyForcedUpdate reuses a bundle the plugin has already downloaded. autoUpdate: 'atBackground' fetches the same bundle independently, so the gate could race the plugin's in-flight copy and strand the user on "Update failed" with a retry that keeps losing the same race. - Added a 'restarting' phase. set() reloads the WebView so it normally never returns; if the reload never arrives the user no longer sits at a full progress bar with nothing to press. - Raised the overlay above every other layer. z-[100] cleared @mieweb/ui modals (z-50) but not its tooltips and menus (z-9999) or the tickets popover (z-99999), any of which could paint over a gate that is supposed to be undismissable. Verified against the live dev backend: /ota/latest and /ota/check carry minVersion, up_to_date is correct at and above latest (including 1.0.10 > 1.0.9, which a string compare gets wrong), builtin falls back to the native version, and min-version set/clear/reject-above-latest round-trips through the CLI. 113 root unit tests + 11 in the new package; lint, typecheck, format and a testflight build all clean.
The bump to 1.0.7 was left over from manual OTA testing, not a release. Nothing was ever published above it — the testflight channel has no bundle at all and production is still serving 1.0.1 — so the bumped value only inflated the version label and the default bundle version that publish-ota derives from package.json. Back to 1.0.1, matching main and the published production bundle, and still ahead of the native 1.0 that iOS MARKETING_VERSION and Android versionName carry.
meteor-backend takes @timehuddle/ota-version as a file: dependency, but the builder installed its dependencies before `COPY . .`, so the package was not on disk yet. npm does not fail on this. It writes a dangling symlink and exits 0, so the break surfaces much later — at `meteor build`, or at runtime when server/ota.js imports the module. Verified both the dangling symlink and the fix by reproducing the builder's layer order outside Docker. Copies the package whole rather than just its manifest, since a file: dependency needs the real package present at install time.
Most of the manual check-list for this feature needed no device — the overlay's behaviour is all reachable in jsdom. Covers: no overlay when the bundle is current, overlay plus download when it is not, the version transition, progress reporting, the absence of any dismiss control, the alertdialog/progressbar semantics, retry after a failed download, and the restarting-then-retry fallback when set() does not reload. Writing them surfaced an unhandled rejection: the effect ran `void checkForcedUpdate().then(...)` with no catch, so the gate's fail-open behaviour silently depended on checkForcedUpdate never throwing. It does not today — every path is inside its try/catch — but the guarantee belonged at the call site. Added an explicit catch. Mutation-checked rather than assumed green: dropping the null guard, removing the restarting phase, and adding a dismiss button each fail. Still needs a device: the real download, the WebView reload, and the plugin's own background updater.
Merging to main triggers ota-publish.yml, which publishes the production channel using package.json's version. Production currently serves 1.0.1, so shipping at 1.0.1 would republish over itself and every device already on that bundle would evaluate isNewer(1.0.1, 1.0.1) as false and never download it — the release would reach nobody but fresh installs still on the native 1.0. 1.0.2 is ahead of both the published bundle and the native version that iOS MARKETING_VERSION and Android versionName carry.
Overview
OTA updates currently swap in silently on the next app background, which means a device running a bad bundle keeps running it until the user happens to background the app. There is no way to force a fix out. This adds a kill switch: the backend publishes a
minVersionper channel, and clients below it are held at a blocking update screen until they download the fix — no rebuild, no republish, no App Store review.Current State
autoUpdate: 'atBackground'downloads in the background and swaps on the next foregroundappReadyTimeout(10s) rolls back automatically when a bundle fails to boot, but only catches hard JS crashes — a bundle that boots fine but is functionally broken is never rolled backProposed Changes
1. Backend (
meteor-backend/server/ota.js)minVersionpersisted inlatest.json, returned by both/ota/checkand/ota/latestPOST /ota/min-version?channel=&version=(Bearer token) sets or clears the gate. Empty version clears it/ota/publishaccepts&minVersion=and carries the existing value over when omitted, so a routine release cannot silently un-gate clients an earlier bump was deliberately holding backminVersionis rejected when it exceeds the latest published version — that would lock every client out with no bundle to climb towriteLatest()for the atomic rename now shared by both writers2. Frontend gate (
src/lib/ota.ts)The failure direction is the important design decision here:
minVersionFailing open on an unreachable backend is deliberate. A device that cannot reach the server cannot download the fix either, so blocking it would brick the app offline while helping nobody.
Channel derives from
import.meta.env.MODE, so dev and web builds are inert by construction rather than by an explicit guard.3. Blocking UI (
src/ui/OtaUpdateGate.tsx)Children render immediately and the overlay mounts on top once staleness is confirmed. Blocking every cold start behind a network round-trip would add startup delay on exactly the slow connections this gate exists to serve. Shows download progress,
v1.0.1 → v1.0.5, and a retry on failure. No dismiss control — that is the point of the gate.4. Tooling
Acceptance Criteria
minVersionround-trips through publish and is returned by/ota/checkand/ota/latestminVersionrequires no rebuild and no republishminVersionabove the latest published version is rejected--min-versionpreserves the existing gaterole="alertdialog"witharia-live, labelled title/description, and a labelled progressbarsrc/lib/ota.test.ts; full suite 103/103 passingnpm run lint,npm run typecheck,npm run formatall cleanDeployment Note
This requires a Meteor backend deploy. Verified that
timecore-devcurrently serves the pre-minVersionmanifest, so/ota/min-versionwill 404 until deployed. The frontend degrades safely in the meantime — nominVersionfield in the response means no gate — so the two sides can ship independently and in either order.Flow
Normal flow (no minVersion needed):
You publish OTA → bundle is on the server
User opens app → background updater downloads it silently
User backgrounds the app → new bundle activates
Next launch → they're on the new version
Done. No admin action needed. This is what happens 99% of the time.
minVersion is for emergencies only, e.g.:
You shipped a bug that corrupts data
A security hole needs patching right now
You can't wait for users to naturally background/relaunch
In that case you set minVersion once → blocks everyone below that version until they update → then you can clear it.