import assert from "node:assert/strict"; import test from "node:test"; import { createPrivacyClient, PrivacyContractError, } from "../../dockerfiles/hermes-webui-hux/privacy/client.ts"; import { FOUNDATION_FLAG, GENERIC_NOTICE, PRIVACY_FLAG, normalizeNotice, normalizeSnapshot, privacyControls, privacyEnabled, withoutNotice, } from "../../dockerfiles/hermes-webui-hux/privacy/model.ts"; import { asRecord, exactKeys, isId, isSafeText, isUtc, normalizeIdentity, normalizeScope, privacyQuery, sameIdentity, } from "../../dockerfiles/hermes-webui-hux/privacy/security.ts"; const IDENTITY = Object.freeze({ tenantRef: "tnt_0123456789abcdef", userRef: "usr_0123456789abcdef", surface: "chat", }); const RAW_IDENTITY = Object.freeze({ tenant_ref: IDENTITY.tenantRef, user_ref: IDENTITY.userRef, surface: IDENTITY.surface, }); const SCOPE = Object.freeze({ sessionId: "ses_alpha1234", conversationId: "conv_alpha1234", }); const SHOWN_AT = "2026-08-24T10:00:00Z"; function notice(extra = {}) { return { schema: "hux.privacy_notice.v1", topic: "health", conversation_id: SCOPE.conversationId, text: "This looks like a health topic. It stays in this conversation.", controls: [ "forget_this_conversation", "switch_to_private", "disable_memory_here", "dismiss", ], shown_at: SHOWN_AT, ...extra, }; } function retention(extra = {}) { return { scope: "conversation", memory_write: "ask", topic_context_decay_days: 30, audit_interval_days: 1, next_audit_at: "2026-08-25T03:00:00Z", cross_surface_sharing: "never", ...extra, }; } function snapshot(extra = {}) { return { schema: "hux.privacy_snapshot.v1", api_version: "hux.v1", identity: RAW_IDENTITY, session_id: SCOPE.sessionId, conversation_id: SCOPE.conversationId, revision: 1, no_store: false, memory_enabled: true, retention: retention(), notice: notice(), ...extra, }; } function noStore(extra = {}) { return snapshot({ revision: 2, no_store: true, memory_enabled: false, retention: retention({scope: "session", memory_write: "deny", topic_context_decay_days: null}), notice: null, ...extra, }); } function memoryOff(extra = {}) { return snapshot({ revision: 2, memory_enabled: false, retention: retention({memory_write: "deny", topic_context_decay_days: null}), notice: null, ...extra, }); } function harness(responses, flags = [FOUNDATION_FLAG, PRIVACY_FLAG]) { const calls = []; const enabled = new Set(flags); const foundation = { apiVersion: "hux.v1", identity: IDENTITY, enabled: (flag) => enabled.has(flag), endpoint: (path) => `/hux/v1${path}`, }; const fetcher = async (url, init) => { calls.push({url, init}); const response = responses.shift(); if (!response) throw new Error("unexpected request"); return {ok: response.ok ?? true, status: response.status ?? 200, json: async () => response.body}; }; return {calls, foundation, client: createPrivacyClient({client: foundation, fetcher})}; } test("HUX-10 is default-off and requires the foundation", () => { assert.equal(privacyEnabled(), false); assert.equal(privacyEnabled([]), false); assert.equal(privacyEnabled([FOUNDATION_FLAG]), false); assert.equal(privacyEnabled([PRIVACY_FLAG]), false); assert.equal(privacyEnabled([FOUNDATION_FLAG, PRIVACY_FLAG]), true); }); test("security primitives bind opaque scope and same-origin query", () => { assert.deepEqual(asRecord({a: 1}), {a: 1}); assert.equal(asRecord(null), null); assert.equal(asRecord([]), null); assert.equal(exactKeys({a: 1, b: 2}, ["a"], ["b"]), true); assert.equal(exactKeys({a: 1, x: 2}, ["a"]), false); assert.equal(isId("conv_alpha1234", "conv"), true); assert.equal(isId("bad", "conv"), false); assert.equal(isId("ses_alpha1234", "conv"), false); assert.equal(isUtc("2026-08-24T10:00:00Z"), true); assert.equal(isUtc("2026-99-99T10:00:00Z"), false); assert.equal(isSafeText("safe", 1, 20), true); assert.equal(isSafeText("bad\u0000", 1, 20), false); assert.deepEqual(normalizeIdentity(RAW_IDENTITY), IDENTITY); for (const raw of [null, [], {}, {...RAW_IDENTITY, extra: true}, {...RAW_IDENTITY, tenant_ref: "bad"}, {...RAW_IDENTITY, user_ref: "bad"}, {...RAW_IDENTITY, surface: "browser"}]) assert.equal(normalizeIdentity(raw), null); assert.equal(sameIdentity(IDENTITY, IDENTITY), true); assert.equal(sameIdentity(IDENTITY, {...IDENTITY, surface: "worker"}), false); assert.deepEqual(normalizeScope(SCOPE), SCOPE); for (const raw of [null, {...SCOPE, extra: true}, {...SCOPE, sessionId: "bad"}, {...SCOPE, conversationId: "bad"}]) assert.equal(normalizeScope(raw), null); assert.equal(privacyQuery(SCOPE), "/privacy/snapshot?session_id=ses_alpha1234&conversation_id=conv_alpha1234"); assert.throws(() => privacyQuery({...SCOPE, sessionId: "bad"}), TypeError); }); test("notice validation drops category and source text from view state", () => { const item = normalizeNotice(notice(), SCOPE.conversationId, "ask", 30); assert.equal(item.text, GENERIC_NOTICE); assert.equal("topic" in item, false); assert.deepEqual(item.controls, notice().controls); const restricted = normalizeNotice(notice({topic: "credentials"}), SCOPE.conversationId, "deny", 1); assert.equal(restricted.text, GENERIC_NOTICE); const invalid = [null, {...notice(), extra: true}, {...notice(), schema: "old"}, {...notice(), topic: "politics"}, {...notice(), conversation_id: "conv_other1234"}, {...notice(), text: ""}, {...notice(), text: "bad\u0000"}, {...notice(), shown_at: "today"}, {...notice(), controls: null}, {...notice(), controls: []}, {...notice(), controls: [...notice().controls, "dismiss"]}, {...notice(), controls: ["unknown"]}]; invalid.forEach((raw) => assert.equal( normalizeNotice(raw, SCOPE.conversationId, "ask", 30), null)); assert.equal(normalizeNotice(notice(), SCOPE.conversationId, "deny", 30), null); assert.equal(normalizeNotice(notice(), SCOPE.conversationId, "ask", 1), null); }); test("snapshot exposes generic retention while enforcing policy semantics", () => { const item = normalizeSnapshot(snapshot(), IDENTITY, SCOPE); assert.equal(item.notice.text, GENERIC_NOTICE); assert.equal(item.retention.topicContextDecayDays, 30); const privateItem = normalizeSnapshot(noStore(), IDENTITY, SCOPE); assert.equal(privateItem.noStore, true); assert.equal(privateItem.retention.scope, "session"); assert.equal(normalizeSnapshot(memoryOff(), IDENTITY, SCOPE).memoryEnabled, false); const restricted = normalizeSnapshot(snapshot({memory_enabled: false, retention: retention({memory_write: "deny", topic_context_decay_days: 1}), notice: notice({topic: "credentials"})}), IDENTITY, SCOPE); assert.equal(restricted.notice.text, GENERIC_NOTICE); const changedTopic = normalizeSnapshot(snapshot({notice: null}), IDENTITY, SCOPE); assert.equal(changedTopic.notice, null); }); test("snapshot rejects malformed scope, retention, no-store and notice state", () => { const invalid = [null, {...snapshot(), extra: true}, {...snapshot(), schema: "old"}, {...snapshot(), api_version: "hux.v0"}, {...snapshot(), identity: {...RAW_IDENTITY, surface: "worker"}}, {...snapshot(), session_id: "ses_other1234"}, {...snapshot(), conversation_id: "conv_other1234"}, {...snapshot(), revision: 0}, {...snapshot(), no_store: "no"}, {...snapshot(), memory_enabled: "yes"}, {...snapshot(), retention: null}, {...snapshot(), retention: {...retention(), extra: true}}, {...snapshot(), retention: retention({scope: "forever"})}, {...snapshot(), retention: retention({memory_write: "automatic"})}, {...snapshot(), retention: retention({topic_context_decay_days: 0})}, {...snapshot(), retention: retention({audit_interval_days: 31})}, {...snapshot(), retention: retention({next_audit_at: "tomorrow"})}, {...snapshot(), retention: retention({cross_surface_sharing: "everyone"})}, {...snapshot(), no_store: true}, {...noStore(), retention: retention({scope: "session", memory_write: "deny", topic_context_decay_days: 1})}, {...noStore(), memory_enabled: true}, {...snapshot(), memory_enabled: false}, {...snapshot(), notice: notice({topic: "credentials"})}, ]; invalid.forEach((raw) => assert.equal(normalizeSnapshot(raw, IDENTITY, SCOPE), null)); }); test("view helpers suppress repeats and expose only effective controls", () => { const item = normalizeSnapshot(snapshot(), IDENTITY, SCOPE); assert.equal(withoutNotice(item).notice, null); const changed = withoutNotice({...item, notice: null}); assert.equal(changed.notice, null); assert.deepEqual(privacyControls(item), [ "enable_no_store", "disable_memory_here", "forget_this_conversation", "switch_to_private", "dismiss", ]); assert.deepEqual(privacyControls(normalizeSnapshot(noStore(), IDENTITY, SCOPE)), ["disable_no_store"]); }); test("client loads scoped no-cache state and does not reiterate notices", async () => { const current = harness([{body: snapshot()}, {body: snapshot()}, {body: snapshot({revision: 2, notice: null})}, {body: snapshot({revision: 3, notice: notice({topic: "finance", shown_at: "2026-08-24T11:00:00Z"})})}]); assert.equal(current.client.enabled(), true); const first = await current.client.load(SCOPE); assert.ok(first.notice); assert.equal((await current.client.load(SCOPE)).notice, null); assert.equal((await current.client.load(SCOPE)).notice, null); assert.ok((await current.client.load(SCOPE)).notice); assert.equal(current.calls[0].url, "/hux/v1/privacy/snapshot?session_id=ses_alpha1234&conversation_id=conv_alpha1234"); assert.equal(current.calls[0].init.method, "GET"); assert.equal(current.calls[0].init.cache, "no-store"); assert.equal(current.calls[0].init.credentials, "same-origin"); }); test("client applies each explicit control with optimistic scope binding", async () => { const cases = [ ["enable_no_store", noStore()], ["switch_to_private", noStore()], ["forget_this_conversation", noStore()], ["disable_memory_here", memoryOff()], ["dismiss", snapshot({revision: 2, notice: null})], ]; for (const [action, result] of cases) { const current = harness([{body: snapshot()}, {body: result}]); const before = await current.client.load(SCOPE); const after = await current.client.control(before, action); assert.equal(after.revision, 2); assert.equal(current.calls[1].url, "/hux/v1/privacy/controls"); assert.deepEqual(JSON.parse(current.calls[1].init.body), { schema: "hux.privacy_control_request.v1", session_id: SCOPE.sessionId, conversation_id: SCOPE.conversationId, expected_revision: 1, action, }); await assert.rejects(current.client.control(before, action), /not bound/); } const current = harness([{body: noStore({revision: 1})}, {body: snapshot({revision: 2, notice: null})}]); const before = await current.client.load(SCOPE); assert.equal((await current.client.control(before, "disable_no_store")).noStore, false); }); test("client fails closed for disabled, invalid, stale and cross-scope data", async () => { const disabled = harness([], [FOUNDATION_FLAG]); assert.equal(disabled.client.enabled(), false); await assert.rejects(disabled.client.load(SCOPE), /not enabled/); disabled.foundation.apiVersion = "hux.v0"; await assert.rejects(disabled.client.load(SCOPE), /not enabled/); const invalidScope = harness([]); await assert.rejects(invalidScope.client.load({...SCOPE, sessionId: "bad"}), /scope/); await assert.rejects(invalidScope.client.control({}, "enable_no_store"), /not bound/); for (const response of [{ok: false, status: 409}, {ok: false, status: 503}]) { const current = harness([response]); await assert.rejects(current.client.load(SCOPE), PrivacyContractError); } const malformed = harness([{body: {}}]); await assert.rejects(malformed.client.load(SCOPE), /crossed/); const wrongEffect = harness([{body: snapshot()}, {body: snapshot({revision: 2})}]); const item = await wrongEffect.client.load(SCOPE); await assert.rejects(wrongEffect.client.control(item, "enable_no_store"), /verified/); const stale = harness([{body: snapshot()}, {body: noStore({revision: 1})}]); await assert.rejects(stale.client.control(await stale.client.load(SCOPE), "enable_no_store"), /verified/); assert.throws(() => createPrivacyClient({client: null, fetcher: async () => ({})}), /requires/); assert.throws(() => createPrivacyClient({client: {...invalidScope.foundation, identity: {...IDENTITY, userRef: "bad"}}, fetcher: async () => ({})}), /identity/); const originalFetch = globalThis.fetch; try { globalThis.fetch = undefined; assert.throws(() => createPrivacyClient({client: invalidScope.foundation}), /requires/); } finally { globalThis.fetch = originalFetch; } });