/* * 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}`); }