diff --git a/APPSTORE.md b/APPSTORE.md index 74e179a..719a73f 100644 --- a/APPSTORE.md +++ b/APPSTORE.md @@ -123,8 +123,8 @@ correct app/team association, and a matching Sandbox Apple Account. with the **App Group `group.com.apt.fuelboard`** capability AND the **In-App Purchase** capability enabled (the widget + app-group cache depend on the group; the tip jar needs IAP). -3. ~~[ME] `PrivacyInfo.xcprivacy`~~ — **DONE 2026-08-16** (app + widget - manifests added; UserDefaults `1C8F.1`, Location + Diagnostics declared). +3. ~~[ME] `PrivacyInfo.xcprivacy`~~ — **DONE** (app, widget, watch app, and + watch wrapper manifests added; UserDefaults `1C8F.1`; app declares Location). 4. **[ME] Real Distribution signing** — Distribution certificate + App Store provisioning profile (or Xcode automatic signing once the account is in Xcode); then `xcodebuild archive` (no more `CODE_SIGNING_ALLOWED=NO`). @@ -135,21 +135,20 @@ correct app/team association, and a matching Sandbox Apple Account. ## Required tasks — owner [ME] (technical, do after account exists) -- [x] **PrivacyInfo.xcprivacy** — app + widget manifests added (2026-08-16): - UserDefaults reason `1C8F.1`; app also declares Location (linked, not - tracking) + Diagnostics (not linked, not tracking — the dev beacon) +- [x] **PrivacyInfo.xcprivacy** — app, widget, watch app, and watch wrapper + manifests added: UserDefaults reason `1C8F.1`; app also declares Location + (linked, not tracking). Diagnostic network beacon removed with LAN relay. - [ ] Distribution archive + TestFlight upload (needs Apple account) -- [x] **Version bump** → `1.0.1` (build 2), app + widget plists +- [x] **Version/build aligned** → `1.0.0` (build 23), app + widget + watch targets - [x] **OGL attribution** — Settings → Data source footer now reads "Prices use the UK Fuel Finder dataset (Open Government Licence) and refresh up to twice a day." (2.3.1 hygiene) - [x] **Out-of-UK empty state** — "No X stations within N miles" + UK-only explainer + "Browse all UK stations" button (switches to Closest) -- [ ] ~~Gate dev surface behind #if DEBUG~~ **DECLINED — kept in Release on - purpose**: the user's home-testing installs the Release IPA and toggles - `fuelboard.relayFallback` via Settings → Debug (revealed only by tapping - About → Version ×5 — invisible to reviewers; no credentials involved). - Gating it out of Release would break that workflow for zero review gain. +- [x] **Remove LAN relay fallback entirely** — no private IP, Local Network + permission string, relay toggle, or diagnostic network beacon remains in + the app/widget/watch binary. Hidden Debug still exists for local notification + and widget diagnostics only. - [ ] Final pass: `swift test` green (95 tests), archive smoke test on device - [ ] ~~Capture screenshots from simulator~~ **RAW CAPTURES DONE (2026-08-16)** — native 6.9″ 1320×2868 PNGs of all four tabs (Stations, Favourites, @@ -175,8 +174,7 @@ correct app/team association, and a matching Sandbox Apple Account. category (Utilities), pricing (Free), availability (UK first?) - [ ] Description, keywords, support URL, marketing URL, privacy policy URL - [ ] Privacy nutrition labels questionnaire (location: yes, linked to user - for alerts/nearby; diagnostics: yes — the widget-diag beacon, not - linked, not used for tracking; no ATT prompt needed) + for alerts/nearby; no tracking; no ATT prompt needed) - [ ] Age rating questionnaire (4+; no objectionable content) - [ ] Export compliance: HTTPS-only + keychain → standard encryption exemption (5A992a), no documentation needed @@ -214,8 +212,8 @@ correct app/team association, and a matching Sandbox Apple Account. glance. It is declared in the plist already. - **Brand logos (Shell, BP, Tesco…)** — Simple Icons (CC0) + Wikimedia, nominative use (cf. GasBuddy); low risk, keep sources documented. -- **GitHub raw as data source** — public data, no credentials; bundled dump - covers any regional GitHub unreachability. +- **GitHub raw as data source** — public HTTPS data, no credentials or Local + Network permission; bundled dump covers any regional GitHub unreachability. ## Timeline (best case) diff --git a/BACKLOG.md b/BACKLOG.md index 03d0a13..9e1c5cd 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -4,7 +4,7 @@ FuelBoard — UK-wide petrol-station price widget + app. GitHub mirror (`aptonline/fuelboard-data`, 2×/day 10:00+16:00) holds the full-UK dump (~8,022 stations, all prices); app fetches it once per 12 h and filters/sorts on-device, with a bundled snapshot as the no-network last resort. -LAN relay is dev-only (hidden `fuelboard.relayFallback` flag). +LAN relay fallback has been removed from the app binary; the public GitHub mirror is the only live network data source. Priority guide: P0 = next build, P1 = soon, P2 = later, P3 = maybe/idea. Status: TODO / IN PROGRESS / DONE / BLOCKED. @@ -211,15 +211,12 @@ Status: TODO / IN PROGRESS / DONE / BLOCKED. deploy key, 180-day retention) + history read side (Trends, P1) + **live chain (`feature/p0-live-chain`)**: `MirrorFuelProvider` fetches CURRENT prices from raw.githubusercontent (latest.json → history/.json, app-group day-cache) → - **GitHub FIRST everywhere** (alert checks + widget too — the relay is - unreachable from any consumer phone and attempting it would fire the Local - Network prompt); LAN relay joins ONLY behind the hidden dev flag - `fuelboard.relayFallback` (Settings → Debug, off by default); bundled REAL - dump (`FuelBoardDump` dataset, refreshed by `scripts/refresh_bundled_dump.sh`) - is the app's no-network last resort. `FuelBeacon` fire-and-forget X-Client app - ping fires only with the dev flag on. **Local Network permission removed from - onboarding entirely** — data page is informational, replay on cellular can no - longer hang. 95 tests. + **GitHub mirror only everywhere** (alert checks + widget too); no LAN relay + fallback, private IP, Local Network permission, or diagnostic network beacon + ships in the app binary. Bundled REAL dump (`FuelBoardDump` dataset, refreshed + by `scripts/refresh_bundled_dump.sh`) is the app's no-network last resort. + Local Network permission removed entirely — data page is informational, replay + on cellular can no longer hang. 107+ tests. - [x] Switch dataset to govuk API data — official Fuel Finder OAuth API via relay-api :8789 (source: api, ~8,012 stations); app baseURL → :8789; credentials live in relay `.env` only, never in the IPA diff --git a/Config/App-Info.plist b/Config/App-Info.plist index a97954b..ced5e9c 100644 --- a/Config/App-Info.plist +++ b/Config/App-Info.plist @@ -33,8 +33,6 @@ $(CURRENT_PROJECT_VERSION) LSRequiresIPhoneOS - NSLocalNetworkUsageDescription - FuelBoard can optionally connect to the FuelBoard Relay on your local network for faster alerts during development. NSLocationAlwaysAndWhenInUseUsageDescription FuelBoard uses Always location to alert you when you approach the cheapest station nearby. NSLocationWhenInUseUsageDescription diff --git a/FuelBoard.xcodeproj/project.pbxproj b/FuelBoard.xcodeproj/project.pbxproj index 4533b1c..7529af4 100644 --- a/FuelBoard.xcodeproj/project.pbxproj +++ b/FuelBoard.xcodeproj/project.pbxproj @@ -524,7 +524,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_ENTITLEMENTS = Config/WatchApp.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 22; + CURRENT_PROJECT_VERSION = 23; DEVELOPMENT_TEAM = F3BE6NE7U3; ENABLE_PREVIEWS = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -590,7 +590,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_ENTITLEMENTS = Config/WatchApp.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 22; + CURRENT_PROJECT_VERSION = 23; DEVELOPMENT_TEAM = F3BE6NE7U3; ENABLE_PREVIEWS = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -654,7 +654,7 @@ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 22; + CURRENT_PROJECT_VERSION = 23; DEVELOPMENT_TEAM = F3BE6NE7U3; ENABLE_PREVIEWS = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -719,7 +719,7 @@ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 22; + CURRENT_PROJECT_VERSION = 23; DEVELOPMENT_TEAM = F3BE6NE7U3; ENABLE_PREVIEWS = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -817,7 +817,7 @@ CODE_SIGN_ENTITLEMENTS = Config/App.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 22; + CURRENT_PROJECT_VERSION = 23; DEVELOPMENT_TEAM = F3BE6NE7U3; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = "Config/App-Info.plist"; @@ -842,7 +842,7 @@ CODE_SIGN_ENTITLEMENTS = Config/App.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 22; + CURRENT_PROJECT_VERSION = 23; DEVELOPMENT_TEAM = F3BE6NE7U3; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = "Config/App-Info.plist"; @@ -866,7 +866,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 22; + CURRENT_PROJECT_VERSION = 23; DEVELOPMENT_TEAM = F3BE6NE7U3; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = "Config/Widget-Info.plist"; @@ -891,7 +891,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 22; + CURRENT_PROJECT_VERSION = 23; DEVELOPMENT_TEAM = F3BE6NE7U3; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = "Config/Widget-Info.plist"; @@ -914,7 +914,7 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 22; + CURRENT_PROJECT_VERSION = 23; DEVELOPMENT_TEAM = F3BE6NE7U3; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = FuelBoardSharedTests; @@ -937,7 +937,7 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 22; + CURRENT_PROJECT_VERSION = 23; DEVELOPMENT_TEAM = F3BE6NE7U3; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = FuelBoardSharedTests; diff --git a/FuelBoard/SettingsView.swift b/FuelBoard/SettingsView.swift index 9e8a262..5bbf400 100644 --- a/FuelBoard/SettingsView.swift +++ b/FuelBoard/SettingsView.swift @@ -62,9 +62,6 @@ struct SettingsView: View { /// Hidden developer flag — the Debug section only appears when on. Toggled /// by tapping the About → Version row five times (NOT a user-facing switch). @State private var debugMode: Bool = FuelStore.loadDebugMode() - /// Dev-only: re-inserts the LAN relay into the live chain (off by default — - /// consumers must never attempt local-network access). - @State private var relayFallbackEnabled: Bool = FuelStore.loadRelayFallbackEnabled() @State private var versionTapCount = 0 @State private var lastVersionTap = Date.distantPast /// Widget diagnostics: the extension's last makeEntry beacon PER intent @@ -176,10 +173,6 @@ struct SettingsView: View { if debugMode { Section { - Toggle("LAN relay fallback (dev)", isOn: $relayFallbackEnabled) - .onChange(of: relayFallbackEnabled) { _, enabled in - FuelStore.saveRelayFallbackEnabled(enabled) - } Button { sendTestAlert() } label: { diff --git a/FuelBoardTests/Tests/FuelBoardSharedTests/LiveChainTests.swift b/FuelBoardTests/Tests/FuelBoardSharedTests/LiveChainTests.swift index a5b9041..362c16a 100644 --- a/FuelBoardTests/Tests/FuelBoardSharedTests/LiveChainTests.swift +++ b/FuelBoardTests/Tests/FuelBoardSharedTests/LiveChainTests.swift @@ -3,23 +3,11 @@ import XCTest @testable import FuelBoardShared #endif -/// P0 live chain: GitHub mirror FIRST everywhere → [dev-only LAN relay] → +/// P0 live chain: public GitHub mirror first everywhere → /// (app: bundled dump in ContentView's catch, widget: cache → placeholder). -/// The relay is NOT part of the consumer chain — it only joins behind the -/// hidden dev flag `fuelboard.relayFallback`. +/// No LAN relay fallback is compiled into the app binary. final class LiveChainTests: XCTestCase { - override func setUp() { - super.setUp() - FuelBeacon.isEnabled = false // chain tests must not fire real pings - FuelStore.saveRelayFallbackEnabled(false) // dev flag off by default - } - - override func tearDown() { - FuelBeacon.isEnabled = true - super.tearDown() - } - // MARK: Fixtures private final class StubProvider: FuelPriceProviding { @@ -45,86 +33,56 @@ final class LiveChainTests: XCTestCase { prices: [.e10: 137.9, .e5: 144.9, .diesel: 144.9], priceUpdated: nil) } - // MARK: Full path (app refresh): GitHub first + // MARK: Full path (app refresh): GitHub mirror only - func testFullPathPrefersMirror() async throws { + func testFullPathUsesMirror() async throws { let mirror = StubProvider(result: [fixtureStation()]) - let relay = StubProvider(error: FuelProviderError.relayUnavailable) - let chain = LiveChainProvider(mirror: mirror, relay: relay) + let chain = LiveChainProvider(mirror: mirror) let stations = try await chain.fetchStations(near: nil, lng: nil, fuel: .e10, radiusKM: nil) XCTAssertEqual(stations.count, 1) - XCTAssertEqual(mirror.callCount, 1, "full path must try the GitHub mirror first") - XCTAssertEqual(relay.callCount, 0, "relay must not run when the mirror serves") + XCTAssertEqual(mirror.callCount, 1, "full path must use the public GitHub mirror") XCTAssertEqual(LiveChainProvider.lastSource, "github") } - func testFullPathRelaySkippedWhenDisabled() async { + func testFullPathPropagatesMirrorFailure() async { let mirror = StubProvider(error: FuelProviderError.mirrorUnavailable) - let relay = StubProvider(result: [fixtureStation("relay")]) - let chain = LiveChainProvider(mirror: mirror, relay: relay) + let chain = LiveChainProvider(mirror: mirror) do { _ = try await chain.fetchStations(near: nil, lng: nil, fuel: .e10, radiusKM: nil) - XCTFail("chain must throw when the mirror fails and the relay flag is off") + XCTFail("chain must throw when the public mirror fails; callers decide cache/bundled fallback") } catch { - XCTAssertEqual(relay.callCount, 0, "consumers must NEVER attempt the relay") + XCTAssertEqual(mirror.callCount, 1) } } - func testFullPathRelayFallbackWhenDevFlagOn() async throws { - FuelStore.saveRelayFallbackEnabled(true) - let mirror = StubProvider(error: FuelProviderError.mirrorUnavailable) - let relay = StubProvider(result: [fixtureStation("relay")]) - let chain = LiveChainProvider(mirror: mirror, relay: relay) + // MARK: Focused path (alert checks + widget): GitHub mirror only - let stations = try await chain.fetchStations(near: nil, lng: nil, fuel: .e10, radiusKM: nil) - - XCTAssertEqual(stations.first?.id, "relay") - XCTAssertEqual(relay.callCount, 1, "dev flag on → relay fallback serves") - XCTAssertEqual(LiveChainProvider.lastSource, "relay") - } - - // MARK: Focused path (alert checks + widget): GitHub first too - - func testFocusedPathPrefersMirror() async throws { + func testFocusedPathUsesMirror() async throws { let mirror = StubProvider(result: [fixtureStation("mirror")]) - let relay = StubProvider(result: [fixtureStation("relay")]) - let chain = LiveChainProvider(mirror: mirror, relay: relay) + let chain = LiveChainProvider(mirror: mirror) let stations = try await chain.fetchStations(near: 53.7, lng: -1.8, fuel: .e10, radiusKM: 5) - XCTAssertEqual(stations.first?.id, "mirror", "focused path must prefer the mirror — the relay is unreachable off-LAN") - XCTAssertEqual(relay.callCount, 0) + XCTAssertEqual(stations.first?.id, "mirror", "focused path must use the public GitHub mirror") + XCTAssertEqual(mirror.callCount, 1) XCTAssertEqual(LiveChainProvider.lastSource, "github") } - func testFocusedPathRelaySkippedWhenDisabled() async { + func testFocusedPathPropagatesMirrorFailure() async { let mirror = StubProvider(error: FuelProviderError.mirrorUnavailable) - let relay = StubProvider(result: [fixtureStation("relay")]) - let chain = LiveChainProvider(mirror: mirror, relay: relay) + let chain = LiveChainProvider(mirror: mirror) do { _ = try await chain.fetchStations(near: 53.7, lng: -1.8, fuel: .e10, radiusKM: 5) - XCTFail("focused path must throw when the mirror fails and the relay flag is off") + XCTFail("focused path must throw when the public mirror fails") } catch { - XCTAssertEqual(relay.callCount, 0, "consumers must NEVER attempt the relay") + XCTAssertEqual(mirror.callCount, 1) } } - func testFocusedPathRelayFallbackWhenDevFlagOn() async throws { - FuelStore.saveRelayFallbackEnabled(true) - let mirror = StubProvider(error: FuelProviderError.mirrorUnavailable) - let relay = StubProvider(result: [fixtureStation("relay")]) - let chain = LiveChainProvider(mirror: mirror, relay: relay) - - let stations = try await chain.fetchStations(near: 53.7, lng: -1.8, fuel: .e10, radiusKM: 5) - - XCTAssertEqual(stations.first?.id, "relay") - XCTAssertEqual(relay.callCount, 1) - } - // MARK: Mirror focused projection (replaces the relay's server-side radius) func testMirrorFocusedFiltersByFuelRadiusDistanceAndLimit() { @@ -165,24 +123,4 @@ final class LiveChainTests: XCTestCase { XCTAssertFalse(MirrorFuelProvider.canReuseCache(cachedDay: "2026-08-15", latestDay: nil)) } - // MARK: Beacon - - func testBeaconURLCarriesAppAttribution() { - guard let url = FuelBeacon.beaconURL(source: "github", n: 8022) else { - return XCTFail("beacon URL must build") - } - XCTAssertTrue(url.absoluteString.contains("api/v1/widget-diag")) - let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems ?? [] - XCTAssertTrue(items.contains(URLQueryItem(name: "intent", value: "app-live"))) - XCTAssertTrue(items.contains(URLQueryItem(name: "source", value: "github"))) - XCTAssertTrue(items.contains(URLQueryItem(name: "n", value: "8022"))) - } - - func testBeaconGatedByDevFlag() { - FuelBeacon.isEnabled = true - FuelStore.saveRelayFallbackEnabled(false) - XCTAssertFalse(FuelBeacon.shouldFire, "consumers: beacon must never fire (no local-network attempt)") - FuelStore.saveRelayFallbackEnabled(true) - XCTAssertTrue(FuelBeacon.shouldFire, "dev flag on: beacon fires for attribution") - } -} +} \ No newline at end of file diff --git a/FuelBoardWatch Watch App/PrivacyInfo.xcprivacy b/FuelBoardWatch Watch App/PrivacyInfo.xcprivacy new file mode 100644 index 0000000..d773e0e --- /dev/null +++ b/FuelBoardWatch Watch App/PrivacyInfo.xcprivacy @@ -0,0 +1,23 @@ + + + + + NSPrivacyTracking + + NSPrivacyTrackingDomains + + NSPrivacyCollectedDataTypes + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasons + + 1C8F.1 + + + + + diff --git a/FuelBoardWatch Wrapper/PrivacyInfo.xcprivacy b/FuelBoardWatch Wrapper/PrivacyInfo.xcprivacy new file mode 100644 index 0000000..d773e0e --- /dev/null +++ b/FuelBoardWatch Wrapper/PrivacyInfo.xcprivacy @@ -0,0 +1,23 @@ + + + + + NSPrivacyTracking + + NSPrivacyTrackingDomains + + NSPrivacyCollectedDataTypes + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasons + + 1C8F.1 + + + + + diff --git a/FuelBoardWidgets/FuelPriceWidget.swift b/FuelBoardWidgets/FuelPriceWidget.swift index 35d357d..eee9e23 100644 --- a/FuelBoardWidgets/FuelPriceWidget.swift +++ b/FuelBoardWidgets/FuelPriceWidget.swift @@ -82,8 +82,8 @@ struct FuelPriceTimelineProvider FuelPriceEntry { @@ -122,10 +122,9 @@ struct FuelPriceTimelineProvider FuelPriceEntry { @@ -201,7 +179,7 @@ struct FuelPriceTimelineProvider [FuelStation]? { - if let stations = try? await MirrorFuelProvider().fetchStations( - near: location.lat, lng: location.lng, fuel: fuel, radiusKM: radiusKM) { - return stations - } - guard FuelStore.loadRelayFallbackEnabled() else { return nil } - var components = URLComponents( - url: RelayFuelProvider().baseURL.appendingPathComponent("api/v1/stations"), - resolvingAgainstBaseURL: false - )! - components.queryItems = [ - URLQueryItem(name: "fuel", value: fuel.rawValue), - URLQueryItem(name: "lat", value: String(location.lat)), - URLQueryItem(name: "lng", value: String(location.lng)), - URLQueryItem(name: "radius", value: String(radiusKM)), - URLQueryItem(name: "limit", value: "500"), - ] - let request = RelayFuelProvider.relayRequest(components.url!, client: "widget", timeout: 5) - do { - let (data, response) = try await URLSession.shared.data(for: request) - guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return nil } - return try? FuelPriceProvider.decodeStations(from: data) - } catch { - return nil - } + try? await MirrorFuelProvider().fetchStations( + near: location.lat, lng: location.lng, fuel: fuel, radiusKM: radiusKM) } /// Fuel-only UK-wide fetch (no lat/lng/radius) — used when the widget has - /// no location fix so the face shows real stations instead of sample - /// data. GitHub mirror first (full dump + local fuel filter), relay only - /// behind the dev flag. Same nil-on-failure semantics as fetchFocused. + /// no location fix so the face shows real stations instead of sample data. + /// Public HTTPS mirror only; same nil-on-failure semantics as fetchFocused. private static func fetchFuelOnly(fuel: FuelType, limit: Int) async -> [FuelStation]? { - if let stations = try? await MirrorFuelProvider().fetchStations( - near: nil, lng: nil, fuel: fuel, radiusKM: nil) { - return Array(stations.filter { $0.prices[fuel] != nil }.prefix(limit)) - } - guard FuelStore.loadRelayFallbackEnabled() else { return nil } - var components = URLComponents( - url: RelayFuelProvider().baseURL.appendingPathComponent("api/v1/stations"), - resolvingAgainstBaseURL: false - )! - components.queryItems = [ - URLQueryItem(name: "fuel", value: fuel.rawValue), - URLQueryItem(name: "limit", value: String(limit)), - ] - let request = RelayFuelProvider.relayRequest(components.url!, client: "widget", timeout: 5) - do { - let (data, response) = try await URLSession.shared.data(for: request) - guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return nil } - return try? FuelPriceProvider.decodeStations(from: data) - } catch { - return nil - } + guard let stations = try? await MirrorFuelProvider().fetchStations( + near: nil, lng: nil, fuel: fuel, radiusKM: nil) else { return nil } + return Array(stations.filter { $0.prices[fuel] != nil }.prefix(limit)) } } diff --git a/SECURITY.md b/SECURITY.md index c715e87..c78dda2 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,24 +4,21 @@ Audit date: 2026-08-11 · Scope: app, widget, relay, build pipeline, git history ## Summary -No critical issues. No secrets in source, git history, or binaries. One -architecture note (relay IP baked into the app) is scheduled for removal by -the govuk-API switch (BACKLOG P0). Findings below are ordered by severity. +No critical issues. No secrets in source, git history, or binaries. The prior +LAN relay/private-IP app path has been removed from the app binary; production +data access is public HTTPS GitHub mirror plus bundled fallback. Findings below +are ordered by severity. ## Findings -### 1. LAN IP baked into app binary — MEDIUM (planned fix) +### 1. LAN IP baked into app binary — RESOLVED -- `Shared/FuelPriceProvider.swift:52` — `baseURL = URL(string: "http://192.168.1.131:8788")!` -- Confirmed present in the built app binary (`strings`). -- **Why it works**: ATS exempts raw numeric IP addresses (documented Apple - behaviour), so no `NSAllowsArbitraryLoads` exception exists in the plist — - the plist is clean. -- **Risk**: the relay is a private LAN host; the app is unusable off the - user's network, and the URL is effectively a hardcoded endpoint. -- **Fix**: the govuk API switch (BACKLOG P0) replaces this with the HTTPS - gov.uk endpoint via the relay. For any future public build, the app should - talk to an HTTPS domain, never a raw IP. +- The app no longer contains `RelayFuelProvider`, `fuelboard.relayFallback`, + `NSLocalNetworkUsageDescription`, or a private `192.168.*` live-data endpoint. +- Live data uses the public HTTPS GitHub mirror; the app falls back to bundled + data/cache when the mirror is unavailable. +- A built-binary `strings` scan should remain part of every release gate to + ensure no private LAN endpoint is reintroduced. ### 2. No secrets in source / git / binary — PASS @@ -31,8 +28,9 @@ the govuk-API switch (BACKLOG P0). Findings below are ordered by severity. - Relay: `CLIENT_ID`/`CLIENT_SECRET` read from environment only (`app/main.py:31-32`); `.env` is gitignored; `__pycache__`/`*.pyc` untracked; `.env.example` ships empty placeholders. -- Binary scan: only the relay IP, `maps.apple.com`, and `group.com.apt.fuelboard` - strings found. No credentials. +- Binary scan should show no private LAN data endpoint; expected network strings + include public GitHub raw URLs, `maps.apple.com`, and `group.com.apt.fuelboard`. + No credentials. ### 3. Price data hardening — PASS (three layers) @@ -53,11 +51,11 @@ the govuk-API switch (BACKLOG P0). Findings below are ordered by severity. - `kSecAttrAccessibleAfterFirstUnlock` — widget can read after reboot without unlock. Correct for background widget refresh. -### 5. Transport security — PASS for LAN dev, MUST CHANGE for public +### 5. Transport security — PASS - No `NSAllowsArbitraryLoads` anywhere (confirmed by plist + pbxproj grep). -- Cleartext HTTP only to the LAN relay IP (ATS-exempt). Once the relay moves - behind an HTTPS domain (govuk switch), no ATS changes are needed. +- No cleartext LAN relay endpoint ships in the app binary. Live network data is + fetched over HTTPS from the public GitHub mirror. ### 6. Privacy / permissions — review for App Store diff --git a/Shared/FuelHistory.swift b/Shared/FuelHistory.swift index 67197bc..fd35e75 100644 --- a/Shared/FuelHistory.swift +++ b/Shared/FuelHistory.swift @@ -1,7 +1,7 @@ // FuelHistory.swift — price-history store for the Favourites Trends graph. // // Reads the GitHub price mirror (aptonline/fuelboard-data, pushed daily by the -// LAN relay): `latest.json` pointer + `history/YYYY-MM-DD.json` full dumps. +// ingestion relay): `latest.json` pointer + `history/YYYY-MM-DD.json` full dumps. // The store keeps a favourites-only day cache in the app group so the chart // works offline once a day has been seen, and only fetches the days it is // missing. Foundation-only so the unit-test target can compile it on macOS. @@ -66,8 +66,8 @@ enum FuelHistoryError: LocalizedError { // MARK: - Store enum FuelHistoryStore { - /// GitHub raw mirror base — the app reads history directly from here; - /// the LAN relay remains the live-data fallback. + /// GitHub raw mirror base — the app reads history and live data directly from here. + /// No private LAN relay fallback ships in the app binary. static let mirrorBase = URL(string: "https://raw.githubusercontent.com/aptonline/fuelboard-data/main")! static let historyCacheKey = "fuelboard.historyCache" static let maxCachedDays = 90 diff --git a/Shared/FuelPriceProvider.swift b/Shared/FuelPriceProvider.swift index 28f38c4..798fedd 100644 --- a/Shared/FuelPriceProvider.swift +++ b/Shared/FuelPriceProvider.swift @@ -19,14 +19,15 @@ protocol FuelPriceProviding { } enum FuelPriceProvider { - /// Default: the live chain — GitHub mirror (primary, off-LAN) → LAN - /// relay (fallback) → bundled dump (last resort in the app target). + /// Default: the live chain — public GitHub mirror (primary, off-LAN) → + /// bundled dump/cache fallback in callers. No private LAN fallback ships in + /// the app binary. static let active: FuelPriceProviding = LiveChainProvider() - /// Decodes a relay payload into stations, applying the defensive price + /// Decodes a mirror payload into stations, applying the defensive price /// band. Internal so the unit-test target can exercise the guard. static func decodeStations(from data: Data) throws -> [FuelStation] { - let payload = try JSONDecoder().decode(RelayResponse.self, from: data) + let payload = try JSONDecoder().decode(MirrorResponse.self, from: data) return payload.stations.map { relay in FuelStation( id: relay.id ?? relay.name ?? UUID().uuidString, @@ -42,13 +43,13 @@ enum FuelPriceProvider { } } - /// Price sanity band (pence/litre) — anything outside is relay regression + /// Price sanity band (pence/litre) — anything outside is mirror regression /// garbage and must never reach calculations (shared by the live decode /// and the history mirror decoder). static let priceBand: ClosedRange = 50...500 - /// Maps relay grade keys (E5/E10/DIESEL…) to FuelType with the defensive - /// band guard. Shared by the relay decode and the history mirror parser so + /// Maps mirror grade keys (E5/E10/DIESEL…) to FuelType with the defensive + /// band guard. Shared by the mirror decode and the history mirror parser so /// both surfaces apply identical sanitisation. static func mapGrades(_ prices: [String: Double]) -> [FuelType: Double] { var result: [FuelType: Double] = [:] @@ -64,12 +65,12 @@ enum FuelPriceProvider { return result } - /// Decodes the relay envelope metadata (source, dataset update time) — the + /// Decodes mirror envelope metadata (source, dataset update time) — the /// About section shows these so the user can see which data source is /// live and how fresh the GOV.UK data itself is. The fields are additive - /// on the relay, so older relays simply return nil values. + /// on the mirror, so older snapshots simply return nil values. static func decodeRelayMeta(from data: Data) -> RelayMeta? { - guard let payload = try? JSONDecoder().decode(RelayResponse.self, from: data) else { + guard let payload = try? JSONDecoder().decode(MirrorResponse.self, from: data) else { return nil } return RelayMeta( @@ -80,64 +81,11 @@ enum FuelPriceProvider { } } -// MARK: - Relay provider (default) +// MARK: - Mirror payload envelope -/// Talks to the FuelBoard Relay (keyless proxy). The relay serves the FULL-UK -/// Fuel Finder dataset — every station in England, Wales, Scotland, NI — and -/// filters/sorts server-side. No credentials in the app. -struct RelayFuelProvider: FuelPriceProviding { - var baseURL = URL(string: "http://192.168.1.131:8789")! - - /// Envelope metadata from the most recent successful relay fetch — - /// consumed by Settings → About (source api/csv, station count, GOV.UK - /// dataset update time). Written by fetchStations, read by the view. - static var latestMeta: RelayMeta? - - /// Admin telemetry: the relay tags inbound connections by client kind - /// (app vs widget) so the developer can monitor the poll model. Siri - /// never fetches (cached-data-first). Purely observational — the relay - /// ignores the header for responses. - static let clientTagHeader = "X-Client" - - static func relayRequest(_ url: URL, client: String, timeout: TimeInterval = 30) -> URLRequest { - var request = URLRequest(url: url) - request.timeoutInterval = timeout - request.setValue(client, forHTTPHeaderField: clientTagHeader) - return request - } - - func fetchStations(near lat: Double?, lng: Double?, fuel: FuelType, radiusKM: Double?) async throws -> [FuelStation] { - var components = URLComponents(url: baseURL.appendingPathComponent("api/v1/stations"), resolvingAgainstBaseURL: false)! - var query: [URLQueryItem] = [URLQueryItem(name: "fuel", value: fuel.rawValue)] - if let lat, let lng { - query.append(URLQueryItem(name: "lat", value: String(lat))) - query.append(URLQueryItem(name: "lng", value: String(lng))) - } - if let radiusKM { - // Focused fetch (alert path) — small response, server-side radius. - query.append(URLQueryItem(name: "radius", value: String(radiusKM))) - query.append(URLQueryItem(name: "limit", value: "500")) - } else { - // Full-UK dump: NO radius — the device filters locally - // (5/10/15-mile radius, fuel, sort). 10,000 covers the dataset. - query.append(URLQueryItem(name: "limit", value: "10000")) - } - components.queryItems = query - - let request = RelayFuelProvider.relayRequest(components.url!, client: "app") - let (data, response) = try await URLSession.shared.data(for: request) - guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { - throw FuelProviderError.relayUnavailable - } - let stations = try FuelPriceProvider.decodeStations(from: data) - Self.latestMeta = FuelPriceProvider.decodeRelayMeta(from: data) - return stations - } -} - -private struct RelayResponse: Codable { +private struct MirrorResponse: Codable { /// Envelope metadata surfaced in Settings → About so the user can see - /// which relay source is live and how fresh the GOV.UK data is. + /// which mirror source is live and how fresh the GOV.UK data is. let source: String? let stationsCount: Int? let dataUpdated: String? @@ -148,7 +96,7 @@ private struct RelayResponse: Codable { case dataUpdated = "data_updated" } - struct RelayStation: Codable { + struct MirrorStation: Codable { let id: String? let name: String? let brand: String? @@ -159,24 +107,24 @@ private struct RelayResponse: Codable { let price: Double? let prices: [String: Double]? - /// Map relay grade keys (E5/E10/DIESEL) to FuelType for ALL fuels the + /// Map mirror grade keys (E5/E10/DIESEL) to FuelType for ALL fuels the /// station sells — one fetch populates every fuel tab. Defensive guard: - /// any grade outside the 50–500 pence/litre band is dropped, so a relay - /// regression (e.g. the band being removed server-side) can't re-poison + /// any grade outside the 50–500 pence/litre band is dropped, so a mirror + /// regression (e.g. the band being removed upstream) can't re-poison /// the nationwide cheapest reference with 1.3p / 1589p garbage. var allPrices: [FuelType: Double] { var result = FuelPriceProvider.mapGrades(prices ?? [:]) - // Backwards-compat: relay versions without `prices` still send `price`. + // Backwards-compat: mirror versions without `prices` still send `price`. if result.isEmpty, let price, FuelPriceProvider.priceBand.contains(price) { result[.e10] = price } return result } } - let stations: [RelayStation] + let stations: [MirrorStation] } -/// Envelope metadata from the relay response — which data source is live +/// Envelope metadata from the mirror response — which data source is live /// (api/csv) and the freshest price-update timestamp the GOV.UK server /// itself reports for the dataset. Displayed in Settings → About. struct RelayMeta: Codable, Equatable { @@ -189,7 +137,7 @@ struct RelayMeta: Codable, Equatable { /// Representative stations spread across ENGLAND (one cluster per region) so /// the fallback works anywhere in the country — it is NOT tied to one town. -/// Real UK-wide data comes from the relay (full-UK Fuel Finder CSV/API). +/// Real UK-wide data comes from the public mirror (full-UK Fuel Finder CSV/API). /// Prices in pence/litre. struct SampleFuelProvider: FuelPriceProviding { func fetchStations(near lat: Double?, lng: Double?, fuel: FuelType, radiusKM: Double?) async throws -> [FuelStation] { @@ -302,7 +250,7 @@ enum FuelProviderError: LocalizedError { case .notImplemented: return "Live Fuel Finder API not wired yet — activate with GOV.UK One Login credentials." case .relayUnavailable: - return "FuelBoard Relay unreachable — showing cached/sample data." + return "FuelBoard live data unavailable — showing cached/sample data." case .mirrorUnavailable: return "GitHub price mirror unreachable." } diff --git a/Shared/FuelStore.swift b/Shared/FuelStore.swift index 4c43d8f..f0ed5bd 100644 --- a/Shared/FuelStore.swift +++ b/Shared/FuelStore.swift @@ -407,7 +407,6 @@ struct FuelStore { static let favouriteAlertSnapshotsKey = "fuelboard.favouriteAlertSnapshots" // [fuel: FavouriteAlertSnapshot] JSON static let liveActivityFollowSearchKey = "fuelboard.liveActivityFollowSearch" // Bool — Live Activity mirrors the Stations-tab distance static let debugModeKey = "fuelboard.debugMode" // Bool — hidden dev flag - static let relayFallbackKey = "fuelboard.relayFallback" // Bool — dev-only relay fallback static let liveActivityKey = "fuelboard.liveActivity" // Bool — Live Activity toggle static let liveActivityFuelKey = "fuelboard.liveActivityFuel" // FuelType raw value static let liveActivityRadiusKey = "fuelboard.liveActivityRadiusMiles" // Int miles (5/10/15) @@ -711,22 +710,6 @@ struct FuelStore { saveString(enabled ? "1" : "0", service: debugModeKey) } - // MARK: Relay fallback (dev-only) - - /// Dev-only switch: re-inserts the LAN relay into the live chain as a - /// fallback for home testing. OFF by default — consumers must never make - /// a local-network attempt (the relay is unreachable off the developer's - /// LAN, and the attempt itself would fire the iOS Local Network prompt). - /// Toggled from Settings → Debug; keychain-first so it survives the - /// delete → reinstall test loop like every other small setting. - static func loadRelayFallbackEnabled() -> Bool { - loadString(service: relayFallbackKey) == "1" - } - - static func saveRelayFallbackEnabled(_ enabled: Bool) { - saveString(enabled ? "1" : "0", service: relayFallbackKey) - } - // MARK: Favourites /// A favourite pins a station FOR ONE fuel type. Starring a row while diff --git a/Shared/MirrorFuelProvider.swift b/Shared/MirrorFuelProvider.swift index 80b6dc5..3ff3410 100644 --- a/Shared/MirrorFuelProvider.swift +++ b/Shared/MirrorFuelProvider.swift @@ -1,28 +1,16 @@ // MirrorFuelProvider.swift — GitHub price-mirror provider (P0 live chain). // -// The mirror (aptonline/fuelboard-data, pushed daily by the LAN relay) is the -// PRODUCTION data source: its day files are the raw relay /api/v1/stations -// envelope, so the shared decode works verbatim. Fetching from -// raw.githubusercontent.com makes live prices work fully off-LAN — App -// Review cannot reach a LAN relay, and consumers never have one to reach — -// and the bundled dump (app target) is the no-network last resort. +// The mirror (aptonline/fuelboard-data, pushed daily by the ingestion relay) +// is the PRODUCTION data source: its day files use the same /api/v1/stations +// envelope, so the shared decoder works verbatim. Fetching from +// raw.githubusercontent.com makes live prices work fully off-LAN — App Review +// cannot reach a private LAN relay, and consumers never have one to reach — +// with the bundled dump (app target) as the no-network last resort. // -// Chain (LiveChainProvider) — GitHub first, EVERYWHERE: -// GitHub mirror → [dev-only LAN relay] → (app target: bundled dump in the -// ContentView catch, widget: cache → placeholder) -// The relay is NOT part of the consumer chain at all: it is unreachable from -// any phone outside the developer's LAN, and even attempting it from a -// consumer phone would fire the iOS Local Network permission prompt for an -// address that can never be reached. It remains reachable only behind the -// hidden dev flag `fuelboard.relayFallback` (Settings → Debug) for home -// testing. The relay keeps its server-side job (GOV.UK ingest + daily mirror -// push) unchanged. -// -// Telemetry (FuelBeacon): raw GitHub fetches are invisible to repo analytics -// (page views/clones only), so every chain fetch opportunistically pings the -// relay's existing widget-diag route (zero relay changes) — but ONLY when the -// same dev flag is on, so consumers never make a local-network attempt. -// /stats app-hit spikes (dev flag on) = GitHub path failing → fallback active. +// Chain (LiveChainProvider) — public HTTPS only: +// GitHub mirror → (app target: bundled dump in the ContentView catch, +// widget: cache → placeholder) +// No local-network fallback or private IP is compiled into the app binary. import Foundation @@ -126,80 +114,24 @@ struct MirrorFuelProvider: FuelPriceProviding { // MARK: - Live chain provider -/// The live price chain — GitHub first, everywhere. The LAN relay is NOT part -/// of the consumer chain: consumers can never reach it (and must never even -/// attempt it — that would prompt for Local Network access on a stranger's -/// phone). It only joins behind the hidden dev flag `fuelboard.relayFallback` -/// (Settings → Debug) for the developer's own home testing. Whichever leg -/// serves is recorded for the About section + telemetry. +/// The live price chain — public GitHub mirror only. No private LAN fallback is +/// compiled into the app binary, so App Store/TestFlight builds cannot trigger +/// Local Network permission or leak a private IP. struct LiveChainProvider: FuelPriceProviding { var mirror: FuelPriceProviding - var relay: FuelPriceProviding - /// Which leg served the last fetch — Settings → About metadata + beacon. + /// Metadata surfaced in Settings → About. static var latestMeta: RelayMeta? static var lastSource: String? - init(mirror: FuelPriceProviding = MirrorFuelProvider(), - relay: FuelPriceProviding = RelayFuelProvider()) { + init(mirror: FuelPriceProviding = MirrorFuelProvider()) { self.mirror = mirror - self.relay = relay } func fetchStations(near lat: Double?, lng: Double?, fuel: FuelType, radiusKM: Double?) async throws -> [FuelStation] { - do { - let stations = try await mirror.fetchStations(near: lat, lng: lng, fuel: fuel, radiusKM: radiusKM) - Self.latestMeta = MirrorFuelProvider.latestMeta - Self.lastSource = "github" - FuelBeacon.fire(source: "github", n: stations.count) - return stations - } catch { - // Dev-only fallback: consumers never reach this branch — the flag - // is off by default and the flag itself lives in the Debug section. - guard FuelStore.loadRelayFallbackEnabled() else { throw error } - let stations = try await relay.fetchStations(near: lat, lng: lng, fuel: fuel, radiusKM: radiusKM) - Self.latestMeta = RelayFuelProvider.latestMeta - Self.lastSource = "relay" - FuelBeacon.fire(source: "relay", n: stations.count) - return stations - } - } -} - -// MARK: - Telemetry beacon - -/// Fire-and-forget attribution for the LIVE chain (see header note). Never -/// awaited, never user-visible; a failed ping is silent. Fires ONLY when the -/// dev relay flag is on — consumers must never make a local-network attempt, -/// and an off-LAN ping failing silently is itself the reachability datum. -/// Disabled in unit tests via `isEnabled`. -enum FuelBeacon { - static var isEnabled = true - - static var shouldFire: Bool { - isEnabled && FuelStore.loadRelayFallbackEnabled() - } - - static func fire(source: String, n: Int, timeout: TimeInterval = 2) { - guard shouldFire, - let url = beaconURL(source: source, n: n) else { return } - var request = RelayFuelProvider.relayRequest(url, client: "app", timeout: timeout) - request.cachePolicy = .reloadIgnoringLocalCacheData - Task { _ = try? await URLSession.shared.data(for: request) } - } - - /// Testable URL construction — hits the relay's existing widget-diag - /// beacon route with app attribution (zero relay changes). - static func beaconURL(source: String, n: Int, base: URL = RelayFuelProvider().baseURL) -> URL? { - guard var components = URLComponents( - url: base.appendingPathComponent("api/v1/widget-diag"), - resolvingAgainstBaseURL: false - ) else { return nil } - components.queryItems = [ - URLQueryItem(name: "intent", value: "app-live"), - URLQueryItem(name: "source", value: source), - URLQueryItem(name: "n", value: String(n)), - ] - return components.url + let stations = try await mirror.fetchStations(near: lat, lng: lng, fuel: fuel, radiusKM: radiusKM) + Self.latestMeta = MirrorFuelProvider.latestMeta + Self.lastSource = "github" + return stations } }