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
131 lines
4.8 KiB
TypeScript
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});
|
|
}
|