/** Fail-closed normalization and immutable-history invariants for HUX-04. */ import { ArtifactContractError, isMime, isOpaqueId, isSha256, isUtc, normalizeIdentity, normalizeScope, safeBlobUrl, safePreviewText, safeText, sameIdentity, sameScope, } from "./security.ts"; import type { Artifact, ArtifactAttachments, ArtifactAuthorization, ArtifactPreview, ArtifactRenderer, ArtifactScope, ArtifactType, ArtifactVersion, AuthorizationStatus, HuxIdentity, RawArtifact, RawArtifactEnvelope, RawArtifactVersion, RawPreviewPayload, Sensitivity, } from "./types.ts"; export const FOUNDATION_FLAG = "hux.foundation"; export const PROJECTS_FLAG = "hux.projects"; export const ARTIFACTS_FLAG = "hux.artifacts"; const TYPES = new Set([ "markdown", "code", "html", "svg", "image", "json", "csv", "document", "audio", ]); const SENSITIVITIES = new Set(["public", "personal", "sensitive", "restricted"]); const AUTHORIZATION = new Set(["unavailable", "requires_approval", "authorized"]); const ACTORS = new Set(["user", "assistant", "tool", "system", "operator"]); const MAX_ARTIFACTS = 200; const MAX_VERSIONS = 200; export function artifactsEnabled(flags?: Iterable): boolean { const active = new Set(flags || []); return active.has(FOUNDATION_FLAG) && active.has(PROJECTS_FLAG) && active.has(ARTIFACTS_FLAG); } function rendererFor(type: ArtifactType, language: string): ArtifactRenderer { if (type === "image") return "image"; if (type === "audio") return "audio"; if (["code", "html", "svg"].includes(type)) return "code"; if (["json", "csv"].includes(type)) return "data"; if (/^(report|research|analysis)$/i.test(language)) return "report"; return "document"; } function mimeAllowed(type: ArtifactType, mime: string): boolean { if (type === "image") return ["image/png", "image/jpeg", "image/webp", "image/gif"].includes(mime); if (type === "audio") return ["audio/mpeg", "audio/ogg", "audio/wav", "audio/webm"].includes(mime); if (type === "html") return mime === "text/html"; if (type === "svg") return mime === "image/svg+xml"; if (type === "json") return mime === "application/json" || mime === "text/plain"; if (type === "csv") return mime === "text/csv" || mime === "text/plain"; if (type === "markdown") return mime === "text/markdown" || mime === "text/plain"; if (type === "code") return mime.startsWith("text/") || mime === "application/json"; return mime === "application/pdf" || mime === "text/plain" || mime === "text/markdown"; } function normalizeVersion(raw: RawArtifactVersion, type: ArtifactType): ArtifactVersion | null { const actor = raw.created_by as Record | null; const content = raw.content_ref as Record | null; if (!Number.isInteger(raw.version) || (raw.version as number) < 1 || !isUtc(raw.created_at) || !actor || !ACTORS.has(String(actor.type)) || typeof actor.id !== "string" || actor.id.length < 1 || actor.id.length > 120 || !content || !isSha256(content.hash) || !Number.isSafeInteger(content.bytes) || (content.bytes as number) < 0 || !isMime(content.mime) || !mimeAllowed(type, content.mime) || (raw.diff_from !== undefined && (!Number.isInteger(raw.diff_from) || (raw.diff_from as number) < 1))) return null; let lineage: ArtifactVersion["lineage"] = null; if (raw.lineage !== undefined) { const value = raw.lineage as Record | null; if (!value || !isOpaqueId(value.artifact_id, "art") || !Number.isInteger(value.version) || (value.version as number) < 1) return null; lineage = {artifactId: value.artifact_id, version: value.version as number}; } return { version: raw.version as number, createdAt: raw.created_at, createdBy: String(actor.type), contentRef: {hash: content.hash, bytes: content.bytes as number, mime: content.mime}, diffFrom: raw.diff_from as number | undefined || null, lineage, note: safeText(raw.note, "", 200), }; } export function normalizeArtifact(raw: RawArtifact, expectedOwner: string, scope: ArtifactScope): Artifact | null { if (raw.schema !== "hux.artifact.v1" || !isOpaqueId(raw.id, "art") || raw.owner !== expectedOwner || raw.project_id !== scope.projectId || raw.conversation_id !== scope.conversationId || !TYPES.has(raw.type as ArtifactType) || !Number.isInteger(raw.current_version) || (raw.current_version as number) < 1 || !Array.isArray(raw.versions) || raw.versions.length < 1 || raw.versions.length > MAX_VERSIONS || !SENSITIVITIES.has(raw.sensitivity as Sensitivity) || !isUtc(raw.created_at) || !isUtc(raw.updated_at) || raw.updated_at < raw.created_at) return null; const type = raw.type as ArtifactType; const language = safeText(raw.language, "", 40); const title = safeText(raw.title, "", 200); if (!title) return null; const versions = raw.versions.map((item) => normalizeVersion(item as RawArtifactVersion, type)); if (versions.some((item) => item === null)) return null; const ordered = versions as ArtifactVersion[]; if (ordered.some((item, index) => item.version !== index + 1 || (index > 0 && item.createdAt < ordered[index - 1].createdAt) || (item.diffFrom !== null && item.diffFrom >= item.version) || (item.lineage?.artifactId === raw.id && item.lineage.version >= item.version)) || raw.current_version !== ordered.length) return null; let promotedVersion: number | null = null; if (raw.promotion !== undefined) { const promotion = raw.promotion as Record | null; if (!promotion || promotion.project_id !== scope.projectId || !Number.isInteger(promotion.version) || (promotion.version as number) < 1 || (promotion.version as number) > ordered.length || !isUtc(promotion.at)) return null; promotedVersion = promotion.version as number; } return { id: raw.id, owner: expectedOwner, projectId: scope.projectId, conversationId: scope.conversationId, type, renderer: rendererFor(type, language), language, title, currentVersion: raw.current_version as number, versions: ordered, sensitivity: raw.sensitivity as Sensitivity, promotedVersion, updatedAt: raw.updated_at, }; } function normalizeAttachments(raw: unknown, allowed: ReadonlySet): ArtifactAttachments[] { if (!Array.isArray(raw)) return []; const result: ArtifactAttachments[] = []; const seen = new Set(); for (const item of raw.slice(0, MAX_ARTIFACTS)) { const value = item as Record | null; const sources = value?.source_ids; const citations = value?.citation_ids; if (!value || !isOpaqueId(value.artifact_id, "art") || !allowed.has(value.artifact_id) || seen.has(value.artifact_id) || !Array.isArray(sources) || !Array.isArray(citations) || sources.length > 500 || citations.length > 500 || sources.some((id) => !isOpaqueId(id, "src")) || citations.some((id) => !isOpaqueId(id, "cit")) || new Set(sources).size !== sources.length || new Set(citations).size !== citations.length) continue; seen.add(value.artifact_id); result.push({artifactId: value.artifact_id, sourceCount: sources.length, citationCount: citations.length}); } return result; } function normalizeAuthorizations(raw: unknown, allowed: ReadonlySet): ArtifactAuthorization[] { if (!Array.isArray(raw)) return []; const result: ArtifactAuthorization[] = []; const seen = new Set(); for (const item of raw.slice(0, MAX_ARTIFACTS)) { const value = item as Record | null; if (!value || !isOpaqueId(value.artifact_id, "art") || !allowed.has(value.artifact_id) || seen.has(value.artifact_id) || !AUTHORIZATION.has(value.download as AuthorizationStatus) || !AUTHORIZATION.has(value.share as AuthorizationStatus)) continue; seen.add(value.artifact_id); result.push({artifactId: value.artifact_id, download: value.download as AuthorizationStatus, share: value.share as AuthorizationStatus}); } return result; } export function normalizeArtifactWorkspace(raw: RawArtifactEnvelope, expected: HuxIdentity, scope: ArtifactScope) { const trustedIdentity = normalizeIdentity({tenant_ref: expected.tenantRef, user_ref: expected.userRef, surface: expected.surface}); if (!raw || typeof raw !== "object" || raw.schema !== "hux.artifact_workspace.v1" || raw.api_version !== "hux.v1" || !sameIdentity(normalizeIdentity(raw.identity), trustedIdentity) || !sameScope(normalizeScope(raw.binding), scope)) { throw new ArtifactContractError("Artifact workspace crossed its scope boundary"); } const records = Array.isArray(raw.artifacts) ? raw.artifacts : []; const normalized = records.slice(0, MAX_ARTIFACTS) .map((item) => normalizeArtifact(item as RawArtifact, trustedIdentity.userRef, scope)) .filter((item): item is Artifact => item !== null); const ids = new Set(); const artifacts = normalized.filter((item) => !ids.has(item.id) && Boolean(ids.add(item.id))); const allowed = new Set(artifacts.map((item) => item.id)); return { artifacts, attachments: normalizeAttachments(raw.attachments, allowed), authorizations: normalizeAuthorizations(raw.authorizations, allowed), rejected: records.length - artifacts.length, }; } export function normalizePreview(raw: RawPreviewPayload, artifact: Artifact, expected: HuxIdentity, scope: ArtifactScope): ArtifactPreview | null { try { const identity = normalizeIdentity(raw.identity); const trustedIdentity = normalizeIdentity({tenant_ref: expected.tenantRef, user_ref: expected.userRef, surface: expected.surface}); const binding = normalizeScope(raw.binding); const version = artifact.versions.find((item) => item.version === raw.version); if (raw.schema !== "hux.artifact_preview.v1" || !sameIdentity(identity, trustedIdentity) || !sameScope(binding, scope) || raw.artifact_id !== artifact.id || !version || raw.hash !== version.contentRef.hash || raw.mime !== version.contentRef.mime) return null; if (artifact.renderer === "image" || artifact.renderer === "audio") { const blobUrl = safeBlobUrl(raw.blob_url); if (!blobUrl || raw.text !== undefined) return null; return {artifactId: artifact.id, version: version.version, renderer: artifact.renderer, mime: version.contentRef.mime, text: null, blobUrl}; } if (raw.blob_url !== undefined) return null; const text = safePreviewText(raw.text); if (text === null) return null; return {artifactId: artifact.id, version: version.version, renderer: artifact.renderer, mime: version.contentRef.mime, text, blobUrl: null}; } catch { return null; } } function sameVersion(left: ArtifactVersion, right: ArtifactVersion): boolean { return left.version === right.version && left.createdAt === right.createdAt && left.createdBy === right.createdBy && left.contentRef.hash === right.contentRef.hash && left.contentRef.bytes === right.contentRef.bytes && left.contentRef.mime === right.contentRef.mime && left.diffFrom === right.diffFrom && left.lineage?.artifactId === right.lineage?.artifactId && left.lineage?.version === right.lineage?.version && left.note === right.note; } /** A server mutation may append versions but never rewrite history or detach scope. */ export function preservesImmutableHistory(before: Artifact, after: Artifact): boolean { return before.id === after.id && before.owner === after.owner && before.projectId === after.projectId && before.conversationId === after.conversationId && before.type === after.type && before.versions.length <= after.versions.length && before.versions.every((version, index) => sameVersion(version, after.versions[index])) && after.currentVersion === after.versions.length; }