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
254 lines
10 KiB
TypeScript
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});
|
|
}
|