diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 945f83f19..107f72f24 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -8,6 +8,35 @@ info: **The API endpoints documented here are not yet stable and may change or break without notice. Use them at your own risk.** + ## Error responses + + Most non-OAuth JSON endpoints return the shared `APIError` body for non-2xx responses: + + ```json + { + "code": 40001, + "msg": "Invalid args" + } + ``` + + The first three digits of `code` match the HTTP status code. Clients should branch on `code` and HTTP status. + + The `msg` field is a developer-facing diagnostic string and is not a stable programmatic contract. It may contain + safe validation details, but it must not contain stack traces, internal exception text, credentials, or other + sensitive implementation details. + + Backend implementations should use the codes defined by `ErrorCode` and should add a new documented code before + exposing a new machine-readable error condition. + + OAuth protocol error responses use the `OAuthError` body instead of `APIError`. + + Moved-resource redirects return `301` with `Location` for `GET` and `HEAD`, while moved-resource write requests + return `409` with `MovedResourceError`. + + Server-Sent Events endpoints may emit a stream-level `error` event after the HTTP response has already started. + + Quota and rate-limit responses include `Retry-After` when the server can compute a retry time. + security: - bearerAuth: [] @@ -52,6 +81,8 @@ paths: type: array items: $ref: "#/components/schemas/AccountIdentityProvider" + "400": + $ref: "#/components/responses/BadRequest" /account/identity-providers/{provider}/authorize: get: @@ -96,7 +127,7 @@ paths: format: uri minLength: 1 "400": - description: Invalid hosted sign-in request. + $ref: "#/components/responses/BadRequest" /account/identity-providers/{provider}/callback: get: @@ -105,9 +136,12 @@ paths: - Account summary: Handle identity provider callback description: | - Handle the identity provider callback for hosted sign-in. Callback state is used to associate the provider - response with the hosted sign-in flow and provide CSRF protection. The provider callback includes either an - authorization code or an OAuth error. + Handle the identity provider callback for hosted sign-in. + + Callback state is used to associate the provider response with the hosted sign-in flow and provide CSRF + protection. + + The provider callback includes either an authorization code or an OAuth error. security: [] parameters: - name: provider @@ -140,7 +174,7 @@ paths: description: Redirect back to hosted sign-in. headers: Location: - description: Hosted sign-in URL containing the original `clientID`, `requestURI`, and optional `uiLocales`. + description: Hosted sign-in URL with the original `clientID`, `requestURI`, and optional `uiLocales`. schema: type: string format: uri @@ -150,15 +184,20 @@ paths: Sets the `__Host-xbuilder-account-session` cookie when the provider callback creates an account session. schema: type: string + "400": + $ref: "#/components/responses/BadRequest" post: operationId: submitAccountIdentityProviderCallback tags: - Account summary: Submit identity provider callback description: | - Handle identity provider callbacks that use form post response mode. These external callbacks use the OAuth - state parameter for CSRF protection instead of a same-site web CSRF token. The provider callback includes either - an authorization code or an OAuth error. + Handle identity provider callbacks that use form post response mode. + + These external callbacks use the OAuth state parameter for CSRF protection instead of a same-site web CSRF + token. + + The provider callback includes either an authorization code or an OAuth error. security: [] parameters: - name: provider @@ -206,7 +245,7 @@ paths: description: Redirect back to hosted sign-in. headers: Location: - description: Hosted sign-in URL containing the original `clientID`, `requestURI`, and optional `uiLocales`. + description: Hosted sign-in URL with the original `clientID`, `requestURI`, and optional `uiLocales`. schema: type: string format: uri @@ -216,6 +255,8 @@ paths: Sets the `__Host-xbuilder-account-session` cookie when the provider callback creates an account session. schema: type: string + "400": + $ref: "#/components/responses/BadRequestForIdentityProviderFormPostCallback" /account/oauth/par: post: @@ -556,10 +597,12 @@ paths: application/json: schema: $ref: "#/components/schemas/AccountUser" + "400": + $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/AccountUnauthorized" "403": - $ref: "#/components/responses/OAuthInsufficientScope" + $ref: "#/components/responses/AccountForbidden" /account/user/avatar: put: @@ -592,7 +635,7 @@ paths: description: | Avatar image file. - Maximum size is 5 MiB (5242880 bytes). + Maximum size is 5 MiB. type: string format: binary encoding: @@ -602,15 +645,15 @@ paths: "204": description: Account user avatar updated. "400": - description: Invalid request parameters. + $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/AccountUnauthorized" "403": - $ref: "#/components/responses/OAuthInsufficientScope" + $ref: "#/components/responses/AccountForbidden" "413": - description: Avatar image file is too large. + $ref: "#/components/responses/ContentTooLarge" "415": - description: Avatar image file type is not supported. + $ref: "#/components/responses/UnsupportedMediaType" /account/user/identities: get: @@ -641,6 +684,8 @@ paths: type: array items: $ref: "#/components/schemas/AccountUserIdentity" + "400": + $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" @@ -678,8 +723,12 @@ paths: application/json: schema: $ref: "#/components/schemas/CurrentAccountSession" + "400": + $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" get: operationId: getAccountSession tags: @@ -718,6 +767,8 @@ paths: type: string "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" /account/sessions: get: @@ -748,6 +799,8 @@ paths: type: array items: $ref: "#/components/schemas/AccountSession" + "400": + $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" delete: @@ -771,6 +824,8 @@ paths: type: string "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" /account/sessions/{sessionID}: delete: @@ -799,10 +854,14 @@ paths: description: Clears the `__Host-xbuilder-account-session` cookie when the deleted session is current. schema: type: string + "400": + $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "404": - description: Account session not found. + $ref: "#/components/responses/NotFound" /user: get: @@ -848,6 +907,8 @@ paths: application/json: schema: $ref: "#/components/schemas/AuthenticatedUser" + "400": + $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" @@ -871,7 +932,7 @@ paths: description: | Avatar image file. - Maximum size is 5 MiB (5242880 bytes). + Maximum size is 5 MiB. type: string format: binary encoding: @@ -881,13 +942,13 @@ paths: "204": description: Authenticated user avatar updated. "400": - description: Invalid request parameters. + $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "413": - description: Avatar image file is too large. + $ref: "#/components/responses/ContentTooLarge" "415": - description: Avatar image file type is not supported. + $ref: "#/components/responses/UnsupportedMediaType" /user/followers: get: @@ -929,6 +990,8 @@ paths: type: array items: $ref: "#/components/schemas/User" + "400": + $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" @@ -972,6 +1035,8 @@ paths: type: array items: $ref: "#/components/schemas/User" + "400": + $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" @@ -997,7 +1062,7 @@ paths: "401": $ref: "#/components/responses/Unauthorized" "404": - description: The target user does not exist, or the authenticated user does not follow the target user. + $ref: "#/components/responses/NotFound" put: operationId: followUser tags: @@ -1128,6 +1193,8 @@ paths: type: array items: $ref: "#/components/schemas/Project" + "400": + $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" post: @@ -1199,19 +1266,12 @@ paths: application/json: schema: $ref: "#/components/schemas/Project" + "400": + $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "409": - description: | - Returned when `remixSource` references a moved project or project release. - - The response includes a `canonical` payload for the route-derived fields in `remixSource`. - - Clients may update the request and resubmit it explicitly. - content: - application/json: - schema: - $ref: "#/components/schemas/MovedResourceError" + $ref: "#/components/responses/ResourceMoved" /user/liked-projects: get: @@ -1303,6 +1363,8 @@ paths: type: array items: $ref: "#/components/schemas/Project" + "400": + $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" @@ -1334,7 +1396,7 @@ paths: "401": $ref: "#/components/responses/Unauthorized" "404": - description: The project does not exist, or the authenticated user has not liked the project. + $ref: "#/components/responses/NotFound" put: operationId: likeProject tags: @@ -1456,6 +1518,8 @@ paths: type: array items: $ref: "#/components/schemas/Asset" + "400": + $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" post: @@ -1518,10 +1582,12 @@ paths: application/json: schema: $ref: "#/components/schemas/Asset" + "400": + $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "403": - description: Insufficient permissions to perform this operation. + $ref: "#/components/responses/Forbidden" /user/courses: get: @@ -1568,6 +1634,8 @@ paths: type: array items: $ref: "#/components/schemas/Course" + "400": + $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" post: @@ -1620,10 +1688,12 @@ paths: application/json: schema: $ref: "#/components/schemas/Course" + "400": + $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "403": - description: Insufficient permissions to perform this operation. + $ref: "#/components/responses/Forbidden" /user/course-series: get: @@ -1665,6 +1735,8 @@ paths: type: array items: $ref: "#/components/schemas/CourseSeries" + "400": + $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" post: @@ -1715,10 +1787,12 @@ paths: application/json: schema: $ref: "#/components/schemas/CourseSeries" + "400": + $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "403": - description: Insufficient permissions to perform this operation. + $ref: "#/components/responses/Forbidden" /users: get: @@ -1760,6 +1834,8 @@ paths: type: array items: $ref: "#/components/schemas/User" + "400": + $ref: "#/components/responses/BadRequest" /users/{username}: get: @@ -1785,6 +1861,8 @@ paths: $ref: "#/components/schemas/User" "301": $ref: "#/components/responses/MovedPermanently" + "404": + $ref: "#/components/responses/NotFound" /users/{username}/followers: get: @@ -1835,6 +1913,10 @@ paths: $ref: "#/components/schemas/User" "301": $ref: "#/components/responses/MovedPermanently" + "400": + $ref: "#/components/responses/BadRequest" + "404": + $ref: "#/components/responses/NotFound" /users/{username}/following: get: @@ -1885,6 +1967,10 @@ paths: $ref: "#/components/schemas/User" "301": $ref: "#/components/responses/MovedPermanently" + "400": + $ref: "#/components/responses/BadRequest" + "404": + $ref: "#/components/responses/NotFound" /users/{username}/projects: get: @@ -1979,6 +2065,10 @@ paths: $ref: "#/components/schemas/Project" "301": $ref: "#/components/responses/MovedPermanently" + "400": + $ref: "#/components/responses/BadRequest" + "404": + $ref: "#/components/responses/NotFound" /users/{username}/liked-projects: get: @@ -2074,6 +2164,10 @@ paths: $ref: "#/components/schemas/Project" "301": $ref: "#/components/responses/MovedPermanently" + "400": + $ref: "#/components/responses/BadRequest" + "404": + $ref: "#/components/responses/NotFound" /users/{username}/assets: get: @@ -2147,6 +2241,10 @@ paths: $ref: "#/components/schemas/Asset" "301": $ref: "#/components/responses/MovedPermanently" + "400": + $ref: "#/components/responses/BadRequest" + "404": + $ref: "#/components/responses/NotFound" /users/{username}/courses: get: @@ -2202,6 +2300,10 @@ paths: $ref: "#/components/schemas/Course" "301": $ref: "#/components/responses/MovedPermanently" + "400": + $ref: "#/components/responses/BadRequest" + "404": + $ref: "#/components/responses/NotFound" /users/{username}/course-series: get: @@ -2252,6 +2354,10 @@ paths: $ref: "#/components/schemas/CourseSeries" "301": $ref: "#/components/responses/MovedPermanently" + "400": + $ref: "#/components/responses/BadRequest" + "404": + $ref: "#/components/responses/NotFound" /projects: get: @@ -2262,9 +2368,12 @@ paths: description: | Retrieve a list of projects visible to the request. - Anonymous requests return public projects. Authenticated requests return public projects and the authenticated - user's own projects when no visibility filter is provided. A non-public visibility filter requires - authentication and is scoped to the authenticated user's own projects. + Visibility rules: + + - Anonymous requests return public projects + - Authenticated requests return public projects and the authenticated user's own projects when no visibility + filter is provided + - A non-public visibility filter requires authentication and is scoped to the authenticated user's own projects security: - {} - bearerAuth: [] @@ -2359,6 +2468,8 @@ paths: type: array items: $ref: "#/components/schemas/Project" + "400": + $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" @@ -2447,6 +2558,8 @@ paths: application/json: schema: $ref: "#/components/schemas/Project" + "400": + $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "409": @@ -2563,6 +2676,8 @@ paths: $ref: "#/components/schemas/ProjectRelease" "301": $ref: "#/components/responses/MovedPermanently" + "400": + $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" post: @@ -2616,19 +2731,12 @@ paths: application/json: schema: $ref: "#/components/schemas/ProjectRelease" + "400": + $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "409": - description: | - Returned when the project path references a moved project. - - The response includes a `canonical` payload for the project route. - - Clients may update the request and resubmit it explicitly. - content: - application/json: - schema: - $ref: "#/components/schemas/MovedResourceError" + $ref: "#/components/responses/ResourceMoved" /projects/{owner}/{project}/releases/{release}: get: @@ -2680,9 +2788,12 @@ paths: description: | Retrieve a list of assets visible to the request. - Anonymous requests return public assets. Authenticated requests return public assets and the authenticated - user's own assets when no visibility filter is provided. A non-public visibility filter requires authentication - and is scoped to the authenticated user's own assets. + Visibility rules: + + - Anonymous requests return public assets + - Authenticated requests return public assets and the authenticated user's own assets when no visibility filter + is provided + - A non-public visibility filter requires authentication and is scoped to the authenticated user's own assets security: - {} - bearerAuth: [] @@ -2747,6 +2858,8 @@ paths: type: array items: $ref: "#/components/schemas/Asset" + "400": + $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" @@ -2826,10 +2939,12 @@ paths: application/json: schema: $ref: "#/components/schemas/Asset" + "400": + $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "403": - description: Insufficient permissions to perform this operation. + $ref: "#/components/responses/Forbidden" delete: operationId: deleteAsset tags: @@ -2897,6 +3012,8 @@ paths: type: array items: $ref: "#/components/schemas/Course" + "400": + $ref: "#/components/responses/BadRequest" /courses/{courseID}: get: @@ -2922,6 +3039,8 @@ paths: application/json: schema: $ref: "#/components/schemas/Course" + "404": + $ref: "#/components/responses/NotFound" patch: operationId: updateCourse tags: @@ -2965,10 +3084,12 @@ paths: application/json: schema: $ref: "#/components/schemas/Course" + "400": + $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "403": - description: Insufficient permissions to perform this operation. + $ref: "#/components/responses/Forbidden" delete: operationId: deleteCourse tags: @@ -2993,7 +3114,7 @@ paths: "401": $ref: "#/components/responses/Unauthorized" "403": - description: Insufficient permissions to perform this operation. + $ref: "#/components/responses/Forbidden" /course-series: get: @@ -3038,6 +3159,8 @@ paths: type: array items: $ref: "#/components/schemas/CourseSeries" + "400": + $ref: "#/components/responses/BadRequest" /course-series/{courseSeriesID}: get: @@ -3063,6 +3186,8 @@ paths: application/json: schema: $ref: "#/components/schemas/CourseSeries" + "404": + $ref: "#/components/responses/NotFound" patch: operationId: updateCourseSeries tags: @@ -3106,10 +3231,12 @@ paths: application/json: schema: $ref: "#/components/schemas/CourseSeries" + "400": + $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "403": - description: Insufficient permissions to perform this operation. + $ref: "#/components/responses/Forbidden" delete: operationId: deleteCourseSeries tags: @@ -3134,7 +3261,7 @@ paths: "401": $ref: "#/components/responses/Unauthorized" "403": - description: Insufficient permissions to perform this operation. + $ref: "#/components/responses/Forbidden" /copilot/messages: post: @@ -3164,7 +3291,8 @@ paths: - Each message consumes 1 quota from the authenticated user's allowance. - Per-user short-window rate limits also apply to prevent bursts. Hitting them returns 429 with `Retry-After`. - Long-window quota limits vary based on the authenticated user's plan. - - When the long-window quota limit is reached, the 403 response includes a `Retry-After` header with the wait time in seconds. + - When the long-window quota limit is reached, the 403 response includes a `Retry-After` header with the wait + time in seconds. requestBody: required: true content: @@ -3181,7 +3309,7 @@ paths: text/event-stream: schema: type: string - description: Server-Sent Events stream containing `text_delta`, `tool_call_delta`, `done`, and `error` events. + description: SSE stream containing `text_delta`, `tool_call_delta`, `done`, and `error` events. examples: textOnly: summary: Text-only assistant response @@ -3210,22 +3338,14 @@ paths: value: | event: error data: {"reason":"streamFailed","message":"stream failed"} + "400": + $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "403": - description: Insufficient permissions or quota to perform this operation. - headers: - Retry-After: - description: Seconds to wait before retrying after hitting the long-window quota limit. - schema: - type: integer + $ref: "#/components/responses/ForbiddenOrQuotaExceeded" "429": - description: Too many requests to perform this operation. - headers: - Retry-After: - description: Seconds to wait before retrying after hitting the short-window rate limit. - schema: - type: integer + $ref: "#/components/responses/RateLimitExceeded" /ai-interaction/turns: post: @@ -3241,7 +3361,8 @@ paths: - Each turn consumes 1 quota from the authenticated user's AI interaction turn allowance. - Per-user short-window rate limits also apply to prevent bursts. Hitting them returns 429 with `Retry-After`. - Long-window quota limits vary based on the authenticated user's plan. - - When the long-window quota limit is reached, the 403 response includes a `Retry-After` header with the wait time in seconds. + - When the long-window quota limit is reached, the 403 response includes a `Retry-After` header with the wait + time in seconds. requestBody: required: true content: @@ -3366,8 +3487,11 @@ paths: description: | Current turn number in a multi-turn interaction sequence. - A value of 0 means this is the initial turn from user input. Values greater than 0 indicate - continuation turns where the AI continues the current interaction sequence based on command outcomes. + Turn values: + + - 0 means this is the initial turn from user input + - Values greater than 0 indicate continuation turns where the AI continues the current interaction + sequence based on command outcomes type: integer format: int32 minimum: 0 @@ -3402,22 +3526,14 @@ paths: - Row: 1 Col: 1 Result: "" + "400": + $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "403": - description: Insufficient permissions or quota to perform this operation. - headers: - Retry-After: - description: Seconds to wait before retrying after hitting the long-window quota limit. - schema: - type: integer + $ref: "#/components/responses/ForbiddenOrQuotaExceeded" "429": - description: Too many requests to perform this operation. - headers: - Retry-After: - description: Seconds to wait before retrying after hitting the short-window rate limit. - schema: - type: integer + $ref: "#/components/responses/RateLimitExceeded" /ai-interaction/archives: post: @@ -3433,7 +3549,8 @@ paths: - Each archive request consumes 1 quota from the authenticated user's AI interaction archive allowance. - Per-user short-window rate limits also apply to prevent bursts. Hitting them returns 429 with `Retry-After`. - Long-window quota limits vary based on the authenticated user's plan. - - When the long-window quota limit is reached, the 403 response includes a `Retry-After` header with the wait time in seconds. + - When the long-window quota limit is reached, the 403 response includes a `Retry-After` header with the wait + time in seconds. requestBody: required: true content: @@ -3472,22 +3589,14 @@ paths: type: string examples: - "Complete conversation summary including new turns..." + "400": + $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "403": - description: Insufficient permissions or quota to perform this operation. - headers: - Retry-After: - description: Seconds to wait before retrying after hitting the long-window quota limit. - schema: - type: integer + $ref: "#/components/responses/ForbiddenOrQuotaExceeded" "429": - description: Too many requests to perform this operation. - headers: - Retry-After: - description: Seconds to wait before retrying after hitting the short-window rate limit. - schema: - type: integer + $ref: "#/components/responses/RateLimitExceeded" /aigc/tasks: post: @@ -3499,8 +3608,7 @@ paths: Create an asynchronous AIGC task for resource-intensive generation and media-processing operations such as image generation, video generation, background removal, and video frame extraction. - Task-based generation and media-processing operations are processed asynchronously. Use the returned task ID to - poll for status via `GET /aigc/tasks/{taskID}` or subscribe to real-time updates via + Use the returned task ID to poll for status via `GET /aigc/tasks/{taskID}` or subscribe to real-time updates via `GET /aigc/tasks/{taskID}/events`. Supported task types: @@ -3624,7 +3732,7 @@ paths: - 0 - 1500 duration: - description: Duration in milliseconds of the segment to extract frames from. Precision is 100ms. + description: Segment extraction duration in milliseconds. Precision is 100ms. type: integer minimum: 0 multipleOf: 100 @@ -3684,23 +3792,13 @@ paths: schema: $ref: "#/components/schemas/AIGCTask" "400": - description: Invalid task type or parameters. + $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "403": - description: Insufficient permissions or quota. - headers: - Retry-After: - description: Seconds to wait before retrying after hitting the long-window quota limit. - schema: - type: integer + $ref: "#/components/responses/ForbiddenOrQuotaExceeded" "429": - description: Too many requests. - headers: - Retry-After: - description: Seconds to wait before retrying after hitting the short-window rate limit. - schema: - type: integer + $ref: "#/components/responses/RateLimitExceeded" /aigc/tasks/{taskID}: get: @@ -3730,7 +3828,7 @@ paths: "401": $ref: "#/components/responses/Unauthorized" "404": - description: Task not found. + $ref: "#/components/responses/NotFound" /aigc/tasks/{taskID}/events: get: @@ -3828,7 +3926,7 @@ paths: "401": $ref: "#/components/responses/Unauthorized" "404": - description: Task not found. + $ref: "#/components/responses/NotFound" /aigc/tasks/{taskID}/cancellation: put: @@ -3839,9 +3937,6 @@ paths: description: | Cancel a pending or processing AIGC task. - If subscribed via `GET /aigc/tasks/{taskID}/events`, a `cancelling` event will be emitted when cancellation - begins, followed by a `cancelled` event when complete. - Cancellation rules: | Current Status | Result | @@ -3857,6 +3952,9 @@ paths: - Tasks cancelled from `pending` status receive a full quota refund. - Tasks cancelled from `processing` status do not receive a refund because resources are already consumed. + + If subscribed via `GET /aigc/tasks/{taskID}/events`, a `cancelling` event will be emitted when cancellation + begins, followed by a `cancelled` event when complete. parameters: - name: taskID description: The task ID to cancel. @@ -3874,9 +3972,9 @@ paths: "401": $ref: "#/components/responses/Unauthorized" "404": - description: Task not found. + $ref: "#/components/responses/NotFound" "409": - description: Task cannot be cancelled because it has already completed or failed. + $ref: "#/components/responses/Conflict" /aigc/project-descriptions: post: @@ -3885,15 +3983,16 @@ paths: - AIGC summary: Generate project description description: | - Generate an AI-powered descriptive summary of a game from the player's perspective based on provided game content - such as spx source code and project structure. + Generate an AI-powered descriptive summary of a game from the player's perspective based on provided game + content such as spx source code and project structure. Quota and rate limits: - Each request consumes 1 quota from the authenticated user's AI description allowance. - Per-user short-window rate limits also apply to prevent bursts. Hitting them returns 429 with `Retry-After`. - Quota limits vary based on the authenticated user's plan. - - When the long-window quota limit is reached, the 403 response includes a `Retry-After` header with the wait time in seconds. + - When the long-window quota limit is reached, the 403 response includes a `Retry-After` header with the wait + time in seconds. requestBody: required: true content: @@ -3904,7 +4003,7 @@ paths: - content properties: content: - description: The game content to generate a description for, such as spx source code or project structure. + description: Game content to summarize, such as spx source code or project structure. type: string minLength: 1 maxLength: 150000 @@ -3948,26 +4047,18 @@ paths: examples: - | This is a Tic-Tac-Toe game played on a 3x3 grid. The player uses `X` marks while the AI opponent - uses `O` marks. Players take turns placing their marks in empty squares, with the goal of getting - three marks in a row horizontally, vertically, or diagonally. The game supports mouse click - interaction. Click on an empty square to place your mark. The game ends when either player + uses `O` marks. Players take turns placing their marks in empty squares, with the goal of + getting three marks in a row horizontally, vertically, or diagonally. The game supports mouse + click interaction. Click on an empty square to place your mark. The game ends when either player achieves three in a row or the board is completely filled. + "400": + $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "403": - description: Insufficient permissions or quota to perform this operation. - headers: - Retry-After: - description: Seconds to wait before retrying after hitting the long-window quota limit. - schema: - type: integer + $ref: "#/components/responses/ForbiddenOrQuotaExceeded" "429": - description: Too many requests to perform this operation. - headers: - Retry-After: - description: Seconds to wait before retrying after hitting the short-window rate limit. - schema: - type: integer + $ref: "#/components/responses/RateLimitExceeded" /aigc/asset-descriptions: post: @@ -3979,13 +4070,15 @@ paths: Generate a description for an asset using AI based on the provided text prompt and image input. This endpoint accepts multimodal input to generate a contextual description suitable for embedding and search - operations. The text prompt typically includes the asset's display name and additional context about how the - description will be used. + operations. + + The text prompt typically includes the asset's display name and additional context about how the description + will be used. Image requirements: - Format: PNG only (`image/png`) - - Maximum image size: 5 MiB (5242880 bytes) after decoding the base64 content + - Maximum image size: 5 MiB after decoding the base64 content - Images must be provided as base64-encoded data URLs - Other formats must be converted to PNG before sending @@ -4041,23 +4134,13 @@ paths: examples: - "A powerful rotating column of air extending from a thunderstorm to the ground, characterized by its distinctive funnel shape and destructive force." "400": - description: Invalid request parameters. + $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "403": - description: Insufficient permissions or quota to perform this operation. - headers: - Retry-After: - description: Seconds to wait before retrying after hitting the long-window quota limit. - schema: - type: integer + $ref: "#/components/responses/ForbiddenOrQuotaExceeded" "429": - description: Too many requests to perform this operation. - headers: - Retry-After: - description: Seconds to wait before retrying after hitting the short-window rate limit. - schema: - type: integer + $ref: "#/components/responses/RateLimitExceeded" /aigc/asset-adoptions: post: @@ -4067,6 +4150,7 @@ paths: summary: Adopt an AIGC-generated asset description: | Record that an AIGC-generated asset has been adopted. + The backend verifies task ownership and file provenance and may publish the adopted asset to the public library. requestBody: required: true @@ -4119,11 +4203,11 @@ paths: "204": description: Asset adoption recorded. "400": - description: Invalid parameters. + $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "403": - description: Insufficient permissions for given tasks. + $ref: "#/components/responses/Forbidden" /aigc/asset-settings-enrichments: post: @@ -4215,8 +4299,9 @@ paths: lang: description: | Language the AI should use for all generated text. - When provided, the output strictly follows this language regardless of the input language. - When omitted, the AI infers language from the user input. + + - When provided, the output strictly follows this language regardless of the input language + - When omitted, the AI infers language from the user input type: string enum: - en @@ -4232,22 +4317,14 @@ paths: - $ref: "#/components/schemas/AIGCCostumeSettings" - $ref: "#/components/schemas/AIGCAnimationSettings" - $ref: "#/components/schemas/AIGCBackdropSettings" + "400": + $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "403": - description: Insufficient permissions or quota. - headers: - Retry-After: - description: Seconds to wait before retrying after hitting the long-window quota limit. - schema: - type: integer + $ref: "#/components/responses/ForbiddenOrQuotaExceeded" "429": - description: Too many requests. - headers: - Retry-After: - description: Seconds to wait before retrying after hitting the short-window rate limit. - schema: - type: integer + $ref: "#/components/responses/RateLimitExceeded" /aigc/sprite-content-settings: post: @@ -4276,8 +4353,9 @@ paths: lang: description: | Language the AI should use for all generated text. - When provided, the output strictly follows this language regardless of the input language. - When omitted, the AI infers language from the user input. + + - When provided, the output strictly follows this language regardless of the input language + - When omitted, the AI infers language from the user input type: string enum: - en @@ -4307,8 +4385,12 @@ paths: animationBindings: description: | Recommended mapping from sprite state enum values to animation names. - Keys are sprite state enum values; values are animation name strings that should match the `name` - field from the animations array, omitted if no animation is bound to that state. + + Keys and values: + + - Keys are sprite state enum values + - Values are animation name strings that should match the `name` field from the animations array + - Omit a state when no animation is bound to it type: object additionalProperties: false properties: @@ -4321,22 +4403,14 @@ paths: example: default: "idle" step: "walk" + "400": + $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "403": - description: Insufficient permissions or quota. - headers: - Retry-After: - description: Seconds to wait before retrying after hitting the long-window quota limit. - schema: - type: integer + $ref: "#/components/responses/ForbiddenOrQuotaExceeded" "429": - description: Too many requests. - headers: - Retry-After: - description: Seconds to wait before retrying after hitting the short-window rate limit. - schema: - type: integer + $ref: "#/components/responses/RateLimitExceeded" /upload-sessions: post: @@ -4404,6 +4478,8 @@ paths: format: uri examples: - kodo://xbuilder-usercontent-test/files/FjgMMuSaAsRWx1t7UAQnQ5r4YsAe-195: https://xbuilder-usercontent-test.gopluscdn.com/files/FjgMMuSaAsRWx1t7UAQnQ5r4YsAe-195?e=1727658000&token=t_6AOOkCdDu4m7fPleblcK0gMBZfbGQzeEIVt5Au:EMJdLqcCWrqQ5pRd01diOv7nhQw= + "400": + $ref: "#/components/responses/BadRequest" /project-conversions: post: @@ -4432,7 +4508,7 @@ paths: description: | Scratch project file, such as .sb2, .sb3, or project zip. Form field name must be `file`. - Maximum size is 64 MiB (67108864 bytes). + Maximum size is 64 MiB. type: string format: binary responses: @@ -4446,6 +4522,12 @@ paths: examples: xbpFile: summary: Example XBP zip file + "400": + $ref: "#/components/responses/BadRequest" + "413": + $ref: "#/components/responses/ContentTooLarge" + "501": + $ref: "#/components/responses/UnsupportedScratchFeature" /admin/account/users: get: @@ -4497,10 +4579,12 @@ paths: type: array items: $ref: "#/components/schemas/AccountUser" + "400": + $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "403": - description: Insufficient permissions to perform this operation. + $ref: "#/components/responses/Forbidden" post: operationId: createAdminAccountUser tags: @@ -4536,10 +4620,12 @@ paths: application/json: schema: $ref: "#/components/schemas/AccountUser" + "400": + $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "403": - description: Insufficient permissions to perform this operation. + $ref: "#/components/responses/Forbidden" /admin/account/users/{userID}: get: @@ -4565,12 +4651,14 @@ paths: application/json: schema: $ref: "#/components/schemas/AccountUser" + "400": + $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "403": - description: Insufficient permissions to perform this operation. + $ref: "#/components/responses/Forbidden" "404": - description: Account user not found. + $ref: "#/components/responses/NotFound" patch: operationId: updateAdminAccountUser tags: @@ -4604,12 +4692,14 @@ paths: application/json: schema: $ref: "#/components/schemas/AccountUser" + "400": + $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "403": - description: Insufficient permissions to perform this operation. + $ref: "#/components/responses/Forbidden" "404": - description: Account user not found. + $ref: "#/components/responses/NotFound" /admin/account/users/{userID}/avatar: put: @@ -4641,7 +4731,7 @@ paths: description: | Avatar image file. - Maximum size is 5 MiB (5242880 bytes). + Maximum size is 5 MiB. type: string format: binary encoding: @@ -4651,17 +4741,17 @@ paths: "204": description: Account user avatar updated. "400": - description: Invalid request parameters. + $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "403": - description: Insufficient permissions to perform this operation. + $ref: "#/components/responses/Forbidden" "404": - description: Account user not found. + $ref: "#/components/responses/NotFound" "413": - description: Avatar image file is too large. + $ref: "#/components/responses/ContentTooLarge" "415": - description: Avatar image file type is not supported. + $ref: "#/components/responses/UnsupportedMediaType" /admin/account/users/{userID}/password: put: @@ -4697,12 +4787,14 @@ paths: responses: "204": description: Password credential set. + "400": + $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "403": - description: Insufficient permissions to perform this operation. + $ref: "#/components/responses/Forbidden" "404": - description: Account user not found. + $ref: "#/components/responses/NotFound" delete: operationId: deleteAdminAccountUserPassword tags: @@ -4722,12 +4814,14 @@ paths: responses: "204": description: Password credential deleted. + "400": + $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "403": - description: Insufficient permissions to perform this operation. + $ref: "#/components/responses/Forbidden" "404": - description: Account user not found. + $ref: "#/components/responses/NotFound" /admin/account/users/{userID}/identities: get: @@ -4765,12 +4859,14 @@ paths: type: array items: $ref: "#/components/schemas/AccountUserIdentity" + "400": + $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "403": - description: Insufficient permissions to perform this operation. + $ref: "#/components/responses/Forbidden" "404": - description: Account user not found. + $ref: "#/components/responses/NotFound" /admin/account/users/{userID}/identities/{identityID}: delete: @@ -4798,12 +4894,14 @@ paths: responses: "204": description: Account user identity deleted. + "400": + $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "403": - description: Insufficient permissions to perform this operation. + $ref: "#/components/responses/Forbidden" "404": - description: Account user or identity not found. + $ref: "#/components/responses/NotFound" /admin/account/users/{userID}/sessions: get: @@ -4841,12 +4939,14 @@ paths: type: array items: $ref: "#/components/schemas/AccountSession" + "400": + $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "403": - description: Insufficient permissions to perform this operation. + $ref: "#/components/responses/Forbidden" "404": - description: Account user not found. + $ref: "#/components/responses/NotFound" delete: operationId: deleteAdminAccountUserSessions tags: @@ -4866,12 +4966,14 @@ paths: responses: "204": description: Account user sessions deleted. + "400": + $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "403": - description: Insufficient permissions to perform this operation. + $ref: "#/components/responses/Forbidden" "404": - description: Account user not found. + $ref: "#/components/responses/NotFound" /admin/account/users/{userID}/app-grants: get: @@ -4922,12 +5024,14 @@ paths: type: array items: $ref: "#/components/schemas/AccountAppGrant" + "400": + $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "403": - description: Insufficient permissions to perform this operation. + $ref: "#/components/responses/Forbidden" "404": - description: Account user not found. + $ref: "#/components/responses/NotFound" /admin/account/app-grants/{appGrantID}: get: @@ -4953,12 +5057,14 @@ paths: application/json: schema: $ref: "#/components/schemas/AccountAppGrant" + "400": + $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "403": - description: Insufficient permissions to perform this operation. + $ref: "#/components/responses/Forbidden" "404": - description: Account app grant not found. + $ref: "#/components/responses/NotFound" /admin/account/app-grants/{appGrantID}/tokens: get: @@ -5014,12 +5120,14 @@ paths: type: array items: $ref: "#/components/schemas/AccountAppToken" + "400": + $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "403": - description: Insufficient permissions to perform this operation. + $ref: "#/components/responses/Forbidden" "404": - description: Account app grant not found. + $ref: "#/components/responses/NotFound" post: operationId: createAdminAccountAppGrantToken tags: @@ -5028,10 +5136,12 @@ paths: description: | Create an Account-issued OAuth token associated with an account app grant. + The token scope is an Account OAuth scope inherited from the app grant. + Currently, this endpoint creates access tokens only. The created token is not issued with a refresh token and - cannot be refreshed. The token value is only returned once in the creation response. + cannot be refreshed. - The token scope is an Account OAuth scope inherited from the app grant. + The token value is only returned once in the creation response. Requires the `accountAdmin` role. parameters: @@ -5055,13 +5165,13 @@ paths: schema: $ref: "#/components/schemas/CreatedAccountAppToken" "400": - description: Invalid request parameters. + $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "403": - description: Insufficient permissions to perform this operation. + $ref: "#/components/responses/Forbidden" "404": - description: Account app grant not found. + $ref: "#/components/responses/NotFound" /admin/account/app-grants/{appGrantID}/tokens/{tokenID}: delete: @@ -5073,7 +5183,7 @@ paths: Revoke an Account-issued OAuth token associated with an account app grant. Revocation follows the existing Account OAuth token semantics. Revoking a refresh token follows refresh-token - rotation semantics; revoking an access token revokes that access token. + rotation semantics, and revoking an access token revokes that access token. Requires the `accountAdmin` role. parameters: @@ -5092,12 +5202,14 @@ paths: responses: "204": description: Account app grant token revoked. + "400": + $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "403": - description: Insufficient permissions to perform this operation. + $ref: "#/components/responses/Forbidden" "404": - description: Account app grant or token not found. + $ref: "#/components/responses/NotFound" /admin/account/sessions/{sessionID}: delete: @@ -5119,12 +5231,14 @@ paths: responses: "204": description: Account session deleted. + "400": + $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "403": - description: Insufficient permissions to perform this operation. + $ref: "#/components/responses/Forbidden" "404": - description: Account session not found. + $ref: "#/components/responses/NotFound" /admin/account/apps: get: @@ -5168,10 +5282,12 @@ paths: type: array items: $ref: "#/components/schemas/AccountApp" + "400": + $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "403": - description: Insufficient permissions to perform this operation. + $ref: "#/components/responses/Forbidden" post: operationId: createAdminAccountApp tags: @@ -5210,10 +5326,12 @@ paths: application/json: schema: $ref: "#/components/schemas/AccountApp" + "400": + $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "403": - description: Insufficient permissions to perform this operation. + $ref: "#/components/responses/Forbidden" /admin/account/apps/{appID}: get: @@ -5239,12 +5357,14 @@ paths: application/json: schema: $ref: "#/components/schemas/AccountApp" + "400": + $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "403": - description: Insufficient permissions to perform this operation. + $ref: "#/components/responses/Forbidden" "404": - description: Account app not found. + $ref: "#/components/responses/NotFound" patch: operationId: updateAdminAccountApp tags: @@ -5284,12 +5404,14 @@ paths: application/json: schema: $ref: "#/components/schemas/AccountApp" + "400": + $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "403": - description: Insufficient permissions to perform this operation. + $ref: "#/components/responses/Forbidden" "404": - description: Account app not found. + $ref: "#/components/responses/NotFound" /admin/account/apps/{appID}/secrets: get: @@ -5327,12 +5449,14 @@ paths: type: array items: $ref: "#/components/schemas/AccountAppSecret" + "400": + $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "403": - description: Insufficient permissions to perform this operation. + $ref: "#/components/responses/Forbidden" "404": - description: Account app not found. + $ref: "#/components/responses/NotFound" post: operationId: createAdminAccountAppSecret tags: @@ -5367,12 +5491,14 @@ paths: application/json: schema: $ref: "#/components/schemas/CreatedAccountAppSecret" + "400": + $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "403": - description: Insufficient permissions to perform this operation. + $ref: "#/components/responses/Forbidden" "404": - description: Account app not found. + $ref: "#/components/responses/NotFound" /admin/account/apps/{appID}/secrets/{secretID}: delete: @@ -5400,12 +5526,14 @@ paths: responses: "204": description: Account app secret deleted. + "400": + $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "403": - description: Insufficient permissions to perform this operation. + $ref: "#/components/responses/Forbidden" "404": - description: Account app or secret not found. + $ref: "#/components/responses/NotFound" /admin/authorization/users/{userID}: get: @@ -5431,12 +5559,14 @@ paths: application/json: schema: $ref: "#/components/schemas/UserAuthorization" + "400": + $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "403": - description: Insufficient permissions to perform this operation. + $ref: "#/components/responses/Forbidden" "404": - description: User authorization not found. + $ref: "#/components/responses/NotFound" patch: operationId: updateAdminUserAuthorization tags: @@ -5472,12 +5602,14 @@ paths: application/json: schema: $ref: "#/components/schemas/UserAuthorization" + "400": + $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "403": - description: Insufficient permissions to perform this operation. + $ref: "#/components/responses/Forbidden" "404": - description: User authorization not found. + $ref: "#/components/responses/NotFound" /admin/audit-logs: get: @@ -5532,10 +5664,12 @@ paths: type: array items: $ref: "#/components/schemas/AuditLog" + "400": + $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "403": - description: Insufficient permissions to perform this operation. + $ref: "#/components/responses/Forbidden" components: securitySchemes: @@ -5584,21 +5718,231 @@ components: example: no-cache ResourceMoved: description: | - Requested resource moved to its canonical route. + Requested resource moved to its canonical route. Returned on non-`GET` and non-`HEAD` requests. - Returned on non-`GET` and non-`HEAD` requests. + The response includes a `canonical` payload with the canonical route. Clients may update the request and + resubmit it explicitly. + content: + application/json: + schema: + $ref: "#/components/schemas/MovedResourceError" + BadRequest: + description: Invalid request syntax, parameters, or body. + content: + application/json: + schema: + $ref: "#/components/schemas/APIError" + examples: + invalidArgs: + value: + code: 40001 + msg: Invalid args + BadRequestForIdentityProviderFormPostCallback: + description: | + Invalid identity provider form-post callback request. - The response includes a `canonical` payload with the canonical route. + Response body shapes: - Clients may update the request and resubmit it explicitly. + - `APIError` for path, provider error, and callback parameter validation failures + - `OAuthError` for malformed form bodies parsed by the OAuth form parser content: application/json: schema: - $ref: "#/components/schemas/MovedResourceError" + oneOf: + - $ref: "#/components/schemas/APIError" + - $ref: "#/components/schemas/OAuthError" + examples: + invalidCallbackRequest: + summary: Invalid callback request + value: + code: 40001 + msg: invalid provider + invalidFormBody: + summary: Malformed form body + value: + error: invalid_request + error_description: invalid form body Unauthorized: - description: Unauthorized. Authentication required. + description: Authentication is required or the supplied bearer token is invalid. + headers: + WWW-Authenticate: + description: Authentication challenge. + schema: + type: string + examples: + - Bearer + content: + application/json: + schema: + $ref: "#/components/schemas/APIError" + examples: + unauthorized: + value: + code: 40100 + msg: Unauthorized + Forbidden: + description: The authenticated principal does not have permission to perform this operation. + content: + application/json: + schema: + $ref: "#/components/schemas/APIError" + examples: + forbidden: + value: + code: 40300 + msg: Forbidden + QuotaExceeded: + description: The authenticated user exceeded a long-window quota for this operation. + headers: + Retry-After: + description: Seconds to wait before retrying. + schema: + type: integer + format: int64 + minimum: 1 + content: + application/json: + schema: + $ref: "#/components/schemas/APIError" + examples: + quotaExceeded: + value: + code: 40301 + msg: aigc:image quota exceeded + ForbiddenOrQuotaExceeded: + description: | + The authenticated principal does not have permission to perform this operation, or a long-window quota was + exceeded. + + When `code` is `40301`, `Retry-After` is included if the server can compute a retry time. + headers: + Retry-After: + description: Seconds to wait before retrying after a quota failure. + schema: + type: integer + format: int64 + minimum: 1 + content: + application/json: + schema: + $ref: "#/components/schemas/APIError" + examples: + forbidden: + value: + code: 40300 + msg: Forbidden + quotaExceeded: + value: + code: 40301 + msg: aigc:image quota exceeded + NotFound: + description: The requested resource was not found. + content: + application/json: + schema: + $ref: "#/components/schemas/APIError" + examples: + notFound: + value: + code: 40400 + msg: Not found + Conflict: + description: The request conflicts with the current resource state. + content: + application/json: + schema: + $ref: "#/components/schemas/APIError" + examples: + conflict: + value: + code: 40900 + msg: Conflict + ContentTooLarge: + description: The request or uploaded content exceeds the allowed size. + content: + application/json: + schema: + $ref: "#/components/schemas/APIError" + examples: + contentTooLarge: + value: + code: 41300 + msg: Content too large + UnsupportedMediaType: + description: The request or uploaded content type is not supported. + content: + application/json: + schema: + $ref: "#/components/schemas/APIError" + examples: + unsupportedMediaType: + value: + code: 41500 + msg: Unsupported media type + TooManyRequests: + description: Too many requests. + content: + application/json: + schema: + $ref: "#/components/schemas/APIError" + examples: + tooManyRequests: + value: + code: 42900 + msg: Too many requests + RateLimitExceeded: + description: The authenticated user exceeded a short-window rate limit for this operation. + headers: + Retry-After: + description: Seconds to wait before retrying. + schema: + type: integer + format: int64 + minimum: 1 + content: + application/json: + schema: + $ref: "#/components/schemas/APIError" + examples: + rateLimitExceeded: + value: + code: 42901 + msg: aigc:image rate limit exceeded + InternalServerError: + description: An internal server error occurred. + content: + application/json: + schema: + $ref: "#/components/schemas/APIError" + examples: + internalServerError: + value: + code: 50000 + msg: Internal server error + UnsupportedScratchFeature: + description: The uploaded Scratch project uses a feature that cannot be converted. + content: + application/json: + schema: + $ref: "#/components/schemas/APIError" + examples: + unsupportedScratchFeature: + value: + code: 50101 + msg: Unsupported scratch feature OAuthError: description: OAuth error response. + headers: + Cache-Control: + description: OAuth error responses are not cacheable. + schema: + type: string + example: no-store + Pragma: + description: OAuth error responses are not cacheable by HTTP/1.0 caches. + schema: + type: string + example: no-cache content: application/json: schema: @@ -5606,12 +5950,22 @@ components: OAuthInvalidClient: description: OAuth client authentication failed. headers: + Cache-Control: + description: OAuth error responses are not cacheable. + schema: + type: string + example: no-store + Pragma: + description: OAuth error responses are not cacheable by HTTP/1.0 caches. + schema: + type: string + example: no-cache WWW-Authenticate: description: Client authentication challenge. schema: type: string examples: - - Basic realm="XBuilder Account" + - Basic realm="XBuilder Account", error="invalid_client" content: application/json: schema: @@ -5626,29 +5980,86 @@ components: examples: - Bearer realm="XBuilder Account", error="insufficient_scope", scope="account:user:read" - Bearer realm="XBuilder Account", error="insufficient_scope", scope="account:user:write" - OAuthUnsupportedMediaType: - description: | - OAuth form request content type is not supported. - - The server returns an OAuth `invalid_request` error before reading form-body OAuth parameters. content: application/json: schema: - $ref: "#/components/schemas/OAuthError" + $ref: "#/components/schemas/APIError" + examples: + insufficientScope: + value: + code: 40300 + msg: Forbidden + AccountForbidden: + description: | + Account request is forbidden. + + Bearer-token requests may lack the required scope. Cookie-authenticated mutation requests may fail Origin + validation. + headers: + WWW-Authenticate: + description: Bearer token insufficient scope challenge, when bearer authentication was used. + schema: + type: string + examples: + - Bearer realm="XBuilder Account", error="insufficient_scope", scope="account:user:write" + content: + application/json: + schema: + $ref: "#/components/schemas/APIError" + examples: + forbidden: + value: + code: 40300 + msg: Forbidden + OAuthUnsupportedMediaType: + description: | + OAuth form request content type is not supported. + + The server returns an OAuth `invalid_request` error before reading form-body OAuth parameters. + headers: + Cache-Control: + description: OAuth error responses are not cacheable. + schema: + type: string + example: no-store + Pragma: + description: OAuth error responses are not cacheable by HTTP/1.0 caches. + schema: + type: string + example: no-cache + content: + application/json: + schema: + $ref: "#/components/schemas/OAuthError" AccountUnauthorized: description: | Account authentication is required. - The request may authenticate with an account session cookie or an OAuth bearer token. When OAuth bearer token - authentication fails, the response includes `WWW-Authenticate`. + The request may authenticate with an account session cookie or an OAuth bearer token. + + The response includes `WWW-Authenticate`. + + Challenge values: + + - Missing credentials use the generic `Bearer` challenge + - Invalid bearer tokens use a realm-specific `invalid_token` challenge headers: WWW-Authenticate: - description: Bearer token challenge returned for OAuth bearer token authentication failures. + description: Bearer token challenge. schema: type: string examples: - - Bearer realm="XBuilder Account" + - Bearer - Bearer realm="XBuilder Account", error="invalid_token" + content: + application/json: + schema: + $ref: "#/components/schemas/APIError" + examples: + unauthorized: + value: + code: 40100 + msg: Unauthorized schemas: Model: @@ -5680,15 +6091,33 @@ components: examples: - 2006-01-02T15:04:05+07:00 + ByPage: + description: Paginated response wrapper. + type: object + required: + - total + - data + properties: + total: + description: Total number of result items in the result set. + type: integer + format: int64 + minimum: 0 + data: + description: Array of result items. + type: array + items: + type: object + MovedResourceCanonical: description: | Canonical route information for a moved resource. - User routes include `path` and `username`. + Route fields: - Project routes include `path`, `owner`, and `name`. - - Project release routes include `path`, `owner`, `name`, and `release`. + - User routes include `path` and `username` + - Project routes include `path`, `owner`, and `name` + - Project release routes include `path`, `owner`, `name`, and `release` type: object required: - path @@ -5721,8 +6150,74 @@ components: examples: - v1.2.3 + ErrorCode: + description: | + Stable machine-readable error code for the shared `APIError` envelope. + + The first three digits match the HTTP status code. Clients should branch on this field instead of parsing + `msg`. + + | Code | HTTP status | Default msg | Meaning | + |------|-------------|-------------|---------| + | 40001 | 400 | Invalid args | Invalid request syntax, parameters, or body. | + | 40100 | 401 | Unauthorized | Authentication is required or invalid. | + | 40300 | 403 | Forbidden | The authenticated principal lacks permission. | + | 40301 | 403 | Quota exceeded | A long-window quota was exceeded. | + | 40400 | 404 | Not found | The requested resource was not found. | + | 40900 | 409 | Conflict | The request conflicts with current resource state. | + | 40901 | 409 | Resource moved | The request used a historical resource route. | + | 41300 | 413 | Content too large | Request or uploaded content exceeds the allowed size. | + | 41500 | 415 | Unsupported media type | Request or uploaded content type is not supported. | + | 42900 | 429 | Too many requests | Generic request throttling. | + | 42901 | 429 | Rate limit exceeded | A short-window rate limit was exceeded. | + | 50000 | 500 | Internal server error | An unexpected server-side failure occurred. | + | 50101 | 501 | Unsupported scratch feature | Scratch conversion hit an unsupported feature. | + type: integer + format: int32 + enum: + - 40001 + - 40100 + - 40300 + - 40301 + - 40400 + - 40900 + - 40901 + - 41300 + - 41500 + - 42900 + - 42901 + - 50000 + - 50101 + examples: + - 40001 + + APIError: + description: | + Shared error payload for most non-OAuth JSON endpoints. + + `msg` is a safe, developer-facing diagnostic string. It is not localized and is not stable enough for + programmatic handling. + + User-facing clients should map `code` to their own localized copy. + type: object + required: + - code + - msg + properties: + code: + $ref: "#/components/schemas/ErrorCode" + msg: + description: Safe developer-facing diagnostic message. + type: string + minLength: 1 + examples: + - Invalid args + MovedResourceError: - description: Error payload for a moved resource route. + description: | + Error payload for a moved resource route. + + This follows the `APIError` envelope and adds `canonical` route metadata. type: object required: - code @@ -5792,11 +6287,12 @@ components: examples: - John Doe avatar: - description: Public URL or universal URL of the user's avatar image. + description: Public HTTP URL or Kodo universal URL of the user's avatar image. type: string format: uri examples: - https://avatars.example.com/users/10137.png + - kodo://xbuilder-usercontent-test/files/avatar.png description: description: Brief bio or description of the user. type: string @@ -5853,14 +6349,6 @@ components: **This field is only present for the authenticated user's own profile.** $ref: "#/components/schemas/UserCapabilities" - AuthenticatedUser: - description: Authenticated user account information. - allOf: - - $ref: "#/components/schemas/User" - - type: object - required: - - capabilities - UserCapabilities: type: object description: User permission capabilities. @@ -5897,6 +6385,14 @@ components: examples: - true + AuthenticatedUser: + description: Authenticated user account information. + allOf: + - $ref: "#/components/schemas/User" + - type: object + required: + - capabilities + AccountUser: description: User fields exposed by XBuilder Account. type: object @@ -6199,6 +6695,19 @@ components: examples: - production + CreatedAccountAppSecret: + description: OAuth client secret created response including the one-time secret value. + allOf: + - $ref: "#/components/schemas/AccountAppSecret" + - type: object + required: + - value + properties: + value: + description: One-time app secret value. + type: string + minLength: 1 + AccountAppGrant: type: object description: Authorization grant from an account user to an account app. @@ -6369,19 +6878,6 @@ components: examples: - xb_at_2L5f8xQy... - CreatedAccountAppSecret: - description: OAuth client secret created response including the one-time secret value. - allOf: - - $ref: "#/components/schemas/AccountAppSecret" - - type: object - required: - - value - properties: - value: - description: One-time app secret value. - type: string - minLength: 1 - OAuthClientID: description: OAuth client ID of the app. type: string @@ -6889,7 +7385,7 @@ components: description: Full name of the project that the release is associated with, in the format `owner/project`. $ref: "#/components/schemas/ProjectFullName" name: - description: Unique name of the project release, adhering to [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0.html). + description: Project release name following [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0.html). type: string maxLength: 255 pattern: '^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$' @@ -7248,6 +7744,49 @@ components: content: $ref: "#/components/schemas/CopilotMessageContent" + CopilotTool: + description: Tool available for copilot to use. + type: object + required: + - type + - function + properties: + type: + description: Type of the tool. + type: string + enum: + - function + examples: + - function + function: + type: object + required: + - name + properties: + name: + description: Name of the function. + type: string + maxLength: 64 + pattern: "^[a-zA-Z0-9_-]{1,64}$" + examples: + - create_project + description: + description: Description of the function. + type: string + examples: + - create project + parameters: + description: JSON Schema object that describes the function parameters. + type: object + examples: + - type: object + properties: + name: + type: string + required: + - name + additionalProperties: false + CopilotMessageRequest: description: Request payload for copilot message generation endpoints. type: object @@ -7429,49 +7968,6 @@ components: examples: - stream failed - CopilotTool: - description: Tool available for copilot to use. - type: object - required: - - type - - function - properties: - type: - description: Type of the tool. - type: string - enum: - - function - examples: - - function - function: - type: object - required: - - name - properties: - name: - description: Name of the function. - type: string - maxLength: 64 - pattern: "^[a-zA-Z0-9_-]{1,64}$" - examples: - - create_project - description: - description: Description of the function. - type: string - examples: - - create project - parameters: - description: JSON Schema object that describes the function parameters. - type: object - examples: - - type: object - properties: - name: - type: string - required: - - name - additionalProperties: false - AIInteractionTurn: description: Single turn in an AI interaction session. type: object @@ -7744,7 +8240,7 @@ components: - type: object properties: referenceImageUrl: - description: Universal URL of the reference image to guide the costume generation (e.g. kodo://bucket/key). + description: Universal URL of the costume reference image (e.g. kodo://bucket/key). type: - string - "null" @@ -7768,7 +8264,7 @@ components: - loopMode properties: referenceFrameUrl: - description: Universal URL of the reference frame image to guide the animation generation (e.g. kodo://bucket/key). + description: Universal URL of the animation reference frame image (e.g. kodo://bucket/key). type: - string - "null" @@ -7793,44 +8289,6 @@ components: - ui - unspecified - UpInfo: - description: Upload credentials and configuration. - type: object - required: - - token - - expiresAt - - maxSize - - bucket - - region - properties: - token: - description: Upload token used for authenticating upload requests. - type: string - examples: - - MY_ACCESS_KEY:wQ4ofysef1R7IKnrziqtomqyDvI=:eyJzY29wZSI6Im15LWJ1Y2tldDpzdW5mbG93ZXIuanBnIiwiZGVhZGxpbmUiOjE0NTE0OTEyMDAsInJldHVybkJvZHkiOiJ7XCJuYW1lXCI6JChmbmFtZSksXCJzaXplXCI6JChmc2l6ZSksXCJ3XCI6JChpbWFnZUluZm8ud2lkdGgpLFwiaFwiOiQoaW1hZ2VJbmZvLmhlaWdodCksXCJoYXNoXCI6JChldGFnKX0ifQ== - expiresAt: - description: Expiration timestamp of the upload token. - type: string - format: date-time - examples: - - 2006-01-02T15:04:05+07:00 - maxSize: - description: Maximum allowed file size in bytes. - type: integer - format: int64 - examples: - - 26214400 - bucket: - description: Name of the Qiniu Kodo bucket where files will be uploaded. - type: string - examples: - - xbuilder-usercontent-test - region: - description: Region of the Qiniu Kodo bucket. - type: string - examples: - - z0 - InputText: description: Text input for multimodal content. type: object @@ -7873,12 +8331,11 @@ components: description: | Base64-encoded PNG image data URL. - All images must be provided as PNG format. If you have images in other formats (SVG, JPEG, WebP), - convert them to PNG before sending. - - Maximum image size: 5 MiB (5242880 bytes) after decoding the base64 content. + Data URL requirements: - Format: `data:image/png;base64,` + - Format: `data:image/png;base64,` + - Maximum image size: 5 MiB after decoding the base64 content + - Other formats such as SVG, JPEG, or WebP must be converted to PNG before sending type: string format: uri minLength: 1 @@ -7886,23 +8343,43 @@ components: examples: - "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" - ByPage: - description: Paginated response wrapper. + UpInfo: + description: Upload credentials and configuration. type: object required: - - total - - data + - token + - expiresAt + - maxSize + - bucket + - region properties: - total: - description: Total number of result items in the result set. + token: + description: Upload token used for authenticating upload requests. + type: string + examples: + - MY_ACCESS_KEY:wQ4ofysef1R7IKnrziqtomqyDvI=:eyJzY29wZSI6Im15LWJ1Y2tldDpzdW5mbG93ZXIuanBnIiwiZGVhZGxpbmUiOjE0NTE0OTEyMDAsInJldHVybkJvZHkiOiJ7XCJuYW1lXCI6JChmbmFtZSksXCJzaXplXCI6JChmc2l6ZSksXCJ3XCI6JChpbWFnZUluZm8ud2lkdGgpLFwiaFwiOiQoaW1hZ2VJbmZvLmhlaWdodCksXCJoYXNoXCI6JChldGFnKX0ifQ== + expiresAt: + description: Expiration timestamp of the upload token. + type: string + format: date-time + examples: + - 2006-01-02T15:04:05+07:00 + maxSize: + description: Maximum allowed file size in bytes. type: integer format: int64 - minimum: 0 - data: - description: Array of result items. - type: array - items: - type: object + examples: + - 26214400 + bucket: + description: Name of the Qiniu Kodo bucket where files will be uploaded. + type: string + examples: + - xbuilder-usercontent-test + region: + description: Region of the Qiniu Kodo bucket. + type: string + examples: + - z0 parameters: SortOrder: diff --git a/spx-gui/AGENTS.md b/spx-gui/AGENTS.md index e81023631..9cfb2cc1f 100644 --- a/spx-gui/AGENTS.md +++ b/spx-gui/AGENTS.md @@ -26,6 +26,15 @@ Keep import statements in order: 2. Internal libraries: from base to specific, e.g., from `utils` to `models` to `components` 3. Local files: relative paths starting with `./` or `../` +### API String Length Validation + +* For API string `minLength` / `maxLength` constraints from `docs/openapi.yaml`, use `getApiStringLength` from + `@/utils/utils` to count Unicode code points. Avoid `string.length`, HTML `maxlength`, or `z.string().max()` for + those code point limits. +* Count the exact string value submitted to the API. If an input is trimmed before submission, trim before counting. +* Exceptions: use byte or source-file-size checks for explicit byte or payload-size limits. For values proven ASCII-only + by a pattern or parser, `string.length` is acceptable. + ### Asset URLs * For widget-safe full asset URLs, use `new URL('...', import.meta.url).href` instead of `import x from './file.ext?url'`. diff --git a/spx-gui/src/apis/admin/account.ts b/spx-gui/src/apis/admin/account.ts index c3010b621..9efb39c7c 100644 --- a/spx-gui/src/apis/admin/account.ts +++ b/spx-gui/src/apis/admin/account.ts @@ -27,10 +27,11 @@ export type { CreatedAccountAppToken } from '@/apis/account/common' -type SortOrder = 'asc' | 'desc' - -/** Maximum allowed length for an app token name. */ export const accountAppTokenNameMaxLength = 100 +export const accountAppDisplayNameMaxLength = 100 +export const accountAppSecretNameMaxLength = 100 + +type SortOrder = 'asc' | 'desc' export type ListAccountUsersParams = PaginationParams & { /** Filter account users by username or display name pattern */ diff --git a/spx-gui/src/apis/asset.ts b/spx-gui/src/apis/asset.ts index 656ff4c56..5c9aab563 100644 --- a/spx-gui/src/apis/asset.ts +++ b/spx-gui/src/apis/asset.ts @@ -13,6 +13,8 @@ import { client, Visibility } from './common' export { Visibility } +export const assetDisplayNameMaxLength = 100 + export enum AssetType { Sprite = 'sprite', Backdrop = 'backdrop', diff --git a/spx-gui/src/apis/common/client.test.ts b/spx-gui/src/apis/common/client.test.ts index 81bf9c518..d2489af1e 100644 --- a/spx-gui/src/apis/common/client.test.ts +++ b/spx-gui/src/apis/common/client.test.ts @@ -1,41 +1,90 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { ApiException, ApiExceptionCode, isQuotaExceededMeta } from './exception' +import { ApiException, ApiExceptionCode, OAuthException, isQuotaExceededMeta, isRetryAfterMeta } from './exception' import { Client } from './client' +function makeJsonResponse(body: unknown, status: number, headers: Record = {}) { + return new Response(JSON.stringify(body), { + status, + headers: { + 'Content-Type': 'application/json', + ...headers + } + }) +} + function makeMovedResponse(canonicalPath: string) { - return new Response( - JSON.stringify({ + return makeJsonResponse( + { code: ApiExceptionCode.errorResourceMoved, msg: 'Resource moved', canonical: { path: canonicalPath } - }), - { - status: 409, - headers: { - 'Content-Type': 'application/json' - } - } + }, + 409 ) } function makeQuotaExceededResponse(retryAfter: string) { - return new Response( - JSON.stringify({ + return makeJsonResponse( + { code: ApiExceptionCode.errorQuotaExceeded, msg: 'Quota exceeded' - }), + }, + 403, { - status: 403, - headers: { - 'Content-Type': 'application/json', - 'Retry-After': retryAfter - } + 'Retry-After': retryAfter + } + ) +} + +function makeRateLimitExceededResponse(retryAfter: string) { + return makeJsonResponse( + { + code: ApiExceptionCode.errorRateLimitExceeded, + msg: 'Rate limit exceeded' + }, + 429, + { + 'Retry-After': retryAfter } ) } +function makeOAuthErrorResponse() { + return makeJsonResponse( + { + error: 'invalid_request', + error_description: 'invalid form body' + }, + 400 + ) +} + +async function expectApiException(promise: Promise, code: ApiExceptionCode) { + try { + await promise + } catch (e) { + expect(e).toBeInstanceOf(ApiException) + const exception = e as ApiException + expect(exception).toMatchObject({ code }) + return exception + } + throw new Error(`expected API error ${code}`) +} + +async function expectOAuthException(promise: Promise, error: string) { + try { + await promise + } catch (e) { + expect(e).toBeInstanceOf(OAuthException) + const exception = e as OAuthException + expect(exception).toMatchObject({ error }) + return exception + } + throw new Error(`expected OAuth error ${error}`) +} + describe('Client', () => { let client: Client let fetchMock: ReturnType> @@ -56,18 +105,10 @@ describe('Client', () => { it('should surface the moved conflict without retrying', async () => { fetchMock.mockResolvedValueOnce(makeMovedResponse('/projects/john/demo/views')) - try { - await client.post('/projects/John/demo/views') - throw new Error('expected moved conflict') - } catch (e) { - expect(e).toBeInstanceOf(ApiException) - expect(e).toMatchObject({ - code: ApiExceptionCode.errorResourceMoved, - meta: { - path: '/projects/john/demo/views' - } - }) - } + const e = await expectApiException(client.post('/projects/John/demo/views'), ApiExceptionCode.errorResourceMoved) + expect(e.meta).toMatchObject({ + path: '/projects/john/demo/views' + }) expect(fetchMock).toHaveBeenCalledTimes(1) expect(new URL((fetchMock.mock.calls[0]![0] as Request).url).pathname).toBe('/projects/John/demo/views') @@ -79,19 +120,48 @@ describe('Client', () => { const retryAfter = 'Wed, 09 Apr 2026 08:00:00 GMT' fetchMock.mockResolvedValueOnce(makeQuotaExceededResponse(retryAfter)) - try { - await client.get('/quota') - throw new Error('expected quota exceeded error') - } catch (e) { - expect(e).toBeInstanceOf(ApiException) - expect(e).toMatchObject({ - code: ApiExceptionCode.errorQuotaExceeded - }) - expect(isQuotaExceededMeta((e as ApiException).code, (e as ApiException).meta)).toBe(true) - expect((e as ApiException).meta).toMatchObject({ - retryAfter: new Date(retryAfter).valueOf() - }) - } + const e = await expectApiException(client.get('/quota'), ApiExceptionCode.errorQuotaExceeded) + expect(isQuotaExceededMeta(e.code, e.meta)).toBe(true) + expect(e.meta).toMatchObject({ + retryAfter: new Date(retryAfter).valueOf() + }) + }) + }) + + describe('retry-after metadata', () => { + it('should parse retry-after metadata for rate limits', async () => { + fetchMock.mockResolvedValueOnce(makeRateLimitExceededResponse('2')) + + const e = await expectApiException(client.get('/rate-limited'), ApiExceptionCode.errorRateLimitExceeded) + expect(isRetryAfterMeta(e.code, e.meta)).toBe(true) + expect(e.meta).toMatchObject({ + retryAfter: expect.any(Number) + }) + }) + + it('should treat empty retry-after headers as no retry time', async () => { + fetchMock.mockResolvedValueOnce(makeRateLimitExceededResponse(' ')) + + const e = await expectApiException(client.get('/rate-limited'), ApiExceptionCode.errorRateLimitExceeded) + expect(isRetryAfterMeta(e.code, e.meta)).toBe(true) + expect(e.meta).toMatchObject({ + retryAfter: null + }) + }) + + it('should reject retry-after metadata without a retryAfter field', () => { + expect(isRetryAfterMeta(ApiExceptionCode.errorRateLimitExceeded, {})).toBe(false) + }) + }) + + describe('OAuth errors', () => { + it('should parse OAuth error payloads', async () => { + fetchMock.mockResolvedValueOnce(makeOAuthErrorResponse()) + + const e = await expectOAuthException(client.postForm('/account/oauth/token', {}), 'invalid_request') + expect(e.errorDescription).toBe('invalid form body') + expect(e.errorUri).toBeNull() + expect(e.message).toContain('[invalid_request] invalid form body') }) }) diff --git a/spx-gui/src/apis/common/client.ts b/spx-gui/src/apis/common/client.ts index 36b383db0..24569d131 100644 --- a/spx-gui/src/apis/common/client.ts +++ b/spx-gui/src/apis/common/client.ts @@ -5,38 +5,68 @@ import * as Sentry from '@sentry/vue' import dayjs from 'dayjs' import { getTimeoutSignal, mergeSignals } from '@/utils/disposable' -import { ApiException, ApiExceptionCode, type MovedResourceCanonical, type QuotaExceededMeta } from './exception' +import { + ApiException, + ApiExceptionCode, + OAuthException, + type MovedResourceCanonical, + type RetryAfterMeta +} from './exception' import { parseSSE, type SSEEvent } from './sse' /** Response body when exception encountered for API calling */ export type ApiExceptionPayload = { - /** Code for program comsuming */ + /** Code for program consuming */ code: number /** Message for developer reading */ msg: string canonical?: MovedResourceCanonical } +/** Response body when OAuth exception encountered for API calling */ +export type OAuthExceptionPayload = { + /** OAuth error code */ + error: string + /** Message for developer reading */ + error_description?: string + /** URI for human-readable error information */ + error_uri?: string +} + function isApiExceptionPayload(body: any): body is ApiExceptionPayload { return body && typeof body.code === 'number' && typeof body.msg === 'string' } -function getQuotaExceededMeta(headers: Headers): QuotaExceededMeta { +function isOAuthExceptionPayload(body: any): body is OAuthExceptionPayload { + return ( + body && + typeof body.error === 'string' && + (body.error_description == null || typeof body.error_description === 'string') && + (body.error_uri == null || typeof body.error_uri === 'string') + ) +} + +function getRetryAfterMeta(headers: Headers): RetryAfterMeta { const retryAfter = headers.get('Retry-After') - let date - if (retryAfter != null) { - const seconds = Number(retryAfter) - date = Number.isFinite(seconds) ? dayjs().add(seconds, 's') : dayjs(retryAfter) + if (retryAfter == null || retryAfter.trim() === '') { + return { + retryAfter: null + } } + + const seconds = Number(retryAfter) + const date = Number.isFinite(seconds) ? dayjs().add(seconds, 's') : dayjs(retryAfter) return { - retryAfter: date?.isValid() ? date.valueOf() : null + retryAfter: date.isValid() ? date.valueOf() : null } } function getApiExceptionMeta(code: number, resp: Response, payload: ApiExceptionPayload): unknown { switch (code) { case ApiExceptionCode.errorQuotaExceeded: - return getQuotaExceededMeta(resp.headers) + case ApiExceptionCode.errorTooManyRequests: + case ApiExceptionCode.errorRateLimitExceeded: + return getRetryAfterMeta(resp.headers) case ApiExceptionCode.errorResourceMoved: return payload.canonical ?? null default: @@ -44,6 +74,14 @@ function getApiExceptionMeta(code: number, resp: Response, payload: ApiException } } +async function readErrorPayload(resp: Response): Promise { + try { + return await resp.json() + } catch { + return null + } +} + /** TokenProvider provides access token used for the Authorization header */ export type TokenProvider = () => Promise @@ -139,21 +177,19 @@ export class Client { const [timeoutSignal, cancelTimeout] = getTimeoutSignal(timeout) const signal = mergeSignals(timeoutSignal, options?.signal) const resp = await this.fetchFn(req, { signal }).finally(cancelTimeout) - if (!resp.ok) { - let payload: ApiExceptionPayload | undefined - try { - const body = await resp.json() - if (isApiExceptionPayload(body)) payload = body - } catch { - // ignore - } - if (payload == null) throw new Error(`status ${resp.status} for api call: ${req.url.slice(0, 200)}`) + if (resp.ok) return resp + + const payload = await readErrorPayload(resp) + if (isApiExceptionPayload(payload)) { throw new ApiException(payload.code, payload.msg, { req, meta: getApiExceptionMeta(payload.code, resp, payload) }) } - return resp + if (isOAuthExceptionPayload(payload)) { + throw new OAuthException(payload.error, payload.error_description ?? null, payload.error_uri ?? null, { req }) + } + throw new Error(`status ${resp.status} for api call: ${req.url.slice(0, 200)}`) } /** Do a JSON request, parsing response body as JSON */ diff --git a/spx-gui/src/apis/common/exception.ts b/spx-gui/src/apis/common/exception.ts index b070f4fbd..f92696f4c 100644 --- a/spx-gui/src/apis/common/exception.ts +++ b/spx-gui/src/apis/common/exception.ts @@ -21,17 +21,34 @@ export class ApiException extends Exception { } } +export class OAuthException extends Exception { + name = 'OAuthException' + userMessage = null + + constructor( + public error: string, + public errorDescription: string | null, + public errorUri: string | null, + { req }: { req: Request } + ) { + super(`[${error}] ${errorDescription ?? error} (${req.method} ${req.url.slice(0, 200)})`) + } +} + export enum ApiExceptionCode { errorInvalidArgs = 40001, errorUnauthorized = 40100, errorForbidden = 40300, errorQuotaExceeded = 40301, errorNotFound = 40400, + errorConflict = 40900, errorResourceMoved = 40901, + errorContentTooLarge = 41300, + errorUnsupportedMediaType = 41500, errorTooManyRequests = 42900, errorRateLimitExceeded = 42901, - errorScratchFeatureNotSupported = 50101, - errorUnknown = 50000 + errorInternalServerError = 50000, + errorScratchFeatureNotSupported = 50101 } export type MovedResourceCanonical = { @@ -42,13 +59,27 @@ export type MovedResourceCanonical = { release?: string } -export type QuotaExceededMeta = { +export type RetryAfterMeta = { // milliseconds or null retryAfter: number | null } +export type QuotaExceededMeta = RetryAfterMeta + +export function isRetryAfterMeta(code: number, meta: unknown): meta is RetryAfterMeta { + if (meta == null || typeof meta !== 'object') return false + if (!('retryAfter' in meta)) return false + const { retryAfter } = meta as RetryAfterMeta + return ( + (retryAfter == null || typeof retryAfter === 'number') && + (code === ApiExceptionCode.errorQuotaExceeded || + code === ApiExceptionCode.errorTooManyRequests || + code === ApiExceptionCode.errorRateLimitExceeded) + ) +} + export function isQuotaExceededMeta(code: number, meta: unknown): meta is QuotaExceededMeta { - return code === ApiExceptionCode.errorQuotaExceeded && meta != null + return code === ApiExceptionCode.errorQuotaExceeded && isRetryAfterMeta(code, meta) } const codeMessages: Record = { @@ -72,10 +103,22 @@ const codeMessages: Record = { en: 'resource not exist', zh: '资源不存在' }, + [ApiExceptionCode.errorConflict]: { + en: 'resource conflict', + zh: '资源状态冲突' + }, [ApiExceptionCode.errorResourceMoved]: { en: 'resource moved', zh: '资源已迁移' }, + [ApiExceptionCode.errorContentTooLarge]: { + en: 'content too large', + zh: '内容过大' + }, + [ApiExceptionCode.errorUnsupportedMediaType]: { + en: 'unsupported media type', + zh: '不支持的内容类型' + }, [ApiExceptionCode.errorTooManyRequests]: { en: 'too many requests', zh: '请求太频繁了' @@ -84,12 +127,12 @@ const codeMessages: Record = { en: 'rate limit exceeded, please retry later', zh: '触发频率限制,请稍后重试' }, + [ApiExceptionCode.errorInternalServerError]: { + en: 'something wrong with the server', + zh: '服务器出问题了' + }, [ApiExceptionCode.errorScratchFeatureNotSupported]: { en: 'Some Scratch features are not supported yet', zh: '部分 Scratch 特性暂不支持' - }, - [ApiExceptionCode.errorUnknown]: { - en: 'something wrong with the server', - zh: '服务器出问题了' } } diff --git a/spx-gui/src/apis/copilot.ts b/spx-gui/src/apis/copilot.ts index 70775c444..242e607ef 100644 --- a/spx-gui/src/apis/copilot.ts +++ b/spx-gui/src/apis/copilot.ts @@ -5,6 +5,8 @@ import type { JsonSchema7Type } from 'zod-to-json-schema' import { client } from './common' +export const copilotMessageContentMaxLength = 40000 + export enum ToolType { Function = 'function' } diff --git a/spx-gui/src/apis/project.ts b/spx-gui/src/apis/project.ts index 86b7631af..8e83e08af 100644 --- a/spx-gui/src/apis/project.ts +++ b/spx-gui/src/apis/project.ts @@ -7,6 +7,7 @@ import type { Prettify } from '@/utils/types' export { Visibility } +export const projectNameMaxLength = 100 export const projectDisplayNameMaxLength = 100 export const projectDescriptionMaxLength = 400 export const projectInstructionsMaxLength = 400 diff --git a/spx-gui/src/apis/user.ts b/spx-gui/src/apis/user.ts index aaa920db6..b5fd4a17e 100644 --- a/spx-gui/src/apis/user.ts +++ b/spx-gui/src/apis/user.ts @@ -1,6 +1,7 @@ import { client, type ByPage, type PaginationParams } from './common' import { ApiException, ApiExceptionCode } from './common/exception' +export const usernameMaxLength = 100 export const userDisplayNameMaxLength = 100 export const userDescriptionMaxLength = 200 diff --git a/spx-gui/src/apps/xbuilder/pages/admin/app.vue b/spx-gui/src/apps/xbuilder/pages/admin/app.vue index 243b3e040..98381591b 100644 --- a/spx-gui/src/apps/xbuilder/pages/admin/app.vue +++ b/spx-gui/src/apps/xbuilder/pages/admin/app.vue @@ -5,6 +5,7 @@ import { RouterLink } from 'vue-router' import { useMessageHandle } from '@/utils/exception' import { useI18n } from '@/utils/i18n' import { useQuery } from '@/utils/query' +import { getApiStringLength } from '@/utils/utils' import { useSignedInStateQuery } from '@/stores/user' import { UIButton, UIError, UILoading, UISwitch, UITextInput } from '@/components/ui' import CopyButton from '@/components/common/CopyButton.vue' @@ -50,6 +51,7 @@ const savedRedirectURIs = ref([]) const savedAllowedOrigins = ref([]) const appUpdatedAt = ref('') const appFallbackText = computed(() => displayName.value.trim().charAt(0).toUpperCase() || '?') +const trimmedDisplayName = computed(() => displayName.value.trim()) watch( app, @@ -71,8 +73,12 @@ watch( const parsedRedirectURIs = computed(() => parseLines(redirectURIs.value)) const parsedAllowedOrigins = computed(() => parseLines(allowedOrigins.value)) const isActive = computed(() => status.value === 'active') +const isDisplayNameTooLong = computed( + () => getApiStringLength(trimmedDisplayName.value) > accountAdminApis.accountAppDisplayNameMaxLength +) +const isDisplayNameValid = computed(() => trimmedDisplayName.value !== '' && !isDisplayNameTooLong.value) const isIdentityChanged = computed( - () => displayName.value.trim() !== '' && displayName.value.trim() !== savedDisplayName.value + () => isDisplayNameValid.value && trimmedDisplayName.value !== savedDisplayName.value ) const isStatusChanged = computed(() => status.value !== savedStatus.value) const areEndpointsChanged = computed( @@ -105,7 +111,7 @@ function refetchAll() { const handleUpdateIdentity = useMessageHandle( async () => { - const updated = await accountAdminApis.updateAccountApp(props.appID, { displayName: displayName.value.trim() }) + const updated = await accountAdminApis.updateAccountApp(props.appID, { displayName: trimmedDisplayName.value }) savedDisplayName.value = updated.displayName appUpdatedAt.value = updated.updatedAt }, @@ -113,6 +119,11 @@ const handleUpdateIdentity = useMessageHandle( { en: 'App identity updated', zh: '应用信息已更新' } ) +function submitIdentityUpdate() { + if (!isIdentityChanged.value) return + handleUpdateIdentity.fn() +} + const handleUpdateEndpoints = useMessageHandle( async () => { const updated = await accountAdminApis.updateAccountApp(props.appID, { @@ -139,10 +150,15 @@ const handleUpdateStatus = useMessageHandle( const secretName = ref('') const createdSecret = ref(null) +const trimmedSecretName = computed(() => secretName.value.trim()) +const isSecretNameTooLong = computed( + () => getApiStringLength(trimmedSecretName.value) > accountAdminApis.accountAppSecretNameMaxLength +) +const isSecretNameValid = computed(() => trimmedSecretName.value !== '' && !isSecretNameTooLong.value) const handleCreateSecret = useMessageHandle( async () => { - const secret = await accountAdminApis.createAccountAppSecret(props.appID, { name: secretName.value.trim() }) + const secret = await accountAdminApis.createAccountAppSecret(props.appID, { name: trimmedSecretName.value }) createdSecret.value = secret secretName.value = '' secretsQuery.refetch() @@ -151,6 +167,11 @@ const handleCreateSecret = useMessageHandle( { en: 'Account app secret created', zh: '账号应用密钥已创建' } ) +function submitSecretCreation() { + if (!isSecretNameValid.value) return + handleCreateSecret.fn() +} + const handleCopySecret = useMessageHandle( () => navigator.clipboard.writeText(createdSecret.value!.value), { en: 'Failed to copy app secret', zh: '复制应用密钥失败' }, @@ -257,7 +278,7 @@ function deleteSecret(secretID: string) { }}

-
+
{{ $t({ en: 'App name', zh: '应用名称' }) }}
@@ -268,7 +289,15 @@ function deleteSecret(secretID: string) {
{{ $t({ en: 'Client ID', zh: '客户端 ID' }) }}
@@ -452,11 +481,19 @@ function deleteSecret(secretID: string) {