Standalone per-card browser model/security/view modules for HUX-01..10 plus node+pytest suites that read the hux.v1 contract schemas directly. Reconciled drift found on integration: the activity model now accepts all 32 hux.event.v1 kinds (delegation.*, memory.suppressed, memory.retrieval_removed, budget.exhausted, side_effect.*), the autonomy model carries the external_side_effect capability, and the foundation boundary test now asserts the shipped static HUX surface exists on disk and that images never bake activated HUX_FLAGS. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
146 lines
5.5 KiB
TypeScript
146 lines
5.5 KiB
TypeScript
/** 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<string, unknown>) => Promise<FetchResponse>;
|
|
}
|
|
|
|
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<string>();
|
|
const activeClaims = new Map<string, SuggestionEvaluation>();
|
|
|
|
async function request(path: string, body: Record<string, unknown>): Promise<unknown> {
|
|
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<SuggestionEvaluation | null> {
|
|
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<SuggestionState> {
|
|
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}),
|
|
});
|
|
}
|