← 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
M apps/mobile/app/(tabs)/browse.tsxM apps/mobile/app/(tabs)/saved.tsxA apps/mobile/components/TrackedWebView.tsxA apps/mobile/device-proof-evidence/FINAL-stills-2026-09-03T22-28-13Z/1-browse.pngA apps/mobile/device-proof-evidence/FINAL-stills-2026-09-03T22-28-13Z/2-saved.pngA apps/mobile/device-proof-evidence/FINAL-stills-2026-09-03T22-28-13Z/3-map.pngA apps/mobile/device-proof-evidence/FINAL-stills-2026-09-03T22-28-13Z/4-alerts.pngA apps/mobile/device-proof-evidence/FINAL-stills-2026-09-03T22-28-13Z/5-browse-return.pngA apps/mobile/device-proof-evidence/homesonspec-DEVICE-journey-FINAL-2026-09-03T22-28-13Z.movA apps/mobile/device-proof-evidence/privacy-sweep-FINAL-2026-09-03T22-28-13Z.jpgM apps/mobile/lib/notifications.tsA apps/mobile/lib/webview-guard.test.mjs
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 →