Files

533 lines
30 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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"
---
<objective>
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).
</objective>
<execution_context>
@$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md
@$HOME/.config/opencode/get-shit-done/templates/summary.md
</execution_context>
<context>
@.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
<interfaces>
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 |
</interfaces>
<tasks>
<task type="auto">
<name>task 1: the source-gates library — storage, TTL, log vocabulary, runtime fetch hosts (ALL src trees)</name>
<files>scripts/lib/source-gates.mjs</files>
<read_first>
- 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
</read_first>
<action>
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 — <the 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.
</action>
<acceptance_criteria>
- `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)
</acceptance_criteria>
<verify>
<automated>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));}})"</automated>
</verify>
<done>
The four source gates run over every production tree, print their
evidence, and report PASS on the current tree.
</done>
</task>
<task type="auto">
<name>task 2: the bundle-gates library + the cloudflare-probes library</name>
<files>scripts/lib/bundle-gates.mjs, scripts/lib/cloudflare-probes.mjs</files>
<read_first>
- 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)
</read_first>
<action>
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 `<script src>` and is NOT in
`web/dist` — so the shipped bundle should carry NO storage token at all. If
a storage token IS found in `web/dist`, it is a FAIL (evidence names the
file).
- The evidence string must name every violation as `relative-path — <token>`.
Create `scripts/lib/cloudflare-probes.mjs`:
```javascript
export function runKvExpiryProbe(opts); // id: "kv-expiry" — LIVE ONLY
export function runAssetHeaderProbe(opts); // id: "asset-headers" — LIVE ONLY
export function probeUnavailable(reason); // { id, pass: false, notRun: true, evidence }
```
- **The live probe needs credentials.** If `CLOUDFLARE_API_TOKEN` +
`CLOUDFLARE_ACCOUNT_ID` are not in the environment (or `npx wrangler` is
unavailable), the probe returns `probeUnavailable("missing Cloudflare credentials")`
— `pass: false`, `notRun: true`. The orchestrator turns a `notRun` result
into exit code 2 (environment drift), NEVER into a pass.
- `runKvExpiryProbe` shells out to
`npx wrangler kv key list --namespace-id=59f58a54bc44483e89872b20a005a5eb`
(the live namespace id from `bot/wrangler.jsonc` — read it from that file
at runtime rather than hard-coding it twice; the id lives in
`bot/wrangler.jsonc` under `kv_namespaces[0].id`). It parses the JSON
output for each key's `name`, the optional `expiration`, the optional
`metadata`, the `list_complete` flag and the `cursor` (Pitfall 7 — key
presence alone does not prove TTL-only). It asserts:
1. every key's family prefix is one of `session:`, `chat:`, `loc:`,
`guidance:` — an unexpected family is REPORTED (A5 says report, never
assume absence);
2. every listed key carries an `expiration`;
3. it reports the OLDEST and NEWEST expiry it saw.
It FOLLOWS THE CURSOR until `list_complete` is true.
- `runAssetHeaderProbe` shells out to
`curl -sSI <MINI_APP_URL or the deployed origin>` and reads the
`cache-control` and `cf-cache-status` headers for the HTML entry and for a
hashed `/assets/*` file. It reports the observed headers VERBATIM
(CF-Cache-Status is probabilistic per Cloudflare's own footnote — the
evidence prints it, it does not interpret it as a guarantee).
- Both probes must be safe to call with no credentials: they return
`probeUnavailable(...)` rather than throwing.
</action>
<acceptance_criteria>
- `scripts/lib/bundle-gates.mjs` exists and exports `runBundleTelemetryGate`; it contains the literal `web/dist` and the literal `dist-harness` (as the FORBIDDEN path, in a comment or an exclusion check)
- `grep -c "ALLOWED\|allowlist" scripts/lib/bundle-gates.mjs` returns ≥ 1 and `grep -c "tiles.openfreemap.org" scripts/lib/bundle-gates.mjs` returns ≥ 1
- `grep -c "navigator.sendBeacon" scripts/lib/bundle-gates.mjs` returns ≥ 1 (the beacon token)
- `scripts/lib/cloudflare-probes.mjs` exists and exports `runKvExpiryProbe`, `runAssetHeaderProbe`, `probeUnavailable`
- `grep -c "list_complete" scripts/lib/cloudflare-probes.mjs` returns ≥ 1 and `grep -c "expiration" scripts/lib/cloudflare-probes.mjs` returns ≥ 1
- `grep -c "kv key list" scripts/lib/cloudflare-probes.mjs` returns ≥ 1 (the current wrangler syntax — the deprecated `kv:key list` form is forbidden)
</acceptance_criteria>
<verify>
<automated>export PATH="$HOME/.nvm/versions/node/v24.18.0/bin:$PATH"; cd /home/C-Fu/araharah && node --check scripts/lib/bundle-gates.mjs && node --check scripts/lib/cloudflare-probes.mjs && node -e "import('./scripts/lib/cloudflare-probes.mjs').then(m=>{const r=m.probeUnavailable('missing Cloudflare credentials');console.log(JSON.stringify(r)); if(r.pass!==false||r.notRun!==true){console.error('probeUnavailable must be pass:false + notRun:true');process.exit(1);}})"</automated>
</verify>
<done>
The bundle gate scans `web/dist` only against the positive host allowlist
and the analytics/beacon token set; the live probes parse the expiry
metadata and the pagination cursor, and degrade to `notRun` (never a
false pass) without credentials.
</done>
</task>
<task type="auto">
<name>task 3: the privacy-audit CLI + the root npm script + the signed audit record</name>
<files>scripts/privacy-audit.mjs, package.json, docs/PRIVACY-AUDIT.md</files>
<read_first>
- theme/scripts/audit.mjs (the exit-code contract, lines 20–24, and the report shape, lines 797–814)
- routing/scripts/measure-route-cpu.mjs (the maintainer-time-tool header law, lines 3–9)
- docs/CONFIGURATION.md (the docs-dir clinical prose style — a leading "surfaces, by kind" table)
- .planning/phases/07-hardening-private-launch/07-CONTEXT.md (D-01 and D-02 verbatim)
</read_first>
<action>
Create `scripts/privacy-audit.mjs` — the orchestrator (RESEARCH Pattern 4's
shape). Its header comment must state: this is a MAINTAINER-TIME REPO TOOL,
not a runtime dependency; it is GPL-licensed and reusable by forks; v1 carries
ZERO telemetry (D-02) so the audit proves an ABSENCE.
Behaviour:
- Accepts an optional `--live` flag. Without it, the live probes are SKIPPED
and the run reports `kv-expiry: NOT RUN (pass --live)` — a skip is never
silently green.
- Runs, in order: `runStorageGate()`, `runKvTtlGate()`, `runLogVocabularyGate()`,
`runRuntimeHostGate()`, `runBundleTelemetryGate()`; with `--live`, also
`runKvExpiryProbe()` and `runAssetHeaderProbe()`.
- Prints ONE line per gate in the form
`<id>: PASS|FAIL|NOT RUN — <evidence>` (the Pattern 4 shape), then a
summary line:
`PRIVACY AUDIT CLEAN — <n> gate(s) passed, 0 violations` or
`PRIVACY AUDIT FAILED — <n> violation(s) across <m> gate(s)` or
`PRIVACY AUDIT NOT RUN — <reason> (environment drift; never a pass)`.
- EXIT CODES (the repo's no-silent-passes contract):
- `0` — every required gate PASSed.
- `1` — at least one gate FAILED (the violations are printed above).
- `2` — environment/credential drift (a required live probe could not run
because credentials or the build are absent) — this is NOT a pass and the
message says so.
- The script must NEVER print a coordinate, a chat id, a user id, a token, or
an initData payload — its output is category-only, exactly like the bot's
logs.
Add to the ROOT `package.json` `scripts` (next to the other root scripts):
```json
"privacy-audit": "node scripts/privacy-audit.mjs",
```
Create `docs/PRIVACY-AUDIT.md` — the SIGNED written audit record (D-01's
"signed written audit record citing the evidence"). Follow the
`docs/CONFIGURATION.md` clinical-prose style. It MUST contain:
1. A title `# Privacy audit record` and a dated front-matter block naming the
audit date, the commit SHA (`git rev-parse --short HEAD`), and the auditor.
2. A `## Scope` section naming what was audited (all five `src` trees, the
production bundle `web/dist`, the live KV namespace) and what was NOT
(the harness build `web/dist-harness`, the Telegram client's own internal
storage — the platform allowlist).
3. A `## Findings` table with one row per gate: `| Gate | Result | Evidence |`
where the Evidence column carries the ACTUAL printed evidence string from
the run (copy it from the audit output — never invent it).
4. A `## Storage inventory` table listing the four KV key families with
their TTL constants and TTLs, and stating that no other family exists and
that no coordinate or profile is persisted.
5. A `## Log vocabulary` section listing the allowed log surfaces and
stating the coordinate-free law.
6. A `## Runtime network` section listing the ACTUAL host inventory the audit
printed, stating that no analytics/beacon host exists, and stating
D-02's zero-telemetry posture explicitly.
7. A `## Statement` section: the PRIV-02 + GEO-03 re-verification claim in
one honest paragraph, naming the evidence that backs it.
8. A `## Signature` block at the end with a line for the maintainer's name
and a date line, both left as `________________` to be filled by the human
at the launch gate (the audit record is SIGNED BY THE HUMAN — the tool
produces the evidence, the human signs the claim).
FILL the Findings/Storage/Log/Network sections with the REAL run output. Run
`npm run privacy-audit` yourself, paste the printed evidence verbatim. If a
gate FAILS, fix the underlying code and re-run — do NOT soften the record.
</action>
<acceptance_criteria>
- `scripts/privacy-audit.mjs` exists and contains the literals `PRIVACY AUDIT CLEAN`, `PRIVACY AUDIT FAILED`, `PRIVACY AUDIT NOT RUN`
- `grep -c "process.exit" scripts/privacy-audit.mjs` returns ≥ 3 (the three exit codes)
- `grep -c "notRun" scripts/privacy-audit.mjs` returns ≥ 1 (a skipped probe is never a pass)
- `grep -c '"privacy-audit"' package.json` returns exactly 1
- `docs/PRIVACY-AUDIT.md` exists and contains the literals `PRIVACY AUDIT RECORD` (or the title `Privacy audit record`), `## Scope`, `## Findings`, `## Storage inventory`, `## Log vocabulary`, `## Runtime network`, `## Statement`, `## Signature`
- `npm run privacy-audit` exits 0 on the current tree and prints `PRIVACY AUDIT CLEAN`
</acceptance_criteria>
<verify>
<automated>export PATH="$HOME/.nvm/versions/node/v24.18.0/bin:$PATH"; cd /home/C-Fu/araharah && npm run privacy-audit 2>&1 | tail -20 && npm run privacy-audit >/dev/null 2>&1; echo "exit=$?"</automated>
</verify>
<done>
`npm run privacy-audit` runs the consolidated gates, prints per-gate
evidence, exits 0/1/2 correctly, and `docs/PRIVACY-AUDIT.md` carries the
signed record citing that run's real evidence.
</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Source tree → audit scanner | The scanner reads untrusted repository text; a comment-stripping mistake could hide a call site behind a prose mention. |
| Production bundle → runtime network | The shipped JS decides which hosts the app actually reaches; the source tree does not prove the bundle. |
| Cloudflare account → audit probe | The live probe reads the real namespace; a missing credential must never read as "clean". |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-07-01 | Information Disclosure | `bot/src/kv/*` — a KV write without a TTL becomes a persistent per-user location history | mitigate | `runKvTtlGate` asserts EVERY `.put(` line carries `expirationTtl` referencing a NAMED `^[A-Z_]{2,}$` constant, across every tree — the PG1 law extended repo-wide (D-01). The live probe additionally asserts every listed key carries an `expiration` and reports the oldest/newest expiry (Pitfall 7). |
| T-07-02 | Information Disclosure | `web/src` — a persistence API would make the client a location-history store | mitigate | `runStorageGate` scans all five production trees for the full BANNED persistence list plus the service-worker family, with comments stripped (the canonical stripper, copied verbatim). The bundle gate re-checks the shipped `web/dist` for the same tokens. |
| T-07-03 | Information Disclosure | `bot/src` / `web/src` log call sites — a coordinate, chat id, or payload in a log line | mitigate | `runLogVocabularyGate` requires every `bot/src` log to open with a bracketed surface token and forbids `lat`/`lng`/`chat_id`/`user_id`/`token`/`initData`/`geometry`/`polyline`/`payload` on the line; every `web/src` log must be an `ARAHARAH-*` state token or the basemap signal. Serialized payloads inside a log are forbidden outright. |
| T-07-04 | Information Disclosure | the production bundle — a smuggled analytics/beacon host or an unapproved fetch | mitigate | `runBundleTelemetryGate` scans `web/dist` (production only) for the analytics/beacon token set AND enforces a POSITIVE host allowlist (`tiles.openfreemap.org`, `telegram.org`); the host inventory is printed as evidence, not assumed (A4). |
| T-07-16 | Spoofing | the audit's own verdict — a missing credential reported as clean | mitigate | The live probes return `{pass:false, notRun:true}` without credentials and the CLI maps a `notRun` result to exit code 2 with the message `PRIVACY AUDIT NOT RUN` — a skip is never a pass (RESEARCH Pattern 4, the repo's no-silent-passes contract). |
| T-07-17 | Tampering | the comment stripper — a prose mention tripping the gate, or a call site hiding behind one | accept | The repo's own stripper is copied VERBATIM with its deliberate over-stripping ("prefer-fail reading"): a false positive fails the gate (a loud failure), and a hidden call site is caught by the bundle scan and the live probe, which do not rely on comment stripping. |
</threat_model>
<verification>
Run after all tasks:
```bash
export PATH="$HOME/.nvm/versions/node/v24.18.0/bin:$PATH"
cd /home/C-Fu/araharah
npm run build -w theme && npm run build -w web
npm run privacy-audit
npm run privacy-audit -- --live
```
The first must exit 0 with `PRIVACY AUDIT CLEAN`. The second must either exit 0
(with the live probes' evidence printed) or exit 2 with
`PRIVACY AUDIT NOT RUN` when the credentials are absent — it must NEVER exit 0
while reporting a live probe as skipped.
</verification>
<success_criteria>
- `npm run privacy-audit` exists and runs the storage, KV-TTL, log-vocabulary, runtime-host and bundle-telemetry gates over ALL production trees plus `web/dist`, printing per-gate evidence.
- The exit codes are 0 / 1 / 2 with "NOT RUN" never counted as a pass.
- The live KV probe reads the expiry metadata and the pagination cursor, and the live header probe prints the observed cache headers verbatim.
- `docs/PRIVACY-AUDIT.md` carries the signed audit record with the real run evidence in its Findings/Storage/Log/Network sections and a human signature block.
</success_criteria>
<output>
After completion, create `.planning/phases/07-hardening-private-launch/07-03-SUMMARY.md`
</output>