diff --git a/package.json b/package.json index e08f8a8e..8e8af1b2 100644 --- a/package.json +++ b/package.json @@ -103,6 +103,7 @@ "@heroui/theme": "^2.4.26", "@next/eslint-plugin-next": "15.5.28", "@octokit/webhooks-types": "^7.6.1", + "@scalar/openapi-parser": "^0.29.16", "@swc/core": "^1.16.18", "@swc/jest": "^0.2.39", "@types/dotenv": "^6.1.1", @@ -163,7 +164,7 @@ "^.+\\.(js|ts)$": "@swc/jest" }, "transformIgnorePatterns": [ - "/node_modules/(?!\\.pnpm/|ai/|@ai-sdk/|@workflow/|jose/)" + "/node_modules/(?!\\.pnpm/|ai/|@ai-sdk/|@workflow/|jose/|@scalar/)" ], "setupFilesAfterEnv": [ "/jestSetup.ts" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4e97a6de..2fdb378d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -226,6 +226,9 @@ importers: '@octokit/webhooks-types': specifier: ^7.6.1 version: 7.6.1 + '@scalar/openapi-parser': + specifier: ^0.29.16 + version: 0.29.16 '@swc/core': specifier: ^1.16.18 version: 1.16.18(@swc/helpers@0.5.23) @@ -3578,6 +3581,46 @@ packages: resolution: { integrity: sha512-TvZbIpeKqGQQ7X0zSCvPH9riMSFQFSggnfBjFZ1mEoILW+UuXCKwOoPcgjMwiUtRqFZ8jWhPJc4um14vC6I4ag== } + '@scalar/helpers@0.16.4': + resolution: + { integrity: sha512-9eILWzUcAkHEuYMMvijpSIy69t+Vkq+NudLB/7lgARFTWqXfQpIxW1L3VH7ogKyRie5+E4CNZ3PSvlw6IIfLGw== } + engines: { node: '>=22' } + + '@scalar/json-magic@0.17.0': + resolution: + { integrity: sha512-YmJAO9NoGCcU/vx7P4Dy9DGv+Vntp+ny3Fh99slHOOh/IZxvbdK5hWeZdzQ3x+ezab9XJNscFoX8H/P6W+H8lA== } + engines: { node: '>=22.19.0' } + + '@scalar/json-schema-validator@0.1.14': + resolution: + { integrity: sha512-+UDt9xn3Gad8i+JP2V6WC16Q0NjGWRXtp8ePacCg9H1A/8mHOdUmRP5MSC+LMO6Ukr5eaKwk7GbcCylU//1N5Q== } + engines: { node: '>=22' } + + '@scalar/openapi-parser@0.29.16': + resolution: + { integrity: sha512-nxIbR7ZFToK21q5CQcHpQed2UQ3PrYbNzWQXxS8hxhmfswRK5Eeyiitr8D3SIY5+34EOSAb/WFDhaKi6x7UJYg== } + engines: { node: '>=22' } + + '@scalar/openapi-types@0.9.8': + resolution: + { integrity: sha512-pGcpkZObsGBC7nRKYte+6gstoe0C4hp7B2UWQYjg5To1wE68s3OHVvin+muL3tn3ZAoahXeJAkn1+aEcMi802w== } + engines: { node: '>=22' } + + '@scalar/openapi-upgrader@0.4.6': + resolution: + { integrity: sha512-wkTbowGZAlDA5AhCAnBVft6y3veoE17P+okH/zrwM/BZWBSzPghGpa4BPTCg3+IzRmRP/8ZeaYO7LfxCDAEmcA== } + engines: { node: '>=22' } + + '@scalar/openapi-validator@0.1.14': + resolution: + { integrity: sha512-02r1/T7/KufhnOQ0r2/Le7ASSKBCSQLoP+r4wjGCEkQFRyjREBe01lAWHXl/nycq3l/ONFAN94vWKTFd1NVtLw== } + engines: { node: '>=22' } + + '@scalar/types@0.25.0': + resolution: + { integrity: sha512-EdwOVV5ajKks4MX5BorHIdMLG0L8EAxfz+NisAPV8l7Rsic50+YoWnqkY1eqB6K96iyreIKUdl7B/PIFGLqylg== } + engines: { node: '>=22' } + '@scarf/scarf@1.4.0': resolution: { integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ== } @@ -7530,6 +7573,12 @@ packages: engines: { node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1 } hasBin: true + nanoid@5.1.16: + resolution: + { integrity: sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ== } + engines: { node: ^18 || >=20 } + hasBin: true + napi-postinstall@0.3.4: resolution: { integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ== } @@ -7941,6 +7990,10 @@ packages: { integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw== } engines: { node: '>=8' } + pathe@2.0.3: + resolution: + { integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w== } + performance-now@2.1.0: resolution: { integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow== } @@ -9048,6 +9101,11 @@ packages: react: '>=16.8.0 <20' react-dom: '>=16.8.0 <20' + tagged-tag@1.0.0: + resolution: + { integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng== } + engines: { node: '>=20' } + tailwind-merge@3.4.0: resolution: { integrity: sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g== } @@ -9280,6 +9338,11 @@ packages: { integrity: sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA== } engines: { node: '>=10' } + type-fest@5.10.0: + resolution: + { integrity: sha512-NoSdpq/WEiAg5sjmBkmV/hfxv6HJH4NqPNrqjtSO5CwRmpsDfaf4begxW34KdJykH/l1yHtwBWQkCRdoXO8mPA== } + engines: { node: '>=20' } + type-is@2.1.0: resolution: { integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA== } @@ -9334,6 +9397,11 @@ packages: { integrity: sha512-XtuDWVdqTLr3NAxm2ly9/hU6kUsJOgGh1RASzMBbsw6aWCOaQzHdW+QQDfdERjfJ2V5vL5pRPa4RetO9UfivDg== } engines: { node: '>=22.19.0' } + undici@8.12.3: + resolution: + { integrity: sha512-+fdndUTwBP86cVjihTp21hZHo7d6oj1c/47XPA3lL8mM0XHs+C4EkcjmgRsFnFk2f/UONGDy9ItBItmXsLOQHw== } + engines: { node: '>=22.19.0' } + unicorn-magic@0.1.0: resolution: { integrity: sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ== } @@ -13489,6 +13557,55 @@ snapshots: '@rushstack/eslint-patch@1.16.1': {} + '@scalar/helpers@0.16.4': {} + + '@scalar/json-magic@0.17.0': + dependencies: + '@scalar/helpers': 0.16.4 + pathe: 2.0.3 + undici: 8.12.3 + yaml: 2.9.1 + + '@scalar/json-schema-validator@0.1.14': + dependencies: + '@scalar/helpers': 0.16.4 + '@scalar/types': 0.25.0 + ajv: 8.20.0 + ajv-draft-04: 1.0.0(ajv@8.20.0) + ajv-formats: 3.0.1(ajv@8.20.0) + yaml: 2.9.1 + + '@scalar/openapi-parser@0.29.16': + dependencies: + '@scalar/helpers': 0.16.4 + '@scalar/json-magic': 0.17.0 + '@scalar/openapi-types': 0.9.8 + '@scalar/openapi-upgrader': 0.4.6 + '@scalar/openapi-validator': 0.1.14 + '@scalar/types': 0.25.0 + yaml: 2.9.1 + + '@scalar/openapi-types@0.9.8': {} + + '@scalar/openapi-upgrader@0.4.6': + dependencies: + '@scalar/helpers': 0.16.4 + '@scalar/openapi-types': 0.9.8 + + '@scalar/openapi-validator@0.1.14': + dependencies: + '@scalar/helpers': 0.16.4 + '@scalar/json-schema-validator': 0.1.14 + '@scalar/types': 0.25.0 + yaml: 2.9.1 + + '@scalar/types@0.25.0': + dependencies: + '@scalar/helpers': 0.16.4 + nanoid: 5.1.16 + type-fest: 5.10.0 + zod: 4.6.7 + '@scarf/scarf@1.4.0': {} '@sinclair/typebox@0.27.12': {} @@ -17379,6 +17496,8 @@ snapshots: nanoid@3.3.20: {} + nanoid@5.1.16: {} + napi-postinstall@0.3.4: {} natural-compare-lite@1.4.1: {} @@ -17735,6 +17854,8 @@ snapshots: path-type@4.0.0: {} + pathe@2.0.3: {} + performance-now@2.1.0: {} pg-cloudflare@1.4.1: @@ -18828,6 +18949,8 @@ snapshots: - debug - supports-color + tagged-tag@1.0.0: {} + tailwind-merge@3.4.0: {} tailwind-variants@3.2.2(tailwind-merge@3.4.0)(tailwindcss@3.4.19(tsx@4.23.15)(yaml@2.9.1)): @@ -19036,6 +19159,10 @@ snapshots: type-fest@1.4.0: {} + type-fest@5.10.0: + dependencies: + tagged-tag: 1.0.0 + type-is@2.1.0: dependencies: content-type: 2.1.0 @@ -19093,6 +19220,8 @@ snapshots: undici@8.12.2: {} + undici@8.12.3: {} + unicorn-magic@0.1.0: {} universal-github-app-jwt@1.2.0: diff --git a/src/app/api/v2/ai/agent/mcp-connections/[slug]/oauth/callback/route.ts b/src/app/api/v2/ai/agent/mcp-connections/[slug]/oauth/callback/route.ts index f1e69fef..1c7a6616 100644 --- a/src/app/api/v2/ai/agent/mcp-connections/[slug]/oauth/callback/route.ts +++ b/src/app/api/v2/ai/agent/mcp-connections/[slug]/oauth/callback/route.ts @@ -170,6 +170,43 @@ function renderCallbackPage(options: { * name: state * schema: * type: string + * responses: + * '200': + * description: HTML page confirming the connection; posts the result to the opener window + * content: + * text/html: + * schema: + * type: string + * '400': + * description: HTML page describing why the connection failed + * content: + * text/html: + * schema: + * type: string + * '404': + * description: HTML page shown when the connection no longer exists + * content: + * text/html: + * schema: + * type: string + * '409': + * description: HTML page shown when the connection state conflicts with the callback + * content: + * text/html: + * schema: + * type: string + * '410': + * description: HTML page shown when the connection attempt expired + * content: + * text/html: + * schema: + * type: string + * '422': + * description: HTML page shown when the provider response could not be processed + * content: + * text/html: + * schema: + * type: string */ const getHandler = async (req: NextRequest, { params }: { params: Promise<{ slug: string }> }) => { const { slug } = await params; diff --git a/src/app/api/v2/builds/[uuid]/services/[name]/deploy-jobs/route.ts b/src/app/api/v2/builds/[uuid]/services/[name]/deploy-jobs/route.ts index 14b035c0..524ea331 100644 --- a/src/app/api/v2/builds/[uuid]/services/[name]/deploy-jobs/route.ts +++ b/src/app/api/v2/builds/[uuid]/services/[name]/deploy-jobs/route.ts @@ -58,30 +58,30 @@ import { getDeploymentJobs } from 'server/lib/kubernetes/getDeploymentJobs'; * application/json: * schema: * $ref: '#/components/schemas/GetDeployLogsSuccessResponse' - * '400': - * description: Invalid parameters - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/ApiErrorResponse' - * '404': - * description: Environment or service not found - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/ApiErrorResponse' - * '502': - * description: Failed to communicate with Kubernetes - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/ApiErrorResponse' - * '500': - * description: Internal server error - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/ApiErrorResponse' + * '400': + * description: Invalid parameters + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiErrorResponse' + * '404': + * description: Environment or service not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiErrorResponse' + * '502': + * description: Failed to communicate with Kubernetes + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiErrorResponse' + * '500': + * description: Internal server error + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiErrorResponse' */ const getHandler = async ( req: NextRequest, diff --git a/src/app/api/v2/environments/[uuid]/route.ts b/src/app/api/v2/environments/[uuid]/route.ts index 4f415d36..38c5588a 100644 --- a/src/app/api/v2/environments/[uuid]/route.ts +++ b/src/app/api/v2/environments/[uuid]/route.ts @@ -103,7 +103,7 @@ import { * '400': { description: Invalid request body (invalid_body). } * '404': { description: Environment not found. } * '409': { description: Teardown already owns the environment (env_tearing_down). } - * '422': { description: Invalid override, or autoTrack on an immutable source (auto_track_pinned_source). } + * '422': { description: 'Invalid override, or autoTrack on an immutable source (auto_track_pinned_source).' } * delete: * summary: Destroy an environment * security: diff --git a/src/app/api/v2/environments/route.ts b/src/app/api/v2/environments/route.ts index 7688e2b6..b1dbe145 100644 --- a/src/app/api/v2/environments/route.ts +++ b/src/app/api/v2/environments/route.ts @@ -138,10 +138,10 @@ import { * application/json: * schema: { $ref: '#/components/schemas/EnvironmentCreateSuccessResponse' } * '400': { description: Invalid body or ambiguous environment (env_ambiguous). } - * '403': { description: Missing scope, repository not allowed for token, or api_environments disabled. } + * '403': { description: 'Missing scope, repository not allowed for token, or api_environments disabled.' } * '404': { description: Repository not onboarded (repo_not_onboarded). } * '409': { description: Vanity name already taken (name_conflict). } - * '422': { description: Invalid config/override, or autoTrack with an immutable sha (auto_track_pinned_source). } + * '422': { description: 'Invalid config/override, or autoTrack with an immutable sha (auto_track_pinned_source).' } */ const getHandler = createPrincipalApiHandler({ scope: 'env:read' }, async (req: NextRequest, principal) => { const { searchParams } = req.nextUrl; diff --git a/src/app/api/v2/me/tokens/route.ts b/src/app/api/v2/me/tokens/route.ts index 2329860b..eaad6906 100644 --- a/src/app/api/v2/me/tokens/route.ts +++ b/src/app/api/v2/me/tokens/route.ts @@ -66,7 +66,7 @@ import { serializeUserToken, assertIssuanceEnabled, assertNoUnknownFields, TOKEN * schema: { $ref: '#/components/schemas/PersonalApiTokenCreateSuccessResponse' } * '400': { description: Invalid body / repository access / expiry. } * '401': { description: Not authenticated. } - * '403': { description: Role not permitted, forbidden scope, or key issuance disabled (api_keys_disabled). } + * '403': { description: 'Role not permitted, forbidden scope, or key issuance disabled (api_keys_disabled).' } */ const getHandler = async (req: NextRequest) => { ApiTokenService.assertManagementAllowed(); diff --git a/src/app/api/v2/tokens/route.ts b/src/app/api/v2/tokens/route.ts index f92289c0..87c5de59 100644 --- a/src/app/api/v2/tokens/route.ts +++ b/src/app/api/v2/tokens/route.ts @@ -110,7 +110,7 @@ const parsePositiveIntegerParam = (value: string | null, name: string, fallback: * application/json: * schema: { $ref: '#/components/schemas/ServiceApiTokenCreateSuccessResponse' } * '400': { description: Invalid body. } - * '403': { description: Requires the admin role, forbidden scope, or key issuance disabled (api_keys_disabled). } + * '403': { description: 'Requires the admin role, forbidden scope, or key issuance disabled (api_keys_disabled).' } */ const getHandler = async (req: NextRequest) => { ApiTokenService.assertManagementAllowed(); diff --git a/src/shared/openApiSpec.test.ts b/src/shared/openApiSpec.test.ts index d7e9f692..12566bf5 100644 --- a/src/shared/openApiSpec.test.ts +++ b/src/shared/openApiSpec.test.ts @@ -1026,3 +1026,13 @@ describe('OpenAPI v2 agent session contract', () => { }); }); }); + +describe('OpenAPI v2 spec validity', () => { + it('passes strict OpenAPI validation', async () => { + const { validate } = await import('@scalar/openapi-parser'); + const result = await validate(swaggerSpec); + const errors = (result.errors ?? []).map((e: { message: string }) => e.message); + expect(errors).toEqual([]); + expect(result.valid).toBe(true); + }); +}); diff --git a/src/shared/openApiSpec.ts b/src/shared/openApiSpec.ts index a795e285..c042cf02 100644 --- a/src/shared/openApiSpec.ts +++ b/src/shared/openApiSpec.ts @@ -214,7 +214,7 @@ export const openApiSpecificationForV2Api: OAS3Options = { request_id: { type: 'string', format: 'uuid' }, metadata: { $ref: '#/components/schemas/ResponseMetadata' }, error: { - type: 'null', + nullable: true, description: 'Always null on successful responses.', }, }, @@ -228,7 +228,7 @@ export const openApiSpecificationForV2Api: OAS3Options = { type: 'object', properties: { request_id: { type: 'string', format: 'uuid' }, - data: { type: 'null' }, + data: { nullable: true, description: 'Always null on error responses.' }, error: { $ref: '#/components/schemas/ApiError' }, }, required: ['request_id', 'data', 'error'], @@ -4241,7 +4241,7 @@ export const openApiSpecificationForV2Api: OAS3Options = { messageCount: { type: 'integer' }, runCount: { type: 'integer' }, pendingActionsCount: { type: 'integer' }, - latestRun: { $ref: '#/components/schemas/AgentRun', nullable: true }, + latestRun: { nullable: true, allOf: [{ $ref: '#/components/schemas/AgentRun' }] }, }, required: ['messageCount', 'runCount', 'pendingActionsCount', 'latestRun'], }, @@ -6060,9 +6060,6 @@ export const openApiSpecificationForV2Api: OAS3Options = { AgentSessionStringRecord: { type: 'object', - propertyNames: { - minLength: 1, - }, additionalProperties: { type: 'string', minLength: 1,