/** * A17 — account/workspace identity as binding precondition (verdict #15). * * The frozen invariant: binding remembers WHICH principal it was verified * under, but never claims that historical fact is the current login fact; * before any remote side effect, the current principal must be freshly * observed and matched. * * Identity source (A17.1, discovered live 2026-09-19): the opaque account * principal embedded in chatgpt.com localStorage key prefixes ("user-/"). * Verified live: identical across the bound conversation tab and the home tab, * across 31 localStorage keys and the profile picture postcache reference; * opaque (not the display name/email); conversation-independent. The * oai-client-auth-info cookie carries name/email/picture — DIAGNOSTIC only, * never authority (verdict #15). * * State model (verdict #15 Q3/Q4): MATCH | MISMATCH | UNKNOWN. Side effects * fail closed on MISMATCH/UNKNOWN unless the deployment's documented * IDENTITY_ASSUMPTION=single scope applies (single fixed authenticated * principal — recorded as a scope limit, not as A17 being solved). Drift to * MISMATCH/UNKNOWN never unbinds, never rewrites durable state; return to the * original principal restores VALID. */ export type AffinityState = "MATCH" | "MISMATCH" | "UNKNOWN"; export interface ObservedIdentity { /** The opaque principal (e.g. the "user-" value). */ principal: string; source: "localstorage-user-prefix"; observedAt: number; } export interface BindingAffinity { /** The principal observed at bind time (verified fact, not a user label). */ observedIdentity: string; source: ObservedIdentity["source"]; verifiedAt: number; verificationVersion: 1; } /** * Pure extractor: the opaque principal from localStorage key names. Keys are * shaped "cache/user-/..." and "oai/apps/.../user-". Returns null when * no unique principal can be established (fail closed upstream). */ export function extractPrincipalFromKeys(keys: readonly string[]): string | null { const ids = new Set(); for (const key of keys) { const match = /(?:^|\/)user-([A-Za-z0-9_-]+)\//.exec(key); if (match) ids.add(match[1]); } if (ids.size !== 1) return null; // zero or conflicting -> cannot establish return [...ids][0]; } /** How the current fresh observation compares to the binding's recorded affinity. */ export function affinityState( current: string | null, recorded: BindingAffinity | null, assumptionSingle = identityAssumptionSingle(), ): AffinityState { if (recorded) { if (current === null) return "UNKNOWN"; // cannot verify now return current === recorded.observedIdentity ? "MATCH" : "MISMATCH"; } // No affinity recorded yet (pre-upgrade bindings, or bind had no page). // Under the documented single-fixed-principal deployment assumption this // scope limit lets the bridge keep working; the affinity field fills in on // the next bind. Outside that assumption, absent data is UNKNOWN -> closed. return assumptionSingle ? "MATCH" : "UNKNOWN"; } export function identityAssumptionSingle(): boolean { const v = process.env.PI_WEBGPT_IDENTITY_ASSUMPTION; return v === undefined || v === "" || v === "single"; }