import { z } from 'zod' export const ASSET_TYPES = [ 'model', 'humanoid-model', 'texture', 'humanoid-animation', 'template', 'sound-effect', 'background-music', 'environment', 'flipbook', ] as const export type AssetType = (typeof ASSET_TYPES)[number] export const assetTypeSchema = z.enum(ASSET_TYPES) export const MAX_UPLOAD_ZIP_SIZE_BYTES = 1024 * 1024 * 1024 export const MAX_ASSET_DESCRIPTION_LENGTH = 1000 export const semverSchema = z .string() .regex( /^\d+\.\d+\.\d+(-[a-zA-Z0-9.]+)?(\+[a-zA-Z0-9.]+)?$/, 'Must be a valid semver version (e.g. 1.0.0)', ) export const assetNameSchema = z .string() .min(1) .max(128) .regex( /^[a-z0-9][a-z0-9-]*[a-z0-9]$/, 'Must be lowercase alphanumeric with hyphens, no leading/trailing hyphens', ) .refine((name) => !name.endsWith('-example'), { message: "Asset names cannot end with '-example' (reserved for generated examples)", }) export const npmDependenciesSchema = z.record(z.string(), z.string()).default({}) // An asset dependency is either a plain range ("^1.0.0") or an object carrying the range plus // `alias`: a map of the dependency's canonical install paths to where this project keeps the file // after renaming/moving it. Install writes each file at `alias[path] ?? path`; pack records the // aliases it detects by content hash, so renamed installed files are omitted from packs and // restored to their renamed locations on reinstall. // // `false` is a deletion tombstone (precedent: package.json's `browser` field): "do not install // this file here". Install skips the file, sync/pack treat it as intentionally absent, and the // entry survives untouched. Tombstones are authored, never inferred — a missing file is ambiguous // (it also covers in-place edits), so nothing auto-tombstones. export const assetDependencyAliasSchema = z.record( z.string(), z.union([z.string(), z.literal(false)]), ) export const assetDependencyValueSchema = z.union([ z.string(), z.object({ version: z.string(), alias: assetDependencyAliasSchema.optional() }), ]) export type AssetDependencyValue = z.infer export type AssetDependencies = Record export const assetDependenciesSchema = z.record(z.string(), assetDependencyValueSchema).default({}) export function assetDependencyRange(value: AssetDependencyValue): string { return typeof value === 'string' ? value : value.version } export type AssetDependencyAlias = Record export function assetDependencyAlias(value: AssetDependencyValue): AssetDependencyAlias { return typeof value === 'string' ? {} : (value.alias ?? {}) } /** The slimmest value expressing `range` + `alias`: a plain string when there are no aliases. */ export function assetDependencyValue( range: string, alias: AssetDependencyAlias, ): AssetDependencyValue { return Object.keys(alias).length === 0 ? range : { version: range, alias } } // Maps a skill label to a `skills add` source: a remote ref (owner/repo, // git/HTTP URL, tree//) or a local path to a skill directory // shipped inside the asset. export const skillDependenciesSchema = z.record(z.string(), z.string()).default({}) export const assetDescriptionSchema = z.string().max(MAX_ASSET_DESCRIPTION_LENGTH) // Asset visibility. `public` behaves as before (subject to the approval flow); `private` is // owner-only. Omitted on upload/generate — the server resolves the default from the caller's // entitlement (private when they hold `market:private`, else public). export const assetAccessSchema = z.enum(['public', 'private']) export type AssetAccess = z.infer export const updateProfileSchema = z.object({ name: z.string().min(1).max(100).optional(), image: z.string().url().optional(), }) export const listAssetsSchema = z.object({ page: z.number().int().min(1).default(1), limit: z.number().int().min(1).max(100).default(20), type: assetTypeSchema.optional(), query: z.string().max(200).optional(), includeUnapproved: z.boolean().default(false), }) /** One client search wave. Keeping the bound explicit protects both AI and database batch sizes. */ export const assetQueriesSchema = z.object({ queries: z .array( z.object({ type: assetTypeSchema, query: z.string().min(1).max(200).optional(), startAfter: z.number().int().min(0).max(32).default(0), amount: z.number().int().min(1).max(8).default(3), }), ) .min(1) .max(24), }) export const MAX_ASSET_REFS_QUERY_LENGTH = 4000 // An asset's capability key — embedded in file URL paths and in the preview URL's `?key=` query. // Accepted by exact reads as an alternative credential to identity, so "sharing an asset" is // sharing this one string. const accessKeySchema = z.string().min(1).max(128) export const exactAssetSchema = z.object({ name: assetNameSchema, type: assetTypeSchema.optional(), version: semverSchema.optional(), includeUnapproved: z.boolean().default(false), key: accessKeySchema.optional(), }) // The refs mode of the collection read: `?refs=name[@version],…` returns exactly those assets — // full entries with files and version history — order-preserved, null per miss. Reads are plural // by default; a single asset is the plural of one. export const assetRefsSchema = z.object({ refs: z.string().min(1).max(MAX_ASSET_REFS_QUERY_LENGTH), includeUnapproved: z.boolean().default(false), key: accessKeySchema.optional(), }) export const assetCollectionReadSchema = z.union([ assetRefsSchema, listAssetsSchema.extend({ refs: z.never().optional() }), ]) export const uploadZipSchema = z.object({ name: assetNameSchema, type: assetTypeSchema, version: semverSchema, description: assetDescriptionSchema.optional(), npmDependencies: npmDependenciesSchema, assetDependencies: assetDependenciesSchema, skillDependencies: skillDependenciesSchema, tags: z.array(z.string()).default([]), // Requested visibility. Omitted → server resolves from entitlement; `private` requires the // `market:private` scope (rejected otherwise). access: assetAccessSchema.optional(), }) // Input of `asset.files`: the derived file index of a version, for per-file installs from the data // plane. Same credential rules as the `GET /download` zip view: an identified viewer or a `key`. export const assetFilesSchema = z.object({ name: assetNameSchema, version: semverSchema, key: accessKeySchema.optional(), }) // Input of `asset.versions`: every published version of an asset (same credential rules as reads). export const assetVersionsSchema = z.object({ name: assetNameSchema, key: accessKeySchema.optional(), }) export interface AssetVersionListing { version: string approved: boolean } export interface AssetVersionsResult { versions: AssetVersionListing[] } // A phone-photo data URI is a few MB; this bounds worker and model payloads while staying far // above what providers keep (images are downscaled upstream). const MAX_REFERENCE_IMAGE_CHARS = 10_000_000 // Reference images the generated asset should match, each an http(s) URL or a // `data:image/;base64,` URI. Only providers that declare // `acceptsReferenceImages` take them, and none takes more than four. const referenceImagesSchema = z .array( z .string() .max(MAX_REFERENCE_IMAGE_CHARS) .regex(/^(https?:\/\/|data:image\/)/, 'Must be an http(s) URL or a data:image URI'), ) .max(4) export const generateAssetSchema = z.object({ description: assetDescriptionSchema.min(3), type: assetTypeSchema.optional(), referenceImages: referenceImagesSchema.optional(), // Requested visibility of the generated asset (same resolution as upload). access: assetAccessSchema.optional(), }) // The completed asset of a generation: an installable name@version. export const generatedAssetSchema = z.object({ assetName: assetNameSchema, version: semverSchema, }) // Fast providers finish inline; slow providers return a Workflow id to poll. export const generateResponseSchema = z.discriminatedUnion('status', [ generatedAssetSchema.extend({ status: z.literal('completed') }), z.object({ status: z.literal('pending'), jobId: z.string().min(1) }), ]) export type GenerateResponse = z.infer export const generateStatusInputSchema = z.object({ jobId: z.string().min(1), }) // The state of a polled generation job: still running, or settled (completed with the asset / failed // with a reason). Mirrors agentRunStatusSchema so the two async flows read the same. export const generateJobStatusSchema = z.discriminatedUnion('status', [ z.object({ status: z.literal('running') }), generatedAssetSchema.extend({ status: z.literal('completed') }), z.object({ status: z.literal('failed'), error: z.string() }), ]) export type GenerateJobStatus = z.infer // The agent takes a natural-language goal (which carries the count and kinds, // e.g. "3 low poly stones" or "a template for a zombie game, none if no fit") // and optional reference images (http(s) URL or data URI). export const agentStartSchema = z.strictObject({ goal: z.string().min(3).max(1000), referenceImages: referenceImagesSchema.optional(), }) export const agentStatusSchema = z.object({ runId: z.string().min(1), }) // One chosen asset: a verified, installable `name@version` (the agent only // returns names the catalog resolved) plus a note on what it contributes. export const agentResultAssetSchema = z.object({ name: assetNameSchema, version: semverSchema, notes: z.string(), origin: z.enum(['catalog', 'generated']), }) export type AgentResultAsset = z.infer // The agent's answer: the chosen assets, plus a general note for limitations, // missing pieces and how to work around them. `assets` is empty when nothing // fits — the note can still recommend a workaround. export const agentResultSchema = z.object({ notes: z.string(), assets: z.array(agentResultAssetSchema), }) export type AgentResult = z.infer // An agent run is an async job: running, completed, or failed. A discriminated // union keeps "completed without a result" and "failed without an error" // unrepresentable. export const agentRunStatusSchema = z.discriminatedUnion('status', [ z.object({ status: z.literal('running') }), z.object({ status: z.literal('completed'), result: agentResultSchema }), z.object({ status: z.literal('failed'), error: z.string() }), ]) export type AgentRunStatus = z.infer