/** Fail-closed suggestion.schema.json adapters for HUX-09. */ import { asRecord, exactKeys, isId, isSafeText, isUtc, normalizeIdentity, normalizeTrigger, sameIdentity, sameTrigger, } from "./security.ts"; import type { OnboardingActionIntent, OnboardingScope, OnboardingSuggestion, RawSuggestion, RawSuggestionState, ScopedIdentity, SuggestionActionType, SuggestionDecision, SuggestionEvaluation, SuggestionKind, SuggestionState, } from "./types.ts"; export const FOUNDATION_FLAG = "hux.foundation"; export const PROJECTS_FLAG = "hux.projects"; export const FRIENDLY_MODES_FLAG = "hux.friendly_modes"; export const ONBOARDING_FLAG = "hux.onboarding"; export const PRIVACY_FLAG = "hux.privacy"; export const REQUIRED_FLAGS = Object.freeze([ FOUNDATION_FLAG, PROJECTS_FLAG, FRIENDLY_MODES_FLAG, ONBOARDING_FLAG, ]); const KINDS = new Set(["workflow", "project", "feature", "tip"]); const ACTIONS = new Set([ "open_mode", "create_project", "open_memory", "open_artifacts", "start_workflow", "none", ]); const MODES = new Set(["fast", "thoughtful", "research", "create", "private"]); const INTENTS = new Set(["schedule_weekly", "save_reusable"]); const KIND_ACTIONS: Readonly>> = { workflow: new Set(["start_workflow"]), project: new Set(["create_project"]), feature: new Set(["open_mode", "open_memory", "open_artifacts"]), tip: new Set(["none"]), }; function finiteInteger(value: unknown, min: number, max: number): value is number { return Number.isSafeInteger(value) && (value as number) >= min && (value as number) <= max; } function normalizePayload( type: SuggestionActionType, raw: unknown, ): Readonly> | null | undefined { const value = raw === undefined ? {} : asRecord(raw); if (!value) return null; if (type === "start_workflow") { return exactKeys(value, ["intent"]) && INTENTS.has(value.intent as string) ? {intent: value.intent as string} : null; } if (type === "create_project") { if (!exactKeys(value, [], ["prefill_title"])) return null; return value.prefill_title === undefined ? {} : isSafeText(value.prefill_title, 1, 120) ? {prefill_title: value.prefill_title} : null; } if (type === "open_mode") { if (!exactKeys(value, [], ["mode"])) return null; return value.mode === undefined ? {} : MODES.has(value.mode as string) ? {mode: value.mode as string} : null; } if (type === "open_memory") { return exactKeys(value, [], ["view"]) && (value.view === undefined || value.view === "suggestions") ? (value.view ? {view: "suggestions"} : {}) : null; } if (type === "open_artifacts") { return exactKeys(value, [], ["view"]) && (value.view === undefined || value.view === "current") ? (value.view ? {view: "current"} : {}) : null; } return exactKeys(value, []) ? undefined : null; } export function onboardingEnabled(flags?: Iterable): boolean { if (!flags) return false; const enabled = new Set(flags); return REQUIRED_FLAGS.every((flag) => enabled.has(flag)); } export function normalizeSuggestion( raw: RawSuggestion, expectedTrigger: OnboardingScope["trigger"], ): OnboardingSuggestion | null { const value = asRecord(raw); if (!value || !exactKeys(value, [ "schema", "id", "kind", "trigger", "title", "body", "priority", "suppression", ], ["action"]) || value.schema !== "hux.suggestion.v1" || !isId(value.id, "sug") || !KINDS.has(value.kind as SuggestionKind) || !isSafeText(value.title, 1, 80) || !isSafeText(value.body, 1, 280) || !finiteInteger(value.priority, 0, 100)) return null; const trigger = normalizeTrigger(value.trigger); const suppression = asRecord(value.suppression); if (!trigger || !sameTrigger(trigger, expectedTrigger) || !suppression || !exactKeys(suppression, [ "dismissable", "max_shows", "cooldown_seconds", "never_again_supported", ]) || suppression.dismissable !== true || suppression.never_again_supported !== true || !finiteInteger(suppression.max_shows, 1, 5) || !finiteInteger(suppression.cooldown_seconds, 3600, Number.MAX_SAFE_INTEGER)) return null; const action = value.action === undefined ? {type: "none"} : asRecord(value.action); if (!action || !exactKeys(action, ["type"], ["payload"]) || !ACTIONS.has(action.type as SuggestionActionType)) return null; const type = action.type as SuggestionActionType; const payload = normalizePayload(type, action.payload); const kind = value.kind as SuggestionKind; if (payload === null || !KIND_ACTIONS[kind].has(type)) return null; return { schema: "hux.suggestion.v1", id: value.id as string, kind, trigger, title: value.title as string, body: value.body as string, action: {type, ...(payload === undefined ? {} : {payload})}, priority: value.priority as number, suppression: { dismissable: true, maxShows: suppression.max_shows as number, cooldownSeconds: suppression.cooldown_seconds as number, neverAgainSupported: true, }, }; } export function normalizeState( raw: RawSuggestionState, suggestion: OnboardingSuggestion, owner: string, ): SuggestionState | null { const value = asRecord(raw); if (!value || !exactKeys(value, [ "schema", "owner", "suggestion_id", "shows", "last_shown_at", "never_again", ], ["dismissed_at", "acted_at"]) || value.schema !== "hux.suggestion_state.v1" || value.owner !== owner || value.suggestion_id !== suggestion.id || !finiteInteger(value.shows, 1, suggestion.suppression.maxShows) || !isUtc(value.last_shown_at) || value.never_again !== false || (value.dismissed_at !== undefined && !isUtc(value.dismissed_at)) || (value.acted_at !== undefined && !isUtc(value.acted_at))) return null; return { schema: "hux.suggestion_state.v1", owner, suggestionId: suggestion.id, shows: value.shows as number, lastShownAt: value.last_shown_at as string, ...(value.dismissed_at ? {dismissedAt: value.dismissed_at as string} : {}), ...(value.acted_at ? {actedAt: value.acted_at as string} : {}), neverAgain: false, }; } export function normalizeDecisionState( raw: RawSuggestionState, evaluation: SuggestionEvaluation, decision: SuggestionDecision, ): SuggestionState | null { const value = asRecord(raw); const suggestion = evaluation.suggestion; if (!value || !exactKeys(value, [ "schema", "owner", "suggestion_id", "shows", "last_shown_at", "never_again", ], ["dismissed_at", "acted_at"]) || value.schema !== "hux.suggestion_state.v1" || value.owner !== evaluation.identity.userRef || value.suggestion_id !== suggestion.id || value.shows !== evaluation.state.shows || !isUtc(value.last_shown_at) || value.last_shown_at !== evaluation.state.lastShownAt || typeof value.never_again !== "boolean") return null; const dismissed = value.dismissed_at === undefined || isUtc(value.dismissed_at); const acted = value.acted_at === undefined || isUtc(value.acted_at); const outcomeMatches = decision === "acted" ? Boolean(value.acted_at) && value.never_again === false : Boolean(value.dismissed_at) && value.never_again === (decision === "never_again"); if (!dismissed || !acted || !outcomeMatches) return null; return { schema: "hux.suggestion_state.v1", owner: evaluation.identity.userRef, suggestionId: suggestion.id, shows: value.shows as number, lastShownAt: value.last_shown_at as string, ...(value.dismissed_at ? {dismissedAt: value.dismissed_at as string} : {}), ...(value.acted_at ? {actedAt: value.acted_at as string} : {}), neverAgain: value.never_again, }; } export function normalizeEvaluation( raw: unknown, expectedIdentity: ScopedIdentity, expectedScope: OnboardingScope, privacyCapability: boolean, ): SuggestionEvaluation | null { const value = asRecord(raw); if (!value || !exactKeys(value, [ "schema", "api_version", "identity", "session_id", "conversation_id", "trigger", "privacy", "claim_id", "issued_at", "suggestion", "state", ]) || value.schema !== "hux.suggestion_evaluation.v1" || value.api_version !== "hux.v1") return null; const identity = normalizeIdentity(value.identity); const trigger = normalizeTrigger(value.trigger); const privacy = asRecord(value.privacy); if (!identity || !sameIdentity(identity, expectedIdentity) || value.session_id !== expectedScope.sessionId || value.conversation_id !== expectedScope.conversationId || !trigger || !sameTrigger(trigger, expectedScope.trigger) || !privacy || !exactKeys(privacy, ["schema", "no_store", "memory_suggestions_allowed"]) || privacy.schema !== "hux.onboarding_privacy.v1" || typeof privacy.no_store !== "boolean" || typeof privacy.memory_suggestions_allowed !== "boolean") return null; if (privacy.no_store || value.suggestion === null || value.state === null || value.claim_id === null) return null; if (!isId(value.claim_id, "clm") || !isUtc(value.issued_at)) return null; const suggestion = normalizeSuggestion(value.suggestion as RawSuggestion, expectedScope.trigger); if (!suggestion || (suggestion.action.type === "open_memory" && (!privacyCapability || privacy.memory_suggestions_allowed !== true))) return null; const state = normalizeState(value.state as RawSuggestionState, suggestion, identity.userRef); if (!state) return null; return { schema: "hux.suggestion_evaluation.v1", identity, scope: expectedScope, claimId: value.claim_id, issuedAt: value.issued_at, privacy: { schema: "hux.onboarding_privacy.v1", noStore: false, memorySuggestionsAllowed: privacy.memory_suggestions_allowed, }, suggestion, state, }; } export function buildActionIntent( evaluation: SuggestionEvaluation, ): OnboardingActionIntent | null { const {action} = evaluation.suggestion; const payload = action.payload || {}; if (action.type === "open_mode") return {type: action.type, ...(payload.mode ? {mode: payload.mode} : {})}; if (action.type === "create_project") return {type: action.type, conversationId: evaluation.scope.conversationId, ...(payload.prefill_title ? {prefillTitle: payload.prefill_title} : {})}; if (action.type === "open_memory") return {type: action.type, view: "suggestions"}; if (action.type === "open_artifacts") return {type: action.type, view: "current"}; if (action.type === "start_workflow") return {type: action.type, intent: payload.intent as "schedule_weekly" | "save_reusable"}; return null; }