jenkins b66c762f5d hermes(webui): add HUX card UI models with contract-locked suites
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
2026-08-24 04:12:03 -03:00

131 lines
4.8 KiB
TypeScript

/** Same-origin, scope-bound transport for HUX-10 privacy state. */
import {ACCEPT, API_VERSION} from "./contracts.ts";
import {
FOUNDATION_FLAG,
PRIVACY_FLAG,
normalizeSnapshot,
privacyControls,
withoutNotice,
} from "./model.ts";
import {
normalizeIdentity,
normalizeScope,
privacyQuery,
sameIdentity,
} from "./security.ts";
import type {
FetchResponse,
HuxFoundationClient,
PrivacyControl,
PrivacyScope,
PrivacySnapshot,
} from "./types.ts";
interface ClientOptions {
client: HuxFoundationClient;
fetcher?: (url: string, init: Record<string, unknown>) => Promise<FetchResponse>;
}
export class PrivacyContractError extends Error {
constructor(message: string) {
super(message);
this.name = "PrivacyContractError";
}
}
function enabled(client: HuxFoundationClient): boolean {
return client.apiVersion === API_VERSION && client.enabled(FOUNDATION_FLAG) &&
client.enabled(PRIVACY_FLAG);
}
function effectMatches(action: PrivacyControl, snapshot: PrivacySnapshot): boolean {
if (action === "enable_no_store" || action === "switch_to_private")
return snapshot.noStore;
if (action === "disable_no_store") return !snapshot.noStore;
if (action === "disable_memory_here") return !snapshot.memoryEnabled;
if (action === "dismiss") return snapshot.notice === null;
return action === "forget_this_conversation" && snapshot.noStore &&
!snapshot.memoryEnabled && snapshot.notice === null;
}
export function createPrivacyClient(options: ClientOptions) {
const client = options?.client;
const fetcher = options?.fetcher ||
(typeof fetch === "function" ? fetch.bind(globalThis) : null);
if (!client || !fetcher) throw new TypeError("Privacy requires HUX and fetch clients");
const identity = normalizeIdentity({
tenant_ref: client.identity?.tenantRef,
user_ref: client.identity?.userRef,
surface: client.identity?.surface,
});
if (!identity || !sameIdentity(identity, client.identity))
throw new TypeError("Privacy requires a scoped HUX identity");
const current = new Map<string, PrivacySnapshot>();
const seenNotices = new Set<string>();
function scopeKey(scope: PrivacyScope): string {
return `${scope.sessionId}\u0000${scope.conversationId}`;
}
function minimizeRepeatedNotice(snapshot: PrivacySnapshot): PrivacySnapshot {
if (!snapshot.notice) return snapshot;
const key = `${snapshot.scope.conversationId}\u0000${snapshot.notice.shownAt}`;
if (seenNotices.has(key)) return withoutNotice(snapshot);
seenNotices.add(key);
return snapshot;
}
async function request(path: string, init: Record<string, unknown>): Promise<unknown> {
if (!enabled(client)) throw new PrivacyContractError("Privacy controls are not enabled");
const response = await fetcher(client.endpoint(path), {
cache: "no-store", credentials: "same-origin",
headers: {Accept: ACCEPT, ...(init.body ? {"Content-Type": "application/json"} : {})},
...init,
});
if (!response.ok) throw new PrivacyContractError(
response.status === 409 ? "Privacy state changed; refresh before trying again" :
`Privacy request failed (${response.status})`,
);
return response.json();
}
async function load(scope: PrivacyScope): Promise<PrivacySnapshot> {
const safeScope = normalizeScope(scope);
if (!safeScope) throw new TypeError("Privacy scope is invalid");
const raw = await request(privacyQuery(safeScope), {method: "GET"});
const snapshot = normalizeSnapshot(raw, identity, safeScope);
if (!snapshot) throw new PrivacyContractError("Privacy response crossed its scope");
const visible = minimizeRepeatedNotice(snapshot);
current.set(scopeKey(safeScope), visible);
return visible;
}
async function control(
snapshot: PrivacySnapshot,
action: PrivacyControl,
): Promise<PrivacySnapshot> {
const key = snapshot?.scope ? scopeKey(snapshot.scope) : "";
if (current.get(key) !== snapshot || !privacyControls(snapshot).includes(action))
throw new TypeError("Privacy control is not bound to the current conversation");
const raw = await request("/privacy/controls", {
method: "POST",
body: JSON.stringify({
schema: "hux.privacy_control_request.v1",
session_id: snapshot.scope.sessionId,
conversation_id: snapshot.scope.conversationId,
expected_revision: snapshot.revision,
action,
}),
});
const updated = normalizeSnapshot(raw, identity, snapshot.scope);
if (!updated || updated.revision <= snapshot.revision || !effectMatches(action, updated))
throw new PrivacyContractError("Privacy update could not be verified");
const visible = minimizeRepeatedNotice(updated);
current.set(key, visible);
return visible;
}
return Object.freeze({enabled: () => enabled(client), load, control});
}