---
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(async m=>{for(const k of ['runStorageGate','runKvTtlGate','runLogVocabularyGate','runRuntimeHostGate']){const r=await 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 `