30 KiB
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 |
|
true |
|
|
|
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 |
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/. Aconsole.error(line is allowed (the basemap error path). Any otherconsole.logFAILS the gate. - ALSO assert that NO file in
bot/srcorweb/srccontains a bareJSON.stringify(inside aconsole.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(m=>{for(const k of ['runStorageGate','runKvTtlGate','runLogVocabularyGate','runRuntimeHostGate']){const r=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.
export function runBundleTelemetryGate(); // id: "bundle-telemetry" — reads web/dist ONLY
- It walks
web/distrecursively (the DirEnt walk fromweb/test/build-artifact.test.mjslines 79–87 — copy it). - PRODUCTION ONLY:
web/dist— NEVERweb/dist-harness(BA5's law: a gate pointed at the harness build inverts itself; the harness is deliberately not clean). - If
web/distdoes 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
.jsfile 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("]. (beaconis checked asnavigator.sendBeacon— a barebeaconsubstring would false-positive on unrelated words; use the explicit form.) - It scans every emitted
.jsAND.htmlfile for ABSOLUTEhttps://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
.jsassets (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.subtlemay legitimately appear in the harness mock — but the mock never ships toweb/dist; if the scan trips on it, that IS a finding.) - It scans the emitted
.jsassets for the storage tokens["localStorage","sessionStorage","indexedDB","document.cookie"]— but with the platform allowlist: the OFFICIAL Telegram SDK's own__telegram__initParamssessionStorage key is written by the EXTERNALtelegram-web-app.js, which is loaded as a<script src>and is NOT inweb/dist— so the shipped bundle should carry NO storage token at all. If a storage token IS found inweb/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_IDare not in the environment (ornpx wrangleris unavailable), the probe returnsprobeUnavailable("missing Cloudflare credentials")—pass: false,notRun: true. The orchestrator turns anotRunresult into exit code 2 (environment drift), NEVER into a pass. runKvExpiryProbeshells out tonpx wrangler kv key list --namespace-id=59f58a54bc44483e89872b20a005a5eb(the live namespace id frombot/wrangler.jsonc— read it from that file at runtime rather than hard-coding it twice; the id lives inbot/wrangler.jsoncunderkv_namespaces[0].id). It parses the JSON output for each key'sname, the optionalexpiration, the optionalmetadata, thelist_completeflag and thecursor(Pitfall 7 — key presence alone does not prove TTL-only). It asserts:- every key's family prefix is one of
session:,chat:,loc:,guidance:— an unexpected family is REPORTED (A5 says report, never assume absence); - every listed key carries an
expiration; - it reports the OLDEST and NEWEST expiry it saw.
It FOLLOWS THE CURSOR until
list_completeis true.
- every key's family prefix is one of
runAssetHeaderProbeshells out tocurl -sSI <MINI_APP_URL or the deployed origin>and reads thecache-controlandcf-cache-statusheaders 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.mjsexists and exportsrunBundleTelemetryGate; it contains the literalweb/distand the literaldist-harness(as the FORBIDDEN path, in a comment or an exclusion check)grep -c "ALLOWED\|allowlist" scripts/lib/bundle-gates.mjsreturns ≥ 1 andgrep -c "tiles.openfreemap.org" scripts/lib/bundle-gates.mjsreturns ≥ 1grep -c "navigator.sendBeacon" scripts/lib/bundle-gates.mjsreturns ≥ 1 (the beacon token)scripts/lib/cloudflare-probes.mjsexists and exportsrunKvExpiryProbe,runAssetHeaderProbe,probeUnavailablegrep -c "list_complete" scripts/lib/cloudflare-probes.mjsreturns ≥ 1 andgrep -c "expiration" scripts/lib/cloudflare-probes.mjsreturns ≥ 1grep -c "kv key list" scripts/lib/cloudflare-probes.mjsreturns ≥ 1 (the current wrangler syntax — the deprecatedkv:key listform 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 scansweb/distonly against the positive host allowlist and the analytics/beacon token set; the live probes parse the expiry metadata and the pagination cursor, and degrade tonotRun(never a false pass) without credentials.
Behaviour:
- Accepts an optional
--liveflag. Without it, the live probes are SKIPPED and the run reportskv-expiry: NOT RUN (pass --live)— a skip is never silently green. - Runs, in order:
runStorageGate(),runKvTtlGate(),runLogVocabularyGate(),runRuntimeHostGate(),runBundleTelemetryGate(); with--live, alsorunKvExpiryProbe()andrunAssetHeaderProbe(). - 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 violationsorPRIVACY AUDIT FAILED — <n> violation(s) across <m> gate(s)orPRIVACY 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:
- A title
# Privacy audit recordand a dated front-matter block naming the audit date, the commit SHA (git rev-parse --short HEAD), and the auditor. - A
## Scopesection naming what was audited (all fivesrctrees, the production bundleweb/dist, the live KV namespace) and what was NOT (the harness buildweb/dist-harness, the Telegram client's own internal storage — the platform allowlist). - A
## Findingstable 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). - A
## Storage inventorytable 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. - A
## Log vocabularysection listing the allowed log surfaces and stating the coordinate-free law. - A
## Runtime networksection listing the ACTUAL host inventory the audit printed, stating that no analytics/beacon host exists, and stating D-02's zero-telemetry posture explicitly. - A
## Statementsection: the PRIV-02 + GEO-03 re-verification claim in one honest paragraph, naming the evidence that backs it. - A
## Signatureblock 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> |
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-auditexists and runs the storage, KV-TTL, log-vocabulary, runtime-host and bundle-telemetry gates over ALL production trees plusweb/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.mdcarries the signed audit record with the real run evidence in its Findings/Storage/Log/Network sections and a human signature block. </success_criteria>