Files

30 KiB
Raw Permalink Blame History

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_have_notes, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_have_notes must_haves
07-hardening-private-launch 03 execute 1
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
true
PX-04
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.
truths artifacts key_links
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.
path provides contains
scripts/privacy-audit.mjs The consolidated audit CLI (npm run privacy-audit) — orchestration, evidence report, exit codes PRIVACY AUDIT
path provides contains
scripts/lib/source-gates.mjs Comment-stripped source scans over ALL src trees — storage, TTL, log vocabulary, runtime fetch hosts stripComments
path provides contains
scripts/lib/bundle-gates.mjs Production bundle scans — analytics/beacon tokens + the positive fetch-host allowlist ALLOWED_FETCH_HOSTS
path provides contains
scripts/lib/cloudflare-probes.mjs The live KV listing probe (key families + expiration metadata) and the live header probe list_complete
path provides contains
docs/PRIVACY-AUDIT.md The signed written audit record citing the evidence (D-01) PRIVACY AUDIT RECORD
from to via pattern
scripts/privacy-audit.mjs scripts/lib/source-gates.mjs runStorageGate / runCoordinateLogGate / runRuntimeHostGate — each returns {id, pass, evidence} source-gates.mjs
from to via pattern
scripts/privacy-audit.mjs scripts/lib/bundle-gates.mjs runBundleTelemetryGate — scans web/dist (PRODUCTION only) bundle-gates.mjs
from to via pattern
scripts/privacy-audit.mjs scripts/lib/cloudflare-probes.mjs runKvExpiryProbe — only under the --live flag; missing credentials => exit 2 cloudflare-probes.mjs
from to via pattern
package.json scripts/privacy-audit.mjs the root script "privacy-audit": "node scripts/privacy-audit.mjs" 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).

<execution_context> @$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md @$HOME/.config/opencode/get-shit-done/templates/summary.md </execution_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 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):
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:

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):

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:

 * 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:

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):

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_idmust be checked as whole words so that[location]does not tripchat`.)
  • 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. <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> 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 mk;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`:
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:

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. <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> 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);}})" 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.
task 3: the privacy-audit CLI + the root npm script + the signed audit record scripts/privacy-audit.mjs, package.json, docs/PRIVACY-AUDIT.md - 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) 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):

"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. <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> 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=$?" 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.

<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>
Run after all tasks:
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.

<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>
After completion, create `.planning/phases/07-hardening-private-launch/07-03-SUMMARY.md`