diff --git a/apps/desktop/wrangler.jsonc b/apps/desktop/wrangler.jsonc
index 8bd1786d6..bdd94461f 100644
--- a/apps/desktop/wrangler.jsonc
+++ b/apps/desktop/wrangler.jsonc
@@ -2,6 +2,15 @@
"$schema": "../../node_modules/wrangler/config-schema.json",
"name": "folo-web",
"compatibility_date": "2026-02-01",
+ "observability": {
+ "logs": {
+ "enabled": true,
+ "invocation_logs": true,
+ },
+ "traces": {
+ "enabled": true,
+ },
+ },
"account_id": "1f1d1678a2413a54c944b3081bab5c84",
"assets": {
"directory": "./out/web",
diff --git a/apps/landing/wrangler.jsonc b/apps/landing/wrangler.jsonc
index c43b19a8a..48c2ae982 100644
--- a/apps/landing/wrangler.jsonc
+++ b/apps/landing/wrangler.jsonc
@@ -4,6 +4,15 @@
"main": "./worker/index.js",
"compatibility_date": "2026-02-01",
"compatibility_flags": ["nodejs_compat"],
+ "observability": {
+ "logs": {
+ "enabled": true,
+ "invocation_logs": true,
+ },
+ "traces": {
+ "enabled": true,
+ },
+ },
"account_id": "1f1d1678a2413a54c944b3081bab5c84",
"assets": {
"directory": "./dist/client",
diff --git a/apps/ota/src/__tests__/policy.test.ts b/apps/ota/src/__tests__/policy.test.ts
index dfd6a584a..7d33be0d2 100644
--- a/apps/ota/src/__tests__/policy.test.ts
+++ b/apps/ota/src/__tests__/policy.test.ts
@@ -143,6 +143,45 @@ describe("/policy", () => {
})
})
+ it("falls back to the iOS App Store page when the Apple lookup API fails", async () => {
+ const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
+
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async (input: RequestInfo | URL) => {
+ const url = String(input)
+
+ if (url === "https://itunes.apple.com/lookup?id=6739802604") {
+ throw new Error("lookup unavailable")
+ }
+
+ if (url === "https://apps.apple.com/us/app/folo-follow-everything/id6739802604") {
+ return new Response(
+ '',
+ { status: 200, headers: { "content-type": "text/html" } },
+ )
+ }
+
+ throw new Error(`Unhandled fetch URL: ${url}`)
+ }),
+ )
+
+ const response = await fetchWorker(
+ "/policy?product=mobile&platform=ios&channel=production&installedBinaryVersion=0.4.1",
+ )
+
+ expect(response.status).toBe(200)
+ await expect(response.json()).resolves.toEqual({
+ action: "prompt",
+ targetVersion: "0.4.4",
+ message: null,
+ })
+ expect(consoleWarnSpy).toHaveBeenCalledWith(
+ "[ota] Apple lookup request failed for iOS store version, falling back",
+ expect.any(Error),
+ )
+ })
+
it("uses the cached iOS store version when KV is populated", async () => {
const fetchSpy = vi.fn()
vi.stubGlobal("fetch", fetchSpy)
diff --git a/apps/ota/src/__tests__/sync.test.ts b/apps/ota/src/__tests__/sync.test.ts
index 504f00445..22ed81c68 100644
--- a/apps/ota/src/__tests__/sync.test.ts
+++ b/apps/ota/src/__tests__/sync.test.ts
@@ -1687,6 +1687,229 @@ describe("syncStoreVersions", () => {
)
expect(kvEntries.get(KV_KEYS.storeVersionSyncLastSuccessAt)).toEqual(expect.any(String))
})
+
+ it("falls back to the iOS App Store page when the Apple lookup payload has no version", async () => {
+ const kvEntries = new Map()
+ const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
+
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async (input: string | URL | Request, _init?: RequestInit) => {
+ const url = String(input)
+
+ if (url === "https://itunes.apple.com/lookup?id=6739802604") {
+ return new Response(
+ JSON.stringify({
+ results: [{}],
+ }),
+ { status: 200, headers: { "Content-Type": "application/json" } },
+ )
+ }
+
+ if (url === "https://apps.apple.com/us/app/folo-follow-everything/id6739802604") {
+ return new Response(
+ '',
+ {
+ status: 200,
+ headers: { "Content-Type": "text/html" },
+ },
+ )
+ }
+
+ if (url === "https://play.google.com/store/apps/details?id=is.follow&hl=en_US&gl=US") {
+ return new Response('[[["0.4.1"]],[[[36]]]', {
+ status: 200,
+ headers: { "Content-Type": "text/html" },
+ })
+ }
+
+ if (
+ url === "https://apps.apple.com/us/app/folo-follow-everything/id6739802604?platform=mac"
+ ) {
+ return new Response('{"primarySubtitle":"Version 1.5.0"}', {
+ status: 200,
+ headers: { "Content-Type": "text/html" },
+ })
+ }
+
+ if (
+ url ===
+ "https://storeedgefd.dsx.mp.microsoft.com/v9.0/products/9nvfzpv0v0ht?market=US&locale=en-US&deviceFamily=Windows.Desktop"
+ ) {
+ return new Response(
+ JSON.stringify({
+ Payload: {
+ Skus: [
+ {
+ FulfillmentData: JSON.stringify({
+ WuCategoryId: "wu-category-id",
+ PackageFamilyName:
+ "NaturalSelectionLabs.Follow-Yourfavoritesinoneinbo_abc123",
+ }),
+ },
+ ],
+ },
+ }),
+ { status: 200, headers: { "Content-Type": "application/json" } },
+ )
+ }
+
+ if (url === "https://fe3.delivery.mp.microsoft.com/ClientWebService/client.asmx") {
+ const body = String(_init?.body ?? "")
+
+ if (body.includes("GetCookie")) {
+ return new Response("cookie-value", {
+ status: 200,
+ headers: { "Content-Type": "application/soap+xml" },
+ })
+ }
+
+ return new Response(
+ '<PackageMoniker="NaturalSelectionLabs.Follow-Yourfavoritesinoneinbo_1.5.0.0_x64__abc123">',
+ {
+ status: 200,
+ headers: { "Content-Type": "application/soap+xml" },
+ },
+ )
+ }
+
+ throw new Error(`Unhandled fetch URL: ${url}`)
+ }),
+ )
+
+ await syncStoreVersions(createEnv({ kvEntries }))
+
+ expect(JSON.parse(String(kvEntries.get(KV_KEYS.storeVersion("mobile", "ios"))))).toEqual(
+ expect.objectContaining({
+ version: "0.5.0",
+ source: "app-store",
+ }),
+ )
+ expect(consoleWarnSpy).toHaveBeenCalledWith(
+ "[ota] Apple lookup response did not include an iOS version, falling back",
+ )
+ })
+
+ it("logs and skips failed storefronts while persisting successful updates", async () => {
+ const kvEntries = new Map()
+ const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
+ const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
+
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async (input: string | URL | Request, _init?: RequestInit) => {
+ const url = String(input)
+
+ if (url === "https://itunes.apple.com/lookup?id=6739802604") {
+ throw new Error("lookup unavailable")
+ }
+
+ if (url === "https://apps.apple.com/us/app/folo-follow-everything/id6739802604") {
+ return new Response("service unavailable", {
+ status: 503,
+ headers: { "Content-Type": "text/html" },
+ })
+ }
+
+ if (url === "https://play.google.com/store/apps/details?id=is.follow&hl=en_US&gl=US") {
+ return new Response('[[["0.4.1"]],[[[36]]]', {
+ status: 200,
+ headers: { "Content-Type": "text/html" },
+ })
+ }
+
+ if (
+ url === "https://apps.apple.com/us/app/folo-follow-everything/id6739802604?platform=mac"
+ ) {
+ return new Response('{"primarySubtitle":"Version 1.5.0"}', {
+ status: 200,
+ headers: { "Content-Type": "text/html" },
+ })
+ }
+
+ if (
+ url ===
+ "https://storeedgefd.dsx.mp.microsoft.com/v9.0/products/9nvfzpv0v0ht?market=US&locale=en-US&deviceFamily=Windows.Desktop"
+ ) {
+ return new Response(
+ JSON.stringify({
+ Payload: {
+ Skus: [
+ {
+ FulfillmentData: JSON.stringify({
+ WuCategoryId: "wu-category-id",
+ PackageFamilyName:
+ "NaturalSelectionLabs.Follow-Yourfavoritesinoneinbo_abc123",
+ }),
+ },
+ ],
+ },
+ }),
+ { status: 200, headers: { "Content-Type": "application/json" } },
+ )
+ }
+
+ if (url === "https://fe3.delivery.mp.microsoft.com/ClientWebService/client.asmx") {
+ const body = String(_init?.body ?? "")
+
+ if (body.includes("GetCookie")) {
+ return new Response("cookie-value", {
+ status: 200,
+ headers: { "Content-Type": "application/soap+xml" },
+ })
+ }
+
+ return new Response(
+ '<PackageMoniker="NaturalSelectionLabs.Follow-Yourfavoritesinoneinbo_1.5.0.0_x64__abc123">',
+ {
+ status: 200,
+ headers: { "Content-Type": "application/soap+xml" },
+ },
+ )
+ }
+
+ throw new Error(`Unhandled fetch URL: ${url}`)
+ }),
+ )
+
+ await expect(syncStoreVersions(createEnv({ kvEntries }))).resolves.toBeUndefined()
+
+ expect(kvEntries.get(KV_KEYS.storeVersion("mobile", "ios"))).toBeUndefined()
+ expect(kvEntries.get(KV_KEYS.storeVersion("mobile", "android"))).toEqual(expect.any(String))
+ expect(kvEntries.get(KV_KEYS.storeVersion("desktop", "mas"))).toEqual(expect.any(String))
+ expect(kvEntries.get(KV_KEYS.storeVersion("desktop", "mss"))).toEqual(expect.any(String))
+ expect(kvEntries.get(KV_KEYS.storeVersionSyncLastSuccessAt)).toEqual(expect.any(String))
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
+ "[ota] Failed to refresh store version for mobile:ios",
+ expect.any(Error),
+ )
+ expect(consoleWarnSpy).toHaveBeenCalledWith(
+ "[ota] Store version sync completed with 1 failure(s); refreshed 3/4 providers",
+ )
+ })
+
+ it("fails when every storefront refresh fails", async () => {
+ const kvEntries = new Map()
+ const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
+ const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
+
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async () => {
+ throw new Error("network unavailable")
+ }),
+ )
+
+ await expect(syncStoreVersions(createEnv({ kvEntries }))).rejects.toThrow(
+ "Failed to refresh all store versions",
+ )
+
+ expect(kvEntries.get(KV_KEYS.storeVersionSyncLastSuccessAt)).toBeUndefined()
+ expect(consoleErrorSpy).toHaveBeenCalledTimes(4)
+ expect(consoleWarnSpy).not.toHaveBeenCalledWith(
+ expect.stringContaining("Store version sync completed with"),
+ )
+ })
})
describe("internal routes", () => {
diff --git a/apps/ota/src/lib/store-version.ts b/apps/ota/src/lib/store-version.ts
index b61fa6ed5..81e4f01ee 100644
--- a/apps/ota/src/lib/store-version.ts
+++ b/apps/ota/src/lib/store-version.ts
@@ -7,7 +7,9 @@ const MICROSOFT_STORE_MARKET = "US"
const MICROSOFT_STORE_LOCALE = "en-US"
const MICROSOFT_STORE_RING = "Retail"
const PLAY_VERSION_PATTERN = /\[\[\["(\d+\.\d+\.\d+)"\]\],\[\[\[36\]\]/
-const APPLE_VERSION_PATTERN = /"primarySubtitle":"Version (\d+\.\d+\.\d+)"/
+const APPLE_LOOKUP_URL = `https://itunes.apple.com/lookup?id=${APPLE_APP_STORE_ID}`
+const APPLE_PRIMARY_SUBTITLE_VERSION_PATTERN = /"primarySubtitle":"Version (\d+\.\d+\.\d+)"/
+const APPLE_TEXT_VERSION_PATTERN = /\bVersion (\d+\.\d+\.\d+)\b/
const MICROSOFT_VERSION_PATTERN = /^\d+(?:\.\d+)+$/
export type MobileStorePlatform = "ios" | "android"
@@ -74,21 +76,31 @@ export function getDesktopStoreUrl(distribution: DesktopDistribution) {
}
async function fetchIosStoreVersion() {
- const response = await fetch(`https://itunes.apple.com/lookup?id=${APPLE_APP_STORE_ID}`, {
- headers: STORE_HEADERS,
- })
- if (!response.ok) {
- throw new Error(`App Store lookup failed (${response.status})`)
+ try {
+ const response = await fetch(APPLE_LOOKUP_URL, {
+ headers: STORE_HEADERS,
+ })
+ if (!response.ok) {
+ throw new Error(`App Store lookup failed (${response.status})`)
+ }
+
+ const payload = (await response.json()) as {
+ results?: Array<{
+ version?: unknown
+ }>
+ }
+
+ const version = payload.results?.[0]?.version
+ if (typeof version === "string") {
+ return version
+ }
+
+ console.warn("[ota] Apple lookup response did not include an iOS version, falling back")
+ } catch (error) {
+ console.warn("[ota] Apple lookup request failed for iOS store version, falling back", error)
}
- const payload = (await response.json()) as {
- results?: Array<{
- version?: unknown
- }>
- }
-
- const version = payload.results?.[0]?.version
- return typeof version === "string" ? version : null
+ return fetchAppleStorefrontVersion(STORE_URLS.ios, "iOS App Store storefront request")
}
async function fetchGooglePlayVersion() {
@@ -104,15 +116,7 @@ async function fetchGooglePlayVersion() {
}
async function fetchMacAppStoreVersion() {
- const response = await fetch(STORE_URLS.mas, {
- headers: STORE_HEADERS,
- })
- if (!response.ok) {
- throw new Error(`Mac App Store storefront request failed (${response.status})`)
- }
-
- const html = await response.text()
- return html.match(APPLE_VERSION_PATTERN)?.[1] ?? null
+ return fetchAppleStorefrontVersion(STORE_URLS.mas, "Mac App Store storefront request")
}
async function fetchMicrosoftStoreVersion() {
@@ -186,6 +190,26 @@ async function fetchJson(url: string, context: string): Promise {
return (await response.json()) as T
}
+async function fetchAppleStorefrontVersion(url: string, context: string) {
+ const response = await fetch(url, {
+ headers: STORE_HEADERS,
+ })
+ if (!response.ok) {
+ throw new Error(`${context} failed (${response.status})`)
+ }
+
+ const html = await response.text()
+ return extractAppleStoreVersion(html)
+}
+
+function extractAppleStoreVersion(html: string) {
+ return (
+ html.match(APPLE_PRIMARY_SUBTITLE_VERSION_PATTERN)?.[1] ??
+ html.match(APPLE_TEXT_VERSION_PATTERN)?.[1] ??
+ null
+ )
+}
+
function resolveMicrosoftFulfillmentData(payload: {
Payload?: {
Skus?: Array<{
diff --git a/apps/ota/src/lib/sync.ts b/apps/ota/src/lib/sync.ts
index 5f9b2ea3b..7e0485360 100644
--- a/apps/ota/src/lib/sync.ts
+++ b/apps/ota/src/lib/sync.ts
@@ -130,11 +130,38 @@ async function runSyncStoreVersions(env: Env) {
}),
)
- const failures = results.filter((result) => result.status === "rejected")
- if (failures.length > 0) {
+ const failures = results.flatMap((result, index) => {
+ const task = syncTasks[index]
+
+ if (!task || result.status === "fulfilled") {
+ return []
+ }
+
+ return [
+ {
+ task,
+ reason: result.reason,
+ },
+ ]
+ })
+
+ for (const failure of failures) {
+ console.error(
+ `[ota] Failed to refresh store version for ${failure.task.product}:${failure.task.target}`,
+ failure.reason,
+ )
+ }
+
+ if (failures.length === syncTasks.length) {
throw new AggregateError(
- failures.map((result) => (result as PromiseRejectedResult).reason),
- "Failed to refresh one or more store versions",
+ failures.map((failure) => failure.reason),
+ "Failed to refresh all store versions",
+ )
+ }
+
+ if (failures.length > 0) {
+ console.warn(
+ `[ota] Store version sync completed with ${failures.length} failure(s); refreshed ${syncTasks.length - failures.length}/${syncTasks.length} providers`,
)
}
diff --git a/apps/ota/wrangler.jsonc b/apps/ota/wrangler.jsonc
index f44a76306..bcb63e917 100644
--- a/apps/ota/wrangler.jsonc
+++ b/apps/ota/wrangler.jsonc
@@ -4,6 +4,15 @@
"main": "src/index.ts",
"compatibility_date": "2026-04-10",
"compatibility_flags": ["nodejs_compat"],
+ "observability": {
+ "logs": {
+ "enabled": true,
+ "invocation_logs": true,
+ },
+ "traces": {
+ "enabled": true,
+ },
+ },
"account_id": "1f1d1678a2413a54c944b3081bab5c84",
"workers_dev": true,
"routes": [
diff --git a/apps/ssr/wrangler.jsonc b/apps/ssr/wrangler.jsonc
index 160c44cc5..97a7676b9 100644
--- a/apps/ssr/wrangler.jsonc
+++ b/apps/ssr/wrangler.jsonc
@@ -4,6 +4,15 @@
"main": "dist/worker/worker-entry.mjs",
"compatibility_date": "2026-02-01",
"compatibility_flags": ["nodejs_compat"],
+ "observability": {
+ "logs": {
+ "enabled": true,
+ "invocation_logs": true,
+ },
+ "traces": {
+ "enabled": true,
+ },
+ },
"account_id": "1f1d1678a2413a54c944b3081bab5c84",
"placement": {
"mode": "smart",