/** Fail-closed privacy.schema.json adapters for the HUX-10 frontend. */ import { asRecord, exactKeys, isSafeText, isUtc, normalizeIdentity, sameIdentity, } from "./security.ts"; import type { PrivacyControl, PrivacyNoticeView, PrivacyScope, PrivacySnapshot, RawPrivacyNotice, ScopedIdentity, } from "./types.ts"; export const FOUNDATION_FLAG = "hux.foundation"; export const PRIVACY_FLAG = "hux.privacy"; export const GENERIC_NOTICE = "Sensitive-topic protections are active. This context stays in this conversation and is not remembered without your approval."; const TOPIC_RULES = Object.freeze({ health: {memoryWrite: "ask", decayDays: 30}, finance: {memoryWrite: "ask", decayDays: 30}, legal: {memoryWrite: "ask", decayDays: 30}, relationships: {memoryWrite: "ask", decayDays: 14}, credentials: {memoryWrite: "deny", decayDays: 1}, minors: {memoryWrite: "deny", decayDays: 7}, location: {memoryWrite: "deny", decayDays: 7}, biometric: {memoryWrite: "deny", decayDays: 1}, } as const); const NOTICE_CONTROLS = new Set([ "forget_this_conversation", "switch_to_private", "disable_memory_here", "dismiss", ]); const RETENTION_SCOPES = new Set(["conversation", "session"]); const MEMORY_WRITES = new Set(["ask", "deny"]); const SHARING = new Set(["never", "same_owner_only"]); function integer(value: unknown, min: number, max: number): value is number { return Number.isSafeInteger(value) && (value as number) >= min && (value as number) <= max; } export function privacyEnabled(flags?: Iterable): boolean { if (!flags) return false; const enabled = new Set(flags); return enabled.has(FOUNDATION_FLAG) && enabled.has(PRIVACY_FLAG); } export function normalizeNotice( raw: RawPrivacyNotice, conversationId: string, expectedMemoryWrite: "ask" | "deny", expectedDecayDays: number | null, ): PrivacyNoticeView | null { const value = asRecord(raw); if (!value || !exactKeys(value, [ "schema", "topic", "conversation_id", "text", "controls", "shown_at", ]) || value.schema !== "hux.privacy_notice.v1" || !Object.prototype.hasOwnProperty.call(TOPIC_RULES, value.topic as string) || value.conversation_id !== conversationId || !isSafeText(value.text, 1, 280) || !isUtc(value.shown_at) || !Array.isArray(value.controls) || value.controls.length < 1 || value.controls.length > NOTICE_CONTROLS.size || value.controls.some((control) => !NOTICE_CONTROLS.has(control as PrivacyControl)) || new Set(value.controls).size !== value.controls.length) return null; const rule = TOPIC_RULES[value.topic as keyof typeof TOPIC_RULES]; if (rule.memoryWrite !== expectedMemoryWrite || rule.decayDays !== expectedDecayDays) return null; // Never carry the category or server text into view state. The server record // is validated, then reduced to one generic sentence and explicit controls. return { schema: "hux.privacy_notice.v1", text: GENERIC_NOTICE, controls: value.controls as PrivacyControl[], shownAt: value.shown_at as string, }; } export function normalizeSnapshot( raw: unknown, expectedIdentity: ScopedIdentity, expectedScope: PrivacyScope, ): PrivacySnapshot | null { const value = asRecord(raw); if (!value || !exactKeys(value, [ "schema", "api_version", "identity", "session_id", "conversation_id", "revision", "no_store", "memory_enabled", "retention", "notice", ]) || value.schema !== "hux.privacy_snapshot.v1" || value.api_version !== "hux.v1" || value.session_id !== expectedScope.sessionId || value.conversation_id !== expectedScope.conversationId || !integer(value.revision, 1, Number.MAX_SAFE_INTEGER) || typeof value.no_store !== "boolean" || typeof value.memory_enabled !== "boolean") return null; const identity = normalizeIdentity(value.identity); const retention = asRecord(value.retention); if (!identity || !sameIdentity(identity, expectedIdentity) || !retention || !exactKeys(retention, [ "scope", "memory_write", "topic_context_decay_days", "audit_interval_days", "next_audit_at", "cross_surface_sharing", ]) || !RETENTION_SCOPES.has(retention.scope as string) || !MEMORY_WRITES.has(retention.memory_write as string) || (retention.topic_context_decay_days !== null && !integer(retention.topic_context_decay_days, 1, 365)) || !integer(retention.audit_interval_days, 1, 30) || !isUtc(retention.next_audit_at) || !SHARING.has(retention.cross_surface_sharing as string)) return null; const memoryWrite = retention.memory_write as "ask" | "deny"; const decayDays = retention.topic_context_decay_days as number | null; if (value.no_store ? retention.scope !== "session" || value.memory_enabled || memoryWrite !== "deny" || decayDays !== null : retention.scope !== "conversation" || (value.memory_enabled !== (memoryWrite === "ask"))) return null; const notice = value.notice === null ? null : normalizeNotice( value.notice as RawPrivacyNotice, expectedScope.conversationId, memoryWrite, decayDays, ); if (value.notice !== null && !notice) return null; return { schema: "hux.privacy_snapshot.v1", identity, scope: expectedScope, revision: value.revision as number, noStore: value.no_store, memoryEnabled: value.memory_enabled, retention: { scope: retention.scope as "conversation" | "session", memoryWrite, topicContextDecayDays: decayDays, auditIntervalDays: retention.audit_interval_days as number, nextAuditAt: retention.next_audit_at as string, crossSurfaceSharing: retention.cross_surface_sharing as "never" | "same_owner_only", }, notice, }; } export function withoutNotice(snapshot: PrivacySnapshot): PrivacySnapshot { return snapshot.notice ? {...snapshot, notice: null} : snapshot; } export function privacyControls(snapshot: PrivacySnapshot): PrivacyControl[] { const controls: PrivacyControl[] = [ snapshot.noStore ? "disable_no_store" : "enable_no_store", ...(snapshot.memoryEnabled ? ["disable_memory_here" as const] : []), ...(snapshot.notice?.controls || []), ]; return [...new Set(controls)]; }