Skip to content

Commit 22e60cd

Browse files
aapelivclaude
andcommitted
Drive OTA manifest bundle from a feature flag and rename endpoint to /native
Move the hardcoded per-platform bundle config into the native_ota_bundles feature flag (JSON) so it can vary per environment and be set in tests. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 6d1a4b3 commit 22e60cd

4 files changed

Lines changed: 31 additions & 43 deletions

File tree

app/backend/src/couchers/servicers/bugs.py

Lines changed: 6 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -22,38 +22,12 @@
2222

2323
_start_time = time.monotonic()
2424

25-
# --- Hardcoded OTA manifest scaffolding (cut 1: validate protocol + transport) ---
26-
# These point at a real staging bundle staged with ota-stage.mjs and uploaded to
27-
# the CDN. They are filled in after that export; until then the client accepts the
28-
# manifest framing but the bundle download 404s (which still proves the endpoint).
29-
# Per-user selection, signing, and the release registry come later.
30-
_OTA_BASE_URL = "https://couchers-dev-assets.s3.amazonaws.com/ota/prod-test"
31-
32-
# Per-platform hardcoded update. `id` must differ from the build's embedded update
33-
# id or the client no-ops; `runtimeVersion` is echoed from the request so the
34-
# fingerprint always matches during validation.
35-
_OTA_BUNDLES: dict[str, dict[str, Any]] = {
36-
"ios": {
37-
"id": "00000000-0000-0000-0000-000000000000",
38-
"launch_asset": {"key": "PLACEHOLDER", "url": f"{_OTA_BASE_URL}/ios/bundle.hbc"},
39-
"assets": [],
40-
},
41-
"android": {
42-
"id": "00000000-0000-0000-0000-000000000000",
43-
"launch_asset": {"key": "PLACEHOLDER", "url": f"{_OTA_BASE_URL}/android/bundle.hbc"},
44-
"assets": [],
45-
},
46-
}
47-
48-
# Boundary baked into both the body and the content-type, matching ota-stage.mjs.
4925
_OTA_BOUNDARY = "COUCHERS_OTA_BOUNDARY"
5026

5127

5228
def _ota_multipart_body(field_name: str, content: dict[str, Any]) -> bytes:
53-
# Protocol-v1 multipart/mixed: the `manifest` (or `directive`) part + an
54-
# `extensions` part, with the exact CRLF framing the dev client verified
55-
# on-device (ota-serve.mjs). field_name is "manifest" for an update or
56-
# "directive" for a noUpdateAvailable/rollBackToEmbedded directive.
29+
# Expo Updates protocol v1 multipart/mixed framing. field_name is "manifest" for
30+
# an update or "directive" for a noUpdateAvailable/rollBackToEmbedded directive.
5731
def part(name: str, body: str, content_type: str) -> str:
5832
return (
5933
f"--{_OTA_BOUNDARY}\r\n"
@@ -149,17 +123,15 @@ def header(name: str) -> str:
149123
value = context.headers.get(name, "")
150124
return value.decode() if isinstance(value, bytes) else value
151125

152-
# The Expo Updates client requires these response headers or it rejects the
153-
# manifest before fetching. Envoy forwards initial metadata as HTTP response
154-
# headers (same path as set-cookie).
126+
# Expo rejects the manifest without these; Envoy forwards them as HTTP response headers.
155127
context.set_response_headers([("expo-protocol-version", "1"), ("expo-sfv-version", "0")])
156128

157129
platform = header("expo-platform") or "ios"
158130
runtime_version = header("expo-runtime-version")
159131

160-
bundle = _OTA_BUNDLES.get(platform)
132+
bundles: dict[str, Any] = context.get_object_value("native_ota_bundles", {})
133+
bundle = bundles.get(platform)
161134
if bundle is None or runtime_version == "":
162-
# Unknown platform or no fingerprint: tell the client to keep its bundle.
163135
directive = {"type": "noUpdateAvailable"}
164136
return httpbody_pb2.HttpBody(
165137
content_type=f"multipart/mixed; boundary={_OTA_BOUNDARY}",
@@ -175,7 +147,7 @@ def header(name: str) -> str:
175147
"contentType": "application/javascript",
176148
"url": bundle["launch_asset"]["url"],
177149
},
178-
"assets": bundle["assets"],
150+
"assets": bundle.get("assets", []),
179151
"metadata": {},
180152
"extra": {"expoClient": {}},
181153
}

app/backend/src/tests/test_bugs.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -403,7 +403,22 @@ def _multipart_part_json(body, name):
403403
return json.loads(body[start:end])
404404

405405

406-
def test_mobile_update_manifest(db):
406+
_OTA_BUNDLES = {
407+
"ios": {
408+
"id": "00000000-0000-0000-0000-000000000000",
409+
"launch_asset": {"key": "ios-key", "url": "https://cdn.example/ios/bundle.hbc"},
410+
"assets": [],
411+
},
412+
"android": {
413+
"id": "00000000-0000-0000-0000-000000000000",
414+
"launch_asset": {"key": "android-key", "url": "https://cdn.example/android/bundle.hbc"},
415+
"assets": [],
416+
},
417+
}
418+
419+
420+
def test_mobile_update_manifest(db, feature_flags):
421+
feature_flags.set("native_ota_bundles", _OTA_BUNDLES)
407422
with real_bugs_session() as (bugs, metadata_interceptor):
408423
res = bugs.GetMobileUpdateManifest(
409424
httpbody_pb2.HttpBody(),
@@ -425,7 +440,8 @@ def test_mobile_update_manifest(db):
425440
assert metadata_interceptor.latest_headers["expo-sfv-version"] == "0"
426441

427442

428-
def test_mobile_update_manifest_android(db):
443+
def test_mobile_update_manifest_android(db, feature_flags):
444+
feature_flags.set("native_ota_bundles", _OTA_BUNDLES)
429445
with real_bugs_session() as (bugs, _metadata_interceptor):
430446
res = bugs.GetMobileUpdateManifest(
431447
httpbody_pb2.HttpBody(),

app/proto/bugs.proto

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ service Bugs {
4141
rpc GetMobileUpdateManifest(google.api.HttpBody) returns (google.api.HttpBody) {
4242
// Serves the Expo Updates protocol (v1) manifest for the mobile app
4343
option (google.api.http) = {
44-
get : "/mobile/ota/manifest"
44+
get : "/native/ota/manifest"
4545
};
4646
}
4747

docs/native-prod-ota.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -125,12 +125,12 @@ metadata.** `CouchersContext.set_response_headers([...])` queues the headers and
125125
`_send_cookies()` emits them alongside cookies in the single
126126
`send_initial_metadata` call (`context.py`). Envoy forwards this initial metadata
127127
downstream as HTTP response headers — confirmed by curling
128-
`/mobile/ota/manifest` through the local proxy: the response carries
128+
`/native/ota/manifest` through the local proxy: the response carries
129129
`expo-protocol-version: 1` and `expo-sfv-version: 0` (same mechanism that already
130130
delivers `set-cookie`).
131131

132132
> **Note — the route-scoped Envoy approach does NOT work here.** A `match: { path:
133-
> "/mobile/ota/manifest" }` route with `response_headers_to_add` never matches,
133+
> "/native/ota/manifest" }` route with `response_headers_to_add` never matches,
134134
> because `grpc_json_transcoder` rewrites the request `:path` to the gRPC method
135135
> path (`/org.couchers.bugs.Bugs/GetMobileUpdateManifest`) *before* route
136136
> selection, so the request falls through to the catch-all `prefix: "/"` route.
@@ -143,7 +143,7 @@ delivers `set-cookie`).
143143

144144
```
145145
cold start / periodic
146-
┌───────────┐ GET /mobile/ota/manifest ┌──────────────────────────┐
146+
┌───────────┐ GET /native/ota/manifest ┌──────────────────────────┐
147147
│ prod app │ ───────────────────────────────► │ Envoy (grpc_json_ │
148148
│ expo- │ headers: │ transcoder, auto_mapping) │
149149
│ updates │ expo-runtime-version └────────────┬─────────────┘
@@ -228,7 +228,7 @@ and already Envoy-allowlisted):
228228
import "google/api/httpbody.proto";
229229
230230
rpc GetMobileUpdateManifest(google.api.HttpBody) returns (google.api.HttpBody) {
231-
option (google.api.http) = { get : "/mobile/ota/manifest" };
231+
option (google.api.http) = { get : "/native/ota/manifest" };
232232
}
233233
```
234234

@@ -397,8 +397,8 @@ In `app.config.js`, **production (and staging) variants** — currently
397397
`updates = { url: "https://u.expo.dev/fb4fc9aa-…" }`:
398398

399399
- Set `updates.url` to the backend endpoint, e.g.
400-
`https://api.couchers.org/mobile/ota/manifest` (staging →
401-
`https://dev-api.couchershq.org/mobile/ota/manifest`).
400+
`https://api.couchers.org/native/ota/manifest` (staging →
401+
`https://dev-api.couchershq.org/native/ota/manifest`).
402402
- Add `updates.codeSigningCertificate` (committed public PEM) +
403403
`updates.codeSigningMetadata` (§7).
404404
- **Do NOT** set `disableAntiBrickingMeasures` — we keep anti-bricking (§2).

0 commit comments

Comments
 (0)