diff --git a/libs/guard/project.json b/libs/guard/project.json index f8e11571c..1af83a3b9 100644 --- a/libs/guard/project.json +++ b/libs/guard/project.json @@ -5,9 +5,15 @@ "projectType": "library", "tags": ["scope:libs", "scope:publishable", "versioning:synchronized"], "targets": { + "generate-types": { + "executor": "nx:run-commands", + "options": { + "command": "npx tsx scripts/generate-schema-types.mjs" + } + }, "build-cjs": { "executor": "@nx/esbuild:esbuild", - "dependsOn": ["^build"], + "dependsOn": ["^build", "generate-types"], "outputs": ["{options.outputPath}"], "options": { "outputPath": "libs/guard/dist", diff --git a/libs/guard/src/schemas/index.ts b/libs/guard/src/schemas/index.ts index 265a24d87..8f94d89a1 100644 --- a/libs/guard/src/schemas/index.ts +++ b/libs/guard/src/schemas/index.ts @@ -6,3 +6,10 @@ export { ipFilterConfigSchema, guardConfigSchema, } from './schemas'; +export type { + ConcurrencyConfigInput, + RateLimitConfigInput, + TimeoutConfigInput, + IpFilterConfigInput, + GuardConfigInput, +} from './schemas.generated'; diff --git a/libs/guard/src/schemas/schemas.generated.ts b/libs/guard/src/schemas/schemas.generated.ts new file mode 100644 index 000000000..4cf7356d4 --- /dev/null +++ b/libs/guard/src/schemas/schemas.generated.ts @@ -0,0 +1,111 @@ +// AUTO-GENERATED from Zod schemas — do not edit manually. +// Run: npx tsx scripts/generate-schema-types.mjs +// Source: scripts/generate-schema-types.mjs + +import type { PartitionKey } from '../partition-key/types'; + +/** + * Input type for concurrency control configuration. + * All fields are optional for IDE autocomplete; required fields + * are validated at runtime by concurrencyConfigSchema. + */ +export interface ConcurrencyConfigInput { + /** Maximum number of concurrent executions allowed. */ + maxConcurrent?: number; + /** + * Maximum time in ms to wait in queue (0 = no wait). + * @default 0 + */ + queueTimeoutMs?: number; + /** + * Partition key strategy. + * @default "global" + */ + partitionBy?: PartitionKey; +} + +/** + * Input type for rate limiting configuration. + * All fields are optional for IDE autocomplete; required fields + * are validated at runtime by rateLimitConfigSchema. + */ +export interface RateLimitConfigInput { + /** Maximum number of requests allowed within the window. */ + maxRequests?: number; + /** + * Time window in milliseconds. + * @default 60000 + */ + windowMs?: number; + /** + * Partition key strategy. + * @default "global" + */ + partitionBy?: PartitionKey; +} + +/** + * Input type for timeout configuration. + * All fields are optional for IDE autocomplete; required fields + * are validated at runtime by timeoutConfigSchema. + */ +export interface TimeoutConfigInput { + /** Maximum execution time in milliseconds. */ + executeMs?: number; +} + +/** + * Input type for IP filtering configuration. + * All fields are optional for IDE autocomplete; required fields + * are validated at runtime by ipFilterConfigSchema. + */ +export interface IpFilterConfigInput { + /** IP addresses or CIDR ranges to always allow. */ + allowList?: Array; + /** IP addresses or CIDR ranges to always block. */ + denyList?: Array; + /** + * Default action when IP matches neither list. + * @default "allow" + */ + defaultAction?: 'allow' | 'deny'; + /** + * Trust X-Forwarded-For header. + * @default false + */ + trustProxy?: boolean; + /** + * Max number of proxies to trust from X-Forwarded-For. + * @default 1 + */ + trustedProxyDepth?: number; +} + +/** + * Input type for guard system configuration. + * All fields are optional for IDE autocomplete; required fields + * are validated at runtime by guardConfigSchema. + */ +export interface GuardConfigInput { + /** Whether the guard system is enabled. */ + enabled?: boolean; + /** Storage backend configuration. */ + storage?: Record; + /** + * Key prefix for all storage keys. + * @default "mcp:guard:" + */ + keyPrefix?: string; + /** Global rate limit applied to all requests. */ + global?: RateLimitConfigInput; + /** Global concurrency limit. */ + globalConcurrency?: ConcurrencyConfigInput; + /** Default rate limit for entities without explicit config. */ + defaultRateLimit?: RateLimitConfigInput; + /** Default concurrency for entities without explicit config. */ + defaultConcurrency?: ConcurrencyConfigInput; + /** Default timeout for entity execution. */ + defaultTimeout?: TimeoutConfigInput; + /** IP filtering configuration. */ + ipFilter?: IpFilterConfigInput; +} diff --git a/libs/guard/src/schemas/schemas.ts b/libs/guard/src/schemas/schemas.ts index c79d7e497..1f2f84b60 100644 --- a/libs/guard/src/schemas/schemas.ts +++ b/libs/guard/src/schemas/schemas.ts @@ -20,19 +20,25 @@ export const partitionKeySchema = z.union([ // ============================================ export const rateLimitConfigSchema = z.object({ - maxRequests: z.number().int().positive(), - windowMs: z.number().int().positive().optional().default(60_000), - partitionBy: partitionKeySchema.optional().default('global'), + maxRequests: z.number().int().positive().describe('Maximum number of requests allowed within the window.'), + windowMs: z.number().int().positive().optional().default(60_000).describe('Time window in milliseconds.'), + partitionBy: partitionKeySchema.optional().default('global').describe('Partition key strategy.'), }); export const concurrencyConfigSchema = z.object({ - maxConcurrent: z.number().int().positive(), - queueTimeoutMs: z.number().int().nonnegative().optional().default(0), - partitionBy: partitionKeySchema.optional().default('global'), + maxConcurrent: z.number().int().positive().describe('Maximum number of concurrent executions allowed.'), + queueTimeoutMs: z + .number() + .int() + .nonnegative() + .optional() + .default(0) + .describe('Maximum time in ms to wait in queue (0 = no wait).'), + partitionBy: partitionKeySchema.optional().default('global').describe('Partition key strategy.'), }); export const timeoutConfigSchema = z.object({ - executeMs: z.number().int().positive(), + executeMs: z.number().int().positive().describe('Maximum execution time in milliseconds.'), }); // ============================================ @@ -40,11 +46,21 @@ export const timeoutConfigSchema = z.object({ // ============================================ export const ipFilterConfigSchema = z.object({ - allowList: z.array(z.string()).optional(), - denyList: z.array(z.string()).optional(), - defaultAction: z.enum(['allow', 'deny']).optional().default('allow'), - trustProxy: z.boolean().optional().default(false), - trustedProxyDepth: z.number().int().positive().optional().default(1), + allowList: z.array(z.string()).optional().describe('IP addresses or CIDR ranges to always allow.'), + denyList: z.array(z.string()).optional().describe('IP addresses or CIDR ranges to always block.'), + defaultAction: z + .enum(['allow', 'deny']) + .optional() + .default('allow') + .describe('Default action when IP matches neither list.'), + trustProxy: z.boolean().optional().default(false).describe('Trust X-Forwarded-For header.'), + trustedProxyDepth: z + .number() + .int() + .positive() + .optional() + .default(1) + .describe('Max number of proxies to trust from X-Forwarded-For.'), }); // ============================================ @@ -52,13 +68,17 @@ export const ipFilterConfigSchema = z.object({ // ============================================ export const guardConfigSchema = z.object({ - enabled: z.boolean(), - storage: z.looseObject({}).optional(), - keyPrefix: z.string().optional().default('mcp:guard:'), - global: rateLimitConfigSchema.optional(), - globalConcurrency: concurrencyConfigSchema.optional(), - defaultRateLimit: rateLimitConfigSchema.optional(), - defaultConcurrency: concurrencyConfigSchema.optional(), - defaultTimeout: timeoutConfigSchema.optional(), - ipFilter: ipFilterConfigSchema.optional(), + enabled: z.boolean().describe('Whether the guard system is enabled.'), + storage: z.looseObject({}).optional().describe('Storage backend configuration.'), + keyPrefix: z.string().optional().default('mcp:guard:').describe('Key prefix for all storage keys.'), + global: rateLimitConfigSchema.optional().describe('Global rate limit applied to all requests.'), + globalConcurrency: concurrencyConfigSchema.optional().describe('Global concurrency limit.'), + defaultRateLimit: rateLimitConfigSchema + .optional() + .describe('Default rate limit for entities without explicit config.'), + defaultConcurrency: concurrencyConfigSchema + .optional() + .describe('Default concurrency for entities without explicit config.'), + defaultTimeout: timeoutConfigSchema.optional().describe('Default timeout for entity execution.'), + ipFilter: ipFilterConfigSchema.optional().describe('IP filtering configuration.'), }); diff --git a/libs/sdk/src/common/decorators/__tests__/tool-decorator-guard-config.spec.ts b/libs/sdk/src/common/decorators/__tests__/tool-decorator-guard-config.spec.ts new file mode 100644 index 000000000..7e7328903 --- /dev/null +++ b/libs/sdk/src/common/decorators/__tests__/tool-decorator-guard-config.spec.ts @@ -0,0 +1,134 @@ +import 'reflect-metadata'; +import { z } from 'zod'; +import { Tool, ToolContext } from '../../'; + +describe('Tool decorator guard config validation', () => { + it('should accept valid concurrency config', () => { + expect(() => { + @Tool({ + name: 'valid-concurrency', + inputSchema: { query: z.string() }, + concurrency: { maxConcurrent: 5 }, + }) + class ValidTool extends ToolContext { + async execute(input: { query: string }) { + return { result: input.query }; + } + } + return ValidTool; + }).not.toThrow(); + }); + + it('should accept valid rateLimit config', () => { + expect(() => { + @Tool({ + name: 'valid-rate-limit', + inputSchema: { query: z.string() }, + rateLimit: { maxRequests: 100, windowMs: 60_000 }, + }) + class ValidTool extends ToolContext { + async execute(input: { query: string }) { + return { result: input.query }; + } + } + return ValidTool; + }).not.toThrow(); + }); + + it('should accept valid timeout config', () => { + expect(() => { + @Tool({ + name: 'valid-timeout', + inputSchema: { query: z.string() }, + timeout: { executeMs: 30_000 }, + }) + class ValidTool extends ToolContext { + async execute(input: { query: string }) { + return { result: input.query }; + } + } + return ValidTool; + }).not.toThrow(); + }); + + it('should accept tool with all guard configs', () => { + expect(() => { + @Tool({ + name: 'all-guards', + inputSchema: { query: z.string() }, + concurrency: { maxConcurrent: 3, queueTimeoutMs: 5000 }, + rateLimit: { maxRequests: 50, windowMs: 30_000 }, + timeout: { executeMs: 10_000 }, + }) + class AllGuardsTool extends ToolContext { + async execute(input: { query: string }) { + return { result: input.query }; + } + } + return AllGuardsTool; + }).not.toThrow(); + }); + + it('should accept tool with no guard config', () => { + expect(() => { + @Tool({ + name: 'no-guards', + inputSchema: { query: z.string() }, + }) + class SimpleTool extends ToolContext { + async execute(input: { query: string }) { + return { result: input.query }; + } + } + return SimpleTool; + }).not.toThrow(); + }); + + it('should reject concurrency config missing maxConcurrent at runtime', () => { + expect(() => { + @Tool({ + name: 'bad-concurrency', + inputSchema: { query: z.string() }, + concurrency: {}, + }) + class BadTool extends ToolContext { + async execute(input: { query: string }) { + return { result: input.query }; + } + } + return BadTool; + }).toThrow(); + }); + + it('should reject rateLimit config missing maxRequests at runtime', () => { + expect(() => { + @Tool({ + name: 'bad-rate-limit', + inputSchema: { query: z.string() }, + rateLimit: {}, + }) + class BadTool extends ToolContext { + async execute(input: { query: string }) { + return { result: input.query }; + } + } + return BadTool; + }).toThrow(); + }); + + it('should reject timeout config missing executeMs at runtime', () => { + expect(() => { + @Tool({ + name: 'bad-timeout', + inputSchema: { query: z.string() }, + timeout: {}, + }) + class BadTool extends ToolContext { + async execute(input: { query: string }) { + return { result: input.query }; + } + } + return BadTool; + }).toThrow(); + }); +}); diff --git a/libs/sdk/src/common/decorators/agent.decorator.ts b/libs/sdk/src/common/decorators/agent.decorator.ts index eb5036aec..9b912c331 100644 --- a/libs/sdk/src/common/decorators/agent.decorator.ts +++ b/libs/sdk/src/common/decorators/agent.decorator.ts @@ -1,6 +1,7 @@ import 'reflect-metadata'; import { extendedAgentMetadata, FrontMcpAgentTokens } from '../tokens'; import { ToolInputType, ToolOutputType, AgentMetadata, frontMcpAgentMetadataSchema } from '../metadata'; +import type { ConcurrencyConfigInput, RateLimitConfigInput, TimeoutConfigInput } from '@frontmcp/guard'; import z from 'zod'; // Forward reference - AgentContext will be defined in agent.interface.ts @@ -303,9 +304,20 @@ type __AgentSingleOutputType = __PrimitiveOutputType | __StructuredOutputType; type __OutputSchema = __AgentSingleOutputType | __AgentSingleOutputType[]; /** - * Agent metadata options with type constraints. + * Agent metadata options with permissive guard config types for IDE IntelliSense. + * + * Guard fields (concurrency, rateLimit, timeout) use auto-generated Input types + * where all fields are optional. Required fields are validated at runtime by Zod. + * @see schemas.generated.ts in @frontmcp/guard */ -export type AgentMetadataOptions = AgentMetadata; +export type AgentMetadataOptions = Omit< + AgentMetadata, + 'concurrency' | 'rateLimit' | 'timeout' +> & { + concurrency?: ConcurrencyConfigInput; + rateLimit?: RateLimitConfigInput; + timeout?: TimeoutConfigInput; +}; declare module '@frontmcp/sdk' { // ---------- the decorator (overloads) ---------- diff --git a/libs/sdk/src/common/decorators/tool.decorator.ts b/libs/sdk/src/common/decorators/tool.decorator.ts index 265ef4463..cdbc29c03 100644 --- a/libs/sdk/src/common/decorators/tool.decorator.ts +++ b/libs/sdk/src/common/decorators/tool.decorator.ts @@ -12,6 +12,7 @@ import { } from '../metadata'; import type { ToolUIConfig } from '../metadata/tool-ui.metadata'; import type { EsmOptions, RemoteOptions } from '../metadata'; +import type { ConcurrencyConfigInput, RateLimitConfigInput, TimeoutConfigInput } from '@frontmcp/guard'; import z from 'zod'; import { ToolContext } from '../interfaces'; import { ToolKind } from '../records/tool.record'; @@ -228,39 +229,20 @@ type __ToolMetadataBase = ToolMetad * The `ui` property accepts a `ToolUIConfig` from `@frontmcp/ui/types` * for configuring interactive widget rendering. */ -export type ToolMetadataOptions = __ToolMetadataBase & { - /** - * UI template configuration for rendering interactive widgets. - * - * The template builder function receives typed `ctx.input` and `ctx.output` - * based on the tool's `inputSchema` and `outputSchema`. - * - * @see {@link ToolUIConfig} for all available options including: - * - `template`: React component, HTML string, or builder function - * - `servingMode`: 'inline' | 'static' | 'hybrid' | 'direct-url' | 'custom-url' - * - `csp`: Content Security Policy configuration - * - `widgetAccessible`: Enable MCP bridge for tool calls from widget - * - `displayMode`: 'inline' | 'fullscreen' | 'pip' - * - And more... - * - * @example HTML template builder with typed context - * ```typescript - * ui: { - * template: (ctx) => `
${ctx.helpers.escapeHtml(ctx.output.name)}
`, - * // ctx.output is typed based on outputSchema - * servingMode: 'inline', - * } - * ``` - * - * @example React component - * ```typescript - * import WeatherCard from './weather-ui'; - * ui: { - * template: WeatherCard, - * servingMode: 'static', - * } - * ``` - */ +/** + * Tool metadata options with permissive guard config types for IDE IntelliSense. + * + * Guard fields (concurrency, rateLimit, timeout) use auto-generated Input types + * where all fields are optional. Required fields are validated at runtime by Zod. + * @see schemas.generated.ts in @frontmcp/guard + */ +export type ToolMetadataOptions = Omit< + __ToolMetadataBase, + 'concurrency' | 'rateLimit' | 'timeout' +> & { + concurrency?: ConcurrencyConfigInput; + rateLimit?: RateLimitConfigInput; + timeout?: TimeoutConfigInput; ui?: ToolUIConfig, ToolOutputOf<{ outputSchema: O }>>; }; @@ -338,29 +320,19 @@ declare module '@frontmcp/sdk' { // @ts-expect-error - Module augmentation requires decorator overload export function Tool< I extends __Shape, - O extends __OutputSchema, // Use our new output schema constraint - T extends ToolMetadataOptions & { outputSchema: any }, // ensure present + O extends __OutputSchema, + T extends ToolMetadataOptions & { outputSchema: any }, >( opts: T, ): ( - cls: C & - __MustExtendCtx & - __MustParam> & // <-- Will now show a rich error - __MustReturn>, // <-- Will now show a rich error + cls: C & __MustExtendCtx & __MustParam> & __MustReturn>, ) => __Rewrap, ToolOutputOf>; // 2) Overload: outputSchema NOT PROVIDED → execute() can return any // @ts-expect-error - Module augmentation requires decorator overload - export function Tool< - I extends __Shape, - // Note: 'O' is omitted, 'any' is used for the generic - T extends ToolMetadataOptions & { outputSchema?: never }, // ensure absent - >( + export function Tool & { outputSchema?: never }>( opts: T, ): ( - cls: C & - __MustExtendCtx & - __MustParam> & // <-- Will now show a rich error - __MustReturn>, // <-- Will now show 'any' + cls: C & __MustExtendCtx & __MustParam> & __MustReturn>, ) => __Rewrap, ToolOutputOf>; } diff --git a/libs/sdk/src/common/metadata/agent.metadata.ts b/libs/sdk/src/common/metadata/agent.metadata.ts index 039db6c8d..fe19d22c2 100644 --- a/libs/sdk/src/common/metadata/agent.metadata.ts +++ b/libs/sdk/src/common/metadata/agent.metadata.ts @@ -1,6 +1,8 @@ import { z } from 'zod'; import { FuncType, Type, Token } from '@frontmcp/di'; import { RawZodShape } from '../types'; +import type { RateLimitConfig, ConcurrencyConfig, TimeoutConfig } from '@frontmcp/guard'; +import { rateLimitConfigSchema, concurrencyConfigSchema, timeoutConfigSchema } from '@frontmcp/guard'; import { ProviderType } from '../interfaces/provider.interface'; import { PluginType } from '../interfaces/plugin.interface'; import { AdapterType } from '../interfaces/adapter.interface'; @@ -488,17 +490,17 @@ export interface AgentMetadata< /** * Rate limiting configuration for this agent. */ - rateLimit?: import('@frontmcp/guard').RateLimitConfig; + rateLimit?: RateLimitConfig; /** * Concurrency control configuration for this agent. */ - concurrency?: import('@frontmcp/guard').ConcurrencyConfig; + concurrency?: ConcurrencyConfig; /** * Timeout configuration for this agent's execution. */ - timeout?: import('@frontmcp/guard').TimeoutConfig; + timeout?: TimeoutConfig; } // ============================================================================ @@ -593,8 +595,8 @@ export const frontMcpAgentMetadataSchema = z execution: executionConfigSchema.optional(), tags: z.array(z.string().min(1)).optional(), hideFromDiscovery: z.boolean().optional().default(false), - rateLimit: z.looseObject({ maxRequests: z.number() }).optional(), - concurrency: z.looseObject({ maxConcurrent: z.number() }).optional(), - timeout: z.looseObject({ executeMs: z.number() }).optional(), + rateLimit: rateLimitConfigSchema.optional(), + concurrency: concurrencyConfigSchema.optional(), + timeout: timeoutConfigSchema.optional(), } satisfies RawZodShape) .passthrough(); diff --git a/package.json b/package.json index 94d3199b2..4902d8da4 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "coverage:all": "yarn test:unit:coverage && yarn test:e2e:coverage && yarn coverage:merge", "coverage:check": "npx nyc check-coverage --temp-dir=coverage/merged", "fix:imports": "node scripts/fix-unused-imports.mjs", + "generate:types": "npx tsx scripts/generate-schema-types.mjs", "build:ui": "nx run-many -t build --projects='@frontmcp/ui'", "publish:ui": "nx run-many -t publish --projects='@frontmcp/ui'" }, diff --git a/scripts/generate-schema-types.mjs b/scripts/generate-schema-types.mjs new file mode 100644 index 000000000..7b16c096a --- /dev/null +++ b/scripts/generate-schema-types.mjs @@ -0,0 +1,418 @@ +/** + * generate-schema-types.mjs + * + * Build-time script that reads Zod schemas and generates TypeScript "Input" + * interfaces where all fields are optional (for decorator IntelliSense). + * JSDoc is extracted from .describe() annotations on schema fields. + * + * Usage: + * npx tsx scripts/generate-schema-types.mjs + * + * Generated files are committed to git so IDEs can read them without + * running the build first. + */ + +import { writeFileSync, readFileSync, existsSync } from 'node:fs'; +import { resolve, dirname, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = resolve(__dirname, '..'); + +// ============================================ +// Schema Targets Configuration +// ============================================ + +const SCHEMA_TARGETS = [ + { + importPath: resolve(ROOT, 'libs/guard/src/schemas/schemas.ts'), + schemas: [ + { + name: 'concurrencyConfigSchema', + interfaceName: 'ConcurrencyConfigInput', + description: + 'Input type for concurrency control configuration.\nAll fields are optional for IDE autocomplete; required fields\nare validated at runtime by concurrencyConfigSchema.', + }, + { + name: 'rateLimitConfigSchema', + interfaceName: 'RateLimitConfigInput', + description: + 'Input type for rate limiting configuration.\nAll fields are optional for IDE autocomplete; required fields\nare validated at runtime by rateLimitConfigSchema.', + }, + { + name: 'timeoutConfigSchema', + interfaceName: 'TimeoutConfigInput', + description: + 'Input type for timeout configuration.\nAll fields are optional for IDE autocomplete; required fields\nare validated at runtime by timeoutConfigSchema.', + }, + { + name: 'ipFilterConfigSchema', + interfaceName: 'IpFilterConfigInput', + description: + 'Input type for IP filtering configuration.\nAll fields are optional for IDE autocomplete; required fields\nare validated at runtime by ipFilterConfigSchema.', + }, + { + name: 'guardConfigSchema', + interfaceName: 'GuardConfigInput', + description: + 'Input type for guard system configuration.\nAll fields are optional for IDE autocomplete; required fields\nare validated at runtime by guardConfigSchema.', + // Override nested schema types to use generated Input types + typeOverrides: { + global: 'RateLimitConfigInput', + globalConcurrency: 'ConcurrencyConfigInput', + defaultRateLimit: 'RateLimitConfigInput', + defaultConcurrency: 'ConcurrencyConfigInput', + defaultTimeout: 'TimeoutConfigInput', + ipFilter: 'IpFilterConfigInput', + storage: 'Record', + }, + }, + ], + outputFile: resolve(ROOT, 'libs/guard/src/schemas/schemas.generated.ts'), + imports: ["import type { PartitionKey } from '../partition-key/types';"], + }, +]; + +// ============================================ +// Zod Schema Walker (uses constructor.name, compatible with Zod v3 and v4) +// ============================================ + +/** + * Get the Zod type name from a schema instance. + * Works with both Zod v3 (_def.typeName) and Zod v4 (constructor.name / _def.type). + */ +function getZodTypeName(schema) { + if (!schema) return 'unknown'; + // Zod v3 uses _def.typeName + if (schema._def?.typeName) return schema._def.typeName; + // Zod v4 uses constructor.name + return schema.constructor?.name || 'unknown'; +} + +/** + * Unwrap layers of ZodOptional, ZodDefault, ZodEffects, ZodNullable + * and collect metadata along the way. + */ +function unwrapSchema(schema) { + let optional = false; + let defaultValue; + // .describe() text lives on schema.description in both v3 and v4 + let description = schema?.description; + + let current = schema; + let maxDepth = 20; + + while (current && maxDepth-- > 0) { + const typeName = getZodTypeName(current); + + if (!description) { + description = current.description; + } + + if (typeName === 'ZodOptional') { + optional = true; + current = current._def.innerType; + continue; + } + + if (typeName === 'ZodDefault') { + optional = true; + const dv = current._def.defaultValue; + if (typeof dv === 'function') { + try { defaultValue = JSON.stringify(dv()); } catch { /* ignore */ } + } else if (dv !== undefined) { + defaultValue = JSON.stringify(dv); + } + current = current._def.innerType; + continue; + } + + if (typeName === 'ZodEffects') { + current = current._def.schema; + continue; + } + + if (typeName === 'ZodNullable') { + current = current._def.innerType; + continue; + } + + if (typeName === 'ZodPipeline') { + current = current._def.in; + continue; + } + + break; + } + + return { inner: current, optional, defaultValue, description }; +} + +/** + * Check if a schema matches the PartitionKey union pattern: + * z.union([ z.enum(['ip','session','userId','global']), z.custom() ]) + */ +function isPartitionKeySchema(schema) { + const typeName = getZodTypeName(schema); + if (typeName !== 'ZodUnion') return false; + const options = schema._def.options || []; + if (options.length !== 2) return false; + const first = options[0]; + if (getZodTypeName(first) !== 'ZodEnum') return false; + // Zod v4 uses _def.entries (object), v3 uses _def.values (array) + const entries = first._def.entries || {}; + const values = Array.isArray(first._def.values) ? first._def.values : Object.keys(entries); + return values.includes('ip') && values.includes('session') && values.includes('global'); +} + +/** + * Convert a Zod schema to a TypeScript type string. + */ +function zodToTsType(schema, depth = 0) { + if (!schema) return 'unknown'; + + const typeName = getZodTypeName(schema); + + // Check for known special schemas first + if (isPartitionKeySchema(schema)) return 'PartitionKey'; + + switch (typeName) { + case 'ZodString': + return 'string'; + case 'ZodNumber': + return 'number'; + case 'ZodBoolean': + return 'boolean'; + case 'ZodBigInt': + return 'bigint'; + case 'ZodDate': + return 'Date'; + case 'ZodNull': + return 'null'; + case 'ZodUndefined': + return 'undefined'; + case 'ZodVoid': + return 'void'; + case 'ZodAny': + case 'ZodUnknown': + return 'unknown'; + + case 'ZodLiteral': + return JSON.stringify(schema._def.value); + + case 'ZodEnum': { + // Zod v4 uses _def.entries (object), v3 uses _def.values (array) + const entries = schema._def.entries || {}; + const vals = Array.isArray(schema._def.values) ? schema._def.values : Object.values(entries); + return vals.map(v => JSON.stringify(v)).join(' | '); + } + + case 'ZodNativeEnum': { + const enumObj = schema._def.values || {}; + const vals = Object.values(enumObj).filter(v => typeof v === 'string' || typeof v === 'number'); + return vals.map(v => JSON.stringify(v)).join(' | ') || 'unknown'; + } + + case 'ZodArray': + return `Array<${zodToTsType(schema._def.element || schema._def.type, depth)}>`; + + case 'ZodRecord': { + const keyType = zodToTsType(schema._def.keyType, depth); + const valueType = zodToTsType(schema._def.valueType, depth); + return `Record<${keyType}, ${valueType}>`; + } + + case 'ZodObject': { + const shape = typeof schema._def.shape === 'function' ? schema._def.shape() : schema._def.shape || {}; + const indent = ' '.repeat(depth + 1); + const closingIndent = ' '.repeat(depth); + const entries = Object.entries(shape); + if (entries.length === 0) return 'Record'; + const fields = entries.map(([key, fieldSchema]) => { + const { inner, description: desc } = unwrapSchema(fieldSchema); + const tsType = zodToTsType(inner, depth + 1); + const jsdoc = desc ? `${indent}/** ${desc} */\n` : ''; + return `${jsdoc}${indent}${key}?: ${tsType};`; + }); + return `{\n${fields.join('\n')}\n${closingIndent}}`; + } + + case 'ZodUnion': + case 'ZodDiscriminatedUnion': { + const options = schema._def.options || []; + return options.map(o => zodToTsType(o, depth)).join(' | ') || 'unknown'; + } + + case 'ZodIntersection': { + const left = zodToTsType(schema._def.left, depth); + const right = zodToTsType(schema._def.right, depth); + return `${left} & ${right}`; + } + + case 'ZodTuple': { + const items = schema._def.items || []; + return `[${items.map(i => zodToTsType(i, depth)).join(', ')}]`; + } + + case 'ZodOptional': + return zodToTsType(schema._def.innerType, depth); + + case 'ZodNullable': + return `${zodToTsType(schema._def.innerType, depth)} | null`; + + case 'ZodDefault': + return zodToTsType(schema._def.innerType, depth); + + case 'ZodEffects': + return zodToTsType(schema._def.schema, depth); + + case 'ZodLazy': + return 'unknown'; + + case 'ZodFunction': + return 'Function'; + + case 'ZodPipeline': + return zodToTsType(schema._def.in, depth); + + case 'ZodCustom': + return 'unknown'; + + default: + return 'unknown'; + } +} + +/** + * Extract fields from a ZodObject schema. + * @param {any} schema - ZodObject schema + * @param {Record} [typeOverrides] - field name → TS type string overrides + */ +function extractFields(schema, typeOverrides = {}) { + const unwrapped = unwrapSchema(schema); + const obj = unwrapped.inner; + const typeName = getZodTypeName(obj); + + if (typeName !== 'ZodObject') { + throw new Error(`Expected ZodObject, got ${typeName}`); + } + + const shape = typeof obj._def.shape === 'function' ? obj._def.shape() : obj._def.shape || {}; + const fields = []; + + for (const [name, fieldSchema] of Object.entries(shape)) { + const { inner, defaultValue, description } = unwrapSchema(fieldSchema); + + // Use type override if provided, otherwise resolve from Zod schema + const tsType = typeOverrides[name] || zodToTsType(inner); + + fields.push({ + name, + tsType, + optional: true, // All fields optional for Input types + description, + defaultValue, + }); + } + + return fields; +} + +// ============================================ +// Interface Generator +// ============================================ + +function generateInterface(name, fields, description) { + const lines = []; + + if (description) { + lines.push('/**'); + for (const line of description.split('\n')) { + lines.push(` * ${line}`); + } + lines.push(' */'); + } + + lines.push(`export interface ${name} {`); + + for (const field of fields) { + const jsdocParts = []; + if (field.description) jsdocParts.push(field.description); + if (field.defaultValue !== undefined) jsdocParts.push(`@default ${field.defaultValue}`); + + if (jsdocParts.length > 0) { + if (jsdocParts.length === 1 && !jsdocParts[0].includes('\n')) { + lines.push(` /** ${jsdocParts[0]} */`); + } else { + lines.push(' /**'); + for (const part of jsdocParts) { + for (const l of part.split('\n')) lines.push(` * ${l}`); + } + lines.push(' */'); + } + } + + const optMark = field.optional ? '?' : ''; + lines.push(` ${field.name}${optMark}: ${field.tsType};`); + } + + lines.push('}'); + return lines.join('\n'); +} + +// ============================================ +// Main +// ============================================ + +const HEADER = `// AUTO-GENERATED from Zod schemas — do not edit manually. +// Run: npx tsx scripts/generate-schema-types.mjs +// Source: scripts/generate-schema-types.mjs`; + +async function main() { + let changed = false; + + for (const target of SCHEMA_TARGETS) { + const mod = await import(target.importPath); + const sections = [HEADER, '']; + + if (target.imports?.length) { + sections.push(...target.imports, ''); + } + + for (const entry of target.schemas) { + const schema = mod[entry.name]; + if (!schema) { + console.error(`Schema "${entry.name}" not found in ${target.importPath}`); + process.exit(1); + } + + const fields = extractFields(schema, entry.typeOverrides); + const iface = generateInterface(entry.interfaceName, fields, entry.description); + sections.push(iface, ''); + } + + const content = sections.join('\n'); + const outPath = target.outputFile; + + if (existsSync(outPath)) { + const existing = readFileSync(outPath, 'utf-8'); + if (existing === content) { + console.log(` ✓ ${relative(ROOT, outPath)} (up to date)`); + continue; + } + } + + writeFileSync(outPath, content, 'utf-8'); + console.log(` ✏ ${relative(ROOT, outPath)} (generated)`); + changed = true; + } + + if (!changed) { + console.log('All generated types are up to date.'); + } +} + +main().catch((err) => { + console.error('generate-schema-types failed:', err); + process.exit(1); +});