--- phase: 07-hardening-private-launch plan: 03 type: execute wave: 1 depends_on: [] files_modified: - scripts/privacy-audit.mjs - scripts/lib/source-gates.mjs - scripts/lib/bundle-gates.mjs - scripts/lib/cloudflare-probes.mjs - package.json - docs/PRIVACY-AUDIT.md autonomous: true requirements: - PX-04 must_have_notes: - "This plan re-verifies PRIV-02 and GEO-03 end-to-end (D-01). PX-04 is claimed here because SC3 is a Phase-7 success criterion under the PX-04 phase." must_haves: truths: - "No production source tree in the repo contains a persistence API outside the platform allowlist — the memory-only client law and the TTL-only Worker law, extended repo-wide." - "Every Worker KV write carries an expirationTtl that references a NAMED constant — no TTL-free write path can ship." - "No log statement can carry a coordinate, a chat id, or a route payload — the category-only vocabulary is machine-verified." - "The production bundle contains no analytics, no beacon, and no fetch to any host outside the positive allowlist (OpenFreeMap tiles, the Telegram API, the app's own Worker)." - "A live KV probe shows every live key family is one of session:/chat:/loc:/guidance: and every key carries an expiration." - "The audit's exit codes distinguish PASS (0), FAIL (1) and NOT-RUN / missing environment (2) — a skipped live probe is never mistaken for a pass." artifacts: - path: "scripts/privacy-audit.mjs" provides: "The consolidated audit CLI (npm run privacy-audit) — orchestration, evidence report, exit codes" contains: "PRIVACY AUDIT" - path: "scripts/lib/source-gates.mjs" provides: "Comment-stripped source scans over ALL src trees — storage, TTL, log vocabulary, runtime fetch hosts" contains: "stripComments" - path: "scripts/lib/bundle-gates.mjs" provides: "Production bundle scans — analytics/beacon tokens + the positive fetch-host allowlist" contains: "ALLOWED_FETCH_HOSTS" - path: "scripts/lib/cloudflare-probes.mjs" provides: "The live KV listing probe (key families + expiration metadata) and the live header probe" contains: "list_complete" - path: "docs/PRIVACY-AUDIT.md" provides: "The signed written audit record citing the evidence (D-01)" contains: "PRIVACY AUDIT RECORD" key_links: - from: "scripts/privacy-audit.mjs" to: "scripts/lib/source-gates.mjs" via: "runStorageGate / runCoordinateLogGate / runRuntimeHostGate — each returns {id, pass, evidence}" pattern: "source-gates\\.mjs" - from: "scripts/privacy-audit.mjs" to: "scripts/lib/bundle-gates.mjs" via: "runBundleTelemetryGate — scans web/dist (PRODUCTION only)" pattern: "bundle-gates\\.mjs" - from: "scripts/privacy-audit.mjs" to: "scripts/lib/cloudflare-probes.mjs" via: "runKvExpiryProbe — only under the --live flag; missing credentials => exit 2" pattern: "cloudflare-probes\\.mjs" - from: "package.json" to: "scripts/privacy-audit.mjs" via: "the root script \"privacy-audit\": \"node scripts/privacy-audit.mjs\"" pattern: "privacy-audit" --- Ship the ONE consolidated privacy-audit tool the repo can run before every fork's first driver, and the signed written audit record that cites its evidence. Purpose: D-01 — PRIV-02 and GEO-03 are re-verified END-TO-END, not asserted: source scans over every `src` tree, a production-bundle scan for telemetry, a live KV listing probe that reads the expiry metadata, and a signed record a reviewer (or a fork) can rerun. D-02 — v1 carries ZERO telemetry, so there is nothing to opt into; the audit proves the absence rather than auditing a consent surface that does not exist. Output: `scripts/privacy-audit.mjs` + `scripts/lib/{source-gates,bundle-gates,cloudflare-probes}.mjs` (new), the root `privacy-audit` npm script, and `docs/PRIVACY-AUDIT.md` (the signed record). @$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md @$HOME/.config/opencode/get-shit-done/templates/summary.md @.planning/PROJECT.md @.planning/ROADMAP.md @.planning/phases/07-hardening-private-launch/07-CONTEXT.md @.planning/phases/07-hardening-private-launch/07-RESEARCH.md @.planning/phases/07-hardening-private-launch/07-PATTERNS.md The comment-stripped source scan — copy this EXACTLY from `web/test/no-storage.test.mjs` lines 29–42 (the canonical twin also lives in `bot/test/route-privacy.test.mjs` lines 42–59): ```javascript function listFiles(dir, exts, acc = []) { for (const entry of readdirSync(dir)) { const full = path.join(dir, entry); if (statSync(full).isDirectory()) listFiles(full, exts, acc); else if (exts.some((e) => entry.endsWith(e))) acc.push(full); } return acc; } function stripComments(src) { return src .replace(/\/\*[\s\S]*?\*\//g, " ") // block comments .replace(/(^|[^:\\])\/\/[^\n]*/g, "$1"); // line comments (not URLs inside strings — benign over-stripping, gate is prefer-fail reading) } ``` The banned persistence vocabulary — from `web/test/no-storage.test.mjs` lines 22–25: ```javascript const BANNED = [ "localStorage", "sessionStorage", "indexedDB", "caches", "CacheStorage", "document.cookie", "WebApp.DeviceStorage", "WebApp.SecureStorage", "WebApp.CloudStorage", ]; ``` The KV write-TTL law — from `bot/test/privacy.test.mjs` lines 32–49 (PG1): ```javascript lines.forEach((line, i) => { if (line.includes(".put(")) { assert.ok(line.includes("expirationTtl"), `${file}:${i + 1} — KV write without expirationTtl (PRIV-02 gate): ${line.trim()}`); const ttl = line.match(/expirationTtl:\s*([A-Za-z0-9_]+)/)?.[1]; assert.ok(ttl && /^[A-Z_]{2,}$/.test(ttl), `${file}:${i + 1} — expirationTtl must reference a named TTL constant, not an inline literal`); } }); ``` The CLI exit-code contract — from `theme/scripts/audit.mjs` lines 20–24 and 797–814: ```text * Exit codes (no silent passes — T-1-07): * 0 clean — every checked declaration derives from tokens * 1 violations — each printed as file:line: declaration * 2 drift/error — token sheet degraded (see message) ``` The gate-orchestration shape — from 07-RESEARCH.md §Pattern 4: ```javascript const results = [ await runStorageGate(), await runCoordinateLogGate(), await runRuntimeHostGate(), await runBundleTelemetryGate(), await runKvExpiryProbe(), ]; for (const result of results) { console.log(`${result.id}: ${result.pass ? "PASS" : "FAIL"} — ${result.evidence}`); } process.exit(results.every((r) => r.pass) ? 0 : 1); ``` The four KV key families and their TTL constants (from `bot/src/kv/*.ts`): | Family | Prefix | TTL constant | TTL | |--------|---------|--------------|-----| | session | `session:` | `SESSION_TTL_SECONDS` | 900 | | chat pointer | `chat:` | `SESSION_TTL_SECONDS` | 900 | | location pin | `loc:` | `PIN_TTL_SECONDS` | 300 | | guidance envelope | `guidance:` | `GUIDANCE_SESSION_TTL_SECONDS` | 7200 | The category-only log vocabulary (from `bot/src/webhook.ts` lines 63–68) — every `console.log` in `bot/src` has the shape `[surface] verdict: category`, where `surface` is one of `webhook`, `initdata`, `location`, `location-fallback`, `guidance`, `guidance-cron`, `route`, `search`, `search-alternate`. The web-side state-token vocabulary (from `web/src/main.ts` and `web/src/route/route.ts`) — every `console.log` in `web/src` is either a `ARAHARAH-*` state token (`ARAHARAH-DEST state=…`, `ARAHARAH-LIVE fix=… acc=…`, `ARAHARAH-ROUTE state=…`, `ARAHARAH-GUIDE state=…`, `ARAHARAH-DRIVE state=…`), the `RAMS-BASEMAP-READY layers=…` signal, or a `console.error` carrying an error object. The positive runtime fetch-host allowlist (D-01) — the ONLY hosts the shipped bundle may reach: | Host | Why | |------|-----| | `tiles.openfreemap.org` | the basemap tiles / sprites / glyphs (`theme/basemap/liberty-rams.json`) | | `telegram.org` | the official Telegram WebApp SDK script tag in `web/index.html` | | the app's own Worker origin | `/api/session`, `/api/location`, `/api/location-fallback`, `/api/guidance` — a RELATIVE path, not a host | task 1: the source-gates library — storage, TTL, log vocabulary, runtime fetch hosts (ALL src trees) scripts/lib/source-gates.mjs - web/test/no-storage.test.mjs (the canonical comment-stripper + the BANNED list) - bot/test/privacy.test.mjs (the PG1 same-line expirationTtl law) - bot/test/route-privacy.test.mjs (the host-ban + import-allowlist style) - .planning/phases/07-hardening-private-launch/07-PATTERNS.md §`scripts/privacy-audit.mjs` + §Shared Patterns Create `scripts/lib/source-gates.mjs` — the repo-local source scanner (a MAINTAINER-TIME REPO TOOL — nothing in `bot/src`, `web/src`, `routing/src`, or `search/src` imports it; its header comment must say so, copying the law from `routing/scripts/measure-route-cpu.mjs` lines 3–9). It MUST export these named functions, each returning `{ id, pass, evidence }` (the RESEARCH Pattern 4 shape): ```javascript export function runStorageGate(); // id: "storage-api" export function runKvTtlGate(); // id: "kv-ttl" export function runLogVocabularyGate(); // id: "log-vocabulary" export function runRuntimeHostGate(); // id: "runtime-hosts" ``` **`runStorageGate()`** — scans EVERY production source tree: `web/src`, `bot/src`, `routing/src`, `search/src`, `theme/src`. For each `.ts`/`.tsx`/ `.js`/`.mjs`/`.html` file, strip comments with the EXACT stripper above, then assert the file contains NONE of the `BANNED` persistence tokens. Also assert no file contains `serviceWorker.register`, `navigator.serviceWorker`, or `workbox`. The evidence string must name the file count scanned and every violation as `relative-path: token`. **`runKvTtlGate()`** — scans EVERY file under `bot/src` (and any other tree containing `.put(`). For each line containing `.put(`, assert the SAME LINE contains `expirationTtl` and that the captured value matches `/^[A-Z_]{2,}$/` (a NAMED constant, never an inline literal). The evidence string must name each violation as `relative-path:line — KV write without a named expirationTtl`. **`runLogVocabularyGate()`** — the category-only vocabulary. For every `console.log(` call site in `bot/src` and `web/src`: - In `bot/src`: the line (comments stripped) MUST match `/console\.log\(\s*[`"']\[([a-z-]+)\][^`"']*`/i` — i.e. it starts with a bracketed surface token. Additionally the line MUST NOT contain any of the coordinate/identifier carriers: `lat`, `lng`, `chat_id`, `user_id`, `token`, `initData`, `geometry`, `polyline`, `payload`. (Note: `chat_id`/`user_id` must be checked as whole words so that `[location]` does not trip `chat`.) - In `web/src`: the line MUST match one of `/ARAHARAH-(DEST|LIVE|ROUTE|GUIDE|DRIVE)\s+state=/`, `/ARAHARAH-LIVE\s+fix=/`, `/RAMS-BASEMAP-READY/`. A `console.error(` line is allowed (the basemap error path). Any other `console.log` FAILS the gate. - ALSO assert that NO file in `bot/src` or `web/src` contains a bare `JSON.stringify(` inside a `console.log(` argument — a serialized payload is a payload leak. The evidence string lists each violation as `relative-path:line — `. **`runRuntimeHostGate()`** — the POSITIVE host allowlist. Enumerate every `fetch(` call site in `web/src` and `bot/src` and every absolute `https://` URL in `web/src`, `bot/src`, `theme/src`, and `web/index.html`. For each ABSOLUTE URL, its host MUST be in the allowlist `["tiles.openfreemap.org", "telegram.org"]` (a relative path like `/api/guidance` is NOT a host and is allowed — the app's own Worker). For each `fetch(` call site, the first argument MUST be a relative path (starting with `/`) or a template literal whose static prefix is relative — NEVER an absolute URL to an unapproved host. The evidence string must print the ACTUAL host inventory it found (this is the A4 assumption's evidence — the host list is produced, not assumed). IMPORTANT — the stripper's over-stripping is deliberate ("prefer-fail reading"). Do NOT write a smarter stripper. The header comment must say so. - `scripts/lib/source-gates.mjs` exists and exports `runStorageGate`, `runKvTtlGate`, `runLogVocabularyGate`, `runRuntimeHostGate` - `grep -c "stripComments" scripts/lib/source-gates.mjs` returns ≥ 2 (the definition + at least one use) - `grep -c "expirationTtl" scripts/lib/source-gates.mjs` returns ≥ 1 and `grep -c "localStorage" scripts/lib/source-gates.mjs` returns ≥ 1 - `grep -c "console.log" scripts/lib/source-gates.mjs` returns ≥ 1 (the log-vocabulary gate exists) - `grep -c "tiles.openfreemap.org" scripts/lib/source-gates.mjs` returns ≥ 1 (the positive allowlist names the tile host) - `grep -c "web/src\|bot/src\|routing/src\|search/src" scripts/lib/source-gates.mjs` returns ≥ 4 (the scan is repo-wide, not web-only) export PATH="$HOME/.nvm/versions/node/v24.18.0/bin:$PATH"; cd /home/C-Fu/araharah && node --check scripts/lib/source-gates.mjs && echo "syntax ok" && node -e "import('./scripts/lib/source-gates.mjs').then(m=>{for(const k of ['runStorageGate','runKvTtlGate','runLogVocabularyGate','runRuntimeHostGate']){const r=m[k]();console.log(r.id+': '+(r.pass?'PASS':'FAIL')+' — '+r.evidence.slice(0,200));}})" The four source gates run over every production tree, print their evidence, and report PASS on the current tree. task 2: the bundle-gates library + the cloudflare-probes library scripts/lib/bundle-gates.mjs, scripts/lib/cloudflare-probes.mjs - web/test/build-artifact.test.mjs (the DirEnt walk at lines 79–87, the PRODUCTION-only scope law at lines 133–147, the JS-assets-only crypto rule at lines 175–187, and the BA9 gzip measurement at lines 221–234) - .planning/phases/07-hardening-private-launch/07-RESEARCH.md §Pitfalls 5, 6, 7, 10 + §Pattern 5 - .planning/phases/07-hardening-private-launch/07-PATTERNS.md §No Analog Found (the live-probe guidance) Create `scripts/lib/bundle-gates.mjs`: ```javascript export function runBundleTelemetryGate(); // id: "bundle-telemetry" — reads web/dist ONLY ``` - It walks `web/dist` recursively (the DirEnt walk from `web/test/build-artifact.test.mjs` lines 79–87 — copy it). - **PRODUCTION ONLY**: `web/dist` — NEVER `web/dist-harness` (BA5's law: a gate pointed at the harness build inverts itself; the harness is deliberately not clean). - If `web/dist` does not exist, the gate returns `{ pass: false, evidence: "web/dist does not exist — run \`npm run build -w theme && npm run build -w web\` first" }` (never a silent pass). - It scans every emitted `.js` file for the analytics/beacon token set: `["google-analytics.com","googletagmanager.com","analytics.google","segment.io", "mixpanel","sentry.io","amplitude","datadog","bugsnag","posthog","plausible.io", "hotjar","intercom","beacon","navigator.sendBeacon","gtag(","fbq("]`. (`beacon` is checked as `navigator.sendBeacon` — a bare `beacon` substring would false-positive on unrelated words; use the explicit form.) - It scans every emitted `.js` AND `.html` file for ABSOLUTE `https://` URLs and asserts every such URL's host is in the allowlist `["tiles.openfreemap.org", "telegram.org"]`. The evidence string PRINTS the full host inventory it found. - It scans the emitted `.js` assets (JS ONLY, never the HTML — the official Telegram SDK script is external and carries its own crypto, BA8's law) for the deep-link crypto tokens `["createHmac","crypto.subtle","sha256","HMAC("]` — the D-11 law. (Note: `crypto.subtle` may legitimately appear in the harness mock — but the mock never ships to `web/dist`; if the scan trips on it, that IS a finding.) - It scans the emitted `.js` assets for the storage tokens `["localStorage","sessionStorage","indexedDB","document.cookie"]` — but with the platform allowlist: the OFFICIAL Telegram SDK's own `__telegram__initParams` sessionStorage key is written by the EXTERNAL `telegram-web-app.js`, which is loaded as a `