feat(03-01): TTL-only KV location-pin layer under the existing privacy gates (GEO-03, PRIV-02)

- kv/location-pin.ts mirrors sessions.ts shape: writePin/getPin/clearPin, key loc:<chat_id> (chat-scoped, claim-derived — never user-id scoped across keys)
- PIN_TTL_SECONDS=300; writePin is the only write path, expirationTtl on the same line referencing the named constant
- getPin is a defensive read: parse guard + shape guard + |lat|<=90 / |lon|<=180 range guard (V5 Input Validation) — corrupt records return null
- privacy.test.mjs: storageModules now scans kv/location-pin.ts, so PG1 (same-line TTL) and PG2 (no persistence-shaped exports) apply automatically
This commit is contained in:
araharah-executor
2026-10-22 23:25:59 +08:00
parent 9376cabca2
commit 4fbbb0b516
2 changed files with 81 additions and 1 deletions
+79
View File
@@ -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:<chat_id>`.
*
* Key scheme (chat-scoped — a coordinate key is never user-id scoped):
* location: `loc:<chat_id>` → 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<string | null>;
put(key: string, value: string, options: { expirationTtl: number }): Promise<void>;
delete(key: string): Promise<void>;
}
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<void> {
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<LocationPin | null> {
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<void> {
await kv.delete(`loc:${chatId}`);
}
+2 -1
View File
@@ -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);