Skip to content

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

Open
Dharp02 wants to merge 6 commits into
mainfrom
feat/ota-min-version-gate
Open

Gate Stale OTA Clients Behind a Minimum Bundle Version#494
Dharp02 wants to merge 6 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 Deployment Ready

Service URL
App https://mieweb-timehuddle-feat-ota-min-version-gate.os.mieweb.org
API (Meteor) https://mieweb-timehuddle-feat-ota-min-version-gate-api.os.mieweb.org

Preview auto-deletes when this PR is closed.

…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

const LATEST = {
version: '1.0.5',
url: 'https://timecore-dev.os.mieweb.org/ota/bundles/testflight/1.0.5.zip',

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Hard coded URL. Should be from a SERVER config or capacitor config.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

why not METEOR_BASE_URL

Comment thread src/lib/ota.test.ts

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

wrong dir for a test maybe?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

Status: Ready

Development

Successfully merging this pull request may close these issues.

3 participants