- source-side runtime-hosts gate gains DOC_POINTER_STATIC_TEXT_HOSTS (the MapLibre-attribution precedent): the published repo origin may appear ONLY as a bare URL literal in bot copy (D-08/D-09), never fetched - class is NARROW and pinned by SA6: a fetch( target naming the same host is still a violation - Rule 1 (found by SA6's RED): the fetch( handler's declaration heuristic misclassified any quoted absolute URL as a TS declaration — quoted strings are targets; they now reach the host check - docs/PRIVACY-AUDIT.md: adjudication documented + dated re-run addendum - deferred-items.md #1 closed: npm run privacy-audit exits 0 again (PRIVACY AUDIT CLEAN — 5 gate(s) passed, 0 violations; suite 13/13)
176 lines
13 KiB
Markdown
176 lines
13 KiB
Markdown
<!-- generated-by: gsd 07-03 executor -->
|
||
|
||
# Privacy audit record
|
||
|
||
**PRIVACY AUDIT RECORD** — the signed written audit record citing the evidence (Phase 7, D-01).
|
||
|
||
| Field | Value |
|
||
| --- | --- |
|
||
| Audit date | 2026-10-24 |
|
||
| Audited tree | commit `9c55e9a` (`npm run privacy-audit` run against that working tree) |
|
||
| Auditor | the maintainer — signature block below (the tool produces the evidence; the human signs the claim) |
|
||
| Tool | `npm run privacy-audit` (`scripts/privacy-audit.mjs` + `scripts/lib/*`) — GPL-3.0, reusable by forks |
|
||
| Result | **PRIVACY AUDIT CLEAN — 5 gate(s) passed, 0 violations** (static gates); live legs recorded as NOT RUN below |
|
||
|
||
The tool is a MAINTAINER-TIME REPO TOOL — nothing in any `src` tree imports it. A fork reruns
|
||
`npm run privacy-audit` over its own trees before its first driver; this record is the v1 tree's
|
||
own evidence.
|
||
|
||
---
|
||
|
||
## Scope
|
||
|
||
**Audited:**
|
||
|
||
| Surface | What was scanned |
|
||
| --- | --- |
|
||
| All five `src` trees | `web/src`, `bot/src`, `routing/src`, `search/src`, `theme/src` — 72 code files (`.ts`/`.tsx`/`.js`/`.mjs`/`.html`), comments stripped (the canonical stripper, copied verbatim from `web/test/no-storage.test.mjs`) |
|
||
| Production bundle | `web/dist` — every emitted `.js` and `.html` file (PRODUCTION only) |
|
||
| Live KV namespace | `chat_sessions` (`bot/wrangler.jsonc`) — TTL-only keys, probed live only |
|
||
|
||
**Not audited (deliberate):**
|
||
|
||
| Excluded surface | Why |
|
||
| --- | --- |
|
||
| `web/dist-harness` | the test-harness build is deliberately NOT clean (BA5's law — a gate pointed at it inverts itself) |
|
||
| The Telegram client's own internal storage | the official `telegram-web-app.js` is EXTERNAL to the bundle; its `__telegram__initParams` sessionStorage write is the platform allowlist, not ours |
|
||
| `web/src/mock/telegram-mock.ts` runtime behaviour | scanned by the source gates (it is in `web/src`); its in-page demo interactions are harness-only and never ship |
|
||
| Third-party library internals beyond the shipped bundle text | the bundle scan covers the shipped artifact itself — that IS the library's runtime footprint |
|
||
|
||
## Findings
|
||
|
||
One row per gate; the Evidence column is the ACTUAL printed evidence string from the run (copied verbatim, never invented):
|
||
|
||
| Gate | Result | Evidence |
|
||
| --- | --- | --- |
|
||
| `storage-api` | PASS | 72 file(s) across 5 src tree(s) — no persistence API outside the platform allowlist |
|
||
| `kv-ttl` | PASS | 4 KV write(s) across 72 file(s) — every .put( line carries expirationTtl referencing a named ^[A-Z_]{2,}$ constant |
|
||
| `log-vocabulary` | PASS | 21 bot + 7 web console.log call site(s) — every bot log opens with a bracketed surface token and carries NO lat/lng/chat_id/user_id/token/initData/geometry/polyline/payload carrier, every web log is an ARAHARAH-* state token or the RAMS-BASEMAP-READY signal, and no log serializes a payload |
|
||
| `runtime-hosts` | PASS | host inventory (printed, not assumed): www.w3.org×3, telegram.org×1; 4 relative fetch target(s) to the app's own Worker, 1 fetch declaration(s) skipped; every absolute URL host is in the positive allowlist [tiles.openfreemap.org, telegram.org] |
|
||
| `bundle-telemetry` | PASS | 3 JS + 1 HTML emitted file(s) scanned (PRODUCTION web/dist only — dist-harness forbidden); host inventory (printed, not assumed): maplibre.invalid×1, github.com×3, wiki.openstreetmap.org×1, maplibre.org×2, tiles.openfreemap.org×5, opendatacommons.org×2, telegram.org×1; every absolute-URL host is in the allowlist [tiles.openfreemap.org, telegram.org] or the documented static-text class; ZERO analytics/beacon/crypto/storage tokens |
|
||
| `kv-expiry` (live) | NOT RUN | (pass --live) — no Cloudflare credentials in the auditing environment; the live leg re-runs at the launch gate with `npm run privacy-audit -- --live`. A skip is never a pass (exit code 2, not 0) |
|
||
| `asset-headers` (live) | NOT RUN | (pass --live) — same re-run protocol; the live leg observed the HTML entry answering `cache-control: public, max-age=0, must-revalidate | cf-cache-status: HIT` and the then-deployed asset answering 404 (deployment drift — the deployed Worker pre-dates the current `web/dist`), which the probe reports as deployment drift, never as a pass |
|
||
|
||
Documented scope adjudications (deliberate; each names its reason): the `beacon` token is checked
|
||
only as `navigator.sendBeacon`; the MapLibre library's own static-text URLs (`maplibre.org`,
|
||
`wiki.openstreetmap.org`, `github.com` diagnostics, `maplibre.invalid` protocol-test placeholder)
|
||
and our own dataset's ODbL license strings (`opendatacommons.org`) are printed in the inventory as
|
||
a documented static-text class — never fetched; `http://www.w3.org/{2000/svg,1999/xhtml}` are W3C
|
||
spec identifiers. The SOURCE-side `runtime-hosts` gate carries the same kind of adjudication for
|
||
the `/help` Known-limits doc pointer (see the 2026-10-25 addendum below): the published repo
|
||
origin appears ONLY as a bare URL literal in the bot strings — user-clicked navigation, never
|
||
fetched — and the class is NARROW (a `fetch(` target naming the same host is still a violation;
|
||
pinned by the SA6 vector).
|
||
|
||
## Storage inventory
|
||
|
||
The FOUR TTL-only KV key families — the complete storage surface. No other family exists, and no
|
||
coordinate or profile is persisted anywhere else (the `kv-ttl` gate proves every write path, the
|
||
`storage-api` gate proves the client carries no persistence API at all):
|
||
|
||
| Family | Prefix | TTL constant | TTL | What is stored |
|
||
| --- | --- | --- | --- | --- |
|
||
| session | `session:` | `SESSION_TTL_SECONDS` (`bot/src/kv/sessions.ts`) | 900 s | the 4-field `SessionClaim` — user_id, chat_id, issued_at, language_code. Nothing else ever serialized (D-08: a 5th property means drift) |
|
||
| chat pointer | `chat:` | `SESSION_TTL_SECONDS` (`bot/src/kv/sessions.ts`) | 900 s | the current opaque session token for a chat |
|
||
| location pin | `loc:` | `PIN_TTL_SECONDS` (`bot/src/kv/location-pin.ts`) | 300 s | Telegram's Location fields only — latitude, longitude, horizontal_accuracy (GEO-03: the ONLY place a coordinate exists server-side, and it expires) |
|
||
| guidance envelope | `guidance:` | `GUIDANCE_SESSION_TTL_SECONDS` (`bot/src/kv/guidance-sessions.ts`) | 7200 s | the frozen 4-field envelope + inline 6-field route snapshot; nothing outlives 2 h, nothing reconstructable after expiry (D-07) |
|
||
|
||
Every write path is pinned by the same-line `expirationTtl` law (PG1, extended repo-wide by the
|
||
`kv-ttl` gate), and the cron performs ZERO KV writes (the free-plan budget law). The client
|
||
(`web/src`) holds route/guidance state in MEMORY ONLY — no localStorage, no sessionStorage, no
|
||
IndexedDB, no CacheStorage, no cookies, no service worker.
|
||
|
||
## Log vocabulary
|
||
|
||
Category-only, machine-verified by the `log-vocabulary` gate. Allowed log surfaces:
|
||
|
||
| Tree | Allowed log shape |
|
||
| --- | --- |
|
||
| `bot/src` | `console.log("[surface] verdict: category")` — surface is one of `webhook`, `initdata`, `location`, `location-fallback`, `guidance`, `guidance-cron`, `route`, `search`, `search-alternate`; every call site opens with a bracketed surface token |
|
||
| `web/src` | the `ARAHARAH-*` state tokens (`ARAHARAH-DEST state=…`, `ARAHARAH-LIVE fix=…`, `ARAHARAH-ROUTE state=…`, `ARAHARAH-GUIDE state=…`, `ARAHARAH-DRIVE state=…`) or the `RAMS-BASEMAP-READY layers=…` signal; `console.error` on the basemap error path |
|
||
|
||
The coordinate-free law: no log line may carry a coordinate, a chat id, a user id, a token, an
|
||
initData payload, a geometry/polyline, or any serialized payload (`JSON.stringify` inside a log
|
||
argument is forbidden outright). 21 bot + 7 web call sites verified.
|
||
|
||
## Runtime network
|
||
|
||
The ACTUAL host inventory the audit printed (source scan + production bundle scan — produced, not
|
||
assumed, A4):
|
||
|
||
| Host | Class | Why it is reachable |
|
||
| --- | --- | --- |
|
||
| `tiles.openfreemap.org` | **fetch allowlist** | the basemap tiles / sprites / glyphs (`theme/basemap/liberty-rams.json`) |
|
||
| `telegram.org` | **fetch allowlist** | the official Telegram WebApp SDK `<script>` tag in `web/index.html` |
|
||
| the app's own Worker origin | **fetch allowlist (relative)** | `/api/session`, `/api/location`, `/api/location-fallback`, `/api/guidance`, `/api/route` — RELATIVE paths, not hosts |
|
||
| `maplibre.org`, `wiki.openstreetmap.org`, `github.com`, `maplibre.invalid` | documented static-text class | MapLibre's own attribution/diagnostic constants inside the bundled library — displayed text, never fetched |
|
||
| `opendatacommons.org` | documented static-text class | our own gazetteer/fixture ODbL license metadata strings |
|
||
| `www.w3.org` | documented spec identifiers | SVG/XHTML namespace constants — never fetched |
|
||
| `gitmirah` (the published repo origin) | documented static-text class (SOURCE-side, URL literals only) | the `/help` Known-limits doc pointer in `bot/src/i18n/strings.ts` (D-08/D-09) — user-facing copy in a chat card, never fetched; a `fetch(` to the same host is still a violation (SA6) |
|
||
|
||
**ZERO telemetry (D-02):** no analytics, no beacons, no tracking pixels, no third-party CDN, no
|
||
error-reporting host, no social pixels — the shipped bundle contains no analytics or beacon token
|
||
of any kind, and no fetch to any host outside the three-row fetch allowlist above. v1 has nothing
|
||
to opt into: opt-in telemetry stays an IDEAS.md aspiration post-validation.
|
||
|
||
## Statement
|
||
|
||
PRIV-02 (storage is TTL-only, per-user data never outlives its window) and GEO-03 (the client
|
||
persists nothing; coordinates live in memory client-side and in a 300 s TTL pin server-side) are
|
||
re-verified END-TO-END by this audit, not asserted: the `storage-api` gate scans all 72 files
|
||
across the five production trees (comments stripped) for any persistence API or service-worker
|
||
surface; the `kv-ttl` gate proves all 4 KV write paths carry a same-line `expirationTtl`
|
||
referencing a named constant; the `log-vocabulary` gate machine-verifies that none of the 28 log
|
||
call sites can carry a coordinate, an identifier, or a payload; the `runtime-hosts` gate proves
|
||
the source tree only ever fetches the app's own Worker; and the `bundle-telemetry` gate proves
|
||
the SHIPPED bundle — the artifact that actually decides the app's runtime network — contains
|
||
zero analytics, zero beacons, zero storage APIs, zero deep-link crypto, and no fetch host beyond
|
||
the OpenFreeMap tiles, the Telegram SDK, and the Worker itself. The live legs (KV listing with
|
||
expiry metadata, deployed cache headers) were observed NOT RUN in this environment and are
|
||
re-run with `npm run privacy-audit -- --live` at the launch gate — exit 0 only when they PASS,
|
||
never when they are skipped.
|
||
|
||
## 2026-10-25 — Phase 7 gate-prep re-run: the /help doc pointer adjudication
|
||
|
||
**Why this addendum exists:** plan 07-05's committed `/help` Known-limits pointer (commit
|
||
`78482ab`) put the repo's actual published-origin URL (`http://gitmirah:3000/c-fu/arah2/…`,
|
||
the maintainer's Gitea remote per `git remote -v`) into the bot strings (en + ms), and the
|
||
`runtime-hosts` gate — a positive allowlist with no static-text escape — flagged it as 2
|
||
violations (`PRIVACY AUDIT FAILED`). Logged in the phase's `deferred-items.md`; fixed by the
|
||
07-06 gate prep, following the audit's OWN laws (the MapLibre static-text adjudication
|
||
precedent), not by weakening them.
|
||
|
||
**The fix (source-side, `scripts/lib/source-gates.mjs`):**
|
||
- A documented `DOC_POINTER_STATIC_TEXT_HOSTS` class on the SOURCE-side `runtime-hosts` gate:
|
||
the published repo origin may appear ONLY as a bare URL literal in bot copy (the /help
|
||
Known-limits line — user-clicked navigation, never fetched). Each entry names its reason;
|
||
forks replace it with their own published origin.
|
||
- The class is **NARROW by construction and pinned by the SA6 vector**: it is consulted ONLY
|
||
for bare URL literals. A `fetch(` target naming the same host is STILL a violation. This
|
||
required fixing a latent gate bug (SA6's RED proved it): the `fetch(` call-site handler's
|
||
declaration heuristic (`argText.includes(":")`) misclassified any QUOTED absolute URL as a
|
||
TS method declaration and skipped it — a quoted string is always a target, so quoted fetch
|
||
targets now reach the host check.
|
||
- **Result:** `npm run privacy-audit` → `runtime-hosts: PASS — host inventory (printed, not
|
||
assumed): www.w3.org×3, gitmirah×2, telegram.org×1; 4 relative fetch target(s) to the app's
|
||
own Worker, 1 fetch declaration(s) skipped; every absolute URL host is in the positive
|
||
allowlist [tiles.openfreemap.org, telegram.org] or the documented doc-pointer static-text
|
||
class (URL literals only — never fetched)` → **PRIVACY AUDIT CLEAN — 5 gate(s) passed,
|
||
0 violations**; the suite is 13/13 (SA1–6, BG1–2, CP1–2, AU1–3). The Findings table above
|
||
remains the 07-03 run's historical record; this section is the gate-time re-run record.
|
||
|
||
**Driver-resolution caveat (unchanged from 07-05's decision):** the URL resolves for a driver
|
||
only once the D-09 launch checklist's "repo pushed public" step lands main on the remote —
|
||
the launch gate verifies the pointer line resolves before signing.
|
||
|
||
## Signature
|
||
|
||
The audit record is SIGNED BY THE HUMAN at the launch gate (the tool produces the evidence; the
|
||
human signs the claim):
|
||
|
||
```
|
||
Maintainer: ________________________________
|
||
|
||
Date: ________________________________
|
||
```
|