/** Same-origin, session-bound transport for contextual HUX-09 suggestions. */ import {ACCEPT, API_VERSION, DECISION_SCHEMA, EVALUATION_SCHEMA} from "./contracts.ts"; import { FOUNDATION_FLAG, FRIENDLY_MODES_FLAG, ONBOARDING_FLAG, PRIVACY_FLAG, PROJECTS_FLAG, normalizeDecisionState, normalizeEvaluation, } from "./model.ts"; import { asRecord, exactKeys, isId, normalizeIdentity, normalizeScope, sameIdentity, scopedSuggestionPath, } from "./security.ts"; import type { FetchResponse, HuxFoundationClient, OnboardingScope, RawSuggestionState, SuggestionDecision, SuggestionEvaluation, SuggestionState, } from "./types.ts"; interface ClientOptions { client: HuxFoundationClient; fetcher?: (url: string, init: Record) => Promise; } export class OnboardingContractError extends Error { constructor(message: string) { super(message); this.name = "OnboardingContractError"; } } function hasCoreFlags(client: HuxFoundationClient): boolean { return client.apiVersion === API_VERSION && [ FOUNDATION_FLAG, PROJECTS_FLAG, FRIENDLY_MODES_FLAG, ONBOARDING_FLAG, ].every((flag) => client.enabled(flag)); } export function createOnboardingClient(options: ClientOptions) { const client = options?.client; const fetcher = options?.fetcher || (typeof fetch === "function" ? fetch.bind(globalThis) : null); if (!client || !fetcher) throw new TypeError("Onboarding requires HUX and fetch clients"); const identity = normalizeIdentity({ tenant_ref: client.identity?.tenantRef, user_ref: client.identity?.userRef, surface: client.identity?.surface, }); if (!identity) throw new TypeError("Onboarding requires a scoped HUX identity"); const seenClaims = new Set(); const activeClaims = new Map(); async function request(path: string, body: Record): Promise { if (!hasCoreFlags(client)) throw new OnboardingContractError("Onboarding is not enabled"); const response = await fetcher(client.endpoint(path), { method: "POST", cache: "no-store", credentials: "same-origin", headers: {Accept: ACCEPT, "Content-Type": "application/json"}, body: JSON.stringify(body), }); if (response.status === 404 || response.status === 204) return null; if (!response.ok) throw new OnboardingContractError( response.status === 409 ? "This suggestion is no longer current" : `Onboarding request failed (${response.status})`, ); return response.json(); } async function evaluate(scope: OnboardingScope): Promise { if (!hasCoreFlags(client)) throw new OnboardingContractError("Onboarding is not enabled"); const safeScope = normalizeScope(scope, identity); if (!safeScope) throw new TypeError("Onboarding trigger is outside this session"); const raw = await request("/onboarding/evaluate", { schema: "hux.suggestion_evaluation_request.v1", session_id: safeScope.sessionId, conversation_id: safeScope.conversationId, trigger: safeScope.trigger, }); if (raw === null) return null; const result = normalizeEvaluation(raw, identity, safeScope, client.enabled(PRIVACY_FLAG)); if (!result || seenClaims.has(result.claimId)) return null; seenClaims.add(result.claimId); activeClaims.set(result.claimId, result); return result; } function normalizeDecision( raw: unknown, evaluation: SuggestionEvaluation, decision: SuggestionDecision, ): SuggestionState | null { const value = asRecord(raw); if (!value || !exactKeys(value, [ "schema", "api_version", "identity", "session_id", "conversation_id", "claim_id", "suggestion_id", "state", ]) || value.schema !== DECISION_SCHEMA || value.api_version !== API_VERSION || value.session_id !== evaluation.scope.sessionId || value.conversation_id !== evaluation.scope.conversationId || value.claim_id !== evaluation.claimId || value.suggestion_id !== evaluation.suggestion.id || !isId(value.claim_id, "clm") || !isId(value.suggestion_id, "sug")) return null; const actualIdentity = normalizeIdentity(value.identity); if (!actualIdentity || !sameIdentity(actualIdentity, identity)) return null; return normalizeDecisionState(value.state as RawSuggestionState, evaluation, decision); } async function decide( evaluation: SuggestionEvaluation, decision: SuggestionDecision, ): Promise { if (!["dismiss", "never_again", "acted"].includes(decision) || activeClaims.get(evaluation?.claimId) !== evaluation) { throw new TypeError("Suggestion decision is not bound to this session"); } const path = scopedSuggestionPath("/onboarding/suggestions", evaluation.suggestion.id); const raw = await request(path, { schema: "hux.suggestion_decision_request.v1", session_id: evaluation.scope.sessionId, conversation_id: evaluation.scope.conversationId, claim_id: evaluation.claimId, suggestion_id: evaluation.suggestion.id, expected_shows: evaluation.state.shows, decision, }); const state = normalizeDecision(raw, evaluation, decision); if (!state) throw new OnboardingContractError("Suggestion decision crossed its scope"); activeClaims.delete(evaluation.claimId); return state; } return Object.freeze({ enabled: () => hasCoreFlags(client), evaluate, decide, schemas: Object.freeze({evaluation: EVALUATION_SCHEMA, decision: DECISION_SCHEMA}), }); }