[object Object]

← back to Homesonspec

Homes on Spec: route every WebView through one hardened TrackedWebView (TK-10387, DTD verdict A)

d98894aa26f95ed16ce047bd45eb3b945c6bd19e · 2026-09-03 20:20:36 -0700 · Steve

The Browse tab was hardened against homesonspec.com's GTM/GA4/Meta-pixel, but
saved.tsx opened a SECOND raw <WebView> to the same site with no injected tracker
block and no URL filter — so trackers ran freely there and an App Store
'Data Not Collected' label would have been FALSE.

The leak happened because hardening was a per-call-site ritual that had to be
remembered. A DTD panel ruled A (complete the blocking) 4/7, and the contrarian's
surviving objection was that A-as-scoped repeats the same failure mode. So this is
structural rather than another copy-paste:

- components/TrackedWebView.tsx is now the ONLY file importing the WebView
  component. It always applies TRACKER_BLOCK and the isTrackerUrl filter. A caller's
  injectedJavaScriptBeforeContentLoaded is APPENDED (never replaces the block), and a
  caller's onShouldStartLoadWithRequest is AND-ed (can restrict, never loosen).
- browse.tsx and saved.tsx both render <TrackedWebView>; browse keeps WebView only
  as a type import for useRef<WebView>.
- lib/webview-guard.test.mjs fails the suite if any file outside the wrapper imports
  the WebView component or renders a raw <WebView>. It caught a real miss on its
  first run.

Also stops handing a device identifier to a third party for a feature that cannot
work: getExpoPushTokenAsync() round-trips to Expo's servers, but the backend
registration POST is still commented out, so no alert can ever be delivered. Token
minting is now gated behind PUSH_BACKEND_READY (false), to be flipped in the same
change that wires the backend. Permission is still requested, so the Alerts toggle
is unchanged.

tsc --noEmit clean; 5/5 tests pass. Local only — no deploy, no submit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QfGYEoLBywwJD1nfrHe1on

Files touched

Diff

commit d98894aa26f95ed16ce047bd45eb3b945c6bd19e
Author: Steve <steve@designerwallcoverings.com>
Date:   Thu Sep 3 20:20:36 2026 -0700

    Homes on Spec: route every WebView through one hardened TrackedWebView (TK-10387, DTD verdict A)
    
    The Browse tab was hardened against homesonspec.com's GTM/GA4/Meta-pixel, but
    saved.tsx opened a SECOND raw <WebView> to the same site with no injected tracker
    block and no URL filter — so trackers ran freely there and an App Store
    'Data Not Collected' label would have been FALSE.
    
    The leak happened because hardening was a per-call-site ritual that had to be
    remembered. A DTD panel ruled A (complete the blocking) 4/7, and the contrarian's
    surviving objection was that A-as-scoped repeats the same failure mode. So this is
    structural rather than another copy-paste:
    
    - components/TrackedWebView.tsx is now the ONLY file importing the WebView
      component. It always applies TRACKER_BLOCK and the isTrackerUrl filter. A caller's
      injectedJavaScriptBeforeContentLoaded is APPENDED (never replaces the block), and a
      caller's onShouldStartLoadWithRequest is AND-ed (can restrict, never loosen).
    - browse.tsx and saved.tsx both render <TrackedWebView>; browse keeps WebView only
      as a type import for useRef<WebView>.
    - lib/webview-guard.test.mjs fails the suite if any file outside the wrapper imports
      the WebView component or renders a raw <WebView>. It caught a real miss on its
      first run.
    
    Also stops handing a device identifier to a third party for a feature that cannot
    work: getExpoPushTokenAsync() round-trips to Expo's servers, but the backend
    registration POST is still commented out, so no alert can ever be delivered. Token
    minting is now gated behind PUSH_BACKEND_READY (false), to be flipped in the same
    change that wires the backend. Permission is still requested, so the Alerts toggle
    is unchanged.
    
    tsc --noEmit clean; 5/5 tests pass. Local only — no deploy, no submit.
    
    Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01QfGYEoLBywwJD1nfrHe1on
---
 apps/mobile/app/(tabs)/browse.tsx                  | 131 +---------------
 apps/mobile/app/(tabs)/saved.tsx                   |   4 +-
 apps/mobile/components/TrackedWebView.tsx          | 169 +++++++++++++++++++++
 .../FINAL-stills-2026-09-03T22-28-13Z/1-browse.png | Bin 0 -> 1890116 bytes
 .../FINAL-stills-2026-09-03T22-28-13Z/2-saved.png  | Bin 0 -> 142021 bytes
 .../FINAL-stills-2026-09-03T22-28-13Z/3-map.png    | Bin 0 -> 1653303 bytes
 .../FINAL-stills-2026-09-03T22-28-13Z/4-alerts.png | Bin 0 -> 300772 bytes
 .../5-browse-return.png                            | Bin 0 -> 1890116 bytes
 ...c-DEVICE-journey-FINAL-2026-09-03T22-28-13Z.mov | Bin 0 -> 1725651 bytes
 .../privacy-sweep-FINAL-2026-09-03T22-28-13Z.jpg   | Bin 0 -> 202337 bytes
 apps/mobile/lib/notifications.ts                   |  27 +++-
 apps/mobile/lib/webview-guard.test.mjs             |  96 ++++++++++++
 12 files changed, 298 insertions(+), 129 deletions(-)

diff --git a/apps/mobile/app/(tabs)/browse.tsx b/apps/mobile/app/(tabs)/browse.tsx
index 0b012ed0..93b42066 100644
--- a/apps/mobile/app/(tabs)/browse.tsx
+++ b/apps/mobile/app/(tabs)/browse.tsx
@@ -22,10 +22,11 @@ import {
   SafeAreaView,
   Alert,
 } from 'react-native';
-import { WebView, WebViewNavigation } from 'react-native-webview';
+import type { WebView, WebViewNavigation } from 'react-native-webview';
+import TrackedWebView from '../../components/TrackedWebView';
 import { saveHome } from '../../lib/storage';
 import { WEB_BASE_URL } from '../../lib/api';
-import { isTrackerUrl, TRACKER_HOSTS } from '../../lib/tracker-policy';
+
 
 const BRAND = '#1a3a6e';
 const ACCENT = '#e07b39';
@@ -35,127 +36,6 @@ const ACCENT = '#e07b39';
  * Kept in sync between the JS-level interceptor (below) and the native
  * onShouldStartLoadWithRequest filter.
  */
-/**
- * Runs BEFORE any page script. Neutralizes analytics/ad trackers two ways so
- * the app genuinely transmits nothing:
- *   1. Stub the JS APIs (gtag / dataLayer / fbq / google_tag_manager) into no-ops.
- *   2. Intercept every network primitive WKWebView exposes to page JS —
- *      script injection (createElement), Image()/img.src pixels (the Facebook
- *      <noscript> <img> fallback path too), fetch, XMLHttpRequest, and
- *      navigator.sendBeacon (GA4's default transport) — and drop any request
- *      whose URL matches a tracker host.
- * onShouldStartLoadWithRequest handles main-frame/navigation-level blocks; this
- * handles the sub-resource requests WKWebView never surfaces to native.
- */
-const TRACKER_BLOCK = `
-(function() {
-  try {
-    var HOSTS = ${JSON.stringify(TRACKER_HOSTS)};
-    function blocked(u) {
-      try {
-        var hostname = new URL(String(u), window.location.href).hostname.toLowerCase();
-        for (var i = 0; i < HOSTS.length; i++) {
-          if (hostname === HOSTS[i] || hostname.slice(-(HOSTS[i].length + 1)) === '.' + HOSTS[i]) return true;
-        }
-      } catch (e) {}
-      return false;
-    }
-
-    // 1. Stub tracker JS APIs
-    var noop = function() {};
-    window.dataLayer = window.dataLayer || [];
-    window.dataLayer.push = noop;
-    window.gtag = noop;
-    window.ga = noop;
-    window.google_tag_manager = {};
-    window.fbq = function() {}; window.fbq.queue = []; window.fbq.loaded = true;
-    window._fbq = window.fbq;
-
-    // 2a. Block <script>/<img>/<iframe> pointed at a tracker host
-    var _createElement = document.createElement.bind(document);
-    document.createElement = function(tag) {
-      var el = _createElement(tag);
-      var t = String(tag || '').toLowerCase();
-      if (t === 'script' || t === 'img' || t === 'iframe') {
-        try {
-          var proto = Object.getPrototypeOf(el);
-          var desc = Object.getOwnPropertyDescriptor(proto, 'src')
-                  || Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'src');
-          if (desc && desc.set) {
-            Object.defineProperty(el, 'src', {
-              configurable: true,
-              get: function() { return desc.get ? desc.get.call(el) : ''; },
-              set: function(v) { if (!blocked(v)) desc.set.call(el, v); }
-            });
-          }
-        } catch (e) {}
-      }
-      return el;
-    };
-
-    // 2b. Block Image()/new Image().src pixels (Facebook tr pixel, GA hits)
-    var _Image = window.Image;
-    if (_Image) {
-      window.Image = function() {
-        var img = new _Image();
-        try {
-          var d = Object.getOwnPropertyDescriptor(HTMLImageElement.prototype, 'src');
-          if (d && d.set) {
-            Object.defineProperty(img, 'src', {
-              configurable: true,
-              get: function() { return d.get.call(img); },
-              set: function(v) { if (!blocked(v)) d.set.call(img, v); }
-            });
-          }
-        } catch (e) {}
-        return img;
-      };
-    }
-
-    // 2c. Block fetch
-    var _fetch = window.fetch;
-    if (_fetch) {
-      window.fetch = function(input) {
-        var url = (input && input.url) ? input.url : input;
-        if (blocked(url)) return Promise.resolve(new Response('', { status: 204 }));
-        return _fetch.apply(this, arguments);
-      };
-    }
-
-    // 2d. Block XHR
-    var _open = XMLHttpRequest.prototype.open;
-    XMLHttpRequest.prototype.open = function(method, url) {
-      this.__blocked = blocked(url);
-      return _open.apply(this, arguments);
-    };
-    var _send = XMLHttpRequest.prototype.send;
-    XMLHttpRequest.prototype.send = function() { if (this.__blocked) return; return _send.apply(this, arguments); };
-
-    // 2e. Block sendBeacon (GA4 default transport)
-    if (navigator.sendBeacon) {
-      var _beacon = navigator.sendBeacon.bind(navigator);
-      navigator.sendBeacon = function(url) { if (blocked(url)) return false; return _beacon.apply(this, arguments); };
-    }
-
-    // 2f. Strip any tracker <img>/<script> that slips into the DOM (e.g. <noscript> fallback)
-    try {
-      var obs = new MutationObserver(function(muts) {
-        muts.forEach(function(m) {
-          for (var i = 0; i < m.addedNodes.length; i++) {
-            var n = m.addedNodes[i];
-            if (n && n.tagName && (n.tagName === 'IMG' || n.tagName === 'SCRIPT' || n.tagName === 'IFRAME')) {
-              var s = n.getAttribute && n.getAttribute('src');
-              if (s && blocked(s)) { n.setAttribute('src', ''); if (n.parentNode) n.parentNode.removeChild(n); }
-            }
-          }
-        });
-      });
-      obs.observe(document.documentElement || document, { childList: true, subtree: true });
-    } catch (e) {}
-  } catch (e) {}
-  true;
-})();
-`;
 
 /**
  * CSS injected after every page load to hide the site's sticky header/nav.
@@ -285,7 +165,7 @@ export default function BrowseTab() {
 
   return (
     <View style={styles.container}>
-      <WebView
+      <TrackedWebView
         ref={webViewRef}
         source={{ uri: WEB_BASE_URL }}
         style={styles.webview}
@@ -301,9 +181,8 @@ export default function BrowseTab() {
           }
         }}
         injectedJavaScript={INJECTED_CSS}
-        injectedJavaScriptBeforeContentLoaded={TRACKER_BLOCK + INJECTED_CSS}
+        injectedJavaScriptBeforeContentLoaded={INJECTED_CSS}
         onNavigationStateChange={handleNavChange}
-        onShouldStartLoadWithRequest={(req) => !isTrackerUrl(req.url)}
         onMessage={handleMessage}
         // Allow mixed content for any embedded widgets
         mixedContentMode="compatibility"
diff --git a/apps/mobile/app/(tabs)/saved.tsx b/apps/mobile/app/(tabs)/saved.tsx
index a58fe760..076f9383 100644
--- a/apps/mobile/app/(tabs)/saved.tsx
+++ b/apps/mobile/app/(tabs)/saved.tsx
@@ -24,7 +24,7 @@ import {
   ActivityIndicator,
 } from 'react-native';
 import { useFocusEffect } from 'expo-router';
-import { WebView } from 'react-native-webview';
+import TrackedWebView from '../../components/TrackedWebView';
 import {
   getSavedHomes,
   enrichSavedHomes,
@@ -222,7 +222,7 @@ export default function SavedTab() {
           </View>
 
           {activeHome && (
-            <WebView
+            <TrackedWebView
               source={{ uri: activeHome.url }}
               onLoadStart={() => setModalLoading(true)}
               onLoadEnd={() => setModalLoading(false)}
diff --git a/apps/mobile/components/TrackedWebView.tsx b/apps/mobile/components/TrackedWebView.tsx
new file mode 100644
index 00000000..3a6d187e
--- /dev/null
+++ b/apps/mobile/components/TrackedWebView.tsx
@@ -0,0 +1,169 @@
+/**
+ * TrackedWebView — the ONLY sanctioned way to render a WebView in this app.
+ *
+ * WHY THIS EXISTS (TK-10387, DTD verdict A, 2026-09-03):
+ * homesonspec.com serves Google Tag Manager, GA4 and the Meta pixel. The Browse
+ * tab had been hardened against those, but the Saved tab opened a SECOND, raw
+ * <WebView> to the same site with none of the protection — so trackers ran freely
+ * there and the App Store "Data Not Collected" privacy label would have been FALSE.
+ *
+ * The leak happened because the hardening had to be REMEMBERED at every call site.
+ * A per-call-site invariant enforced only by developer memory fails exactly once
+ * per new WebView. So the fix is structural, not another copy-paste: this component
+ * bakes the protection in, and `lib/webview-guard.test.mjs` fails the test suite if
+ * any other file imports `react-native-webview` directly.
+ *
+ * Callers may still pass `injectedJavaScriptBeforeContentLoaded` for their own
+ * scripts — it is APPENDED after the tracker block, never replacing it — and may
+ * pass their own `onShouldStartLoadWithRequest`, which is AND-ed with the tracker
+ * filter so a caller can further restrict but never loosen it.
+ */
+import { WebView, WebViewProps } from 'react-native-webview';
+import { forwardRef } from 'react';
+import { isTrackerUrl, TRACKER_HOSTS } from '../lib/tracker-policy';
+
+/**
+ * Runs BEFORE any page script. Neutralizes analytics/ad trackers two ways so
+ * the app genuinely transmits nothing:
+ *   1. Stub the JS APIs (gtag / dataLayer / fbq / google_tag_manager) into no-ops.
+ *   2. Intercept every network primitive WKWebView exposes to page JS —
+ *      script injection (createElement), Image()/img.src pixels (the Facebook
+ *      <noscript> <img> fallback path too), fetch, XMLHttpRequest, and
+ *      navigator.sendBeacon (GA4's default transport) — and drop any request
+ *      whose URL matches a tracker host.
+ * onShouldStartLoadWithRequest handles main-frame/navigation-level blocks; this
+ * handles the sub-resource requests WKWebView never surfaces to native.
+ */
+const TRACKER_BLOCK = `
+(function() {
+  try {
+    var HOSTS = ${JSON.stringify(TRACKER_HOSTS)};
+    function blocked(u) {
+      try {
+        var hostname = new URL(String(u), window.location.href).hostname.toLowerCase();
+        for (var i = 0; i < HOSTS.length; i++) {
+          if (hostname === HOSTS[i] || hostname.slice(-(HOSTS[i].length + 1)) === '.' + HOSTS[i]) return true;
+        }
+      } catch (e) {}
+      return false;
+    }
+
+    // 1. Stub tracker JS APIs
+    var noop = function() {};
+    window.dataLayer = window.dataLayer || [];
+    window.dataLayer.push = noop;
+    window.gtag = noop;
+    window.ga = noop;
+    window.google_tag_manager = {};
+    window.fbq = function() {}; window.fbq.queue = []; window.fbq.loaded = true;
+    window._fbq = window.fbq;
+
+    // 2a. Block <script>/<img>/<iframe> pointed at a tracker host
+    var _createElement = document.createElement.bind(document);
+    document.createElement = function(tag) {
+      var el = _createElement(tag);
+      var t = String(tag || '').toLowerCase();
+      if (t === 'script' || t === 'img' || t === 'iframe') {
+        try {
+          var proto = Object.getPrototypeOf(el);
+          var desc = Object.getOwnPropertyDescriptor(proto, 'src')
+                  || Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'src');
+          if (desc && desc.set) {
+            Object.defineProperty(el, 'src', {
+              configurable: true,
+              get: function() { return desc.get ? desc.get.call(el) : ''; },
+              set: function(v) { if (!blocked(v)) desc.set.call(el, v); }
+            });
+          }
+        } catch (e) {}
+      }
+      return el;
+    };
+
+    // 2b. Block Image()/new Image().src pixels (Facebook tr pixel, GA hits)
+    var _Image = window.Image;
+    if (_Image) {
+      window.Image = function() {
+        var img = new _Image();
+        try {
+          var d = Object.getOwnPropertyDescriptor(HTMLImageElement.prototype, 'src');
+          if (d && d.set) {
+            Object.defineProperty(img, 'src', {
+              configurable: true,
+              get: function() { return d.get.call(img); },
+              set: function(v) { if (!blocked(v)) d.set.call(img, v); }
+            });
+          }
+        } catch (e) {}
+        return img;
+      };
+    }
+
+    // 2c. Block fetch
+    var _fetch = window.fetch;
+    if (_fetch) {
+      window.fetch = function(input) {
+        var url = (input && input.url) ? input.url : input;
+        if (blocked(url)) return Promise.resolve(new Response('', { status: 204 }));
+        return _fetch.apply(this, arguments);
+      };
+    }
+
+    // 2d. Block XHR
+    var _open = XMLHttpRequest.prototype.open;
+    XMLHttpRequest.prototype.open = function(method, url) {
+      this.__blocked = blocked(url);
+      return _open.apply(this, arguments);
+    };
+    var _send = XMLHttpRequest.prototype.send;
+    XMLHttpRequest.prototype.send = function() { if (this.__blocked) return; return _send.apply(this, arguments); };
+
+    // 2e. Block sendBeacon (GA4 default transport)
+    if (navigator.sendBeacon) {
+      var _beacon = navigator.sendBeacon.bind(navigator);
+      navigator.sendBeacon = function(url) { if (blocked(url)) return false; return _beacon.apply(this, arguments); };
+    }
+
+    // 2f. Strip any tracker <img>/<script> that slips into the DOM (e.g. <noscript> fallback)
+    try {
+      var obs = new MutationObserver(function(muts) {
+        muts.forEach(function(m) {
+          for (var i = 0; i < m.addedNodes.length; i++) {
+            var n = m.addedNodes[i];
+            if (n && n.tagName && (n.tagName === 'IMG' || n.tagName === 'SCRIPT' || n.tagName === 'IFRAME')) {
+              var s = n.getAttribute && n.getAttribute('src');
+              if (s && blocked(s)) { n.setAttribute('src', ''); if (n.parentNode) n.parentNode.removeChild(n); }
+            }
+          }
+        });
+      });
+      obs.observe(document.documentElement || document, { childList: true, subtree: true });
+    } catch (e) {}
+  } catch (e) {}
+  true;
+})();
+`;
+export type TrackedWebViewProps = WebViewProps;
+
+const TrackedWebView = forwardRef<WebView, TrackedWebViewProps>(function TrackedWebView(
+  { injectedJavaScriptBeforeContentLoaded, onShouldStartLoadWithRequest, ...rest },
+  ref,
+) {
+  return (
+    <WebView
+      ref={ref}
+      {...rest}
+      // Tracker block always runs FIRST; a caller's script is appended, not swapped in.
+      injectedJavaScriptBeforeContentLoaded={
+        TRACKER_BLOCK + (injectedJavaScriptBeforeContentLoaded ?? '')
+      }
+      // Tracker filter is AND-ed with any caller filter: callers can restrict further,
+      // never loosen.
+      onShouldStartLoadWithRequest={(req) =>
+        !isTrackerUrl(req.url) && (onShouldStartLoadWithRequest?.(req) ?? true)
+      }
+    />
+  );
+});
+
+export default TrackedWebView;
diff --git a/apps/mobile/device-proof-evidence/FINAL-stills-2026-09-03T22-28-13Z/1-browse.png b/apps/mobile/device-proof-evidence/FINAL-stills-2026-09-03T22-28-13Z/1-browse.png
new file mode 100644
index 00000000..e956b99d
Binary files /dev/null and b/apps/mobile/device-proof-evidence/FINAL-stills-2026-09-03T22-28-13Z/1-browse.png differ
diff --git a/apps/mobile/device-proof-evidence/FINAL-stills-2026-09-03T22-28-13Z/2-saved.png b/apps/mobile/device-proof-evidence/FINAL-stills-2026-09-03T22-28-13Z/2-saved.png
new file mode 100644
index 00000000..d0ce2ce8
Binary files /dev/null and b/apps/mobile/device-proof-evidence/FINAL-stills-2026-09-03T22-28-13Z/2-saved.png differ
diff --git a/apps/mobile/device-proof-evidence/FINAL-stills-2026-09-03T22-28-13Z/3-map.png b/apps/mobile/device-proof-evidence/FINAL-stills-2026-09-03T22-28-13Z/3-map.png
new file mode 100644
index 00000000..f3d20487
Binary files /dev/null and b/apps/mobile/device-proof-evidence/FINAL-stills-2026-09-03T22-28-13Z/3-map.png differ
diff --git a/apps/mobile/device-proof-evidence/FINAL-stills-2026-09-03T22-28-13Z/4-alerts.png b/apps/mobile/device-proof-evidence/FINAL-stills-2026-09-03T22-28-13Z/4-alerts.png
new file mode 100644
index 00000000..4ab1a4d7
Binary files /dev/null and b/apps/mobile/device-proof-evidence/FINAL-stills-2026-09-03T22-28-13Z/4-alerts.png differ
diff --git a/apps/mobile/device-proof-evidence/FINAL-stills-2026-09-03T22-28-13Z/5-browse-return.png b/apps/mobile/device-proof-evidence/FINAL-stills-2026-09-03T22-28-13Z/5-browse-return.png
new file mode 100644
index 00000000..e956b99d
Binary files /dev/null and b/apps/mobile/device-proof-evidence/FINAL-stills-2026-09-03T22-28-13Z/5-browse-return.png differ
diff --git a/apps/mobile/device-proof-evidence/homesonspec-DEVICE-journey-FINAL-2026-09-03T22-28-13Z.mov b/apps/mobile/device-proof-evidence/homesonspec-DEVICE-journey-FINAL-2026-09-03T22-28-13Z.mov
new file mode 100644
index 00000000..b3fdaa5a
Binary files /dev/null and b/apps/mobile/device-proof-evidence/homesonspec-DEVICE-journey-FINAL-2026-09-03T22-28-13Z.mov differ
diff --git a/apps/mobile/device-proof-evidence/privacy-sweep-FINAL-2026-09-03T22-28-13Z.jpg b/apps/mobile/device-proof-evidence/privacy-sweep-FINAL-2026-09-03T22-28-13Z.jpg
new file mode 100644
index 00000000..91c88b9f
Binary files /dev/null and b/apps/mobile/device-proof-evidence/privacy-sweep-FINAL-2026-09-03T22-28-13Z.jpg differ
diff --git a/apps/mobile/lib/notifications.ts b/apps/mobile/lib/notifications.ts
index 5570c133..d666a9d0 100644
--- a/apps/mobile/lib/notifications.ts
+++ b/apps/mobile/lib/notifications.ts
@@ -3,7 +3,11 @@
  *
  * Registers for push permissions via expo-notifications.
  * Does NOT send any notifications — this is opt-in scaffolding only.
- * The push token is stored locally for future server-side wiring.
+ *
+ * The push TOKEN is deliberately not minted while PUSH_BACKEND_READY is false:
+ * getExpoPushTokenAsync() transmits a device identifier to Expo, and until the
+ * backend registration exists that is a third-party data flow bought for a
+ * feature that cannot deliver anything. See PUSH_BACKEND_READY below.
  */
 
 import * as Notifications from 'expo-notifications';
@@ -13,6 +17,22 @@ import { Platform } from 'react-native';
 
 const EAS_PROJECT_ID = Constants.expoConfig?.extra?.eas?.projectId ?? '';
 
+/**
+ * Is there a server that can actually SEND a push yet?
+ *
+ * No. The backend registration call below is still commented out, so no token
+ * ever reaches homesonspec.com and no alert can ever be delivered. Minting a
+ * token anyway is not harmless: `getExpoPushTokenAsync()` round-trips to Expo's
+ * servers, so a THIRD PARTY receives a device push token — a device identifier —
+ * for a feature that cannot function. That is both a privacy-label problem
+ * ("Data Not Collected" would be false) and a Guideline 2.1 completeness problem
+ * (the Alerts tab promises alerts a reviewer will never receive).
+ *
+ * So: do not mint the token until the backend exists. Flip this to true in the
+ * SAME change that uncomments the POST below — never before. (TK-10387)
+ */
+const PUSH_BACKEND_READY = false;
+
 const PUSH_TOKEN_KEY = '@homesonspec/push_token_v1';
 const ALERTS_ENABLED_KEY = '@homesonspec/alerts_enabled_v1';
 
@@ -50,6 +70,11 @@ export async function registerForPushNotifications(): Promise<string | null> {
 
   if (!EAS_PROJECT_ID) return null;
 
+  // Nothing can send a push yet, so do not hand a device identifier to Expo for
+  // a feature that cannot work. Permission is still requested above, which is
+  // what the Alerts toggle reflects; only the token round-trip is withheld.
+  if (!PUSH_BACKEND_READY) return null;
+
   try {
     const tokenData = await Notifications.getExpoPushTokenAsync({
       projectId: EAS_PROJECT_ID,
diff --git a/apps/mobile/lib/webview-guard.test.mjs b/apps/mobile/lib/webview-guard.test.mjs
new file mode 100644
index 00000000..cc5e7093
--- /dev/null
+++ b/apps/mobile/lib/webview-guard.test.mjs
@@ -0,0 +1,96 @@
+/**
+ * Structural guard for the WebView tracker-blocking invariant (TK-10387).
+ *
+ * The Saved-tab tracker leak existed because hardening a WebView was a per-call-site
+ * ritual: `browse.tsx` remembered the injected tracker block and the URL filter,
+ * `saved.tsx` did not, and nothing caught the difference. An invariant enforced only
+ * by developer memory fails exactly once per new WebView.
+ *
+ * So the invariant is enforced here instead: `components/TrackedWebView.tsx` is the
+ * only file allowed to import the WebView COMPONENT. Everyone else must go through
+ * the wrapper, which bakes in the tracker block and the request filter. Type-only
+ * imports (e.g. `import type { WebViewNavigation }`) are fine — a type cannot render
+ * an unprotected WebView.
+ *
+ * If this test fails, do NOT add the props by hand at the new call site. Use
+ * <TrackedWebView>. That is the whole point.
+ */
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { readdirSync, readFileSync, statSync } from 'node:fs';
+import { join } from 'node:path';
+
+const ROOT = new URL('..', import.meta.url).pathname;
+const SCAN_DIRS = ['app', 'components', 'lib'];
+const WRAPPER = 'components/TrackedWebView.tsx';
+
+function walk(dir, out = []) {
+  let entries;
+  try {
+    entries = readdirSync(join(ROOT, dir));
+  } catch {
+    return out;
+  }
+  for (const name of entries) {
+    if (name === 'node_modules' || name.startsWith('.')) continue;
+    const rel = `${dir}/${name}`;
+    if (statSync(join(ROOT, rel)).isDirectory()) walk(rel, out);
+    else if (/\.(ts|tsx|js|jsx)$/.test(name)) out.push(rel);
+  }
+  return out;
+}
+
+const files = SCAN_DIRS.flatMap((d) => walk(d));
+
+test('the wrapper exists and is the one place WebView is imported', () => {
+  assert.ok(files.includes(WRAPPER), `${WRAPPER} is missing — the guard has nothing to protect`);
+  const src = readFileSync(join(ROOT, WRAPPER), 'utf8');
+  assert.match(src, /from 'react-native-webview'/, 'wrapper should import the real WebView');
+  assert.match(src, /TRACKER_BLOCK/, 'wrapper must carry the tracker-block script');
+  assert.match(src, /isTrackerUrl/, 'wrapper must apply the tracker URL filter');
+});
+
+test('no file outside the wrapper imports the WebView component', () => {
+  const offenders = [];
+
+  for (const rel of files) {
+    if (rel === WRAPPER) continue;
+    const src = readFileSync(join(ROOT, rel), 'utf8');
+
+    for (const m of src.matchAll(/import\s+(type\s+)?\{([^}]*)\}\s+from\s+'react-native-webview'/g)) {
+      const isTypeOnly = Boolean(m[1]);
+      const named = m[2].split(',').map((x) => x.trim()).filter(Boolean);
+      // `import type { ... }` can never render anything.
+      if (isTypeOnly) continue;
+      // A value import of the WebView component itself is the violation.
+      const bad = named.filter((n) => /^WebView\b/.test(n) && !n.startsWith('type '));
+      if (bad.length) offenders.push(`${rel}: imports ${bad.join(', ')}`);
+    }
+
+    // default / namespace imports of the module are violations too
+    if (/import\s+(?!type\b)[A-Za-z_$][\w$]*\s*(,|\s+from)\s*.*'react-native-webview'/.test(src)) {
+      offenders.push(`${rel}: default or namespace import of react-native-webview`);
+    }
+  }
+
+  assert.deepEqual(
+    offenders,
+    [],
+    `Raw WebView usage found outside ${WRAPPER}. Use <TrackedWebView> instead — it applies the ` +
+      `tracker block and URL filter automatically:\n  ${offenders.join('\n  ')}`,
+  );
+});
+
+test('every WebView element rendered in app/ is a TrackedWebView', () => {
+  const offenders = [];
+  for (const rel of files) {
+    if (rel === WRAPPER) continue;
+    const src = readFileSync(join(ROOT, rel), 'utf8');
+    // A JSX element `<WebView ...>` — but NOT a generic type argument like
+    // `useRef<WebView>(null)`, where `<` is preceded by an identifier character.
+    // Requiring a non-identifier char before `<` cleanly separates the two, and
+    // `<TrackedWebView` never contains the literal `<WebView`.
+    if (/(^|[\s(){}])<WebView[\s/>]/m.test(src)) offenders.push(rel);
+  }
+  assert.deepEqual(offenders, [], `Raw <WebView> element rendered outside the wrapper: ${offenders.join(', ')}`);
+});

← 92f06c15 mobile: make Alerts an inert coming-soon state — disable the  ·  back to Homesonspec  ·  Homes on Spec: correct stale README claims that misled a dow 2726cf35 →