/** 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; } interface ClientOptions { client: FoundationClient; fetcher?: (url: string, init: Record) => Promise; } 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, "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 { 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, ): Page { 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 = {}): Promise { 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> { const query = pageQuery(cursor, limit); return projectPage(await request(`/projects?${query}`), identity); } async function listConversations( projectId: string, projectIds: ReadonlySet, cursor?: string | null, limit?: number, ): Promise> { 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, ): Promise> { 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 { 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, ): Promise { 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}); }