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
105 lines
4.5 KiB
TypeScript
105 lines
4.5 KiB
TypeScript
/** 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<string, unknown>;
|
|
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<string, unknown>;
|
|
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("/");
|
|
}
|