diff --git a/dockerfiles/hermes-webui-hux/artifacts/ArtifactWorkspace.tsx b/dockerfiles/hermes-webui-hux/artifacts/ArtifactWorkspace.tsx new file mode 100644 index 00000000..bddba1a1 --- /dev/null +++ b/dockerfiles/hermes-webui-hux/artifacts/ArtifactWorkspace.tsx @@ -0,0 +1,221 @@ +/** Accessible HUX-04 artifact UI. This module never fetches or executes content. */ + +import { + Code2, + Download, + FileText, + GitCompare, + History, + Image as ImageIcon, + Pencil, + Share2, + Sparkles, +} from "lucide-react"; +import { useMemo, useState } from "react"; + +import { artifactsEnabled, normalizeArtifactWorkspace, normalizePreview } from "./model.ts"; +import type { + Artifact, + ArtifactAuthorization, + ArtifactMutationIntent, + ArtifactPreview, + ArtifactScope, + DiffIntent, + HuxIdentity, + PromoteIntent, + RawArtifactEnvelope, + RawPreviewPayload, +} from "./types.ts"; + +interface ArtifactWorkspaceProps { + flags?: Iterable; + identity: HuxIdentity; + scope: ArtifactScope; + page: RawArtifactEnvelope; + previews?: readonly RawPreviewPayload[]; + busy?: boolean; + error?: string | null; + onLoadPreview?: (intent: ArtifactMutationIntent & {version: number}) => void; + onContinueEdit?: (intent: ArtifactMutationIntent) => void; + onDiff?: (intent: DiffIntent) => void; + onAuthorize?: (action: "download" | "share", intent: ArtifactMutationIntent) => void; + onDownload?: (intent: ArtifactMutationIntent & {version: number}) => void; + onShare?: (intent: ArtifactMutationIntent & {version: number}) => void; + onPromote?: (intent: PromoteIntent) => void; +} + +function intent(artifact: Artifact, scope: ArtifactScope): ArtifactMutationIntent { + return {artifactId: artifact.id, expectedVersion: artifact.currentVersion, scope}; +} + +function RendererIcon({artifact}: {artifact: Artifact}) { + if (artifact.renderer === "image") return ; + if (artifact.renderer === "code" || artifact.renderer === "data") return ; + return ; +} + +function Preview({artifact, preview}: {artifact: Artifact; preview: ArtifactPreview | null}) { + if (!preview) return
+ +

Preview not loaded. Content stays behind its verified content reference.

+
; + if (preview.renderer === "image") { + return
+ {`Preview +
{preview.mime}; local authorized preview
+
; + } + if (preview.renderer === "audio") { + return
+ + {preview.mime}; local authorized preview +
; + } + const className = `hux-artifact-${preview.renderer}`; + return
+ {preview.renderer === "report" &&

Report preview

} +
{preview.text}
+
; +} + +function AuthorizationAction({action, state, busy, onAuthorize, onAction}: { + action: "download" | "share"; + state: ArtifactAuthorization["download"]; + busy: boolean; + onAuthorize: () => void; + onAction: () => void; +}) { + const label = action === "download" ? "Download" : "Share"; + const Icon = action === "download" ? Download : Share2; + if (state === "authorized") return ; + if (state === "requires_approval") return ; + return ; +} + +function VersionHistory({artifact, selectedVersion, setSelectedVersion, busy, scope, onDiff}: { + artifact: Artifact; + selectedVersion: number; + setSelectedVersion: (version: number) => void; + busy: boolean; + scope: ArtifactScope; + onDiff?: ArtifactWorkspaceProps["onDiff"]; +}) { + return
+

Immutable version history

+
    {[...artifact.versions].reverse().map((version) =>
  1. + + {version.version > 1 && onDiff && } +
  2. )}
+
; +} + +/** Render nothing unless all negotiated HUX-04 dependencies are active. */ +export function ArtifactWorkspace(props: ArtifactWorkspaceProps) { + const enabled = artifactsEnabled(props.flags); + const page = useMemo(() => enabled ? + normalizeArtifactWorkspace(props.page, props.identity, props.scope) : null, + [enabled, props.identity, props.page, props.scope]); + const [selectedId, setSelectedId] = useState(null); + const [selectedVersion, setSelectedVersion] = useState(null); + if (!enabled || !page) return null; + const artifact = page.artifacts.find((item) => item.id === selectedId) || page.artifacts[0] || null; + const version = artifact ? Math.min(selectedVersion || artifact.currentVersion, artifact.currentVersion) : null; + const rawPreview = artifact && version ? props.previews?.find((item) => + item.artifact_id === artifact.id && item.version === version) : undefined; + const preview = artifact && rawPreview ? + normalizePreview(rawPreview, artifact, props.identity, props.scope) : null; + const attachments = artifact ? page.attachments.find((item) => item.artifactId === artifact.id) : null; + const authorization = artifact ? page.authorizations.find((item) => item.artifactId === artifact.id) : null; + + function selectArtifact(item: Artifact) { + setSelectedId(item.id); + setSelectedVersion(item.currentVersion); + props.onLoadPreview?.({...intent(item, props.scope), version: item.currentVersion}); + } + + function selectVersion(next: number) { + if (!artifact) return; + setSelectedVersion(next); + props.onLoadPreview?.({...intent(artifact, props.scope), version: next}); + } + + return
+ + +
+ {props.error &&

{props.error}

} + {page.rejected > 0 &&

+ Some artifacts were withheld because their owner or workspace binding was invalid. +

} + {artifact && version ? <> +
+
{artifact.renderer}

{artifact.title}

+

Version {version} · {artifact.sensitivity} · {artifact.language || artifact.type}

+
+ + props.onAuthorize?.("download", intent(artifact, props.scope))} + onAction={() => props.onDownload?.({...intent(artifact, props.scope), version})} /> + props.onAuthorize?.("share", intent(artifact, props.scope))} + onAction={() => props.onShare?.({...intent(artifact, props.scope), version})} /> +
+
+ +
+
{attachments?.sourceCount || 0}Sources attached
+
{attachments?.citationCount || 0}Citations attached
+
+ + :

Select an artifact to inspect it.

} +
+ + +

+ {props.busy ? "Artifact workspace is updating" : `${page.artifacts.length} artifacts available`} +

+
; +} diff --git a/dockerfiles/hermes-webui-hux/artifacts/endpoints.ts b/dockerfiles/hermes-webui-hux/artifacts/endpoints.ts new file mode 100644 index 00000000..79246087 --- /dev/null +++ b/dockerfiles/hermes-webui-hux/artifacts/endpoints.ts @@ -0,0 +1,94 @@ +/** Same-origin endpoint descriptions for a future HUX-04 backend adapter. */ + +import { artifactsEnabled } from "./model.ts"; +import { isOpaqueId, normalizeIdentity, scopedPath } from "./security.ts"; +import type { + ArtifactEndpoint, + ArtifactEndpointContract, + ArtifactScope, + HuxIdentity, +} from "./types.ts"; + +interface FoundationClient { + apiVersion: string; + identity: HuxIdentity; + endpoint(path: string): string; +} + +function endpoint(method: ArtifactEndpoint["method"], path: string, responseSchema: string, + requiredBody?: readonly string[]): ArtifactEndpoint { + return Object.freeze({method, path, responseSchema, ...(requiredBody ? {requiredBody} : {})}); +} + +function versionNumber(value: number): string { + if (!Number.isSafeInteger(value) || value < 1) throw new TypeError("Artifact version is invalid"); + return String(value); +} + +/** + * The server derives identity from the authenticated session and verifies the + * project/conversation path binding. Mutation bodies MUST carry + * expected_current_version and return 409 on stale state. Existing versions + * are never PATCHed or deleted; edits append a new content_ref-backed version. + */ +export function createArtifactEndpointContract(client: FoundationClient, scope: ArtifactScope, + flags?: Iterable): ArtifactEndpointContract | null { + if (!artifactsEnabled(flags)) return null; + if (client.apiVersion !== "hux.v1") throw new TypeError("HUX v1 client required"); + normalizeIdentity({tenant_ref: client.identity.tenantRef, user_ref: client.identity.userRef, + surface: client.identity.surface}); + const collection = scopedPath("/projects", scope); + + function itemPath(artifactId: string): string { + if (!isOpaqueId(artifactId, "art")) throw new TypeError("Opaque artifact id required"); + return scopedPath("/projects", scope, artifactId); + } + + function itemEndpoint(artifactId: string, suffix = ""): string { + return client.endpoint(`${itemPath(artifactId)}${suffix}`); + } + + return Object.freeze({ + list: endpoint("GET", client.endpoint(collection), "hux.artifact_workspace.v1"), + item(artifactId) { + return endpoint("GET", itemEndpoint(artifactId), "hux.artifact_response.v1"); + }, + preview(artifactId, version) { + return endpoint("GET", itemEndpoint(artifactId, `/versions/${versionNumber(version)}/preview`), + "hux.artifact_preview.v1"); + }, + diff(artifactId, fromVersion, toVersion) { + if (fromVersion >= toVersion) throw new TypeError("Artifact diff range is invalid"); + return endpoint("POST", itemEndpoint(artifactId, "/diffs"), "hux.artifact_diff.v1", + Object.freeze(["from_version", "to_version", "expected_current_version"])); + }, + appendVersion(artifactId) { + return endpoint("POST", itemEndpoint(artifactId, "/versions"), "hux.artifact_response.v1", + Object.freeze(["content_ref", "expected_current_version"])); + }, + continueEdit(artifactId) { + return endpoint("POST", itemEndpoint(artifactId, "/continue"), "hux.artifact_edit_intent.v1", + Object.freeze(["version", "expected_current_version"])); + }, + authorize(artifactId, action) { + if (action !== "download" && action !== "share") throw new TypeError("Artifact action is invalid"); + return endpoint("POST", itemEndpoint(artifactId, `/authorizations/${action}`), + "hux.artifact_authorization.v1", Object.freeze(["expected_current_version"])); + }, + download(artifactId, version) { + return endpoint("GET", itemEndpoint(artifactId, `/versions/${versionNumber(version)}/download`), + "hux.artifact_content.v1"); + }, + share(artifactId, version) { + return endpoint("POST", itemEndpoint(artifactId, `/versions/${versionNumber(version)}/shares`), + "hux.artifact_share.v1", Object.freeze(["expected_current_version"])); + }, + promote(artifactId) { + return endpoint("POST", itemEndpoint(artifactId, "/promotions"), "hux.artifact_response.v1", + Object.freeze(["target_project_id", "version", "expected_current_version"])); + }, + sources(artifactId) { + return endpoint("GET", itemEndpoint(artifactId, "/sources"), "hux.artifact_sources.v1"); + }, + }); +} diff --git a/dockerfiles/hermes-webui-hux/artifacts/index.ts b/dockerfiles/hermes-webui-hux/artifacts/index.ts new file mode 100644 index 00000000..2c7aa7df --- /dev/null +++ b/dockerfiles/hermes-webui-hux/artifacts/index.ts @@ -0,0 +1,14 @@ +export { ArtifactWorkspace } from "./ArtifactWorkspace.tsx"; +export { createArtifactEndpointContract } from "./endpoints.ts"; +export { + ARTIFACTS_FLAG, + FOUNDATION_FLAG, + PROJECTS_FLAG, + artifactsEnabled, + normalizeArtifact, + normalizeArtifactWorkspace, + normalizePreview, + preservesImmutableHistory, +} from "./model.ts"; +export { ArtifactContractError } from "./security.ts"; +export type * from "./types.ts"; diff --git a/dockerfiles/hermes-webui-hux/artifacts/model.ts b/dockerfiles/hermes-webui-hux/artifacts/model.ts new file mode 100644 index 00000000..6b140592 --- /dev/null +++ b/dockerfiles/hermes-webui-hux/artifacts/model.ts @@ -0,0 +1,239 @@ +/** 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; +} diff --git a/dockerfiles/hermes-webui-hux/artifacts/security.ts b/dockerfiles/hermes-webui-hux/artifacts/security.ts new file mode 100644 index 00000000..9ab105ea --- /dev/null +++ b/dockerfiles/hermes-webui-hux/artifacts/security.ts @@ -0,0 +1,104 @@ +/** Identity, content-reference, URL, and display guards for HUX-04. */ + +import type { ArtifactScope, HuxIdentity } from "./types.ts"; + +const OPAQUE_ID = /^[a-z]{2,6}_[A-Za-z0-9._-]{4,80}$/; +const TENANT = /^tnt_[0-9a-f]{16,64}$/; +const USER = /^usr_[0-9a-f]{16,64}$/; +const UTC = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?Z$/; +const HASH = /^sha256:[0-9a-f]{64}$/; +const MIME = /^[a-z0-9][a-z0-9.+-]{0,63}\/[a-z0-9][a-z0-9.+-]{0,63}$/i; +const CONTROL = /[\u0000-\u001f\u007f]/g; +const PREVIEW_CONTROL = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/; +const SURFACES = new Set(["chat", "worker", "telegram", "voice", "api"]); +const CREDENTIALS = [ + /\b(?:password|passwd|token|secret|api[_-]?key)\s*[:=]\s*\S+/gi, + /\bBearer\s+[A-Za-z0-9._~-]+/gi, + /-----BEGIN [^-]+-----[\s\S]*?-----END [^-]+-----/gi, +]; + +export class ArtifactContractError extends Error { + constructor(message: string) { + super(message); + this.name = "ArtifactContractError"; + } +} + +export function isOpaqueId(value: unknown, prefix?: string): value is string { + return typeof value === "string" && OPAQUE_ID.test(value) && + (!prefix || value.startsWith(`${prefix}_`)); +} + +export function isUtc(value: unknown): value is string { + return typeof value === "string" && UTC.test(value) && !Number.isNaN(Date.parse(value)); +} + +export function isSha256(value: unknown): value is string { + return typeof value === "string" && HASH.test(value); +} + +export function isMime(value: unknown): value is string { + return typeof value === "string" && value.length <= 120 && MIME.test(value); +} + +export function safeText(value: unknown, fallback: string, limit: number): string { + if (typeof value !== "string") return fallback; + let clean = value.replace(CONTROL, " ").replace(/\s+/g, " ").trim(); + for (const pattern of CREDENTIALS) clean = clean.replace(pattern, "[redacted]"); + if (!clean) return fallback; + return clean.length <= limit ? clean : `${clean.slice(0, Math.max(0, limit - 1)).trimEnd()}…`; +} + +export function safePreviewText(value: unknown, limit = 200_000): string | null { + return typeof value === "string" && value.length <= limit && !PREVIEW_CONTROL.test(value) ? value : null; +} + +export function normalizeIdentity(raw: unknown): HuxIdentity { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + throw new ArtifactContractError("Artifact identity is invalid"); + } + const value = raw as Record; + if (typeof value.tenant_ref !== "string" || !TENANT.test(value.tenant_ref) || + typeof value.user_ref !== "string" || !USER.test(value.user_ref) || + typeof value.surface !== "string" || !SURFACES.has(value.surface)) { + throw new ArtifactContractError("Artifact identity is invalid"); + } + return {tenantRef: value.tenant_ref, userRef: value.user_ref, + surface: value.surface as HuxIdentity["surface"]}; +} + +export function sameIdentity(left: HuxIdentity, right: HuxIdentity): boolean { + return left.tenantRef === right.tenantRef && left.userRef === right.userRef && + left.surface === right.surface; +} + +export function normalizeScope(raw: unknown): ArtifactScope { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + throw new ArtifactContractError("Artifact binding is invalid"); + } + const value = raw as Record; + if (!isOpaqueId(value.project_id, "prj") || !isOpaqueId(value.conversation_id, "conv")) { + throw new ArtifactContractError("Artifact binding is invalid"); + } + return {projectId: value.project_id, conversationId: value.conversation_id}; +} + +export function sameScope(left: ArtifactScope, right: ArtifactScope): boolean { + return left.projectId === right.projectId && left.conversationId === right.conversationId; +} + +/** Only local object URLs created from an authorized response may reach img/audio. */ +export function safeBlobUrl(value: unknown): string | null { + return typeof value === "string" && /^blob:[^\s]{1,2000}$/.test(value) ? value : null; +} + +export function scopedPath(base: string, scope: ArtifactScope, artifactId?: string): string { + if (!base.startsWith("/") || base.startsWith("//") || /[?#\\]/.test(base) || base.includes("..") || + !isOpaqueId(scope.projectId, "prj") || !isOpaqueId(scope.conversationId, "conv") || + (artifactId !== undefined && !isOpaqueId(artifactId, "art"))) { + throw new TypeError("Artifact endpoint scope is invalid"); + } + const parts = [base.replace(/\/$/, ""), scope.projectId, "conversations", scope.conversationId, "artifacts"]; + if (artifactId) parts.push(artifactId); + return parts.map((part, index) => index ? encodeURIComponent(part) : part).join("/"); +} diff --git a/dockerfiles/hermes-webui-hux/artifacts/styles.css b/dockerfiles/hermes-webui-hux/artifacts/styles.css new file mode 100644 index 00000000..e146882b --- /dev/null +++ b/dockerfiles/hermes-webui-hux/artifacts/styles.css @@ -0,0 +1,294 @@ +.hux-artifacts { + color: var(--hux-text, #dce8f4); + display: grid; + gap: 0.8rem; + grid-template-columns: minmax(12rem, 0.75fr) minmax(20rem, 2fr) minmax(13rem, 0.85fr); + min-height: 30rem; +} + +.hux-artifact-list, +.hux-artifact-main, +.hux-artifact-side { + background: color-mix(in srgb, currentColor 2.5%, transparent); + border: 1px solid color-mix(in srgb, currentColor 13%, transparent); + border-radius: 0.9rem; + min-width: 0; + padding: 0.8rem; +} + +.hux-artifact-list header h2, +.hux-artifact-history h3, +.hux-artifact-list button, +.hux-artifact-actions, +.hux-artifact-actions button, +.hux-artifact-heading, +.hux-artifact-preview-empty, +.hux-artifact-evidence, +.hux-artifact-promote, +.hux-artifact-diff { + align-items: center; + display: flex; +} + +.hux-artifact-list header h2, +.hux-artifact-history h3 { + font-size: 0.9rem; + gap: 0.4rem; + margin: 0; +} + +.hux-artifact-list header p, +.hux-artifact-heading p, +.hux-artifact-empty { + color: color-mix(in srgb, currentColor 58%, transparent); + font-size: 0.7rem; + margin: 0.25rem 0 0; +} + +.hux-artifact-list ul, +.hux-artifact-history ol { + display: grid; + gap: 0.4rem; + list-style: none; + margin: 0.8rem 0 0; + padding: 0; +} + +.hux-artifact-list button, +.hux-artifact-history button, +.hux-artifact-actions button, +.hux-artifact-promote, +.hux-artifact-diff { + background: color-mix(in srgb, currentColor 4%, transparent); + border: 1px solid color-mix(in srgb, currentColor 13%, transparent); + border-radius: 0.6rem; + color: inherit; + cursor: pointer; + font: inherit; +} + +.hux-artifact-list button { + gap: 0.55rem; + padding: 0.55rem; + text-align: left; + width: 100%; +} + +.hux-artifact-list button[aria-current="page"] { + background: color-mix(in srgb, var(--hux-accent, #25d4c3) 11%, transparent); + border-color: color-mix(in srgb, var(--hux-accent, #25d4c3) 45%, transparent); +} + +.hux-artifact-list button > span { + display: grid; + gap: 0.15rem; + min-width: 0; +} + +.hux-artifact-list strong { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.hux-artifact-list small { + color: color-mix(in srgb, currentColor 55%, transparent); + text-transform: capitalize; +} + +.hux-artifact-list svg, +.hux-artifact-actions svg, +.hux-artifact-history svg, +.hux-artifact-preview-empty svg, +.hux-artifact-promote svg { + flex: 0 0 auto; + height: 0.95rem; + width: 0.95rem; +} + +.hux-artifact-heading { + align-items: start; + flex-wrap: wrap; + gap: 0.75rem; + justify-content: space-between; +} + +.hux-artifact-heading > div:first-child > span { + color: var(--hux-accent, #25d4c3); + font-size: 0.62rem; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.hux-artifact-heading h2 { + font-size: 1rem; + margin: 0.15rem 0; +} + +.hux-artifact-actions { + flex-wrap: wrap; + gap: 0.4rem; + justify-content: flex-end; +} + +.hux-artifact-actions button, +.hux-artifact-promote, +.hux-artifact-diff { + gap: 0.35rem; + padding: 0.4rem 0.55rem; +} + +.hux-artifacts button:focus-visible, +.hux-artifacts pre:focus-visible { + outline: 2px solid var(--hux-accent, #25d4c3); + outline-offset: 2px; +} + +.hux-artifacts button:disabled { + cursor: not-allowed; + opacity: 0.48; +} + +.hux-artifact-document, +.hux-artifact-code, +.hux-artifact-report, +.hux-artifact-data, +.hux-artifact-image, +.hux-artifact-audio, +.hux-artifact-preview-empty { + background: color-mix(in srgb, #050a12 70%, transparent); + border: 1px solid color-mix(in srgb, currentColor 12%, transparent); + border-radius: 0.75rem; + margin: 0.85rem 0; + min-height: 15rem; + overflow: auto; + padding: 0.8rem; +} + +.hux-artifact-report h3 { + color: var(--hux-accent, #25d4c3); + font-size: 0.7rem; + letter-spacing: 0.08em; + margin: 0 0 0.65rem; + text-transform: uppercase; +} + +.hux-artifact-document pre, +.hux-artifact-code pre, +.hux-artifact-report pre, +.hux-artifact-data pre { + font: 0.76rem/1.6 ui-monospace, SFMono-Regular, Consolas, monospace; + margin: 0; + overflow-wrap: anywhere; + white-space: pre-wrap; +} + +.hux-artifact-preview-empty { + align-content: center; + color: color-mix(in srgb, currentColor 55%, transparent); + flex-direction: column; + justify-content: center; + text-align: center; +} + +.hux-artifact-image { + display: grid; + place-items: center; +} + +.hux-artifact-image img { + max-height: 32rem; + max-width: 100%; + object-fit: contain; +} + +.hux-artifact-image figcaption, +.hux-artifact-audio span { + color: color-mix(in srgb, currentColor 55%, transparent); + font-size: 0.65rem; + margin-top: 0.5rem; +} + +.hux-artifact-audio { + align-content: center; + display: grid; +} + +.hux-artifact-audio audio { width: 100%; } + +.hux-artifact-evidence { + gap: 0.55rem; + margin: 0.8rem 0; +} + +.hux-artifact-evidence div { + background: color-mix(in srgb, currentColor 4%, transparent); + border-radius: 0.55rem; + display: grid; + flex: 1; + padding: 0.55rem; +} + +.hux-artifact-evidence strong { color: var(--hux-accent, #25d4c3); } +.hux-artifact-evidence span { font-size: 0.65rem; } + +.hux-artifact-promote { margin-left: auto; } + +.hux-artifact-history ol > li { + border-bottom: 1px solid color-mix(in srgb, currentColor 10%, transparent); + display: grid; + gap: 0.35rem; + padding-bottom: 0.45rem; +} + +.hux-artifact-history ol > li[data-current="true"] { border-color: var(--hux-accent, #25d4c3); } + +.hux-artifact-history li > button:first-child { + display: grid; + gap: 0.15rem; + padding: 0.48rem; + text-align: left; + width: 100%; +} + +.hux-artifact-history li > button[aria-pressed="true"] { + background: color-mix(in srgb, var(--hux-accent, #25d4c3) 9%, transparent); +} + +.hux-artifact-history span, +.hux-artifact-history time { + color: color-mix(in srgb, currentColor 56%, transparent); + font-size: 0.63rem; +} + +.hux-artifact-diff { font-size: 0.68rem; justify-self: end; } + +.hux-artifact-error, +.hux-artifact-warning { + border-left: 2px solid #efb756; + color: #efcf96; + font-size: 0.72rem; + margin: 0 0 0.7rem; + padding-left: 0.55rem; +} + +.hux-artifact-error { border-color: #e36b7c; color: #f0a4ae; } + +@media (max-width: 66rem) { + .hux-artifacts { grid-template-columns: minmax(12rem, 0.7fr) minmax(20rem, 2fr); } + .hux-artifact-side { grid-column: 1 / -1; } + .hux-artifact-history ol { grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr)); } +} + +@media (max-width: 42rem) { + .hux-artifacts { grid-template-columns: 1fr; } + .hux-artifact-side { grid-column: auto; } + .hux-artifact-heading { align-items: stretch; } + .hux-artifact-actions { justify-content: stretch; } + .hux-artifact-actions button { flex: 1; } +} + +@media (prefers-reduced-motion: reduce) { + .hux-artifacts * { scroll-behavior: auto; transition: none; } +} diff --git a/dockerfiles/hermes-webui-hux/artifacts/types.ts b/dockerfiles/hermes-webui-hux/artifacts/types.ts new file mode 100644 index 00000000..2eb70083 --- /dev/null +++ b/dockerfiles/hermes-webui-hux/artifacts/types.ts @@ -0,0 +1,174 @@ +/** Safe frontend contracts for the default-off HUX-04 artifact workspace. */ + +export type HuxSurface = "chat" | "worker" | "telegram" | "voice" | "api"; +export type ArtifactType = + | "markdown" + | "code" + | "html" + | "svg" + | "image" + | "json" + | "csv" + | "document" + | "audio"; +export type ArtifactRenderer = "document" | "code" | "image" | "report" | "data" | "audio"; +export type Sensitivity = "public" | "personal" | "sensitive" | "restricted"; +export type AuthorizationStatus = "unavailable" | "requires_approval" | "authorized"; + +export interface HuxIdentity { + tenantRef: string; + userRef: string; + surface: HuxSurface; +} + +export interface ArtifactScope { + projectId: string; + conversationId: string; +} + +export interface RawArtifactVersion { + version?: unknown; + created_at?: unknown; + created_by?: unknown; + message_id?: unknown; + content_ref?: unknown; + diff_from?: unknown; + lineage?: unknown; + note?: unknown; +} + +export interface RawArtifact { + schema?: unknown; + id?: unknown; + owner?: unknown; + conversation_id?: unknown; + project_id?: unknown; + type?: unknown; + language?: unknown; + title?: unknown; + current_version?: unknown; + versions?: unknown; + promotion?: unknown; + sensitivity?: unknown; + created_at?: unknown; + updated_at?: unknown; +} + +export interface RawArtifactEnvelope { + schema?: unknown; + api_version?: unknown; + identity?: unknown; + binding?: unknown; + artifacts?: unknown; + attachments?: unknown; + authorizations?: unknown; +} + +export interface ContentReference { + hash: string; + bytes: number; + mime: string; +} + +export interface ArtifactVersion { + version: number; + createdAt: string; + createdBy: string; + contentRef: ContentReference; + diffFrom: number | null; + lineage: {artifactId: string; version: number} | null; + note: string; +} + +export interface Artifact { + id: string; + owner: string; + projectId: string; + conversationId: string; + type: ArtifactType; + renderer: ArtifactRenderer; + language: string; + title: string; + currentVersion: number; + versions: ArtifactVersion[]; + sensitivity: Sensitivity; + promotedVersion: number | null; + updatedAt: string; +} + +export interface ArtifactAttachments { + artifactId: string; + sourceCount: number; + citationCount: number; +} + +export interface ArtifactAuthorization { + artifactId: string; + download: AuthorizationStatus; + share: AuthorizationStatus; +} + +export interface ArtifactWorkspacePage { + artifacts: Artifact[]; + attachments: ArtifactAttachments[]; + authorizations: ArtifactAuthorization[]; + rejected: number; +} + +export interface RawPreviewPayload { + schema?: unknown; + identity?: unknown; + binding?: unknown; + artifact_id?: unknown; + version?: unknown; + hash?: unknown; + mime?: unknown; + text?: unknown; + blob_url?: unknown; +} + +export interface ArtifactPreview { + artifactId: string; + version: number; + renderer: ArtifactRenderer; + mime: string; + text: string | null; + blobUrl: string | null; +} + +export interface ArtifactMutationIntent { + artifactId: string; + expectedVersion: number; + scope: ArtifactScope; +} + +export interface PromoteIntent extends ArtifactMutationIntent { + targetProjectId: string; + version: number; +} + +export interface DiffIntent extends ArtifactMutationIntent { + fromVersion: number; + toVersion: number; +} + +export interface ArtifactEndpoint { + method: "GET" | "POST"; + path: string; + responseSchema: string; + requiredBody?: readonly string[]; +} + +export interface ArtifactEndpointContract { + list: ArtifactEndpoint; + item(artifactId: string): ArtifactEndpoint; + preview(artifactId: string, version: number): ArtifactEndpoint; + diff(artifactId: string, fromVersion: number, toVersion: number): ArtifactEndpoint; + appendVersion(artifactId: string): ArtifactEndpoint; + continueEdit(artifactId: string): ArtifactEndpoint; + authorize(artifactId: string, action: "download" | "share"): ArtifactEndpoint; + download(artifactId: string, version: number): ArtifactEndpoint; + share(artifactId: string, version: number): ArtifactEndpoint; + promote(artifactId: string): ArtifactEndpoint; + sources(artifactId: string): ArtifactEndpoint; +}