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

254 lines
10 KiB
TypeScript

/** Same-origin, capability-gated transport for HUX-03. */
import { ACCEPT, API_VERSION, RESPONSE_SCHEMAS } from "./contracts.ts";
import { normalizeConversation, normalizeProject, preservesConversation } from "./model.ts";
import {
boundedLimit,
isOpaqueId,
isUtc,
normalizeIdentity,
safeCursor,
safeTags,
sameIdentity,
scopedPath,
} from "./security.ts";
import type {
Conversation,
ConversationPatch,
Page,
Project,
ProjectPatch,
RawConversation,
RawEnvelope,
RawProject,
ScopedIdentity,
SearchFilters,
} from "./types.ts";
interface FoundationClient {
apiVersion: string;
identity: ScopedIdentity;
enabled(flag: string): boolean;
endpoint(path: string): string;
}
interface FetchResponse {
ok: boolean;
status: number;
json(): Promise<unknown>;
}
interface ClientOptions {
client: FoundationClient;
fetcher?: (url: string, init: Record<string, unknown>) => Promise<FetchResponse>;
}
interface RawMutationEnvelope {
schema?: unknown;
api_version?: unknown;
identity?: unknown;
item?: unknown;
}
export class OrganizationContractError extends Error {
constructor(message: string) {
super(message);
this.name = "OrganizationContractError";
}
}
function assertAvailable(client: FoundationClient): void {
if (client.apiVersion !== API_VERSION || !client.enabled("hux.foundation") ||
!client.enabled("hux.projects")) {
throw new OrganizationContractError("Project organization is not enabled");
}
}
function assertEnvelope(raw: unknown, schema: string, expected: ScopedIdentity): RawEnvelope {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
throw new OrganizationContractError("Invalid organization response");
}
const value = raw as RawEnvelope;
const identity = normalizeIdentity(value.identity);
if (value.schema !== schema || value.api_version !== API_VERSION || !identity ||
!sameIdentity(identity, expected)) {
throw new OrganizationContractError("Organization response crossed its scope");
}
return value;
}
function pagination(raw: RawEnvelope): Pick<Page<never>, "nextCursor" | "total"> {
const cursor = safeCursor(raw.next_cursor);
if (cursor === undefined || (raw.total !== null && raw.total !== undefined &&
(!Number.isSafeInteger(raw.total) || (raw.total as number) < 0))) {
throw new OrganizationContractError("Invalid organization pagination");
}
return {nextCursor: cursor || null, total: raw.total as number | null | undefined ?? null};
}
function pageQuery(cursor: string | null | undefined, limit: number | undefined): URLSearchParams {
const query = new URLSearchParams();
const safe = safeCursor(cursor);
if (safe === undefined) throw new TypeError("Invalid organization cursor");
if (safe) query.set("cursor", safe);
query.set("limit", String(boundedLimit(limit)));
return query;
}
function projectPage(raw: unknown, expected: ScopedIdentity): Page<Project> {
const value = assertEnvelope(raw, RESPONSE_SCHEMAS.projects, expected);
if (!Array.isArray(value.items) || value.items.length > 100) {
throw new OrganizationContractError("Project page exceeds its bound");
}
const items = value.items.map((item) => normalizeProject(item as RawProject, expected.userRef));
if (items.some((item) => item === null) ||
new Set(items.map((item) => item?.id)).size !== items.length) {
throw new OrganizationContractError("Invalid project record");
}
return {...pagination(value), items: items as Project[]};
}
function conversationPage(
raw: unknown,
schema: string,
expected: ScopedIdentity,
projectIds: ReadonlySet<string>,
): Page<Conversation> {
const value = assertEnvelope(raw, schema, expected);
if (!Array.isArray(value.items) || value.items.length > 100) {
throw new OrganizationContractError("Conversation page exceeds its bound");
}
const items = value.items.map((item) =>
normalizeConversation(item as RawConversation, expected.userRef, projectIds));
if (items.some((item) => item === null) ||
new Set(items.map((item) => item?.id)).size !== items.length) {
throw new OrganizationContractError("Invalid conversation record");
}
return {...pagination(value), items: items as Conversation[]};
}
function mutationItem(
raw: unknown,
schema: string,
expected: ScopedIdentity,
): unknown {
const value = assertEnvelope(raw, schema, expected) as RawMutationEnvelope;
if (!("item" in value) || value.item === undefined) {
throw new OrganizationContractError("Mutation response has no item");
}
return value.item;
}
export function createOrganizationClient(options: ClientOptions) {
const client = options.client;
const fetcher = options.fetcher || (typeof fetch === "function" ? fetch.bind(globalThis) : null);
if (!client || !fetcher) throw new TypeError("Organization client requires HUX and fetch clients");
const identity = client.identity;
async function request(path: string, init: Record<string, unknown> = {}): Promise<unknown> {
assertAvailable(client);
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 OrganizationContractError(
response.status === 409 ? "This item changed; refresh before trying again" :
`Organization request failed (${response.status})`,
);
return response.json();
}
async function listProjects(cursor?: string | null, limit?: number): Promise<Page<Project>> {
const query = pageQuery(cursor, limit);
return projectPage(await request(`/projects?${query}`), identity);
}
async function listConversations(
projectId: string,
projectIds: ReadonlySet<string>,
cursor?: string | null,
limit?: number,
): Promise<Page<Conversation>> {
if (!projectIds.has(projectId)) throw new TypeError("Project is outside this workspace");
const path = `${scopedPath("/projects", projectId)}/conversations?${pageQuery(cursor, limit)}`;
const page = conversationPage(await request(path), RESPONSE_SCHEMAS.conversations, identity, projectIds);
if (page.items.some((item) => item.projectId !== projectId)) {
throw new OrganizationContractError("Conversation crossed its project boundary");
}
return page;
}
async function searchConversations(
filters: SearchFilters,
projectIds: ReadonlySet<string>,
): Promise<Page<Conversation>> {
const text = typeof filters.query === "string" ? filters.query.trim() : "";
if (!text || text.length > 200) throw new TypeError("Search query must be 1 to 200 characters");
const query = pageQuery(filters.cursor, filters.limit);
query.set("q", text);
if (filters.projectId) {
if (!projectIds.has(filters.projectId)) throw new TypeError("Search project is outside this workspace");
query.set("project_id", filters.projectId);
}
const tags = safeTags(filters.tags || []);
if (tags === null) throw new TypeError("Search tags are invalid");
tags.forEach((tag) => query.append("tag", tag));
if (filters.pinned !== undefined) query.set("pinned", String(filters.pinned));
return conversationPage(await request(`/conversations/search?${query}`),
RESPONSE_SCHEMAS.search, identity, projectIds);
}
async function updateProject(before: Project, patch: ProjectPatch): Promise<Project> {
if (before.owner !== identity.userRef || !isUtc(patch.expectedUpdatedAt)) {
throw new TypeError("Project mutation is outside this workspace");
}
const tags = patch.tags === undefined ? undefined : safeTags(patch.tags);
const name = patch.name === undefined ? undefined : patch.name.trim();
if (tags === null || (name !== undefined && (!name || name.length > 120))) {
throw new TypeError("Project mutation is invalid");
}
const body = {expected_updated_at: patch.expectedUpdatedAt,
...(name !== undefined ? {name} : {}), ...(tags !== undefined ? {tags} : {}),
...(patch.pinned !== undefined ? {pinned: patch.pinned} : {})};
const raw = mutationItem(await request(scopedPath("/projects", before.id),
{method: "PATCH", body: JSON.stringify(body)}), RESPONSE_SCHEMAS.projectMutation, identity);
const item = normalizeProject(raw as RawProject, identity.userRef);
if (!item || item.id !== before.id) throw new OrganizationContractError("Project identity changed during mutation");
return item;
}
async function updateConversation(
before: Conversation,
patch: ConversationPatch,
projectIds: ReadonlySet<string>,
): Promise<Conversation> {
if (before.owner !== identity.userRef || !isUtc(patch.expectedUpdatedAt) ||
(before.projectId !== null && !projectIds.has(before.projectId)) ||
(patch.projectId !== undefined && patch.projectId !== null && !projectIds.has(patch.projectId))) {
throw new TypeError("Conversation mutation is outside this workspace");
}
const tags = patch.tags === undefined ? undefined : safeTags(patch.tags);
const title = patch.title === undefined ? undefined : patch.title.trim();
if (tags === null || (title !== undefined && (!title || title.length > 200))) {
throw new TypeError("Conversation mutation is invalid");
}
const body = {expected_updated_at: patch.expectedUpdatedAt,
...(title !== undefined ? {title} : {}),
...(patch.projectId !== undefined ? {project_id: patch.projectId} : {}),
...(tags !== undefined ? {tags} : {}),
...(patch.pinned !== undefined ? {pinned: patch.pinned} : {})};
const raw = mutationItem(await request(scopedPath("/conversations", before.id),
{method: "PATCH", body: JSON.stringify(body)}), RESPONSE_SCHEMAS.conversationMutation, identity);
const item = normalizeConversation(raw as RawConversation, identity.userRef, projectIds);
const intendedProject = patch.projectId === undefined ? before.projectId : patch.projectId;
if (!item || item.projectId !== intendedProject || !preservesConversation(before, item)) {
throw new OrganizationContractError("Conversation lineage or attachments changed during mutation");
}
return item;
}
return Object.freeze({listConversations, listProjects, searchConversations,
updateConversation, updateProject});
}