Skip to content

Gate Stale OTA Clients Behind a Minimum Bundle Version - #494

Merged
Dharp02 merged 12 commits into
mainfrom
feat/ota-min-version-gate
Sep 1, 2026
Merged

Gate Stale OTA Clients Behind a Minimum Bundle Version#494
Dharp02 merged 12 commits into
mainfrom
feat/ota-min-version-gate

Conversation

@Dharp02

@Dharp02 Dharp02 commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

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 minVersion per 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 foreground
  • appReadyTimeout (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 back
  • Users are never told an update happened, and there is no mechanism to require one
  • A device on a slow connection can sit on a broken version indefinitely

Proposed Changes

1. Backend (meteor-backend/server/ota.js)

  • minVersion persisted in latest.json, returned by both /ota/check and /ota/latest
  • New POST /ota/min-version?channel=&version= (Bearer token) sets or clears the gate. Empty version clears it
  • /ota/publish accepts &minVersion= and carries the existing value over when omitted, so a routine release cannot silently un-gate clients an earlier bump was deliberately holding back
  • minVersion is rejected when it exceeds the latest published version — that would lock every client out with no bundle to climb to
  • Extracted writeLatest() for the atomic rename now shared by both writers

2. Frontend gate (src/lib/ota.ts)

The failure direction is the important design decision here:

Situation Behavior
Backend unreachable / timeout (8s) Fail open — let the user in
Confirmed running < minVersion Fail closed — block and download

Failing 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

npm run ota:min-version -- --channel testflight --version 1.0.3   # gate
npm run ota:min-version -- --channel testflight --clear           # release
node scripts/publish-ota.mjs --channel testflight --min-version 1.0.3

Acceptance Criteria

  • minVersion round-trips through publish and is returned by /ota/check and /ota/latest
  • Setting minVersion requires no rebuild and no republish
  • minVersion above the latest published version is rejected
  • Publishing without --min-version preserves the existing gate
  • Unreachable backend does not block the user
  • Web and dev builds never gate
  • Gate has no dismiss control; failed downloads offer retry
  • Overlay is role="alertdialog" with aria-live, labelled title/description, and a labelled progressbar
  • 6 unit tests in src/lib/ota.test.ts; full suite 103/103 passing
  • npm run lint, npm run typecheck, npm run format all clean

Deployment Note

This requires a Meteor backend deploy. Verified that timecore-dev currently serves the pre-minVersion manifest, so /ota/min-version will 404 until deployed. The frontend degrades safely in the meantime — no minVersion field 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.

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
@github-actions

github-actions Bot commented Aug 11, 2026

Copy link
Copy Markdown

🗑️ Preview Environment Cleaned Up

The 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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-version endpoint, and ensures /ota/publish preserves 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 minVersion and 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 if minVersion is unset). If the intended behavior is to block only when running < minVersion, call checkForcedUpdate() 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.

Comment thread src/ui/OtaUpdateGate.tsx Outdated
Comment thread src/ui/OtaUpdateGate.tsx Outdated
Comment thread capacitor.config.ts Outdated
Comment thread meteor-backend/server/ota.js
Comment thread src/ui/OrgTeamSwitcher.tsx
Comment thread src/ui/BottomNav.tsx
Comment thread src/lib/ota.ts Outdated
- 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 Dharp02 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All 7 Copilot review comments addressed in c408b37:

Fixed:

  • OtaUpdateGate used checkPendingUpdate instead of checkForcedUpdate — changed to checkForcedUpdate so the gate only blocks clients below minVersion, not on every launch with any newer bundle. This restores the intended kill-switch behavior.

  • z-100 not in Tailwind scale — changed to z-[100] (arbitrary value) so the overlay actually sits above other fixed UI.

  • AbortSignal.timeout() iOS 15 incompatibility — replaced with AbortController + setTimeout in both checkPendingUpdate and checkForcedUpdate. The 8-second timeout now works across all supported iOS WKWebView versions.

  • VITE_APP_VERSION missing || '1.0.0' fallback — added to BottomNav.tsx and OrgTeamSwitcher.tsx to match the pattern used elsewhere and prevent vundefined in non-Vite contexts.

  • writeLatest() tmp file collision — replaced process.pid with randomBytes(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>
Comment thread src/lib/ota.test.ts Outdated
Comment thread src/lib/ota.test.ts
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.
@Dharp02
Dharp02 requested review from horner and removed request for SarkarShubhdeep September 1, 2026 00:32
@Dharp02
Dharp02 merged commit f67081a into main Sep 1, 2026
8 checks passed
@github-project-automation github-project-automation Bot moved this from Ready to Done in Scrum Team Jerry Sep 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

3 participants