Architecture Decision Record

ADR — OTA Updates for SHOT Capacitor App

Date2026-05-11 Authors@stevie DecidersSHOT Engineering RFC#2561 Epic#2562 Accepted
01

Context

SHOT ships as a Capacitor 6.1.2 native shell wrapping a Vite/React web bundle. Every UI tweak, copy fix, or non-native bug fix today goes through a full App Store / Play Store review cycle — 24-48h on Apple, 1-3h on Google. We want to ship JS/HTML/CSS-only changes to live users without that round-trip, while keeping native changes (new Capacitor plugins, entitlement/permission changes, paywall logic) on the store-review track.

Three pains drove the question: (1) hot-fix dead-time when a Pulse layout bug or copy mistake ships, (2) no granular rollback below a full native re-release, and (3) reviewer-cycle anxiety on minor UI changes that are visually obvious to test but tediously slow to ship.

Forces in tension
  • Ship UI/copy/layout fixes without 24-48h Apple review
  • Reuse existing R2 + Supabase edge-fn + transcrypt infra muscle memory
  • BYO signing keys — SHOT-owned, EU-resident, no third-party trust
  • Apple DPLA §3.3.1(B) — must not change primary purpose or features post-review
  • Stripe / RevenueCat / paywall flows cannot OTA (§3.1.1)
  • COPPA + DSA Art.14 visibility on minor consent + UGC moderation
  • No per-seat vendor pricing (Stevie's Vercel allergy)
02

Options considered

Three delivery models, all built around the same MPL-2.0 plugin (@capgo/capacitor-updater@lts-v6). The vendor landscape collapsed in 2025 — Microsoft App Center / CodePush shut down March, Ionic Appflow closed to new signups in February.

Option A

Capgo Cloud (SaaS)

app capgo.app (per-MAU SaaS)

Pay-as-you-go SaaS. RSA+AES E2E encryption, channels, rollout, dashboard. Solo bootstrapped (Madeira).

  • 1-day setup
  • Built-in dashboard
  • BYO RSA key
  • $33–83/mo
  • No EU residency
  • Solo founder risk
  • Vendor data flow
Effortlow
Fitok
Riskmed
Option BCHOSEN

Self-host on R2 + Supabase

app edge fn (supabase) R2 (EU)

Same MPL-2.0 plugin, but pointed at SHOT-owned R2 + edge function. Ed25519-signed manifests, BYO keys.

  • ~$0.05/yr
  • EU R2 (WEUR)
  • BYO Ed25519
  • SHOT-owned data flow
  • Reuses existing infra
  • 8-10 day build
  • Two storage shapes to operate
Effortmed
Fitstrong
Risklow
Option CEOL

Ionic Live Updates / Appflow

app ionic.io EOL 2027-12-31

Historical incumbent. New signups closed Feb 2025. Full shutdown 2027-12-31. Not a real option for new builds.

  • Tightest Capacitor fit (was)
  • Closed to new customers
  • Per-seat pricing ($500+/mo)
  • 2-year shutdown clock
  • Plugin unmaintained
Effortn/a
Fitn/a
Riskfatal
03

Decision

B
We will adopt
Self-host OTA on Cloudflare R2 + Supabase edge function
Option B reuses every piece of infrastructure SHOT already runs (R2 announcements buckets, Supabase edge-fn pattern, transcrypt-encrypted env, GitHub Actions workflow_dispatch), gives us EU-resident bundle delivery (R2 WEUR), and keeps signing keys under SHOT control. Capgo Cloud (Option A) is a perfectly good fallback if dev time becomes the binding constraint — migration cost is low (plain zip + open plugin) and the architecture preserves the option to flip back with a single config field. Appflow (Option C) is eliminated by EOL — not a decision so much as a fact.
04

Resulting architecture

Publish path (GitHub Actions → R2 + Postgres) sits on top. Runtime check path (App → edge fn → R2 presigned URL) sits at the bottom. Bundle apply happens on next cold-start after background download.

PUBLISH PATH GitHub Actions deploy-ota.yml workflow_dispatch aws s3 cp (signed) INSERT ota_bundles + flip channel Cloudflare R2 (WEUR) bundles/{sha256}.zip · immutable manifests/{sha256}.json · Ed25519 signed channels/{env}/latest.json · pointer channels/{env}/rollback.json · override Supabase Postgres ota_bundles (hash, bad, signed_at) ota_channels (env, current_hash) ota_native_builds (ver, capabilities) RUNTIME PATH Capacitor App @capgo/capacitor-updater + OtaService.ts on appStateChange: 1. POST check 2. verify Ed25519 3. download + apply cold-start channel-check {channel, native, hash} Supabase Edge Fn ota-channel-check · read rollback / latest · verify manifest sig · minNative + capabilities gate · presign URL (15 min) read manifest + channel bad-flag / capability manifest + presigned URL GET bundle (presigned) CI gates (publish-time) no-touch file allow-list no new @capacitor/* plugin no eval / new Function env-pin hash match DSA report-UI invariant enforced, not trusted
Solid arrows = synchronous · dashed = async/read · clay = client/bundle path · olive = response/Postgres · rust = compliance gates
05

Consequences

Positive

  • UI/copy fixes ship in minutes, not 24-48h
  • ~$0.05/year R2 cost — no per-MAU pricing, no per-seat fees
  • EU-resident bundle delivery (R2 WEUR) for GDPR / DSA posture
  • Signing keys under SHOT control — no third-party trust surface
  • Reuses existing R2 + Supabase + transcrypt + workflow_dispatch patterns
  • Server-flip rollback recovers fleet within hours on bad bundle
  • Capabilities-manifest catches plugin-version skew before crash

Negative

  • ~8-10 days to production-grade vs ~1 day for Capgo Cloud
  • Two storage shapes operationally (R2 + Postgres ota_*)
  • Ed25519 public-key rotation requires a new native release
  • 13 must-not-OTA surfaces become discipline AND CI-enforced
  • Native/JS version skew risk if capabilities-manifest drifts

Neutral

  • Web (Vercel) bundle continues to roll separately — only native shell uses OTA
  • App Store / Play release pipeline unchanged — native still ships via build-release.sh
  • Capgo Cloud remains a 1-config-field fallback if self-host becomes a burden
  • Bundle versioning is decoupled from native CFBundleShortVersionString
06

Hard constraints — must-not-OTA list

These 13 surfaces are forbidden for hot-shipping and must go through a native App Store / Play release. The "no-touch file allow-list" CI gate in deploy-ota.yml enforces every row below; override requires --force-native-only-surface flag plus written reason.

Surface Path Rule Severity
Feature-gate registry src/foundation/premium/featureRegistry.ts Apple §3.1.1, §2.5.2 Critical
RevenueCat keys / product IDs src/services/RevenueCatService.ts Apple §3.1.1 Critical
Stripe checkout invocation src/services/MembershipService.ts, useMembership.ts Apple §3.1.3 Critical
Paywall surface area src/pages/Paywall/index.tsx Apple §3.1.1 High
COPPA / age-gate / consent routing src/foundation/auth/guards/MinorAccountGuard.tsx COPPA + DSA Art.14 + §5.1.1 Critical
Parent handoff flows src/services/RoleSwitchService.ts, ParentRelationshipService.ts COPPA High
ATT prompt timing / copy src/utils/trackingPermission.ts, App.tsx:373 Apple Privacy Manifest High
Push entitlement scope src/foundation/notifications/services/CapacitorPushService.ts Native-bound High
Sentry Replay iOS Safari UA-gate src/utils/sentry.ts:219-247 Prior render-storm crash High
New @capacitor/* plugin call sites e.g. src/utils/downloadSportHead.ts (@capacitor/share) Capabilities-manifest gate Critical
Pulse UGC moderation / report UI src/features/announcements/pages/ModerationDashboard.tsx DSA Art.17 Critical
Feature-flag flips (Alex, PulseV2) VITE_ENABLE_ALEX, VITE_ENABLE_PULSE_V2 Apple §3.3.2 High
Env binding (Supabase URL, RC keys) import.meta.env.VITE_* Data-integrity / env-pin gate Critical
07

Lineage

today
Native-release-only · every JS change goes through store review
this ADR
Self-hosted OTA on R2 + Supabase · native still ships natively
08

References