forked from c-fu/arah2
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:
@@ -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}`);
|
||||||
|
}
|
||||||
@@ -22,7 +22,8 @@ import { test } from "./harness.mjs";
|
|||||||
|
|
||||||
const SRC = path.join(fileURLToPath(new URL(import.meta.url)), "..", "..", "src");
|
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");
|
const webhookModule = path.join(SRC, "webhook.ts");
|
||||||
if (existsSync(webhookModule)) storageModules.push(webhookModule);
|
if (existsSync(webhookModule)) storageModules.push(webhookModule);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user