feat(06-03): guidance envelope service — TTL-only mint/get/delete beneath the frozen shape
- mintGuidanceSession: crypto.randomUUID id, claim-derived chat_id arg, ONE put at guidance:<id> with same-line expirationTtl (GE1/PG1) - getGuidanceSession: UUID-shape guard BEFORE any KV touch (V5), exact 4+6-field + type + finite/range validation (the location-pin defensive read law) — corrupt records read as null, never reach the cron - deleteGuidanceSession: active trip-end sweep (D-14's server half) - module header: the types-only note becomes the shape-frozen + service- owns-every-write law; envelope/snapshot interfaces byte-identical (GE2) - no new namespace (GE5), no new Env field (GE6) — the chat_sessions binding is handed in structurally
This commit is contained in:
@@ -1,13 +1,23 @@
|
|||||||
/*
|
/*
|
||||||
* guidance-sessions.ts — the FROZEN Phase 6 contract, types-only (D-06/D-07).
|
* guidance-sessions.ts — the frozen Phase 5 envelope + its TTL-only KV service
|
||||||
|
* (D-06/D-07, GUID-05; service live as of Phase 6 plan 06-03).
|
||||||
* ------------------------------------------------------------------------
|
* ------------------------------------------------------------------------
|
||||||
* THIS MODULE SHIPS NO BEHAVIOR. No KV binding, no Env field, no wrangler
|
* SHAPE FROZEN (GE2): the four envelope fields and six snapshot fields below
|
||||||
* namespace, no runtime function — the envelope is designed here (ROADMAP
|
* are the Phase 5 contract — byte-identical, a 5th property means drift.
|
||||||
* §Phase 5 GUID-05/06 upstream obligation) so Phase 6 builds its service
|
* The SERVICE (Phase 6, plan 06-03) owns every write; every write carries
|
||||||
* on a frozen shape. Phase 6's service owns every write; every write
|
* expirationTtl: GUIDANCE_SESSION_TTL_SECONDS (~2h) and the snapshot is
|
||||||
* carries expirationTtl: GUIDANCE_SESSION_TTL_SECONDS (~2h) and the
|
* INLINE (one key, one TTL) — nothing outlives 2h, nothing is
|
||||||
* snapshot is INLINE (one key, one TTL) — nothing outlives 2h, nothing is
|
|
||||||
* reconstructable after expiry (D-07, the location-pin TTL precedent).
|
* reconstructable after expiry (D-07, the location-pin TTL precedent).
|
||||||
|
* privacy.test.mjs PG1 pins the same-line TTL law over this file.
|
||||||
|
*
|
||||||
|
* Identity law (T-06-02): chat_id is minted server-side from the Bearer
|
||||||
|
* claim — the service takes it as an argument; no endpoint may pass a
|
||||||
|
* client-supplied chat id.
|
||||||
|
*
|
||||||
|
* Defensive read law (the location-pin precedent): a missing, malformed,
|
||||||
|
* or tampered record reads as null — the cron (plan 07) can never push
|
||||||
|
* from garbage. Ids that fail the UUID shape never touch KV (V5: no
|
||||||
|
* arbitrary key probing).
|
||||||
*
|
*
|
||||||
* Key scheme: guidance:<id> → JSON GuidanceSessionEnvelope
|
* Key scheme: guidance:<id> → JSON GuidanceSessionEnvelope
|
||||||
*/
|
*/
|
||||||
@@ -56,3 +66,130 @@ export interface GuidanceSessionEnvelope {
|
|||||||
/** Envelope creation, seconds-since-epoch. */
|
/** Envelope creation, seconds-since-epoch. */
|
||||||
created_at: number;
|
created_at: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ————————————————————————————————————————————————————————————————————————
|
||||||
|
* The service (Phase 6, plan 06-03) — every write TTL-only, every read
|
||||||
|
* defensive. The KV seam is structural (the sessions.ts/location-pin.ts
|
||||||
|
* precedent): production hands the existing `chat_sessions` binding —
|
||||||
|
* no new namespace (GE5), no new Env field (GE6).
|
||||||
|
* ———————————————————————————————————————————————————————————————————————— */
|
||||||
|
|
||||||
|
interface GuidanceSessionKv {
|
||||||
|
get(key: string): Promise<string | null>;
|
||||||
|
put(key: string, value: string, options: { expirationTtl: number }): Promise<void>;
|
||||||
|
delete(key: string): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Envelope ids are lowercase UUIDs — anything else never constructs a KV
|
||||||
|
* key (V5: a malformed id must not become arbitrary-key probing). */
|
||||||
|
const ENVELOPE_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||||
|
|
||||||
|
/** Exact persisted field sets (GE2's runtime twin) — sorted for compare. */
|
||||||
|
const ENVELOPE_KEYS = ["chat_id", "created_at", "id", "route_snapshot"];
|
||||||
|
const SNAPSHOT_KEYS = [
|
||||||
|
"destinationId",
|
||||||
|
"destinationName",
|
||||||
|
"distanceM",
|
||||||
|
"durationS",
|
||||||
|
"geometry",
|
||||||
|
"startedAtSec",
|
||||||
|
];
|
||||||
|
|
||||||
|
function hasExactKeys(value: object, expected: readonly string[]): boolean {
|
||||||
|
const keys = Object.keys(value).sort();
|
||||||
|
return keys.length === expected.length && expected.every((key, i) => keys[i] === key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUuidShape(id: string): boolean {
|
||||||
|
return ENVELOPE_ID_PATTERN.test(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function keyFor(id: string): string {
|
||||||
|
return `guidance:${id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mint an envelope under a fresh opaque id and write it ONCE, TTL on the
|
||||||
|
* call line (GE1/PG1). `chatId` is the CALLER'S CLAIM-derived chat — the
|
||||||
|
* service never learns of request bodies. `nowSec` is injectable for
|
||||||
|
* deterministic tests; production stamps the current time.
|
||||||
|
*/
|
||||||
|
export async function mintGuidanceSession(
|
||||||
|
kv: GuidanceSessionKv,
|
||||||
|
chatId: number,
|
||||||
|
snapshot: GuidanceRouteSnapshot,
|
||||||
|
nowSec: number = Math.floor(Date.now() / 1000),
|
||||||
|
): Promise<GuidanceSessionEnvelope> {
|
||||||
|
const envelope: GuidanceSessionEnvelope = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
chat_id: chatId,
|
||||||
|
route_snapshot: snapshot,
|
||||||
|
created_at: nowSec,
|
||||||
|
};
|
||||||
|
await kv.put(keyFor(envelope.id), JSON.stringify(envelope), { expirationTtl: GUIDANCE_SESSION_TTL_SECONDS });
|
||||||
|
return envelope;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defensive read (the location-pin law): null unless the record parses,
|
||||||
|
* carries EXACTLY the frozen envelope fields with well-typed, in-range
|
||||||
|
* values. A corrupt or tampered record can never reach the cron (plan 07).
|
||||||
|
* A malformed id returns null BEFORE any KV touch (V5).
|
||||||
|
*/
|
||||||
|
export async function getGuidanceSession(
|
||||||
|
kv: GuidanceSessionKv,
|
||||||
|
id: string,
|
||||||
|
): Promise<GuidanceSessionEnvelope | null> {
|
||||||
|
if (!isUuidShape(id)) return null;
|
||||||
|
const raw = await kv.get(keyFor(id));
|
||||||
|
if (raw === null) return null;
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(raw);
|
||||||
|
if (
|
||||||
|
parsed &&
|
||||||
|
typeof parsed === "object" &&
|
||||||
|
!Array.isArray(parsed) &&
|
||||||
|
hasExactKeys(parsed, ENVELOPE_KEYS) &&
|
||||||
|
isEnvelopeValid(parsed as GuidanceSessionEnvelope)
|
||||||
|
) {
|
||||||
|
return parsed as GuidanceSessionEnvelope;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Field law under the exact-key check: UUID-shaped id, integer chat_id and
|
||||||
|
* epoch stamps, a snapshot that itself carries exactly its six fields with
|
||||||
|
* finite non-negative metrics and non-empty strings (a 5th snapshot
|
||||||
|
* property or a negative duration reads as corruption). */
|
||||||
|
function isEnvelopeValid(envelope: GuidanceSessionEnvelope): boolean {
|
||||||
|
if (!isUuidShape(envelope.id)) return false;
|
||||||
|
if (!Number.isSafeInteger(envelope.chat_id)) return false;
|
||||||
|
if (!Number.isSafeInteger(envelope.created_at) || envelope.created_at <= 0) return false;
|
||||||
|
const snap = envelope.route_snapshot;
|
||||||
|
if (!snap || typeof snap !== "object" || Array.isArray(snap)) return false;
|
||||||
|
if (!hasExactKeys(snap, SNAPSHOT_KEYS)) return false;
|
||||||
|
if (typeof snap.destinationId !== "string" || snap.destinationId.length === 0) return false;
|
||||||
|
if (typeof snap.destinationName !== "string" || snap.destinationName.length === 0) return false;
|
||||||
|
if (typeof snap.geometry !== "string" || snap.geometry.length === 0) return false;
|
||||||
|
if (typeof snap.distanceM !== "number" || !Number.isFinite(snap.distanceM) || snap.distanceM < 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (typeof snap.durationS !== "number" || !Number.isFinite(snap.durationS) || snap.durationS < 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!Number.isSafeInteger(snap.startedAtSec) || snap.startedAtSec <= 0) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Explicit sweep — the trip-end half of the TTL law (D-14): the record is
|
||||||
|
* removed ACTIVELY, not merely left to expire. A malformed id is a silent
|
||||||
|
* no-op (nothing to delete, no key ever constructed).
|
||||||
|
*/
|
||||||
|
export async function deleteGuidanceSession(kv: GuidanceSessionKv, id: string): Promise<void> {
|
||||||
|
if (!isUuidShape(id)) return;
|
||||||
|
await kv.delete(keyFor(id));
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user