chore(cli): merge main into PR #608

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
XiaoSeS 2026-07-29 10:19:03 +08:00
commit ad4a2dbc2f
51 changed files with 3957 additions and 100 deletions

View file

@ -18,6 +18,9 @@ SKILLHUB_PUBLIC_BASE_URL=https://skillhub.example.com
# Usually keep empty when web and api are served from the same domain.
SKILLHUB_WEB_API_BASE_URL=
SKILLHUB_API_UPSTREAM=http://server:8080
# Enable only when a trusted TLS-terminating proxy replaces X-Forwarded-Proto
# and the web container cannot be reached directly.
SKILLHUB_TRUST_FORWARDED_PROTO=false
# Keep database and redis local-only on the host unless you explicitly need remote access.
POSTGRES_BIND_ADDRESS=127.0.0.1

View file

@ -15,6 +15,9 @@ SKILLHUB_PUBLIC_BASE_URL=http://localhost
# Frontend usually keeps this empty and proxies to the backend through nginx.
SKILLHUB_WEB_API_BASE_URL=
SKILLHUB_API_UPSTREAM=http://server:8080
# Keep false for direct exposure. Enable only behind a trusted proxy that replaces
# X-Forwarded-Proto and blocks direct access to the web container.
SKILLHUB_TRUST_FORWARDED_PROTO=false
POSTGRES_BIND_ADDRESS=127.0.0.1
POSTGRES_PORT=5432

View file

@ -8,6 +8,8 @@ on:
- '.env.release.draft'
- 'compose.release.yml'
- 'Makefile'
- 'web/Dockerfile'
- 'web/nginx.conf.template'
- '.github/workflows/pr-cli.yml'
- '.github/workflows/pr-e2e.yml'
- '.github/workflows/pr-tests.yml'
@ -33,5 +35,6 @@ jobs:
- run: bash scripts/tests/publish-cli-test.sh
- run: bash scripts/tests/runtime-secret-test.sh
- run: bash scripts/tests/validate-release-config-test.sh
- run: bash scripts/tests/nginx-forwarded-proto-test.sh
- run: bash scripts/tests/dev-web-host-test.sh
- run: bash scripts/tests/workflow-security-test.sh

View file

@ -15,6 +15,9 @@
[![Java](https://img.shields.io/badge/java-21-ED8B00?logo=openjdk&logoColor=white)](https://openjdk.org/projects/jdk/21/)
[![React](https://img.shields.io/badge/react-19-61DAFB?logo=react&logoColor=black)](https://react.dev)
[![GitHub Stars](https://img.shields.io/github/stars/iflytek/skillhub?style=social)](https://github.com/iflytek/skillhub/stargazers)
[![GitHub Watchers](https://img.shields.io/github/watchers/iflytek/skillhub?style=social)](https://github.com/iflytek/skillhub/watchers)
</div>
<div align="center">
@ -35,6 +38,8 @@ it to a namespace, and let others find it through search or
install it via CLI. Built for on-premise deployment behind your
firewall, with the same polish you'd expect from a public registry.
> ⭐ If SkillHub fits your team, **star** the repo to help other teams find it, and **Watch → Custom → Releases** to get notified when a new version ships.
## Documentation
- 📖 **[User Guide](https://iflytek.github.io/skillhub/)** — Skill publishing, search, CLI usage and other user guides
@ -442,6 +447,13 @@ namespace `my-space` plus skill slug `my-skill`.
📖 **[Complete Hermes Agent Integration Guide →](./docs/hermes-integration-en.md)**
### [HarnessClaw Engine](https://github.com/harnessclaw/harnessclaw-engine)
[HarnessClaw Engine](https://github.com/harnessclaw/harnessclaw-engine) is a Go LLM programming assistant engine that exposes its capabilities over WebSocket. It loads skills from `SKILL.md` files with YAML frontmatter and parameter substitution, scanning each configured directory for `skill-name/SKILL.md` (default `~/.harnessclaw/workspace/skills/`, with earlier directories taking priority on name conflicts). Install a SkillHub package straight into that directory with the CLI's `--dir` option, no registry adapter required:
```bash
npx clawhub --dir ~/.harnessclaw/workspace/skills install my-skill
```
### [AstronClaw](https://agent.xfyun.cn/astron-claw)
[AstronClaw](https://agent.xfyun.cn/astron-claw) is a cloud AI assistant built on OpenClaw's core capabilities, providing 24/7 online service through enterprise platforms like WeChat Work, DingTalk, and Feishu. It features a built-in skill system with over 130 official skills. You can connect it to a self-hosted SkillHub registry to enable one-click skill installation, search repository, dialogue-based automatic installation, and even custom skills management within your organization.

View file

@ -14,6 +14,9 @@
[![Java](https://img.shields.io/badge/java-21-ED8B00?logo=openjdk&logoColor=white)](https://openjdk.org/projects/jdk/21/)
[![React](https://img.shields.io/badge/react-19-61DAFB?logo=react&logoColor=black)](https://react.dev)
[![GitHub Stars](https://img.shields.io/github/stars/iflytek/skillhub?style=social)](https://github.com/iflytek/skillhub/stargazers)
[![GitHub Watchers](https://img.shields.io/github/watchers/iflytek/skillhub?style=social)](https://github.com/iflytek/skillhub/watchers)
</div>
---
@ -24,6 +27,8 @@
SkillHub 是一个自托管平台,为团队提供私有的、受治理的智能体技能共享空间。发布技能包,推送到命名空间,让其他人通过搜索发现或通过 CLI 安装。专为防火墙后的本地部署而构建,提供与公共注册中心相同的精致体验。
> ⭐ 如果 SkillHub 适合你的团队,欢迎 **Star** 本仓库帮助更多团队发现它;点 **Watch → Custom → Releases** 可在新版本发布时收到通知。
## 文档
- 📖 **[用户指南](https://iflytek.github.io/skillhub/)** — 技能发布、搜索、CLI 使用等用户操作指南
@ -376,6 +381,13 @@ namespace `my-space` 和 skill slug `my-skill`。
📖 **[完整 Hermes Agent 集成指南 →](./docs/hermes-integration.md)**
### [HarnessClaw Engine](https://github.com/harnessclaw/harnessclaw-engine)
[HarnessClaw Engine](https://github.com/harnessclaw/harnessclaw-engine) 是基于 Go 的 LLM 编程助手引擎,通过 WebSocket 协议对外提供能力。它从 `SKILL.md` 文件加载技能,支持 YAML frontmatter 与参数替换,并按配置顺序扫描各目录下的 `skill-name/SKILL.md`(默认 `~/.harnessclaw/workspace/skills/`,靠前的目录在重名时优先)。通过 SkillHub CLI 的 `--dir` 参数即可把技能包直接安装到该目录,无需新增 registry 适配器:
```bash
npx clawhub --dir ~/.harnessclaw/workspace/skills install my-skill
```
### [AstronClaw](https://agent.xfyun.cn/astron-claw)
[AstronClaw](https://agent.xfyun.cn/astron-claw) 是基于 OpenClaw 核心能力打造的云端 AI 助手,提供全天候在线服务,随时随地通过企业微信、钉钉、飞书等渠道提供服务。它内置了丰富的技能系统,您可以将其连接到自托管的 SkillHub 注册中心,支持技能市场一键安装、仓库搜索、对话自动安装,甚至管理和分发组织内部的自定义私有技能。

View file

@ -22,7 +22,7 @@ export function createFakeRegistry(handlers: Record<string, FakeHandler>) {
/**
* Controls how a specific endpoint behaves when a failure is injected:
* 'auth' => 401 { code: 401, message: 'unauthorized' }
* 'forbidden' => 403 { code: 403, message: 'forbidden' }
* 'forbidden' => 403 with a standard SkillHub error envelope
* 'not_found' => 404 { code: 404, message: 'not found' }
* 'server_error' => 500 { code: 500, message: 'internal error' }
* 'network' => handler throws, causing fetch() to reject with a TypeError
@ -34,7 +34,11 @@ function failureResponse(mode: FailureMode): Response {
case 'auth':
return Response.json({ code: 401, message: 'unauthorized' }, { status: 401 })
case 'forbidden':
return Response.json({ code: 403, message: 'forbidden' }, { status: 403 })
return Response.json({
code: 403,
msg: 'API token is missing required scope: skill:publish',
requestId: 'req-test-forbidden'
}, { status: 403 })
case 'not_found':
return Response.json({ code: 404, message: 'not found' }, { status: 404 })
case 'server_error':

View file

@ -159,7 +159,7 @@ describe('publish --dry-run', () => {
expect(result.stderr).toContain('authentication')
})
test('--dry-run uses a neutral fallback on an unstructured 403', async () => {
test('--dry-run preserves a structured 403 reason and request ID', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({ token: 'sk_ok', failures: { validate: 'forbidden' } })
await login(env, registry.url)
@ -171,7 +171,7 @@ describe('publish --dry-run', () => {
})
expect(result.exitCode).toBe(2)
expect(result.stderr).toContain('access denied')
expect(result.stderr).not.toContain('scope')
expect(result.stderr).toContain('scope')
expect(result.stderr).toContain('Request ID: req-test-forbidden')
})
})

View file

@ -36,13 +36,33 @@ describe('SkillHubClient', () => {
await err.toHaveProperty('exitCode', EXIT.auth)
})
test('download() throws a neutral access error on 403', async () => {
test('download() preserves the server reason and request ID on 403', async () => {
const fetchImpl = (async () => Response.json({
code: 403,
msg: 'API token is missing required scope: skill:read',
requestId: 'req-download'
}, { status: 403 })) as unknown as typeof fetch
const client = new SkillHubClient('http://registry.test', 'token', fetchImpl)
await expect(client.download('ns', 'slug')).rejects.toMatchObject({
message: 'API token is missing required scope: skill:read',
exitCode: EXIT.auth,
details: {
registry: 'http://registry.test',
requestId: 'req-download'
}
})
})
test('download() uses a neutral access error for an unstructured 403', async () => {
const fetchImpl = (async () => new Response(null, { status: 403 })) as unknown as typeof fetch
const client = new SkillHubClient('http://registry.test', 'token', fetchImpl)
const err = expect(client.download('ns', 'slug')).rejects
await err.toBeInstanceOf(CliError)
await err.toHaveProperty('message', 'access denied')
await err.toHaveProperty('exitCode', EXIT.auth)
await expect(client.download('ns', 'slug')).rejects.toMatchObject({
message: 'access denied',
exitCode: EXIT.auth,
details: { registry: 'http://registry.test' }
})
})
test('download() throws not-found error on 404', async () => {
@ -264,6 +284,35 @@ describe('SkillHubClient', () => {
}
})
test('whoami() surfaces server reason and request ID on 403', async () => {
const fetchImpl = (async () => Response.json({
code: 403,
msg: 'API token cannot access endpoint: /api/cli/v1/whoami',
requestId: 'req-610'
}, { status: 403 })) as unknown as typeof fetch
const client = new SkillHubClient('http://registry.test', 'token', fetchImpl)
await expect(client.whoami()).rejects.toMatchObject({
message: 'API token cannot access endpoint: /api/cli/v1/whoami',
exitCode: EXIT.auth,
details: {
registry: 'http://registry.test',
requestId: 'req-610'
}
})
})
test('whoami() falls back to generic access denied when 403 body is invalid', async () => {
const fetchImpl = (async () => new Response('not-json', { status: 403 })) as unknown as typeof fetch
const client = new SkillHubClient('http://registry.test', 'token', fetchImpl)
await expect(client.whoami()).rejects.toMatchObject({
message: 'access denied',
exitCode: EXIT.auth,
details: { registry: 'http://registry.test' }
})
})
test('whoami() throws generic error on 500', async () => {
const fetchImpl = (async () => new Response(null, { status: 500 })) as unknown as typeof fetch
const client = new SkillHubClient('http://registry.test', 'token', fetchImpl)

View file

@ -16,11 +16,13 @@ describe('renderError', () => {
test('renders human error without stack trace', () => {
const error = new CliError('registry unreachable', 3, {
registry: 'https://registry.example.com',
requestId: 'req-610',
next: 'check network or pass --registry'
})
expect(renderError(error, false)).toBe([
'Error: registry unreachable',
'Context: registry https://registry.example.com',
'Request ID: req-610',
'Next: check network or pass --registry'
].join('\n'))
})

View file

@ -116,6 +116,7 @@ services:
- "${WEB_PORT:-80}:80"
environment:
SKILLHUB_API_UPSTREAM: ${SKILLHUB_API_UPSTREAM:-http://server:8080}
SKILLHUB_TRUST_FORWARDED_PROTO: ${SKILLHUB_TRUST_FORWARDED_PROTO:-false}
SKILLHUB_WEB_API_BASE_URL: ${SKILLHUB_WEB_API_BASE_URL:-}
SKILLHUB_PUBLIC_BASE_URL: ${SKILLHUB_PUBLIC_BASE_URL:-}
SKILLHUB_WEB_AUTH_DIRECT_ENABLED: ${SKILLHUB_WEB_AUTH_DIRECT_ENABLED:-false}

View file

@ -377,8 +377,9 @@ API Token 仍保留但定位从“CLI 唯一认证方式”调整为“平台
- 用途:自动化脚本、兼容层调用、手工 Token 管理、后续系统集成
- 存储:只存 SHA-256 哈希,明文只展示一次
- 校验:从 `Authorization: Bearer <token>` 提取 → 哈希比对 → 加载关联用户 → 检查用户状态
- 失败闭合:公共读接口只有在缺少 `Authorization` 头时才按匿名访问处理;只要出现 Bearer 凭证,空值、格式错误、未知、过期、已吊销、用户缺失或用户禁用均返回 401不能回退为匿名访问
- 失败闭合与身份优先级:共享认证过滤器只识别 Bearer scheme。有效 Bearer 覆盖已加载的 Web Session 身份Bearer 为空、格式错误、未知、过期、已吊销、用户缺失或用户禁用时立即返回 401即使存在有效 Session 也不得回退。缺少 `Authorization` 头或使用 Basic/其他非 Bearer scheme 时保留有效 Session若无 Session公共读接口按匿名访问`whoami` 返回 401
- 作用域:`skill:read`, `skill:publish`, `skill:delete`, `token:manage`
- 拒绝原因API Token 缺少作用域或不能访问某个接口时403 响应返回本地化的安全原因和 `requestId`;其他授权失败仍返回通用信息,避免暴露内部异常
> **一期作用域说明(非最小权限)**:一期 Token 作用域为粗粒度动作级别,不与 namespace 绑定。Token 继承用户的全部权限——如果用户是某个 namespace 的 MEMBER则该用户的任何 Token只要包含 `skill:publish` scope都可以向该 namespace 发布技能。这是有意的一期简化,不满足最小权限原则。后续版本计划引入 namespace 级别的 Token 作用域限定(如 `namespace:ai-team:skill:publish`),或通过 `api_token_scope` 子表实现 Token 与 namespace 的绑定。
@ -621,10 +622,15 @@ window.location.href = '/oauth2/authorization/github'
### 10.3 CLI API
| 接口 | 所需凭证 | 额外判定 |
|------|---------|---------|
| `GET /api/v1/whoami` | 任意有效 Bearer Token | 无 |
| `POST /api/v1/publish` | Bearer Token + `skill:publish` | 普通用户要求目标 namespace 成员;`SUPER_ADMIN` 可绕过 |
| 接口 | 凭证规则 | 授权与错误语义 |
|------|---------|---------------|
| `GET /api/cli/v1/auth/whoami` | 有效 Web Session 或有效 Bearer Token | 无有效身份返回 401坏 Bearer 即使存在 Session 也返回 401 |
| `GET /api/cli/v1/skills/search` | Session 可用;无 Session 时可匿名;提供 Bearer 时必须有效 | 匿名仅返回公开可安装 skill有效 Bearer 覆盖 Session坏 Bearer 返回 401不得降级 |
| `GET /api/cli/v1/skills/{namespace}/{slug}/resolve` | Session 可用;无 Session 时可匿名读取公开资源;提供 Bearer 时必须有效 | 有效 Bearer 覆盖 Session坏 Bearer 返回 401有效身份无资源权限返回 403 |
| `GET /api/cli/v1/skills/{namespace}/{slug}/download` | Session 可用;无 Session 时可匿名下载公开资源;提供 Bearer 时必须有效 | 有效 Bearer 覆盖 Session坏 Bearer 返回 401有效身份无资源权限返回 403 |
| `GET /api/cli/v1/skills/{namespace}/{slug}/versions/{version}/download` | Session 可用;无 Session 时可匿名下载公开资源;提供 Bearer 时必须有效 | 有效 Bearer 覆盖 Session坏 Bearer 返回 401有效身份无资源权限返回 403 |
Spring Security 先加载 Web Session 身份,共享 API token 过滤器随后只处理 Bearer scheme。有效 Bearer 会覆盖 Session确保请求使用 token 的用户、角色与 scopeBearer 为空、格式错误、未知、过期、已撤销、用户缺失或用户禁用时,过滤器清除当前身份并立即返回 401不能回退到 Session 或匿名身份。完全缺少 `Authorization` 头或使用 Basic/其他非 Bearer scheme 时,过滤器不改变已有 Session如果 Session 也不存在,公共读接口按匿名身份执行,而 `whoami` 返回 401。身份已验证但 token scope 或资源可见性不足时返回 403服务端不向客户端区分 token 不存在、过期或已撤销。`whoami.email` 字段始终存在,但没有可用邮箱时值为 `null`
### 10.4 Admin API

View file

@ -194,6 +194,9 @@ docker compose --env-file .env.release -f compose.release.yml up -d
- 推荐将敏感变量放入 CI/CD Secret 或主机上的受控 `.env.release`
- 外部对象存储通过 `SKILLHUB_STORAGE_S3_*` 注入
- 前端反代和运行时 API 地址通过 `SKILLHUB_API_UPSTREAM` / `SKILLHUB_WEB_API_BASE_URL` 注入
- `SKILLHUB_TRUST_FORWARDED_PROTO` 默认保持 `false`。只有 Web 容器仅能经由可信
TLS 终止代理访问,且该代理会覆盖客户端传入的 `X-Forwarded-Proto` 时才设为
`true`;否则客户端可伪造协议并影响 OAuth 回调、重定向和安全 Cookie 判断
- 如果要开放真实登录,再补充 `OAUTH2_GITHUB_CLIENT_ID` / `OAUTH2_GITHUB_CLIENT_SECRET`
- 如果要启用密码重置验证码邮件,参见:`docs/19-smtp-password-reset-email-setup.md`

View file

@ -0,0 +1,305 @@
openapi: 3.0.3
info:
title: SkillHub CLI Authentication API
version: 1.0.0
description: >-
Authentication contract for CLI identity and public skill reads. A valid
Bearer credential overrides a Web Session identity. Once the Bearer scheme
is used, the credential must be valid: empty, malformed, unknown, expired,
or revoked Bearer credentials return HTTP 401 and never fall back to the
Session or anonymous access. An absent Authorization header or an
unsupported scheme such as Basic preserves a valid Web Session. Without a
Session, public reads use anonymous visibility and whoami returns HTTP 401.
servers:
- url: /
tags:
- name: CLI Authentication
- name: CLI Skills
paths:
/api/cli/v1/auth/whoami:
get:
tags: [CLI Authentication]
summary: Return the current CLI identity
operationId: cliWhoAmI
description: Requires a valid Bearer credential or Web Session. Bearer takes priority over Session; invalid Bearer returns 401 without Session fallback. An absent or non-Bearer Authorization header preserves Session, but returns 401 when no Session exists.
security:
- bearerAuth: []
- sessionAuth: []
responses:
'200':
description: Authenticated CLI identity
content:
application/json:
schema:
$ref: '#/components/schemas/CliWhoAmIEnvelope'
'401':
$ref: '#/components/responses/Unauthorized'
/api/cli/v1/skills/search:
get:
tags: [CLI Skills]
summary: Search CLI-installable skills
operationId: cliSearchSkills
description: Valid Bearer overrides Web Session. Invalid Bearer returns 401 without Session fallback. An absent or non-Bearer Authorization header preserves Session; without Session, the request uses anonymous public visibility.
security:
- {}
- sessionAuth: []
- bearerAuth: []
parameters:
- name: q
in: query
required: false
schema: {type: string}
example: pdf
description: Optional search text.
- name: limit
in: query
required: false
schema: {type: integer, format: int32, default: 20}
example: 20
description: Maximum number of results.
responses:
'200':
description: Search result
content:
application/json:
schema:
$ref: '#/components/schemas/CliSearchEnvelope'
'401':
$ref: '#/components/responses/Unauthorized'
/api/cli/v1/skills/{namespace}/{slug}/resolve:
get:
tags: [CLI Skills]
summary: Resolve a skill version
operationId: cliResolveSkill
description: Valid Bearer overrides Web Session. Invalid Bearer returns 401 without Session fallback. An absent or non-Bearer Authorization header preserves Session; without Session, the request uses anonymous public visibility.
security:
- {}
- sessionAuth: []
- bearerAuth: []
parameters:
- $ref: '#/components/parameters/Namespace'
- $ref: '#/components/parameters/Slug'
- name: version
in: query
required: false
schema: {type: string}
example: 1.0.0
description: Optional exact version; omitted resolves latest.
responses:
'200':
description: Resolved version
content:
application/json:
schema:
$ref: '#/components/schemas/CliResolveEnvelope'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
/api/cli/v1/skills/{namespace}/{slug}/download:
get:
tags: [CLI Skills]
summary: Download the latest installable skill version
operationId: cliDownloadLatestSkill
description: Valid Bearer overrides Web Session. Invalid Bearer returns 401 without Session fallback. An absent or non-Bearer Authorization header preserves Session; without Session, the request uses anonymous public visibility.
security:
- {}
- sessionAuth: []
- bearerAuth: []
parameters:
- $ref: '#/components/parameters/Namespace'
- $ref: '#/components/parameters/Slug'
responses:
'200':
$ref: '#/components/responses/Download'
'302':
$ref: '#/components/responses/DownloadRedirect'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'503':
$ref: '#/components/responses/StorageUnavailable'
/api/cli/v1/skills/{namespace}/{slug}/versions/{version}/download:
get:
tags: [CLI Skills]
summary: Download an exact installable skill version
operationId: cliDownloadSkillVersion
description: Valid Bearer overrides Web Session. Invalid Bearer returns 401 without Session fallback. An absent or non-Bearer Authorization header preserves Session; without Session, the request uses anonymous public visibility.
security:
- {}
- sessionAuth: []
- bearerAuth: []
parameters:
- $ref: '#/components/parameters/Namespace'
- $ref: '#/components/parameters/Slug'
- $ref: '#/components/parameters/Version'
responses:
'200':
$ref: '#/components/responses/Download'
'302':
$ref: '#/components/responses/DownloadRedirect'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'503':
$ref: '#/components/responses/StorageUnavailable'
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: SkillHub API token
description: API token issued by SkillHub. A valid token overrides Web Session; invalid lifecycle states all return the same 401 response without Session fallback.
sessionAuth:
type: apiKey
in: cookie
name: SESSION
description: Spring Session browser identity. It is preserved when Authorization is absent or uses a non-Bearer scheme, and is overridden by a valid Bearer token.
parameters:
Namespace:
name: namespace
in: path
required: true
schema: {type: string}
example: global
description: Namespace slug.
Slug:
name: slug
in: path
required: true
schema: {type: string}
example: pdf-parser
description: Skill slug.
Version:
name: version
in: path
required: true
schema: {type: string}
example: 1.0.0
description: Exact semantic version.
responses:
Download:
description: ZIP package stream
headers:
Content-Disposition:
schema: {type: string}
description: Attachment filename.
content:
application/zip:
schema: {type: string, format: binary}
DownloadRedirect:
description: Redirect to a presigned object-storage URL
headers:
Location:
schema: {type: string, format: uri}
BadRequest:
description: Namespace, skill, or version cannot be resolved.
content:
application/json:
schema: {$ref: '#/components/schemas/ErrorEnvelope'}
Unauthorized:
description: No valid supported identity is present where required, or the Bearer credential is empty, malformed, unknown, expired, revoked, or belongs to an unavailable user. Invalid Bearer never falls back to Web Session.
content:
application/json:
schema: {$ref: '#/components/schemas/ErrorEnvelope'}
example:
code: 401
msg: Authentication required
data: null
timestamp: '2026-07-28T00:00:00Z'
requestId: req-123
Forbidden:
description: Credential is valid but token scope or resource permission is insufficient.
content:
application/json:
schema: {$ref: '#/components/schemas/ErrorEnvelope'}
example:
code: 403
msg: Forbidden
data: null
timestamp: '2026-07-28T00:00:00Z'
requestId: req-123
StorageUnavailable:
description: Object storage is unavailable.
content:
application/json:
schema: {$ref: '#/components/schemas/ErrorEnvelope'}
schemas:
Envelope:
type: object
required: [code, msg, data, timestamp, requestId]
properties:
code: {type: integer, format: int32}
msg: {type: string}
data: {type: object, nullable: true}
timestamp: {type: string, format: date-time}
requestId: {type: string, example: req-123}
ErrorEnvelope:
allOf:
- $ref: '#/components/schemas/Envelope'
- type: object
properties:
data: {type: object, nullable: true, example: null}
CliWhoAmIEnvelope:
allOf:
- $ref: '#/components/schemas/Envelope'
- type: object
properties:
data:
$ref: '#/components/schemas/CliWhoAmI'
CliWhoAmI:
type: object
required: [handle, displayName, email]
properties:
handle: {type: string, example: user-123}
displayName: {type: string, example: CLI User}
email: {type: string, format: email, nullable: true, example: cli@example.com, description: Email address when available; the required field is null when the account has no email.}
CliSearchEnvelope:
allOf:
- $ref: '#/components/schemas/Envelope'
- type: object
properties:
data:
$ref: '#/components/schemas/CliSearchResult'
CliSearchResult:
type: object
required: [items, total, limit]
properties:
items:
type: array
items: {$ref: '#/components/schemas/CliSearchItem'}
total: {type: integer, format: int64, example: 1}
limit: {type: integer, format: int32, example: 20}
CliSearchItem:
type: object
required: [namespace, slug, latestVersion]
properties:
namespace: {type: string, example: global}
slug: {type: string, example: pdf-parser}
latestVersion: {type: string, example: 1.2.0}
summary: {type: string, nullable: true, example: Parse PDF files}
CliResolveEnvelope:
allOf:
- $ref: '#/components/schemas/Envelope'
- type: object
properties:
data:
$ref: '#/components/schemas/CliResolveResult'
CliResolveResult:
type: object
required: [namespace, slug, version, versionId, fingerprint, downloadUrl]
properties:
namespace: {type: string, example: global}
slug: {type: string, example: pdf-parser}
version: {type: string, example: 1.2.0}
versionId: {type: integer, format: int64, example: 42}
fingerprint: {type: string, example: 'sha256:abc123'}
downloadUrl: {type: string, example: /api/v1/skills/global/pdf-parser/versions/1.2.0/download}

View file

@ -156,10 +156,18 @@ A: This is most commonly seen with **manual deployment** (caused by API errors o
## Q: How do I change the admin password? Why don't my config changes take effect?
A: Environment variables are read at container startup, so you must restart the containers after changing them.
A: Environment variables are injected when a container is created, so you must recreate the containers after changing them; `restart` alone does not re-inject environment variables.
1. Edit `/tmp/skillhub-runtime/.env.release` in the runtime directory (refer to [.env.release.example](https://github.com/iflytek/skillhub/blob/main/.env.release.example)).
2. Restart the relevant containers.
2. Recreate the relevant containers:
```bash
docker compose \
--env-file /tmp/skillhub-runtime/.env.release \
-f /tmp/skillhub-runtime/compose.release.yml \
up -d --force-recreate
```
3. If the password was already persisted to the database and the change still doesn't take effect, you may need to clear the corresponding data and re-initialize.
## Q: Is an email verification code required to change / reset a password?
@ -219,7 +227,7 @@ A: The default limit is **100 files** (this is separate from the 100MB size limi
SKILLHUB_PUBLISH_MAX_FILE_COUNT=500
```
Restart the containers for the change to take effect. Note that `compose.release.yml` must also reference this variable; older versions (e.g. v0.2.6) may hard-code the value, so upgrading to the latest version is recommended.
Recreate the containers for the change to take effect; `restart` alone does not re-inject environment variables. Note that `compose.release.yml` must also reference this variable; older versions (e.g. v0.2.6) may hard-code the value, so upgrading to the latest version is recommended.
## Q: Is there a server version requirement for using the CLI (publish / download, etc.)?
@ -246,6 +254,75 @@ docker image inspect ghcr.io/iflytek/skillhub-server:latest --format '{{index .C
- Check the CLI version: `skillhub version`.
- For customization (e.g. changing the logo), it is recommended to fork the latest code, modify it, and build your own Docker image.
## Q: The page loads, but the login / register APIs return 502?
A: The page is served by the `web` container, while login, register and other APIs are proxied by `web` to `server` (default `SKILLHUB_API_UPSTREAM=http://server:8080`). When the page works but the API returns 502, check whether `server` started correctly first; a wrong upstream, DNS, or container-network problem can also produce a 502.
Troubleshooting order:
```bash
# 1. Check whether server is running
docker compose --env-file .env.release -f compose.release.yml ps
# 2. Look at the first error in the server startup log
docker compose --env-file .env.release -f compose.release.yml logs server | head -50
```
One common startup failure is:
```
SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET must not use the default placeholder
```
This means `server` still reads the placeholder from the template. Replace it in `.env.release` with your own random string (**at least 32 characters**) and recreate the containers:
```bash
SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET=<your own random string, at least 32 characters>
```
Running `make validate-release-config` before startup validates `.env.release` and surfaces placeholders and missing values early.
## Q: Why doesn't my configuration change take effect?
A: Two common causes:
1. **Edited the wrong file**: `.env.release.example` is only a template; Compose reads the file passed via `--env-file`, i.e. `.env.release`. Run `cp .env.release.example .env.release` first, then edit `.env.release`.
2. **Restarted instead of recreated**: environment variables are injected when the container is created, and `restart` does not re-inject them. Recreate the containers after a config change:
```bash
docker compose --env-file .env.release -f compose.release.yml up -d --force-recreate
```
## Q: What external dependencies does SkillHub require at runtime?
A: PostgreSQL and Redis are required. Object storage supports both `local` and S3, controlled by `SKILLHUB_STORAGE_PROVIDER`. `.env.release.example` explicitly selects `local`, but if the variable is completely unset when using `compose.release.yml`, the Compose fallback is `s3`. Set it explicitly; S3 is recommended for production (configured via `SKILLHUB_STORAGE_S3_*`). Only PostgreSQL is supported as the database — MySQL is not.
The release Compose file already bundles PostgreSQL and Redis, bound to `127.0.0.1` by default.
## Q: How does an account created through OAuth (GitHub / GitLab, etc.) get admin rights?
A: The first OAuth login creates a regular user. An existing `SUPER_ADMIN` (for example the bootstrap admin created during initialization) has to promote it from the admin console.
A `USER_ADMIN` can manage user status and assign platform roles other than `SUPER_ADMIN`, but cannot grant `SUPER_ADMIN` to any account or change the role of an existing `SUPER_ADMIN`. Only a `SUPER_ADMIN` can perform those two operations.
## Q: How do I install multiple skills in bulk?
A: The CLI `install` command handles one skill at a time. Both examples below use `--dir` to install the skills under the same target root; each skill is placed in `$target_dir/<skill-slug>/`:
```bash
target_dir=/opt/skillhub-skills
# install one by one
for skill in skill-a skill-b skill-c; do
skillhub install "$skill" --dir "$target_dir"
done
# or read from a manifest file (one skill name per line)
xargs -a skills.txt -I {} skillhub install "{}" --dir "$target_dir"
```
Since **SkillHub Server v0.2.12**, public skills support anonymous search and install. Note that an invalid bearer token now fails the command instead of falling back to anonymous access — update or remove the stale credential in that case.
## Q: What should I do if I encounter issues?
A: You can get help through the following channels:

View file

@ -83,6 +83,8 @@ skillhub login --token sk_xxx --registry https://skillhub.example.com
`login` validates the token, stores it in `~/.skillhub/credentials.json`, and writes the registry to `~/.skillhub/config.json`.
When an API-token request is denied, the CLI shows the safe reason returned by the server and its `Request ID`. Use that ID to correlate the failure with server logs. Other authorization failures continue to use a generic message.
### Check Current Identity
```bash
@ -478,12 +480,18 @@ Search published skills.
### install
```bash
skillhub install <slug> [options]
skillhub install <coordinate> [options]
```
`<coordinate>` accepts a bare slug (`my-skill`, resolved as `global/my-skill`)
or any of the equivalent explicit namespace forms: `team/my-skill`,
`@team/my-skill`, and `team--my-skill`. Use `--namespace team` to select a
non-global namespace for a bare slug. An explicit coordinate may be combined
with the same `--namespace`; a conflicting value is rejected as a usage error.
Options:
- `--scope <user|project>` — Install scope (omit for interactive prompt in TTY, or fall back to existing detection in non-TTY)
- `--namespace <slug>` — Namespace (default: `global`)
- `--namespace <slug>` — Namespace for a bare slug
- `--version <v>` — Version (default: latest)
- `--agent <profile>` — Agent profile (repeatable)
- `--dir <path>` — Custom installation directory (mutually exclusive with `--scope` and `--agent`)

View file

@ -156,10 +156,18 @@ A: 该现象多见于「手动部署」场景(接口异常或初始化未完
## Q: 如何修改 admin 密码?修改配置后不生效?
A: 环境变量在容器启动时读取,修改后必须重启容器才会生效
A: 环境变量在容器创建时注入,修改后必须重新创建容器才会生效;仅执行 `restart` 不会重新注入环境变量
1. 修改运行时目录下的 `/tmp/skillhub-runtime/.env.release`(参考仓库 [.env.release.example](https://github.com/iflytek/skillhub/blob/main/.env.release.example))。
2. 重启相关容器。
2. 重新创建相关容器:
```bash
docker compose \
--env-file /tmp/skillhub-runtime/.env.release \
-f /tmp/skillhub-runtime/compose.release.yml \
up -d --force-recreate
```
3. 若此前密码已写入数据库导致仍不生效,可能需要清理对应数据后重新初始化。
## Q: 修改 / 找回密码必须使用邮箱验证码吗?
@ -219,7 +227,7 @@ A: 默认上限为 **100 个文件**(这与 100MB 的大小限制是两回事
SKILLHUB_PUBLISH_MAX_FILE_COUNT=500
```
修改后需重启容器生效。注意 `compose.release.yml` 中也需引用该变量;较旧版本(如 v0.2.6)可能将该值写死,建议升级到最新版本。
修改后需重新创建容器才会生效;仅执行 `restart` 不会重新注入环境变量。注意 `compose.release.yml` 中也需引用该变量;较旧版本(如 v0.2.6)可能将该值写死,建议升级到最新版本。
## Q: 使用 CLI发布 / 下载等)对服务端版本有要求吗?
@ -246,6 +254,75 @@ docker image inspect ghcr.io/iflytek/skillhub-server:latest --format '{{index .C
- 查看 CLI 版本:`skillhub version`
- 如需定制(如修改 logo 等),建议基于最新代码进行二次开发并自行构建 docker 镜像。
## Q: 页面能打开,但登录 / 注册接口返回 502
A: 页面由 `web` 容器提供,登录、注册等接口由 `web` 转发给 `server`(默认 `SKILLHUB_API_UPSTREAM=http://server:8080`)。出现「页面正常但 API 502」时通常先检查 `server` 是否正常启动upstream 配置、DNS 或容器网络异常也可能返回 502。
排查顺序:
```bash
# 1. 看 server 是否处于运行状态
docker compose --env-file .env.release -f compose.release.yml ps
# 2. 看 server 启动日志中的第一条错误
docker compose --env-file .env.release -f compose.release.yml logs server | head -50
```
一条常见的启动失败日志是:
```
SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET must not use the default placeholder
```
说明 `server` 读到的仍是模板里的占位值。在 `.env.release` 中改成自己的随机字符串(**至少 32 个字符**)后重建容器即可:
```bash
SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET=<替换成你自己的随机字符串至少 32 个字符>
```
启动前可以先执行 `make validate-release-config`,它会校验 `.env.release`,提前暴露这类占位值和缺失项。
## Q: 改了配置为什么不生效?
A: 两个高频原因:
1. **改错了文件**`.env.release.example` 只是模板Compose 实际读取的是 `--env-file` 指定的 `.env.release`。请先 `cp .env.release.example .env.release`,然后修改 `.env.release`
2. **只重启没重建**:环境变量在容器创建时注入,`restart` 不会重新注入。改完配置需要重建容器:
```bash
docker compose --env-file .env.release -f compose.release.yml up -d --force-recreate
```
## Q: SkillHub 运行时需要哪些外部依赖?
A: 必需 PostgreSQL 和 Redis对象存储支持 `local` 与 S3 两种模式,由 `SKILLHUB_STORAGE_PROVIDER` 控制。`.env.release.example` 显式配置为 `local`,但如果使用 `compose.release.yml` 时完全没有设置该变量Compose 的回退值是 `s3`。建议始终显式设置;生产环境推荐使用 S3通过 `SKILLHUB_STORAGE_S3_*` 配置)。数据库仅支持 PostgreSQL暂不支持 MySQL。
发布版 Compose 已内置 PostgreSQL 与 Redis默认只绑定在 `127.0.0.1`
## Q: 通过 OAuthGitHub / GitLab 等)登录的账号,如何取得管理员权限?
A: OAuth 首次登录创建的是普通用户。需要由已有的 `SUPER_ADMIN`(例如初始化时的 bootstrap admin在后台将其提升为管理员。
`USER_ADMIN` 可以管理用户状态,并分配除 `SUPER_ADMIN` 之外的平台角色;但不能向任何账号授予 `SUPER_ADMIN`,也不能修改已有 `SUPER_ADMIN` 账号的角色。这两类操作只有 `SUPER_ADMIN` 可以执行。
## Q: 如何批量安装多个技能包?
A: CLI 的 `install` 一次处理一个技能包。下面两个示例都通过 `--dir` 将技能批量安装到同一个目标根目录;每个技能实际位于 `$target_dir/<skill-slug>/`
```bash
target_dir=/opt/skillhub-skills
# 逐个安装
for skill in skill-a skill-b skill-c; do
skillhub install "$skill" --dir "$target_dir"
done
# 或从清单文件读取(每行一个技能名)
xargs -a skills.txt -I {} skillhub install "{}" --dir "$target_dir"
```
**SkillHub Server v0.2.12** 起,公开技能支持匿名搜索与安装;如果配置了无效的 Bearer Token命令会直接失败而不再回退匿名访问遇到这种情况请更新凭据或先移除无效 Token。
## Q: 遇到问题怎么办?
A: 可以通过以下方式获取帮助:

View file

@ -83,6 +83,8 @@ skillhub login --token sk_xxx --registry https://skillhub.example.com
`login` 会验证 token 有效性,然后将 token 存储到 `~/.skillhub/credentials.json`,同时将 registry 写入 `~/.skillhub/config.json`
API Token 请求被拒绝时CLI 会显示服务端返回的具体原因和 `Request ID`。排查问题时可使用该 ID 对照服务端日志;非 API Token 的授权失败仍只显示通用信息。
### 查看当前身份
```bash
@ -478,12 +480,17 @@ skillhub search <query> [--registry <url>] [--limit <n>] [--json]
### install
```bash
skillhub install <slug> [options]
skillhub install <coordinate> [options]
```
`<coordinate>` 支持裸 slug`my-skill`,解析为 `global/my-skill`)以及
`team/my-skill``@team/my-skill``team--my-skill` 三种等价的显式
namespace 形式。裸 slug 可通过 `--namespace team` 选择非 global namespace
显式坐标可以同时传入相同的 `--namespace`,但冲突值会作为用法错误被拒绝。
选项:
- `--scope <user|project>` — 安装范围不传时TTY 模式下交互式询问,非 TTY 模式沿用现有探测逻辑)
- `--namespace <slug>` — namespace默认 `global`
- `--namespace <slug>`为裸 slug 指定 namespace
- `--version <v>` — 版本(默认最新版本)
- `--agent <profile>` — Agent 配置(可重复)
- `--dir <path>` — 自定义安装目录(与 `--scope``--agent` 互斥)

View file

@ -8,6 +8,24 @@
**Tech Stack:** TypeScript, Bun test/build, cac, npm package tarballs.
## Completion record
Completed in PR #608 and revalidated after merging `origin/main` on 2026-07-29.
The checklist below reflects the delivered implementation. The maintainer
revalidation did not recreate historical RED states; it reran the current
GREEN gates with the repository-pinned Bun 1.3.13:
- Focused namespace/error/help regression: 142 tests passed.
- Complete CLI regression: 373 tests passed with
`bun test --max-concurrency=1` (peak RSS 170224 KiB).
- Typecheck, lint, and build passed.
- The packed `@astron-team/skillhub@0.1.9` artifact contained `dist/index.js`,
`README.md`, `CHANGELOG.md`, `LICENSE`, and `package.json`.
- Packed Node artifact smoke passed for `version`, `help install`, and
`remove --help`; the install help listed all three namespaced coordinate
forms.
- The Chinese and English VitePress documentation build passed.
---
### Task 1: Establish release artifact baseline
@ -16,7 +34,7 @@
- Inspect: `cli/package.json`
- Inspect: npm package `@astron-team/skillhub@0.1.9`
- [ ] **Step 1: Read published metadata and download the package**
- [x] **Step 1: Read published metadata and download the package**
Run:
@ -28,7 +46,7 @@ npm pack @astron-team/skillhub@0.1.9 --pack-destination /tmp/skillhub-npm-019-is
Expected: version `0.1.9`, a tarball with `dist/index.js`, `README.md`,
`LICENSE`, and `package.json`.
- [ ] **Step 2: Confirm the published bundle contains both bug signatures**
- [x] **Step 2: Confirm the published bundle contains both bug signatures**
Run:
@ -45,14 +63,14 @@ parser but also the misleading 403 fallback.
- Modify: `cli/test/unit/shared/skill-name-parser.test.ts`
- Modify: `cli/src/shared/skill-name-parser.ts`
- [ ] **Step 1: Replace permissive edge tests with the public coordinate matrix**
- [x] **Step 1: Replace permissive edge tests with the public coordinate matrix**
Add table-driven assertions for `my-skill`, `team/my-skill`,
`@team/my-skill`, and `team--my-skill`. Add resolver assertions for an explicit
namespace on a bare slug, a matching coordinate namespace, and a conflicting
namespace. Add malformed-input assertions for empty or incomplete coordinates.
- [ ] **Step 2: Run the parser test and verify RED**
- [x] **Step 2: Run the parser test and verify RED**
Run:
@ -62,14 +80,14 @@ cd cli && bun test test/unit/shared/skill-name-parser.test.ts
Expected: failures for slash forms, malformed input, and the missing resolver.
- [ ] **Step 3: Implement the minimal parser and resolver**
- [x] **Step 3: Implement the minimal parser and resolver**
Keep `ParsedSkillName` unchanged. Add `resolveSkillName(skillName,
explicitNamespace?)` returning `ParsedSkillName`. It calls one internal parser,
applies `global` only to bare slugs, accepts a matching explicit namespace, and
throws `CliError(..., EXIT.usage)` on malformed input or conflict.
- [ ] **Step 4: Run the parser test and verify GREEN**
- [x] **Step 4: Run the parser test and verify GREEN**
Run:
@ -88,7 +106,7 @@ Expected: all parser tests pass with no warnings.
- Modify: `cli/test/unit/commands/install-command.test.ts`
- Modify: `cli/test/integration/install-command.test.ts`
- [ ] **Step 1: Add failing command and integration tests**
- [x] **Step 1: Add failing command and integration tests**
Capture `installSkill` options in the unit test and assert a namespaced
coordinate passes `namespace: 'team'` and `slug: 'my-skill'`. In the integration
@ -102,7 +120,7 @@ Assert exit 0, JSON namespace `team`, and fake-registry resolve state
`{ namespace: 'team', slug: 'my-skill' }`. Add a conflicting
`--namespace other` case that exits with usage code 5 before registry access.
- [ ] **Step 2: Run the focused command tests and verify RED**
- [x] **Step 2: Run the focused command tests and verify RED**
Run:
@ -113,7 +131,7 @@ cd cli && bun test test/unit/commands/install-command.test.ts test/integration/i
Expected: the namespaced integration case resolves `global` or fails, and the
conflict case does not produce the expected usage error.
- [ ] **Step 3: Use `resolveSkillName` and remove the cac default**
- [x] **Step 3: Use `resolveSkillName` and remove the cac default**
Change install/remove to call:
@ -127,7 +145,7 @@ Change install's option declaration to:
.option('--namespace <slug>', 'Namespace for a bare skill slug')
```
- [ ] **Step 4: Run the focused command tests and verify GREEN**
- [x] **Step 4: Run the focused command tests and verify GREEN**
Run the same Bun test command. Expected: all focused command tests pass.
@ -139,7 +157,7 @@ Run the same Bun test command. Expected: all focused command tests pass.
- Modify: `cli/src/clients/skillhub-client.ts`
- Modify: `cli/src/shared/output.ts`
- [ ] **Step 1: Add failing response and output tests**
- [x] **Step 1: Add failing response and output tests**
Add client tests for:
@ -155,7 +173,7 @@ Assert message `token has been revoked`, auth exit code, and details containing
404 with structured fields. Add a download 403 structured-response test. Add a
human output assertion for `Request ID: req-403`.
- [ ] **Step 2: Run focused tests and verify RED**
- [x] **Step 2: Run focused tests and verify RED**
Run:
@ -166,7 +184,7 @@ cd cli && bun test test/unit/clients/skillhub-client.test.ts test/unit/shared/ou
Expected: structured messages/request IDs are discarded and human output omits
the request ID.
- [ ] **Step 3: Implement one safe response-error converter**
- [x] **Step 3: Implement one safe response-error converter**
Inside `SkillHubClient`, add a private method that reads non-success bodies once,
parses only object-shaped JSON, accepts only non-empty string `msg` and
@ -180,7 +198,7 @@ if (typeof cliError.details.requestId === 'string') {
}
```
- [ ] **Step 4: Run focused tests and verify GREEN**
- [x] **Step 4: Run focused tests and verify GREEN**
Run the same focused Bun test command. Expected: all client/output tests pass.
@ -193,12 +211,12 @@ Run the same focused Bun test command. Expected: all client/output tests pass.
- Modify: `cli/package.json`
- Modify: `cli/test/integration/help-command.test.ts`
- [ ] **Step 1: Add a failing help assertion**
- [x] **Step 1: Add a failing help assertion**
Assert `skillhub help install` includes `@team/my-skill`,
`team/my-skill`, and `team--my-skill` examples.
- [ ] **Step 2: Run the help test and verify RED**
- [x] **Step 2: Run the help test and verify RED**
Run:
@ -208,14 +226,14 @@ cd cli && bun test test/integration/help-command.test.ts
Expected: the coordinate examples are absent.
- [ ] **Step 3: Update help, README, and release notes**
- [x] **Step 3: Update help, README, and release notes**
Use `<coordinate>` in install usage. Document all accepted forms and the
same-namespace/conflict rule. Add an Unreleased changelog entry covering
coordinate normalization and structured 403 messages/request IDs. Include
`CHANGELOG.md` in the npm package `files` list.
- [ ] **Step 4: Run the help test and verify GREEN**
- [x] **Step 4: Run the help test and verify GREEN**
Run the same Bun test command. Expected: all help tests pass.
@ -226,7 +244,7 @@ Run the same Bun test command. Expected: all help tests pass.
- Produce locally: `cli/dist/index.js`
- Produce locally: npm tarball under `/tmp`
- [ ] **Step 1: Run the complete CLI quality gate**
- [x] **Step 1: Run the complete CLI quality gate**
Run:
@ -239,7 +257,7 @@ cd cli && bun run build
Expected: every command exits 0 with no errors or warnings.
- [ ] **Step 2: Pack and inspect the candidate artifact**
- [x] **Step 2: Pack and inspect the candidate artifact**
Run:
@ -251,14 +269,14 @@ tar -tf /tmp/skillhub-cli-issue-606/astron-team-skillhub-0.1.9.tgz
Expected: the package contains the built executable, README, changelog,
license, and package metadata.
- [ ] **Step 3: Run packed-bundle smoke checks**
- [x] **Step 3: Run packed-bundle smoke checks**
Extract the tarball to a temporary directory and run the built executable's
`version` and `help install` commands. Expected: version reports 0.1.9 and help
shows every coordinate form. Run the relevant unit/integration suites against
source to verify request paths and structured errors.
- [ ] **Step 4: Review the diff and commit**
- [x] **Step 4: Review the diff and commit**
Run:
@ -282,14 +300,14 @@ git commit -m "fix(cli): normalize namespace coordinates and errors (#606)"
**Files:**
- Review: committed diff against `origin/main`
- [ ] **Step 1: Run tester and reviewer gates**
- [x] **Step 1: Run tester and reviewer gates**
The tester must confirm focused and full CLI gates plus package smoke evidence.
The reviewer must inspect coordinate compatibility, error disclosure, test
coverage, docs, commit metadata, and absence of unrelated changes. Resolve all
blocking findings before continuing.
- [ ] **Step 2: Push only the assigned branch**
- [x] **Step 2: Push only the assigned branch**
Run:
@ -299,7 +317,7 @@ git push -u origin fix/cli-namespace-errors
Expected: only the assigned branch is created or updated remotely.
- [ ] **Step 3: Create one PR linked to the issue**
- [x] **Step 3: Create one PR linked to the issue**
Create one PR titled `fix(cli): normalize namespace coordinates and errors`
with `Related to #606` in the body, complete test/package evidence, docs and

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,279 @@
# Revoked API Token Validation Design
## Goal
Prove and preserve fail-closed API-token behavior across the CLI API using a
real persisted token lifecycle. Invalid Bearer credentials must return HTTP
401 before endpoint business logic runs, including when a valid Web Session is
also present. A valid Bearer credential overrides the Session identity. When
Bearer is absent or the Authorization scheme is unsupported, the existing Web
Session identity is preserved; without a valid Session, public reads remain
anonymous and `whoami` returns 401. Valid credentials without sufficient
authorization continue to return HTTP 403.
## Scope
This change covers the following CLI routes:
- `GET /api/cli/v1/auth/whoami`
- `GET /api/cli/v1/skills/search`
- `GET /api/cli/v1/skills/{namespace}/{slug}/resolve`
- `GET /api/cli/v1/skills/{namespace}/{slug}/download`
- `GET /api/cli/v1/skills/{namespace}/{slug}/versions/{version}/download`
It also covers the authenticated-versus-forbidden boundary on the affected
restricted read routes. An existing scope-protected CLI route may provide
supplementary scope-filter evidence only. This change does not add endpoints,
change runtime response fields, change token storage, add a database migration,
or change anonymous resource visibility rules. The OpenAPI correction marks
the already-nullable `whoami.email` value accurately without changing its JSON
field presence.
## Current-State Finding
The fail-closed implementation from closed PR #511 was later included in the
single replacement PR #523 and is present in both v0.2.14 and current `main`.
`ApiTokenAuthenticationFilter` already validates Bearer credentials before
business logic and rejects empty, malformed, unknown, expired, revoked,
missing-user, and disabled-user credentials through the configured
`AuthenticationEntryPoint`.
The verified repository gap is regression coverage, not a demonstrated
production-code gap. Existing tests separately prove token lifecycle
validation and invalid-Bearer filtering, but they do not exercise persisted
token creation, revocation, and all affected CLI endpoints in one integrated
matrix. The CLI API table in `docs/03-authentication-design.md` also retains
legacy paths, and there is no dedicated OpenAPI 3.0 authentication contract in
`docs/api/`.
The reported v0.2.14 runtime behavior still contradicts the source and test
evidence. Source equality alone does not establish which artifact or replica
served the reported requests. The defect therefore remains open until the
release artifact and affected runtime are identified and the same token
lifecycle is replayed against that identified runtime.
## Release Artifact and Runtime Identity Gate
Runtime verification is a required investigation track, not an optional
deployment check. Before interpreting a runtime result, record all of the
following for every server replica that may receive the request:
1. The configured deployment version and resolved image reference from the
runtime environment and `docker compose config --images`.
2. The running container's image ID and registry `RepoDigest` from
`docker inspect` / `docker image inspect`.
3. The OCI `org.opencontainers.image.revision` and
`org.opencontainers.image.version` labels. The publish workflow generates
these labels and also publishes a `sha-<short-sha>` tag, so the revision can
be mapped back to a repository commit.
4. The externally observed application URL, health result, deployment profile,
and request IDs for the authentication probes.
If the revision label is absent, the image digest must be mapped to the
corresponding publish-images workflow output or registry manifest. A mutable
tag such as `latest` or `v0.2.14` is not sufficient identity evidence by
itself. If neither a revision nor a digest-to-build mapping can be obtained,
the source/runtime contradiction is unresolved and the defect cannot be
closed.
Using a dedicated test user and non-production token, replay one lifecycle
against the identified running image:
1. Issue the token and call every matrix endpoint while it is valid.
2. Revoke that same token through the normal product flow and verify its
persisted `revoked_at` value without exposing the raw token.
3. Reuse the same raw token against every matrix endpoint and capture status,
response envelope, request ID, timestamp, and serving replica when
available.
4. Repeat or pin requests per replica when a load balancer can route to mixed
versions, and compare the image digest/revision of each replica.
If production mutation is not authorized, run the exact identified digest in
an approved isolated environment with equivalent auth/proxy configuration and
record that limitation. This does not by itself close the original field
report: an authorized runtime replay or owner-provided equivalent evidence is
still required.
The contradiction is closed only when source commit, published image digest,
running instance identity, and replay result form one consistent chain. A
mismatched digest indicates deployment drift; identical application images
with divergent behavior require investigation of proxy header forwarding,
mixed replicas, session/cookie contamination, and request routing before any
source-code conclusion is accepted.
## Architecture
`ApiTokenAuthenticationFilter` remains the single Bearer-authentication entry
point. Spring Security loads an existing Web Session identity before the token
filter runs. A valid Bearer token replaces that identity; an invalid, empty, or
malformed Bearer attempt clears it and returns 401. The filter ignores Basic
and other non-Bearer schemes, preserving the loaded Session identity. If no
Session exists, those schemes reach public reads anonymously and `whoami`
returns 401. Controllers must not duplicate token parsing, Session resolution,
or lifecycle checks.
The regression test will boot the Spring application with MockMvc, real
`ApiTokenService`, real `ApiTokenRepository`, and real user persistence. CLI
endpoint business services may be mocked only to make successful public-read
responses deterministic; authentication and token lifecycle components remain
real. This isolates the contract boundary under test: a rejected credential
must stop in the security chain before controller business logic executes.
The restricted-read authorization test is separate and must not mock the
permission decision. It will persist a PRIVATE or NAMESPACE_ONLY skill owned by
another user, authenticate a valid outsider token with no qualifying namespace
role, and exercise the real `CliSkillAppService` plus domain query/download
authorization path. At least `resolve`, latest download, and versioned download
must return HTTP 403. A DELETE request with a missing token scope may supplement
this check, but cannot replace any affected read-path assertion.
Production authentication code will be changed only when a new regression
test fails for the expected behavioral reason. Any fix must be the smallest
change at the shared authentication or token-validation source of the failure.
Endpoint-specific authentication patches and unrelated refactoring are out of
scope.
## Persisted Token Lifecycle
The test fixture creates an active user and issues a token through
`ApiTokenService`, retaining only the raw token returned at creation time.
Lifecycle transitions use production persistence paths:
1. Call an affected endpoint with the valid raw token and confirm successful
authentication.
2. Revoke the token through `ApiTokenService.revokeToken`.
3. Call every affected endpoint with the same raw token.
4. Assert HTTP 401 and confirm protected endpoint business logic was not
reached.
Expired-token coverage persists a token with an expiration timestamp earlier
than the service clock, then validates it through the same filter and
repository path. Unknown and malformed tokens exercise the same HTTP security
chain without creating a token row.
## Behavioral Matrix
The authentication rows use deterministic public fixtures. Latest and
versioned downloads are independent endpoints and must have independent test
arguments and assertions for every credential state.
| Credential state | `whoami` | Public `search` | Public `resolve` | Public latest download | Public versioned download | Meaning |
|---|---:|---:|---:|---:|---:|---|
| No `Authorization` header | 401 | 200 | 200 | Existing 200/302 success | Existing 200/302 success | Anonymous access is preserved only where already public |
| Basic or another non-Bearer scheme | 401 | 200 | 200 | Existing 200/302 success | Existing 200/302 success | Unsupported schemes are not treated as API-token attempts |
| Valid Web Session, no `Authorization` header | 200 as Session user | 200 as Session user | 200 as Session user | Existing 200/302 as Session user | Existing 200/302 as Session user | Existing browser identity is preserved |
| Valid Web Session + Basic | 200 as Session user | 200 as Session user | 200 as Session user | Existing 200/302 as Session user | Existing 200/302 as Session user | Non-Bearer schemes do not erase Session identity |
| Valid active token | 200 | 200 | 200 | Existing 200/302 success | Existing 200/302 success | Principal and roles/scopes are projected |
| Valid Web Session + valid active token | 200 as token user | 200 as token user | 200 as token user | Existing 200/302 as token user | Existing 200/302 as token user | Bearer identity overrides Session identity |
| Valid Web Session + revoked token | 401 | 401 | 401 | 401 | 401 | Credential cannot fall back to Session or anonymous |
| Valid Web Session + expired token | 401 | 401 | 401 | 401 | 401 | Credential cannot fall back to Session or anonymous |
| Valid Web Session + unknown token | 401 | 401 | 401 | 401 | 401 | Credential cannot fall back to Session or anonymous |
| Valid Web Session + empty Bearer credential | 401 | 401 | 401 | 401 | 401 | Empty authentication attempt is rejected before business logic |
| Valid Web Session + malformed Bearer credential | 401 | 401 | 401 | 401 | 401 | Malformed authentication attempt is rejected before business logic |
The authorization row uses a persisted PRIVATE or NAMESPACE_ONLY fixture and
the real read-authorization path:
| Valid credential, insufficient resource permission | `whoami` | `search` | Restricted `resolve` | Restricted latest download | Restricted versioned download |
|---|---:|---:|---:|---:|---:|
| Outsider token with no qualifying namespace role | 200 | 200 with restricted skill omitted | 403 | 403 | 403 |
The same fixture must also prove that an authorized owner or qualifying
namespace member can reach the restricted read path, so a 403 cannot be caused
by an invalid fixture. Missing-scope DELETE coverage is optional supplementary
evidence for the API-token scope filter only.
## Error Handling and Security
- Invalid Bearer credentials return the existing structured HTTP 401 response
through `ApiAuthenticationEntryPoint`.
- Valid credentials that fail scope or resource authorization return the
existing structured HTTP 403 response through the access-denied path.
- Responses must not reveal whether a token is unknown, expired, or revoked.
- Tests, logs, documentation, and commits must not contain real secrets. Test
credentials are generated locally and exist only in the in-memory test
database.
- Token material must never be logged.
## Documentation
Two documentation updates are required:
1. Update `docs/03-authentication-design.md` so the CLI API section uses the
current `/api/cli/v1/...` routes and explicitly states Bearer-over-Session
priority, Session fallback, and the anonymous/401/403 boundary.
2. Add `docs/api/authentication.openapi.yaml` using OpenAPI 3.0. The document
must define Bearer and Web Session authentication, all affected paths,
query/path parameters, success schemas, the common response envelope, HTTP
401 and 403 responses, examples, credential priority, and the rule that
requests without either identity are allowed only on existing public-read
routes. `CliWhoAmI.email` remains required but is nullable.
No controller signature or response schema changes are planned. Therefore the
generated `web/src/api/generated/schema.d.ts` should remain unchanged; if a
production fix unexpectedly changes a controller contract, `make generate-api`
becomes mandatory and the generated diff must be committed.
## Implementation Plan Requirements
The detailed implementation plan must preserve the following independent
steps rather than collapsing them into one generic download case:
1. Create the real persisted token/user fixture and public endpoint stubs used
by the authentication matrix.
2. Exercise `whoami`, `search`, and `resolve` for every credential state.
3. Exercise latest download for every credential state.
4. Exercise versioned download for every credential state.
5. Persist a restricted skill plus authorized and unauthorized users, then use
the real read-authorization path to prove 403 for restricted `resolve`,
latest download, and versioned download and success for an authorized user.
6. Update the authentication design and OpenAPI contract.
7. Exercise Session-only, Session + Basic, Basic-only, and Session + valid or
invalid Bearer independently on all five endpoints; latest and versioned
download remain separate cases.
8. Prove PRIVATE search omission with a non-empty same-keyword PUBLIC result
and assert the fixed five-field 403 envelope on each restricted read.
9. Identify the published/running image and replay the valid-to-revoked token
lifecycle against that exact digest, or record the external access blocker
without treating the field contradiction as resolved.
Each endpoint/state step must state its own expected status and test command.
The plan may share fixture helpers, but it must not share one assertion in a
way that can skip either download route.
## Verification
Verification proceeds in this order:
1. Run the new focused persisted-token matrix and record whether it fails or
passes on unmodified `main` behavior, with separate results for latest and
versioned download.
2. Run the persisted restricted-resource checks through real query/download
authorization and record outsider 403 plus authorized-user success.
3. If an authentication row fails, preserve the failure output as reproduction
evidence, apply one minimal shared fix, and rerun the focused matrix.
4. Run auth-module and affected app integration tests.
5. Run `make test-backend-app`.
6. Run `make typecheck-web` and `make lint-web` as repository pre-PR gates.
7. Run `make staging` for containerized regression and smoke coverage.
8. Run `git diff --check` and confirm no generated OpenAPI type drift when no
controller contract changed.
9. Record the release tag, build revision, image reference, immutable digest,
and every serving replica's running image identity.
10. Replay the same valid-to-revoked token lifecycle against the identified
runtime and record endpoint-level status, request ID, and replica evidence,
keeping latest and versioned download results separate.
11. Perform structured security and code review before updating the existing
single final pull request.
## Delivery Constraints
- Work only on `fix/auth-revoked-token-validation`.
- Keep PR #511 closed and use it only as historical reference.
- Create exactly one final pull request for GitHub issue #605.
- GitHub-facing text must not contain a Multica issue identifier.
- Do not mark the defect resolved or eligible for closure while the reported
runtime behavior and the identified artifact/runtime replay remain
contradictory or incomplete.
- Do not merge `main`; merging remains the responsibility of an explicitly
authorized human owner.

View file

@ -0,0 +1,101 @@
#!/usr/bin/env bash
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
TEMPLATE="$REPO_ROOT/web/nginx.conf.template"
NGINX_IMAGE="${NGINX_TEST_IMAGE:-nginx:alpine}"
TEST_ID="skillhub-nginx-forwarded-proto-$$"
NETWORK="${TEST_ID}-network"
BACKEND="${TEST_ID}-backend"
DEFAULT_PROXY="${TEST_ID}-default"
TRUSTED_PROXY="${TEST_ID}-trusted"
TMP_DIR="$(mktemp -d)"
CONTAINERS=()
cleanup() {
if ((${#CONTAINERS[@]} > 0)); then
docker rm -f "${CONTAINERS[@]}" >/dev/null 2>&1 || true
fi
docker network rm "$NETWORK" >/dev/null 2>&1 || true
rm -rf "$TMP_DIR"
}
trap cleanup EXIT
fail() {
echo "FAIL: $*" >&2
exit 1
}
wait_for_nginx() {
local container="$1"
local attempt
for attempt in {1..30}; do
if docker exec "$container" wget -qO- http://127.0.0.1/nginx-health >/dev/null 2>&1; then
return 0
fi
sleep 0.2
done
docker logs "$container" >&2 || true
fail "$container did not become healthy"
}
start_proxy() {
local container="$1"
local trust_forwarded_proto="$2"
docker run --detach \
--name "$container" \
--network "$NETWORK" \
--env "SKILLHUB_API_UPSTREAM=http://$BACKEND:8080" \
--env "SKILLHUB_TRUST_FORWARDED_PROTO=$trust_forwarded_proto" \
--volume "$TEMPLATE:/etc/nginx/templates/default.conf.template:ro" \
"$NGINX_IMAGE" >/dev/null
CONTAINERS+=("$container")
wait_for_nginx "$container"
}
assert_proto() {
local container="$1"
local expected="$2"
local header="${3:-}"
local path="${4:-/api/proto}"
local actual
if [[ -n "$header" ]]; then
actual="$(docker exec "$container" wget -qO- \
--header="X-Forwarded-Proto: $header" \
"http://127.0.0.1$path")"
else
actual="$(docker exec "$container" wget -qO- "http://127.0.0.1$path")"
fi
[[ "$actual" == "$expected" ]] \
|| fail "$container forwarded proto '$actual', expected '$expected' for $path with header '${header:-<none>}'"
}
cat >"$TMP_DIR/backend.conf" <<'EOF'
server {
listen 8080;
location / {
default_type text/plain;
return 200 $http_x_forwarded_proto;
}
}
EOF
docker network create "$NETWORK" >/dev/null
docker run --detach \
--name "$BACKEND" \
--network "$NETWORK" \
--volume "$TMP_DIR/backend.conf:/etc/nginx/conf.d/default.conf:ro" \
"$NGINX_IMAGE" >/dev/null
CONTAINERS+=("$BACKEND")
start_proxy "$DEFAULT_PROXY" false
start_proxy "$TRUSTED_PROXY" true
for path in /api/proto /oauth2/proto /login/oauth2/proto /.well-known/proto; do
assert_proto "$DEFAULT_PROXY" http https "$path"
assert_proto "$TRUSTED_PROXY" https https "$path"
done
assert_proto "$TRUSTED_PROXY" http
assert_proto "$TRUSTED_PROXY" http "https,http"
echo "nginx-forwarded-proto-test passed"

View file

@ -36,6 +36,7 @@ POSTGRES_USER=skillhub
POSTGRES_PASSWORD=strong-postgres-password
SESSION_COOKIE_SECURE=true
BOOTSTRAP_ADMIN_ENABLED=false
SKILLHUB_TRUST_FORWARDED_PROTO=false
SKILLHUB_STORAGE_PROVIDER=s3
SKILLHUB_STORAGE_S3_ENDPOINT=https://storage.example.com
SKILLHUB_STORAGE_S3_BUCKET=skillhub
@ -80,6 +81,11 @@ short_env="$tmp/short.env"
write_env "$short_env" "too-short"
expect_fail "$short_env" "SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET must be at least 32 characters"
invalid_forwarded_proto_env="$tmp/invalid-forwarded-proto.env"
write_env "$invalid_forwarded_proto_env" "release-download-secret-32-bytes-minimum"
printf '%s\n' "SKILLHUB_TRUST_FORWARDED_PROTO=yes" >>"$invalid_forwarded_proto_env"
expect_fail "$invalid_forwarded_proto_env" "SKILLHUB_TRUST_FORWARDED_PROTO must be true or false"
draft_env="$tmp/draft.env"
while IFS= read -r line || [[ -n "$line" ]]; do
case "$line" in

View file

@ -64,8 +64,14 @@ grep -Fq '.env.release.draft' "$PR_SCRIPTS_WORKFLOW" \
|| fail "pr-scripts must run when release env draft changes"
grep -Fq 'compose.release.yml' "$PR_SCRIPTS_WORKFLOW" \
|| fail "pr-scripts must run when release compose changes"
grep -Fq 'web/Dockerfile' "$PR_SCRIPTS_WORKFLOW" \
|| fail "pr-scripts must run when the web image changes"
grep -Fq 'web/nginx.conf.template' "$PR_SCRIPTS_WORKFLOW" \
|| fail "pr-scripts must run when the nginx template changes"
grep -Fq 'bash scripts/tests/validate-release-config-test.sh' "$PR_SCRIPTS_WORKFLOW" \
|| fail "pr-scripts must run validate-release-config-test"
grep -Fq 'bash scripts/tests/nginx-forwarded-proto-test.sh' "$PR_SCRIPTS_WORKFLOW" \
|| fail "pr-scripts must run nginx-forwarded-proto-test"
grep -Fq 'bash scripts/tests/runtime-secret-test.sh' "$PR_SCRIPTS_WORKFLOW" \
|| fail "pr-scripts must run runtime-secret-test"
grep -Fq 'bash scripts/tests/dev-web-host-test.sh' "$PR_SCRIPTS_WORKFLOW" \

View file

@ -151,6 +151,7 @@ reject_patterns SPRING_MAIL_PASSWORD "TODO_*" "todo_*" "replace*"
validate_boolean SESSION_COOKIE_SECURE
validate_boolean BOOTSTRAP_ADMIN_ENABLED
validate_boolean SKILLHUB_TRUST_FORWARDED_PROTO
validate_boolean SKILLHUB_STORAGE_S3_FORCE_PATH_STYLE
validate_boolean SKILLHUB_STORAGE_S3_AUTO_CREATE_BUCKET

View file

@ -1,6 +1,7 @@
package com.iflytek.skillhub.security;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.iflytek.skillhub.auth.token.ApiTokenAccessDeniedException;
import com.iflytek.skillhub.dto.ApiResponse;
import com.iflytek.skillhub.dto.ApiResponseFactory;
import jakarta.servlet.http.HttpServletRequest;
@ -38,14 +39,25 @@ public class ApiAccessDeniedHandler implements AccessDeniedHandler {
public void handle(HttpServletRequest request,
HttpServletResponse response,
AccessDeniedException accessDeniedException) throws IOException {
ApiTokenAccessDeniedException apiTokenException =
accessDeniedException instanceof ApiTokenAccessDeniedException typedException
? typedException
: null;
logger.info(
"Forbidden API request [requestId={}, method={}, path={}, reason={}]",
"Forbidden API request [requestId={}, method={}, path={}, reason={}, detail={}]",
MDC.get("requestId"),
request.getMethod(),
sensitiveLogSanitizer.sanitizeRequestTarget(request),
accessDeniedException.getClass().getSimpleName()
accessDeniedException.getClass().getSimpleName(),
apiTokenException != null ? apiTokenException.getMessage() : null
);
ApiResponse<Void> body = apiResponseFactory.error(403, "error.forbidden");
ApiResponse<Void> body = apiTokenException != null
? apiResponseFactory.error(
403,
apiTokenException.getMessageCode(),
apiTokenException.getMessageArgs()
)
: apiResponseFactory.error(403, "error.forbidden");
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
objectMapper.writeValue(response.getOutputStream(), body);

View file

@ -12,6 +12,7 @@ import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientProperties;
import org.springframework.stereotype.Service;
@ -43,6 +44,7 @@ public class AuthMethodCatalog {
public List<AuthProviderResponse> listOAuthProviders(String returnTo) {
String sanitizedReturnTo = OAuthLoginRedirectSupport.sanitizeReturnTo(returnTo);
return new ArrayList<>(oAuth2ClientProperties.getRegistration().entrySet().stream()
.filter(entry -> isValidOAuthProvider(entry.getValue()))
.sorted(Comparator.comparing(entry -> entry.getKey()))
.map(entry -> new AuthProviderResponse(
entry.getKey(),
@ -54,6 +56,17 @@ public class AuthMethodCatalog {
.toList());
}
/**
* Checks whether an OAuth provider has a non-empty, non-placeholder client ID.
*/
private boolean isValidOAuthProvider(OAuth2ClientProperties.Registration registration) {
String clientId = registration.getClientId();
if (clientId == null || clientId.isBlank()) {
return false;
}
return !clientId.toLowerCase(Locale.ROOT).contains("placeholder");
}
public List<AuthMethodResponse> listMethods(String returnTo) {
String sanitizedReturnTo = OAuthLoginRedirectSupport.sanitizeReturnTo(returnTo);
List<AuthMethodResponse> methods = new ArrayList<>();
@ -67,6 +80,7 @@ public class AuthMethodCatalog {
));
oAuth2ClientProperties.getRegistration().entrySet().stream()
.filter(entry -> isValidOAuthProvider(entry.getValue()))
.sorted(Comparator.comparing(entry -> entry.getKey()))
.forEach(entry -> methods.add(new AuthMethodResponse(
"oauth-" + entry.getKey(),

View file

@ -20,8 +20,13 @@ public class LabelSearchSyncService {
this.searchRebuildService = searchRebuildService;
}
@Async("skillhubEventExecutor")
public void rebuildSkill(Long skillId) {
searchRebuildService.rebuildBySkill(skillId);
try {
searchRebuildService.rebuildBySkill(skillId);
} catch (RuntimeException ex) {
log.error("Failed to rebuild search document for skill {}", skillId, ex);
}
}
@Async("skillhubEventExecutor")

View file

@ -48,6 +48,8 @@ error.auth.sessionBootstrap.providerUnsupported=Unsupported session bootstrap pr
error.auth.sessionBootstrap.notAuthenticated=No authenticated external session found
error.badRequest=Invalid request
error.forbidden=Forbidden
error.apiToken.scope.missing=API token is missing required scope: {0}
error.apiToken.endpoint.unsupported=API token cannot access endpoint: {0}
error.request.timeout=Request timed out
error.rateLimit.exceeded=Rate limit exceeded
error.storage.unavailable=Object storage is temporarily unavailable. Please try again later.

View file

@ -48,6 +48,8 @@ error.auth.sessionBootstrap.providerUnsupported=不支持的会话引导提供
error.auth.sessionBootstrap.notAuthenticated=未检测到已认证的外部会话
error.badRequest=请求参数不合法
error.forbidden=没有权限执行该操作
error.apiToken.scope.missing=API 令牌缺少所需权限范围:{0}
error.apiToken.endpoint.unsupported=API 令牌无法访问接口:{0}
error.request.timeout=请求超时
error.rateLimit.exceeded=请求过于频繁,请稍后再试
error.storage.unavailable=对象存储暂时不可用,请稍后再试

View file

@ -150,13 +150,9 @@ class AuthControllerTest {
mockMvc.perform(get("/api/v1/auth/providers"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.length()").value(3))
.andExpect(jsonPath("$.data[*].id", hasItems("github", "gitee", "gitlab")))
.andExpect(jsonPath("$.data[*].authorizationUrl", hasItems(
"/oauth2/authorization/github",
"/oauth2/authorization/gitee",
"/oauth2/authorization/gitlab"
)))
.andExpect(jsonPath("$.data.length()").value(1))
.andExpect(jsonPath("$.data[*].id", hasItems("github")))
.andExpect(jsonPath("$.data[*].authorizationUrl", hasItems("/oauth2/authorization/github")))
.andExpect(jsonPath("$.timestamp").isNotEmpty())
.andExpect(jsonPath("$.requestId").isNotEmpty());
}
@ -167,8 +163,7 @@ class AuthControllerTest {
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data[*].authorizationUrl", hasItems(
"/oauth2/authorization/github?returnTo=%2Fdashboard%2Fpublish",
"/oauth2/authorization/gitee?returnTo=%2Fdashboard%2Fpublish"
"/oauth2/authorization/github?returnTo=%2Fdashboard%2Fpublish"
)));
}
@ -177,7 +172,8 @@ class AuthControllerTest {
mockMvc.perform(get("/api/v1/auth/methods").param("returnTo", "/dashboard/publish"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data[*].id", hasItems("local-password", "oauth-github", "oauth-gitee")))
.andExpect(jsonPath("$.data.length()").value(2))
.andExpect(jsonPath("$.data[*].id", hasItems("local-password", "oauth-github")))
.andExpect(jsonPath("$.data[?(@.id=='local-password')].methodType").value(hasItems("PASSWORD")))
.andExpect(jsonPath("$.data[?(@.id=='oauth-github')].actionUrl")
.value(hasItems("/oauth2/authorization/github?returnTo=%2Fdashboard%2Fpublish")));

View file

@ -0,0 +1,187 @@
package com.iflytek.skillhub.controller.cli;
import com.iflytek.skillhub.auth.token.ApiTokenService;
import com.iflytek.skillhub.domain.namespace.Namespace;
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
import com.iflytek.skillhub.domain.skill.Skill;
import com.iflytek.skillhub.domain.skill.SkillRepository;
import com.iflytek.skillhub.domain.skill.SkillVersion;
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
import com.iflytek.skillhub.domain.skill.SkillVersionStatus;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import com.iflytek.skillhub.infra.jpa.SkillSearchDocumentEntity;
import com.iflytek.skillhub.infra.jpa.SkillSearchDocumentJpaRepository;
import java.time.Instant;
import java.util.UUID;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.HttpHeaders;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
import static org.hamcrest.Matchers.aMapWithSize;
import static org.hamcrest.Matchers.hasItem;
import static org.hamcrest.Matchers.not;
import static org.hamcrest.Matchers.nullValue;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
class CliRestrictedReadAuthorizationIntegrationTest {
@Autowired MockMvc mockMvc;
@Autowired ApiTokenService apiTokenService;
@Autowired UserAccountRepository userAccountRepository;
@Autowired NamespaceRepository namespaceRepository;
@Autowired SkillRepository skillRepository;
@Autowired SkillVersionRepository skillVersionRepository;
@Autowired SkillSearchDocumentJpaRepository skillSearchDocumentRepository;
private String namespaceSlug;
private String skillSlug;
private String publicSkillSlug;
private String version;
private String ownerToken;
private String outsiderToken;
@BeforeEach
void setUp() {
String suffix = UUID.randomUUID().toString().replace("-", "");
String ownerId = "private-owner-" + suffix;
String outsiderId = "private-outsider-" + suffix;
namespaceSlug = "private-ns-" + suffix;
skillSlug = Long.toUnsignedString(UUID.randomUUID().getMostSignificantBits());
publicSkillSlug = "public-skill-" + suffix;
version = "1.0.0";
userAccountRepository.save(new UserAccount(
ownerId, "Private Skill Owner", ownerId + "@example.com", ""));
userAccountRepository.save(new UserAccount(
outsiderId, "Private Skill Outsider", outsiderId + "@example.com", ""));
ownerToken = apiTokenService.createToken(
ownerId, "owner-token-" + suffix, "[\"skill:read\"]").rawToken();
outsiderToken = apiTokenService.createToken(
outsiderId, "outsider-token-" + suffix, "[\"skill:read\"]").rawToken();
Namespace namespace = namespaceRepository.save(
new Namespace(namespaceSlug, "Private Namespace", ownerId));
Skill skill = skillRepository.save(new Skill(
namespace.getId(), skillSlug, ownerId, SkillVisibility.PRIVATE));
SkillVersion published = new SkillVersion(skill.getId(), version, ownerId);
published.setStatus(SkillVersionStatus.PUBLISHED);
published.setPublishedAt(Instant.parse("2026-07-28T00:00:00Z"));
published.setDownloadReady(true);
published = skillVersionRepository.save(published);
skill.setLatestVersionId(published.getId());
skillRepository.save(skill);
skillRepository.flush();
skillVersionRepository.flush();
skillSearchDocumentRepository.saveAndFlush(new SkillSearchDocumentEntity(
skill.getId(),
namespace.getId(),
namespaceSlug,
ownerId,
skillSlug,
"Private skill search fixture",
"private",
skillSlug,
"",
SkillVisibility.PRIVATE.name(),
skill.getStatus().name()));
Skill publicSkill = skillRepository.save(new Skill(
namespace.getId(), publicSkillSlug, ownerId, SkillVisibility.PUBLIC));
SkillVersion publicPublished = new SkillVersion(publicSkill.getId(), version, ownerId);
publicPublished.setStatus(SkillVersionStatus.PUBLISHED);
publicPublished.setPublishedAt(Instant.parse("2026-07-28T00:00:00Z"));
publicPublished.setDownloadReady(true);
publicPublished = skillVersionRepository.save(publicPublished);
publicSkill.setLatestVersionId(publicPublished.getId());
skillRepository.save(publicSkill);
skillRepository.flush();
skillVersionRepository.flush();
skillSearchDocumentRepository.saveAndFlush(new SkillSearchDocumentEntity(
publicSkill.getId(),
namespace.getId(),
namespaceSlug,
ownerId,
skillSlug,
"Public match for " + publicSkillSlug,
"public",
skillSlug,
"",
SkillVisibility.PUBLIC.name(),
publicSkill.getStatus().name()));
}
@Test
void outsiderSearchReturnsMatchingPublicSkillAndOmitsPrivateSkill() throws Exception {
mockMvc.perform(withBearer(
get("/api/cli/v1/skills/search")
.param("q", skillSlug)
.param("limit", "20"),
outsiderToken))
.andExpect(status().isOk())
.andExpect(jsonPath("$", aMapWithSize(5)))
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.items[*].slug", hasItem(publicSkillSlug)))
.andExpect(jsonPath("$.data.items[*].slug", not(hasItem(skillSlug))));
}
@Test
void outsiderCannotResolvePrivateSkill() throws Exception {
assertForbiddenEnvelope(withBearer(
get("/api/cli/v1/skills/{namespace}/{slug}/resolve", namespaceSlug, skillSlug),
outsiderToken));
}
@Test
void outsiderCannotDownloadLatestPrivateSkill() throws Exception {
assertForbiddenEnvelope(withBearer(
get("/api/cli/v1/skills/{namespace}/{slug}/download", namespaceSlug, skillSlug),
outsiderToken));
}
@Test
void outsiderCannotDownloadVersionedPrivateSkill() throws Exception {
assertForbiddenEnvelope(withBearer(
get("/api/cli/v1/skills/{namespace}/{slug}/versions/{version}/download",
namespaceSlug, skillSlug, version),
outsiderToken));
}
private void assertForbiddenEnvelope(MockHttpServletRequestBuilder request) throws Exception {
mockMvc.perform(request)
.andExpect(status().isForbidden())
.andExpect(jsonPath("$", aMapWithSize(5)))
.andExpect(jsonPath("$.code").value(403))
.andExpect(jsonPath("$.msg").isString())
.andExpect(jsonPath("$.data").value(nullValue()))
.andExpect(jsonPath("$.timestamp").isString())
.andExpect(jsonPath("$.requestId").isString());
}
@Test
void ownerCanResolvePrivateSkill() throws Exception {
mockMvc.perform(withBearer(
get("/api/cli/v1/skills/{namespace}/{slug}/resolve", namespaceSlug, skillSlug),
ownerToken))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.slug").value(skillSlug));
}
private MockHttpServletRequestBuilder withBearer(
MockHttpServletRequestBuilder request,
String rawToken) {
return request.header(HttpHeaders.AUTHORIZATION, "Bearer " + rawToken);
}
}

View file

@ -0,0 +1,468 @@
package com.iflytek.skillhub.controller.cli;
import com.iflytek.skillhub.auth.entity.ApiToken;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.auth.repository.ApiTokenRepository;
import com.iflytek.skillhub.auth.token.ApiTokenService;
import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import com.iflytek.skillhub.dto.cli.CliResolveResponse;
import com.iflytek.skillhub.service.cli.CliSkillAppService;
import jakarta.servlet.http.HttpServletRequest;
import java.io.ByteArrayInputStream;
import java.time.Clock;
import java.time.Instant;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Stream;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.mockito.ArgumentCaptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.core.io.InputStreamResource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;
import org.springframework.mock.web.MockHttpSession;
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
import static org.hamcrest.Matchers.aMapWithSize;
import static org.hamcrest.Matchers.hasKey;
import static org.hamcrest.Matchers.nullValue;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.clearInvocations;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
class CliTokenLifecycleSecurityIntegrationTest {
private enum InvalidCredentialState {
REVOKED,
EXPIRED,
UNKNOWN,
EMPTY,
MALFORMED
}
private enum EndpointCase {
WHOAMI,
SEARCH,
RESOLVE,
LATEST_DOWNLOAD,
VERSIONED_DOWNLOAD
}
private enum MixedCredentialState {
SESSION_ONLY,
SESSION_BASIC,
BASIC_ONLY,
SESSION_VALID_BEARER
}
@Autowired MockMvc mockMvc;
@Autowired ApiTokenService apiTokenService;
@Autowired ApiTokenRepository apiTokenRepository;
@Autowired UserAccountRepository userAccountRepository;
@Autowired Clock clock;
@MockBean CliSkillAppService cliSkillAppService;
private String userId;
private String sessionUserId;
@BeforeEach
void setUp() {
userId = "token-matrix-" + UUID.randomUUID();
sessionUserId = "session-matrix-" + UUID.randomUUID();
userAccountRepository.save(new UserAccount(
userId, "Token Matrix", userId + "@example.com", ""));
userAccountRepository.save(new UserAccount(
sessionUserId, "Session Matrix", sessionUserId + "@example.com", ""));
given(cliSkillAppService.search(any(), anyInt(), any(), any()))
.willReturn(new CliSkillAppService.CliSearchResult(List.of(), 0, 20));
given(cliSkillAppService.resolve(anyString(), anyString(), any(), any(), any()))
.willReturn(new CliResolveResponse(
"global", "demo", "1.0.0", 1L, "sha256:empty",
"/api/v1/skills/global/demo/versions/1.0.0/download"));
given(cliSkillAppService.downloadLatest(anyString(), anyString(), any()))
.willAnswer(ignored -> downloadResponse());
given(cliSkillAppService.downloadVersion(anyString(), anyString(), anyString(), any()))
.willAnswer(ignored -> downloadResponse());
}
@Test
void whoamiWithoutAuthorizationReturns401() throws Exception {
mockMvc.perform(get("/api/cli/v1/auth/whoami"))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value(401));
}
@Test
void whoamiWithValidPersistedTokenReturns200() throws Exception {
String token = createActiveToken();
mockMvc.perform(withBearer(get("/api/cli/v1/auth/whoami"), token))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.handle").value(userId));
}
@ParameterizedTest(name = "{0} with {1}")
@MethodSource("mixedCredentialMatrix")
void sessionAndAuthorizationSchemeMatrix(
EndpointCase endpoint,
MixedCredentialState credentialState) throws Exception {
clearInvocations(cliSkillAppService);
String expectedUserId = expectedUserId(credentialState);
MockHttpServletRequestBuilder request = withCredentials(requestFor(endpoint), credentialState);
if (endpoint == EndpointCase.WHOAMI) {
if (credentialState == MixedCredentialState.BASIC_ONLY) {
assertUnauthorizedEnvelope(request);
} else {
assertSuccessEnvelope(request)
.andExpect(jsonPath("$.data.handle").value(expectedUserId));
}
verifyNoInteractions(cliSkillAppService);
return;
}
ResultActions result = mockMvc.perform(request).andExpect(status().isOk());
if (endpoint == EndpointCase.LATEST_DOWNLOAD
|| endpoint == EndpointCase.VERSIONED_DOWNLOAD) {
result.andExpect(content().contentType("application/zip"));
} else {
result.andExpect(jsonPath("$", aMapWithSize(5)))
.andExpect(jsonPath("$.code").value(0));
}
assertProjectedUser(endpoint, expectedUserId);
}
@Test
void whoamiReturnsNullEmailForPersistedUserWithoutEmail() throws Exception {
String noEmailUserId = "token-no-email-" + UUID.randomUUID();
userAccountRepository.save(new UserAccount(noEmailUserId, "No Email User", null, ""));
String rawToken = apiTokenService.createToken(
noEmailUserId, "no-email-" + UUID.randomUUID(), "[\"skill:read\"]").rawToken();
assertSuccessEnvelope(withBearer(get("/api/cli/v1/auth/whoami"), rawToken))
.andExpect(jsonPath("$.data", hasKey("email")))
.andExpect(jsonPath("$.data.email").value(nullValue()));
}
@ParameterizedTest(name = "whoami rejects {0}")
@EnumSource(InvalidCredentialState.class)
void whoamiRejectsInvalidBearer(InvalidCredentialState state) throws Exception {
clearInvocations(cliSkillAppService);
assertUnauthorizedEnvelope(withInvalidBearer(get("/api/cli/v1/auth/whoami"), state));
verifyNoInteractions(cliSkillAppService);
}
@Test
void searchWithoutAuthorizationReturns200() throws Exception {
mockMvc.perform(get("/api/cli/v1/skills/search").param("q", "demo").param("limit", "20"))
.andExpect(status().isOk());
}
@Test
void searchWithValidPersistedTokenReturns200() throws Exception {
String token = createActiveToken();
mockMvc.perform(withBearer(
get("/api/cli/v1/skills/search").param("q", "demo").param("limit", "20"), token))
.andExpect(status().isOk());
}
@ParameterizedTest(name = "search rejects {0}")
@EnumSource(InvalidCredentialState.class)
void searchRejectsInvalidBearer(InvalidCredentialState state) throws Exception {
clearInvocations(cliSkillAppService);
assertUnauthorizedEnvelope(withInvalidBearer(
get("/api/cli/v1/skills/search").param("q", "demo").param("limit", "20"), state));
verifyNoInteractions(cliSkillAppService);
}
@Test
void resolveWithoutAuthorizationReturns200() throws Exception {
mockMvc.perform(get("/api/cli/v1/skills/global/demo/resolve"))
.andExpect(status().isOk());
}
@Test
void resolveWithValidPersistedTokenReturns200() throws Exception {
String token = createActiveToken();
mockMvc.perform(withBearer(get("/api/cli/v1/skills/global/demo/resolve"), token))
.andExpect(status().isOk());
}
@ParameterizedTest(name = "resolve rejects {0}")
@EnumSource(InvalidCredentialState.class)
void resolveRejectsInvalidBearer(InvalidCredentialState state) throws Exception {
clearInvocations(cliSkillAppService);
assertUnauthorizedEnvelope(withInvalidBearer(
get("/api/cli/v1/skills/global/demo/resolve"), state));
verifyNoInteractions(cliSkillAppService);
}
@Test
void latestDownloadWithoutAuthorizationReturns200() throws Exception {
mockMvc.perform(get("/api/cli/v1/skills/global/demo/download"))
.andExpect(status().isOk());
}
@Test
void latestDownloadWithValidPersistedTokenReturns200() throws Exception {
String token = createActiveToken();
mockMvc.perform(withBearer(get("/api/cli/v1/skills/global/demo/download"), token))
.andExpect(status().isOk())
.andExpect(content().contentType("application/zip"));
}
@ParameterizedTest(name = "latest download rejects {0}")
@EnumSource(InvalidCredentialState.class)
void latestDownloadRejectsInvalidBearer(InvalidCredentialState state) throws Exception {
clearInvocations(cliSkillAppService);
assertUnauthorizedEnvelope(withInvalidBearer(
get("/api/cli/v1/skills/global/demo/download"), state));
verifyNoInteractions(cliSkillAppService);
}
@Test
void versionedDownloadWithoutAuthorizationReturns200() throws Exception {
mockMvc.perform(get("/api/cli/v1/skills/global/demo/versions/1.0.0/download"))
.andExpect(status().isOk());
}
@Test
void versionedDownloadWithValidPersistedTokenReturns200() throws Exception {
String token = createActiveToken();
mockMvc.perform(withBearer(
get("/api/cli/v1/skills/global/demo/versions/1.0.0/download"), token))
.andExpect(status().isOk())
.andExpect(content().contentType("application/zip"));
}
@ParameterizedTest(name = "versioned download rejects {0}")
@EnumSource(InvalidCredentialState.class)
void versionedDownloadRejectsInvalidBearer(InvalidCredentialState state) throws Exception {
clearInvocations(cliSkillAppService);
assertUnauthorizedEnvelope(withInvalidBearer(
get("/api/cli/v1/skills/global/demo/versions/1.0.0/download"), state));
verifyNoInteractions(cliSkillAppService);
}
@Test
void sameRawTokenIsRejectedByAllEndpointsAfterValidUseAndRevocation() throws Exception {
ApiTokenService.TokenCreateResult token = createToken();
String rawToken = token.rawToken();
assertSuccessEnvelope(withBearer(get("/api/cli/v1/auth/whoami"), rawToken))
.andExpect(jsonPath("$.data.handle").value(userId));
assertSuccessEnvelope(withBearer(
get("/api/cli/v1/skills/search").param("q", "demo").param("limit", "20"),
rawToken));
assertSuccessEnvelope(withBearer(
get("/api/cli/v1/skills/global/demo/resolve"), rawToken));
mockMvc.perform(withBearer(
get("/api/cli/v1/skills/global/demo/download"), rawToken))
.andExpect(status().isOk())
.andExpect(content().contentType("application/zip"));
mockMvc.perform(withBearer(
get("/api/cli/v1/skills/global/demo/versions/1.0.0/download"), rawToken))
.andExpect(status().isOk())
.andExpect(content().contentType("application/zip"));
apiTokenService.revokeToken(token.entity().getId(), userId);
clearInvocations(cliSkillAppService);
assertUnauthorizedEnvelope(withBearer(get("/api/cli/v1/auth/whoami"), rawToken));
assertUnauthorizedEnvelope(withBearer(
get("/api/cli/v1/skills/search").param("q", "demo").param("limit", "20"),
rawToken));
assertUnauthorizedEnvelope(withBearer(
get("/api/cli/v1/skills/global/demo/resolve"), rawToken));
assertUnauthorizedEnvelope(withBearer(
get("/api/cli/v1/skills/global/demo/download"), rawToken));
assertUnauthorizedEnvelope(withBearer(
get("/api/cli/v1/skills/global/demo/versions/1.0.0/download"), rawToken));
verifyNoInteractions(cliSkillAppService);
}
private ResultActions assertSuccessEnvelope(MockHttpServletRequestBuilder request) throws Exception {
return mockMvc.perform(request)
.andExpect(status().isOk())
.andExpect(jsonPath("$", aMapWithSize(5)))
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.msg").isString())
.andExpect(jsonPath("$.data").exists())
.andExpect(jsonPath("$.timestamp").isString())
.andExpect(jsonPath("$.requestId").isString());
}
private void assertUnauthorizedEnvelope(MockHttpServletRequestBuilder request) throws Exception {
mockMvc.perform(request)
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$", aMapWithSize(5)))
.andExpect(jsonPath("$.code").value(401))
.andExpect(jsonPath("$.msg").isString())
.andExpect(jsonPath("$.data").value(nullValue()))
.andExpect(jsonPath("$.timestamp").isString())
.andExpect(jsonPath("$.requestId").isString());
}
private MockHttpServletRequestBuilder withInvalidBearer(
MockHttpServletRequestBuilder request,
InvalidCredentialState state) {
return request
.header(HttpHeaders.AUTHORIZATION, authorizationHeader(state))
.session(session());
}
private static Stream<Arguments> mixedCredentialMatrix() {
return Stream.of(EndpointCase.values())
.flatMap(endpoint -> Stream.of(MixedCredentialState.values())
.map(state -> Arguments.of(endpoint, state)));
}
private MockHttpServletRequestBuilder requestFor(EndpointCase endpoint) {
return switch (endpoint) {
case WHOAMI -> get("/api/cli/v1/auth/whoami");
case SEARCH -> get("/api/cli/v1/skills/search")
.param("q", "demo")
.param("limit", "20");
case RESOLVE -> get("/api/cli/v1/skills/global/demo/resolve");
case LATEST_DOWNLOAD -> get("/api/cli/v1/skills/global/demo/download");
case VERSIONED_DOWNLOAD ->
get("/api/cli/v1/skills/global/demo/versions/1.0.0/download");
};
}
private MockHttpServletRequestBuilder withCredentials(
MockHttpServletRequestBuilder request,
MixedCredentialState state) {
return switch (state) {
case SESSION_ONLY -> request.session(session());
case SESSION_BASIC -> request.session(session())
.header(HttpHeaders.AUTHORIZATION, "Basic dGVzdDp0ZXN0");
case BASIC_ONLY -> request.header(HttpHeaders.AUTHORIZATION, "Basic dGVzdDp0ZXN0");
case SESSION_VALID_BEARER -> withBearer(request.session(session()), createActiveToken());
};
}
private String expectedUserId(MixedCredentialState state) {
return switch (state) {
case SESSION_ONLY, SESSION_BASIC -> sessionUserId;
case BASIC_ONLY -> null;
case SESSION_VALID_BEARER -> userId;
};
}
private void assertProjectedUser(EndpointCase endpoint, String expectedUserId) {
if (endpoint == EndpointCase.SEARCH) {
ArgumentCaptor<String> userCaptor = ArgumentCaptor.forClass(String.class);
verify(cliSkillAppService).search(any(), anyInt(), userCaptor.capture(), any());
assertEquals(expectedUserId, userCaptor.getValue());
return;
}
if (endpoint == EndpointCase.RESOLVE) {
ArgumentCaptor<String> userCaptor = ArgumentCaptor.forClass(String.class);
verify(cliSkillAppService).resolve(anyString(), anyString(), any(), userCaptor.capture(), any());
assertEquals(expectedUserId, userCaptor.getValue());
return;
}
ArgumentCaptor<HttpServletRequest> requestCaptor = ArgumentCaptor.forClass(HttpServletRequest.class);
if (endpoint == EndpointCase.LATEST_DOWNLOAD) {
verify(cliSkillAppService).downloadLatest(anyString(), anyString(), requestCaptor.capture());
} else {
verify(cliSkillAppService).downloadVersion(
anyString(), anyString(), anyString(), requestCaptor.capture());
}
assertEquals(expectedUserId, requestCaptor.getValue().getAttribute("userId"));
}
private MockHttpServletRequestBuilder withBearer(
MockHttpServletRequestBuilder request,
String rawToken) {
return request.header(HttpHeaders.AUTHORIZATION, "Bearer " + rawToken);
}
private String authorizationHeader(InvalidCredentialState state) {
return switch (state) {
case REVOKED -> {
ApiTokenService.TokenCreateResult result = createToken();
apiTokenService.revokeToken(result.entity().getId(), userId);
yield "Bearer " + result.rawToken();
}
case EXPIRED -> {
ApiTokenService.TokenCreateResult result = createToken();
ApiToken token = result.entity();
token.setExpiresAt(Instant.now(clock).minusSeconds(1));
apiTokenRepository.saveAndFlush(token);
yield "Bearer " + result.rawToken();
}
case UNKNOWN -> "Bearer sk_unknown_" + UUID.randomUUID();
case EMPTY -> "Bearer ";
case MALFORMED -> "Bearer";
};
}
private String createActiveToken() {
return createToken().rawToken();
}
private ApiTokenService.TokenCreateResult createToken() {
return apiTokenService.createToken(
userId, "matrix-" + UUID.randomUUID(), "[\"skill:read\"]");
}
private MockHttpSession session() {
SecurityContext securityContext = SecurityContextHolder.createEmptyContext();
securityContext.setAuthentication(sessionAuthentication());
MockHttpSession session = new MockHttpSession();
session.setAttribute(
HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY,
securityContext);
return session;
}
private UsernamePasswordAuthenticationToken sessionAuthentication() {
PlatformPrincipal principal = new PlatformPrincipal(
sessionUserId,
"Session User",
sessionUserId + "@example.com",
"",
"session",
Set.of("USER"));
return new UsernamePasswordAuthenticationToken(principal, null, List.of());
}
private ResponseEntity<InputStreamResource> downloadResponse() {
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType("application/zip"))
.body(new InputStreamResource(
new ByteArrayInputStream("zip".getBytes(java.nio.charset.StandardCharsets.UTF_8))));
}
}

View file

@ -0,0 +1,137 @@
package com.iflytek.skillhub.security;
import static org.assertj.core.api.Assertions.assertThat;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.iflytek.skillhub.auth.token.ApiTokenScopeFilter;
import com.iflytek.skillhub.auth.token.ApiTokenScopeService;
import com.iflytek.skillhub.auth.policy.RouteSecurityPolicyRegistry;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.dto.ApiResponseFactory;
import jakarta.servlet.FilterChain;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.slf4j.MDC;
import org.springframework.context.i18n.LocaleContextHolder;
import org.springframework.context.support.ResourceBundleMessageSource;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
class ApiAccessDeniedHandlerTest {
private final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules();
private ApiAccessDeniedHandler handler;
@BeforeEach
void setUp() {
ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource();
messageSource.setBasename("messages");
messageSource.setDefaultEncoding("UTF-8");
ApiResponseFactory responseFactory = new ApiResponseFactory(
messageSource,
Clock.fixed(Instant.parse("2026-07-28T00:00:00Z"), ZoneOffset.UTC)
);
handler = new ApiAccessDeniedHandler(
objectMapper,
responseFactory,
new SensitiveLogSanitizer()
);
MDC.put("requestId", "req-610");
LocaleContextHolder.setLocale(Locale.ENGLISH);
}
@AfterEach
void tearDown() {
MDC.clear();
LocaleContextHolder.resetLocaleContext();
SecurityContextHolder.clearContext();
}
@Test
void shouldExposeLocalizedApiTokenScopeReasonAndRequestId() throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/v1/publish");
MockHttpServletResponse response = new MockHttpServletResponse();
ApiTokenScopeService scopeService =
new ApiTokenScopeService(objectMapper, new RouteSecurityPolicyRegistry());
ApiTokenScopeFilter filter = new ApiTokenScopeFilter(scopeService, handler);
PlatformPrincipal principal = new PlatformPrincipal(
"user-1",
"Alice",
"alice@example.com",
"",
"api_token",
Set.of("USER")
);
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(
principal,
null,
List.of(new SimpleGrantedAuthority("SCOPE_skill:read"))
)
);
FilterChain chain = (servletRequest, servletResponse) -> {
throw new AssertionError("Denied request must not continue");
};
filter.doFilter(request, response, chain);
JsonNode body = objectMapper.readTree(response.getContentAsByteArray());
assertThat(response.getStatus()).isEqualTo(403);
assertThat(body.path("msg").asText())
.isEqualTo("API token is missing required scope: skill:publish");
assertThat(body.path("requestId").asText()).isEqualTo("req-610");
}
@Test
void shouldTranslateSafeApiTokenReason() throws Exception {
LocaleContextHolder.setLocale(Locale.SIMPLIFIED_CHINESE);
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/cli/v1/whoami");
MockHttpServletResponse response = new MockHttpServletResponse();
ApiTokenScopeService scopeService =
new ApiTokenScopeService(objectMapper, new RouteSecurityPolicyRegistry());
ApiTokenScopeFilter filter = new ApiTokenScopeFilter(scopeService, handler);
PlatformPrincipal principal = new PlatformPrincipal(
"user-1",
"Alice",
"alice@example.com",
"",
"api_token",
Set.of("USER")
);
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(principal, null, List.of())
);
filter.doFilter(request, response, (servletRequest, servletResponse) -> {
throw new AssertionError("Denied request must not continue");
});
JsonNode body = objectMapper.readTree(response.getContentAsByteArray());
assertThat(body.path("msg").asText())
.isEqualTo("API 令牌无法访问接口:/api/cli/v1/whoami");
}
@Test
void shouldHideGenericAccessDeniedExceptionMessage() throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/v1/admin");
MockHttpServletResponse response = new MockHttpServletResponse();
handler.handle(request, response, new AccessDeniedException("internal authorization detail"));
JsonNode body = objectMapper.readTree(response.getContentAsByteArray());
assertThat(body.path("msg").asText()).isEqualTo("Forbidden");
assertThat(response.getContentAsString()).doesNotContain("internal authorization detail");
}
}

View file

@ -16,6 +16,31 @@ import org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2Clien
class AuthMethodCatalogTest {
@Test
void catalogsShouldHideEmptyAndPlaceholderOAuthProviders() {
OAuth2ClientProperties oauthProperties = new OAuth2ClientProperties();
oauthProperties.getRegistration().put("valid", registration("production-client", "Valid"));
oauthProperties.getRegistration().put("missing", registration(null, "Missing"));
oauthProperties.getRegistration().put("blank", registration(" ", "Blank"));
oauthProperties.getRegistration().put("placeholder", registration("PLACEHOLDER", "Placeholder"));
oauthProperties.getRegistration().put("local", registration("local-placeholder", "Local"));
AuthMethodCatalog catalog = new AuthMethodCatalog(
oauthProperties,
new DirectAuthProperties(),
new AuthSessionBootstrapProperties(),
List.of(),
List.of()
);
assertThat(catalog.listOAuthProviders(null))
.extracting(provider -> provider.id())
.containsExactly("valid");
assertThat(catalog.listMethods(null))
.extracting(method -> method.id())
.containsExactly("local-password", "oauth-valid");
}
@Test
void listMethodsShouldUseProviderDisplayNamesForCompatibleAuthMethods() {
OAuth2ClientProperties oauthProperties = new OAuth2ClientProperties();
@ -122,4 +147,11 @@ class AuthMethodCatalogTest {
"bootstrap-private-sso:private-sso"
);
}
private static OAuth2ClientProperties.Registration registration(String clientId, String clientName) {
OAuth2ClientProperties.Registration registration = new OAuth2ClientProperties.Registration();
registration.setClientId(clientId);
registration.setClientName(clientName);
return registration;
}
}

View file

@ -0,0 +1,269 @@
package com.iflytek.skillhub.service;
import com.iflytek.skillhub.SkillhubApplication;
import com.iflytek.skillhub.TestRedisConfig;
import com.iflytek.skillhub.domain.label.LabelDefinition;
import com.iflytek.skillhub.domain.label.LabelDefinitionRepository;
import com.iflytek.skillhub.domain.label.LabelTranslation;
import com.iflytek.skillhub.domain.label.LabelTranslationRepository;
import com.iflytek.skillhub.domain.label.LabelType;
import com.iflytek.skillhub.domain.namespace.Namespace;
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.namespace.NamespaceType;
import com.iflytek.skillhub.domain.skill.Skill;
import com.iflytek.skillhub.domain.skill.SkillRepository;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
import com.iflytek.skillhub.infra.jpa.SkillSearchDocumentEntity;
import com.iflytek.skillhub.infra.jpa.SkillSearchDocumentJpaRepository;
import com.iflytek.skillhub.search.SearchEmbeddingService;
import com.iflytek.skillhub.search.SearchRebuildService;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.transaction.support.TransactionTemplate;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.when;
/**
* Reproduces the bug where attaching a skill label does not update the search
* index. The label keyword should appear in the rebuilt search document after
* {@code attachLabel} commits.
*
* <p>With the upstream (synchronous) {@code LabelSearchSyncService.rebuildSkill},
* the rebuild runs inside the {@code afterCommit} callback on the request thread,
* where the {@code @Transactional index()} write does not persist so the keyword
* never lands in the index and this test fails. Adding {@code @Async} moves the
* rebuild to a fresh thread/transaction and the keyword appears.
*/
@SpringBootTest(classes = SkillhubApplication.class)
@ActiveProfiles("test")
@Import(TestRedisConfig.class)
class LabelSearchSyncIntegrationTest {
@Autowired
private SkillLabelAppService skillLabelAppService;
@Autowired
private NamespaceRepository namespaceRepository;
@Autowired
private SkillRepository skillRepository;
@Autowired
private LabelDefinitionRepository labelDefinitionRepository;
@Autowired
private LabelTranslationRepository labelTranslationRepository;
@Autowired
private SkillSearchDocumentJpaRepository skillSearchDocumentJpaRepository;
@Autowired
private SearchRebuildService searchRebuildService;
@Autowired
private TransactionTemplate transactionTemplate;
@MockBean
private SearchEmbeddingService searchEmbeddingService;
@BeforeEach
void setUp() {
when(searchEmbeddingService.embed(anyString())).thenReturn("");
when(searchEmbeddingService.similarity(anyString(), anyString())).thenReturn(0.0d);
}
@Test
void attachingLabel_updatesSearchIndexWithLabelKeyword() throws Exception {
String suffix = UUID.randomUUID().toString().substring(0, 8);
String ownerId = "owner-" + suffix;
// ASCII display name so the tokenizer keeps it as a single searchable token.
String labelDisplayName = "MachineLearning" + suffix;
String labelSlug = "ml-" + suffix;
Namespace namespace = new Namespace("ns-" + suffix, "NS " + suffix, ownerId);
namespace.setType(NamespaceType.GLOBAL);
namespace = namespaceRepository.save(namespace);
Skill skill = new Skill(namespace.getId(), "skill-" + suffix, ownerId, SkillVisibility.PUBLIC);
skill.setDisplayName("Skill " + suffix);
skill.setSummary("A skill used to reproduce the label search sync bug.");
skill.setCreatedBy(ownerId);
skill.setUpdatedBy(ownerId);
skill = skillRepository.save(skill);
skillRepository.flush();
LabelDefinition label = labelDefinitionRepository.save(
new LabelDefinition(labelSlug, LabelType.RECOMMENDED, true, 0, ownerId));
labelTranslationRepository.saveAll(List.of(
new LabelTranslation(label.getId(), "en", labelDisplayName)));
labelTranslationRepository.flush();
// Baseline: nothing indexed yet.
assertThat(skillSearchDocumentJpaRepository.findBySkillId(skill.getId())).isEmpty();
// Act: attach the label as the skill owner (passes resolve + permission checks).
Map<Long, NamespaceRole> ownerRoles = Map.of(namespace.getId(), NamespaceRole.OWNER);
skillLabelAppService.attachLabel(
namespace.getSlug(),
skill.getSlug(),
labelSlug,
ownerId,
ownerRoles,
new AuditRequestContext("127.0.0.1", "junit"));
// Assert: the rebuilt search document must contain the label keyword.
SkillSearchDocumentEntity indexed = awaitIndexedDocument(skill.getId());
assertThat(indexed.getKeywords())
.as("label keyword should be indexed after attachLabel commits")
.contains(labelDisplayName);
}
@Test
void detachingLabel_removesKeywordFromSearchIndex() throws Exception {
Fixture f = createFixture();
skillLabelAppService.attachLabel(
f.namespaceSlug, f.skillSlug, f.labelSlug, f.ownerId, f.ownerRoles, auditContext());
SkillSearchDocumentEntity afterAttach = awaitIndexedDocument(f.skillId);
assertThat(afterAttach.getKeywords())
.as("precondition: label keyword indexed after attach")
.contains(f.labelDisplayName);
// Act: detach the same label.
skillLabelAppService.detachLabel(
f.namespaceSlug, f.skillSlug, f.labelSlug, f.ownerId, f.ownerRoles, auditContext());
// Assert: the rebuilt document must no longer contain the label keyword.
awaitKeywordAbsent(f.skillId, f.labelDisplayName);
}
/**
* Guards against the {@code CallerRunsPolicy} regression: when the executor is
* saturated, {@code rebuildSkill} runs synchronously on the request thread inside
* the {@code afterCommit} phase the exact context where the index write used to be
* dropped. This exercises that path directly (no async hop) and asserts the document
* is still persisted, proving the fix relies on {@code REQUIRES_NEW}, not on the
* executor having spare capacity.
*/
@Test
void syncRebuildInAfterCommitPhase_persistsIndex() throws Exception {
Fixture f = createFixture();
// Establish the skill-label association and a baseline index via the normal path.
skillLabelAppService.attachLabel(
f.namespaceSlug, f.skillSlug, f.labelSlug, f.ownerId, f.ownerRoles, auditContext());
awaitIndexedDocument(f.skillId);
// Clear the index so we can observe the synchronous rebuild in isolation.
transactionTemplate.executeWithoutResult(
status -> skillSearchDocumentJpaRepository.deleteBySkillId(f.skillId));
assertThat(skillSearchDocumentJpaRepository.findBySkillId(f.skillId)).isEmpty();
// Rebuild synchronously on the caller thread, inside a post-commit synchronization
// (mirrors the CallerRuns fallback from afterCommit(() -> rebuildSkill(...))).
transactionTemplate.executeWithoutResult(status ->
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
searchRebuildService.rebuildBySkill(f.skillId);
}
}));
SkillSearchDocumentEntity indexed = skillSearchDocumentJpaRepository.findBySkillId(f.skillId)
.orElseThrow(() -> new AssertionError(
"synchronous rebuild in afterCommit phase must persist the index document"));
assertThat(indexed.getKeywords())
.as("label keyword must be indexed even on the synchronous caller-runs path")
.contains(f.labelDisplayName);
}
private SkillSearchDocumentEntity awaitIndexedDocument(Long skillId) throws InterruptedException {
Instant deadline = Instant.now().plus(Duration.ofSeconds(15));
Optional<SkillSearchDocumentEntity> indexed = skillSearchDocumentJpaRepository.findBySkillId(skillId);
while (indexed.isEmpty() && Instant.now().isBefore(deadline)) {
Thread.sleep(100L);
indexed = skillSearchDocumentJpaRepository.findBySkillId(skillId);
}
return indexed.orElseThrow(
() -> new AssertionError("Expected search document for skill " + skillId));
}
private void awaitKeywordAbsent(Long skillId, String keyword) throws InterruptedException {
Instant deadline = Instant.now().plus(Duration.ofSeconds(15));
while (Instant.now().isBefore(deadline)) {
Optional<SkillSearchDocumentEntity> indexed =
skillSearchDocumentJpaRepository.findBySkillId(skillId);
if (indexed.isPresent() && !indexed.get().getKeywords().contains(keyword)) {
return;
}
Thread.sleep(100L);
}
String keywords = skillSearchDocumentJpaRepository.findBySkillId(skillId)
.map(SkillSearchDocumentEntity::getKeywords)
.orElse("<no document>");
throw new AssertionError(
"Expected keyword '" + keyword + "' to be removed from index for skill "
+ skillId + " but keywords were: " + keywords);
}
private AuditRequestContext auditContext() {
return new AuditRequestContext("127.0.0.1", "junit");
}
private Fixture createFixture() {
String suffix = UUID.randomUUID().toString().substring(0, 8);
String ownerId = "owner-" + suffix;
// ASCII display name so the tokenizer keeps it as a single searchable token.
String labelDisplayName = "MachineLearning" + suffix;
String labelSlug = "ml-" + suffix;
Namespace namespace = new Namespace("ns-" + suffix, "NS " + suffix, ownerId);
namespace.setType(NamespaceType.GLOBAL);
namespace = namespaceRepository.save(namespace);
Skill skill = new Skill(namespace.getId(), "skill-" + suffix, ownerId, SkillVisibility.PUBLIC);
skill.setDisplayName("Skill " + suffix);
skill.setSummary("A skill used to reproduce the label search sync bug.");
skill.setCreatedBy(ownerId);
skill.setUpdatedBy(ownerId);
skill = skillRepository.save(skill);
skillRepository.flush();
LabelDefinition label = labelDefinitionRepository.save(
new LabelDefinition(labelSlug, LabelType.RECOMMENDED, true, 0, ownerId));
labelTranslationRepository.saveAll(List.of(
new LabelTranslation(label.getId(), "en", labelDisplayName)));
labelTranslationRepository.flush();
return new Fixture(
namespace.getSlug(), skill.getSlug(), skill.getId(),
labelSlug, labelDisplayName, ownerId,
Map.of(namespace.getId(), NamespaceRole.OWNER));
}
private record Fixture(
String namespaceSlug,
String skillSlug,
Long skillId,
String labelSlug,
String labelDisplayName,
String ownerId,
Map<Long, NamespaceRole> ownerRoles) {
}
}

View file

@ -1,5 +1,6 @@
package com.iflytek.skillhub.auth.device;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.iflytek.skillhub.auth.token.ApiTokenService;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import org.springframework.beans.factory.annotation.Value;
@ -33,14 +34,17 @@ public class DeviceAuthService {
private final RedisTemplate<String, Object> redisTemplate;
private final ApiTokenService apiTokenService;
private final ObjectMapper objectMapper;
private final String verificationUri;
private final SecureRandom random = new SecureRandom();
public DeviceAuthService(RedisTemplate<String, Object> redisTemplate,
ApiTokenService apiTokenService,
ObjectMapper objectMapper,
@Value("${skillhub.device-auth.verification-uri:/cli/auth}") String verificationUri) {
this.redisTemplate = redisTemplate;
this.apiTokenService = apiTokenService;
this.objectMapper = objectMapper;
this.verificationUri = verificationUri;
}
@ -71,7 +75,7 @@ public class DeviceAuthService {
throw new DomainBadRequestException("error.deviceAuth.userCode.invalid");
}
DeviceCodeData data = (DeviceCodeData) redisTemplate.opsForValue().get(DEVICE_CODE_PREFIX + deviceCode);
DeviceCodeData data = readDeviceCodeData(deviceCode);
if (data == null) {
throw new DomainBadRequestException("error.deviceAuth.deviceCode.expired");
}
@ -97,7 +101,7 @@ public class DeviceAuthService {
* into an API token exactly once.
*/
public DeviceTokenResponse pollToken(String deviceCode) {
DeviceCodeData data = (DeviceCodeData) redisTemplate.opsForValue().get(DEVICE_CODE_PREFIX + deviceCode);
DeviceCodeData data = readDeviceCodeData(deviceCode);
if (data == null) {
throw new DomainBadRequestException("error.deviceAuth.deviceCode.invalid");
@ -147,6 +151,17 @@ public class DeviceAuthService {
}
}
/**
* Reads device-code state from Redis. The shared template's JSON value
* serializer carries no type information, so values deserialize as plain
* maps; convert explicitly instead of casting (a direct cast throws
* {@code ClassCastException} on every read).
*/
private DeviceCodeData readDeviceCodeData(String deviceCode) {
Object raw = redisTemplate.opsForValue().get(DEVICE_CODE_PREFIX + deviceCode);
return raw == null ? null : objectMapper.convertValue(raw, DeviceCodeData.class);
}
private String generateRandomDeviceCode() {
byte[] bytes = new byte[32];
random.nextBytes(bytes);

View file

@ -19,7 +19,9 @@ public class DeviceCodeData implements Serializable {
}
public String getDeviceCode() { return deviceCode; }
public void setDeviceCode(String deviceCode) { this.deviceCode = deviceCode; }
public String getUserCode() { return userCode; }
public void setUserCode(String userCode) { this.userCode = userCode; }
public DeviceCodeStatus getStatus() { return status; }
public void setStatus(DeviceCodeStatus status) { this.status = status; }
public String getUserId() { return userId; }

View file

@ -0,0 +1,42 @@
package com.iflytek.skillhub.auth.token;
import org.springframework.security.access.AccessDeniedException;
/**
* Marks an API-token authorization failure whose structured reason is safe to expose to clients.
*/
public final class ApiTokenAccessDeniedException extends AccessDeniedException {
private final String messageCode;
private final Object[] messageArgs;
private ApiTokenAccessDeniedException(String logMessage, String messageCode, Object... messageArgs) {
super(logMessage);
this.messageCode = messageCode;
this.messageArgs = messageArgs.clone();
}
static ApiTokenAccessDeniedException missingScope(String requiredScope) {
return new ApiTokenAccessDeniedException(
"Missing API token scope: " + requiredScope,
"error.apiToken.scope.missing",
requiredScope
);
}
static ApiTokenAccessDeniedException unsupportedEndpoint(String path) {
return new ApiTokenAccessDeniedException(
"API token cannot access endpoint: " + path,
"error.apiToken.endpoint.unsupported",
path
);
}
public String getMessageCode() {
return messageCode;
}
public Object[] getMessageArgs() {
return messageArgs.clone();
}
}

View file

@ -5,7 +5,6 @@ import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
@ -59,11 +58,10 @@ public class ApiTokenScopeFilter extends OncePerRequestFilter {
return;
}
accessDeniedHandler.handle(
request,
response,
new AccessDeniedException(decision.message())
);
ApiTokenAccessDeniedException exception = decision.requiredScope() != null
? ApiTokenAccessDeniedException.missingScope(decision.requiredScope())
: ApiTokenAccessDeniedException.unsupportedEndpoint(request.getRequestURI());
accessDeniedHandler.handle(request, response, exception);
}
@Override

View file

@ -0,0 +1,106 @@
package com.iflytek.skillhub.auth.device;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.iflytek.skillhub.auth.token.ApiTokenService;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import java.util.LinkedHashMap;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.startsWith;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class DeviceAuthServiceTest {
private static final String DEVICE_CODE = "device-code-1";
private static final String USER_CODE = "ABCD-2345";
@Mock
private RedisTemplate<String, Object> redisTemplate;
@Mock
private ValueOperations<String, Object> valueOperations;
@Mock
private ApiTokenService apiTokenService;
private DeviceAuthService service;
@BeforeEach
void setUp() {
lenient().when(redisTemplate.opsForValue()).thenReturn(valueOperations);
service = new DeviceAuthService(redisTemplate, apiTokenService, new ObjectMapper(), "/cli/auth");
}
/**
* The shared RedisTemplate's JSON serializer keeps no type information, so
* stored DeviceCodeData comes back as a plain map. A typed cast used to
* throw ClassCastException on every poll; the service must convert instead.
*/
private static Map<String, Object> storedDeviceCode(DeviceCodeStatus status, String userId) {
Map<String, Object> raw = new LinkedHashMap<>();
raw.put("deviceCode", DEVICE_CODE);
raw.put("userCode", USER_CODE);
raw.put("status", status.name());
raw.put("userId", userId);
return raw;
}
@Test
void pollTokenReturnsPendingWhenRedisValueIsUntypedMap() {
when(valueOperations.get("device:code:" + DEVICE_CODE))
.thenReturn(storedDeviceCode(DeviceCodeStatus.PENDING, null));
DeviceTokenResponse response = service.pollToken(DEVICE_CODE);
assertThat(response.error()).isEqualTo("authorization_pending");
}
@Test
void pollTokenRedeemsAuthorizedCodeFromUntypedMap() {
when(valueOperations.get("device:code:" + DEVICE_CODE))
.thenReturn(storedDeviceCode(DeviceCodeStatus.AUTHORIZED, "usr_1"));
when(valueOperations.setIfAbsent(eq("device:claim:" + DEVICE_CODE), any(), anyLong(), any()))
.thenReturn(Boolean.TRUE);
when(apiTokenService.rotateToken(eq("usr_1"), any(), any()))
.thenReturn(new ApiTokenService.TokenCreateResult("sk_test_token", null));
DeviceTokenResponse response = service.pollToken(DEVICE_CODE);
assertThat(response.accessToken()).isEqualTo("sk_test_token");
}
@Test
void pollTokenRejectsUnknownDeviceCode() {
when(valueOperations.get("device:code:" + DEVICE_CODE)).thenReturn(null);
assertThatThrownBy(() -> service.pollToken(DEVICE_CODE))
.isInstanceOf(DomainBadRequestException.class);
}
@Test
void authorizeDeviceCodeMarksPendingCodeFromUntypedMap() {
when(valueOperations.get("device:usercode:" + USER_CODE)).thenReturn(DEVICE_CODE);
when(valueOperations.get("device:code:" + DEVICE_CODE))
.thenReturn(storedDeviceCode(DeviceCodeStatus.PENDING, null));
service.authorizeDeviceCode(USER_CODE, "usr_1");
verify(valueOperations).set(startsWith("device:code:"), any(DeviceCodeData.class), anyLong(), any());
}
}

View file

@ -17,8 +17,10 @@ import org.springframework.security.web.access.AccessDeniedHandler;
import java.util.List;
import java.util.Set;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
@ -38,7 +40,9 @@ class ApiTokenScopeFilterTest {
@Test
void shouldDenyApiTokenWithoutRequiredScope() throws Exception {
AtomicReference<Exception> deniedException = new AtomicReference<>();
AccessDeniedHandler handler = (request, response, accessDeniedException) -> {
deniedException.set(accessDeniedException);
response.sendError(HttpServletResponse.SC_FORBIDDEN, accessDeniedException.getMessage());
};
ApiTokenScopeFilter filter = new ApiTokenScopeFilter(scopeService, handler);
@ -69,6 +73,12 @@ class ApiTokenScopeFilterTest {
assertEquals(HttpServletResponse.SC_FORBIDDEN, response.getStatus());
assertTrue(response.getErrorMessage().contains("Missing API token scope: skill:publish"));
ApiTokenAccessDeniedException exception = assertInstanceOf(
ApiTokenAccessDeniedException.class,
deniedException.get()
);
assertEquals("error.apiToken.scope.missing", exception.getMessageCode());
assertEquals("skill:publish", exception.getMessageArgs()[0]);
verify(chain, never()).doFilter(request, response);
}

View file

@ -62,6 +62,12 @@ public class SkillPublishService {
private static final DateTimeFormatter AUTO_VERSION_FORMATTER =
DateTimeFormatter.ofPattern("yyyyMMdd.HHmmss").withZone(ZoneId.systemDefault());
private static final Set<SkillVersionStatus> REPLACEABLE_VERSION_STATUSES = Set.of(
SkillVersionStatus.DRAFT,
SkillVersionStatus.SCAN_FAILED,
SkillVersionStatus.UPLOADED,
SkillVersionStatus.REJECTED
);
private static final Logger log = LoggerFactory.getLogger(SkillPublishService.class);
public record PublishResult(
@ -566,7 +572,7 @@ public class SkillPublishService {
}
private void deleteReplaceableVersionArtifacts(Skill skill, SkillVersion version, String namespaceSlug) {
if (version.getStatus() == SkillVersionStatus.PUBLISHED) {
if (!REPLACEABLE_VERSION_STATUSES.contains(version.getStatus())) {
throw new DomainBadRequestException("error.skill.version.exists", version.getVersion());
}
@ -577,8 +583,10 @@ public class SkillPublishService {
skillRepository.flush();
}
reviewTaskRepository.findBySkillVersionIdAndStatus(version.getId(), ReviewTaskStatus.PENDING)
.ifPresent(reviewTaskRepository::delete);
// Every review task referencing this version has to go, not just a PENDING one:
// a rejected version still owns a REJECTED task whose foreign key blocks the
// skill_version delete below, which surfaces to the caller as an HTTP 500.
reviewTaskRepository.deleteBySkillVersionIdIn(List.of(version.getId()));
List<SkillFile> files = skillFileRepository.findByVersionId(version.getId());
List<String> storageKeys = new ArrayList<>();

View file

@ -260,7 +260,7 @@ class SkillPublishServiceTest {
}
@Test
void testPublishFromEntries_ShouldReplaceDraftVersionWithSameVersion() throws Exception {
void testPublishFromEntries_ShouldReplaceRejectedVersionWithSameVersion() throws Exception {
String namespaceSlug = "test-ns";
String publisherId = "user-100";
String skillMdContent = "---\nname: test-skill\ndescription: Test\nversion: 1.0.0\n---\nBody";
@ -275,9 +275,9 @@ class SkillPublishServiceTest {
Skill skill = new Skill(1L, "test-skill", publisherId, SkillVisibility.PUBLIC);
setId(skill, 1L);
SkillVersion draftVersion = new SkillVersion(1L, "1.0.0", publisherId);
draftVersion.setStatus(SkillVersionStatus.DRAFT);
setId(draftVersion, 8L);
SkillVersion rejectedVersion = new SkillVersion(1L, "1.0.0", publisherId);
rejectedVersion.setStatus(SkillVersionStatus.REJECTED);
setId(rejectedVersion, 8L);
SkillFile oldFile = new SkillFile(8L, "SKILL.md", (long) skillMdContent.length(), "text/markdown", "abc", "skills/1/8/SKILL.md");
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
@ -288,7 +288,7 @@ class SkillPublishServiceTest {
when(skillRepository.findByNamespaceIdAndSlug(any(), eq("test-skill"))).thenReturn(List.of(skill));
when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(any(), eq("test-skill"), eq(publisherId))).thenReturn(Optional.of(skill));
when(skillVersionRepository.findBySkillIdAndStatus(1L, SkillVersionStatus.PENDING_REVIEW)).thenReturn(List.of());
when(skillVersionRepository.findBySkillIdAndVersion(1L, "1.0.0")).thenReturn(Optional.of(draftVersion));
when(skillVersionRepository.findBySkillIdAndVersion(1L, "1.0.0")).thenReturn(Optional.of(rejectedVersion));
when(skillFileRepository.findByVersionId(8L)).thenReturn(List.of(oldFile));
when(skillVersionRepository.save(any(SkillVersion.class))).thenAnswer(invocation -> {
SkillVersion saved = invocation.getArgument(0);
@ -309,10 +309,60 @@ class SkillPublishServiceTest {
assertEquals("1.0.0", result.version().getVersion());
assertEquals(SkillVersionStatus.PENDING_REVIEW, result.version().getStatus());
verify(reviewTaskRepository).deleteBySkillVersionIdIn(List.of(8L));
verify(skillFileRepository).deleteByVersionId(8L);
verify(skillVersionRepository).delete(draftVersion);
verify(skillVersionRepository).delete(rejectedVersion);
verify(skillVersionRepository).flush();
verify(objectStorageService).deleteObjects(List.of("skills/1/8/SKILL.md", "packages/1/8/bundle.zip"));
ArgumentCaptor<ReviewTask> reviewTaskCaptor = ArgumentCaptor.forClass(ReviewTask.class);
verify(reviewTaskRepository).save(reviewTaskCaptor.capture());
assertEquals(result.version().getId(), reviewTaskCaptor.getValue().getSkillVersionId());
assertEquals(publisherId, reviewTaskCaptor.getValue().getSubmittedBy());
}
@Test
void testPublishFromEntries_ShouldRejectReplacementOfYankedVersion() throws Exception {
String namespaceSlug = "test-ns";
String publisherId = "user-100";
String skillMdContent = "---\nname: test-skill\ndescription: Test\nversion: 1.0.0\n---\nBody";
PackageEntry skillMd = new PackageEntry("SKILL.md", skillMdContent.getBytes(), skillMdContent.length(), "text/markdown");
List<PackageEntry> entries = List.of(skillMd);
Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1");
setId(namespace, 1L);
NamespaceMember member = mock(NamespaceMember.class);
SkillMetadata metadata = new SkillMetadata("test-skill", "Test", "1.0.0", "Body", Map.of());
Skill skill = new Skill(1L, "test-skill", publisherId, SkillVisibility.PUBLIC);
setId(skill, 1L);
SkillVersion yankedVersion = new SkillVersion(1L, "1.0.0", publisherId);
yankedVersion.setStatus(SkillVersionStatus.YANKED);
setId(yankedVersion, 8L);
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(namespaceMemberRepository.findByNamespaceIdAndUserId(any(), eq(publisherId))).thenReturn(Optional.of(member));
when(skillPackageValidator.validate(entries)).thenReturn(ValidationResult.pass());
when(skillMetadataParser.parse(skillMdContent)).thenReturn(metadata);
when(prePublishValidator.validate(any())).thenReturn(ValidationResult.pass());
when(skillRepository.findByNamespaceIdAndSlug(any(), eq("test-skill"))).thenReturn(List.of(skill));
when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(any(), eq("test-skill"), eq(publisherId))).thenReturn(Optional.of(skill));
when(skillVersionRepository.findBySkillIdAndVersion(1L, "1.0.0")).thenReturn(Optional.of(yankedVersion));
DomainBadRequestException exception = assertThrows(DomainBadRequestException.class, () ->
service.publishFromEntries(
namespaceSlug,
entries,
publisherId,
SkillVisibility.PUBLIC,
Set.of()
));
assertEquals("error.skill.version.exists", exception.messageCode());
verify(reviewTaskRepository, never()).deleteBySkillVersionIdIn(anyList());
verify(skillVersionRepository, never()).delete(any());
verify(skillFileRepository, never()).deleteByVersionId(any());
}
@Test

View file

@ -6,6 +6,7 @@ import com.iflytek.skillhub.search.SearchEmbeddingService;
import com.iflytek.skillhub.search.SearchIndexService;
import com.iflytek.skillhub.search.SkillSearchDocument;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@ -32,7 +33,7 @@ public class PostgresFullTextIndexService implements SearchIndexService {
}
@Override
@Transactional
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void index(SkillSearchDocument document) {
SkillSearchDocument normalizedDocument = normalize(document);
Optional<SkillSearchDocumentEntity> existing = repository.findBySkillId(document.skillId());

View file

@ -7,6 +7,7 @@ COPY . .
RUN pnpm build
FROM nginx:alpine
ENV SKILLHUB_TRUST_FORWARDED_PROTO=false
COPY --from=build /app/dist /usr/share/nginx/html
COPY --from=build /app/src/docs/skill.md.template /usr/share/nginx/html/registry/skill.md.template
COPY nginx.conf.template /etc/nginx/templates/default.conf.template

View file

@ -3,6 +3,7 @@ import { tmpdir } from 'node:os'
import { execFileSync } from 'node:child_process'
import path from 'node:path'
import type { APIRequestContext, Page, TestInfo } from '@playwright/test'
import type { components } from '../../src/api/generated/schema'
import { csrfHeaders } from './csrf'
type CleanupTask = () => Promise<void>
@ -32,14 +33,9 @@ export interface SeededReviewData {
skill: SeededSkill
}
interface ReviewTaskSummary {
id: number
namespace: string
skillSlug: string
status: string
submittedBy: string
version: string
}
type ReviewTaskResponse = components['schemas']['ReviewTaskResponse']
type SkillVersionResponse = components['schemas']['SkillVersionResponse']
type SkillVersionStatus = NonNullable<SkillVersionResponse['status']>
interface NamespaceCandidate {
userId: string
@ -463,19 +459,17 @@ export class E2eTestDataBuilder {
async waitForPendingReview(namespaceSlug: string, skillSlug: string, version: string): Promise<number> {
for (let attempt = 0; attempt < 20; attempt += 1) {
try {
const page = await parseEnvelope<{
items: ReviewTaskSummary[]
}>(
const page = await parseEnvelope<components['schemas']['PageResponseReviewTaskResponse']>(
await this.request.get('/api/web/reviews?status=PENDING&page=0&size=100&sortDirection=DESC'),
)
const matched = page.items.find((item) =>
const matched = page.items?.find((item) =>
item.namespace === namespaceSlug &&
item.skillSlug === skillSlug &&
item.version === version &&
item.status === 'PENDING',
)
if (matched) {
if (matched?.id != null) {
return matched.id
}
} catch {
@ -488,6 +482,38 @@ export class E2eTestDataBuilder {
throw new Error(`Timed out waiting for pending review ${namespaceSlug}/${skillSlug}@${version}`)
}
async waitForVersionStatus(
namespaceSlug: string,
skillSlug: string,
version: string,
expectedStatus: SkillVersionStatus,
): Promise<number> {
for (let attempt = 0; attempt < 60; attempt += 1) {
try {
const page = await parseEnvelope<components['schemas']['PageResponseSkillVersionResponse']>(
await this.request.get(
`/api/web/skills/${encodeURIComponent(namespaceSlug)}/${encodeURIComponent(skillSlug)}/versions?page=0&size=100`,
),
)
const matched = page.items?.find((item) =>
item.version === version && item.status === expectedStatus,
)
if (matched?.id != null) {
return matched.id
}
} catch {
// Security scanning and version projection can complete asynchronously.
}
await new Promise((resolve) => setTimeout(resolve, 1_000))
}
throw new Error(
`Timed out waiting for ${namespaceSlug}/${skillSlug}@${version} to reach ${expectedStatus}`,
)
}
async approveReview(reviewTaskId: number, comment = 'Approved by Playwright E2E'): Promise<void> {
let lastError: unknown
for (let attempt = 0; attempt < 60; attempt += 1) {
@ -512,6 +538,15 @@ export class E2eTestDataBuilder {
throw lastError instanceof Error ? lastError : new Error('approveReview timed out')
}
async rejectReview(reviewTaskId: number, comment = 'Rejected by Playwright E2E'): Promise<void> {
await parseEnvelope<ReviewTaskResponse>(
await this.request.post(`/api/web/reviews/${reviewTaskId}/reject`, {
data: { comment },
headers: await csrfHeaders(this.page),
}),
)
}
async searchNamespaceMemberCandidates(slug: string, search: string): Promise<NamespaceCandidate[]> {
const query = new URLSearchParams({ search })
return parseEnvelope<NamespaceCandidate[]>(

View file

@ -0,0 +1,85 @@
import { expect, test } from '@playwright/test'
import { setEnglishLocale } from './helpers/auth-fixtures'
import { loginWithCredentials, registerSession } from './helpers/session'
import { E2eTestDataBuilder } from './helpers/test-data-builder'
function getOptionalEnv(name: string): string | undefined {
const value = process.env[name]?.trim()
return value ? value : undefined
}
function adminCredentials() {
return {
username: getOptionalEnv('E2E_ADMIN_USERNAME') ?? getOptionalEnv('BOOTSTRAP_ADMIN_USERNAME') ?? 'admin',
password: getOptionalEnv('E2E_ADMIN_PASSWORD') ?? getOptionalEnv('BOOTSTRAP_ADMIN_PASSWORD') ?? 'ChangeMe!2026',
}
}
test.describe('Rejected version replacement (Real API)', () => {
test.describe.configure({ timeout: 150_000 })
test.beforeEach(async ({ page }, testInfo) => {
await setEnglishLocale(page)
await registerSession(page, testInfo)
})
test('re-publishes the same version after rejection', async ({ page, browser }, testInfo) => {
const publisherBuilder = new E2eTestDataBuilder(page, testInfo)
await publisherBuilder.init()
const adminContext = await browser.newContext()
const adminPage = await adminContext.newPage()
const adminBuilder = new E2eTestDataBuilder(adminPage, testInfo)
await loginWithCredentials(adminPage, adminCredentials(), testInfo)
await adminBuilder.init()
try {
const namespace = await publisherBuilder.ensureWritableNamespace()
const skillName = `replace-rejected-${Date.now().toString(36)}`
const firstPublish = await publisherBuilder.publishSkill(namespace.slug, {
name: skillName,
version: '1.0.0',
})
const rejectedReviewId = await adminBuilder.waitForPendingReview(
namespace.slug,
firstPublish.slug,
firstPublish.version,
)
await publisherBuilder.waitForVersionStatus(
namespace.slug,
firstPublish.slug,
firstPublish.version,
'PENDING_REVIEW',
)
await adminBuilder.rejectReview(rejectedReviewId)
const replacement = await publisherBuilder.publishSkill(namespace.slug, {
name: skillName,
description: 'Replacement after review rejection',
version: '1.0.0',
})
const replacementReviewId = await adminBuilder.waitForPendingReview(
namespace.slug,
replacement.slug,
replacement.version,
)
await publisherBuilder.waitForVersionStatus(
namespace.slug,
replacement.slug,
replacement.version,
'PENDING_REVIEW',
)
expect(replacement.skillId).toBe(firstPublish.skillId)
expect(replacement.version).toBe(firstPublish.version)
expect(replacementReviewId).not.toBe(rejectedReviewId)
const replacedReviewResponse = await adminPage.request.get(`/api/web/reviews/${rejectedReviewId}`)
expect(replacedReviewResponse.status()).toBe(404)
} finally {
await adminBuilder.cleanup()
await adminContext.close()
await publisherBuilder.cleanup()
}
})
})

View file

@ -10,6 +10,17 @@ server {
gzip_types text/plain text/css application/json application/javascript text/xml;
gzip_min_length 1000;
# Ignore client-supplied forwarded proto by default. Operators may explicitly trust a
# sanitizing upstream proxy; only canonical http/https values are then accepted.
set $proxy_x_forwarded_proto $scheme;
set $forwarded_proto_source "${SKILLHUB_TRUST_FORWARDED_PROTO}:$http_x_forwarded_proto";
if ($forwarded_proto_source ~* "^true:https$") {
set $proxy_x_forwarded_proto https;
}
if ($forwarded_proto_source ~* "^true:http$") {
set $proxy_x_forwarded_proto http;
}
location / {
try_files $uri $uri/ /index.html;
}
@ -19,27 +30,31 @@ server {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
}
location /oauth2/ {
proxy_pass ${SKILLHUB_API_UPSTREAM};
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
}
location /login/oauth2/ {
proxy_pass ${SKILLHUB_API_UPSTREAM};
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
}
location /.well-known/ {
proxy_pass ${SKILLHUB_API_UPSTREAM};
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
}
location /assets/ {

View file

@ -4,6 +4,7 @@ import { Layout } from './layout'
import { getCurrentUser } from '@/api/client'
import { RoleGuard } from '@/shared/components/role-guard'
import { createRequireAuth } from '@/shared/lib/auth-route'
import { clearDynamicImportReloadGuard, recoverFromDynamicImportError } from '@/shared/lib/dynamic-import-recovery'
import { normalizeSearchQuery } from '@/shared/lib/search-query'
/**
@ -25,7 +26,15 @@ function createLazyRouteComponent<TModule extends Record<string, unknown>>(
// Lazy route modules are wrapped in a uniform suspense fallback so route transitions behave
// consistently across public and dashboard pages.
const LazyComponent = lazy(async () => {
const module = await importer()
const module = await importer().catch((error) => {
if (recoverFromDynamicImportError(error)) {
return new Promise<never>(() => {})
}
throw error
})
// Router resolution can finish before React.lazy imports the route module. Only clear the
// one-time reload guard after the chunk itself has loaded successfully.
clearDynamicImportReloadGuard()
return { default: module[exportName] as ComponentType<Record<string, unknown>> }
})

View file

@ -0,0 +1,87 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
clearDynamicImportReloadGuard,
isDynamicImportFetchError,
recoverFromDynamicImportError,
} from './dynamic-import-recovery'
const values = new Map<string, string>()
const reload = vi.fn()
const sessionStorage = {
get length() {
return values.size
},
clear: vi.fn(() => values.clear()),
getItem: vi.fn((key: string) => values.get(key) ?? null),
key: vi.fn((index: number) => Array.from(values.keys())[index] ?? null),
removeItem: vi.fn((key: string) => values.delete(key)),
setItem: vi.fn((key: string, value: string) => values.set(key, value)),
} satisfies Storage
describe('dynamic import recovery', () => {
beforeEach(() => {
values.clear()
reload.mockClear()
vi.stubGlobal('window', {
location: { reload },
sessionStorage,
})
})
afterEach(() => {
vi.unstubAllGlobals()
})
it.each([
'Failed to fetch dynamically imported module: /assets/login.js',
'error loading dynamically imported module: /assets/login.js',
'Importing a module script failed',
'ChunkLoadError: Loading chunk 42 failed',
])('recognizes a stale dynamic import error: %s', (message) => {
expect(isDynamicImportFetchError(new Error(message))).toBe(true)
})
it('ignores unrelated errors', () => {
expect(isDynamicImportFetchError(new Error('Request failed with status 500'))).toBe(false)
})
it('recognizes errors whose name is ChunkLoadError', () => {
const error = new Error('Loading chunk 42 failed')
error.name = 'ChunkLoadError'
expect(isDynamicImportFetchError(error)).toBe(true)
})
it('reloads only once while the recovery guard is active', () => {
const error = new Error('Failed to fetch dynamically imported module')
expect(recoverFromDynamicImportError(error)).toBe(true)
expect(recoverFromDynamicImportError(error)).toBe(false)
expect(recoverFromDynamicImportError(error)).toBe(false)
expect(reload).toHaveBeenCalledTimes(1)
})
it('allows recovery again after a dynamic import succeeds', () => {
const error = new Error('Failed to fetch dynamically imported module')
expect(recoverFromDynamicImportError(error)).toBe(true)
clearDynamicImportReloadGuard()
expect(recoverFromDynamicImportError(error)).toBe(true)
expect(reload).toHaveBeenCalledTimes(2)
})
it('does not mask the original import error when session storage is unavailable', () => {
vi.stubGlobal('window', {
location: { reload },
get sessionStorage() {
throw new DOMException('Access denied', 'SecurityError')
},
})
const error = new Error('Failed to fetch dynamically imported module')
expect(recoverFromDynamicImportError(error)).toBe(false)
expect(() => clearDynamicImportReloadGuard()).not.toThrow()
expect(reload).not.toHaveBeenCalled()
})
})

View file

@ -0,0 +1,48 @@
const RELOAD_GUARD_KEY = 'skillhub:dynamic-import-reload'
function resolveErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message
}
return String(error ?? '')
}
export function isDynamicImportFetchError(error: unknown): boolean {
const message = resolveErrorMessage(error)
return (error instanceof Error && error.name === 'ChunkLoadError')
|| message.includes('Failed to fetch dynamically imported module')
|| message.includes('error loading dynamically imported module')
|| message.includes('Importing a module script failed')
|| message.includes('ChunkLoadError')
}
export function recoverFromDynamicImportError(error: unknown): boolean {
if (typeof window === 'undefined' || !isDynamicImportFetchError(error)) {
return false
}
let sessionStorage: Storage
try {
sessionStorage = window.sessionStorage
if (sessionStorage.getItem(RELOAD_GUARD_KEY) === '1') {
return false
}
sessionStorage.setItem(RELOAD_GUARD_KEY, '1')
} catch {
return false
}
window.location.reload()
return true
}
export function clearDynamicImportReloadGuard(): void {
if (typeof window === 'undefined') {
return
}
try {
window.sessionStorage.removeItem(RELOAD_GUARD_KEY)
} catch {
// Session storage can be unavailable in restricted browsing contexts.
}
}