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
100 lines
5.0 KiB
TypeScript
100 lines
5.0 KiB
TypeScript
/** Identity, binding, media, and display guards for HUX-07. */
|
|
|
|
import type { ArtifactScope, HuxIdentity } from "../artifacts/types.ts";
|
|
import type { FileCandidate, MediaKind, UploadValidation } from "./types.ts";
|
|
|
|
const ID = /^[a-z]{2,8}_[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 CONTROL = /[\u0000-\u001f\u007f]/g;
|
|
const SECRET = /\b(password|token|api[-_ ]?key|secret|authorization|cookie)\b\s*[:=]\s*[^\s,;]+/gi;
|
|
const MIME_KIND = new Map<string, MediaKind>([
|
|
["image/png", "image"], ["image/jpeg", "image"], ["image/webp", "image"],
|
|
["application/pdf", "document"], ["text/plain", "document"],
|
|
["text/markdown", "document"], ["audio/webm", "audio"], ["audio/wav", "audio"],
|
|
["audio/mpeg", "audio"], ["audio/ogg", "audio"],
|
|
]);
|
|
const MAX_BYTES: Readonly<Record<MediaKind, number>> = {
|
|
image: 20 * 1024 * 1024, document: 25 * 1024 * 1024, audio: 50 * 1024 * 1024,
|
|
};
|
|
|
|
export function isOpaqueId(value: unknown, prefix?: string): value is string {
|
|
return typeof value === "string" && 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 safeText(value: unknown, fallback: string, limit = 500): string {
|
|
if (typeof value !== "string") return fallback;
|
|
const clean = value.replace(SECRET, "$1=[redacted]").replace(CONTROL, " ")
|
|
.replace(/\s+/g, " ").trim();
|
|
if (!clean) return fallback;
|
|
return clean.length <= limit ? clean : `${clean.slice(0, Math.max(0, limit - 1)).trimEnd()}…`;
|
|
}
|
|
|
|
export function normalizeIdentity(raw: unknown): HuxIdentity | null {
|
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
|
const value = raw as Record<string, unknown>;
|
|
const tenantRef = value.tenant_ref ?? value.tenantRef;
|
|
const userRef = value.user_ref ?? value.userRef;
|
|
if (typeof tenantRef !== "string" || !TENANT.test(tenantRef) ||
|
|
typeof userRef !== "string" || !USER.test(userRef) ||
|
|
!["chat", "worker", "telegram", "voice", "api"].includes(String(value.surface))) return null;
|
|
return {tenantRef, userRef, surface: value.surface as HuxIdentity["surface"]};
|
|
}
|
|
|
|
export function normalizeScope(raw: unknown): ArtifactScope | null {
|
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
|
const value = raw as Record<string, unknown>;
|
|
const projectId = value.project_id ?? value.projectId;
|
|
const conversationId = value.conversation_id ?? value.conversationId;
|
|
return isOpaqueId(projectId, "prj") && isOpaqueId(conversationId, "conv") ?
|
|
{projectId, conversationId} : null;
|
|
}
|
|
|
|
export function sameIdentity(left: HuxIdentity | null, right: HuxIdentity): boolean {
|
|
return Boolean(left && left.tenantRef === right.tenantRef && left.userRef === right.userRef &&
|
|
left.surface === right.surface);
|
|
}
|
|
|
|
export function sameScope(left: ArtifactScope | null, right: ArtifactScope): boolean {
|
|
return Boolean(left && left.projectId === right.projectId && left.conversationId === right.conversationId);
|
|
}
|
|
|
|
/** Preview URLs must be local object URLs. Inline data and remote resources are never accepted. */
|
|
export function safeBlobUrl(value: unknown): string | null {
|
|
return typeof value === "string" && /^blob:(?:https?:\/\/|null\/)[^\s]+$/.test(value) ? value : null;
|
|
}
|
|
|
|
/** Text previews render only as text nodes; controls that can spoof layout are rejected. */
|
|
export function safePreviewText(value: unknown, limit = 200_000): string | null {
|
|
return typeof value === "string" && value.length <= limit &&
|
|
!/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(value) ? value : null;
|
|
}
|
|
|
|
export function classifyMime(value: unknown): MediaKind | null {
|
|
return typeof value === "string" ? MIME_KIND.get(value.toLowerCase()) || null : null;
|
|
}
|
|
|
|
/** Validate before reading bytes or handing a File to a transport adapter. */
|
|
export function validateUpload(file: FileCandidate, authorization: string): UploadValidation {
|
|
if (authorization !== "authorized") return {ok: false, reason: authorization === "requires_approval" ?
|
|
"Upload approval is required before selecting media." : "Media uploads are denied for this conversation."};
|
|
const kind = classifyMime(file?.type);
|
|
if (!kind) return {ok: false, reason: "This file type is not supported."};
|
|
if (!Number.isSafeInteger(file.size) || file.size < 1 || file.size > MAX_BYTES[kind]) {
|
|
return {ok: false, reason: `This ${kind} is empty or exceeds the ${MAX_BYTES[kind] / 1024 / 1024} MB limit.`};
|
|
}
|
|
const name = safeText(file.name, "", 180);
|
|
if (!name || /[/\\]/.test(name)) return {ok: false, reason: "The file name is invalid."};
|
|
return {ok: true, kind, file: {name, type: file.type.toLowerCase(), size: file.size}};
|
|
}
|
|
|
|
export function safeEndpointPart(value: unknown, prefix: string): string {
|
|
if (!isOpaqueId(value, prefix)) throw new TypeError(`Opaque ${prefix} id required`);
|
|
return encodeURIComponent(value);
|
|
}
|