ソースを参照

feat(api-docs): generate OpenAPI components/schemas from Go structs

A new emit_jsonschema.go walks the same allow-listed structs as the zod/types/examples emitters and writes generated/schemas.ts (SCHEMAS). build-openapi mounts it under components.schemas and points each typed response obj at a $ref instead of an untyped {} blob, so Swagger renders real models and openapi-generator can emit clients.

Also add a vitest guard that safeParses every EXAMPLES entry against its generated zod schema, reviving the previously unused generated/zod.ts and catching drift between the example and schema emitters.
MHSanaei 1 日 前
コミット
a014c01725

+ 14 - 4
frontend/scripts/build-openapi.mjs

@@ -5,6 +5,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
 
 import { sections } from '../src/pages/api-docs/endpoints.ts';
 import { EXAMPLES } from '../src/generated/examples.ts';
+import { SCHEMAS } from '../src/generated/schemas.ts';
 
 const __dirname = dirname(fileURLToPath(import.meta.url));
 const outPath = join(__dirname, '..', 'public', 'openapi.json');
@@ -130,12 +131,20 @@ function buildOperation(ep, tag) {
 
   const responses = {};
   let successExample = tryParseJson(ep.response);
-  if (successExample === undefined && ep.responseSchema) {
+  let objSchema = {};
+  if (ep.responseSchema) {
     const obj = EXAMPLES[ep.responseSchema];
     if (obj === undefined) {
       throw new Error(`${ep.method} ${ep.path}: responseSchema "${ep.responseSchema}" has no generated example`);
     }
-    successExample = { success: true, obj: ep.responseSchemaArray ? [obj] : obj };
+    if (SCHEMAS[ep.responseSchema] === undefined) {
+      throw new Error(`${ep.method} ${ep.path}: responseSchema "${ep.responseSchema}" has no generated schema`);
+    }
+    const ref = { $ref: `#/components/schemas/${ep.responseSchema}` };
+    objSchema = ep.responseSchemaArray ? { type: 'array', items: ref } : ref;
+    if (successExample === undefined) {
+      successExample = { success: true, obj: ep.responseSchemaArray ? [obj] : obj };
+    }
   }
   responses['200'] = {
     description: 'Successful response',
@@ -146,7 +155,7 @@ function buildOperation(ep, tag) {
           properties: {
             success: { type: 'boolean' },
             msg: { type: 'string' },
-            obj: {},
+            obj: objSchema,
           },
         },
         ...(successExample !== undefined ? { example: successExample } : {}),
@@ -200,13 +209,14 @@ function buildSpec() {
       title: '3X-UI Panel API',
       version: PANEL_VERSION,
       description:
-        'Programmatic interface to a 3X-UI panel. Authenticate either by logging in (cookie) or with an API token from Settings → Security → API Token (Bearer). All endpoints under /panel/api/* honour both modes.',
+        'Programmatic interface to a 3X-UI panel. Authenticate either by logging in (cookie) or with an API token from Settings → Security → API Token (Bearer). All endpoints under /panel/api/* honour both modes — an API token is a full-admin credential, so treat it like the panel password.',
     },
     servers: [
       { url: '/', description: 'Current panel (basePath aware)' },
     ],
     components: {
       securitySchemes: SECURITY_SCHEMES,
+      schemas: SCHEMAS,
     },
     security: [{ bearerAuth: [] }, { cookieAuth: [] }],
     tags,

+ 1785 - 0
frontend/src/generated/schemas.ts

@@ -0,0 +1,1785 @@
+// Code generated by tools/openapigen. DO NOT EDIT.
+export const SCHEMAS: Record<string, unknown> = {
+  "AllSetting": {
+    "description": "AllSetting contains all configuration settings for the 3x-ui panel including web server, Telegram bot, and subscription settings.",
+    "properties": {
+      "datepicker": {
+        "description": "Date picker format",
+        "type": "string"
+      },
+      "expireDiff": {
+        "description": "Expiration warning threshold in days",
+        "minimum": 0,
+        "type": "integer"
+      },
+      "externalTrafficInformEnable": {
+        "description": "Enable external traffic reporting",
+        "type": "boolean"
+      },
+      "externalTrafficInformURI": {
+        "description": "URI for external traffic reporting",
+        "type": "string"
+      },
+      "ldapAutoCreate": {
+        "type": "boolean"
+      },
+      "ldapAutoDelete": {
+        "type": "boolean"
+      },
+      "ldapBaseDN": {
+        "type": "string"
+      },
+      "ldapBindDN": {
+        "type": "string"
+      },
+      "ldapDefaultExpiryDays": {
+        "minimum": 0,
+        "type": "integer"
+      },
+      "ldapDefaultLimitIP": {
+        "minimum": 0,
+        "type": "integer"
+      },
+      "ldapDefaultTotalGB": {
+        "minimum": 0,
+        "type": "integer"
+      },
+      "ldapEnable": {
+        "description": "LDAP settings",
+        "type": "boolean"
+      },
+      "ldapFlagField": {
+        "description": "Generic flag configuration",
+        "type": "string"
+      },
+      "ldapHost": {
+        "type": "string"
+      },
+      "ldapInboundTags": {
+        "type": "string"
+      },
+      "ldapInvertFlag": {
+        "type": "boolean"
+      },
+      "ldapPassword": {
+        "type": "string"
+      },
+      "ldapPort": {
+        "maximum": 65535,
+        "minimum": 0,
+        "type": "integer"
+      },
+      "ldapSyncCron": {
+        "type": "string"
+      },
+      "ldapTruthyValues": {
+        "type": "string"
+      },
+      "ldapUseTLS": {
+        "type": "boolean"
+      },
+      "ldapUserAttr": {
+        "description": "e.g., mail or uid",
+        "type": "string"
+      },
+      "ldapUserFilter": {
+        "type": "string"
+      },
+      "ldapVlessField": {
+        "type": "string"
+      },
+      "pageSize": {
+        "description": "UI settings\nNumber of items per page in lists (0 disables pagination)",
+        "maximum": 1000,
+        "minimum": 0,
+        "type": "integer"
+      },
+      "panelProxy": {
+        "description": "Proxy URL for the panel's own outbound requests (GitHub/Telegram)",
+        "type": "string"
+      },
+      "remarkModel": {
+        "description": "Remark model pattern for inbounds",
+        "type": "string"
+      },
+      "restartXrayOnClientDisable": {
+        "description": "Restart Xray when clients are auto-disabled by expiry/traffic limit",
+        "type": "boolean"
+      },
+      "sessionMaxAge": {
+        "description": "Session maximum age in minutes (cap at one year)",
+        "maximum": 525600,
+        "minimum": 1,
+        "type": "integer"
+      },
+      "subAnnounce": {
+        "description": "Subscription announce",
+        "type": "string"
+      },
+      "subCertFile": {
+        "description": "SSL certificate file for subscription server",
+        "type": "string"
+      },
+      "subClashEnable": {
+        "description": "Enable Clash/Mihomo subscription endpoint",
+        "type": "boolean"
+      },
+      "subClashEnableRouting": {
+        "description": "Enable global routing rules for Clash/Mihomo",
+        "type": "boolean"
+      },
+      "subClashPath": {
+        "description": "Path for Clash/Mihomo subscription endpoint",
+        "type": "string"
+      },
+      "subClashRules": {
+        "description": "Clash/Mihomo global routing rules",
+        "type": "string"
+      },
+      "subClashURI": {
+        "description": "Clash/Mihomo subscription server URI",
+        "type": "string"
+      },
+      "subDomain": {
+        "description": "Domain for subscription server validation",
+        "type": "string"
+      },
+      "subEmailInRemark": {
+        "description": "Include email in subscription remark/name",
+        "type": "boolean"
+      },
+      "subEnable": {
+        "description": "Subscription server settings\nEnable subscription server",
+        "type": "boolean"
+      },
+      "subEnableRouting": {
+        "description": "Enable routing for subscription",
+        "type": "boolean"
+      },
+      "subEncrypt": {
+        "description": "Encrypt subscription responses",
+        "type": "boolean"
+      },
+      "subJsonEnable": {
+        "description": "Enable JSON subscription endpoint",
+        "type": "boolean"
+      },
+      "subJsonFinalMask": {
+        "description": "JSON subscription global finalmask (tcp/udp masks + quicParams)",
+        "type": "string"
+      },
+      "subJsonMux": {
+        "description": "JSON subscription mux configuration",
+        "type": "string"
+      },
+      "subJsonPath": {
+        "description": "Path for JSON subscription endpoint",
+        "type": "string"
+      },
+      "subJsonRules": {
+        "type": "string"
+      },
+      "subJsonURI": {
+        "description": "JSON subscription server URI",
+        "type": "string"
+      },
+      "subKeyFile": {
+        "description": "SSL private key file for subscription server",
+        "type": "string"
+      },
+      "subListen": {
+        "description": "Subscription server listen IP",
+        "type": "string"
+      },
+      "subPath": {
+        "description": "Base path for subscription URLs",
+        "type": "string"
+      },
+      "subPort": {
+        "description": "Subscription server port",
+        "maximum": 65535,
+        "minimum": 1,
+        "type": "integer"
+      },
+      "subProfileUrl": {
+        "description": "Subscription profile URL",
+        "type": "string"
+      },
+      "subRoutingRules": {
+        "description": "Subscription global routing rules (Only for Happ)",
+        "type": "string"
+      },
+      "subShowInfo": {
+        "description": "Show client information in subscriptions",
+        "type": "boolean"
+      },
+      "subSupportUrl": {
+        "description": "Subscription support URL",
+        "type": "string"
+      },
+      "subTitle": {
+        "description": "Subscription title",
+        "type": "string"
+      },
+      "subURI": {
+        "description": "Subscription server URI",
+        "type": "string"
+      },
+      "subUpdates": {
+        "description": "Subscription update interval in minutes",
+        "maximum": 525600,
+        "minimum": 0,
+        "type": "integer"
+      },
+      "tgBotAPIServer": {
+        "description": "Custom API server for Telegram bot",
+        "type": "string"
+      },
+      "tgBotBackup": {
+        "description": "Enable database backup via Telegram",
+        "type": "boolean"
+      },
+      "tgBotChatId": {
+        "description": "Telegram chat ID for notifications",
+        "type": "string"
+      },
+      "tgBotEnable": {
+        "description": "Telegram bot settings\nEnable Telegram bot notifications",
+        "type": "boolean"
+      },
+      "tgBotLoginNotify": {
+        "description": "Send login notifications",
+        "type": "boolean"
+      },
+      "tgBotProxy": {
+        "description": "Proxy URL for Telegram bot",
+        "type": "string"
+      },
+      "tgBotToken": {
+        "description": "Telegram bot token",
+        "type": "string"
+      },
+      "tgCpu": {
+        "description": "CPU usage threshold for alerts (percent)",
+        "maximum": 100,
+        "minimum": 0,
+        "type": "integer"
+      },
+      "tgLang": {
+        "description": "Telegram bot language",
+        "type": "string"
+      },
+      "tgRunTime": {
+        "description": "Cron schedule for Telegram notifications",
+        "type": "string"
+      },
+      "timeLocation": {
+        "description": "Security settings\nTime zone location",
+        "type": "string"
+      },
+      "trafficDiff": {
+        "description": "Traffic warning threshold percentage",
+        "maximum": 100,
+        "minimum": 0,
+        "type": "integer"
+      },
+      "trustedProxyCIDRs": {
+        "description": "Trusted reverse proxy IPs/CIDRs for forwarded headers",
+        "type": "string"
+      },
+      "twoFactorEnable": {
+        "description": "Enable two-factor authentication",
+        "type": "boolean"
+      },
+      "twoFactorToken": {
+        "description": "Two-factor authentication token",
+        "type": "string"
+      },
+      "webBasePath": {
+        "description": "Base path for web panel URLs",
+        "type": "string"
+      },
+      "webCertFile": {
+        "description": "Path to SSL certificate file for web server",
+        "type": "string"
+      },
+      "webDomain": {
+        "description": "Web server domain for domain validation",
+        "type": "string"
+      },
+      "webKeyFile": {
+        "description": "Path to SSL private key file for web server",
+        "type": "string"
+      },
+      "webListen": {
+        "description": "Web server settings\nWeb server listen IP address",
+        "type": "string"
+      },
+      "webPort": {
+        "description": "Web server port number",
+        "maximum": 65535,
+        "minimum": 1,
+        "type": "integer"
+      }
+    },
+    "required": [
+      "datepicker",
+      "expireDiff",
+      "externalTrafficInformEnable",
+      "externalTrafficInformURI",
+      "ldapAutoCreate",
+      "ldapAutoDelete",
+      "ldapBaseDN",
+      "ldapBindDN",
+      "ldapDefaultExpiryDays",
+      "ldapDefaultLimitIP",
+      "ldapDefaultTotalGB",
+      "ldapEnable",
+      "ldapFlagField",
+      "ldapHost",
+      "ldapInboundTags",
+      "ldapInvertFlag",
+      "ldapPassword",
+      "ldapPort",
+      "ldapSyncCron",
+      "ldapTruthyValues",
+      "ldapUseTLS",
+      "ldapUserAttr",
+      "ldapUserFilter",
+      "ldapVlessField",
+      "pageSize",
+      "panelProxy",
+      "remarkModel",
+      "restartXrayOnClientDisable",
+      "sessionMaxAge",
+      "subAnnounce",
+      "subCertFile",
+      "subClashEnable",
+      "subClashEnableRouting",
+      "subClashPath",
+      "subClashRules",
+      "subClashURI",
+      "subDomain",
+      "subEmailInRemark",
+      "subEnable",
+      "subEnableRouting",
+      "subEncrypt",
+      "subJsonEnable",
+      "subJsonFinalMask",
+      "subJsonMux",
+      "subJsonPath",
+      "subJsonRules",
+      "subJsonURI",
+      "subKeyFile",
+      "subListen",
+      "subPath",
+      "subPort",
+      "subProfileUrl",
+      "subRoutingRules",
+      "subShowInfo",
+      "subSupportUrl",
+      "subTitle",
+      "subURI",
+      "subUpdates",
+      "tgBotAPIServer",
+      "tgBotBackup",
+      "tgBotChatId",
+      "tgBotEnable",
+      "tgBotLoginNotify",
+      "tgBotProxy",
+      "tgBotToken",
+      "tgCpu",
+      "tgLang",
+      "tgRunTime",
+      "timeLocation",
+      "trafficDiff",
+      "trustedProxyCIDRs",
+      "twoFactorEnable",
+      "twoFactorToken",
+      "webBasePath",
+      "webCertFile",
+      "webDomain",
+      "webKeyFile",
+      "webListen",
+      "webPort"
+    ],
+    "type": "object"
+  },
+  "AllSettingView": {
+    "description": "AllSettingView is the browser-safe settings read model. Secret values\nare redacted from the embedded write model and represented by presence\nflags so the UI can show configured/not configured state.",
+    "properties": {
+      "datepicker": {
+        "description": "Date picker format",
+        "type": "string"
+      },
+      "expireDiff": {
+        "description": "Expiration warning threshold in days",
+        "minimum": 0,
+        "type": "integer"
+      },
+      "externalTrafficInformEnable": {
+        "description": "Enable external traffic reporting",
+        "type": "boolean"
+      },
+      "externalTrafficInformURI": {
+        "description": "URI for external traffic reporting",
+        "type": "string"
+      },
+      "hasApiToken": {
+        "type": "boolean"
+      },
+      "hasLdapPassword": {
+        "type": "boolean"
+      },
+      "hasNordSecret": {
+        "type": "boolean"
+      },
+      "hasTgBotToken": {
+        "type": "boolean"
+      },
+      "hasTwoFactorToken": {
+        "type": "boolean"
+      },
+      "hasWarpSecret": {
+        "type": "boolean"
+      },
+      "ldapAutoCreate": {
+        "type": "boolean"
+      },
+      "ldapAutoDelete": {
+        "type": "boolean"
+      },
+      "ldapBaseDN": {
+        "type": "string"
+      },
+      "ldapBindDN": {
+        "type": "string"
+      },
+      "ldapDefaultExpiryDays": {
+        "minimum": 0,
+        "type": "integer"
+      },
+      "ldapDefaultLimitIP": {
+        "minimum": 0,
+        "type": "integer"
+      },
+      "ldapDefaultTotalGB": {
+        "minimum": 0,
+        "type": "integer"
+      },
+      "ldapEnable": {
+        "description": "LDAP settings",
+        "type": "boolean"
+      },
+      "ldapFlagField": {
+        "description": "Generic flag configuration",
+        "type": "string"
+      },
+      "ldapHost": {
+        "type": "string"
+      },
+      "ldapInboundTags": {
+        "type": "string"
+      },
+      "ldapInvertFlag": {
+        "type": "boolean"
+      },
+      "ldapPassword": {
+        "type": "string"
+      },
+      "ldapPort": {
+        "maximum": 65535,
+        "minimum": 0,
+        "type": "integer"
+      },
+      "ldapSyncCron": {
+        "type": "string"
+      },
+      "ldapTruthyValues": {
+        "type": "string"
+      },
+      "ldapUseTLS": {
+        "type": "boolean"
+      },
+      "ldapUserAttr": {
+        "description": "e.g., mail or uid",
+        "type": "string"
+      },
+      "ldapUserFilter": {
+        "type": "string"
+      },
+      "ldapVlessField": {
+        "type": "string"
+      },
+      "pageSize": {
+        "description": "UI settings\nNumber of items per page in lists (0 disables pagination)",
+        "maximum": 1000,
+        "minimum": 0,
+        "type": "integer"
+      },
+      "panelProxy": {
+        "description": "Proxy URL for the panel's own outbound requests (GitHub/Telegram)",
+        "type": "string"
+      },
+      "remarkModel": {
+        "description": "Remark model pattern for inbounds",
+        "type": "string"
+      },
+      "restartXrayOnClientDisable": {
+        "description": "Restart Xray when clients are auto-disabled by expiry/traffic limit",
+        "type": "boolean"
+      },
+      "sessionMaxAge": {
+        "description": "Session maximum age in minutes (cap at one year)",
+        "maximum": 525600,
+        "minimum": 1,
+        "type": "integer"
+      },
+      "subAnnounce": {
+        "description": "Subscription announce",
+        "type": "string"
+      },
+      "subCertFile": {
+        "description": "SSL certificate file for subscription server",
+        "type": "string"
+      },
+      "subClashEnable": {
+        "description": "Enable Clash/Mihomo subscription endpoint",
+        "type": "boolean"
+      },
+      "subClashEnableRouting": {
+        "description": "Enable global routing rules for Clash/Mihomo",
+        "type": "boolean"
+      },
+      "subClashPath": {
+        "description": "Path for Clash/Mihomo subscription endpoint",
+        "type": "string"
+      },
+      "subClashRules": {
+        "description": "Clash/Mihomo global routing rules",
+        "type": "string"
+      },
+      "subClashURI": {
+        "description": "Clash/Mihomo subscription server URI",
+        "type": "string"
+      },
+      "subDomain": {
+        "description": "Domain for subscription server validation",
+        "type": "string"
+      },
+      "subEmailInRemark": {
+        "description": "Include email in subscription remark/name",
+        "type": "boolean"
+      },
+      "subEnable": {
+        "description": "Subscription server settings\nEnable subscription server",
+        "type": "boolean"
+      },
+      "subEnableRouting": {
+        "description": "Enable routing for subscription",
+        "type": "boolean"
+      },
+      "subEncrypt": {
+        "description": "Encrypt subscription responses",
+        "type": "boolean"
+      },
+      "subJsonEnable": {
+        "description": "Enable JSON subscription endpoint",
+        "type": "boolean"
+      },
+      "subJsonFinalMask": {
+        "description": "JSON subscription global finalmask (tcp/udp masks + quicParams)",
+        "type": "string"
+      },
+      "subJsonMux": {
+        "description": "JSON subscription mux configuration",
+        "type": "string"
+      },
+      "subJsonPath": {
+        "description": "Path for JSON subscription endpoint",
+        "type": "string"
+      },
+      "subJsonRules": {
+        "type": "string"
+      },
+      "subJsonURI": {
+        "description": "JSON subscription server URI",
+        "type": "string"
+      },
+      "subKeyFile": {
+        "description": "SSL private key file for subscription server",
+        "type": "string"
+      },
+      "subListen": {
+        "description": "Subscription server listen IP",
+        "type": "string"
+      },
+      "subPath": {
+        "description": "Base path for subscription URLs",
+        "type": "string"
+      },
+      "subPort": {
+        "description": "Subscription server port",
+        "maximum": 65535,
+        "minimum": 1,
+        "type": "integer"
+      },
+      "subProfileUrl": {
+        "description": "Subscription profile URL",
+        "type": "string"
+      },
+      "subRoutingRules": {
+        "description": "Subscription global routing rules (Only for Happ)",
+        "type": "string"
+      },
+      "subShowInfo": {
+        "description": "Show client information in subscriptions",
+        "type": "boolean"
+      },
+      "subSupportUrl": {
+        "description": "Subscription support URL",
+        "type": "string"
+      },
+      "subTitle": {
+        "description": "Subscription title",
+        "type": "string"
+      },
+      "subURI": {
+        "description": "Subscription server URI",
+        "type": "string"
+      },
+      "subUpdates": {
+        "description": "Subscription update interval in minutes",
+        "maximum": 525600,
+        "minimum": 0,
+        "type": "integer"
+      },
+      "tgBotAPIServer": {
+        "description": "Custom API server for Telegram bot",
+        "type": "string"
+      },
+      "tgBotBackup": {
+        "description": "Enable database backup via Telegram",
+        "type": "boolean"
+      },
+      "tgBotChatId": {
+        "description": "Telegram chat ID for notifications",
+        "type": "string"
+      },
+      "tgBotEnable": {
+        "description": "Telegram bot settings\nEnable Telegram bot notifications",
+        "type": "boolean"
+      },
+      "tgBotLoginNotify": {
+        "description": "Send login notifications",
+        "type": "boolean"
+      },
+      "tgBotProxy": {
+        "description": "Proxy URL for Telegram bot",
+        "type": "string"
+      },
+      "tgBotToken": {
+        "description": "Telegram bot token",
+        "type": "string"
+      },
+      "tgCpu": {
+        "description": "CPU usage threshold for alerts (percent)",
+        "maximum": 100,
+        "minimum": 0,
+        "type": "integer"
+      },
+      "tgLang": {
+        "description": "Telegram bot language",
+        "type": "string"
+      },
+      "tgRunTime": {
+        "description": "Cron schedule for Telegram notifications",
+        "type": "string"
+      },
+      "timeLocation": {
+        "description": "Security settings\nTime zone location",
+        "type": "string"
+      },
+      "trafficDiff": {
+        "description": "Traffic warning threshold percentage",
+        "maximum": 100,
+        "minimum": 0,
+        "type": "integer"
+      },
+      "trustedProxyCIDRs": {
+        "description": "Trusted reverse proxy IPs/CIDRs for forwarded headers",
+        "type": "string"
+      },
+      "twoFactorEnable": {
+        "description": "Enable two-factor authentication",
+        "type": "boolean"
+      },
+      "twoFactorToken": {
+        "description": "Two-factor authentication token",
+        "type": "string"
+      },
+      "webBasePath": {
+        "description": "Base path for web panel URLs",
+        "type": "string"
+      },
+      "webCertFile": {
+        "description": "Path to SSL certificate file for web server",
+        "type": "string"
+      },
+      "webDomain": {
+        "description": "Web server domain for domain validation",
+        "type": "string"
+      },
+      "webKeyFile": {
+        "description": "Path to SSL private key file for web server",
+        "type": "string"
+      },
+      "webListen": {
+        "description": "Web server settings\nWeb server listen IP address",
+        "type": "string"
+      },
+      "webPort": {
+        "description": "Web server port number",
+        "maximum": 65535,
+        "minimum": 1,
+        "type": "integer"
+      }
+    },
+    "required": [
+      "datepicker",
+      "expireDiff",
+      "externalTrafficInformEnable",
+      "externalTrafficInformURI",
+      "hasApiToken",
+      "hasLdapPassword",
+      "hasNordSecret",
+      "hasTgBotToken",
+      "hasTwoFactorToken",
+      "hasWarpSecret",
+      "ldapAutoCreate",
+      "ldapAutoDelete",
+      "ldapBaseDN",
+      "ldapBindDN",
+      "ldapDefaultExpiryDays",
+      "ldapDefaultLimitIP",
+      "ldapDefaultTotalGB",
+      "ldapEnable",
+      "ldapFlagField",
+      "ldapHost",
+      "ldapInboundTags",
+      "ldapInvertFlag",
+      "ldapPassword",
+      "ldapPort",
+      "ldapSyncCron",
+      "ldapTruthyValues",
+      "ldapUseTLS",
+      "ldapUserAttr",
+      "ldapUserFilter",
+      "ldapVlessField",
+      "pageSize",
+      "panelProxy",
+      "remarkModel",
+      "restartXrayOnClientDisable",
+      "sessionMaxAge",
+      "subAnnounce",
+      "subCertFile",
+      "subClashEnable",
+      "subClashEnableRouting",
+      "subClashPath",
+      "subClashRules",
+      "subClashURI",
+      "subDomain",
+      "subEmailInRemark",
+      "subEnable",
+      "subEnableRouting",
+      "subEncrypt",
+      "subJsonEnable",
+      "subJsonFinalMask",
+      "subJsonMux",
+      "subJsonPath",
+      "subJsonRules",
+      "subJsonURI",
+      "subKeyFile",
+      "subListen",
+      "subPath",
+      "subPort",
+      "subProfileUrl",
+      "subRoutingRules",
+      "subShowInfo",
+      "subSupportUrl",
+      "subTitle",
+      "subURI",
+      "subUpdates",
+      "tgBotAPIServer",
+      "tgBotBackup",
+      "tgBotChatId",
+      "tgBotEnable",
+      "tgBotLoginNotify",
+      "tgBotProxy",
+      "tgBotToken",
+      "tgCpu",
+      "tgLang",
+      "tgRunTime",
+      "timeLocation",
+      "trafficDiff",
+      "trustedProxyCIDRs",
+      "twoFactorEnable",
+      "twoFactorToken",
+      "webBasePath",
+      "webCertFile",
+      "webDomain",
+      "webKeyFile",
+      "webListen",
+      "webPort"
+    ],
+    "type": "object"
+  },
+  "ApiToken": {
+    "properties": {
+      "createdAt": {
+        "type": "integer"
+      },
+      "enabled": {
+        "type": "boolean"
+      },
+      "id": {
+        "type": "integer"
+      },
+      "name": {
+        "type": "string"
+      },
+      "token": {
+        "description": "SHA-256 hash; the plaintext is shown only once at creation",
+        "type": "string"
+      }
+    },
+    "required": [
+      "createdAt",
+      "enabled",
+      "id",
+      "name",
+      "token"
+    ],
+    "type": "object"
+  },
+  "ApiTokenView": {
+    "properties": {
+      "createdAt": {
+        "example": 1736000000,
+        "type": "integer"
+      },
+      "enabled": {
+        "example": true,
+        "type": "boolean"
+      },
+      "id": {
+        "example": 2,
+        "type": "integer"
+      },
+      "name": {
+        "example": "central-panel-a",
+        "type": "string"
+      },
+      "token": {
+        "example": "new-token-string",
+        "type": "string"
+      }
+    },
+    "required": [
+      "createdAt",
+      "enabled",
+      "id",
+      "name"
+    ],
+    "type": "object"
+  },
+  "Client": {
+    "description": "Client represents a client configuration for Xray inbounds with traffic limits and settings.",
+    "properties": {
+      "auth": {
+        "description": "Auth password (Hysteria)",
+        "type": "string"
+      },
+      "comment": {
+        "description": "Client comment",
+        "type": "string"
+      },
+      "created_at": {
+        "description": "Creation timestamp",
+        "type": "integer"
+      },
+      "email": {
+        "description": "Client email identifier",
+        "type": "string"
+      },
+      "enable": {
+        "description": "Whether the client is enabled",
+        "type": "boolean"
+      },
+      "expiryTime": {
+        "description": "Expiration timestamp",
+        "type": "integer"
+      },
+      "flow": {
+        "description": "Flow control (XTLS)",
+        "type": "string"
+      },
+      "group": {
+        "description": "Logical grouping label",
+        "type": "string"
+      },
+      "id": {
+        "description": "Unique client identifier",
+        "type": "string"
+      },
+      "limitIp": {
+        "description": "IP limit for this client",
+        "type": "integer"
+      },
+      "password": {
+        "description": "Client password",
+        "type": "string"
+      },
+      "reset": {
+        "description": "Reset period in days",
+        "type": "integer"
+      },
+      "reverse": {
+        "allOf": [
+          {
+            "$ref": "#/components/schemas/ClientReverse"
+          }
+        ],
+        "description": "VLESS simple reverse proxy settings",
+        "nullable": true
+      },
+      "security": {
+        "description": "Security method (e.g., \"auto\", \"aes-128-gcm\")",
+        "type": "string"
+      },
+      "subId": {
+        "description": "Subscription identifier",
+        "type": "string"
+      },
+      "tgId": {
+        "description": "Telegram user ID for notifications",
+        "type": "integer"
+      },
+      "totalGB": {
+        "description": "Total traffic limit in GB",
+        "type": "integer"
+      },
+      "updated_at": {
+        "description": "Last update timestamp",
+        "type": "integer"
+      }
+    },
+    "required": [
+      "comment",
+      "email",
+      "enable",
+      "expiryTime",
+      "limitIp",
+      "reset",
+      "security",
+      "subId",
+      "tgId",
+      "totalGB"
+    ],
+    "type": "object"
+  },
+  "ClientInbound": {
+    "properties": {
+      "clientId": {
+        "type": "integer"
+      },
+      "createdAt": {
+        "type": "integer"
+      },
+      "flowOverride": {
+        "type": "string"
+      },
+      "inboundId": {
+        "type": "integer"
+      }
+    },
+    "required": [
+      "clientId",
+      "createdAt",
+      "flowOverride",
+      "inboundId"
+    ],
+    "type": "object"
+  },
+  "ClientRecord": {
+    "properties": {
+      "auth": {
+        "type": "string"
+      },
+      "comment": {
+        "type": "string"
+      },
+      "createdAt": {
+        "type": "integer"
+      },
+      "email": {
+        "type": "string"
+      },
+      "enable": {
+        "type": "boolean"
+      },
+      "expiryTime": {
+        "type": "integer"
+      },
+      "flow": {
+        "type": "string"
+      },
+      "group": {
+        "type": "string"
+      },
+      "id": {
+        "type": "integer"
+      },
+      "limitIp": {
+        "type": "integer"
+      },
+      "password": {
+        "type": "string"
+      },
+      "reset": {
+        "type": "integer"
+      },
+      "reverse": {},
+      "security": {
+        "type": "string"
+      },
+      "subId": {
+        "type": "string"
+      },
+      "tgId": {
+        "type": "integer"
+      },
+      "totalGB": {
+        "type": "integer"
+      },
+      "updatedAt": {
+        "type": "integer"
+      },
+      "uuid": {
+        "type": "string"
+      }
+    },
+    "required": [
+      "auth",
+      "comment",
+      "createdAt",
+      "email",
+      "enable",
+      "expiryTime",
+      "flow",
+      "group",
+      "id",
+      "limitIp",
+      "password",
+      "reset",
+      "reverse",
+      "security",
+      "subId",
+      "tgId",
+      "totalGB",
+      "updatedAt",
+      "uuid"
+    ],
+    "type": "object"
+  },
+  "ClientReverse": {
+    "properties": {
+      "tag": {
+        "type": "string"
+      }
+    },
+    "required": [
+      "tag"
+    ],
+    "type": "object"
+  },
+  "ClientTraffic": {
+    "description": "ClientTraffic represents traffic statistics and limits for a specific client.\nIt tracks upload/download usage, expiry times, and online status for inbound clients.",
+    "properties": {
+      "down": {
+        "example": 2097152,
+        "type": "integer"
+      },
+      "email": {
+        "example": "user1",
+        "type": "string"
+      },
+      "enable": {
+        "example": true,
+        "type": "boolean"
+      },
+      "expiryTime": {
+        "example": 1735689600000,
+        "type": "integer"
+      },
+      "id": {
+        "example": 14825,
+        "type": "integer"
+      },
+      "inboundId": {
+        "example": 1,
+        "type": "integer"
+      },
+      "lastOnline": {
+        "example": 1735680000000,
+        "type": "integer"
+      },
+      "reset": {
+        "example": 0,
+        "type": "integer"
+      },
+      "subId": {
+        "example": "i7tvdpeffi0hvvf1",
+        "type": "string"
+      },
+      "total": {
+        "example": 10737418240,
+        "type": "integer"
+      },
+      "up": {
+        "example": 1048576,
+        "type": "integer"
+      },
+      "uuid": {
+        "example": "e18c9a96-71bf-48d4-933f-8b9a46d4290c",
+        "type": "string"
+      }
+    },
+    "required": [
+      "down",
+      "email",
+      "enable",
+      "expiryTime",
+      "id",
+      "inboundId",
+      "lastOnline",
+      "reset",
+      "subId",
+      "total",
+      "up",
+      "uuid"
+    ],
+    "type": "object"
+  },
+  "CustomGeoResource": {
+    "properties": {
+      "alias": {
+        "type": "string"
+      },
+      "createdAt": {
+        "type": "integer"
+      },
+      "id": {
+        "type": "integer"
+      },
+      "lastModified": {
+        "type": "string"
+      },
+      "lastUpdatedAt": {
+        "type": "integer"
+      },
+      "localPath": {
+        "type": "string"
+      },
+      "type": {
+        "type": "string"
+      },
+      "updatedAt": {
+        "type": "integer"
+      },
+      "url": {
+        "type": "string"
+      }
+    },
+    "required": [
+      "alias",
+      "createdAt",
+      "id",
+      "lastModified",
+      "lastUpdatedAt",
+      "localPath",
+      "type",
+      "updatedAt",
+      "url"
+    ],
+    "type": "object"
+  },
+  "FallbackParentInfo": {
+    "description": "FallbackParentInfo carries everything the frontend needs to rewrite a\nchild inbound's client link: where to connect (the master's address\nand port) and which path matched on the master's fallbacks array.\nThe frontend already has the master inbound in its dbInbounds list,\nso we only ship identifiers + the match path here.",
+    "properties": {
+      "masterId": {
+        "type": "integer"
+      },
+      "path": {
+        "type": "string"
+      }
+    },
+    "required": [
+      "masterId"
+    ],
+    "type": "object"
+  },
+  "HistoryOfSeeders": {
+    "description": "HistoryOfSeeders tracks which database seeders have been executed to prevent re-running.",
+    "properties": {
+      "id": {
+        "type": "integer"
+      },
+      "seederName": {
+        "type": "string"
+      }
+    },
+    "required": [
+      "id",
+      "seederName"
+    ],
+    "type": "object"
+  },
+  "Inbound": {
+    "description": "Inbound represents an Xray inbound configuration with traffic statistics and settings.",
+    "properties": {
+      "clientStats": {
+        "description": "Client traffic statistics",
+        "items": {
+          "$ref": "#/components/schemas/ClientTraffic"
+        },
+        "type": "array"
+      },
+      "down": {
+        "description": "Download traffic in bytes",
+        "type": "integer"
+      },
+      "enable": {
+        "description": "Whether the inbound is enabled",
+        "example": true,
+        "type": "boolean"
+      },
+      "expiryTime": {
+        "description": "Expiration timestamp",
+        "type": "integer"
+      },
+      "fallbackParent": {
+        "allOf": [
+          {
+            "$ref": "#/components/schemas/FallbackParentInfo"
+          }
+        ],
+        "description": "FallbackParent is populated by the API layer when this inbound is\nattached as a fallback child of a VLESS/Trojan TCP-TLS master.\nThe frontend uses it to rewrite client-share links so they advertise\nthe master's externally reachable endpoint instead of the child's\nloopback listen. Not persisted.",
+        "nullable": true
+      },
+      "id": {
+        "description": "Unique identifier",
+        "example": 1,
+        "type": "integer"
+      },
+      "lastTrafficResetTime": {
+        "description": "Last traffic reset timestamp",
+        "type": "integer"
+      },
+      "listen": {
+        "description": "Xray configuration fields",
+        "type": "string"
+      },
+      "nodeId": {
+        "nullable": true,
+        "type": "integer"
+      },
+      "originNodeGuid": {
+        "description": "OriginNodeGuid is the panelGuid of the node that physically hosts this\ninbound, propagated up across hops (#4983). Empty for an inbound that\nlives on this panel's own xray; set to the originating node's GUID when\nthe inbound was synced from a node (kept as-is across further hops). Lets\nthe master attribute a deeply nested inbound to the real node instead of\nthe intermediate one it was fetched through.",
+        "type": "string"
+      },
+      "port": {
+        "example": 443,
+        "maximum": 65535,
+        "minimum": 0,
+        "type": "integer"
+      },
+      "protocol": {
+        "enum": [
+          "vmess",
+          "vless",
+          "trojan",
+          "shadowsocks",
+          "wireguard",
+          "hysteria",
+          "http",
+          "mixed",
+          "tunnel",
+          "tun"
+        ],
+        "example": "vless",
+        "type": "string"
+      },
+      "remark": {
+        "description": "Human-readable remark",
+        "example": "VLESS-443",
+        "type": "string"
+      },
+      "settings": {},
+      "sniffing": {},
+      "streamSettings": {},
+      "tag": {
+        "example": "in-443-tcp",
+        "type": "string"
+      },
+      "total": {
+        "description": "Total traffic limit in bytes",
+        "type": "integer"
+      },
+      "trafficReset": {
+        "description": "Traffic reset schedule",
+        "enum": [
+          "never",
+          "hourly",
+          "daily",
+          "weekly",
+          "monthly"
+        ],
+        "type": "string"
+      },
+      "up": {
+        "description": "Upload traffic in bytes",
+        "type": "integer"
+      }
+    },
+    "required": [
+      "clientStats",
+      "down",
+      "enable",
+      "expiryTime",
+      "id",
+      "lastTrafficResetTime",
+      "listen",
+      "port",
+      "protocol",
+      "remark",
+      "settings",
+      "sniffing",
+      "streamSettings",
+      "tag",
+      "total",
+      "trafficReset",
+      "up"
+    ],
+    "type": "object"
+  },
+  "InboundClientIps": {
+    "description": "InboundClientIps stores IP addresses associated with inbound clients for access control.",
+    "properties": {
+      "clientEmail": {
+        "type": "string"
+      },
+      "id": {
+        "type": "integer"
+      },
+      "ips": {}
+    },
+    "required": [
+      "clientEmail",
+      "id",
+      "ips"
+    ],
+    "type": "object"
+  },
+  "InboundFallback": {
+    "properties": {
+      "alpn": {
+        "type": "string"
+      },
+      "childId": {
+        "type": "integer"
+      },
+      "dest": {
+        "type": "string"
+      },
+      "id": {
+        "type": "integer"
+      },
+      "masterId": {
+        "type": "integer"
+      },
+      "name": {
+        "type": "string"
+      },
+      "path": {
+        "type": "string"
+      },
+      "sortOrder": {
+        "type": "integer"
+      },
+      "xver": {
+        "type": "integer"
+      }
+    },
+    "required": [
+      "alpn",
+      "childId",
+      "dest",
+      "id",
+      "masterId",
+      "name",
+      "path",
+      "sortOrder",
+      "xver"
+    ],
+    "type": "object"
+  },
+  "InboundOption": {
+    "properties": {
+      "id": {
+        "example": 1,
+        "type": "integer"
+      },
+      "port": {
+        "example": 443,
+        "type": "integer"
+      },
+      "protocol": {
+        "example": "vless",
+        "type": "string"
+      },
+      "remark": {
+        "example": "VLESS-443",
+        "type": "string"
+      },
+      "ssMethod": {
+        "type": "string"
+      },
+      "tag": {
+        "example": "in-443-tcp",
+        "type": "string"
+      },
+      "tlsFlowCapable": {
+        "example": true,
+        "type": "boolean"
+      }
+    },
+    "required": [
+      "id",
+      "port",
+      "protocol",
+      "remark",
+      "ssMethod",
+      "tag",
+      "tlsFlowCapable"
+    ],
+    "type": "object"
+  },
+  "Msg": {
+    "description": "Msg represents a standard API response message with success status, message text, and optional data object.",
+    "properties": {
+      "msg": {
+        "description": "Response message text",
+        "type": "string"
+      },
+      "obj": {
+        "description": "Optional data object"
+      },
+      "success": {
+        "description": "Indicates if the operation was successful",
+        "type": "boolean"
+      }
+    },
+    "required": [
+      "msg",
+      "obj",
+      "success"
+    ],
+    "type": "object"
+  },
+  "Node": {
+    "description": "Node represents a remote 3x-ui panel registered with the central panel.\nThe central panel polls each node's existing /panel/api/server/status\nendpoint over HTTP using the per-node ApiToken to populate the runtime\nstatus fields below.",
+    "properties": {
+      "address": {
+        "example": "node1.example.com",
+        "type": "string"
+      },
+      "allowPrivateAddress": {
+        "type": "boolean"
+      },
+      "apiToken": {
+        "example": "abcdef0123456789",
+        "type": "string"
+      },
+      "basePath": {
+        "example": "/",
+        "type": "string"
+      },
+      "clientCount": {
+        "example": 27,
+        "type": "integer"
+      },
+      "configDirty": {
+        "type": "boolean"
+      },
+      "configDirtyAt": {
+        "type": "integer"
+      },
+      "cpuPct": {
+        "example": 23.5,
+        "type": "number"
+      },
+      "createdAt": {
+        "example": 1700000000,
+        "type": "integer"
+      },
+      "depletedCount": {
+        "example": 1,
+        "type": "integer"
+      },
+      "enable": {
+        "example": true,
+        "type": "boolean"
+      },
+      "guid": {
+        "description": "Guid is the remote panel's stable self-identifier (its panelGuid),\nlearned from each heartbeat. It is the globally stable node identity used\nto attribute online clients/inbounds to the physical node across a chain\nof nodes (#4983); panel-local autoincrement ids don't survive a hop.\nObserved-state only — never user-edited.",
+        "type": "string"
+      },
+      "id": {
+        "example": 1,
+        "type": "integer"
+      },
+      "inboundCount": {
+        "example": 5,
+        "type": "integer"
+      },
+      "lastError": {
+        "type": "string"
+      },
+      "lastHeartbeat": {
+        "description": "unix seconds, 0 = never",
+        "example": 1700000000,
+        "type": "integer"
+      },
+      "latencyMs": {
+        "example": 42,
+        "type": "integer"
+      },
+      "memPct": {
+        "example": 45.1,
+        "type": "number"
+      },
+      "name": {
+        "example": "de-fra-1",
+        "type": "string"
+      },
+      "onlineCount": {
+        "example": 3,
+        "type": "integer"
+      },
+      "panelVersion": {
+        "example": "v3.x.x",
+        "type": "string"
+      },
+      "parentGuid": {
+        "description": "ParentGuid + Transitive are set only when a node is surfaced as part of a\nnode tree (#4983): direct nodes carry the master panel's own GUID, a\ntransitive sub-node carries its parent node's GUID. Transitive nodes are\nread-only projections (Id == 0, not persisted) — never edited or deployed.",
+        "type": "string"
+      },
+      "pinnedCertSha256": {
+        "type": "string"
+      },
+      "port": {
+        "example": 2053,
+        "maximum": 65535,
+        "minimum": 1,
+        "type": "integer"
+      },
+      "remark": {
+        "type": "string"
+      },
+      "scheme": {
+        "enum": [
+          "http",
+          "https"
+        ],
+        "example": "https",
+        "type": "string"
+      },
+      "status": {
+        "description": "Heartbeat-updated fields. UpdatedAt advances on every probe even when\nthe row is otherwise unchanged so the UI's \"last seen\" tooltip is\ntruthful without us having to read LastHeartbeat separately.\nonline|offline|unknown",
+        "example": "online",
+        "type": "string"
+      },
+      "tlsVerifyMode": {
+        "enum": [
+          "verify",
+          "skip",
+          "pin"
+        ],
+        "type": "string"
+      },
+      "transitive": {
+        "type": "boolean"
+      },
+      "updatedAt": {
+        "example": 1700000000,
+        "type": "integer"
+      },
+      "uptimeSecs": {
+        "example": 86400,
+        "type": "integer"
+      },
+      "xrayVersion": {
+        "example": "25.10.31",
+        "type": "string"
+      }
+    },
+    "required": [
+      "address",
+      "allowPrivateAddress",
+      "apiToken",
+      "basePath",
+      "clientCount",
+      "configDirty",
+      "configDirtyAt",
+      "cpuPct",
+      "createdAt",
+      "depletedCount",
+      "enable",
+      "guid",
+      "id",
+      "inboundCount",
+      "lastError",
+      "lastHeartbeat",
+      "latencyMs",
+      "memPct",
+      "name",
+      "onlineCount",
+      "panelVersion",
+      "pinnedCertSha256",
+      "port",
+      "remark",
+      "scheme",
+      "status",
+      "tlsVerifyMode",
+      "updatedAt",
+      "uptimeSecs",
+      "xrayVersion"
+    ],
+    "type": "object"
+  },
+  "OutboundTraffics": {
+    "description": "OutboundTraffics tracks traffic statistics for Xray outbound connections.",
+    "properties": {
+      "down": {
+        "type": "integer"
+      },
+      "id": {
+        "type": "integer"
+      },
+      "tag": {
+        "type": "string"
+      },
+      "total": {
+        "type": "integer"
+      },
+      "up": {
+        "type": "integer"
+      }
+    },
+    "required": [
+      "down",
+      "id",
+      "tag",
+      "total",
+      "up"
+    ],
+    "type": "object"
+  },
+  "ProbeResultUI": {
+    "properties": {
+      "cpuPct": {
+        "example": 12.5,
+        "type": "number"
+      },
+      "error": {
+        "type": "string"
+      },
+      "latencyMs": {
+        "example": 42,
+        "type": "integer"
+      },
+      "memPct": {
+        "example": 45.2,
+        "type": "number"
+      },
+      "panelVersion": {
+        "example": "v3.x.x",
+        "type": "string"
+      },
+      "status": {
+        "example": "online",
+        "type": "string"
+      },
+      "uptimeSecs": {
+        "example": 86400,
+        "type": "integer"
+      },
+      "xrayVersion": {
+        "example": "25.10.31",
+        "type": "string"
+      }
+    },
+    "required": [
+      "cpuPct",
+      "error",
+      "latencyMs",
+      "memPct",
+      "panelVersion",
+      "status",
+      "uptimeSecs",
+      "xrayVersion"
+    ],
+    "type": "object"
+  },
+  "Setting": {
+    "description": "Setting stores key-value configuration settings for the 3x-ui panel.",
+    "properties": {
+      "id": {
+        "type": "integer"
+      },
+      "key": {
+        "type": "string"
+      },
+      "value": {
+        "type": "string"
+      }
+    },
+    "required": [
+      "id",
+      "key",
+      "value"
+    ],
+    "type": "object"
+  },
+  "User": {
+    "description": "User represents a user account in the 3x-ui panel.",
+    "properties": {
+      "id": {
+        "type": "integer"
+      },
+      "password": {
+        "type": "string"
+      },
+      "username": {
+        "type": "string"
+      }
+    },
+    "required": [
+      "id",
+      "password",
+      "username"
+    ],
+    "type": "object"
+  }
+};

+ 30 - 0
frontend/src/test/generated-examples.test.ts

@@ -0,0 +1,30 @@
+import { describe, it, expect } from 'vitest';
+import type { ZodType } from 'zod';
+
+import { EXAMPLES } from '@/generated/examples';
+import * as zodSchemas from '@/generated/zod';
+
+const registry = zodSchemas as unknown as Record<string, ZodType>;
+const names = Object.keys(EXAMPLES);
+
+describe('generated response examples', () => {
+  it('has at least one example to validate', () => {
+    expect(names.length).toBeGreaterThan(0);
+  });
+
+  it('pairs every example with a generated zod schema', () => {
+    const missing = names.filter((name) => typeof registry[`${name}Schema`]?.safeParse !== 'function');
+    expect(missing).toEqual([]);
+  });
+
+  it.each(names)('EXAMPLES.%s satisfies its generated zod schema', (name) => {
+    const schema = registry[`${name}Schema`];
+    const result = schema.safeParse(EXAMPLES[name]);
+    if (!result.success) {
+      throw new Error(
+        `EXAMPLES.${name} does not match ${name}Schema:\n${JSON.stringify(result.error.issues, null, 2)}`,
+      );
+    }
+    expect(result.success).toBe(true);
+  });
+});

+ 190 - 0
tools/openapigen/emit_jsonschema.go

@@ -0,0 +1,190 @@
+package main
+
+import (
+	"encoding/json"
+	"fmt"
+	"io"
+	"sort"
+	"strconv"
+	"strings"
+)
+
+func emitJSONSchema(w io.Writer, schemas []Schema, aliases []Alias) error {
+	byName := make(map[string]Schema, len(schemas))
+	for _, s := range schemas {
+		byName[s.Name] = s
+	}
+	aliasByName := make(map[string]Alias, len(aliases))
+	for _, a := range aliases {
+		aliasByName[a.Name] = a
+	}
+
+	gen := &schemaGen{byName: byName, aliasByName: aliasByName}
+
+	out := make(map[string]any, len(schemas))
+	for _, s := range schemas {
+		out[s.Name] = gen.objectSchema(s)
+	}
+
+	payload, err := json.MarshalIndent(out, "", "  ")
+	if err != nil {
+		return err
+	}
+	if _, err := fmt.Fprintln(w, examplesHeader); err != nil {
+		return err
+	}
+	if _, err := fmt.Fprintf(w, "export const SCHEMAS: Record<string, unknown> = %s;\n", payload); err != nil {
+		return err
+	}
+	return nil
+}
+
+type schemaGen struct {
+	byName      map[string]Schema
+	aliasByName map[string]Alias
+}
+
+func (g *schemaGen) objectSchema(s Schema) map[string]any {
+	props := make(map[string]any, len(s.Fields))
+	var required []string
+	for _, f := range s.Fields {
+		props[f.JSONName] = g.fieldSchema(f)
+		if !f.Optional {
+			required = append(required, f.JSONName)
+		}
+	}
+	obj := map[string]any{"type": "object", "properties": props}
+	if len(required) > 0 {
+		sort.Strings(required)
+		obj["required"] = required
+	}
+	if s.Doc != "" {
+		obj["description"] = s.Doc
+	}
+	return obj
+}
+
+func (g *schemaGen) fieldSchema(f Field) map[string]any {
+	sch := g.typeSchema(f.Type)
+	if ref, ok := sch["$ref"]; ok {
+		if f.Doc == "" && f.Example == "" {
+			return sch
+		}
+		wrap := map[string]any{"allOf": []any{map[string]any{"$ref": ref}}}
+		if f.Doc != "" {
+			wrap["description"] = f.Doc
+		}
+		if f.Example != "" {
+			wrap["example"] = coerceExample(f.Example, baseKind(f.Type))
+		}
+		return wrap
+	}
+	applyConstraints(sch, f.Type, f.Validate)
+	if f.Doc != "" {
+		sch["description"] = f.Doc
+	}
+	if f.Example != "" {
+		sch["example"] = coerceExample(f.Example, baseKind(f.Type))
+	}
+	return sch
+}
+
+func (g *schemaGen) typeSchema(t TypeRef) map[string]any {
+	switch t.Kind {
+	case KindString:
+		if t.Name == "datetime" {
+			return map[string]any{"type": "string", "format": "date-time"}
+		}
+		return map[string]any{"type": "string"}
+	case KindInt:
+		return map[string]any{"type": "integer"}
+	case KindNumber:
+		return map[string]any{"type": "number"}
+	case KindBool:
+		return map[string]any{"type": "boolean"}
+	case KindArray:
+		return map[string]any{"type": "array", "items": g.typeSchema(*t.Element)}
+	case KindMap:
+		return map[string]any{"type": "object", "additionalProperties": g.typeSchema(*t.Value)}
+	case KindAny, KindUnknown, KindRaw:
+		return map[string]any{}
+	case KindRef:
+		if t.Name == "nullable" {
+			inner := g.typeSchema(*t.Inner)
+			if ref, ok := inner["$ref"]; ok {
+				return map[string]any{"nullable": true, "allOf": []any{map[string]any{"$ref": ref}}}
+			}
+			inner["nullable"] = true
+			return inner
+		}
+		if alias, ok := g.aliasByName[t.Name]; ok {
+			return g.typeSchema(alias.Underlying)
+		}
+		if _, ok := g.byName[t.Name]; ok {
+			return map[string]any{"$ref": "#/components/schemas/" + t.Name}
+		}
+		return map[string]any{}
+	}
+	return map[string]any{}
+}
+
+func applyConstraints(sch map[string]any, t TypeRef, rules []ValidateRule) {
+	base := baseKind(t)
+	numeric := base.Kind == KindInt || base.Kind == KindNumber
+	str := base.Kind == KindString
+	for _, r := range rules {
+		switch r.Name {
+		case "gte":
+			if numeric {
+				sch["minimum"] = coerceExample(r.Param, base)
+			}
+		case "lte":
+			if numeric {
+				sch["maximum"] = coerceExample(r.Param, base)
+			}
+		case "gt":
+			if numeric {
+				sch["minimum"] = coerceExample(r.Param, base)
+				sch["exclusiveMinimum"] = true
+			}
+		case "lt":
+			if numeric {
+				sch["maximum"] = coerceExample(r.Param, base)
+				sch["exclusiveMaximum"] = true
+			}
+		case "min":
+			if numeric {
+				sch["minimum"] = coerceExample(r.Param, base)
+			} else if str {
+				if n, err := strconv.Atoi(r.Param); err == nil {
+					sch["minLength"] = n
+				}
+			}
+		case "max":
+			if numeric {
+				sch["maximum"] = coerceExample(r.Param, base)
+			} else if str {
+				if n, err := strconv.Atoi(r.Param); err == nil {
+					sch["maxLength"] = n
+				}
+			}
+		case "oneof":
+			vals := strings.Fields(r.Param)
+			if len(vals) > 0 {
+				enum := make([]any, len(vals))
+				for i, v := range vals {
+					enum[i] = v
+				}
+				sch["enum"] = enum
+			}
+		case "email":
+			if str {
+				sch["format"] = "email"
+			}
+		case "url":
+			if str {
+				sch["format"] = "uri"
+			}
+		}
+	}
+}

+ 7 - 0
tools/openapigen/main.go

@@ -106,6 +106,10 @@ func run(root, outDir string) error {
 	if err := emitExamples(examplesBuf, schemas, aliases); err != nil {
 		return err
 	}
+	schemasBuf := &bytes.Buffer{}
+	if err := emitJSONSchema(schemasBuf, schemas, aliases); err != nil {
+		return err
+	}
 
 	if err := os.WriteFile(filepath.Join(target, "zod.ts"), zodBuf.Bytes(), 0o644); err != nil {
 		return err
@@ -116,6 +120,9 @@ func run(root, outDir string) error {
 	if err := os.WriteFile(filepath.Join(target, "examples.ts"), examplesBuf.Bytes(), 0o644); err != nil {
 		return err
 	}
+	if err := os.WriteFile(filepath.Join(target, "schemas.ts"), schemasBuf.Bytes(), 0o644); err != nil {
+		return err
+	}
 
 	fmt.Printf("openapigen: wrote %d schemas to %s\n", len(schemas), target)
 	return nil