import assert from "node:assert/strict"; import test from "node:test"; import { createOnboardingClient, OnboardingContractError, } from "../../dockerfiles/hermes-webui-hux/onboarding/client.ts"; import { FOUNDATION_FLAG, FRIENDLY_MODES_FLAG, ONBOARDING_FLAG, PRIVACY_FLAG, PROJECTS_FLAG, buildActionIntent, normalizeDecisionState, normalizeEvaluation, normalizeState, normalizeSuggestion, onboardingEnabled, } from "../../dockerfiles/hermes-webui-hux/onboarding/model.ts"; import { asRecord, exactKeys, isId, isSafeText, isUtc, normalizeIdentity, normalizeScope, normalizeTrigger, sameIdentity, sameTrigger, scopedSuggestionPath, } from "../../dockerfiles/hermes-webui-hux/onboarding/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", trigger: {surface: "chat", context: "after_approval"}, }); const FLAGS = [ FOUNDATION_FLAG, PROJECTS_FLAG, FRIENDLY_MODES_FLAG, ONBOARDING_FLAG, PRIVACY_FLAG, ]; function suggestion(extra = {}) { return { schema: "hux.suggestion.v1", id: "sug_alpha1234", kind: "workflow", trigger: SCOPE.trigger, title: "Run this every week?", body: "Hermes can prepare the same report each week after you confirm it.", action: {type: "start_workflow", payload: {intent: "schedule_weekly"}}, priority: 35, suppression: { dismissable: true, max_shows: 2, cooldown_seconds: 86400, never_again_supported: true, }, ...extra, }; } function state(extra = {}) { return { schema: "hux.suggestion_state.v1", owner: IDENTITY.userRef, suggestion_id: "sug_alpha1234", shows: 1, last_shown_at: "2026-08-24T10:00:00Z", never_again: false, ...extra, }; } function evaluation(extra = {}) { return { schema: "hux.suggestion_evaluation.v1", api_version: "hux.v1", identity: RAW_IDENTITY, session_id: SCOPE.sessionId, conversation_id: SCOPE.conversationId, trigger: SCOPE.trigger, privacy: { schema: "hux.onboarding_privacy.v1", no_store: false, memory_suggestions_allowed: true, }, claim_id: "clm_alpha1234", issued_at: "2026-08-24T10:00:01Z", suggestion: suggestion(), state: state(), ...extra, }; } function decisionEnvelope(item, decision = "dismiss", extra = {}) { const timestamp = "2026-08-24T10:01:00Z"; return { schema: "hux.suggestion_decision.v1", api_version: "hux.v1", identity: RAW_IDENTITY, session_id: SCOPE.sessionId, conversation_id: SCOPE.conversationId, claim_id: "clm_alpha1234", suggestion_id: "sug_alpha1234", state: { ...state(), ...(decision === "acted" ? {acted_at: timestamp} : {dismissed_at: timestamp}), never_again: decision === "never_again", }, ...extra, ...(item ? {state: item} : {}), }; } function harness(responses, flags = FLAGS) { 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: createOnboardingClient({client: foundation, fetcher})}; } test("HUX-09 is default-off and requires all four dependencies", () => { assert.equal(onboardingEnabled(), false); assert.equal(onboardingEnabled([]), false); for (const flag of FLAGS.slice(0, 4)) { assert.equal(onboardingEnabled(FLAGS.slice(0, 4).filter((item) => item !== flag)), false); } assert.equal(onboardingEnabled(FLAGS.slice(0, 4)), true); }); test("security primitives bind exact identity, session and trigger", () => { 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("sug_alpha1234", "sug"), true); assert.equal(isId("bad", "sug"), false); assert.equal(isId("clm_alpha1234", "sug"), false); assert.equal(isUtc("2026-08-24T10:00:00Z"), true); assert.equal(isUtc("2026-99-99T10:00:00Z"), false); assert.equal(isSafeText("hello", 1, 10), true); assert.equal(isSafeText("bad\u0000", 1, 10), 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(normalizeTrigger(SCOPE.trigger), SCOPE.trigger); for (const raw of [null, {}, {...SCOPE.trigger, extra: true}, {...SCOPE.trigger, surface: "browser"}, {...SCOPE.trigger, context: "often"}]) assert.equal(normalizeTrigger(raw), null); assert.equal(sameTrigger(SCOPE.trigger, SCOPE.trigger), true); assert.equal(sameTrigger(SCOPE.trigger, {...SCOPE.trigger, context: "idle"}), false); assert.deepEqual(normalizeScope(SCOPE, IDENTITY), SCOPE); for (const raw of [{...SCOPE, extra: true}, {...SCOPE, sessionId: "bad"}, {...SCOPE, conversationId: "bad"}, {...SCOPE, trigger: {...SCOPE.trigger, surface: "worker"}}]) assert.equal(normalizeScope(raw, IDENTITY), null); assert.equal(scopedSuggestionPath("/onboarding/suggestions/", "sug_alpha1234"), "/onboarding/suggestions/sug_alpha1234/decisions"); for (const [base, id] of [["//evil", "sug_alpha1234"], ["/x?y", "sug_alpha1234"], ["/../x", "sug_alpha1234"], ["/safe", "bad"]]) assert.throws(() => scopedSuggestionPath(base, id), TypeError); }); test("suggestion schema normalizes all four contextual actions", () => { const workflow = normalizeSuggestion(suggestion(), SCOPE.trigger); assert.equal(workflow.action.payload.intent, "schedule_weekly"); assert.equal(workflow.suppression.cooldownSeconds, 86400); const save = normalizeSuggestion(suggestion({ action: {type: "start_workflow", payload: {intent: "save_reusable"}}, }), SCOPE.trigger); assert.equal(save.action.payload.intent, "save_reusable"); const project = normalizeSuggestion(suggestion({kind: "project", action: {type: "create_project", payload: {prefill_title: "Launch plan"}}}), SCOPE.trigger); assert.equal(project.action.payload.prefill_title, "Launch plan"); const memory = normalizeSuggestion(suggestion({kind: "feature", action: {type: "open_memory", payload: {view: "suggestions"}}}), SCOPE.trigger); assert.equal(memory.action.type, "open_memory"); const mode = normalizeSuggestion(suggestion({kind: "feature", action: {type: "open_mode", payload: {mode: "research"}}}), SCOPE.trigger); assert.equal(mode.action.payload.mode, "research"); const artifacts = normalizeSuggestion(suggestion({kind: "feature", action: {type: "open_artifacts", payload: {view: "current"}}}), SCOPE.trigger); assert.equal(artifacts.action.type, "open_artifacts"); const tip = normalizeSuggestion(suggestion({kind: "tip", action: undefined}), SCOPE.trigger); assert.equal(tip.action.type, "none"); assert.deepEqual(normalizeSuggestion(suggestion({kind: "project", action: {type: "create_project"}}), SCOPE.trigger).action.payload, {}); assert.deepEqual(normalizeSuggestion(suggestion({kind: "feature", action: {type: "open_mode"}}), SCOPE.trigger).action.payload, {}); assert.deepEqual(normalizeSuggestion(suggestion({kind: "feature", action: {type: "open_memory"}}), SCOPE.trigger).action.payload, {}); assert.deepEqual(normalizeSuggestion(suggestion({kind: "feature", action: {type: "open_artifacts"}}), SCOPE.trigger).action.payload, {}); }); test("malformed, mismatched, or pushy suggestions fail closed", () => { const invalid = [ null, {...suggestion(), extra: true}, {...suggestion(), schema: "hux.suggestion.v0"}, {...suggestion(), id: "bad"}, {...suggestion(), kind: "ad"}, {...suggestion(), title: ""}, {...suggestion(), title: "x".repeat(81)}, {...suggestion(), body: "bad\u0000"}, {...suggestion(), priority: 101}, {...suggestion(), trigger: {...SCOPE.trigger, context: "idle"}}, {...suggestion(), suppression: null}, {...suggestion(), suppression: {...suggestion().suppression, dismissable: false}}, {...suggestion(), suppression: {...suggestion().suppression, max_shows: 6}}, {...suggestion(), suppression: {...suggestion().suppression, cooldown_seconds: 30}}, {...suggestion(), suppression: {...suggestion().suppression, never_again_supported: false}}, {...suggestion(), action: null}, {...suggestion(), action: {type: "unknown"}}, {...suggestion(), action: {type: "start_workflow", payload: {intent: "daily"}}}, {...suggestion(), action: {type: "start_workflow", payload: {intent: "schedule_weekly", extra: 1}}}, {...suggestion(), action: {type: "create_project", payload: {prefill_title: ""}}}, {...suggestion(), action: {type: "create_project", payload: {prefill_title: "x".repeat(121)}}}, {...suggestion(), action: {type: "create_project", payload: {extra: true}}}, {...suggestion(), action: {type: "open_mode", payload: {mode: "turbo"}}}, {...suggestion(), action: {type: "open_mode", payload: {extra: true}}}, {...suggestion(), action: {type: "open_memory", payload: {view: "all"}}}, {...suggestion(), action: {type: "open_memory", payload: {extra: true}}}, {...suggestion(), action: {type: "open_artifacts", payload: {view: "all"}}}, {...suggestion(), action: {type: "open_artifacts", payload: {extra: true}}}, {...suggestion(), action: {type: "none", payload: {surprise: true}}}, {...suggestion(), kind: "project"}, ]; invalid.forEach((raw) => assert.equal(normalizeSuggestion(raw, SCOPE.trigger), null)); }); test("eligible state is owner-bound and inside the server rate limit", () => { const item = normalizeSuggestion(suggestion(), SCOPE.trigger); assert.equal(normalizeState(state(), item, IDENTITY.userRef).shows, 1); const complete = normalizeState(state({dismissed_at: "2026-08-24T10:01:00Z", acted_at: "2026-08-24T10:02:00Z"}), item, IDENTITY.userRef); assert.equal(complete.dismissedAt, "2026-08-24T10:01:00Z"); assert.equal(complete.actedAt, "2026-08-24T10:02:00Z"); const invalid = [null, {...state(), extra: true}, {...state(), schema: "old"}, {...state(), owner: "usr_aaaaaaaaaaaaaaaa"}, {...state(), suggestion_id: "sug_other1234"}, {...state(), shows: 0}, {...state(), shows: 3}, {...state(), last_shown_at: "today"}, {...state(), never_again: true}, {...state(), dismissed_at: "today"}, {...state(), acted_at: "today"}]; invalid.forEach((raw) => assert.equal(normalizeState(raw, item, IDENTITY.userRef), null)); }); test("evaluation binds identity, session, trigger, privacy and one claim", () => { const item = normalizeEvaluation(evaluation(), IDENTITY, SCOPE, true); assert.equal(item.claimId, "clm_alpha1234"); assert.equal(item.state.owner, IDENTITY.userRef); assert.equal(normalizeEvaluation(evaluation({privacy: { schema: "hux.onboarding_privacy.v1", no_store: true, memory_suggestions_allowed: false}, claim_id: null, suggestion: null, state: null}), IDENTITY, SCOPE, true), null); assert.equal(normalizeEvaluation(evaluation({suggestion: null, claim_id: null, state: null}), IDENTITY, SCOPE, true), null); const memoryRaw = evaluation({suggestion: suggestion({kind: "feature", action: {type: "open_memory", payload: {view: "suggestions"}}})}); assert.equal(normalizeEvaluation(memoryRaw, IDENTITY, SCOPE, false), null); assert.equal(normalizeEvaluation({...memoryRaw, privacy: {...memoryRaw.privacy, memory_suggestions_allowed: false}}, IDENTITY, SCOPE, true), null); assert.ok(normalizeEvaluation(memoryRaw, IDENTITY, SCOPE, true)); const invalid = [null, {...evaluation(), extra: true}, {...evaluation(), schema: "old"}, {...evaluation(), api_version: "hux.v0"}, {...evaluation(), identity: {...RAW_IDENTITY, surface: "worker"}}, {...evaluation(), session_id: "ses_other1234"}, {...evaluation(), conversation_id: "conv_other1234"}, {...evaluation(), trigger: {...SCOPE.trigger, context: "idle"}}, {...evaluation(), privacy: null}, {...evaluation(), privacy: {...evaluation().privacy, extra: true}}, {...evaluation(), privacy: {...evaluation().privacy, no_store: "no"}}, {...evaluation(), claim_id: "bad"}, {...evaluation(), issued_at: "today"}, {...evaluation(), state: {...state(), owner: "usr_aaaaaaaaaaaaaaaa"}}]; invalid.forEach((raw) => assert.equal(normalizeEvaluation(raw, IDENTITY, SCOPE, true), null)); }); test("actions bridge only validated HUX-03/HUX-06 interfaces", () => { const make = (change) => normalizeEvaluation(evaluation({ suggestion: suggestion(change), }), IDENTITY, SCOPE, true); assert.deepEqual(buildActionIntent(make({})), {type: "start_workflow", intent: "schedule_weekly"}); assert.deepEqual(buildActionIntent(make({kind: "project", action: { type: "create_project", payload: {prefill_title: "Launch"}}})), {type: "create_project", conversationId: SCOPE.conversationId, prefillTitle: "Launch"}); assert.deepEqual(buildActionIntent(make({kind: "feature", action: { type: "open_mode", payload: {mode: "fast"}}})), {type: "open_mode", mode: "fast"}); assert.deepEqual(buildActionIntent(make({kind: "feature", action: { type: "open_mode"}})), {type: "open_mode"}); assert.deepEqual(buildActionIntent(make({kind: "project", action: { type: "create_project"}})), {type: "create_project", conversationId: SCOPE.conversationId}); assert.deepEqual(buildActionIntent(make({kind: "feature", action: { type: "open_memory"}})), {type: "open_memory", view: "suggestions"}); assert.deepEqual(buildActionIntent(make({kind: "feature", action: { type: "open_artifacts"}})), {type: "open_artifacts", view: "current"}); assert.equal(buildActionIntent(make({kind: "tip", action: {type: "none"}})), null); }); test("decision states preserve shows and prove the chosen outcome", () => { const item = normalizeEvaluation(evaluation(), IDENTITY, SCOPE, true); for (const decision of ["dismiss", "never_again", "acted"]) { const raw = decisionEnvelope(null, decision).state; const normalized = normalizeDecisionState(raw, item, decision); assert.ok(normalized); assert.equal(normalized.neverAgain, decision === "never_again"); } for (const raw of [null, {...state(), extra: true}, {...state(), shows: 2}, {...state(), last_shown_at: "2026-08-24T11:00:00Z"}, {...state(), never_again: "yes"}, {...state(), dismissed_at: "today"}, {...state(), acted_at: "today"}, {...state(), acted_at: "2026-08-24T10:01:00Z"}]) assert.equal(normalizeDecisionState(raw, item, "dismiss"), null); assert.ok(normalizeDecisionState({...state(), dismissed_at: "2026-08-24T10:01:00Z", acted_at: "2026-08-24T10:02:00Z"}, item, "dismiss")); }); test("client evaluates exact triggers with no-store same-origin requests", async () => { const current = harness([{body: evaluation()}]); assert.equal(current.client.enabled(), true); const item = await current.client.evaluate(SCOPE); assert.equal(item.suggestion.title, "Run this every week?"); assert.equal(current.calls[0].url, "/hux/v1/onboarding/evaluate"); assert.equal(current.calls[0].init.method, "POST"); assert.equal(current.calls[0].init.cache, "no-store"); assert.equal(current.calls[0].init.credentials, "same-origin"); assert.deepEqual(JSON.parse(current.calls[0].init.body), { schema: "hux.suggestion_evaluation_request.v1", session_id: SCOPE.sessionId, conversation_id: SCOPE.conversationId, trigger: SCOPE.trigger, }); const replay = harness([{body: evaluation()}, {body: evaluation()}]); assert.ok(await replay.client.evaluate(SCOPE)); assert.equal(await replay.client.evaluate(SCOPE), null); const empty = harness([{status: 204}, {status: 404}]); assert.equal(await empty.client.evaluate(SCOPE), null); assert.equal(await empty.client.evaluate(SCOPE), null); }); test("client persists explicit decisions without performing suggested work", async () => { for (const decision of ["dismiss", "never_again", "acted"]) { const current = harness([{body: evaluation()}, {body: decisionEnvelope(null, decision)}]); const item = await current.client.evaluate(SCOPE); const after = await current.client.decide(item, decision); assert.equal(after.neverAgain, decision === "never_again"); assert.equal(current.calls[1].url, "/hux/v1/onboarding/suggestions/sug_alpha1234/decisions"); assert.deepEqual(JSON.parse(current.calls[1].init.body), { schema: "hux.suggestion_decision_request.v1", session_id: SCOPE.sessionId, conversation_id: SCOPE.conversationId, claim_id: item.claimId, suggestion_id: item.suggestion.id, expected_shows: 1, decision, }); await assert.rejects(current.client.decide(item, decision), /not bound/); } }); test("client rejects disabled, malformed, cross-scope, stale, and unissued data", async () => { const disabled = harness([], [FOUNDATION_FLAG, PROJECTS_FLAG]); assert.equal(disabled.client.enabled(), false); await assert.rejects(disabled.client.evaluate(SCOPE), /not enabled/); disabled.foundation.apiVersion = "hux.v0"; await assert.rejects(disabled.client.evaluate(SCOPE), /not enabled/); const invalidScope = harness([]); await assert.rejects(invalidScope.client.evaluate({...SCOPE, sessionId: "bad"}), /outside/); await assert.rejects(invalidScope.client.decide({claimId: "clm_fake1234"}, "dismiss"), /not bound/); await assert.rejects(invalidScope.client.decide({}, "delete"), /not bound/); for (const response of [{ok: false, status: 409}, {ok: false, status: 503}]) { const current = harness([response]); await assert.rejects(current.client.evaluate(SCOPE), OnboardingContractError); } const malformed = harness([{body: {}}]); assert.equal(await malformed.client.evaluate(SCOPE), null); const wrongDecision = harness([{body: evaluation()}, {body: decisionEnvelope(null, "dismiss", { identity: {...RAW_IDENTITY, surface: "worker"}})}]); const item = await wrongDecision.client.evaluate(SCOPE); await assert.rejects(wrongDecision.client.decide(item, "dismiss"), /crossed/); const missing = harness([{body: evaluation()}, {status: 204}]); await assert.rejects(missing.client.decide(await missing.client.evaluate(SCOPE), "dismiss"), /crossed/); assert.throws(() => createOnboardingClient({client: null, fetcher: async () => ({})}), /requires/); assert.throws(() => createOnboardingClient({client: {...invalidScope.foundation, identity: {...IDENTITY, tenantRef: "bad"}}, fetcher: async () => ({})}), /identity/); const originalFetch = globalThis.fetch; try { globalThis.fetch = undefined; assert.throws(() => createOnboardingClient({client: invalidScope.foundation}), /requires/); } finally { globalThis.fetch = originalFetch; } });