diff --git a/docs/openapi.yaml b/docs/openapi.yaml
index 945f83f19..57112f4f7 100644
--- a/docs/openapi.yaml
+++ b/docs/openapi.yaml
@@ -592,7 +592,7 @@ paths:
description: |
Avatar image file.
- Maximum size is 5 MiB (5242880 bytes).
+ Maximum size is 5 MiB.
type: string
format: binary
encoding:
@@ -871,7 +871,7 @@ paths:
description: |
Avatar image file.
- Maximum size is 5 MiB (5242880 bytes).
+ Maximum size is 5 MiB.
type: string
format: binary
encoding:
@@ -3985,7 +3985,7 @@ paths:
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
@@ -4432,7 +4432,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:
@@ -4641,7 +4641,7 @@ paths:
description: |
Avatar image file.
- Maximum size is 5 MiB (5242880 bytes).
+ Maximum size is 5 MiB.
type: string
format: binary
encoding:
@@ -7876,7 +7876,7 @@ components:
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.
+ Maximum image size: 5 MiB after decoding the base64 content.
Format: `data:image/png;base64,`
type: string
diff --git a/spx-gui/AGENTS.md b/spx-gui/AGENTS.md
index e81023631..cc80620c6 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 `../`
+### String Length Validation
+
+* Use native `string.length`, browser input constraints, and `z.string().max()` for ordinary frontend input validation,
+ following the app's existing validation and feedback patterns.
+* Use `getStringLengthInCodePoints` only when frontend logic must exactly match backend/OpenAPI Unicode code point
+ semantics, or when slicing, truncating, or constructing hard-budget strings such as LLM/API payloads.
+* Count after submission-time normalization such as `trim()`. Use byte or source-file-size checks for explicit byte or
+ payload-size limits.
+
### 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 5350e95b1..fdb7bb181 100644
--- a/spx-gui/src/apis/admin/account.ts
+++ b/spx-gui/src/apis/admin/account.ts
@@ -27,7 +27,13 @@ export type {
CreatedAccountAppToken
} from '@/apis/account/common'
-/** Maximum allowed length for an app token name. */
+export const accountUsersKeywordMaxLength = 100
+export const accountUserUsernameMaxLength = 100
+export const accountUserDisplayNameMaxLength = 100
+export const accountUserPasswordMinLength = 8
+export const accountUserPasswordMaxLength = 128
+export const accountAppDisplayNameMaxLength = 100
+export const accountAppSecretNameMaxLength = 100
export const accountAppTokenNameMaxLength = 100
export type ListAccountUsersParams = PaginationParams & {
@@ -149,7 +155,7 @@ export function listAccountAppGrantTokens(appGrantID: string, params?: ListAccou
export type CreateAccountAppGrantTokenParams = {
/** OAuth token type to create */
tokenType: 'accessToken'
- /** Human-readable token name. Maximum length: accountAppTokenNameMaxLength. */
+ /** Human-readable token name */
name: string
/** Expiration timestamp */
expiresAt: string
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/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 763d68a7f..be069ef08 100644
--- a/spx-gui/src/apps/xbuilder/pages/admin/app.vue
+++ b/spx-gui/src/apps/xbuilder/pages/admin/app.vue
@@ -59,6 +59,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,
@@ -81,7 +82,7 @@ const parsedRedirectURIs = computed(() => parseLines(redirectURIs.value))
const parsedAllowedOrigins = computed(() => parseLines(allowedOrigins.value))
const isActive = computed(() => status.value === 'active')
const isIdentityChanged = computed(
- () => displayName.value.trim() !== '' && displayName.value.trim() !== savedDisplayName.value
+ () => trimmedDisplayName.value !== '' && trimmedDisplayName.value !== savedDisplayName.value
)
const isStatusChanged = computed(() => status.value !== savedStatus.value)
const areEndpointsChanged = computed(
@@ -114,7 +115,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
},
@@ -148,10 +149,12 @@ const handleUpdateStatus = useMessageHandle(
const secretName = ref('')
const createdSecret = ref(null)
+const trimmedSecretName = computed(() => secretName.value.trim())
+const isSecretNameValid = computed(() => trimmedSecretName.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()
@@ -247,7 +250,6 @@ function deleteSecret(secretID: string) {
icon="reload"
shape="square"
type="white"
- :aria-label="$t({ en: 'Refresh app details', zh: '刷新应用详情' })"
@click="refetchAll"
/>
@@ -277,7 +279,10 @@ function deleteSecret(secretID: string) {
{{ $t({ en: 'Client ID', zh: '客户端 ID' }) }}
@@ -399,7 +404,6 @@ function deleteSecret(secretID: string) {
desc: 'Enable or disable OAuth flows for this app'
}"
:value="isActive"
- :aria-label="$t({ en: 'App availability', zh: '应用可用状态' })"
@update:value="handleActiveChange"
/>
@@ -464,7 +468,7 @@ function deleteSecret(secretID: string) {
diff --git a/spx-gui/src/apps/xbuilder/pages/admin/users.vue b/spx-gui/src/apps/xbuilder/pages/admin/users.vue
index 0065565ca..139fc341c 100644
--- a/spx-gui/src/apps/xbuilder/pages/admin/users.vue
+++ b/spx-gui/src/apps/xbuilder/pages/admin/users.vue
@@ -92,6 +92,12 @@ const handleCreateUser = useMessageHandle(
const displayName = createForm.displayName.trim() || username
const password = createForm.password.trim()
if (password === '') throw new DefaultException({ en: 'Password is required', zh: '密码不能为空' })
+ if (password.length < accountAdminApis.accountUserPasswordMinLength) {
+ throw new DefaultException({
+ en: `The password must be at least ${accountAdminApis.accountUserPasswordMinLength} characters`,
+ zh: `密码长度不能少于 ${accountAdminApis.accountUserPasswordMinLength} 个字符`
+ })
+ }
const user = await accountAdminApis.createAccountUser({ username, displayName, password })
await router.push(`/admin/users/${encodeURIComponent(user.id)}`)
@@ -142,18 +148,30 @@ async function handleImportUsers() {
@@ -190,6 +208,7 @@ async function handleImportUsers() {
v-model:value="keywordInput"
clearable
class="w-64 max-w-full"
+ :maxlength="accountAdminApis.accountUsersKeywordMaxLength"
:placeholder="$t({ en: 'Username or display name', zh: '用户名或显示名称' })"
/>
diff --git a/spx-gui/src/apps/xbuilder/pages/sign-in/token.vue b/spx-gui/src/apps/xbuilder/pages/sign-in/token.vue
index 58c06f13b..d37a18834 100644
--- a/spx-gui/src/apps/xbuilder/pages/sign-in/token.vue
+++ b/spx-gui/src/apps/xbuilder/pages/sign-in/token.vue
@@ -8,7 +8,7 @@
v-radar="{ name: 'Token input', desc: 'Input field for authentication token' }"
class="justify-self-stretch h-40"
type="textarea"
- :placeholder="$t({ en: 'Paste token here', zh: '在此粘贴 Token' })"
+ :placeholder="$t({ en: 'Paste token here', zh: '在此粘贴 token' })"
/>