import { z } from "zod"; import { portableRepoPathError } from "./config-paths.js"; import { canonicalPreimage, contractHash, sha256Hex } from "./contract/hash.js"; export const REGISTRY_MANIFEST_SCHEMA_VERSION = "fragments.registry.v1"; export const REGISTRY_ARTIFACT_SCHEMA_VERSION = "fragments.registry-artifact.v1"; export const REGISTRY_INSTALL_RECEIPT_SCHEMA_VERSION = "fragments.registry-lock.v1"; const sha256Schema = z.string().regex(/^[0-9a-f]{64}$/); const registryPathSchema = z .string() .min(1) .superRefine((value, ctx) => { const error = portableRepoPathError(value); if (error) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: error }); } }); const registryFilePathSchema = registryPathSchema.superRefine((value, ctx) => { if (value === ".") { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "File path must point to a file, not the package root", }); } }); export const registryDependencyTypeSchema = z.enum(["runtime", "peer", "dev", "optional"]); export const registryDependencySchema = z.object({ name: z.string().min(1), versionRange: z.string().min(1), type: registryDependencyTypeSchema, optional: z.boolean().optional(), }); export const registryFileRoleSchema = z.enum([ "source", "style", "token", "contract", "test", "story", "example", "asset", "barrel", "metadata", ]); export const registryFileSchema = z.object({ path: registryFilePathSchema, role: registryFileRoleSchema, sha256: sha256Schema, size: z.number().int().nonnegative(), contentType: z.string().min(1), contentRef: z.string().min(1), }); export const registryFileContentSchema = z.object({ path: registryFilePathSchema, content: z.string(), sha256: sha256Schema, size: z.number().int().nonnegative(), contentType: z.string().min(1), }); export const registrySourceSchema = z.discriminatedUnion("kind", [ z.object({ kind: z.literal("package"), packageName: z.string().min(1), version: z.string().optional(), rootPath: registryPathSchema.optional(), commitSha: z.string().optional(), }), z.object({ kind: z.literal("github"), repoFullName: z.string().min(1), commitSha: z.string().min(1), path: registryPathSchema.optional(), }), z.object({ kind: z.literal("local"), rootPath: registryPathSchema.optional(), commitSha: z.string().optional(), }), z.object({ kind: z.literal("manual"), label: z.string().min(1), }), ]); export const registryComponentExportSchema = z.object({ name: z.string().min(1), kind: z.enum(["component", "hook", "utility", "type", "style"]).default("component"), path: registryFilePathSchema.optional(), }); export const registryComponentSchema = z.object({ componentId: z.string().min(1), publicRef: z.string().min(1), name: z.string().min(1), status: z.string().min(1).default("stable"), tier: z.enum(["core", "composition", "utility", "custom"]).default("core"), category: z.string().optional(), files: z.array(registryFileSchema).min(1), styleFiles: z.array(registryFilePathSchema).default([]), tokenDependencies: z.array(z.string().min(1)).default([]), runtimeDependencies: z.array(registryDependencySchema).default([]), peerDependencies: z.array(registryDependencySchema).default([]), internalDependencies: z.array(z.string().min(1)).default([]), exports: z.array(registryComponentExportSchema).default([]), contract: z.unknown().optional(), examples: z.array(z.unknown()).default([]), provenance: z.record(z.unknown()).default({}), }); export const registryEntrypointSchema = z.object({ specifier: z.string().min(1), sourcePath: registryFilePathSchema, }); export const registryInstallProfileSchema = z.object({ name: z.string().min(1), targetPath: registryPathSchema, importPath: z.string().min(1), stylePath: registryPathSchema.optional(), includeTests: z.boolean().default(false), }); const registryManifestBaseSchema = z.object({ schemaVersion: z.literal(REGISTRY_MANIFEST_SCHEMA_VERSION), registryId: z.string().min(1), name: z.string().min(1), channel: z.string().min(1), version: z.string().min(1), contractVersionId: z.string().optional(), fcid: sha256Schema.optional(), generatedAt: z.string().datetime(), source: registrySourceSchema, packageFiles: z.array(registryFileSchema).default([]), entrypoints: z.array(registryEntrypointSchema).default([]), components: z.record(z.string(), registryComponentSchema), dependencies: z.array(registryDependencySchema).default([]), installProfiles: z.array(registryInstallProfileSchema).default([]), }); export const registryManifestDraftSchema = registryManifestBaseSchema; export const registryManifestSchema = registryManifestBaseSchema.extend({ registryHash: sha256Schema, }); export const registryArtifactSchema = z .object({ schemaVersion: z.literal(REGISTRY_ARTIFACT_SCHEMA_VERSION), manifest: registryManifestSchema, files: z.record(registryFilePathSchema, registryFileContentSchema), }) .superRefine((artifact, ctx) => { const filesByPath = artifact.files; for (const [path, content] of Object.entries(filesByPath)) { if (content.path !== path) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["files", path, "path"], message: `Registry file map key ${path} does not match content path ${content.path}`, }); } } const manifestFiles = [ ...artifact.manifest.packageFiles, ...artifact.manifest.entrypoints.map((entrypoint) => ({ path: entrypoint.sourcePath, role: "barrel" as const, sha256: filesByPath[entrypoint.sourcePath]?.sha256 ?? "", size: filesByPath[entrypoint.sourcePath]?.size ?? 0, contentType: filesByPath[entrypoint.sourcePath]?.contentType ?? "text/plain", contentRef: `sha256:${filesByPath[entrypoint.sourcePath]?.sha256 ?? ""}`, })), ...Object.values(artifact.manifest.components).flatMap((component) => component.files), ]; for (const file of manifestFiles) { const content = filesByPath[file.path]; if (!content) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["files", file.path], message: `Missing content for registry file ${file.path}`, }); continue; } if (content.sha256 !== file.sha256) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["files", file.path, "sha256"], message: `Content hash for ${file.path} does not match manifest`, }); } if (content.size !== file.size) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["files", file.path, "size"], message: `Content size for ${file.path} does not match manifest`, }); } } }); export const registryInstallReceiptFileSchema = z.object({ path: registryFilePathSchema, sha256: sha256Schema, sourcePath: registryFilePathSchema, componentId: z.string().min(1).optional(), }); export const registryInstallReceiptComponentSchema = z.object({ componentId: z.string().min(1), publicRef: z.string().min(1), name: z.string().min(1), files: z.array(registryFilePathSchema), }); export const registryInstallReceiptSchema = z.object({ schemaVersion: z.literal(REGISTRY_INSTALL_RECEIPT_SCHEMA_VERSION), registryId: z.string().min(1), registryVersion: z.string().min(1), registryHash: sha256Schema, fcid: sha256Schema.optional(), channel: z.string().min(1).optional(), targetPath: registryPathSchema, importPath: z.string().min(1), installedComponents: z.array(registryInstallReceiptComponentSchema), files: z.array(registryInstallReceiptFileSchema), dependencies: z.array(registryDependencySchema).default([]), installedAt: z.string().datetime(), }); export type RegistryDependencyType = z.infer; export type RegistryDependency = z.infer; export type RegistryFileRole = z.infer; export type RegistryFile = z.infer; export type RegistryFileContent = z.infer; export type RegistrySource = z.infer; export type RegistryComponentExport = z.infer; export type RegistryComponent = z.infer; export type RegistryEntrypoint = z.infer; export type RegistryInstallProfile = z.infer; export type RegistryManifestDraft = z.input; export type RegistryManifest = z.infer; export type RegistryArtifact = z.infer; export type RegistryInstallReceiptFile = z.infer; export type RegistryInstallReceiptComponent = z.infer; export type RegistryInstallReceipt = z.infer; export interface RegistryFileInput { path: string; role: RegistryFileRole; content: string; contentType: string; contentRef?: string; } export interface RegistryArtifactInput { manifest: RegistryManifestDraft; files: readonly RegistryFileContent[]; } function registryHashPayload(manifest: RegistryManifestDraft | RegistryManifest): unknown { const { generatedAt: _generatedAt, ...withoutGeneratedAt } = manifest; if ("registryHash" in withoutGeneratedAt) { const { registryHash: _registryHash, ...withoutHash } = withoutGeneratedAt; return withoutHash; } return withoutGeneratedAt; } export function computeRegistryHash(manifest: RegistryManifestDraft | RegistryManifest): string { return contractHash(registryHashPayload(manifest)); } export function finalizeRegistryManifest(input: RegistryManifestDraft): RegistryManifest { const draft = registryManifestDraftSchema.parse(input); return registryManifestSchema.parse({ ...draft, registryHash: computeRegistryHash(draft), }); } export function hashRegistryFileContent(content: string): string { return sha256Hex(content); } export function registryFileSize(content: string): number { return new TextEncoder().encode(content).length; } export function buildRegistryFile(input: RegistryFileInput): { metadata: RegistryFile; content: RegistryFileContent; } { const sha256 = hashRegistryFileContent(input.content); const size = registryFileSize(input.content); const path = registryFilePathSchema.parse(input.path); const contentType = z.string().min(1).parse(input.contentType); return { metadata: registryFileSchema.parse({ path, role: input.role, sha256, size, contentType, contentRef: input.contentRef ?? `sha256:${sha256}`, }), content: registryFileContentSchema.parse({ path, content: input.content, sha256, size, contentType, }), }; } export function buildRegistryArtifact(input: RegistryArtifactInput): RegistryArtifact { const manifest = finalizeRegistryManifest(input.manifest); const files = new Map(); for (const file of input.files) { const content = registryFileContentSchema.parse(file); if (files.has(content.path)) { throw new Error(`Duplicate registry file content for ${content.path}`); } files.set(content.path, content); } return registryArtifactSchema.parse({ schemaVersion: REGISTRY_ARTIFACT_SCHEMA_VERSION, manifest, files: Object.fromEntries(files), }); } export function assertValidRegistryArtifact(artifact: unknown): RegistryArtifact { return registryArtifactSchema.parse(artifact); } export function registryArtifactDigest(artifact: RegistryArtifact): string { const files = Object.fromEntries( Object.entries(artifact.files) .map(([path, file]) => [path, { sha256: file.sha256, size: file.size }] as const) .sort(([leftPath], [rightPath]) => leftPath.localeCompare(rightPath)) ); return contractHash({ schemaVersion: artifact.schemaVersion, manifest: registryHashPayload(artifact.manifest), files, }); } export function registryManifestCanonicalPreimage( manifest: RegistryManifestDraft | RegistryManifest ): string { return canonicalPreimage(registryHashPayload(manifest)); }