diff --git a/bot/src/kv/location-pin.ts b/bot/src/kv/location-pin.ts new file mode 100644 index 0000000..4224fd0 --- /dev/null +++ b/bot/src/kv/location-pin.ts @@ -0,0 +1,79 @@ +/* + * location-pin.ts — TTL-only KV location-pin layer (GEO-02, GEO-03, PRIV-02). + * ---------------------------------------------------------------------- + * Storage contract (privacy by construction — GEO-03, PRIV-02): + * - EVERY write to `chat_sessions` carries an explicit `expirationTtl` + * on its call line — no write path in this module can persist + * without a TTL. privacy.test.mjs's PG1 gate pins this. + * - The record is exactly Telegram's Location fields: latitude, + * longitude, horizontal_accuracy (optional). Nothing else is ever + * serialized. A 4th property means drift. + * - The pin is keyed by chat_id, derived from the SESSION CLAIM + * (never from a client-supplied id) — a token resolves to exactly + * one chat_id, and the KV key is `loc:`. + * + * Key scheme (chat-scoped — a coordinate key is never user-id scoped): + * location: `loc:` → JSON LocationPin record + * + * GEO-03 note: this pin lives only in KV with a TTL. The client never + * persists a coordinate anywhere (D-07) — this is the ONLY place a + * coordinate ever exists server-side, and it expires. + */ + +/** GEO-03: a shared pin ephemerally relays chat → Mini App (which polls + * GET /api/location); 300 s bounds the exposure, then it is gone. */ +export const PIN_TTL_SECONDS = 300; + +/** The entire persisted pin surface — a 4th property means drift. */ +export interface LocationPin { + latitude: number; + longitude: number; + horizontal_accuracy: number | null; +} + +interface KvLike { + get(key: string): Promise; + put(key: string, value: string, options: { expirationTtl: number }): Promise; + delete(key: string): Promise; +} + +export interface LocationPinKv extends KvLike {} + +/** Relay-write the user's shared pin under the chat-scoped key. The only + * write path in this module — TTL on the same line (PRIV-02 gate). */ +export async function writePin(kv: LocationPinKv, chatId: number, pin: LocationPin): Promise { + await kv.put(`loc:${chatId}`, JSON.stringify(pin), { expirationTtl: PIN_TTL_SECONDS }); +} + +/** Defensive read: null unless the record parses, carries exactly the pin + * fields, is numeric, and is inside the geographic envelope. Range + * validation (V5 Input Validation) on READ — a corrupt or tampered + * record can never fly the client camera to null island. */ +export async function getPin(kv: LocationPinKv, chatId: number): Promise { + const raw = await kv.get(`loc:${chatId}`); + if (raw === null) return null; + try { + const parsed: unknown = JSON.parse(raw); + if ( + parsed && + typeof parsed === "object" && + "latitude" in parsed && + "longitude" in parsed && + typeof (parsed as LocationPin).latitude === "number" && + typeof (parsed as LocationPin).longitude === "number" && + Math.abs((parsed as LocationPin).latitude) <= 90 && + Math.abs((parsed as LocationPin).longitude) <= 180 + ) { + return parsed as LocationPin; + } + return null; + } catch { + return null; + } +} + +/** Explicit sweep — the Mini App deletes its memory copy on consume, and + * this is the server-side mirror for that handoff. */ +export async function clearPin(kv: LocationPinKv, chatId: number): Promise { + await kv.delete(`loc:${chatId}`); +} diff --git a/bot/test/privacy.test.mjs b/bot/test/privacy.test.mjs index 2579fab..2dc83d5 100644 --- a/bot/test/privacy.test.mjs +++ b/bot/test/privacy.test.mjs @@ -22,7 +22,8 @@ import { test } from "./harness.mjs"; const SRC = path.join(fileURLToPath(new URL(import.meta.url)), "..", "..", "src"); -const storageModules = ["kv/sessions.ts"].map((rel) => path.join(SRC, ...rel.split("/"))); +const storageModules = ["kv/sessions.ts", "kv/location-pin.ts"] + .map((rel) => path.join(SRC, ...rel.split("/"))); const webhookModule = path.join(SRC, "webhook.ts"); if (existsSync(webhookModule)) storageModules.push(webhookModule);