The artifacts/ directory matched a repo-wide ignore rule and was left out of the UI card commit; its suites only passed locally because the files existed on disk. Force-track the complete card so the source push carries every module the tests import. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
240 lines
12 KiB
TypeScript
240 lines
12 KiB
TypeScript
/** 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<ArtifactType>([
|
|
"markdown", "code", "html", "svg", "image", "json", "csv", "document", "audio",
|
|
]);
|
|
const SENSITIVITIES = new Set<Sensitivity>(["public", "personal", "sensitive", "restricted"]);
|
|
const AUTHORIZATION = new Set<AuthorizationStatus>(["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<string>): 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<string, unknown> | null;
|
|
const content = raw.content_ref as Record<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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<string>): ArtifactAttachments[] {
|
|
if (!Array.isArray(raw)) return [];
|
|
const result: ArtifactAttachments[] = [];
|
|
const seen = new Set<string>();
|
|
for (const item of raw.slice(0, MAX_ARTIFACTS)) {
|
|
const value = item as Record<string, unknown> | 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<string>): ArtifactAuthorization[] {
|
|
if (!Array.isArray(raw)) return [];
|
|
const result: ArtifactAuthorization[] = [];
|
|
const seen = new Set<string>();
|
|
for (const item of raw.slice(0, MAX_ARTIFACTS)) {
|
|
const value = item as Record<string, unknown> | 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<string>();
|
|
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;
|
|
}
|