/** Same-origin, scope-bound transport for HUX-10 privacy state. */ import {ACCEPT, API_VERSION} from "./contracts.ts"; import { FOUNDATION_FLAG, PRIVACY_FLAG, normalizeSnapshot, privacyControls, withoutNotice, } from "./model.ts"; import { normalizeIdentity, normalizeScope, privacyQuery, sameIdentity, } from "./security.ts"; import type { FetchResponse, HuxFoundationClient, PrivacyControl, PrivacyScope, PrivacySnapshot, } from "./types.ts"; interface ClientOptions { client: HuxFoundationClient; fetcher?: (url: string, init: Record) => Promise; } export class PrivacyContractError extends Error { constructor(message: string) { super(message); this.name = "PrivacyContractError"; } } function enabled(client: HuxFoundationClient): boolean { return client.apiVersion === API_VERSION && client.enabled(FOUNDATION_FLAG) && client.enabled(PRIVACY_FLAG); } function effectMatches(action: PrivacyControl, snapshot: PrivacySnapshot): boolean { if (action === "enable_no_store" || action === "switch_to_private") return snapshot.noStore; if (action === "disable_no_store") return !snapshot.noStore; if (action === "disable_memory_here") return !snapshot.memoryEnabled; if (action === "dismiss") return snapshot.notice === null; return action === "forget_this_conversation" && snapshot.noStore && !snapshot.memoryEnabled && snapshot.notice === null; } export function createPrivacyClient(options: ClientOptions) { const client = options?.client; const fetcher = options?.fetcher || (typeof fetch === "function" ? fetch.bind(globalThis) : null); if (!client || !fetcher) throw new TypeError("Privacy requires HUX and fetch clients"); const identity = normalizeIdentity({ tenant_ref: client.identity?.tenantRef, user_ref: client.identity?.userRef, surface: client.identity?.surface, }); if (!identity || !sameIdentity(identity, client.identity)) throw new TypeError("Privacy requires a scoped HUX identity"); const current = new Map(); const seenNotices = new Set(); function scopeKey(scope: PrivacyScope): string { return `${scope.sessionId}\u0000${scope.conversationId}`; } function minimizeRepeatedNotice(snapshot: PrivacySnapshot): PrivacySnapshot { if (!snapshot.notice) return snapshot; const key = `${snapshot.scope.conversationId}\u0000${snapshot.notice.shownAt}`; if (seenNotices.has(key)) return withoutNotice(snapshot); seenNotices.add(key); return snapshot; } async function request(path: string, init: Record): Promise { if (!enabled(client)) throw new PrivacyContractError("Privacy controls are not enabled"); const response = await fetcher(client.endpoint(path), { cache: "no-store", credentials: "same-origin", headers: {Accept: ACCEPT, ...(init.body ? {"Content-Type": "application/json"} : {})}, ...init, }); if (!response.ok) throw new PrivacyContractError( response.status === 409 ? "Privacy state changed; refresh before trying again" : `Privacy request failed (${response.status})`, ); return response.json(); } async function load(scope: PrivacyScope): Promise { const safeScope = normalizeScope(scope); if (!safeScope) throw new TypeError("Privacy scope is invalid"); const raw = await request(privacyQuery(safeScope), {method: "GET"}); const snapshot = normalizeSnapshot(raw, identity, safeScope); if (!snapshot) throw new PrivacyContractError("Privacy response crossed its scope"); const visible = minimizeRepeatedNotice(snapshot); current.set(scopeKey(safeScope), visible); return visible; } async function control( snapshot: PrivacySnapshot, action: PrivacyControl, ): Promise { const key = snapshot?.scope ? scopeKey(snapshot.scope) : ""; if (current.get(key) !== snapshot || !privacyControls(snapshot).includes(action)) throw new TypeError("Privacy control is not bound to the current conversation"); const raw = await request("/privacy/controls", { method: "POST", body: JSON.stringify({ schema: "hux.privacy_control_request.v1", session_id: snapshot.scope.sessionId, conversation_id: snapshot.scope.conversationId, expected_revision: snapshot.revision, action, }), }); const updated = normalizeSnapshot(raw, identity, snapshot.scope); if (!updated || updated.revision <= snapshot.revision || !effectMatches(action, updated)) throw new PrivacyContractError("Privacy update could not be verified"); const visible = minimizeRepeatedNotice(updated); current.set(key, visible); return visible; } return Object.freeze({enabled: () => enabled(client), load, control}); }