docs(07): plan phase 07 — hardening + private launch (6 plans, 3 waves)

This commit is contained in:
araharah-executor
2026-10-25 02:31:50 +08:00
parent 0d437db10c
commit 27b340fa6b
7 changed files with 3106 additions and 0 deletions
@@ -0,0 +1,532 @@
---
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(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));}})"</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>