mirror of
https://github.com/iflytek/skillhub.git
synced 2026-08-28 11:25:00 +00:00
Compare commits
No commits in common. "main" and "v0.2.16" have entirely different histories.
272 changed files with 438 additions and 20408 deletions
8
.github/workflows/publish-images.yml
vendored
8
.github/workflows/publish-images.yml
vendored
|
|
@ -13,6 +13,9 @@ permissions:
|
|||
contents: read
|
||||
packages: write
|
||||
|
||||
env:
|
||||
DOCKER_PLATFORMS: linux/amd64,linux/arm64
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
|
|
@ -28,19 +31,16 @@ jobs:
|
|||
- name: server
|
||||
context: ./server
|
||||
dockerfile: ./server/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64,linux/riscv64
|
||||
image: ghcr.io/${{ github.repository_owner }}/skillhub-server
|
||||
mirror_image: skillhub-server
|
||||
- name: web
|
||||
context: ./web
|
||||
dockerfile: ./web/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64,linux/riscv64
|
||||
image: ghcr.io/${{ github.repository_owner }}/skillhub-web
|
||||
mirror_image: skillhub-web
|
||||
- name: scanner
|
||||
context: ./scanner
|
||||
dockerfile: ./scanner/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
image: ghcr.io/${{ github.repository_owner }}/skillhub-scanner
|
||||
mirror_image: skillhub-scanner
|
||||
|
||||
|
|
@ -109,7 +109,7 @@ jobs:
|
|||
with:
|
||||
context: ${{ matrix.context }}
|
||||
file: ${{ matrix.dockerfile }}
|
||||
platforms: ${{ matrix.platforms }}
|
||||
platforms: ${{ env.DOCKER_PLATFORMS }}
|
||||
push: true
|
||||
provenance: false
|
||||
sbom: false
|
||||
|
|
|
|||
65
.github/workflows/riscv64-images.yml
vendored
65
.github/workflows/riscv64-images.yml
vendored
|
|
@ -1,65 +0,0 @@
|
|||
name: RISC-V Images
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/riscv64-images.yml'
|
||||
- '.github/workflows/publish-images.yml'
|
||||
- 'server/**'
|
||||
- 'web/**'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build ${{ matrix.name }} (linux/riscv64)
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: server
|
||||
context: ./server
|
||||
dockerfile: ./server/Dockerfile
|
||||
- name: web
|
||||
context: ./web
|
||||
dockerfile: ./web/Dockerfile
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
with:
|
||||
platforms: riscv64
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build RISC-V image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ${{ matrix.context }}
|
||||
file: ${{ matrix.dockerfile }}
|
||||
platforms: linux/riscv64
|
||||
load: true
|
||||
tags: skillhub-${{ matrix.name }}:riscv64-ci
|
||||
cache-from: type=gha,scope=riscv64-${{ matrix.name }}
|
||||
cache-to: type=gha,mode=max,scope=riscv64-${{ matrix.name }}
|
||||
|
||||
- name: Verify image architecture and runtime
|
||||
shell: bash
|
||||
run: |
|
||||
image="skillhub-${{ matrix.name }}:riscv64-ci"
|
||||
test "$(docker image inspect "$image" --format '{{.Architecture}}')" = riscv64
|
||||
case "${{ matrix.name }}" in
|
||||
server)
|
||||
docker run --rm --platform linux/riscv64 --entrypoint java "$image" -version
|
||||
;;
|
||||
web)
|
||||
docker run --rm --platform linux/riscv64 --entrypoint nginx "$image" -v
|
||||
;;
|
||||
esac
|
||||
|
|
@ -29,16 +29,5 @@ project spaces.
|
|||
|
||||
## Reporting
|
||||
|
||||
Report conduct issues privately to
|
||||
[ifly_opensource@iflytek.com](mailto:ifly_opensource@iflytek.com) with the subject
|
||||
`SkillHub Code of Conduct report`. Do not use public issues for personal, sensitive,
|
||||
or confidential reports.
|
||||
|
||||
Reports are handled under the iFLYTEK community
|
||||
[incident resolution procedures](https://github.com/iflytek/community/blob/master/code-of-conduct/coc-incident-resolution-procedures.md).
|
||||
Information is shared only with people who need it to review the report, protect
|
||||
participants, or comply with law. Retaliation for a good-faith report is prohibited.
|
||||
|
||||
People materially affected by a conduct decision may request an impartial review
|
||||
through the appeal process in the
|
||||
[Content Safety Policy](docs/CONTENT_SAFETY.md#appeals).
|
||||
Report conduct issues privately to the maintainers through a private maintainer
|
||||
channel. Do not use public issues for personal or sensitive reports.
|
||||
|
|
|
|||
8
Makefile
8
Makefile
|
|
@ -43,7 +43,7 @@ dev-all: ## 一键启动本地开发环境(依赖 + scanner + 后端 + 前端
|
|||
echo "Backend already running with PID $$(cat $(DEV_SERVER_PID))"; \
|
||||
else \
|
||||
echo "Starting backend..."; \
|
||||
$(DEV_PROCESS) start --pid-file $(DEV_SERVER_PID) --log-file $(DEV_SERVER_LOG) --cwd server -- bash -lc '$(DEV_SERVER_PREPARE) && exec env $(DEV_SERVER_SCANNER_ENV) $(DEV_SERVER_CMD)' >/dev/null; \
|
||||
$(DEV_PROCESS) start --pid-file $(DEV_SERVER_PID) --log-file $(DEV_SERVER_LOG) --cwd server -- /bin/sh -lc '$(DEV_SERVER_PREPARE) && exec env $(DEV_SERVER_SCANNER_ENV) $(DEV_SERVER_CMD)' >/dev/null; \
|
||||
fi
|
||||
@if $(DEV_PROCESS) status --pid-file $(DEV_WEB_PID) >/dev/null 2>&1; then \
|
||||
echo "Frontend already running with PID $$(cat $(DEV_WEB_PID))"; \
|
||||
|
|
@ -69,7 +69,7 @@ dev-all: ## 一键启动本地开发环境(依赖 + scanner + 后端 + 前端
|
|||
echo "Backend did not become ready on attempt $$attempt. Restarting..."; \
|
||||
$(DEV_PROCESS) stop --pid-file $(DEV_SERVER_PID); \
|
||||
sleep 2; \
|
||||
$(DEV_PROCESS) start --pid-file $(DEV_SERVER_PID) --log-file $(DEV_SERVER_LOG) --cwd server -- bash -lc '$(DEV_SERVER_PREPARE) && exec env $(DEV_SERVER_SCANNER_ENV) $(DEV_SERVER_CMD)' >/dev/null; \
|
||||
$(DEV_PROCESS) start --pid-file $(DEV_SERVER_PID) --log-file $(DEV_SERVER_LOG) --cwd server -- /bin/sh -lc '$(DEV_SERVER_PREPARE) && exec env $(DEV_SERVER_SCANNER_ENV) $(DEV_SERVER_CMD)' >/dev/null; \
|
||||
fi; \
|
||||
done; \
|
||||
if [ "$$backend_ready" -ne 1 ]; then \
|
||||
|
|
@ -127,12 +127,12 @@ dev-all: ## 一键启动本地开发环境(依赖 + scanner + 后端 + 前端
|
|||
@echo " Frontend: $(DEV_WEB_LOG)"
|
||||
|
||||
dev-server: ## 启动后端开发服务器
|
||||
cd server && bash -lc '$(DEV_SERVER_PREPARE) && exec env $(DEV_SERVER_SCANNER_ENV) $(DEV_SERVER_CMD)'
|
||||
cd server && /bin/sh -lc '$(DEV_SERVER_PREPARE) && exec env $(DEV_SERVER_SCANNER_ENV) $(DEV_SERVER_CMD)'
|
||||
|
||||
dev-server-restart: ## 重启后端开发服务器
|
||||
@mkdir -p $(DEV_DIR)
|
||||
@$(DEV_PROCESS) stop --pid-file $(DEV_SERVER_PID)
|
||||
@$(DEV_PROCESS) start --pid-file $(DEV_SERVER_PID) --log-file $(DEV_SERVER_LOG) --cwd server -- bash -lc '$(DEV_SERVER_PREPARE) && exec env $(DEV_SERVER_SCANNER_ENV) $(DEV_SERVER_CMD)' >/dev/null
|
||||
@$(DEV_PROCESS) start --pid-file $(DEV_SERVER_PID) --log-file $(DEV_SERVER_LOG) --cwd server -- /bin/sh -lc '$(DEV_SERVER_PREPARE) && exec env $(DEV_SERVER_SCANNER_ENV) $(DEV_SERVER_CMD)' >/dev/null
|
||||
@echo "Waiting for backend on $(DEV_API_URL) ..."
|
||||
@for i in $$(seq 1 30); do \
|
||||
if curl -sf $(DEV_API_URL)/actuator/health >/dev/null; then \
|
||||
|
|
|
|||
29
README.md
29
README.md
|
|
@ -64,19 +64,6 @@ with the Skill's source and the problem it solves, or submit a PR by following t
|
|||
|
||||
- 📖 **[User Guide](https://iflytek.github.io/skillhub/)** — Skill publishing, search, CLI usage and other user guides
|
||||
- 🛠️ **[Developer Docs](https://zread.ai/iflytek/skillhub)** — Architecture, API reference, local development, deployment and operations
|
||||
- 🐍 **[Python Examples](./examples/python)** — Search, download, and publish skills from Python via the REST API
|
||||
|
||||
## Governance and Safety
|
||||
|
||||
- **[Privacy and Data Governance](docs/PRIVACY_AND_DATA_GOVERNANCE.md)** —
|
||||
Data categories, operator responsibilities, retention, portability, and incident
|
||||
handling for public and self-hosted instances
|
||||
- **[Content Safety](docs/CONTENT_SAFETY.md)** — Package safety expectations,
|
||||
review and reporting controls, appeals, and child-safety responsibilities
|
||||
- **[Code of Conduct](CODE_OF_CONDUCT.md)** — Community standards and the private
|
||||
reporting channel
|
||||
- **[Security Policy](https://github.com/iflytek/.github/blob/main/SECURITY.md)** —
|
||||
Private vulnerability reporting and coordinated disclosure
|
||||
|
||||
## Highlights
|
||||
|
||||
|
|
@ -249,9 +236,7 @@ frontend schema, and fails if the checked-in SDK is stale.
|
|||
Published runtime images are built by GitHub Actions and pushed to GHCR.
|
||||
This is the supported path for anyone who wants a ready-to-use local
|
||||
environment without building the backend or frontend on their machine.
|
||||
Published server and web images target `linux/amd64`, `linux/arm64`, and
|
||||
`linux/riscv64`; the scanner image currently targets `linux/amd64` and
|
||||
`linux/arm64`.
|
||||
Published images target both `linux/amd64` and `linux/arm64`.
|
||||
|
||||
**Quick deployment with curl:**
|
||||
|
||||
|
|
@ -423,18 +408,6 @@ Run it against a local backend:
|
|||
./scripts/smoke-test.sh http://localhost:8080
|
||||
```
|
||||
|
||||
Local Compose and staging runs can keep using one backend URL. For an ingress
|
||||
deployment where the public URL exposes application APIs but keeps Actuator on
|
||||
the backend service, set a separate Actuator target:
|
||||
|
||||
```bash
|
||||
ACTUATOR_BASE_URL=http://skillhub-server:8080 \
|
||||
./scripts/smoke-test.sh https://skillhub.example.com
|
||||
```
|
||||
|
||||
The health check requires an Actuator JSON response, so an HTML SPA fallback is
|
||||
reported as a routing or target error instead of a successful health response.
|
||||
|
||||
Admin label-management smoke checks run only when current admin credentials are
|
||||
supplied explicitly:
|
||||
|
||||
|
|
|
|||
|
|
@ -50,7 +50,6 @@ Skill,欢迎分享给 SkillHub 社区,与大家一起丰富开放、实用
|
|||
|
||||
- 📖 **[用户指南](https://iflytek.github.io/skillhub/)** — 技能发布、搜索、CLI 使用等用户操作指南
|
||||
- 🛠️ **[开发者文档](https://zread.ai/iflytek/skillhub)** — 架构设计、API 参考、本地开发、部署运维等技术文档
|
||||
- 🐍 **[Python 示例](./examples/python)** — 使用 REST API 在 Python 中搜索、下载和发布技能
|
||||
|
||||
## 核心特性
|
||||
|
||||
|
|
|
|||
|
|
@ -32,9 +32,6 @@ skillhub list
|
|||
|
||||
# Publish skill
|
||||
skillhub publish ./my-skill --namespace myspace
|
||||
|
||||
# Synchronize a team workspace
|
||||
skillhub sync pull --namespace myspace
|
||||
```
|
||||
|
||||
## 🌐 Registry Configuration
|
||||
|
|
@ -232,45 +229,11 @@ For a custom path or an unsupported Agent directory, use `--dir` to specify the
|
|||
"namespace": "global",
|
||||
"slug": "pdf-parser",
|
||||
"version": "1.0.0",
|
||||
"fingerprint": "sha256:...",
|
||||
"source": "skillhub",
|
||||
"agent": "codex",
|
||||
"installedAt": "2026-04-28T06:00:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
## 🔄 Namespace Workspaces
|
||||
|
||||
Use namespace synchronization when an Agent workspace should maintain all installable skills from one team space.
|
||||
|
||||
```bash
|
||||
# Pull new and updated skills into ./.agents/skills
|
||||
skillhub sync pull --namespace team-a
|
||||
|
||||
# Use an explicit workspace directory
|
||||
skillhub sync pull --namespace team-a --dir ./.claude/skills
|
||||
|
||||
# Check without downloading
|
||||
skillhub sync pull --namespace team-a --check
|
||||
|
||||
# Show local edits and remote updates
|
||||
skillhub sync status --namespace team-a --json
|
||||
skillhub sync diff --namespace team-a
|
||||
|
||||
# Remove only unchanged SkillHub-managed skills that no longer exist remotely
|
||||
skillhub sync pull --namespace team-a --prune
|
||||
|
||||
# Validate and upload every local skill for review
|
||||
skillhub sync push --all --namespace team-a --dry-run
|
||||
skillhub sync push --all --namespace team-a --submit-review
|
||||
```
|
||||
|
||||
The default workspace is `<cwd>/.agents/skills`. Pull never overwrites local changes unless `--force` is supplied. Remote removals are reported as `orphaned` and are retained unless `--prune` is supplied. Both destructive cases still require explicit flags.
|
||||
|
||||
Workspace push is non-overwriting: an existing namespace/slug/version is reported as a conflict, including versions that are still uploaded or pending review. Other skills in the same `--all` run continue processing.
|
||||
|
||||
Namespace sync writes `.skillhub/namespace-sync.json` in the workspace and per-skill `.skillhub/metadata.json` files. These files contain the registry coordinate, published version, aggregate fingerprint, and file hashes used by `status` and `diff`.
|
||||
|
||||
## 📋 Local Management
|
||||
|
||||
### List Installed Skills
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@astron-team/skillhub",
|
||||
"version": "0.1.10",
|
||||
"version": "0.1.9",
|
||||
"description": "Manage and install skills for AI coding agents",
|
||||
"keywords": [
|
||||
"skillhub",
|
||||
|
|
|
|||
|
|
@ -42,30 +42,6 @@ export interface PublishResponse {
|
|||
slug: string
|
||||
version: string
|
||||
visibility: string
|
||||
status: string
|
||||
}
|
||||
|
||||
export interface NamespaceSyncItem {
|
||||
namespace: string
|
||||
slug: string
|
||||
version: string
|
||||
versionId: number
|
||||
fingerprint: string
|
||||
updatedAt: string
|
||||
visibility: string
|
||||
downloadUrl: string
|
||||
}
|
||||
|
||||
export interface NamespaceSyncResponse {
|
||||
items: NamespaceSyncItem[]
|
||||
nextCursor?: string | null
|
||||
}
|
||||
|
||||
export interface SubmitReviewResponse {
|
||||
skillId: number
|
||||
versionId: number
|
||||
action: string
|
||||
status: string
|
||||
}
|
||||
|
||||
export interface DryRunResponse {
|
||||
|
|
@ -104,12 +80,6 @@ export class SkillHubClient {
|
|||
return this.getJson(`/skills/${namespace}/${slug}/resolve${params}`)
|
||||
}
|
||||
|
||||
async listNamespaceSkills(namespace: string, cursor?: string, limit = 100): Promise<NamespaceSyncResponse> {
|
||||
const params = new URLSearchParams({ limit: String(limit) })
|
||||
if (cursor) params.set('cursor', cursor)
|
||||
return this.getJson(`/namespaces/${encodeURIComponent(namespace)}/skills?${params}`)
|
||||
}
|
||||
|
||||
async downloadUrl(namespace: string, slug: string, version?: string): Promise<string> {
|
||||
if (version) {
|
||||
return `${this.registry}/api/cli/v1/skills/${namespace}/${slug}/versions/${version}/download`
|
||||
|
|
@ -135,17 +105,10 @@ export class SkillHubClient {
|
|||
return this.deleteJson(`/skills/${namespace}/${slug}`)
|
||||
}
|
||||
|
||||
async publish(
|
||||
namespace: string,
|
||||
file: Blob,
|
||||
visibility: string,
|
||||
fileName = 'skill.zip',
|
||||
rejectExistingVersion = false
|
||||
): Promise<PublishResponse> {
|
||||
async publish(namespace: string, file: Blob, visibility: string, fileName = 'skill.zip'): Promise<PublishResponse> {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file, fileName)
|
||||
formData.append('visibility', visibility)
|
||||
if (rejectExistingVersion) formData.append('rejectExistingVersion', 'true')
|
||||
let response: Response
|
||||
try {
|
||||
response = await this.fetchImpl(`${this.registry}/api/cli/v1/skills/${namespace}/publish`, {
|
||||
|
|
@ -159,17 +122,10 @@ export class SkillHubClient {
|
|||
return this.handleJsonResponse<PublishResponse>(response)
|
||||
}
|
||||
|
||||
async validatePublish(
|
||||
namespace: string,
|
||||
file: Blob,
|
||||
visibility: string,
|
||||
fileName = 'skill.zip',
|
||||
rejectExistingVersion = false
|
||||
): Promise<DryRunResponse> {
|
||||
async validatePublish(namespace: string, file: Blob, visibility: string, fileName = 'skill.zip'): Promise<DryRunResponse> {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file, fileName)
|
||||
formData.append('visibility', visibility)
|
||||
if (rejectExistingVersion) formData.append('rejectExistingVersion', 'true')
|
||||
let response: Response
|
||||
try {
|
||||
response = await this.fetchImpl(`${this.registry}/api/cli/v1/skills/${namespace}/publish/validate`, {
|
||||
|
|
@ -183,28 +139,6 @@ export class SkillHubClient {
|
|||
return this.handleJsonResponse<DryRunResponse>(response)
|
||||
}
|
||||
|
||||
async submitReview(
|
||||
namespace: string,
|
||||
slug: string,
|
||||
version: string,
|
||||
targetVisibility: 'PUBLIC' | 'NAMESPACE_ONLY'
|
||||
): Promise<SubmitReviewResponse> {
|
||||
let response: Response
|
||||
try {
|
||||
response = await this.fetchImpl(
|
||||
`${this.registry}/api/v1/skills/${encodeURIComponent(namespace)}/${encodeURIComponent(slug)}/submit-review`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { ...this.headers(), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ version, targetVisibility })
|
||||
}
|
||||
)
|
||||
} catch {
|
||||
throw new CliError('registry unreachable', EXIT.network, { registry: this.registry, next: 'check network or pass --registry' })
|
||||
}
|
||||
return this.handleJsonResponse<SubmitReviewResponse>(response)
|
||||
}
|
||||
|
||||
private async getJson<T>(path: string): Promise<T> {
|
||||
let response: Response
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -43,15 +43,6 @@ export const commands = {
|
|||
'skillhub install pdf-parser --scope project --agent codex'
|
||||
]
|
||||
},
|
||||
sync: {
|
||||
summary: 'Synchronize and maintain namespace workspaces',
|
||||
usage: 'skillhub sync <pull|status|diff|push> [options]',
|
||||
examples: [
|
||||
'skillhub sync pull --namespace team-a',
|
||||
'skillhub sync status --namespace team-a --json',
|
||||
'skillhub sync push --all --namespace team-a --submit-review'
|
||||
]
|
||||
},
|
||||
list: {
|
||||
summary: 'List local installs',
|
||||
usage: 'skillhub list [--agent <profile>] [--dir <path>] [--registry <url>] [--json]',
|
||||
|
|
|
|||
|
|
@ -1,179 +0,0 @@
|
|||
import { join, resolve } from 'node:path'
|
||||
import { ConfigStore } from '../stores/config-store'
|
||||
import { CredentialsStore } from '../stores/credentials-store'
|
||||
import { SkillHubClient } from '../clients/skillhub-client'
|
||||
import { resolveRegistry, resolveToken } from '../services/registry-service'
|
||||
import {
|
||||
discoverSkillDirectories,
|
||||
inspectNamespaceWorkspace,
|
||||
pullNamespace,
|
||||
pushSkills,
|
||||
type PullResult,
|
||||
type PushResultItem,
|
||||
type SyncStatusEntry
|
||||
} from '../services/sync-service'
|
||||
import { CliError } from '../shared/errors'
|
||||
import { EXIT } from '../shared/constants'
|
||||
|
||||
export interface SyncCommonOptions {
|
||||
namespace?: string
|
||||
dir?: string
|
||||
registry?: string
|
||||
token?: string
|
||||
json?: boolean
|
||||
}
|
||||
|
||||
export interface SyncPullOptions extends SyncCommonOptions {
|
||||
check?: boolean
|
||||
prune?: boolean
|
||||
force?: boolean
|
||||
}
|
||||
|
||||
export interface SyncPushOptions extends SyncCommonOptions {
|
||||
all?: boolean
|
||||
visibility?: string
|
||||
dryRun?: boolean
|
||||
submitReview?: boolean
|
||||
}
|
||||
|
||||
export async function syncPullCommand(options: SyncPullOptions): Promise<string> {
|
||||
const context = await resolveSyncContext(options)
|
||||
const result = await pullNamespace({
|
||||
...context,
|
||||
check: Boolean(options.check),
|
||||
prune: Boolean(options.prune),
|
||||
force: Boolean(options.force)
|
||||
})
|
||||
const output = renderPullResult(result, Boolean(options.json), Boolean(options.check))
|
||||
if (result.failures.length > 0) {
|
||||
process.stdout.write(`${output}\n`)
|
||||
throw new CliError('namespace sync completed with failures', EXIT.generic, {
|
||||
namespace: context.namespace,
|
||||
failures: result.failures
|
||||
})
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
export async function syncStatusCommand(options: SyncCommonOptions): Promise<string> {
|
||||
const context = await resolveSyncContext(options)
|
||||
const result = await inspectNamespaceWorkspace(context)
|
||||
return renderStatusEntries(context.namespace, context.rootDir, result.entries, Boolean(options.json))
|
||||
}
|
||||
|
||||
export async function syncDiffCommand(options: SyncCommonOptions): Promise<string> {
|
||||
const context = await resolveSyncContext(options)
|
||||
const result = await inspectNamespaceWorkspace(context)
|
||||
const changed = result.entries.filter(entry => entry.status !== 'up-to-date')
|
||||
if (options.json) {
|
||||
return JSON.stringify({ ok: true, namespace: context.namespace, rootDir: context.rootDir, items: changed })
|
||||
}
|
||||
if (changed.length === 0) return `No differences for namespace ${context.namespace}.`
|
||||
return changed.flatMap(entry => {
|
||||
const lines = [`${entry.status.padEnd(16)} ${entry.slug}`]
|
||||
for (const path of entry.changedFiles) lines.push(` ${path}`)
|
||||
if (entry.reason) lines.push(` ${entry.reason}`)
|
||||
return lines
|
||||
}).join('\n')
|
||||
}
|
||||
|
||||
export async function syncPushCommand(path: string | undefined, options: SyncPushOptions): Promise<string> {
|
||||
const context = await resolveSyncContext(options)
|
||||
if (path && options.all) {
|
||||
throw new CliError('path cannot be combined with --all', EXIT.usage)
|
||||
}
|
||||
if (!path && !options.all) {
|
||||
throw new CliError('provide a skill path or pass --all', EXIT.usage)
|
||||
}
|
||||
|
||||
const visibility = normalizeVisibility(options.visibility ?? 'namespace-only')
|
||||
if (options.submitReview && visibility === 'PRIVATE') {
|
||||
throw new CliError('--submit-review requires public or namespace-only visibility', EXIT.usage)
|
||||
}
|
||||
const paths = options.all
|
||||
? await discoverSkillDirectories(context.rootDir)
|
||||
: [resolve(path!)]
|
||||
if (paths.length === 0) {
|
||||
throw new CliError(`no skill directories found in ${context.rootDir}`, EXIT.filesystem, { path: context.rootDir })
|
||||
}
|
||||
|
||||
const results = await pushSkills({
|
||||
client: context.client,
|
||||
namespace: context.namespace,
|
||||
paths,
|
||||
visibility,
|
||||
dryRun: Boolean(options.dryRun),
|
||||
submitReview: Boolean(options.submitReview)
|
||||
})
|
||||
const output = renderPushResults(context.namespace, results, Boolean(options.json), Boolean(options.dryRun))
|
||||
if (results.some(item => item.action === 'failed')) {
|
||||
process.stdout.write(`${output}\n`)
|
||||
throw new CliError('one or more skills failed to push', EXIT.validation, {
|
||||
namespace: context.namespace,
|
||||
failed: results.filter(item => item.action === 'failed')
|
||||
})
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
async function resolveSyncContext(options: SyncCommonOptions): Promise<{
|
||||
client: SkillHubClient
|
||||
registry: string
|
||||
token: string
|
||||
namespace: string
|
||||
rootDir: string
|
||||
}> {
|
||||
const configStore = new ConfigStore()
|
||||
const credentialsStore = new CredentialsStore()
|
||||
const registry = resolveRegistry(options, process.env, await configStore.read())
|
||||
const token = resolveToken(options, process.env, await credentialsStore.getToken(registry))
|
||||
if (!token) {
|
||||
throw new CliError('authentication required for namespace sync', EXIT.auth, { next: 'run `skillhub login`' })
|
||||
}
|
||||
const namespace = options.namespace ?? 'global'
|
||||
const rootDir = resolve(options.dir ?? join(process.cwd(), '.agents', 'skills'))
|
||||
return { client: new SkillHubClient(registry, token), registry, token, namespace, rootDir }
|
||||
}
|
||||
|
||||
function renderPullResult(result: PullResult, json: boolean, check: boolean): string {
|
||||
if (json) {
|
||||
return JSON.stringify({ ok: result.failures.length === 0, check, ...result })
|
||||
}
|
||||
const lines = [
|
||||
`${check ? 'Checked' : 'Synchronized'} ${result.namespace} in ${result.rootDir}`,
|
||||
...result.actions.map(item => `${item.action.padEnd(10)} ${item.slug}`),
|
||||
...result.entries
|
||||
.filter(entry => !result.actions.some(action => action.slug === entry.slug))
|
||||
.map(entry => `${entry.status.padEnd(16)} ${entry.slug}`),
|
||||
...result.failures.map(item => `failed ${item.slug}: ${item.message}`)
|
||||
]
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
function renderStatusEntries(namespace: string, rootDir: string, entries: SyncStatusEntry[], json: boolean): string {
|
||||
if (json) return JSON.stringify({ ok: true, namespace, rootDir, items: entries })
|
||||
if (entries.length === 0) return `No installable skills found in namespace ${namespace}.`
|
||||
return entries.map(entry => {
|
||||
const versions = entry.remoteVersion
|
||||
? ` local=${entry.localVersion ?? '-'} remote=${entry.remoteVersion}`
|
||||
: ` local=${entry.localVersion ?? '-'}`
|
||||
return `${entry.status.padEnd(16)} ${entry.slug}${versions}`
|
||||
}).join('\n')
|
||||
}
|
||||
|
||||
function renderPushResults(namespace: string, results: PushResultItem[], json: boolean, dryRun: boolean): string {
|
||||
if (json) return JSON.stringify({ ok: results.every(item => item.action !== 'failed'), namespace, dryRun, items: results })
|
||||
return results.map(item => {
|
||||
const coordinate = item.slug ? `${namespace}/${item.slug}${item.version ? `@${item.version}` : ''}` : item.path
|
||||
const detail = item.errors?.length ? `: ${item.errors.join('; ')}` : ''
|
||||
return `${item.action.padEnd(16)} ${coordinate}${detail}`
|
||||
}).join('\n')
|
||||
}
|
||||
|
||||
function normalizeVisibility(value: string): 'PUBLIC' | 'NAMESPACE_ONLY' | 'PRIVATE' {
|
||||
const normalized = value.toUpperCase().replace(/-/g, '_')
|
||||
if (normalized !== 'PUBLIC' && normalized !== 'NAMESPACE_ONLY' && normalized !== 'PRIVATE') {
|
||||
throw new CliError('visibility must be public, namespace-only, or private', EXIT.usage)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
// Generated by scripts/generate-pkg-info.ts - do not edit by hand.
|
||||
export const PKG_NAME = "@astron-team/skillhub"
|
||||
export const PKG_VERSION = "0.1.10"
|
||||
export const PKG_VERSION = "0.1.9"
|
||||
|
|
|
|||
|
|
@ -9,11 +9,9 @@ import { logoutCommand } from './commands/logout'
|
|||
import { publishCommand, type PublishCommandOptions } from './commands/publish'
|
||||
import { removeCommand, type RemoveCommandOptions } from './commands/remove'
|
||||
import { searchCommand } from './commands/search'
|
||||
import { syncDiffCommand, syncPullCommand, syncPushCommand, syncStatusCommand, type SyncCommonOptions, type SyncPullOptions, type SyncPushOptions } from './commands/sync'
|
||||
import { updateCommand } from './commands/update'
|
||||
import { versionCommand } from './commands/version'
|
||||
import { whoamiCommand } from './commands/whoami'
|
||||
import { EXIT } from './shared/constants'
|
||||
import { CliError } from './shared/errors'
|
||||
import { renderError } from './shared/output'
|
||||
|
||||
|
|
@ -247,37 +245,6 @@ cli
|
|||
return runCommand(() => installCommand(slug, { ...options, agent: toArray(options.agent) }), Boolean(options.json))
|
||||
})
|
||||
|
||||
cli
|
||||
.command('sync <action> [path]', 'Synchronize and maintain a namespace workspace')
|
||||
.option('--namespace <slug>', 'Namespace', { default: 'global' })
|
||||
.option('--dir <path>', 'Skill workspace directory')
|
||||
.option('--check', 'Show changes without downloading')
|
||||
.option('--prune', 'Remove managed local skills missing remotely')
|
||||
.option('--force', 'Overwrite local changes')
|
||||
.option('--all', 'Push every skill directory in the workspace')
|
||||
.option('--visibility <v>', 'Visibility (public|namespace-only|private)', { default: 'namespace-only' })
|
||||
.option('--dry-run', 'Validate without uploading')
|
||||
.option('--submit-review', 'Submit an uploaded version for review when required')
|
||||
.option('--registry <url>', 'Registry URL')
|
||||
.option('--token <token>', 'API token')
|
||||
.option('--json', 'Output JSON')
|
||||
.action((action: string, path: string | undefined, options: SyncPullOptions & SyncPushOptions) => {
|
||||
const command = action === 'pull'
|
||||
? () => syncPullCommand(options)
|
||||
: action === 'status'
|
||||
? () => syncStatusCommand(options as SyncCommonOptions)
|
||||
: action === 'diff'
|
||||
? () => syncDiffCommand(options as SyncCommonOptions)
|
||||
: action === 'push'
|
||||
? () => syncPushCommand(path, options)
|
||||
: () => Promise.reject(new CliError(
|
||||
`unknown sync action: ${action}`,
|
||||
EXIT.usage,
|
||||
{ next: 'use pull, status, diff, or push' }
|
||||
))
|
||||
return runCommand(command, Boolean(options.json))
|
||||
})
|
||||
|
||||
cli
|
||||
.command('list', 'List local installs')
|
||||
.option('--agent <profile>', 'Filter by agent (repeatable)')
|
||||
|
|
|
|||
|
|
@ -111,31 +111,21 @@ function findEndOfCentralDirectory(view: DataView): number {
|
|||
* Returns the archive as a Blob.
|
||||
* Pure JS implementation using fflate — no system commands needed.
|
||||
*/
|
||||
export interface CreateZipOptions {
|
||||
exclude?: (relativePath: string) => boolean
|
||||
}
|
||||
|
||||
export async function createZip(dirPath: string, options: CreateZipOptions = {}): Promise<Blob> {
|
||||
export async function createZip(dirPath: string): Promise<Blob> {
|
||||
const entries: Record<string, Uint8Array> = {}
|
||||
await collectFiles(dirPath, dirPath, entries, options)
|
||||
await collectFiles(dirPath, dirPath, entries)
|
||||
const zipped = zipSync(entries, { level: 6 })
|
||||
return new Blob([zipped.buffer as ArrayBuffer], { type: 'application/zip' })
|
||||
}
|
||||
|
||||
async function collectFiles(
|
||||
basePath: string,
|
||||
currentPath: string,
|
||||
entries: Record<string, Uint8Array>,
|
||||
options: CreateZipOptions
|
||||
): Promise<void> {
|
||||
async function collectFiles(basePath: string, currentPath: string, entries: Record<string, Uint8Array>): Promise<void> {
|
||||
const items = await readdir(currentPath, { withFileTypes: true })
|
||||
for (const item of items) {
|
||||
const fullPath = join(currentPath, item.name)
|
||||
const relPath = relative(basePath, fullPath).split('\\').join('/')
|
||||
if (options.exclude?.(relPath)) continue
|
||||
const relPath = relative(basePath, fullPath)
|
||||
if (item.isDirectory()) {
|
||||
entries[relPath + '/'] = new Uint8Array(0)
|
||||
await collectFiles(basePath, fullPath, entries, options)
|
||||
await collectFiles(basePath, fullPath, entries)
|
||||
} else if (item.isFile()) {
|
||||
entries[relPath] = new Uint8Array(await readFile(fullPath))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,9 +7,7 @@ import { EXIT } from '../shared/constants'
|
|||
import { extractZip } from '../platform/archive'
|
||||
import { readBoundedResponseBody } from '../platform/download'
|
||||
import { canonicalizeExistingPath, pathExists } from '../platform/paths'
|
||||
import { snapshotSkillDirectory } from './skill-fingerprint'
|
||||
import type { AgentCandidate } from '../agents/types'
|
||||
import type { ResolveResponse } from '../clients/skillhub-client'
|
||||
|
||||
export interface InstallOptions {
|
||||
registry: string
|
||||
|
|
@ -20,7 +18,6 @@ export interface InstallOptions {
|
|||
targets: AgentCandidate[]
|
||||
force: boolean
|
||||
home?: string | undefined
|
||||
resolved?: ResolveResponse | undefined
|
||||
}
|
||||
|
||||
async function preflightInstallTargets(
|
||||
|
|
@ -58,7 +55,7 @@ async function preflightInstallTargets(
|
|||
export async function installSkill(options: InstallOptions): Promise<{ installed: Array<{ agent: string; dir: string }> }> {
|
||||
const preparedTargets = await preflightInstallTargets(options.targets, options.slug, options.force)
|
||||
const client = new SkillHubClient(options.registry, options.token)
|
||||
const resolved = options.resolved ?? await client.resolve(options.namespace, options.slug, options.version)
|
||||
const resolved = await client.resolve(options.namespace, options.slug, options.version)
|
||||
const response = await client.download(options.namespace, options.slug, resolved.version)
|
||||
const buffer = await readBoundedResponseBody(response)
|
||||
|
||||
|
|
@ -74,7 +71,6 @@ export async function installSkill(options: InstallOptions): Promise<{ installed
|
|||
await extractZip(buffer, tempDir)
|
||||
|
||||
const installedAt = new Date().toISOString()
|
||||
const snapshot = await snapshotSkillDirectory(tempDir)
|
||||
const metaDir = join(tempDir, '.skillhub')
|
||||
await mkdir(metaDir, { recursive: true })
|
||||
await writeFile(join(metaDir, 'metadata.json'), JSON.stringify({
|
||||
|
|
@ -82,9 +78,6 @@ export async function installSkill(options: InstallOptions): Promise<{ installed
|
|||
namespace: options.namespace,
|
||||
slug: options.slug,
|
||||
version: resolved.version,
|
||||
fingerprint: resolved.fingerprint,
|
||||
files: snapshot.files,
|
||||
source: 'skillhub',
|
||||
agent: target.agent,
|
||||
installedAt
|
||||
}, null, 2))
|
||||
|
|
@ -96,30 +89,14 @@ export async function installSkill(options: InstallOptions): Promise<{ installed
|
|||
})
|
||||
}
|
||||
|
||||
const backupDir = `${skillDir}.skillhub-backup-${process.pid}-${Date.now()}`
|
||||
let backupCreated = false
|
||||
if (await pathExists(skillDir) && options.force) {
|
||||
await store.removeTargetsByInstallDir(skillDir)
|
||||
await rm(skillDir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
try {
|
||||
if (await pathExists(skillDir)) {
|
||||
await rename(skillDir, backupDir)
|
||||
backupCreated = true
|
||||
}
|
||||
await rename(tempDir, skillDir)
|
||||
movedIntoPlace = true
|
||||
|
||||
await store.replaceTargetAtInstallDir(options.registry, options.namespace, options.slug, resolved.version, {
|
||||
agent: target.agent,
|
||||
rootDir: target.rootDir,
|
||||
installDir: skillDir,
|
||||
installedAt
|
||||
}, resolved.fingerprint)
|
||||
|
||||
if (backupCreated) await rm(backupDir, { recursive: true, force: true }).catch(() => {})
|
||||
} catch (error) {
|
||||
if (movedIntoPlace) {
|
||||
await rm(skillDir, { recursive: true, force: true }).catch(() => {})
|
||||
movedIntoPlace = false
|
||||
}
|
||||
if (backupCreated) await rename(backupDir, skillDir).catch(() => {})
|
||||
if (!options.force && await pathExists(skillDir)) {
|
||||
throw new CliError(`skill already installed at ${skillDir}`, EXIT.filesystem, {
|
||||
path: skillDir,
|
||||
|
|
@ -128,6 +105,14 @@ export async function installSkill(options: InstallOptions): Promise<{ installed
|
|||
}
|
||||
throw error
|
||||
}
|
||||
movedIntoPlace = true
|
||||
|
||||
await store.upsertTarget(options.registry, options.namespace, options.slug, resolved.version, {
|
||||
agent: target.agent,
|
||||
rootDir: target.rootDir,
|
||||
installDir: skillDir,
|
||||
installedAt
|
||||
})
|
||||
} finally {
|
||||
if (!movedIntoPlace) {
|
||||
await rm(tempDir, { recursive: true, force: true }).catch(() => {})
|
||||
|
|
|
|||
|
|
@ -1,54 +0,0 @@
|
|||
import { createHash } from 'node:crypto'
|
||||
import { readdir, readFile } from 'node:fs/promises'
|
||||
import { join, relative } from 'node:path'
|
||||
|
||||
export interface SkillSnapshot {
|
||||
fingerprint: string
|
||||
files: Record<string, string>
|
||||
}
|
||||
|
||||
export async function snapshotSkillDirectory(skillDir: string): Promise<SkillSnapshot> {
|
||||
const paths = await listSkillFiles(skillDir)
|
||||
const files: Record<string, string> = {}
|
||||
const aggregate = createHash('sha256')
|
||||
|
||||
for (const path of paths) {
|
||||
const content = await readFile(join(skillDir, path))
|
||||
const fileHash = createHash('sha256').update(content).digest('hex')
|
||||
files[path] = fileHash
|
||||
aggregate.update(`${path}:${fileHash}\n`, 'utf8')
|
||||
}
|
||||
|
||||
return { fingerprint: `sha256:${aggregate.digest('hex')}`, files }
|
||||
}
|
||||
|
||||
export function diffSkillFiles(
|
||||
baseline: Record<string, string> | undefined,
|
||||
current: Record<string, string>
|
||||
): string[] {
|
||||
if (!baseline) return []
|
||||
const paths = new Set([...Object.keys(baseline), ...Object.keys(current)])
|
||||
return [...paths]
|
||||
.filter(path => baseline[path] !== current[path])
|
||||
.sort((left, right) => left.localeCompare(right))
|
||||
}
|
||||
|
||||
async function listSkillFiles(root: string): Promise<string[]> {
|
||||
const files: string[] = []
|
||||
|
||||
async function walk(current: string): Promise<void> {
|
||||
const entries = await readdir(current, { withFileTypes: true })
|
||||
for (const entry of entries) {
|
||||
if (entry.name === '.skillhub') continue
|
||||
const absolute = join(current, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
await walk(absolute)
|
||||
} else if (entry.isFile()) {
|
||||
files.push(relative(root, absolute).split('\\').join('/'))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await walk(root)
|
||||
return files.sort((left, right) => left.localeCompare(right))
|
||||
}
|
||||
|
|
@ -1,353 +0,0 @@
|
|||
import { readdir, readFile, rename, rm, stat } from 'node:fs/promises'
|
||||
import { basename, join } from 'node:path'
|
||||
import { SkillHubClient, type NamespaceSyncItem } from '../clients/skillhub-client'
|
||||
import { installSkill } from './install-service'
|
||||
import { diffSkillFiles, snapshotSkillDirectory } from './skill-fingerprint'
|
||||
import { InventoryStore } from '../stores/inventory-store'
|
||||
import { SyncWorkspaceStore, type NamespaceSyncState } from '../stores/sync-workspace-store'
|
||||
import { createZip, isZipFile } from '../platform/archive'
|
||||
import { pathExists } from '../platform/paths'
|
||||
|
||||
export type SyncStatus = 'up-to-date' | 'update-available' | 'local-changed' | 'orphaned' | 'not-installed'
|
||||
|
||||
export interface SkillSyncMetadata {
|
||||
registry: string
|
||||
namespace: string
|
||||
slug: string
|
||||
version: string
|
||||
fingerprint: string
|
||||
files?: Record<string, string>
|
||||
source?: string
|
||||
}
|
||||
|
||||
export interface SyncStatusEntry {
|
||||
namespace: string
|
||||
slug: string
|
||||
status: SyncStatus
|
||||
localVersion?: string
|
||||
remoteVersion?: string
|
||||
changedFiles: string[]
|
||||
reason?: string
|
||||
}
|
||||
|
||||
export interface PullResult {
|
||||
namespace: string
|
||||
rootDir: string
|
||||
entries: SyncStatusEntry[]
|
||||
actions: Array<{ slug: string; action: 'installed' | 'updated' | 'pruned' }>
|
||||
failures: Array<{ slug: string; message: string }>
|
||||
}
|
||||
|
||||
export interface PushResultItem {
|
||||
path: string
|
||||
slug?: string
|
||||
version?: string
|
||||
status?: string
|
||||
action: 'validated' | 'uploaded' | 'submitted-review' | 'failed'
|
||||
errors?: string[]
|
||||
warnings?: string[]
|
||||
}
|
||||
|
||||
export async function listAllNamespaceSkills(
|
||||
client: SkillHubClient,
|
||||
namespace: string
|
||||
): Promise<NamespaceSyncItem[]> {
|
||||
const items: NamespaceSyncItem[] = []
|
||||
let cursor: string | undefined
|
||||
do {
|
||||
const page = await client.listNamespaceSkills(namespace, cursor, 100)
|
||||
items.push(...page.items)
|
||||
cursor = page.nextCursor ?? undefined
|
||||
} while (cursor)
|
||||
return items
|
||||
}
|
||||
|
||||
export async function inspectNamespaceWorkspace(options: {
|
||||
client: SkillHubClient
|
||||
registry: string
|
||||
namespace: string
|
||||
rootDir: string
|
||||
remoteItems?: NamespaceSyncItem[]
|
||||
}): Promise<{ entries: SyncStatusEntry[]; remoteItems: NamespaceSyncItem[] }> {
|
||||
const remoteItems = options.remoteItems ?? await listAllNamespaceSkills(options.client, options.namespace)
|
||||
const managed = await scanManagedSkills(options.rootDir, options.registry, options.namespace)
|
||||
const entries: SyncStatusEntry[] = []
|
||||
const remoteSlugs = new Set(remoteItems.map(item => item.slug))
|
||||
|
||||
for (const remote of remoteItems) {
|
||||
const skillDir = join(options.rootDir, remote.slug)
|
||||
const metadata = managed.get(remote.slug)
|
||||
if (!(await pathExists(skillDir))) {
|
||||
entries.push(baseEntry(remote, 'not-installed'))
|
||||
continue
|
||||
}
|
||||
if (!metadata) {
|
||||
entries.push({ ...baseEntry(remote, 'local-changed'), reason: 'directory is not managed by SkillHub' })
|
||||
continue
|
||||
}
|
||||
|
||||
const snapshot = await snapshotSkillDirectory(skillDir)
|
||||
if (snapshot.fingerprint !== metadata.fingerprint) {
|
||||
entries.push({
|
||||
...baseEntry(remote, 'local-changed'),
|
||||
localVersion: metadata.version,
|
||||
changedFiles: diffSkillFiles(metadata.files, snapshot.files)
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (metadata.fingerprint !== remote.fingerprint) {
|
||||
entries.push({
|
||||
...baseEntry(remote, 'update-available'),
|
||||
localVersion: metadata.version
|
||||
})
|
||||
continue
|
||||
}
|
||||
entries.push({ ...baseEntry(remote, 'up-to-date'), localVersion: metadata.version })
|
||||
}
|
||||
|
||||
for (const [slug, metadata] of managed) {
|
||||
if (!remoteSlugs.has(slug)) {
|
||||
const snapshot = await snapshotSkillDirectory(join(options.rootDir, slug))
|
||||
const orphan: SyncStatusEntry = {
|
||||
namespace: options.namespace,
|
||||
slug,
|
||||
status: 'orphaned',
|
||||
localVersion: metadata.version,
|
||||
changedFiles: diffSkillFiles(metadata.files, snapshot.files)
|
||||
}
|
||||
if (snapshot.fingerprint !== metadata.fingerprint) orphan.reason = 'local changes detected'
|
||||
entries.push(orphan)
|
||||
}
|
||||
}
|
||||
|
||||
entries.sort((left, right) => left.slug.localeCompare(right.slug))
|
||||
return { entries, remoteItems }
|
||||
}
|
||||
|
||||
export async function pullNamespace(options: {
|
||||
client: SkillHubClient
|
||||
registry: string
|
||||
token: string
|
||||
namespace: string
|
||||
rootDir: string
|
||||
check: boolean
|
||||
prune: boolean
|
||||
force: boolean
|
||||
}): Promise<PullResult> {
|
||||
const inspected = await inspectNamespaceWorkspace(options)
|
||||
const result: PullResult = {
|
||||
namespace: options.namespace,
|
||||
rootDir: options.rootDir,
|
||||
entries: inspected.entries,
|
||||
actions: [],
|
||||
failures: []
|
||||
}
|
||||
if (options.check) return result
|
||||
|
||||
const remoteBySlug = new Map(inspected.remoteItems.map(item => [item.slug, item]))
|
||||
for (const entry of inspected.entries) {
|
||||
if (entry.status === 'up-to-date' || entry.status === 'orphaned') continue
|
||||
if (entry.status === 'local-changed' && !options.force) {
|
||||
result.failures.push({ slug: entry.slug, message: entry.reason ?? 'local changes detected; pass --force to overwrite' })
|
||||
continue
|
||||
}
|
||||
const remote = remoteBySlug.get(entry.slug)
|
||||
if (!remote) continue
|
||||
try {
|
||||
await installSkill({
|
||||
registry: options.registry,
|
||||
token: options.token,
|
||||
namespace: options.namespace,
|
||||
slug: remote.slug,
|
||||
version: remote.version,
|
||||
resolved: {
|
||||
namespace: remote.namespace,
|
||||
slug: remote.slug,
|
||||
version: remote.version,
|
||||
versionId: remote.versionId,
|
||||
fingerprint: remote.fingerprint,
|
||||
downloadUrl: remote.downloadUrl
|
||||
},
|
||||
targets: [{ agent: 'workspace', rootDir: options.rootDir, scope: 'project', source: 'explicit' }],
|
||||
force: entry.status !== 'not-installed' || options.force
|
||||
})
|
||||
result.actions.push({ slug: entry.slug, action: entry.status === 'not-installed' ? 'installed' : 'updated' })
|
||||
} catch (error) {
|
||||
result.failures.push({ slug: entry.slug, message: error instanceof Error ? error.message : 'install failed' })
|
||||
}
|
||||
}
|
||||
|
||||
if (options.prune) {
|
||||
for (const entry of inspected.entries.filter(item => item.status === 'orphaned')) {
|
||||
if (entry.reason && !options.force) {
|
||||
result.failures.push({ slug: entry.slug, message: 'orphan has local changes; pass --force to prune' })
|
||||
continue
|
||||
}
|
||||
const installDir = join(options.rootDir, entry.slug)
|
||||
const backupDir = `${installDir}.skillhub-prune-${process.pid}-${Date.now()}`
|
||||
try {
|
||||
await rename(installDir, backupDir)
|
||||
try {
|
||||
await new InventoryStore().removeTargetsByInstallDir(installDir)
|
||||
} catch (error) {
|
||||
await rename(backupDir, installDir).catch(() => {})
|
||||
throw error
|
||||
}
|
||||
await rm(backupDir, { recursive: true, force: true }).catch(() => {})
|
||||
result.actions.push({ slug: entry.slug, action: 'pruned' })
|
||||
} catch (error) {
|
||||
result.failures.push({
|
||||
slug: entry.slug,
|
||||
message: error instanceof Error ? error.message : 'prune failed'
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (result.failures.length === 0) {
|
||||
const state: NamespaceSyncState = {
|
||||
registry: options.registry,
|
||||
namespace: options.namespace,
|
||||
lastSyncAt: new Date().toISOString(),
|
||||
skills: Object.fromEntries(inspected.remoteItems.map(item => [item.slug, {
|
||||
version: item.version,
|
||||
fingerprint: item.fingerprint
|
||||
}]))
|
||||
}
|
||||
await new SyncWorkspaceStore(options.rootDir).write(state)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export async function pushSkills(options: {
|
||||
client: SkillHubClient
|
||||
namespace: string
|
||||
paths: string[]
|
||||
visibility: 'PUBLIC' | 'NAMESPACE_ONLY' | 'PRIVATE'
|
||||
dryRun: boolean
|
||||
submitReview: boolean
|
||||
}): Promise<PushResultItem[]> {
|
||||
const results: PushResultItem[] = []
|
||||
for (const path of options.paths) {
|
||||
try {
|
||||
const archive = await prepareArchive(path)
|
||||
const validation = await options.client.validatePublish(
|
||||
options.namespace, archive.blob, options.visibility, archive.fileName, true)
|
||||
if (!validation.valid) {
|
||||
const failed: PushResultItem = {
|
||||
path,
|
||||
action: 'failed',
|
||||
errors: validation.errors,
|
||||
warnings: validation.warnings
|
||||
}
|
||||
if (validation.resolvedSlug) failed.slug = validation.resolvedSlug
|
||||
if (validation.resolvedVersion) failed.version = validation.resolvedVersion
|
||||
results.push(failed)
|
||||
continue
|
||||
}
|
||||
if (options.dryRun) {
|
||||
const validated: PushResultItem = {
|
||||
path,
|
||||
action: 'validated',
|
||||
warnings: validation.warnings
|
||||
}
|
||||
if (validation.resolvedSlug) validated.slug = validation.resolvedSlug
|
||||
if (validation.resolvedVersion) validated.version = validation.resolvedVersion
|
||||
results.push(validated)
|
||||
continue
|
||||
}
|
||||
|
||||
const published = await options.client.publish(
|
||||
options.namespace, archive.blob, options.visibility, archive.fileName, true)
|
||||
let action: PushResultItem['action'] = 'uploaded'
|
||||
let status = published.status
|
||||
if (options.submitReview && published.status === 'PENDING_REVIEW') {
|
||||
action = 'submitted-review'
|
||||
} else if (options.submitReview && published.status === 'UPLOADED') {
|
||||
if (options.visibility === 'PRIVATE') {
|
||||
throw new Error('--submit-review requires public or namespace-only visibility')
|
||||
}
|
||||
const review = await options.client.submitReview(
|
||||
options.namespace,
|
||||
published.slug,
|
||||
published.version,
|
||||
options.visibility
|
||||
)
|
||||
action = 'submitted-review'
|
||||
status = review.status
|
||||
}
|
||||
results.push({ path, slug: published.slug, version: published.version, status, action })
|
||||
} catch (error) {
|
||||
results.push({ path, action: 'failed', errors: [error instanceof Error ? error.message : 'push failed'] })
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
export async function discoverSkillDirectories(rootDir: string): Promise<string[]> {
|
||||
if (!(await pathExists(rootDir))) return []
|
||||
const entries = await readdir(rootDir, { withFileTypes: true })
|
||||
const paths: string[] = []
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || entry.name === '.skillhub') continue
|
||||
const path = join(rootDir, entry.name)
|
||||
if (await pathExists(join(path, 'SKILL.md'))) paths.push(path)
|
||||
}
|
||||
return paths.sort((left, right) => left.localeCompare(right))
|
||||
}
|
||||
|
||||
async function prepareArchive(path: string): Promise<{ blob: Blob; fileName: string }> {
|
||||
const pathStat = await stat(path)
|
||||
if (pathStat.isDirectory()) {
|
||||
return {
|
||||
blob: await createZip(path, { exclude: relativePath => relativePath === '.skillhub' || relativePath.startsWith('.skillhub/') }),
|
||||
fileName: `${basename(path)}.zip`
|
||||
}
|
||||
}
|
||||
if (pathStat.isFile() && await isZipFile(path)) {
|
||||
return { blob: new Blob([await readFile(path)], { type: 'application/zip' }), fileName: basename(path) }
|
||||
}
|
||||
throw new Error(`path must be a skill directory or zip archive: ${path}`)
|
||||
}
|
||||
|
||||
async function scanManagedSkills(
|
||||
rootDir: string,
|
||||
registry: string,
|
||||
namespace: string
|
||||
): Promise<Map<string, SkillSyncMetadata>> {
|
||||
const managed = new Map<string, SkillSyncMetadata>()
|
||||
if (!(await pathExists(rootDir))) return managed
|
||||
const entries = await readdir(rootDir, { withFileTypes: true })
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || entry.name === '.skillhub') continue
|
||||
const metadataPath = join(rootDir, entry.name, '.skillhub', 'metadata.json')
|
||||
if (!(await pathExists(metadataPath))) continue
|
||||
try {
|
||||
const metadata = JSON.parse(await readFile(metadataPath, 'utf8')) as SkillSyncMetadata
|
||||
if (metadata.source === 'skillhub'
|
||||
&& normalizeRegistry(metadata.registry) === normalizeRegistry(registry)
|
||||
&& metadata.namespace === namespace
|
||||
&& metadata.slug === entry.name) {
|
||||
managed.set(entry.name, metadata)
|
||||
}
|
||||
} catch {
|
||||
// Corrupt metadata is treated as an unmanaged local directory.
|
||||
}
|
||||
}
|
||||
return managed
|
||||
}
|
||||
|
||||
function baseEntry(remote: NamespaceSyncItem, status: SyncStatus): SyncStatusEntry {
|
||||
return {
|
||||
namespace: remote.namespace,
|
||||
slug: remote.slug,
|
||||
status,
|
||||
remoteVersion: remote.version,
|
||||
changedFiles: []
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRegistry(registry: string): string {
|
||||
return registry.replace(/\/+$/, '')
|
||||
}
|
||||
|
|
@ -14,7 +14,6 @@ export interface InventoryItem {
|
|||
namespace: string
|
||||
slug: string
|
||||
version: string
|
||||
fingerprint?: string
|
||||
targets: InventoryTarget[]
|
||||
}
|
||||
|
||||
|
|
@ -118,19 +117,17 @@ export class InventoryStore {
|
|||
namespace: string,
|
||||
slug: string,
|
||||
version: string,
|
||||
target: InventoryTarget,
|
||||
fingerprint?: string
|
||||
target: InventoryTarget
|
||||
): Promise<void> {
|
||||
const inventory = await this.read()
|
||||
const existing = inventory.items.find(
|
||||
let item = inventory.items.find(
|
||||
i => i.registry === registry && i.namespace === namespace && i.slug === slug
|
||||
)
|
||||
const item: InventoryItem = existing ?? { registry, namespace, slug, version, targets: [] }
|
||||
if (!existing) {
|
||||
if (!item) {
|
||||
item = { registry, namespace, slug, version, targets: [] }
|
||||
inventory.items.push(item)
|
||||
}
|
||||
item.version = version
|
||||
if (fingerprint !== undefined) item.fingerprint = fingerprint
|
||||
const existingIdx = item.targets.findIndex(t => t.installDir === target.installDir)
|
||||
if (existingIdx >= 0) {
|
||||
item.targets[existingIdx] = target
|
||||
|
|
@ -168,30 +165,4 @@ export class InventoryStore {
|
|||
}
|
||||
return removed
|
||||
}
|
||||
|
||||
async replaceTargetAtInstallDir(
|
||||
registry: string,
|
||||
namespace: string,
|
||||
slug: string,
|
||||
version: string,
|
||||
target: InventoryTarget,
|
||||
fingerprint?: string
|
||||
): Promise<void> {
|
||||
const inventory = await this.read()
|
||||
for (const item of inventory.items) {
|
||||
item.targets = item.targets.filter(existing => existing.installDir !== target.installDir)
|
||||
}
|
||||
inventory.items = inventory.items.filter(item => item.targets.length > 0)
|
||||
|
||||
let item = inventory.items.find(candidate =>
|
||||
candidate.registry === registry && candidate.namespace === namespace && candidate.slug === slug)
|
||||
if (!item) {
|
||||
item = { registry, namespace, slug, version, targets: [] }
|
||||
inventory.items.push(item)
|
||||
}
|
||||
item.version = version
|
||||
if (fingerprint !== undefined) item.fingerprint = fingerprint
|
||||
item.targets.push(target)
|
||||
await this.writeAtomic(inventory)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,39 +0,0 @@
|
|||
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { pathExists } from '../platform/paths'
|
||||
|
||||
export interface NamespaceSyncStateSkill {
|
||||
version: string
|
||||
fingerprint: string
|
||||
}
|
||||
|
||||
export interface NamespaceSyncState {
|
||||
registry: string
|
||||
namespace: string
|
||||
lastSyncAt: string
|
||||
skills: Record<string, NamespaceSyncStateSkill>
|
||||
}
|
||||
|
||||
export class SyncWorkspaceStore {
|
||||
readonly path: string
|
||||
|
||||
constructor(rootDir: string) {
|
||||
this.path = join(rootDir, '.skillhub', 'namespace-sync.json')
|
||||
}
|
||||
|
||||
async read(): Promise<NamespaceSyncState | null> {
|
||||
if (!(await pathExists(this.path))) return null
|
||||
return JSON.parse(await readFile(this.path, 'utf8')) as NamespaceSyncState
|
||||
}
|
||||
|
||||
async write(state: NamespaceSyncState): Promise<void> {
|
||||
await mkdir(dirname(this.path), { recursive: true })
|
||||
const tempPath = `${this.path}.${process.pid}.${Date.now()}.tmp`
|
||||
try {
|
||||
await writeFile(tempPath, JSON.stringify(state, null, 2))
|
||||
await rename(tempPath, this.path)
|
||||
} finally {
|
||||
await rm(tempPath, { force: true }).catch(() => {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -102,14 +102,12 @@ export interface CapturedPublish {
|
|||
fileName: string
|
||||
/** Visibility string from the multipart form field. */
|
||||
visibility: string
|
||||
rejectExistingVersion: boolean
|
||||
}
|
||||
|
||||
export interface CapturedValidate {
|
||||
namespace: string
|
||||
fileName: string
|
||||
visibility: string
|
||||
rejectExistingVersion: boolean
|
||||
}
|
||||
|
||||
/** Last resolve GET: useful for verifying --version is forwarded as ?version=. */
|
||||
|
|
@ -127,13 +125,6 @@ export interface CapturedDelete {
|
|||
token: string | null
|
||||
}
|
||||
|
||||
export interface CapturedReview {
|
||||
namespace: string
|
||||
slug: string
|
||||
version: string
|
||||
targetVisibility: string
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Options
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -146,7 +137,6 @@ interface FakeRegistryOptions {
|
|||
skills?: FakeSkill[]
|
||||
/** Response to return for publish/validate (dry-run) requests. */
|
||||
dryRunResponse?: { valid: boolean; errors: string[]; warnings: string[]; resolvedSlug: string | null; resolvedVersion: string | null }
|
||||
publishStatus?: string
|
||||
/**
|
||||
* Per-endpoint failure injection. When set for an endpoint, that endpoint
|
||||
* ignores all other logic and returns the specified failure (or throws for
|
||||
|
|
@ -160,8 +150,6 @@ interface FakeRegistryOptions {
|
|||
deleteRemote?: FailureMode
|
||||
publish?: FailureMode
|
||||
validate?: FailureMode
|
||||
namespaceSync?: FailureMode
|
||||
submitReview?: FailureMode
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -202,8 +190,7 @@ export async function startFakeRegistry(options: FakeRegistryOptions = {}) {
|
|||
resolve: CapturedResolve | null
|
||||
delete: CapturedDelete | null
|
||||
validate: CapturedValidate | null
|
||||
review: CapturedReview | null
|
||||
} = { publish: null, resolve: null, delete: null, validate: null, review: null }
|
||||
} = { publish: null, resolve: null, delete: null, validate: null }
|
||||
|
||||
// If any endpoint is configured with 'network' failure mode, we need a real
|
||||
// TCP-level failure. Start a connection-dropping server and return its URL
|
||||
|
|
@ -276,31 +263,6 @@ export async function startFakeRegistry(options: FakeRegistryOptions = {}) {
|
|||
})
|
||||
}
|
||||
|
||||
const namespaceSyncMatch = path.match(/^\/api\/cli\/v1\/namespaces\/([^/]+)\/skills$/)
|
||||
if (namespaceSyncMatch && req.method === 'GET') {
|
||||
if (options.failures?.namespaceSync) return failureResponse(options.failures.namespaceSync)
|
||||
const authErr = checkAuth(req)
|
||||
if (authErr) return authErr
|
||||
const namespace = namespaceSyncMatch[1]!
|
||||
const skills = (options.skills ?? []).filter(skill => skill.namespace === namespace)
|
||||
return Response.json({
|
||||
code: 0,
|
||||
data: {
|
||||
items: skills.map((skill, index) => ({
|
||||
namespace,
|
||||
slug: skill.slug,
|
||||
version: skill.version ?? '1.0.0',
|
||||
versionId: skill.versionId ?? index + 1,
|
||||
fingerprint: skill.fingerprint ?? 'deadbeef',
|
||||
updatedAt: '2026-08-18T00:00:00Z',
|
||||
visibility: 'NAMESPACE_ONLY',
|
||||
downloadUrl: buildDownloadUrl(baseUrl, namespace, skill.slug, skill.version ?? '1.0.0')
|
||||
})),
|
||||
nextCursor: null
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ //
|
||||
// Route: /api/cli/v1/skills/:namespace/:slug/...
|
||||
// ------------------------------------------------------------------ //
|
||||
|
|
@ -415,12 +377,7 @@ export async function startFakeRegistry(options: FakeRegistryOptions = {}) {
|
|||
if (fileField instanceof File) {
|
||||
fileName = fileField.name || fileName
|
||||
}
|
||||
state.validate = {
|
||||
namespace,
|
||||
fileName,
|
||||
visibility,
|
||||
rejectExistingVersion: form.get('rejectExistingVersion') === 'true'
|
||||
}
|
||||
state.validate = { namespace, fileName, visibility }
|
||||
|
||||
const dryRunData = options.dryRunResponse ?? {
|
||||
valid: true,
|
||||
|
|
@ -453,12 +410,7 @@ export async function startFakeRegistry(options: FakeRegistryOptions = {}) {
|
|||
}
|
||||
|
||||
// Record for test assertions.
|
||||
state.publish = {
|
||||
namespace,
|
||||
fileName,
|
||||
visibility,
|
||||
rejectExistingVersion: form.get('rejectExistingVersion') === 'true'
|
||||
}
|
||||
state.publish = { namespace, fileName, visibility }
|
||||
|
||||
return Response.json({
|
||||
code: 0,
|
||||
|
|
@ -466,30 +418,12 @@ export async function startFakeRegistry(options: FakeRegistryOptions = {}) {
|
|||
namespace,
|
||||
slug: fileName.replace(/\.zip$/, ''),
|
||||
version: '1.0.0',
|
||||
visibility,
|
||||
status: options.publishStatus ?? 'PENDING_REVIEW'
|
||||
visibility
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const submitReviewMatch = path.match(/^\/api\/v1\/skills\/([^/]+)\/([^/]+)\/submit-review$/)
|
||||
if (submitReviewMatch && req.method === 'POST') {
|
||||
if (options.failures?.submitReview) return failureResponse(options.failures.submitReview)
|
||||
const authErr = checkAuth(req)
|
||||
if (authErr) return authErr
|
||||
return req.json().then(body => {
|
||||
const request = body as { version: string; targetVisibility: string }
|
||||
const namespace = submitReviewMatch[1]!
|
||||
const slug = submitReviewMatch[2]!
|
||||
state.review = { namespace, slug, version: request.version, targetVisibility: request.targetVisibility }
|
||||
return Response.json({
|
||||
code: 0,
|
||||
data: { skillId: 1, versionId: 1, action: 'SUBMIT_REVIEW', status: 'PENDING_REVIEW' }
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ //
|
||||
// Fallthrough
|
||||
// ------------------------------------------------------------------ //
|
||||
|
|
|
|||
|
|
@ -1,187 +0,0 @@
|
|||
import { createHash } from 'node:crypto'
|
||||
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { strToU8, zipSync } from 'fflate'
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { startFakeRegistry, type FakeSkill } from '../helpers/fake-registry'
|
||||
import { runCli } from '../helpers/run-cli'
|
||||
import { createTempHome } from '../helpers/temp-env'
|
||||
|
||||
function makeSkill(body: string): { zipBytes: Uint8Array; fingerprint: string } {
|
||||
const content = strToU8(body)
|
||||
const fileHash = createHash('sha256').update(content).digest('hex')
|
||||
const fingerprint = `sha256:${createHash('sha256').update(`SKILL.md:${fileHash}\n`).digest('hex')}`
|
||||
return { zipBytes: zipSync({ 'SKILL.md': content }), fingerprint }
|
||||
}
|
||||
|
||||
describe('sync command', () => {
|
||||
test('pull installs a namespace incrementally and writes workspace metadata', async () => {
|
||||
const env = await createTempHome()
|
||||
const skillsDir = join(env.cwd, 'team-skills')
|
||||
const first = makeSkill('---\nname: first\ndescription: First\nversion: 1.0.0\n---\n')
|
||||
const second = makeSkill('---\nname: second\ndescription: Second\nversion: 1.0.0\n---\n')
|
||||
const registry = await startFakeRegistry({
|
||||
token: 'token',
|
||||
skills: [
|
||||
{ namespace: 'team-a', slug: 'first', ...first },
|
||||
{ namespace: 'team-a', slug: 'second', ...second }
|
||||
]
|
||||
})
|
||||
|
||||
try {
|
||||
const pulled = await runCli([
|
||||
'sync', 'pull', '--namespace', 'team-a', '--dir', skillsDir,
|
||||
'--registry', registry.url, '--token', 'token', '--json'
|
||||
], { HOME: env.home }, { cwd: env.cwd })
|
||||
|
||||
expect(pulled.exitCode).toBe(0)
|
||||
expect(JSON.parse(pulled.stdout).actions).toHaveLength(2)
|
||||
const metadata = JSON.parse(await readFile(join(skillsDir, 'first', '.skillhub', 'metadata.json'), 'utf8'))
|
||||
expect(metadata).toMatchObject({
|
||||
source: 'skillhub', namespace: 'team-a', slug: 'first', fingerprint: first.fingerprint
|
||||
})
|
||||
expect(await readFile(join(skillsDir, '.skillhub', 'namespace-sync.json'), 'utf8')).toContain('team-a')
|
||||
|
||||
const secondPull = await runCli([
|
||||
'sync', 'pull', '--namespace', 'team-a', '--dir', skillsDir,
|
||||
'--registry', registry.url, '--token', 'token', '--json'
|
||||
], { HOME: env.home }, { cwd: env.cwd })
|
||||
expect(secondPull.exitCode).toBe(0)
|
||||
expect(JSON.parse(secondPull.stdout).actions).toHaveLength(0)
|
||||
expect(JSON.parse(secondPull.stdout).entries.every((item: { status: string }) => item.status === 'up-to-date')).toBe(true)
|
||||
} finally {
|
||||
registry.stop()
|
||||
}
|
||||
})
|
||||
|
||||
test('status detects local changes and pull does not overwrite without force', async () => {
|
||||
const env = await createTempHome()
|
||||
const skillsDir = join(env.cwd, 'team-skills')
|
||||
const fixture = makeSkill('---\nname: demo\ndescription: Demo\nversion: 1.0.0\n---\n')
|
||||
const registry = await startFakeRegistry({
|
||||
token: 'token',
|
||||
skills: [{ namespace: 'team-a', slug: 'demo', ...fixture }]
|
||||
})
|
||||
|
||||
try {
|
||||
await runCli([
|
||||
'sync', 'pull', '--namespace', 'team-a', '--dir', skillsDir,
|
||||
'--registry', registry.url, '--token', 'token'
|
||||
], { HOME: env.home }, { cwd: env.cwd })
|
||||
await writeFile(join(skillsDir, 'demo', 'SKILL.md'), '# local change\n')
|
||||
|
||||
const status = await runCli([
|
||||
'sync', 'status', '--namespace', 'team-a', '--dir', skillsDir,
|
||||
'--registry', registry.url, '--token', 'token', '--json'
|
||||
], { HOME: env.home }, { cwd: env.cwd })
|
||||
expect(JSON.parse(status.stdout).items[0].status).toBe('local-changed')
|
||||
|
||||
const pull = await runCli([
|
||||
'sync', 'pull', '--namespace', 'team-a', '--dir', skillsDir,
|
||||
'--registry', registry.url, '--token', 'token', '--json'
|
||||
], { HOME: env.home }, { cwd: env.cwd })
|
||||
expect(pull.exitCode).toBe(1)
|
||||
expect(await readFile(join(skillsDir, 'demo', 'SKILL.md'), 'utf8')).toBe('# local change\n')
|
||||
} finally {
|
||||
registry.stop()
|
||||
}
|
||||
})
|
||||
|
||||
test('prune removes only unchanged managed orphan skills', async () => {
|
||||
const env = await createTempHome()
|
||||
const skillsDir = join(env.cwd, 'team-skills')
|
||||
const fixture = makeSkill('---\nname: demo\ndescription: Demo\nversion: 1.0.0\n---\n')
|
||||
const skills: FakeSkill[] = [{ namespace: 'team-a', slug: 'demo', ...fixture }]
|
||||
const registry = await startFakeRegistry({ token: 'token', skills })
|
||||
|
||||
try {
|
||||
await runCli([
|
||||
'sync', 'pull', '--namespace', 'team-a', '--dir', skillsDir,
|
||||
'--registry', registry.url, '--token', 'token'
|
||||
], { HOME: env.home }, { cwd: env.cwd })
|
||||
skills.splice(0, skills.length)
|
||||
|
||||
const pruned = await runCli([
|
||||
'sync', 'pull', '--namespace', 'team-a', '--dir', skillsDir, '--prune',
|
||||
'--registry', registry.url, '--token', 'token', '--json'
|
||||
], { HOME: env.home }, { cwd: env.cwd })
|
||||
expect(pruned.exitCode).toBe(0)
|
||||
expect(JSON.parse(pruned.stdout).actions).toContainEqual({ slug: 'demo', action: 'pruned' })
|
||||
expect(await Bun.file(join(skillsDir, 'demo')).exists()).toBe(false)
|
||||
} finally {
|
||||
registry.stop()
|
||||
}
|
||||
})
|
||||
|
||||
test('push all validates packages and submits an uploaded version for review', async () => {
|
||||
const env = await createTempHome()
|
||||
const skillsDir = join(env.cwd, 'team-skills')
|
||||
const skillDir = join(skillsDir, 'demo')
|
||||
await mkdir(join(skillDir, '.skillhub'), { recursive: true })
|
||||
await writeFile(join(skillDir, 'SKILL.md'), '---\nname: demo\ndescription: Demo\nversion: 1.0.0\n---\n')
|
||||
await writeFile(join(skillDir, '.skillhub', 'metadata.json'), '{"must":"not be uploaded"}')
|
||||
const registry = await startFakeRegistry({ token: 'token', publishStatus: 'UPLOADED' })
|
||||
|
||||
try {
|
||||
const pushed = await runCli([
|
||||
'sync', 'push', '--all', '--namespace', 'team-a', '--dir', skillsDir,
|
||||
'--submit-review', '--registry', registry.url, '--token', 'token', '--json'
|
||||
], { HOME: env.home }, { cwd: env.cwd })
|
||||
|
||||
expect(pushed.exitCode).toBe(0)
|
||||
expect(JSON.parse(pushed.stdout).items[0].action).toBe('submitted-review')
|
||||
expect(registry.received.publish?.visibility).toBe('NAMESPACE_ONLY')
|
||||
expect(registry.received.publish?.rejectExistingVersion).toBe(true)
|
||||
expect(registry.received.review).toMatchObject({
|
||||
namespace: 'team-a', slug: 'demo', version: '1.0.0', targetVisibility: 'NAMESPACE_ONLY'
|
||||
})
|
||||
} finally {
|
||||
registry.stop()
|
||||
await rm(skillsDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('push dry-run uses strict validation without uploading', async () => {
|
||||
const env = await createTempHome()
|
||||
const skillDir = join(env.cwd, 'demo')
|
||||
await mkdir(skillDir, { recursive: true })
|
||||
await writeFile(join(skillDir, 'SKILL.md'), '---\nname: demo\ndescription: Demo\nversion: 1.0.0\n---\n')
|
||||
const registry = await startFakeRegistry({ token: 'token' })
|
||||
|
||||
try {
|
||||
const result = await runCli([
|
||||
'sync', 'push', skillDir, '--namespace', 'team-a', '--dry-run',
|
||||
'--registry', registry.url, '--token', 'token', '--json'
|
||||
], { HOME: env.home }, { cwd: env.cwd })
|
||||
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(JSON.parse(result.stdout).items[0].action).toBe('validated')
|
||||
expect(registry.received.validate?.rejectExistingVersion).toBe(true)
|
||||
expect(registry.received.publish).toBeNull()
|
||||
} finally {
|
||||
registry.stop()
|
||||
}
|
||||
})
|
||||
|
||||
test('pull refuses to replace an unmanaged conflicting directory', async () => {
|
||||
const env = await createTempHome()
|
||||
const skillsDir = join(env.cwd, 'team-skills')
|
||||
await mkdir(join(skillsDir, 'demo'), { recursive: true })
|
||||
await writeFile(join(skillsDir, 'demo', 'local.txt'), 'keep')
|
||||
const fixture = makeSkill('---\nname: demo\ndescription: Demo\nversion: 1.0.0\n---\n')
|
||||
const registry = await startFakeRegistry({
|
||||
token: 'token', skills: [{ namespace: 'team-a', slug: 'demo', ...fixture }]
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await runCli([
|
||||
'sync', 'pull', '--namespace', 'team-a', '--dir', skillsDir,
|
||||
'--registry', registry.url, '--token', 'token', '--json'
|
||||
], { HOME: env.home }, { cwd: env.cwd })
|
||||
expect(result.exitCode).toBe(1)
|
||||
expect(await readFile(join(skillsDir, 'demo', 'local.txt'), 'utf8')).toBe('keep')
|
||||
} finally {
|
||||
registry.stop()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -15,8 +15,7 @@ describe('SkillHubClient', () => {
|
|||
namespace: 'team',
|
||||
slug: 'custom-skill',
|
||||
version: '1.0.0',
|
||||
visibility: 'PRIVATE',
|
||||
status: 'UPLOADED'
|
||||
visibility: 'PRIVATE'
|
||||
}
|
||||
})
|
||||
}) as unknown as typeof fetch
|
||||
|
|
@ -189,34 +188,6 @@ describe('SkillHubClient', () => {
|
|||
expect(capturedUrl).toContain('?version=2.0.0')
|
||||
})
|
||||
|
||||
test('listNamespaceSkills forwards cursor and limit', async () => {
|
||||
let capturedUrl = ''
|
||||
const fetchImpl = (async (input: URL | RequestInfo) => {
|
||||
capturedUrl = String(input)
|
||||
return Response.json({ data: { items: [], nextCursor: null } })
|
||||
}) as unknown as typeof fetch
|
||||
const client = new SkillHubClient('http://registry.test', 'token', fetchImpl)
|
||||
|
||||
await client.listNamespaceSkills('team a', 'cursor-value', 50)
|
||||
|
||||
expect(capturedUrl).toContain('/api/cli/v1/namespaces/team%20a/skills')
|
||||
expect(capturedUrl).toContain('cursor=cursor-value')
|
||||
expect(capturedUrl).toContain('limit=50')
|
||||
})
|
||||
|
||||
test('submitReview posts the lifecycle request with bearer auth', async () => {
|
||||
const fetchImpl = (async (input: URL | RequestInfo, init?: RequestInit) => {
|
||||
expect(String(input)).toContain('/api/v1/skills/team-a/demo/submit-review')
|
||||
expect(init?.headers).toMatchObject({ Authorization: 'Bearer token', 'Content-Type': 'application/json' })
|
||||
expect(JSON.parse(String(init?.body))).toEqual({ version: '1.0.0', targetVisibility: 'NAMESPACE_ONLY' })
|
||||
return Response.json({ data: { skillId: 1, versionId: 2, action: 'SUBMIT_REVIEW', status: 'PENDING_REVIEW' } })
|
||||
}) as unknown as typeof fetch
|
||||
const client = new SkillHubClient('http://registry.test', 'token', fetchImpl)
|
||||
|
||||
await expect(client.submitReview('team-a', 'demo', '1.0.0', 'NAMESPACE_ONLY'))
|
||||
.resolves.toMatchObject({ status: 'PENDING_REVIEW' })
|
||||
})
|
||||
|
||||
// --- handleJsonResponse() non-2xx classification ---
|
||||
|
||||
test('whoami() preserves public fields and ignores unknown fields on a structured 401', async () => {
|
||||
|
|
|
|||
|
|
@ -228,27 +228,6 @@ describe('installSkill', () => {
|
|||
expect(inventory.items[0].targets[0].installDir).toBe(skillDir)
|
||||
})
|
||||
|
||||
test('force restores the old installation when inventory persistence fails', async () => {
|
||||
globalThis.fetch = installFetch({ 'SKILL.md': '# New' })
|
||||
const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-install-root-'))
|
||||
const skillDir = join(rootDir, 'demo')
|
||||
await mkdir(skillDir, { recursive: true })
|
||||
await writeFile(join(skillDir, 'SKILL.md'), '# Old')
|
||||
const invalidHome = join(rootDir, 'home-is-a-file')
|
||||
await writeFile(invalidHome, 'not a directory')
|
||||
|
||||
await expect(installSkill({
|
||||
registry: 'http://registry.test',
|
||||
namespace: 'global',
|
||||
slug: 'demo',
|
||||
targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }],
|
||||
force: true,
|
||||
home: invalidHome
|
||||
})).rejects.toThrow()
|
||||
|
||||
expect(await readFile(join(skillDir, 'SKILL.md'), 'utf-8')).toBe('# Old')
|
||||
})
|
||||
|
||||
test('rejects downloads whose content-length exceeds the package limit', async () => {
|
||||
globalThis.fetch = installFetchWithDownloadResponse(new Response(new Uint8Array(0), {
|
||||
status: 200,
|
||||
|
|
|
|||
|
|
@ -1,23 +0,0 @@
|
|||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { createTempHome } from '../../helpers/temp-env'
|
||||
import { diffSkillFiles, snapshotSkillDirectory } from '../../../src/services/skill-fingerprint'
|
||||
|
||||
describe('skill fingerprint', () => {
|
||||
test('ignores SkillHub metadata and reports changed files', async () => {
|
||||
const env = await createTempHome()
|
||||
const skillDir = join(env.cwd, 'demo')
|
||||
await mkdir(join(skillDir, '.skillhub'), { recursive: true })
|
||||
await writeFile(join(skillDir, 'SKILL.md'), '# one\n')
|
||||
await writeFile(join(skillDir, '.skillhub', 'metadata.json'), '{"ignored":true}')
|
||||
|
||||
const baseline = await snapshotSkillDirectory(skillDir)
|
||||
await writeFile(join(skillDir, '.skillhub', 'metadata.json'), '{"ignored":false}')
|
||||
expect((await snapshotSkillDirectory(skillDir)).fingerprint).toBe(baseline.fingerprint)
|
||||
|
||||
await writeFile(join(skillDir, 'SKILL.md'), '# two\n')
|
||||
const current = await snapshotSkillDirectory(skillDir)
|
||||
expect(diffSkillFiles(baseline.files, current.files)).toEqual(['SKILL.md'])
|
||||
})
|
||||
})
|
||||
|
|
@ -71,7 +71,6 @@ services:
|
|||
SKILLHUB_API_UPSTREAM: http://server:8080
|
||||
SKILLHUB_WEB_API_BASE_URL: ""
|
||||
SKILLHUB_PUBLIC_BASE_URL: ""
|
||||
SKILLHUB_TRUST_FORWARDED_PROTO: "false"
|
||||
depends_on:
|
||||
server:
|
||||
condition: service_healthy
|
||||
|
|
|
|||
|
|
@ -136,8 +136,7 @@ skillhub/
|
|||
|
||||
- 开发路径:`make dev-all`。前后端在宿主机运行,`docker-compose.yml` 只负责 PostgreSQL、Redis、MinIO。
|
||||
- 交付路径:GitHub Actions 构建并发布 `server` / `web` 镜像;用户通过 `compose.release.yml` 在本地一键拉起前后端容器和基础服务。
|
||||
- 发布镜像为多架构 manifest:`server` / `web` 覆盖 `linux/amd64`、`linux/arm64` 与
|
||||
`linux/riscv64`;`scanner` 暂保持 `linux/amd64` 与 `linux/arm64`。
|
||||
- 发布镜像为多架构 manifest,至少覆盖 `linux/amd64` 与 `linux/arm64`。
|
||||
|
||||
单机运行时统一入口:
|
||||
- `http://localhost/` → Web 容器(Nginx)
|
||||
|
|
@ -170,8 +169,7 @@ skillhub/
|
|||
- 数据库迁移:Flyway
|
||||
- 认证:Spring Security OAuth2 Client(一期 GitHub)
|
||||
- 镜像发布:GitHub Actions 推送至 GHCR,默认维护 `edge` 与语义化版本标签
|
||||
- 运行时兼容:`server` / `web` 发布镜像默认输出 `linux/amd64` + `linux/arm64` +
|
||||
`linux/riscv64` 多架构 manifest,`scanner` 暂保持 `linux/amd64` + `linux/arm64`
|
||||
- 运行时兼容:发布镜像默认输出 `linux/amd64` + `linux/arm64` 多架构 manifest
|
||||
|
||||
## 11. Repository / Query Boundary 约定
|
||||
|
||||
|
|
|
|||
|
|
@ -245,7 +245,6 @@
|
|||
| avatar_url | varchar(512) | |
|
||||
| status | enum | `ACTIVE` / `PENDING` / `DISABLED` / `MERGED` |
|
||||
| merged_to_user_id | varchar(128) | 合并目标用户 ID,仅 MERGED 状态有值 |
|
||||
| system_account | boolean | 系统服务账号,禁止交互式 Web/OAuth 登录 |
|
||||
| created_at | datetime | |
|
||||
| updated_at | datetime | |
|
||||
|
||||
|
|
@ -253,10 +252,8 @@
|
|||
- `ACTIVE`:正常使用
|
||||
- `PENDING`:等待管理员审批(AccessPolicy 返回 PENDING_APPROVAL 时创建)
|
||||
- `DISABLED`:管理员封禁,登录后拒绝所有操作,返回 403
|
||||
- `MERGED`:已合并到其他账号,保留记录不物理删除;登录直接拒绝,不向调用方泄露合并目标
|
||||
- `MERGED`:已合并到其他账号,保留记录不物理删除,登录时自动跳转到合并目标账号
|
||||
- 授权层在每次请求时检查用户状态,非 `ACTIVE` 用户拒绝所有写操作
|
||||
- system account 可按独立 Token Policy 使用非交互凭证,但不能通过本地密码或外部 OAuth
|
||||
建立普通用户 Session
|
||||
|
||||
### identity_binding
|
||||
|
||||
|
|
|
|||
|
|
@ -94,8 +94,7 @@ astron:
|
|||
- `DENY`:抛出 `OAuth2AccessDeniedException`,由 `failureHandler` 重定向到 `/access-denied` 页面。不创建用户,不建立 Session。
|
||||
- `PENDING_APPROVAL`:创建 `user_account`(status=`PENDING`),但不建立业务 Session。抛出 `AccountPendingException`,由 `failureHandler` 重定向到 `/pending-approval` 页面(纯静态提示页,无需登录态)。管理员在后台审批后状态变为 `ACTIVE`,用户下次 OAuth 登录才会正常建立 Session。
|
||||
|
||||
安全边界:PENDING / DISABLED / MERGED 用户和 system account 绝不会通过交互式登录获得
|
||||
业务 Session。外部身份命中这些账号时,在更新用户资料或加载角色前直接拒绝。
|
||||
安全边界:PENDING / DISABLED 用户绝不会拥有有效的业务 Session,从根源上杜绝"待审批账号已认证"的风险。
|
||||
|
||||
### 2.3 扩展性
|
||||
|
||||
|
|
@ -362,8 +361,7 @@ public class OAuthClaimsExtractor {
|
|||
合并操作规则:
|
||||
- 合并操作写入审计日志
|
||||
- 合并后原 user_account 标记为 `MERGED`,保留记录不物理删除
|
||||
- 不提供按 email 自动合并;即使 Provider 声明 email 已验证,也不能替代对两个账号控制权
|
||||
的分别证明。未来绑定/合并必须使用显式、可审计的重新认证流程。
|
||||
- 预留扩展位:未来可配置 `astron.identity.auto-merge-on-verified-email=true` 开启基于已验证邮箱的自动合并
|
||||
|
||||
## 5. CLI 认证(OAuth Device Flow + 平台凭证)
|
||||
|
||||
|
|
|
|||
|
|
@ -52,57 +52,9 @@ description: When to use
|
|||
x-astron-category: code-review
|
||||
x-astron-runtime: claude-code # 预留
|
||||
x-astron-min-version: "1.0" # 预留
|
||||
x-astron-compliance: # 可选,平台私有合规元数据
|
||||
- standard: mitre-attack
|
||||
version: "v19.1"
|
||||
controlId: T1059
|
||||
title: Command and Scripting Interpreter
|
||||
evidence:
|
||||
- type: packaged-file
|
||||
path: references/standards.md
|
||||
---
|
||||
```
|
||||
|
||||
> 合规元数据先按 SkillHub/Astron 私有扩展实现,字段名采用 `x-astron-compliance`。
|
||||
> 当前支持发布校验、版本级 `complianceSnapshot` 固化、详情展示、审核 diff 和轻量搜索投影。
|
||||
> 这些信息表示“技能作者声明的合规映射”,SkillHub 校验证据引用的格式和可追溯性,
|
||||
> 但不等同于第三方认证或平台背书。设计边界、分阶段实现和 Runtime 职责划分见
|
||||
> [24-compliance-metadata-design.md](24-compliance-metadata-design.md)。
|
||||
|
||||
`x-astron-compliance` 的稳定字段如下:
|
||||
|
||||
| 字段 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `standard` | 是 | 合规标准、框架或知识库标识,例如 `mitre-attack`、`nist-csf`、`soc2` |
|
||||
| `version` | 是 | 标准版本或适用版本,例如 `v19.1`、`2.0` |
|
||||
| `controlId` | 是 | 控制项、技术编号或条款 ID,例如 `T1059`、`PR.AA-01` |
|
||||
| `title` | 否 | 人类可读的控制项名称 |
|
||||
| `evidence` | 否 | 证据列表,指向包内文件或外部 URL |
|
||||
|
||||
`evidence` 支持两类:
|
||||
|
||||
| `type` | 字段 | 说明 |
|
||||
|--------|------|------|
|
||||
| `packaged-file` | `path` | 指向技能包内的证据文件。路径必须在包内,不能路径逃逸。 |
|
||||
| `external-url` | `url` | 指向外部证据材料。URL 必须使用允许的安全 scheme。 |
|
||||
|
||||
发布校验规则:
|
||||
|
||||
- 没有 `x-astron-compliance` 的旧技能继续正常发布。
|
||||
- `x-astron-compliance` 存在时必须是数组。
|
||||
- `standard`、`version`、`controlId` 必填。
|
||||
- 同一技能版本内不允许重复 `standard + version + controlId`。
|
||||
- `packaged-file.path` 必须存在于上传包内,且不能使用 `../` 等方式逃逸包目录。
|
||||
- 合法合规声明会被规范化为版本级 `complianceSnapshot`,并生成稳定 `digest`。
|
||||
|
||||
Runtime 集成边界:
|
||||
|
||||
- SkillHub 是技能元数据和版本级 `complianceSnapshot` 的权威源。
|
||||
- Agent Runtime 是执行 trace 的权威源。
|
||||
- Runtime 如需在执行链路中记录合规上下文,应引用 SkillHub 返回的不可变版本 `id`
|
||||
和 `complianceSnapshot.digest`,而不是复制或改写 SkillHub 的声明内容。
|
||||
- SkillHub 当前不记录 Agent 执行输入输出、Runtime trace 或实际调用结果。
|
||||
|
||||
## 8.3 技能包目录结构
|
||||
|
||||
```
|
||||
|
|
|
|||
|
|
@ -10,8 +10,7 @@
|
|||
- 单机交付环境:`docker compose --env-file .env.release -f compose.release.yml up -d`
|
||||
- 前端和后端都运行在容器内
|
||||
- 使用 GitHub Actions 发布到 GHCR 的镜像
|
||||
- 默认发布多架构镜像:`server` / `web` 覆盖 `linux/amd64`、`linux/arm64` 与
|
||||
`linux/riscv64`,`scanner` 暂保持 `linux/amd64` 与 `linux/arm64`
|
||||
- 默认发布 `linux/amd64` 与 `linux/arm64` 多架构镜像
|
||||
- PostgreSQL、Redis 与应用容器一起通过 Compose 启动
|
||||
|
||||
不再维护本地构建整套 demo 容器的中间模式,也不再保留 `docker-compose.prod.yml`。
|
||||
|
|
@ -206,41 +205,10 @@ Sentinel 配置优先于 Cluster 和单机 `host`/`port`。在 Kubernetes 等 Se
|
|||
- `ghcr.io/iflytek/skillhub-server`
|
||||
- `ghcr.io/iflytek/skillhub-web`
|
||||
5. 写入 `edge` / `vX.Y.Z` / `latest` / `sha-*` 标签
|
||||
6. 同时发布多架构 manifest:`server` / `web` 覆盖 `linux/amd64`、`linux/arm64` 与
|
||||
`linux/riscv64`,`scanner` 暂保持 `linux/amd64` 与 `linux/arm64`
|
||||
6. 同时发布 `linux/amd64` 与 `linux/arm64` manifest,避免 Apple Silicon / ARM 主机依赖模拟层
|
||||
|
||||
## 7 配置管理
|
||||
|
||||
### 7.1 请求限流配置
|
||||
|
||||
限流默认开启。未配置分类覆盖时,各接口使用代码中 `@RateLimit` 声明的默认值,现有部署无需调整。
|
||||
|
||||
可通过环境变量关闭全部限流,或按分类覆盖额度和时间窗口:
|
||||
|
||||
```bash
|
||||
SKILLHUB_RATELIMIT_ENABLED=false
|
||||
SKILLHUB_RATELIMIT_CATEGORIES_SEARCH_ANONYMOUS=100
|
||||
SKILLHUB_RATELIMIT_CATEGORIES_SEARCH_AUTHENTICATED=300
|
||||
SKILLHUB_RATELIMIT_CATEGORIES_SEARCH_WINDOW_SECONDS=60
|
||||
```
|
||||
|
||||
支持的配置字段为 `authenticated`、`anonymous` 和 `window-seconds`。分类名称来自接口的
|
||||
`@RateLimit(category = "...")`,例如 `search`、`download`、`publish` 和 `resolve`。只设置其中一个字段时,
|
||||
其他字段仍回退到接口默认值。
|
||||
|
||||
Docker Compose 用户需要显式传入变量,宿主机环境变量不会自动注入容器:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
server:
|
||||
environment:
|
||||
SKILLHUB_RATELIMIT_CATEGORIES_SEARCH_ANONYMOUS: "100"
|
||||
SKILLHUB_RATELIMIT_CATEGORIES_SEARCH_AUTHENTICATED: "300"
|
||||
SKILLHUB_RATELIMIT_CATEGORIES_SEARCH_WINDOW_SECONDS: "60"
|
||||
```
|
||||
|
||||
修改后重启 server 容器生效。超过额度时接口返回 HTTP `429`;该配置只调整阈值,不改变 Redis 限流算法或响应格式。
|
||||
|
||||
前端运行时配置通过 `web/runtime-config.js.template` 注入。与认证兼容层相关的新变量如下:
|
||||
|
||||
- `SKILLHUB_WEB_AUTH_DIRECT_ENABLED`
|
||||
|
|
|
|||
|
|
@ -1,85 +0,0 @@
|
|||
# 注册时自动创建个人命名空间
|
||||
|
||||
## 背景
|
||||
|
||||
自建部署里常见的诉求:每个新账号都应该有一块属于自己的地盘,可以直接发布技能,
|
||||
而不必先向管理员申请命名空间、也不必把半成品塞进 `global`。
|
||||
|
||||
## 一、自动创建个人命名空间
|
||||
|
||||
### 「私有」在当前模型里的含义
|
||||
|
||||
命名空间没有可见性字段——只有 `GLOBAL` 和 `TEAM` 两种类型,
|
||||
技能的可见性是技能自己的属性。因此这里的「私有命名空间」= **一个只有本人为成员的 TEAM 命名空间**。
|
||||
本人拿到的是 `OWNER` 角色(比 `ADMIN` 更强:可以改设置、管成员、删除)。
|
||||
|
||||
如果要做到「别人搜不到这个命名空间」,那是独立的 namespace visibility 特性,不在本次范围内。
|
||||
|
||||
### 触发时机
|
||||
|
||||
在账号**第一次变得可用**时触发,共三处,均发布 `UserActivatedEvent`:
|
||||
|
||||
| 入口 | 位置 |
|
||||
|------|------|
|
||||
| 本地注册 | `LocalAuthService.register` |
|
||||
| 外部身份首次登录 | `IdentityBindingService.bindOrCreate`(仅 `initialStatus == ACTIVE`) |
|
||||
| 管理员审批 / 解封 | `AdminUserAppService.updateUserStatus`(仅从非 ACTIVE 转为 ACTIVE) |
|
||||
|
||||
第三处不可省略:开启了准入审批的部署里,用户在 OAuth 首次尝试时就以 `PENDING` 建号,
|
||||
真正可用是在管理员审批那一刻。
|
||||
|
||||
### 为什么走事件 + AFTER_COMMIT
|
||||
|
||||
`PersonalNamespaceProvisioningListener` 用 `@TransactionalEventListener`
|
||||
(默认 AFTER_COMMIT)并在自己的事务里建命名空间。原因是数据库约束:
|
||||
|
||||
```
|
||||
namespace.created_by REFERENCES user_account(id)
|
||||
namespace_member.user_id REFERENCES user_account(id)
|
||||
```
|
||||
|
||||
- 如果**加入注册事务**:命名空间创建失败(例如 slug 竞态撞唯一约束)会把注册一起回滚,
|
||||
用户会因为「命名空间没建成」而登不上来。
|
||||
- 如果在注册事务中**用 `REQUIRES_NEW` 挂起**:新事务看不到尚未提交的 `user_account` 行,
|
||||
外键检查会阻塞在外层事务的行锁上,形成互等。
|
||||
|
||||
放到提交之后就同时避开了这两点:账号已经落库,建命名空间失败只损失一个命名空间,
|
||||
监听器捕获异常并记 WARN。
|
||||
|
||||
监听器**不加 `@Async`**:命名空间要在用户下一个请求到达前就绪。
|
||||
|
||||
### 命名模板
|
||||
|
||||
两个模板,占位符语法 `${...}`:
|
||||
|
||||
| 占位符 | 取值 |
|
||||
|--------|------|
|
||||
| `${username}` | 认证路径提供的用户名;缺失时依次回落到邮箱前缀、用户 ID |
|
||||
| `${email_prefix}` | 邮箱 `@` 之前的部分 |
|
||||
| `${user_id}` | 平台内部用户 ID |
|
||||
|
||||
未知占位符原样保留,让拼错的名字暴露出来,而不是静默消失。
|
||||
|
||||
slug 模板渲染后按 `SlugValidator` 的规则归一化:转小写、
|
||||
字母数字以外的字符变连字符、去掉首尾与重复连字符。
|
||||
**注意下划线不合法**——`${username}_space` 会得到 `alice-space`。
|
||||
冲突处理:候选 slug 若非法(保留字如 `admin`、长度不足)或已被占用,
|
||||
依次尝试 `-2`、`-3`……最多 64 次;全部失败则跳过并记 WARN。
|
||||
`admin` 这类保留字因此自然落到 `admin-2`。
|
||||
|
||||
幂等:用户若已经拥有任意非 GLOBAL 命名空间,直接跳过。
|
||||
解封会再次发布 `UserActivatedEvent`,靠这条保证不会重复发一个命名空间。
|
||||
|
||||
## 二、配置
|
||||
|
||||
| 位置 | 项 | 默认 |
|
||||
|------|-----|------|
|
||||
| `application.yml` | `skillhub.namespace.personal-provisioning.enabled` | `false` |
|
||||
| 配置文件/环境变量 | 启用开关 | `true` |
|
||||
|
||||
默认只对新激活账号生效,不回填已有账号;如需关闭可设置环境变量。
|
||||
|
||||
模板刻意**不放在 `application.yml`**:它们含 `${...}`,
|
||||
Spring 会当成属性占位符去解析(Boot 3.2 / Framework 6.1 尚不支持转义 `\${`)。
|
||||
模板默认值固定为 `personal-${random}` 和 `${username}-个人空间`,
|
||||
如需关闭可设置 `SKILLHUB_NAMESPACE_PERSONAL_PROVISIONING_ENABLED=false`。
|
||||
|
|
@ -1,408 +0,0 @@
|
|||
# Compliance Metadata 设计方案
|
||||
|
||||
状态:第一阶段已落地发布校验和版本级 snapshot 固化;详情展示、审核 diff、搜索 facet 和 Runtime trace 集成仍按本文后续阶段推进。
|
||||
|
||||
## 1. 背景
|
||||
|
||||
Issue #556 提出的方向是让 SkillHub 支持“可标准映射、可审计引用”的技能元数据。它参考了两个不同类型的开源仓库:
|
||||
|
||||
- `mukul975/Anthropic-Cybersecurity-Skills`:大量 `SKILL.md` 在 frontmatter 中声明 MITRE ATT&CK、NIST CSF 等标准映射,并通过 `references/standards.md` 等文件补充证据。
|
||||
- `calesthio/OpenMontage`:通过 pipeline manifest、artifact schema、checkpoint 和 review gate 证明垂直工作流的可恢复、可审核和可追踪。
|
||||
|
||||
这两个仓库给 SkillHub 的启发不同:
|
||||
|
||||
- 标准映射应该进入 skill 协议和版本事实,而不是只作为 UI 标签。
|
||||
- 运行时 trace 应由执行方记录,SkillHub 不应承担 Agent Runtime 的执行事实。
|
||||
|
||||
需要注意:`compliance` 不是当前已经被广泛应用的 `SKILL.md` 标准字段。SkillHub 现有协议文档已经约定 `x-astron-*` 作为平台私有扩展命名空间。因此第一阶段应使用 `x-astron-compliance`,先解决 SkillHub 自己的治理和审计需求;未来如果 OpenSkills / Agent Skills 生态形成公开字段,再通过兼容读取 `compliance` 或迁移工具对齐。
|
||||
|
||||
因此本方案采用职责分离:
|
||||
|
||||
> SkillHub 负责“这个技能版本声明了什么合规能力”;Agent Runtime 负责“这次执行实际用了哪个技能版本”。两者通过 `skillVersionId + complianceSnapshotDigest` 关联。
|
||||
|
||||
这里的 compliance 是作者随技能包提交的声明型元数据。SkillHub 第一阶段只验证字段结构、取值格式、
|
||||
包内证据文件是否存在、外部证据 URL 是否是合法 HTTP(S) URL,并生成不可变快照摘要;它不验证外部标准内容是否真实适用,
|
||||
也不代表第三方审计、认证通过或平台背书。
|
||||
|
||||
## 2. 职责边界
|
||||
|
||||
### 2.1 SkillHub 职责
|
||||
|
||||
SkillHub 是技能注册中心和元数据权威源,负责:
|
||||
|
||||
- 解析 `SKILL.md` frontmatter 中的 `x-astron-compliance` 字段。
|
||||
- 发布时校验 compliance 元数据和证据引用。
|
||||
- 将规范化结果固化为技能版本级 snapshot。
|
||||
- 在已有技能详情、版本详情、审核和搜索能力中投影 compliance 信息。
|
||||
- 记录 SkillHub 内部发生的发布、审核、compliance 变更审计。
|
||||
- 为未来 Agent Runtime 引用提供稳定的 `skillVersionId` 和 `complianceSnapshotDigest`。
|
||||
|
||||
### 2.2 Agent Runtime 职责
|
||||
|
||||
Agent Runtime,例如 Astron、Claude Code、Codex、OpenClaw 或其他执行方,负责:
|
||||
|
||||
- 实际加载和执行技能。
|
||||
- 生成 execution trace。
|
||||
- 记录本次执行使用的 skill coordinate、skill version、`skillVersionId` 和 `complianceSnapshotDigest`。
|
||||
- 记录运行时输入输出摘要、审批 gate、执行结果、错误和运行时策略。
|
||||
|
||||
SkillHub 不记录 Agent 每次执行,也不实现 Agent execution trace。
|
||||
|
||||
## 3. 非目标
|
||||
|
||||
第一阶段不做以下内容:
|
||||
|
||||
- 不新增独立 compliance 查询 API。
|
||||
- 不实现 Astron execution trace。
|
||||
- 不新增复杂 facet / 聚合搜索。
|
||||
- 不引入外部审计系统集成。
|
||||
- 不把 `compliance` 当作已经存在的上游通用标准字段。
|
||||
- 不为了 compliance 过早新建复杂表结构,除非后续性能或查询需求明确。
|
||||
|
||||
## 4. 协议草案
|
||||
|
||||
建议在 `SKILL.md` frontmatter 中先支持 SkillHub/Astron 私有扩展字段 `x-astron-compliance`:
|
||||
|
||||
```yaml
|
||||
---
|
||||
name: incident-response-helper
|
||||
description: Guide analysts through incident response triage and evidence collection.
|
||||
version: "1.2.0"
|
||||
x-astron-compliance:
|
||||
- standard: mitre-attack
|
||||
version: "v19.1"
|
||||
controlId: T1059
|
||||
title: Command and Scripting Interpreter
|
||||
evidence:
|
||||
- type: packaged-file
|
||||
path: references/standards.md
|
||||
- type: external-url
|
||||
url: https://attack.mitre.org/techniques/T1059/
|
||||
---
|
||||
```
|
||||
|
||||
字段含义:
|
||||
|
||||
| 字段 | 含义 |
|
||||
|---|---|
|
||||
| `standard` | 标准名称,例如 `mitre-attack`、`nist-csf`、`soc2`、`hipaa` |
|
||||
| `version` | 标准版本,例如 `v19.1`、`2.0` |
|
||||
| `controlId` | 标准控制项、技术编号或条款 ID |
|
||||
| `title` | 人类可读名称 |
|
||||
| `evidence` | 证据列表 |
|
||||
| `evidence.type` | `packaged-file` 或 `external-url` |
|
||||
| `evidence.path` | 技能包内证据文件路径,仅 `packaged-file` 使用 |
|
||||
| `evidence.url` | 外部证据链接,仅 `external-url` 使用 |
|
||||
|
||||
未来兼容策略:
|
||||
|
||||
- 写入规范:第一阶段只推荐作者写 `x-astron-compliance`。
|
||||
- 读取兼容:如果后续生态出现公开 `compliance` 字段,解析器可以同时读取 `compliance` 和 `x-astron-compliance`,但需要定义冲突优先级。
|
||||
- 对外展示:UI 和审计报告仍统一展示为“Compliance Metadata”,不暴露内部字段名前缀给普通用户。
|
||||
|
||||
## 5. 版本级 Snapshot
|
||||
|
||||
发布时,SkillHub 将 compliance 规范化为版本级 snapshot,并写入版本元数据。
|
||||
|
||||
第一阶段优先复用:
|
||||
|
||||
```text
|
||||
skill_version.parsed_metadata_json
|
||||
```
|
||||
|
||||
建议结构:
|
||||
|
||||
```json
|
||||
{
|
||||
"frontmatter": {
|
||||
"name": "incident-response-helper",
|
||||
"description": "Guide analysts through incident response triage and evidence collection.",
|
||||
"version": "1.2.0",
|
||||
"x-astron-compliance": []
|
||||
},
|
||||
"complianceSnapshot": {
|
||||
"schemaVersion": "1.0",
|
||||
"items": [
|
||||
{
|
||||
"standard": "mitre-attack",
|
||||
"version": "v19.1",
|
||||
"controlId": "T1059",
|
||||
"title": "Command and Scripting Interpreter",
|
||||
"evidence": [
|
||||
{
|
||||
"type": "packaged-file",
|
||||
"path": "references/standards.md",
|
||||
"sha256": "..."
|
||||
},
|
||||
{
|
||||
"type": "external-url",
|
||||
"url": "https://attack.mitre.org/techniques/T1059/"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"digest": "sha256:..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`digest` 用于未来运行时 trace 或外部审计引用。第一阶段只生成并写入
|
||||
`parsed_metadata_json`,不新增独立 endpoint;后续再通过既有详情或版本详情投影给前端。
|
||||
|
||||
## 6. 分步执行计划
|
||||
|
||||
### Phase 1:协议和领域模型
|
||||
|
||||
目标:先把 `x-astron-compliance` 字段定义清楚,并放在领域层。
|
||||
|
||||
建议新增位置:
|
||||
|
||||
```text
|
||||
server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/metadata/
|
||||
```
|
||||
|
||||
候选对象:
|
||||
|
||||
```text
|
||||
ComplianceMapping
|
||||
ComplianceEvidence
|
||||
ComplianceEvidenceType
|
||||
ComplianceMetadataService
|
||||
ComplianceSnapshot
|
||||
```
|
||||
|
||||
设计要求:
|
||||
|
||||
- `SkillMetadataParser` 继续只负责解析 frontmatter,不承担 compliance 业务校验。
|
||||
- `ComplianceMetadataService` 负责提取、规范化、校验 compliance。
|
||||
- 不在 controller 中做 compliance 校验。
|
||||
- 使用已有 `x-astron-*` 私有扩展命名空间,不新增未验证的公开字段。
|
||||
|
||||
### Phase 2:发布时解析和校验
|
||||
|
||||
目标:技能发布时能识别并校验 compliance。
|
||||
|
||||
接入点:
|
||||
|
||||
```text
|
||||
SkillPackageValidator
|
||||
SkillPublishService
|
||||
SkillVersion.parsedMetadataJson
|
||||
```
|
||||
|
||||
基础校验规则:
|
||||
|
||||
- `x-astron-compliance` 缺失时兼容旧技能。
|
||||
- `x-astron-compliance` 存在时必须是数组。
|
||||
- 每个 mapping 必须是对象。
|
||||
- `standard`、`version`、`controlId` 必填。
|
||||
- `title` 可选,但应有长度限制。
|
||||
- `evidence` 可选;提供时必须是数组。
|
||||
- 同一版本内不允许重复 `standard + version + controlId`。
|
||||
- mapping 数量、evidence 数量和字符串长度要有上限。
|
||||
|
||||
证据校验规则:
|
||||
|
||||
- `packaged-file.path` 必须存在于技能包。
|
||||
- `packaged-file.path` 不允许 `../` 路径逃逸。
|
||||
- `external-url.url` 只允许 `http` / `https`。
|
||||
- 包内证据文件应计算 `sha256` 并写入 snapshot。
|
||||
|
||||
错误信息要求:
|
||||
|
||||
- 使用现有 i18n 机制。
|
||||
- 不在领域服务中散落不可翻译的长英文错误字符串。
|
||||
|
||||
### Phase 3:固化版本级 Snapshot
|
||||
|
||||
目标:每个技能版本都有不可变 compliance snapshot。
|
||||
|
||||
实现要求:
|
||||
|
||||
- 发布成功后生成规范化 `complianceSnapshot`。
|
||||
- snapshot 内容和 digest 与该 `SkillVersion` 绑定。
|
||||
- 后续详情、审核、搜索均读取 snapshot,不重新解释最新源码。
|
||||
- snapshot 为空时也要有确定行为,避免旧技能受影响。
|
||||
|
||||
第一阶段不强制新建表。后续出现结构化过滤、统计或性能瓶颈时,再考虑:
|
||||
|
||||
- `jsonb` GIN index;
|
||||
- `skill_version_compliance_mapping` 表;
|
||||
- 搜索 projection 表扩展。
|
||||
|
||||
### Phase 4:已有接口投影,不新增独立 API
|
||||
|
||||
目标:让前端和审核能看到 compliance,但不发布猜测性 public API。
|
||||
|
||||
建议:
|
||||
|
||||
- 在已有技能详情或版本详情 response 中增加 compliance projection。
|
||||
- 审核详情中带出当前版本 compliance snapshot。
|
||||
- 不新增以下 endpoint:
|
||||
|
||||
```text
|
||||
GET /api/skills/{namespace}/{slug}/versions/{version}/compliance
|
||||
GET /api/skills/{namespace}/{slug}/versions/{version}/metadata
|
||||
```
|
||||
|
||||
后续只有出现明确使用方时再新增独立 API,例如:
|
||||
|
||||
- Agent Runtime 只需要拉 compliance snapshot,不需要完整技能详情。
|
||||
- 企业审计系统按 `skillVersionId` 拉取合规声明。
|
||||
- 前端需要单独比较两个版本的 compliance diff。
|
||||
- 完整 detail payload 性能不可接受。
|
||||
|
||||
如果后续需要独立 API,优先考虑按不可变版本 ID 设计:
|
||||
|
||||
```text
|
||||
GET /api/skill-versions/{skillVersionId}/compliance
|
||||
```
|
||||
|
||||
### Phase 5:轻量搜索
|
||||
|
||||
目标:先提升可发现性,不直接做复杂 facet。
|
||||
|
||||
后续阶段:
|
||||
|
||||
- 在搜索文档重建时,将 snapshot 中的 `standard`、`controlId`、`title` 加入搜索文本。
|
||||
- 用户搜索 `T1059`、`mitre-attack`、`nist-csf` 时能命中对应技能。
|
||||
|
||||
更后续再考虑:
|
||||
|
||||
- 按 standard filter。
|
||||
- 按 controlId filter。
|
||||
- compliance coverage 聚合。
|
||||
- 独立索引或结构化 projection。
|
||||
|
||||
### Phase 6:审核和审计
|
||||
|
||||
目标:只记录 SkillHub 自己发生的事实。
|
||||
|
||||
审核展示:
|
||||
|
||||
- 当前版本 compliance snapshot。
|
||||
- 与上一发布版本的 diff:
|
||||
- 新增 mapping;
|
||||
- 删除 mapping;
|
||||
- 修改 mapping;
|
||||
- evidence 变化;
|
||||
- digest 变化。
|
||||
|
||||
审计记录:
|
||||
|
||||
- 发布时记录 compliance digest。
|
||||
- 审核通过 / 拒绝时记录 compliance diff 摘要。
|
||||
- evidence 变化作为风险信息进入 audit detail。
|
||||
|
||||
不记录:
|
||||
|
||||
- Agent 执行输入输出。
|
||||
- Astron trace。
|
||||
- runtime 调用结果。
|
||||
|
||||
### Phase 7:文档
|
||||
|
||||
目标:让技能作者、平台维护者和 Agent Runtime 接入方都理解边界。
|
||||
|
||||
需要更新的文档:
|
||||
|
||||
- `docs/07-skill-protocol.md`:实现稳定后补充正式 `x-astron-compliance` 协议。
|
||||
- 用户文档:说明如何在 `SKILL.md` 中声明 `x-astron-compliance`。
|
||||
- 管理员文档:说明发布校验、审核 diff、审计记录。
|
||||
- 集成文档:说明 Runtime 如何引用 `skillVersionId + complianceSnapshotDigest`。
|
||||
|
||||
文档必须明确:
|
||||
|
||||
> SkillHub 只提供版本级 compliance snapshot。运行时 trace 由 Agent Runtime 记录,并可引用 SkillHub 的 `skillVersionId` 和 `complianceSnapshotDigest`。
|
||||
|
||||
### Phase 8:测试
|
||||
|
||||
单元测试:
|
||||
|
||||
- 无 `x-astron-compliance` 的旧技能正常发布。
|
||||
- 合法 `x-astron-compliance` 正常解析。
|
||||
- `standard` 缺失失败。
|
||||
- `version` 缺失失败。
|
||||
- `controlId` 缺失失败。
|
||||
- 重复 `standard + version + controlId` 失败。
|
||||
- `packaged-file.path` 不存在失败。
|
||||
- `packaged-file.path` 路径逃逸失败。
|
||||
- `external-url.url` scheme 非法失败。
|
||||
- digest 稳定生成。
|
||||
|
||||
发布链路测试:
|
||||
|
||||
- 上传含 `x-astron-compliance` 的技能包成功。
|
||||
- 上传非法 `x-astron-compliance` 的技能包失败。
|
||||
- 发布后 `parsedMetadataJson` 包含 `complianceSnapshot`。
|
||||
- snapshot digest 与内容一致。
|
||||
|
||||
搜索测试:
|
||||
|
||||
- 搜标准名能命中。
|
||||
- 搜 controlId 能命中。
|
||||
- 无 compliance 的旧技能不受影响。
|
||||
|
||||
审核测试:
|
||||
|
||||
- 新版本新增 compliance。
|
||||
- 新版本删除 compliance。
|
||||
- 新版本修改 evidence。
|
||||
- 审核详情能看到 diff。
|
||||
|
||||
## 7. 推荐 PR 拆分
|
||||
|
||||
### PR 1:协议、解析、校验、快照
|
||||
|
||||
范围:
|
||||
|
||||
- domain metadata service;
|
||||
- package validator;
|
||||
- publish snapshot;
|
||||
- `parsedMetadataJson` 结构;
|
||||
- 单元测试和发布链路测试。
|
||||
|
||||
不包含:
|
||||
|
||||
- UI;
|
||||
- 搜索 facet;
|
||||
- 独立 API;
|
||||
- Agent trace。
|
||||
|
||||
### PR 2:详情页和审核展示
|
||||
|
||||
范围:
|
||||
|
||||
- 既有 response 增加 compliance projection;
|
||||
- 技能详情展示;
|
||||
- 审核 diff 展示;
|
||||
- 前端测试。
|
||||
|
||||
### PR 3:轻量搜索
|
||||
|
||||
范围:
|
||||
|
||||
- 搜索文档增加 compliance keywords;
|
||||
- 搜索测试。
|
||||
|
||||
不做复杂 facet。
|
||||
|
||||
### PR 4:文档和 Runtime 集成契约
|
||||
|
||||
范围:
|
||||
|
||||
- 用户文档;
|
||||
- 管理员文档;
|
||||
- Runtime 引用方式;
|
||||
- `skillVersionId + complianceSnapshotDigest` 契约说明。
|
||||
|
||||
不实现 Astron trace。
|
||||
|
||||
## 8. 最终架构原则
|
||||
|
||||
1. SkillHub 不执行技能,因此不记录执行 trace。
|
||||
2. SkillHub 是 skill metadata 和 version snapshot 的权威源。
|
||||
3. Agent Runtime 是 execution trace 的权威源。
|
||||
4. 合规审计通过 `skillVersionId + complianceSnapshotDigest` 把两边事实关联起来。
|
||||
5. 第一阶段不发布猜测性 API;先通过已有详情和版本投影满足内部使用。
|
||||
6. 先做稳定协议和可验证快照,再做 UI、搜索和外部集成。
|
||||
|
|
@ -1,220 +0,0 @@
|
|||
# Content Safety Policy
|
||||
|
||||
Last updated: August 18, 2026
|
||||
|
||||
## Purpose and scope
|
||||
|
||||
SkillHub accepts, stores, reviews, and distributes agent skill packages. Packages
|
||||
can contain instructions, scripts, documentation, examples, images, and other files
|
||||
that influence an AI agent or execute on a user's computer. Profiles, namespace
|
||||
descriptions, reviews, reports, ratings, and release notes also contain user-supplied
|
||||
content.
|
||||
|
||||
This document describes the project's content-safety expectations, available
|
||||
technical and governance controls, and the responsibilities of people who publish,
|
||||
review, operate, and install skills.
|
||||
|
||||
The SkillHub maintainers do not operate or moderate every independently hosted
|
||||
instance. Each operator must assess its users, jurisdiction, deployment model, and
|
||||
risk; publish enforceable rules and a reporting channel; configure appropriate
|
||||
controls; and staff its own review, appeal, and emergency processes.
|
||||
|
||||
## Baseline rules
|
||||
|
||||
SkillHub instances should not knowingly publish or distribute content or packages
|
||||
that:
|
||||
|
||||
- violate applicable law or another person's intellectual-property, privacy, or
|
||||
other rights;
|
||||
- sexually exploit or endanger children, including child sexual abuse material;
|
||||
- credibly threaten, harass, or promote violence or hateful abuse against people;
|
||||
- expose personal, confidential, or authentication data without authorization;
|
||||
- contain malware, credential theft, destructive payloads, unauthorized access,
|
||||
persistence, evasion, or instructions intended to defeat security controls;
|
||||
- impersonate people or organizations, facilitate fraud, or intentionally present
|
||||
deceptive or materially misleading claims;
|
||||
- secretly collect, transmit, or use data beyond the skill's documented purpose;
|
||||
- conceal important external services, downloads, commands, permissions, or side
|
||||
effects from reviewers and users; or
|
||||
- bypass an instance's review, scanning, namespace, visibility, or access-control
|
||||
rules.
|
||||
|
||||
Context matters. Legitimate security research, education, documentation, and
|
||||
defensive automation can discuss or test risky behavior without promoting harm.
|
||||
Reviewers should consider purpose, provenance, permissions, likely impact, and
|
||||
applicable law instead of relying on keywords alone.
|
||||
|
||||
## Content and package risks
|
||||
|
||||
A skill may instruct an agent to read or modify files, run commands, call external
|
||||
services, install dependencies, browse websites, or handle sensitive inputs.
|
||||
Documentation and examples may be inaccurate or omit important consequences.
|
||||
Images and archives may contain hidden payloads. Ratings or social signals do not
|
||||
prove that a package is safe, lawful, accurate, or suitable for a particular use.
|
||||
|
||||
Publishers must accurately describe required permissions, external recipients,
|
||||
dependencies, expected side effects, supported environments, and known limitations.
|
||||
Installers must review package contents and apply least privilege before execution.
|
||||
|
||||
## Available technical and governance controls
|
||||
|
||||
SkillHub includes controls that an operator can combine according to its risk:
|
||||
|
||||
- package limits and file-type validation, including size, file-count, extension,
|
||||
and selected file-signature checks;
|
||||
- a configurable security scanner that can inspect uploaded packages and produce
|
||||
findings for reviewers;
|
||||
- namespace and platform review workflows with approve, reject, withdraw, and
|
||||
promotion decisions;
|
||||
- user reports and administrator actions such as hiding, archiving, rejecting, or
|
||||
yanking content and versions;
|
||||
- platform and namespace RBAC for publishing and governance actions;
|
||||
- public, namespace-only, and private visibility; and
|
||||
- audit records and notifications for relevant governance activity.
|
||||
|
||||
The implementation and operating guidance are documented in the
|
||||
[scanner guide](security-scanning.md),
|
||||
[review guide](skillhub/en/guide/review.md), and
|
||||
[security architecture](../document/docs/04-developer/architecture/security.md).
|
||||
|
||||
## Important limitations
|
||||
|
||||
These controls reduce risk but do not certify a package as safe or compliant:
|
||||
|
||||
- scanner operation is configurable, and an operator can run SkillHub without an
|
||||
enabled scanner;
|
||||
- optional LLM-backed analysis depends on the service selected by the operator and
|
||||
can create additional privacy and reliability risks;
|
||||
- static, behavioral, metadata, and model-based analysis can produce false
|
||||
positives and false negatives;
|
||||
- a successfully scanned package can still be misleading, vulnerable, or
|
||||
malicious in context, or unsuitable for a specific environment;
|
||||
- privileged publication paths require operator governance and periodic review;
|
||||
and
|
||||
- independently hosted instances can configure different review, visibility, and
|
||||
enforcement practices.
|
||||
|
||||
Operators must disclose which controls are active. A package that fails scanning
|
||||
or review should remain unavailable for ordinary installation until the failure
|
||||
is resolved through a documented process. Scanner failure must not be treated as
|
||||
proof that a package is safe.
|
||||
|
||||
## Publisher responsibilities
|
||||
|
||||
Before submitting a skill, a publisher should:
|
||||
|
||||
- inspect every included file and remove secrets, personal data, build artifacts,
|
||||
and unrelated binaries;
|
||||
- document commands, network destinations, external downloads, required
|
||||
permissions, and persistent changes;
|
||||
- pin or constrain dependencies where practical and preserve their license notices;
|
||||
- provide evidence for security, compliance, or standards claims instead of relying
|
||||
on labels alone;
|
||||
- test failure and rollback behavior in an isolated environment;
|
||||
- avoid manipulative instructions designed to override system, user, or operator
|
||||
safety controls; and
|
||||
- update or withdraw a package when a material risk is discovered.
|
||||
|
||||
## Operator safeguards
|
||||
|
||||
Before opening an instance to publishers or installers, an operator should:
|
||||
|
||||
- define permitted and prohibited content, reviewers, escalation owners, and
|
||||
emergency contacts;
|
||||
- enable scanning and human review appropriate to the instance's exposure and
|
||||
package risk;
|
||||
- restrict direct-publish and governance roles, log their use, and review them
|
||||
regularly;
|
||||
- isolate scanning and package inspection from production secrets and sensitive
|
||||
networks;
|
||||
- rate-limit uploads, downloads, reports, and automated activity;
|
||||
- preserve only the evidence needed for review and protect reporter identities;
|
||||
- provide a visible reporting channel and an impartial appeal route; and
|
||||
- train reviewers to handle malware, privacy, child-safety, fraud, and
|
||||
intellectual-property reports safely.
|
||||
|
||||
## Reporting
|
||||
|
||||
For a skill or profile visible in a SkillHub instance, use that instance's report
|
||||
feature or contact the operator identified in its published policies. Include the
|
||||
package coordinate and version, the reason for concern, the time observed, and the
|
||||
minimum context needed to investigate. Do not execute a suspected malicious package
|
||||
or resend illegal, exploitative, personal, or confidential material through an
|
||||
unprotected channel.
|
||||
|
||||
For abusive or harassing conduct on SkillHub project-managed community surfaces,
|
||||
report privately to
|
||||
[ifly_opensource@iflytek.com](mailto:ifly_opensource@iflytek.com) under the
|
||||
[Code of Conduct](../CODE_OF_CONDUCT.md). Report upstream security vulnerabilities
|
||||
privately to [security@iflytek.com](mailto:security@iflytek.com) under the
|
||||
[iFLYTEK organization security policy](https://github.com/iflytek/.github/blob/main/SECURITY.md).
|
||||
Do not disclose vulnerabilities or personal data in a public issue.
|
||||
|
||||
## Review, action, and notice
|
||||
|
||||
An operator's documented process should:
|
||||
|
||||
1. triage imminent danger, child-safety concerns, credible malware, exposed
|
||||
credentials, and active security incidents for urgent specialist handling;
|
||||
2. preserve only the evidence needed for a proportionate review;
|
||||
3. assess the package, context, applicable rule, law, provenance, permissions, and
|
||||
likely user impact;
|
||||
4. take proportionate action, such as rejecting a version, hiding or yanking a
|
||||
package, restricting an account, revoking a token, or escalating to an authorized
|
||||
specialist;
|
||||
5. record the rule, evidence, and rationale and notify affected people when lawful
|
||||
and safe; and
|
||||
6. provide an appeal route and use confirmed incidents to improve controls.
|
||||
|
||||
SkillHub's review guide recommends completing routine package reviews within 24
|
||||
hours to avoid blocking publishers. That recommendation is not a historical average
|
||||
for safety reports and is not an emergency-response guarantee. The upstream project
|
||||
does not yet have enough comparable safety reports to publish a meaningful average
|
||||
assessment or action time. Each operator must publish targets appropriate to its
|
||||
risk, staffing, and legal obligations, with an urgent path for imminent harm and
|
||||
child safety.
|
||||
|
||||
## Appeals
|
||||
|
||||
A publisher, reporter, account holder, or other person materially affected by a
|
||||
governance decision should be able to request review through the instance
|
||||
operator's private channel. The request should identify the original decision and
|
||||
give a reason for review, such as significant new evidence, a material procedural
|
||||
error, a conflict of interest, or a clearly disproportionate action.
|
||||
|
||||
Appeals should be handled by a person who did not make the original decision and
|
||||
has no conflict of interest. The reviewer may uphold, modify, or reverse the action,
|
||||
or require a new investigation. Temporary protective measures may remain in place
|
||||
while needed to protect people, systems, evidence, or legal obligations.
|
||||
|
||||
For Code of Conduct decisions on project-managed community surfaces, send an appeal
|
||||
to [ifly_opensource@iflytek.com](mailto:ifly_opensource@iflytek.com) with the
|
||||
subject `SkillHub Code of Conduct appeal`. Include the original case reference,
|
||||
the outcome being challenged, and the reason for review. Appeal information must
|
||||
be limited to people who need it. Retaliation for a good-faith report or appeal
|
||||
is prohibited.
|
||||
|
||||
## Children and young people
|
||||
|
||||
SkillHub is a general-purpose developer and enterprise collaboration tool, not a
|
||||
child-directed service. An operator that permits use by children or processes their
|
||||
data must perform an age-appropriate risk assessment, use any legally required
|
||||
parental or guardian consent, minimize collection and profiling, restrict contact
|
||||
and high-risk package capabilities, provide child-accessible notices and reporting,
|
||||
and route serious concerns to trained personnel and appropriate authorities.
|
||||
|
||||
If those protections cannot be provided, the instance should not be offered to
|
||||
children. The project Code of Conduct separately protects community participation
|
||||
from harassment regardless of age.
|
||||
|
||||
## Privacy and policy review
|
||||
|
||||
Package inspection, reports, audit logs, and investigations can expose sensitive
|
||||
information. They must follow the
|
||||
[Privacy and Data Governance Policy](PRIVACY_AND_DATA_GOVERNANCE.md) and the
|
||||
instance's own privacy notice and retention schedule.
|
||||
|
||||
Material changes to this policy are made through the repository's public review
|
||||
process. Operators should periodically test their controls, review incident trends,
|
||||
and update their policy when the product, threat model, law, or operating context
|
||||
changes.
|
||||
|
|
@ -1,187 +0,0 @@
|
|||
# Privacy and Data Governance Policy
|
||||
|
||||
Last updated: August 18, 2026
|
||||
|
||||
## Purpose and scope
|
||||
|
||||
SkillHub is open-source software for publishing, reviewing, discovering, and
|
||||
installing reusable agent skill packages. This document describes the project's
|
||||
privacy and data-governance expectations and the controls available to people who
|
||||
operate SkillHub instances.
|
||||
|
||||
The SkillHub maintainers publish source code and project infrastructure. They do
|
||||
not operate or control every independently hosted instance. The organization or
|
||||
person operating an instance determines why and how personal data is processed in
|
||||
that environment and is responsible for publishing an instance-specific privacy
|
||||
notice, selecting lawful processing grounds, handling data-subject requests, and
|
||||
complying with applicable law.
|
||||
|
||||
The public SkillHub service also publishes an in-product
|
||||
[privacy notice](https://skill.xfyun.cn/privacy). This project document supplements
|
||||
that notice for source-code reviewers and self-hosted operators. It is not legal
|
||||
advice and does not certify that every deployment is automatically compliant with
|
||||
any law.
|
||||
|
||||
## Applicable law
|
||||
|
||||
Privacy and data-protection obligations depend on the operator's legal entity,
|
||||
where the instance and its users are located, the people it serves, the data in
|
||||
skill packages, and the infrastructure and integrations selected by the operator.
|
||||
|
||||
Before processing personal data, each operator must:
|
||||
|
||||
- identify and document the domestic and international laws that apply;
|
||||
- determine and record the lawful basis for each material processing purpose;
|
||||
- complete any required privacy, child-safety, security, or transfer assessment;
|
||||
- reflect those obligations in notices, contracts, procedures, and configuration;
|
||||
and
|
||||
- avoid or redesign processing that cannot be operated lawfully.
|
||||
|
||||
Open-source availability and configurable controls support implementation. They
|
||||
do not replace an operator's legal analysis or operational responsibilities.
|
||||
|
||||
## Data the software can process
|
||||
|
||||
The exact data depends on the authentication, storage, email, observability,
|
||||
scanner, and deployment options selected by the operator. A SkillHub instance can
|
||||
process:
|
||||
|
||||
- account and identity data, such as username, email address, avatar, OAuth
|
||||
provider identifiers, account status, platform roles, and namespace membership;
|
||||
- authentication and security data, such as session identifiers, password hashes,
|
||||
API token metadata, login events, IP addresses, device or browser information,
|
||||
and password-reset records;
|
||||
- skill package content, including `SKILL.md`, scripts, documentation, images,
|
||||
examples, license files, archives, and version metadata;
|
||||
- collaboration and governance data, such as namespaces, reviews, review comments,
|
||||
promotion requests, reports, ratings, stars, notifications, and audit records;
|
||||
- usage and operational data, such as searches, downloads, request identifiers,
|
||||
timestamps, errors, metrics, traces, application logs, and security findings;
|
||||
and
|
||||
- configuration and connection data for object storage, identity providers, email,
|
||||
monitoring, and optional scanning services.
|
||||
|
||||
A skill package, review comment, profile, log entry, or security report can contain
|
||||
personal, confidential, or authentication data even when a field is not labelled
|
||||
as personal data. Operators and publishers must classify data according to its
|
||||
actual content and use.
|
||||
|
||||
## Roles and responsibilities
|
||||
|
||||
For an independently operated instance, the instance operator normally decides the
|
||||
purposes and means of processing and must document its role under applicable law.
|
||||
Publishers and namespace administrators are responsible for the content they upload
|
||||
and the access decisions they make. External identity, storage, email, monitoring,
|
||||
and scanner providers may process data under their own terms and assigned roles.
|
||||
|
||||
The upstream SkillHub maintainers generally cannot access, correct, export, or
|
||||
delete data held by an independently operated instance. Requests concerning an
|
||||
instance must go to the operator identified in that instance's privacy notice.
|
||||
|
||||
## Purpose limitation and data minimization
|
||||
|
||||
Data should be collected only when needed to authenticate users, enforce access
|
||||
rules, publish and distribute skill packages, operate review and governance
|
||||
workflows, secure and troubleshoot the instance, and meet documented legal duties.
|
||||
|
||||
Operators, administrators, and publishers should:
|
||||
|
||||
- avoid placing secrets or unnecessary personal data in skill packages, README
|
||||
files, examples, namespace profiles, reviews, or report details;
|
||||
- use pseudonymous or organization-scoped identifiers where practical;
|
||||
- configure the shortest retention and least visibility needed for each purpose;
|
||||
- redact sensitive values before sending packages or findings to an external
|
||||
scanner, model, log sink, or support channel;
|
||||
- restrict privileged roles and review them regularly; and
|
||||
- document the source, purpose, recipients, lawful basis, and retention period for
|
||||
each material category of personal data.
|
||||
|
||||
## Storage, access, and isolation
|
||||
|
||||
SkillHub supports authenticated access, platform and namespace RBAC, public,
|
||||
namespace-only and private visibility, audit logs, hashed API tokens, PostgreSQL,
|
||||
Redis, and local or S3-compatible object storage. These capabilities are building
|
||||
blocks, not a secure deployment by themselves.
|
||||
|
||||
Operators are responsible for:
|
||||
|
||||
- disabling development authentication and replacing example credentials before
|
||||
exposing an instance;
|
||||
- using HTTPS for external traffic and protected networks for internal services;
|
||||
- applying least-privilege roles to users, services, databases, caches, and object
|
||||
stores;
|
||||
- encrypting sensitive data and backups according to their threat model and legal
|
||||
obligations;
|
||||
- storing secrets in an appropriate secrets manager rather than source code, skill
|
||||
packages, client-side configuration, or logs;
|
||||
- testing namespace and object-storage isolation for their configuration;
|
||||
- restricting and monitoring access to packages, audit data, logs, traces, backups,
|
||||
and security findings; and
|
||||
- applying supported security updates and maintaining a recovery process.
|
||||
|
||||
## External services and international transfers
|
||||
|
||||
OAuth providers, S3-compatible storage, email services, monitoring systems,
|
||||
mirrors, and optional scanner integrations can receive data from a SkillHub
|
||||
instance. An operator that enables an external service must assess its privacy and
|
||||
security terms, hosting locations, retention, subprocessors, training-data rules,
|
||||
and cross-border transfer mechanism.
|
||||
|
||||
The optional scanner can process uploaded skill archives and findings. If an
|
||||
operator enables an external or LLM-backed scanner, that disclosure must be covered
|
||||
by the instance's privacy notice and data-flow review. A service does not become
|
||||
private merely because SkillHub can integrate with it.
|
||||
|
||||
## Retention, deletion, and portability
|
||||
|
||||
The open-source project does not impose one retention period on independently
|
||||
operated instances. Each operator must publish periods that are no longer than
|
||||
necessary for its purposes and legal duties.
|
||||
|
||||
A deletion process should cover account and namespace records, package objects,
|
||||
reviews, reports, ratings, notifications, security findings, caches, audit records,
|
||||
logs, traces, exports, and backups. Where immediate backup deletion is not
|
||||
practical, deleted data should be isolated from normal use and expire under a
|
||||
documented schedule. Operators must also account for copies already disclosed to
|
||||
external providers or downloaded by authorized users.
|
||||
|
||||
Operators should provide authenticated channels for access, correction, deletion,
|
||||
restriction, objection, and portability requests where applicable. Requests should
|
||||
be verified, recorded, completed within legally required time limits, and denied
|
||||
only on a documented basis.
|
||||
|
||||
Skill packages can be downloaded as archives and installed through the CLI. Public
|
||||
APIs expose package and metadata workflows. Operators should separately document
|
||||
which instance records can be imported or exported, their non-proprietary formats,
|
||||
version compatibility, and any PII excluded from an export.
|
||||
|
||||
## Security and incident handling
|
||||
|
||||
Security vulnerabilities in the upstream project must be reported under the public
|
||||
[iFLYTEK organization security policy](https://github.com/iflytek/.github/blob/main/SECURITY.md)
|
||||
and its detailed
|
||||
[community security policy](https://github.com/iflytek/community/blob/master/SECURITY.md).
|
||||
Send vulnerability details privately to
|
||||
[security@iflytek.com](mailto:security@iflytek.com), not in a public issue.
|
||||
|
||||
Instance operators remain responsible for monitoring their environments,
|
||||
maintaining an incident-response plan, preserving proportionate evidence, rotating
|
||||
affected credentials, applying fixes, and notifying users or authorities where
|
||||
required.
|
||||
|
||||
## Project and instance contacts
|
||||
|
||||
- Report an upstream security vulnerability privately to
|
||||
[security@iflytek.com](mailto:security@iflytek.com).
|
||||
- Send questions about this project policy to
|
||||
[ifly_opensource@iflytek.com](mailto:ifly_opensource@iflytek.com). Do not include
|
||||
personal data or confidential incident details in a public GitHub issue.
|
||||
- Contact the operator named in an instance's privacy notice for data-subject
|
||||
requests or incidents involving that instance.
|
||||
|
||||
## Governance and changes
|
||||
|
||||
Privacy-impacting changes should be reviewed for data minimization, access and
|
||||
namespace boundaries, package visibility, external disclosures, retention,
|
||||
logging, deletion, and portability. Material changes to this document are made
|
||||
through the repository's public review process, and the file history records them.
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
# RISC-V (`linux/riscv64`) support
|
||||
|
||||
## Current scope
|
||||
|
||||
RISC-V support is incremental. The SkillHub server and web images have
|
||||
`linux/riscv64` build and runtime paths. The security scanner and the complete
|
||||
Docker Compose deployment are not yet supported on RISC-V.
|
||||
|
||||
| Component | `linux/riscv64` status | Notes |
|
||||
| --- | --- | --- |
|
||||
| `skillhub-server` | Supported | The architecture-neutral Java 21 JAR is built on the Buildx host and copied into the target-architecture Eclipse Temurin runtime. |
|
||||
| `skillhub-web` | Supported | Static assets are built on the Buildx host and served by a target-architecture Nginx runtime. |
|
||||
| `skillhub-scanner` | Not yet verified | Its Python dependency tree still needs a native-extension and runtime audit. |
|
||||
| PostgreSQL 16 and Redis 7 | Upstream images available | Keep these images explicitly pinned and verify them on the target board before production use. |
|
||||
| Complete Compose stack | Unsupported | `compose.release.yml` starts the unverified scanner, so do not deploy it unchanged on RISC-V. |
|
||||
|
||||
## Build the supported images
|
||||
|
||||
Buildx can create both images from an AMD64 or ARM64 host. Register a RISC-V
|
||||
QEMU handler before running these commands when the host is not RISC-V:
|
||||
|
||||
```bash
|
||||
docker run --privileged --rm tonistiigi/binfmt --install riscv64
|
||||
docker buildx create --use --name skillhub-riscv64
|
||||
|
||||
docker buildx build \
|
||||
--platform linux/riscv64 \
|
||||
--file server/Dockerfile \
|
||||
--tag skillhub-server:riscv64 \
|
||||
--load \
|
||||
server
|
||||
|
||||
docker buildx build \
|
||||
--platform linux/riscv64 \
|
||||
--file web/Dockerfile \
|
||||
--tag skillhub-web:riscv64 \
|
||||
--load \
|
||||
web
|
||||
```
|
||||
|
||||
The release workflow publishes `linux/amd64`, `linux/arm64`, and
|
||||
`linux/riscv64` variants for `skillhub-server` and `skillhub-web`. The scanner
|
||||
remains limited to its existing AMD64/ARM64 platform list.
|
||||
|
||||
## Verification boundary
|
||||
|
||||
The pull-request workflow builds both supported target images, checks their OCI
|
||||
architecture metadata, and executes the Java and Nginx runtimes under RISC-V
|
||||
emulation. This is a component-image guardrail, not a full-stack integration
|
||||
test. A native RISC-V smoke test with PostgreSQL, Redis, object storage, and a
|
||||
verified scanner remains required before claiming complete deployment support.
|
||||
|
|
@ -44,7 +44,6 @@ export default defineConfig({
|
|||
{ text: '审核与治理', link: '/guide/review' },
|
||||
{ text: '安全扫描', link: '/guide/scanner' },
|
||||
{ text: '用户交互与社交', link: '/guide/social' },
|
||||
{ text: 'Runtime 集成契约', link: '/guide/runtime-integration' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
@ -91,7 +90,6 @@ export default defineConfig({
|
|||
{ text: 'Review & Governance', link: '/en/guide/review' },
|
||||
{ text: 'Security Scanning', link: '/en/guide/scanner' },
|
||||
{ text: 'Social & Interaction', link: '/en/guide/social' },
|
||||
{ text: 'Runtime Integration Contract', link: '/en/guide/runtime-integration' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -71,7 +71,6 @@ An administrator batch-approves multiple skill packages that meet the standards.
|
|||
- Browse the file list
|
||||
- View file contents online
|
||||
- Download the full package for local testing
|
||||
- Review the compliance snapshot and the diff from the previous published version
|
||||
|
||||

|
||||
|
||||
|
|
@ -81,24 +80,6 @@ An administrator batch-approves multiple skill packages that meet the standards.
|
|||
|
||||
5. Add review comments (optional)
|
||||
|
||||
**Reviewing Compliance Declarations**:
|
||||
|
||||
If the pending version contains `x-astron-compliance`, the review detail page shows the version-level compliance snapshot and a diff summary:
|
||||
|
||||
- Added declarations: standards, controls, or evidence newly added by the pending version.
|
||||
- Removed declarations: declarations that existed in the previous published version and are no longer present.
|
||||
- Modified declarations: standard metadata, control title, or evidence changed.
|
||||
- Digest changes: `complianceSnapshot.digest` changed, which means the normalized declaration content changed.
|
||||
|
||||
Review guidance:
|
||||
|
||||
1. Check whether each declaration matches the actual skill behavior. For example, a security response skill that declares a MITRE ATT&CK technique should provide supporting documentation or packaged evidence.
|
||||
2. Expand diff items to inspect evidence paths, external links, and digests instead of relying only on the declaration title.
|
||||
3. Treat removals and broad rewrites as higher-priority review items because downstream audit systems may reference those snapshots.
|
||||
4. Reject the submission if evidence is missing, paths are inaccessible, or declarations clearly do not match the skill capability.
|
||||
|
||||
SkillHub guarantees structural validation, traceable evidence references, and immutable version snapshots. It does not certify that the author's declaration is objectively compliant.
|
||||
|
||||
**Withdrawing a Review**:
|
||||
|
||||
If a developer discovers an issue, they can withdraw the submission before it is approved:
|
||||
|
|
@ -200,7 +181,6 @@ Content-Type: application/json
|
|||
|
||||
- **Review Turnaround**: It is recommended to complete reviews within 24 hours to avoid blocking developers
|
||||
- **Review Records**: All review actions are recorded in the audit log
|
||||
- **Compliance Audit**: Compliance declarations are recorded as version snapshots. Approval or rejection should consider the diff summary, but Agent execution traces are not recorded by SkillHub
|
||||
- **Batch Review**: Administrators can batch-approve multiple skill packages
|
||||
- **Review Comments**: When rejecting, it is recommended to provide detailed improvement suggestions
|
||||
- **Withdrawal Restrictions**: Only skill packages in the pending review state can be withdrawn
|
||||
|
|
|
|||
|
|
@ -1,101 +0,0 @@
|
|||
# Runtime Integration Contract
|
||||
|
||||
## Responsibility Boundary
|
||||
|
||||
SkillHub and Agent Runtime own different facts:
|
||||
|
||||
| System | Source of truth |
|
||||
|--------|-----------------|
|
||||
| SkillHub | Skill packages, versions, metadata, compliance declaration snapshots, downloads, and review records |
|
||||
| Agent Runtime | Actual skill execution, inputs and outputs, model calls, tool calls, and execution traces |
|
||||
|
||||
SkillHub does not execute skills, so it does not record Runtime traces and does not decide whether a real execution was compliant. SkillHub provides version-level facts: what compliance declarations were included in an immutable skill version at publish time, and the stable digest of that normalized snapshot.
|
||||
|
||||
## What Runtime Should Record
|
||||
|
||||
When Runtime needs to connect an execution trace with SkillHub compliance declarations, record these fields:
|
||||
|
||||
| Field | Source | Description |
|
||||
|-------|--------|-------------|
|
||||
| `registryUrl` | Runtime configuration | SkillHub registry URL |
|
||||
| `namespace` | SkillHub coordinate | Skill namespace, such as `global` or a team slug |
|
||||
| `skillSlug` | SkillHub coordinate | Skill slug |
|
||||
| `requestedVersion` | Runtime request | User-requested version, tag, or range |
|
||||
| `resolvedVersion` | SkillHub response | Exact resolved version |
|
||||
| `skillVersionId` | Version `id` from SkillHub response | Immutable version ID and the primary audit join key |
|
||||
| `complianceSnapshotDigest` | `complianceSnapshot.digest` | Stable digest of the version-level compliance declaration snapshot |
|
||||
| `packageDigest` | Download or install flow | Skill package content digest, useful for confirming executed content |
|
||||
| `runtimeTraceId` | Runtime | Execution trace ID generated by Runtime |
|
||||
|
||||
If Runtime uses an Astron-specific trace schema, it may map these fields into `x-astron-*` keys. That is a Runtime-owned trace convention; SkillHub server does not need to write or parse those trace fields.
|
||||
|
||||
## Reading Version-Level Compliance Snapshots
|
||||
|
||||
The first phase does not expose a standalone compliance API. Runtime can read the version ID and snapshot from the existing version detail endpoint:
|
||||
|
||||
```bash
|
||||
GET /api/v1/skills/{namespace}/{slug}/versions/{version}
|
||||
```
|
||||
|
||||
Key response fields:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 123,
|
||||
"version": "1.2.0",
|
||||
"complianceSnapshot": {
|
||||
"schemaVersion": "1.0",
|
||||
"digest": "sha256:8d8c...",
|
||||
"items": [
|
||||
{
|
||||
"standard": "mitre-attack",
|
||||
"version": "v19.1",
|
||||
"controlId": "T1059",
|
||||
"title": "Command and Scripting Interpreter",
|
||||
"evidence": [
|
||||
{
|
||||
"type": "packaged-file",
|
||||
"path": "references/mitre-t1059.md",
|
||||
"sha256": "sha256:..."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Runtime should write both `id` and `complianceSnapshot.digest` to the execution trace. Recording only the digest is not enough, because the version ID is needed to locate the full snapshot across registries or future migrations.
|
||||
|
||||
## Recommended Execution Flow
|
||||
|
||||
1. Runtime resolves the requested skill coordinate and version.
|
||||
2. Runtime reads exact version details from SkillHub.
|
||||
3. Runtime downloads and verifies the skill package.
|
||||
4. Runtime executes the skill.
|
||||
5. Runtime records these facts in its own trace:
|
||||
- SkillHub registry;
|
||||
- skill coordinate;
|
||||
- exact version;
|
||||
- `skillVersionId`;
|
||||
- `complianceSnapshotDigest`;
|
||||
- Runtime-owned execution evidence.
|
||||
|
||||
An audit system can then start from the Runtime trace, locate the exact execution, and query SkillHub for the compliance declaration snapshot that existed when that version was published.
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
- Do not copy the raw `x-astron-compliance` declaration into a trace and let Runtime mutate it.
|
||||
- Do not record only the skill slug without the version ID; the slug identifies the skill container, not an immutable version.
|
||||
- Do not treat SkillHub compliance declarations as third-party certification.
|
||||
- Do not require SkillHub to record model inputs and outputs; that belongs to the Runtime audit boundary.
|
||||
|
||||
## Possible Future API
|
||||
|
||||
If a clear consumer appears, such as Runtime needing only the compliance snapshot without full skill details, SkillHub can add an immutable version-level endpoint:
|
||||
|
||||
```text
|
||||
GET /api/skill-versions/{skillVersionId}/compliance
|
||||
```
|
||||
|
||||
For now, reuse the version detail response to avoid designing multiple APIs before the caller contract is stable.
|
||||
|
|
@ -95,45 +95,6 @@ Team admins receive review notifications and approve skill packages for official
|
|||
|
||||
Skill package can be discovered through search, others can download via CLI or Web UI.
|
||||
|
||||
## Compliance Declarations
|
||||
|
||||
Skill authors can add `x-astron-compliance` to the `SKILL.md` frontmatter to declare how a skill version maps to compliance standards, controls, or security knowledge-base entries.
|
||||
|
||||
```yaml
|
||||
---
|
||||
name: incident-response-helper
|
||||
description: Helps analysts draft incident response steps.
|
||||
x-astron-compliance:
|
||||
- standard: mitre-attack
|
||||
version: "v19.1"
|
||||
controlId: T1059
|
||||
title: Command and Scripting Interpreter
|
||||
evidence:
|
||||
- type: packaged-file
|
||||
path: references/mitre-t1059.md
|
||||
- type: external-url
|
||||
url: https://attack.mitre.org/techniques/T1059/
|
||||
---
|
||||
```
|
||||
|
||||
Important boundaries:
|
||||
|
||||
- This is an author declaration, not a SkillHub endorsement or third-party certification.
|
||||
- SkillHub validates the structure, duplicate mappings, packaged evidence paths, and external URL format.
|
||||
- After publishing, the declaration is normalized into the version-level `complianceSnapshot` with a stable `digest`.
|
||||
- Review pages show the diff when a later version adds, removes, or changes compliance declarations.
|
||||
- Searching for `mitre-attack`, `T1059`, or the declaration title can discover the matching skill.
|
||||
|
||||
Field reference:
|
||||
|
||||
| Field | Required | Description |
|
||||
|-------|----------|-------------|
|
||||
| `standard` | Yes | Standard or framework identifier, such as `mitre-attack`, `nist-csf`, or `soc2` |
|
||||
| `version` | Yes | Standard version |
|
||||
| `controlId` | Yes | Control, technique, or clause ID |
|
||||
| `title` | No | Human-readable control title; recommended for review and search |
|
||||
| `evidence` | No | Evidence list, supporting packaged files and external URLs |
|
||||
|
||||
## API Reference
|
||||
|
||||
**Publish Skill Package**:
|
||||
|
|
|
|||
|
|
@ -71,7 +71,6 @@ SkillHub 提供了完整的审核工作流,确保发布到注册中心的技
|
|||
- 浏览文件列表
|
||||
- 在线查看文件内容
|
||||
- 下载完整包进行本地测试
|
||||
- 查看合规声明快照和相对上一发布版本的差异
|
||||
|
||||

|
||||
|
||||
|
|
@ -81,24 +80,6 @@ SkillHub 提供了完整的审核工作流,确保发布到注册中心的技
|
|||
|
||||
5. 添加审核意见(可选)
|
||||
|
||||
**审核合规声明**:
|
||||
|
||||
如果待审核版本包含 `x-astron-compliance`,审核详情会展示版本级合规快照和差异摘要:
|
||||
|
||||
- 新增声明:待审版本新增了标准、控制项或证据。
|
||||
- 删除声明:待审版本移除了上一发布版本已有的声明。
|
||||
- 修改声明:标准、控制项标题或证据发生变化。
|
||||
- 摘要变化:`complianceSnapshot.digest` 变化,表示规范化后的声明内容发生变化。
|
||||
|
||||
审核建议:
|
||||
|
||||
1. 确认声明是否与技能实际能力相关。例如安全响应技能声明 MITRE ATT&CK 技术编号时,应能在说明或证据文件中看到对应依据。
|
||||
2. 点击差异项查看证据路径、外部链接和摘要,不只看声明标题。
|
||||
3. 对删除或大范围修改的声明提高审核优先级,因为这会影响下游审计系统引用。
|
||||
4. 如果证据缺失、路径不可访问、声明明显不匹配技能能力,建议拒绝并要求作者修正。
|
||||
|
||||
SkillHub 能保证的是结构正确、证据可追溯、版本快照不可变;不能替作者保证“真的合规”。
|
||||
|
||||
**撤回审核**:
|
||||
|
||||
开发者发现问题,可以在审核通过前撤回提交:
|
||||
|
|
@ -200,7 +181,6 @@ Content-Type: application/json
|
|||
|
||||
- **审核时效**:建议在 24 小时内完成审核,避免阻塞开发者
|
||||
- **审核记录**:所有审核操作都会记录到审计日志
|
||||
- **合规审计**:合规声明以版本快照形式记录。审核通过或拒绝时,应结合差异摘要判断风险,但 Agent 执行 trace 不由 SkillHub 记录
|
||||
- **批量审核**:管理员可以批量批准多个技能包
|
||||
- **审核意见**:拒绝时建议提供详细的改进建议
|
||||
- **撤回限制**:只有待审核状态的技能包可以撤回
|
||||
|
|
|
|||
|
|
@ -1,101 +0,0 @@
|
|||
# Runtime 集成契约
|
||||
|
||||
## 职责边界
|
||||
|
||||
SkillHub 和 Agent Runtime 的职责分开:
|
||||
|
||||
| 系统 | 权威负责内容 |
|
||||
|------|--------------|
|
||||
| SkillHub | 技能包、版本、元数据、合规声明快照、下载与审核记录 |
|
||||
| Agent Runtime | 技能实际执行、输入输出、模型调用、工具调用、执行 trace |
|
||||
|
||||
SkillHub 不执行技能,因此不记录 Runtime trace,也不判断一次真实执行是否合规。SkillHub 提供的是版本级事实:某个不可变技能版本在发布时包含了什么合规声明,以及该声明快照的稳定摘要。
|
||||
|
||||
## Runtime 应记录什么
|
||||
|
||||
Runtime 在执行技能时,如果需要把执行链路与 SkillHub 的合规声明关联起来,建议记录以下字段:
|
||||
|
||||
| 字段 | 来源 | 说明 |
|
||||
|------|------|------|
|
||||
| `registryUrl` | Runtime 配置 | 使用的 SkillHub 注册中心地址 |
|
||||
| `namespace` | SkillHub 坐标 | 技能命名空间,例如 `global` 或团队 slug |
|
||||
| `skillSlug` | SkillHub 坐标 | 技能 slug |
|
||||
| `requestedVersion` | Runtime 请求 | 用户请求的版本、标签或版本范围 |
|
||||
| `resolvedVersion` | SkillHub 响应 | 实际解析到的版本号 |
|
||||
| `skillVersionId` | SkillHub 响应里的版本 `id` | 不可变版本 ID,审计关联的主键 |
|
||||
| `complianceSnapshotDigest` | `complianceSnapshot.digest` | 该版本合规声明快照的稳定摘要 |
|
||||
| `packageDigest` | 下载或安装流程 | 技能包内容摘要,便于确认执行内容 |
|
||||
| `runtimeTraceId` | Runtime | Runtime 自己生成的执行链路 ID |
|
||||
|
||||
如果 Runtime 使用 Astron 自有 trace schema,可以把这些字段映射成 `x-astron-*` 键;这属于 Runtime 的 trace 约定,不是 SkillHub 服务端必须写入或解析的字段。
|
||||
|
||||
## 获取版本级合规快照
|
||||
|
||||
第一阶段不提供独立的 compliance API。Runtime 可以通过既有版本详情接口读取版本 ID 和快照:
|
||||
|
||||
```bash
|
||||
GET /api/v1/skills/{namespace}/{slug}/versions/{version}
|
||||
```
|
||||
|
||||
响应中的关键字段:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 123,
|
||||
"version": "1.2.0",
|
||||
"complianceSnapshot": {
|
||||
"schemaVersion": "1.0",
|
||||
"digest": "sha256:8d8c...",
|
||||
"items": [
|
||||
{
|
||||
"standard": "mitre-attack",
|
||||
"version": "v19.1",
|
||||
"controlId": "T1059",
|
||||
"title": "Command and Scripting Interpreter",
|
||||
"evidence": [
|
||||
{
|
||||
"type": "packaged-file",
|
||||
"path": "references/mitre-t1059.md",
|
||||
"sha256": "sha256:..."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Runtime 应把 `id` 和 `complianceSnapshot.digest` 一起写入执行 trace。只记录 digest 不够,因为不同注册中心或未来迁移场景下需要版本 ID 来定位完整快照。
|
||||
|
||||
## 推荐执行链路
|
||||
|
||||
1. Runtime 根据用户请求解析技能坐标和版本。
|
||||
2. Runtime 从 SkillHub 获取精确版本详情。
|
||||
3. Runtime 下载并校验技能包。
|
||||
4. Runtime 执行技能。
|
||||
5. Runtime 在自己的 trace 中记录:
|
||||
- SkillHub 注册中心;
|
||||
- 技能坐标;
|
||||
- 实际版本号;
|
||||
- `skillVersionId`;
|
||||
- `complianceSnapshotDigest`;
|
||||
- Runtime 自己的执行证据。
|
||||
|
||||
这样审计系统可以先通过 Runtime trace 找到实际执行,再回到 SkillHub 查询该版本发布时的合规声明快照。
|
||||
|
||||
## 不建议的做法
|
||||
|
||||
- 不要把 `x-astron-compliance` 原文复制到 trace 后再由 Runtime 修改。
|
||||
- 不要只记录技能 slug,不记录版本 ID;slug 指向的是技能容器,不是不可变版本。
|
||||
- 不要把 SkillHub 的合规声明当成第三方认证结果。
|
||||
- 不要要求 SkillHub 记录模型输入输出;这是 Runtime 的审计边界。
|
||||
|
||||
## 未来可能新增的 API
|
||||
|
||||
如果出现明确使用方,例如 Runtime 只需要合规快照而不需要完整技能详情,可以新增不可变版本维度的接口:
|
||||
|
||||
```text
|
||||
GET /api/skill-versions/{skillVersionId}/compliance
|
||||
```
|
||||
|
||||
当前阶段先复用版本详情响应,避免为尚未稳定的调用方提前设计多套 API。
|
||||
|
|
@ -95,45 +95,6 @@ visibility: PUBLIC
|
|||
|
||||
技能包可以通过搜索发现,其他人可以通过 CLI 或 Web UI 下载使用。
|
||||
|
||||
## 合规声明
|
||||
|
||||
技能作者可以在 `SKILL.md` frontmatter 中添加 `x-astron-compliance`,声明该技能版本与某些合规标准、控制项或安全知识库条目的映射关系。
|
||||
|
||||
```yaml
|
||||
---
|
||||
name: incident-response-helper
|
||||
description: Helps analysts draft incident response steps.
|
||||
x-astron-compliance:
|
||||
- standard: mitre-attack
|
||||
version: "v19.1"
|
||||
controlId: T1059
|
||||
title: Command and Scripting Interpreter
|
||||
evidence:
|
||||
- type: packaged-file
|
||||
path: references/mitre-t1059.md
|
||||
- type: external-url
|
||||
url: https://attack.mitre.org/techniques/T1059/
|
||||
---
|
||||
```
|
||||
|
||||
需要注意:
|
||||
|
||||
- 这是“作者声明”,不是 SkillHub 或第三方机构的合规认证。
|
||||
- SkillHub 会校验字段结构、重复项、包内证据路径和外部 URL 格式。
|
||||
- 发布成功后,声明会被固化为当前版本的 `complianceSnapshot`,并生成稳定摘要 `digest`。
|
||||
- 后续版本如果新增、删除或修改合规声明,审核页会展示差异。
|
||||
- 搜索 `mitre-attack`、`T1059` 或声明标题时,可以命中对应技能。
|
||||
|
||||
字段说明:
|
||||
|
||||
| 字段 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `standard` | 是 | 标准或框架标识,例如 `mitre-attack`、`nist-csf`、`soc2` |
|
||||
| `version` | 是 | 标准版本 |
|
||||
| `controlId` | 是 | 控制项、技术编号或条款 ID |
|
||||
| `title` | 否 | 控制项名称,建议填写,便于审核和搜索 |
|
||||
| `evidence` | 否 | 证据列表,支持包内文件和外部 URL |
|
||||
|
||||
## API 接口
|
||||
|
||||
**发布技能包**:
|
||||
|
|
|
|||
14
docs/skillhub/package-lock.json
generated
14
docs/skillhub/package-lock.json
generated
|
|
@ -2065,9 +2065,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.18",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
|
||||
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
|
||||
"version": "3.3.12",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
|
||||
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
|
|
@ -2123,9 +2123,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.26",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
|
||||
"integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
|
||||
"version": "8.5.15",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
||||
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
|
|
@ -2143,7 +2143,7 @@
|
|||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.17",
|
||||
"nanoid": "^3.3.12",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
},
|
||||
"overrides": {
|
||||
"vite": "^6.4.3",
|
||||
"postcss": "^8.5.23",
|
||||
"postcss": "^8.5.10",
|
||||
"esbuild": "^0.28.1"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,17 +34,6 @@ description: 常见问题解答
|
|||
3. 是否包含必需的 SKILL.md
|
||||
4. SKILL.md frontmatter 格式是否正确
|
||||
|
||||
### 使用 CLI 安装技能时报 `namespace not found`?
|
||||
|
||||
多数情况是 CLI 没有指向你自己的 SkillHub 实例,或命名空间格式不对:
|
||||
|
||||
1. **配置 registry 并登录**:用环境变量或 `--registry` 指向你的实例,例如
|
||||
`clawhub --registry https://skillhub.your-company.com install <skill>`;登录需要先在 Web 控制台生成 API Token。
|
||||
2. **命名空间 slug 格式**:全局命名空间的技能直接用名字(如 `my-skill`);团队命名空间要用 `team--skill` 的形式(`@team/skill` → `team--skill`)。
|
||||
3. 最稳妥的方式是直接在 SkillHub Web 界面点技能的「安装」按钮,复制其中已经带好正确 registry 与命名空间的命令。
|
||||
|
||||
> SkillHub 同时提供 `clawhub` 和 `skillhub` 两种 CLI,用法见各自 README;通过 OpenClaw 对话安装技能时,底层同样调用 CLI。
|
||||
|
||||
## 开发相关
|
||||
|
||||
### 如何扩展 OAuth Provider?
|
||||
|
|
|
|||
|
|
@ -22,17 +22,6 @@ description: 常见问题诊断和解决方案
|
|||
- Redis 连接失败
|
||||
- 环境变量缺失
|
||||
|
||||
### PostgreSQL 容器启动报 `operation not permitted`(写 `postmaster.pid` / `pg_wal` 失败)
|
||||
|
||||
SkillHub 默认的 Compose / `runtime.sh` 使用 Docker named volume(`postgres_data`),通常不需要手工处理宿主机目录权限。这个错误更多出现在你把 PostgreSQL 数据目录改成宿主机 bind mount 时,例如 `/data/skillhub/postgres:/var/lib/postgresql/data`。
|
||||
|
||||
排查顺序:
|
||||
|
||||
1. 优先恢复为 Docker named volume,或直接使用官方 `runtime.sh` 部署脚本,避免手写 compose 时漏配权限。
|
||||
2. 如果必须使用 bind mount,先确认当前镜像中的 `postgres` 用户 UID/GID:`docker run --rm postgres:16-alpine id postgres`,再按实际 UID/GID 调整数据目录属主,例如 `chown -R <uid>:<gid> <数据目录>`。不要固定假设所有环境都是 `999:999`。
|
||||
3. 在 RHEL/CentOS 上检查 SELinux;在启用 AppArmor、rootless Docker、NFS/CIFS/NAS 等环境时,也要确认宿主文件系统是否允许 PostgreSQL 需要的写入、锁和权限变更。
|
||||
4. 不建议把 PostgreSQL `PGDATA` 放在不支持完整 POSIX 权限语义的网络文件系统上;生产环境优先使用本地盘、Docker named volume、块存储或外部 PostgreSQL。
|
||||
|
||||
## 上传失败
|
||||
|
||||
### 技能包上传失败
|
||||
|
|
|
|||
|
|
@ -34,16 +34,6 @@ Check:
|
|||
3. Whether required SKILL.md is included
|
||||
4. Whether SKILL.md frontmatter format is correct
|
||||
|
||||
### CLI install reports `namespace not found`?
|
||||
|
||||
Usually the CLI is not pointing at your own SkillHub instance, or the namespace format is wrong:
|
||||
|
||||
1. **Set the registry and log in**: point at your instance via an environment variable or `--registry`, e.g. `clawhub --registry https://skillhub.your-company.com install <skill>`. Logging in requires an API Token generated in the web console first.
|
||||
2. **Namespace slug format**: skills in the global namespace use the bare name (e.g. `my-skill`); team namespaces use the `team--skill` form (`@team/skill` → `team--skill`).
|
||||
3. The most reliable way is to click the **Install** button on the skill's page in the SkillHub web UI and copy the command, which already includes the correct registry and namespace.
|
||||
|
||||
> SkillHub ships both a `clawhub` and a `skillhub` CLI (see their respective READMEs); installing a skill through an OpenClaw conversation calls the CLI under the hood as well.
|
||||
|
||||
## Development Related
|
||||
|
||||
### How to extend OAuth Provider?
|
||||
|
|
|
|||
|
|
@ -22,17 +22,6 @@ description: Common problem diagnosis and solutions
|
|||
- Redis connection failed
|
||||
- Environment variables missing
|
||||
|
||||
### PostgreSQL container fails to start with `operation not permitted` (cannot write `postmaster.pid` / `pg_wal`)
|
||||
|
||||
SkillHub's default Compose / `runtime.sh` deployment uses a Docker named volume (`postgres_data`), so you normally do not need to manage host directory permissions manually. This error is more common after changing PostgreSQL storage to a host bind mount, for example `/data/skillhub/postgres:/var/lib/postgresql/data`.
|
||||
|
||||
Recommended checks:
|
||||
|
||||
1. Prefer switching back to a Docker named volume, or use the official `runtime.sh` deployment script to avoid permission gaps from hand-written compose files.
|
||||
2. If you must use a bind mount, first check the `postgres` UID/GID in the image you run: `docker run --rm postgres:16-alpine id postgres`. Then change the data directory owner to the actual UID/GID, for example `chown -R <uid>:<gid> <data-dir>`. Do not assume every environment is `999:999`.
|
||||
3. On RHEL/CentOS, check SELinux. If AppArmor, rootless Docker, NFS/CIFS/NAS, or another restricted filesystem is involved, also verify that PostgreSQL can write, lock files, and change permissions as required.
|
||||
4. Avoid putting PostgreSQL `PGDATA` on network filesystems that do not provide full POSIX permission semantics. For production, prefer local disks, Docker named volumes, block storage, or an external PostgreSQL service.
|
||||
|
||||
## Upload Failed
|
||||
|
||||
### Skill Package Upload Failed
|
||||
|
|
|
|||
|
|
@ -1,85 +0,0 @@
|
|||
# SkillHub Python Examples
|
||||
|
||||
A minimal, dependency-light (`requests`-only) Python client and runnable
|
||||
examples for the SkillHub REST API. Use it to **search, inspect, download,
|
||||
and publish** skills from Python — the same operations the ClawHub CLI
|
||||
performs, without shelling out to the CLI.
|
||||
|
||||
> These are reference examples, not (yet) an officially published pip
|
||||
> package. See [iflytek/skillhub#701](https://github.com/iflytek/skillhub/issues/701)
|
||||
> for the discussion on whether to ship a full published SDK.
|
||||
|
||||
## Files
|
||||
|
||||
| File | What it is |
|
||||
|------|------------|
|
||||
| [`skillhub_client.py`](./skillhub_client.py) | A small `SkillHubClient` class wrapping the REST API |
|
||||
| [`example_usage.py`](./example_usage.py) | Runnable script: search → resolve → download, and publish |
|
||||
| [`requirements.txt`](./requirements.txt) | The only dependency: `requests` |
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Point at your SkillHub instance
|
||||
export SKILLHUB_URL=https://skill.example.com
|
||||
# Only needed for write operations (publish / star / rate)
|
||||
export SKILLHUB_TOKEN=<your-api-token>
|
||||
```
|
||||
|
||||
Generate an API token from the SkillHub web UI (**Settings → API Tokens**) or
|
||||
via `POST /api/v1/tokens`.
|
||||
|
||||
## Quick start
|
||||
|
||||
```python
|
||||
from skillhub_client import SkillHubClient
|
||||
|
||||
client = SkillHubClient() # reads SKILLHUB_URL / SKILLHUB_TOKEN from env
|
||||
|
||||
# Search public skills
|
||||
results = client.search(keyword="email", size=5)
|
||||
|
||||
# Inspect and resolve a version
|
||||
detail = client.get_skill("my-namespace", "my-skill")
|
||||
resolved = client.resolve("my-namespace", "my-skill", tag="stable")
|
||||
|
||||
# Download the latest package (returns the written file path)
|
||||
path = client.download("my-namespace", "my-skill")
|
||||
|
||||
# Publish a skill package (requires a token)
|
||||
client.publish("./my-skill.zip", namespace="my-namespace")
|
||||
```
|
||||
|
||||
Or run the end-to-end script:
|
||||
|
||||
```bash
|
||||
python example_usage.py # search + inspect + download
|
||||
python example_usage.py publish ./my-skill.zip my-namespace
|
||||
```
|
||||
|
||||
## Supported operations
|
||||
|
||||
| Method | Endpoint | Auth |
|
||||
|--------|----------|------|
|
||||
| `search(keyword, namespace, page, size)` | `GET /api/v1/skills` | — |
|
||||
| `get_skill(namespace, slug)` | `GET /api/v1/skills/{ns}/{slug}` | — |
|
||||
| `list_versions(namespace, slug)` | `GET /api/v1/skills/{ns}/{slug}/versions` | — |
|
||||
| `resolve(namespace, slug, version, tag)` | `GET /api/v1/skills/{ns}/{slug}/resolve` | — |
|
||||
| `download(namespace, slug, version, dest)` | `GET /api/v1/skills/{ns}/{slug}[/versions/{v}]/download` | — |
|
||||
| `whoami()` | `GET /api/v1/whoami` | Bearer |
|
||||
| `publish(zip_path, namespace, request_id)` | `POST /api/v1/publish` | Bearer |
|
||||
| `star(namespace, slug)` | `POST /api/v1/skills/{ns}/{slug}/star` | Bearer |
|
||||
| `rate(namespace, slug, score)` | `POST /api/v1/skills/{ns}/{slug}/rating` | Bearer |
|
||||
|
||||
The client unwraps the unified `{code, msg, data}` response envelope
|
||||
automatically and raises `SkillHubError` on a non-zero business code.
|
||||
|
||||
## Notes
|
||||
|
||||
- Write operations accept an optional `request_id` (a UUID) that is sent as
|
||||
the `X-Request-Id` header for idempotency.
|
||||
- For the full API surface (namespaces, reviews, promotion, tags), see the
|
||||
[Developer Docs → API](https://iflytek.github.io/skillhub/) and
|
||||
[`document/docs/04-developer/api`](../../document/docs/04-developer/api).
|
||||
|
|
@ -1,90 +0,0 @@
|
|||
"""Runnable examples for the SkillHub Python client.
|
||||
|
||||
Configure the target registry via environment variables:
|
||||
|
||||
export SKILLHUB_URL=https://skill.example.com
|
||||
export SKILLHUB_TOKEN=<your-api-token> # only needed for write operations
|
||||
|
||||
Then run:
|
||||
|
||||
python example_usage.py # search + inspect + download
|
||||
python example_usage.py publish ./my-skill.zip my-namespace
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
from skillhub_client import SkillHubClient, SkillHubError
|
||||
|
||||
|
||||
def _pick(obj, *keys, default=None):
|
||||
"""Best-effort field access across slightly different response shapes."""
|
||||
for key in keys:
|
||||
if isinstance(obj, dict) and obj.get(key) is not None:
|
||||
return obj[key]
|
||||
return default
|
||||
|
||||
|
||||
def demo_read(client: SkillHubClient) -> None:
|
||||
print(f"Searching {client.base_url} for skills matching 'email'...\n")
|
||||
result = client.search(keyword="email", size=5)
|
||||
|
||||
# The search payload may expose the list under 'items' or 'results'.
|
||||
items = _pick(result, "items", "results", default=result if isinstance(result, list) else [])
|
||||
if not items:
|
||||
print("No skills found. Try a different keyword or registry.")
|
||||
return
|
||||
|
||||
for skill in items:
|
||||
name = _pick(skill, "name", "slug", default="(unnamed)")
|
||||
ns = _pick(skill, "namespace", default="")
|
||||
version = _pick(skill, "version", "latestVersion", default="?")
|
||||
downloads = _pick(skill, "downloadCount", "downloads", default=0)
|
||||
coord = f"{ns}/{name}" if ns else name
|
||||
print(f" - {coord} v{version} ({downloads} downloads)")
|
||||
|
||||
# Download the first result's latest package.
|
||||
first = items[0]
|
||||
ns = _pick(first, "namespace", default="")
|
||||
slug = _pick(first, "slug", "name")
|
||||
if ns and slug:
|
||||
print(f"\nResolving latest version of {ns}/{slug}...")
|
||||
resolved = client.resolve(ns, slug)
|
||||
version = _pick(resolved, "version", default=None)
|
||||
print(f" resolved version: {version}")
|
||||
|
||||
dest = client.download(ns, slug, version=version)
|
||||
size = os.path.getsize(dest)
|
||||
print(f" downloaded -> {dest} ({size} bytes)")
|
||||
|
||||
|
||||
def demo_publish(client: SkillHubClient, zip_path: str, namespace: str) -> None:
|
||||
if not client.token:
|
||||
sys.exit("Publishing requires SKILLHUB_TOKEN to be set.")
|
||||
print(f"Publishing {zip_path} to namespace '{namespace}'...")
|
||||
result = client.publish(zip_path, namespace)
|
||||
print(f" published: {result}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
try:
|
||||
client = SkillHubClient()
|
||||
except ValueError as exc:
|
||||
sys.exit(str(exc))
|
||||
|
||||
args = sys.argv[1:]
|
||||
try:
|
||||
if args and args[0] == "publish":
|
||||
if len(args) != 3:
|
||||
sys.exit("usage: python example_usage.py publish <zip_path> <namespace>")
|
||||
demo_publish(client, args[1], args[2])
|
||||
else:
|
||||
demo_read(client)
|
||||
except SkillHubError as exc:
|
||||
sys.exit(f"API error: {exc}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -1 +0,0 @@
|
|||
requests>=2.25
|
||||
|
|
@ -1,244 +0,0 @@
|
|||
"""A minimal Python client for the SkillHub REST API.
|
||||
|
||||
This is a dependency-light reference client (only ``requests``) that mirrors
|
||||
the operations the ClawHub CLI performs: search, inspect, resolve, download
|
||||
and publish skills. It is meant as a copy-pasteable starting point for Python
|
||||
integrations, not (yet) an officially published package.
|
||||
|
||||
API reference: https://iflytek.github.io/skillhub/ (Developer Docs -> API)
|
||||
|
||||
Endpoints used (see docs/04-developer/api):
|
||||
Public (no auth):
|
||||
GET /api/v1/skills?keyword=&namespace=&page=&size=
|
||||
GET /api/v1/skills/{namespace}/{slug}
|
||||
GET /api/v1/skills/{namespace}/{slug}/versions
|
||||
GET /api/v1/skills/{namespace}/{slug}/resolve?version=&tag=
|
||||
GET /api/v1/skills/{namespace}/{slug}/download
|
||||
GET /api/v1/skills/{namespace}/{slug}/versions/{version}/download
|
||||
Authenticated (Bearer token):
|
||||
GET /api/v1/whoami
|
||||
POST /api/v1/publish (multipart: file, namespace)
|
||||
POST /api/v1/skills/{namespace}/{slug}/star
|
||||
POST /api/v1/skills/{namespace}/{slug}/rating (json: {"score": 1-5})
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
class SkillHubError(RuntimeError):
|
||||
"""Raised when the API returns a non-zero business code."""
|
||||
|
||||
def __init__(self, code: Any, message: str, request_id: Optional[str] = None):
|
||||
self.code = code
|
||||
self.request_id = request_id
|
||||
super().__init__(f"SkillHub API error {code}: {message}"
|
||||
+ (f" (requestId={request_id})" if request_id else ""))
|
||||
|
||||
|
||||
class SkillHubClient:
|
||||
"""Thin wrapper over the SkillHub REST API.
|
||||
|
||||
Args:
|
||||
base_url: Registry base URL, e.g. ``https://skill.example.com``.
|
||||
token: Optional API token for authenticated calls (Bearer).
|
||||
timeout: Per-request timeout in seconds.
|
||||
session: Optional pre-configured ``requests.Session``.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: Optional[str] = None,
|
||||
token: Optional[str] = None,
|
||||
timeout: int = 30,
|
||||
session: Optional[requests.Session] = None,
|
||||
):
|
||||
base_url = base_url or os.environ.get("SKILLHUB_URL")
|
||||
if not base_url:
|
||||
raise ValueError(
|
||||
"base_url is required (pass it explicitly or set SKILLHUB_URL)"
|
||||
)
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.token = token or os.environ.get("SKILLHUB_TOKEN")
|
||||
self.timeout = timeout
|
||||
self.session = session or requests.Session()
|
||||
|
||||
# -- internals -------------------------------------------------------
|
||||
|
||||
def _headers(self, extra: Optional[Dict[str, str]] = None) -> Dict[str, str]:
|
||||
headers: Dict[str, str] = {}
|
||||
if self.token:
|
||||
headers["Authorization"] = f"Bearer {self.token}"
|
||||
if extra:
|
||||
headers.update(extra)
|
||||
return headers
|
||||
|
||||
def _url(self, path: str) -> str:
|
||||
return f"{self.base_url}{path}"
|
||||
|
||||
def _unwrap(self, resp: requests.Response) -> Any:
|
||||
"""Return the payload, unwrapping the ``{code,msg,data}`` envelope.
|
||||
|
||||
Native ``/api/v1`` endpoints wrap responses in a unified envelope,
|
||||
while the CLI-compat endpoints return the object directly. This
|
||||
handles both.
|
||||
"""
|
||||
resp.raise_for_status()
|
||||
payload = resp.json()
|
||||
if isinstance(payload, dict) and "code" in payload and "data" in payload:
|
||||
if payload.get("code") not in (0, None):
|
||||
raise SkillHubError(
|
||||
payload.get("code"), payload.get("msg", ""), payload.get("requestId")
|
||||
)
|
||||
return payload["data"]
|
||||
return payload
|
||||
|
||||
# -- public API ------------------------------------------------------
|
||||
|
||||
def search(
|
||||
self,
|
||||
keyword: Optional[str] = None,
|
||||
namespace: Optional[str] = None,
|
||||
page: int = 1,
|
||||
size: int = 20,
|
||||
) -> Any:
|
||||
"""Search public skills."""
|
||||
params = {"keyword": keyword, "namespace": namespace, "page": page, "size": size}
|
||||
params = {k: v for k, v in params.items() if v is not None}
|
||||
return self._unwrap(
|
||||
self.session.get(
|
||||
self._url("/api/v1/skills"),
|
||||
params=params,
|
||||
headers=self._headers(),
|
||||
timeout=self.timeout,
|
||||
)
|
||||
)
|
||||
|
||||
def get_skill(self, namespace: str, slug: str) -> Any:
|
||||
"""Fetch a single skill's detail."""
|
||||
return self._unwrap(
|
||||
self.session.get(
|
||||
self._url(f"/api/v1/skills/{namespace}/{slug}"),
|
||||
headers=self._headers(),
|
||||
timeout=self.timeout,
|
||||
)
|
||||
)
|
||||
|
||||
def list_versions(self, namespace: str, slug: str) -> Any:
|
||||
"""List all versions of a skill."""
|
||||
return self._unwrap(
|
||||
self.session.get(
|
||||
self._url(f"/api/v1/skills/{namespace}/{slug}/versions"),
|
||||
headers=self._headers(),
|
||||
timeout=self.timeout,
|
||||
)
|
||||
)
|
||||
|
||||
def resolve(
|
||||
self,
|
||||
namespace: str,
|
||||
slug: str,
|
||||
version: Optional[str] = None,
|
||||
tag: Optional[str] = None,
|
||||
) -> Any:
|
||||
"""Resolve a version constraint / tag to a concrete version."""
|
||||
params = {"version": version, "tag": tag}
|
||||
params = {k: v for k, v in params.items() if v is not None}
|
||||
return self._unwrap(
|
||||
self.session.get(
|
||||
self._url(f"/api/v1/skills/{namespace}/{slug}/resolve"),
|
||||
params=params,
|
||||
headers=self._headers(),
|
||||
timeout=self.timeout,
|
||||
)
|
||||
)
|
||||
|
||||
def download(
|
||||
self,
|
||||
namespace: str,
|
||||
slug: str,
|
||||
version: Optional[str] = None,
|
||||
dest: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Download a skill package (zip). Returns the written file path.
|
||||
|
||||
If ``version`` is omitted the ``latest`` package is downloaded. If
|
||||
``dest`` is omitted a file named ``{slug}-{version}.zip`` (or
|
||||
``{slug}.zip``) is written to the current directory.
|
||||
"""
|
||||
if version:
|
||||
path = f"/api/v1/skills/{namespace}/{slug}/versions/{version}/download"
|
||||
else:
|
||||
path = f"/api/v1/skills/{namespace}/{slug}/download"
|
||||
if dest is None:
|
||||
dest = f"{slug}-{version}.zip" if version else f"{slug}.zip"
|
||||
with self.session.get(
|
||||
self._url(path), headers=self._headers(), timeout=self.timeout, stream=True
|
||||
) as resp:
|
||||
resp.raise_for_status()
|
||||
with open(dest, "wb") as fh:
|
||||
for chunk in resp.iter_content(chunk_size=8192):
|
||||
if chunk:
|
||||
fh.write(chunk)
|
||||
return dest
|
||||
|
||||
# -- authenticated API ----------------------------------------------
|
||||
|
||||
def whoami(self) -> Any:
|
||||
"""Return the authenticated principal (requires a token)."""
|
||||
return self._unwrap(
|
||||
self.session.get(
|
||||
self._url("/api/v1/whoami"),
|
||||
headers=self._headers(),
|
||||
timeout=self.timeout,
|
||||
)
|
||||
)
|
||||
|
||||
def publish(
|
||||
self, zip_path: str, namespace: str, request_id: Optional[str] = None
|
||||
) -> Any:
|
||||
"""Publish a skill package (zip) to a namespace. Requires a token.
|
||||
|
||||
Pass ``request_id`` (a UUID) to make the publish idempotent via the
|
||||
``X-Request-Id`` header.
|
||||
"""
|
||||
extra = {"X-Request-Id": request_id} if request_id else None
|
||||
with open(zip_path, "rb") as fh:
|
||||
files = {"file": (os.path.basename(zip_path), fh, "application/zip")}
|
||||
data = {"namespace": namespace}
|
||||
return self._unwrap(
|
||||
self.session.post(
|
||||
self._url("/api/v1/publish"),
|
||||
files=files,
|
||||
data=data,
|
||||
headers=self._headers(extra),
|
||||
timeout=self.timeout,
|
||||
)
|
||||
)
|
||||
|
||||
def star(self, namespace: str, slug: str) -> Any:
|
||||
"""Star a skill. Requires a token."""
|
||||
return self._unwrap(
|
||||
self.session.post(
|
||||
self._url(f"/api/v1/skills/{namespace}/{slug}/star"),
|
||||
headers=self._headers(),
|
||||
timeout=self.timeout,
|
||||
)
|
||||
)
|
||||
|
||||
def rate(self, namespace: str, slug: str, score: int) -> Any:
|
||||
"""Rate a skill from 1 to 5. Requires a token."""
|
||||
if not 1 <= score <= 5:
|
||||
raise ValueError("score must be between 1 and 5")
|
||||
return self._unwrap(
|
||||
self.session.post(
|
||||
self._url(f"/api/v1/skills/{namespace}/{slug}/rating"),
|
||||
json={"score": score},
|
||||
headers=self._headers(),
|
||||
timeout=self.timeout,
|
||||
)
|
||||
)
|
||||
|
|
@ -118,13 +118,9 @@ done
|
|||
|
||||
if [ "$USE_ALIYUN" = "true" ]; then
|
||||
SKILLHUB_RAW_BASE="${SKILLHUB_RAW_BASE:-https://imageless.oss-cn-beijing.aliyuncs.com}"
|
||||
RUNTIME_SCRIPT_URL="$SKILLHUB_RAW_BASE/runtime.sh"
|
||||
RUNTIME_SOURCE_ARG=" --aliyun"
|
||||
echo "Using Aliyun OSS for runtime files: $SKILLHUB_RAW_BASE"
|
||||
else
|
||||
SKILLHUB_RAW_BASE="${SKILLHUB_RAW_BASE:-https://raw.githubusercontent.com/iflytek/skillhub/$SKILLHUB_REF}"
|
||||
RUNTIME_SCRIPT_URL="$SKILLHUB_RAW_BASE/scripts/runtime.sh"
|
||||
RUNTIME_SOURCE_ARG=""
|
||||
echo "Using GitHub raw for runtime files: $SKILLHUB_RAW_BASE"
|
||||
fi
|
||||
COMPOSE_FILE="$SKILLHUB_HOME/compose.release.yml"
|
||||
|
|
@ -400,7 +396,7 @@ Web UI: $PUBLIC_URL
|
|||
Backend API: http://localhost:8080
|
||||
Runtime dir: $SKILLHUB_HOME
|
||||
Stop with:
|
||||
curl -fsSL $RUNTIME_SCRIPT_URL | sh -s -- down$RUNTIME_SOURCE_ARG$HOME_ARG
|
||||
curl -fsSL $SKILLHUB_RAW_BASE/scripts/runtime.sh | sh -s -- down$HOME_ARG
|
||||
EOF
|
||||
;;
|
||||
down)
|
||||
|
|
|
|||
|
|
@ -2,18 +2,16 @@
|
|||
set -euo pipefail
|
||||
|
||||
BASE_URL="${1:-http://localhost:8080}"
|
||||
ACTUATOR_BASE_URL="${ACTUATOR_BASE_URL:-$BASE_URL}"
|
||||
PASS=0
|
||||
FAIL=0
|
||||
TMP_DIR="$(mktemp -d)"
|
||||
COOKIE_JAR="$TMP_DIR/cookies"
|
||||
COOKIE_JAR="$(mktemp)"
|
||||
USERNAME="smoketest_$(date +%s)"
|
||||
EMAIL="${USERNAME}@example.com"
|
||||
PASSWORD="Smoke@2026"
|
||||
NEW_PASSWORD="Smoke@2027"
|
||||
|
||||
cleanup() {
|
||||
rm -rf "$TMP_DIR"
|
||||
rm -f "$COOKIE_JAR"
|
||||
}
|
||||
|
||||
trap cleanup EXIT
|
||||
|
|
@ -33,56 +31,6 @@ check() {
|
|||
fi
|
||||
}
|
||||
|
||||
check_health() {
|
||||
local desc="$1"
|
||||
local url="$2"
|
||||
local body_file="$TMP_DIR/health-body"
|
||||
local result
|
||||
local status
|
||||
local content_type
|
||||
result="$(curl --retry 3 --retry-delay 1 --max-time 10 -sS -o "$body_file" \
|
||||
-w "%{http_code}|%{content_type}" "$url" || true)"
|
||||
status="${result%%|*}"
|
||||
content_type="${result#*|}"
|
||||
|
||||
if [[ "$content_type" == text/html* ]]; then
|
||||
echo "FAIL: $desc (routing/target error: received $content_type from $url)"
|
||||
FAIL=$((FAIL + 1))
|
||||
elif [[ "$status" == "200" \
|
||||
&& ( "$content_type" == application/json* || "$content_type" == application/*+json* ) \
|
||||
&& -f "$body_file" \
|
||||
&& "$(grep -Ec '"status"[[:space:]]*:' "$body_file" || true)" -gt 0 ]]; then
|
||||
echo "PASS: $desc (HTTP $status, $content_type)"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo "FAIL: $desc (expected HTTP 200 actuator JSON, got HTTP $status, ${content_type:-no content type})"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
check_protected_actuator() {
|
||||
local desc="$1"
|
||||
local url="$2"
|
||||
local result
|
||||
local status
|
||||
local content_type
|
||||
result="$(curl --retry 3 --retry-delay 1 --max-time 10 -sS -o /dev/null \
|
||||
-w "%{http_code}|%{content_type}" "$url" || true)"
|
||||
status="${result%%|*}"
|
||||
content_type="${result#*|}"
|
||||
|
||||
if [[ "$content_type" == text/html* ]]; then
|
||||
echo "FAIL: $desc (routing/target error: received $content_type from $url)"
|
||||
FAIL=$((FAIL + 1))
|
||||
elif [[ "$status" == "401" ]]; then
|
||||
echo "PASS: $desc (HTTP $status)"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo "FAIL: $desc (expected 401, got $status)"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
finish() {
|
||||
echo
|
||||
echo "Results: $PASS passed, $FAIL failed"
|
||||
|
|
@ -90,12 +38,11 @@ finish() {
|
|||
}
|
||||
|
||||
echo "=== SkillHub Smoke Test ==="
|
||||
echo "API target: $BASE_URL"
|
||||
echo "Actuator target: $ACTUATOR_BASE_URL"
|
||||
echo "Target: $BASE_URL"
|
||||
echo
|
||||
|
||||
check_health "Health endpoint" "$ACTUATOR_BASE_URL/actuator/health"
|
||||
check_protected_actuator "Prometheus metrics requires auth" "$ACTUATOR_BASE_URL/actuator/prometheus"
|
||||
check "Health endpoint" "$BASE_URL/actuator/health" "200"
|
||||
check "Prometheus metrics requires auth" "$BASE_URL/actuator/prometheus" "401"
|
||||
check "Namespaces API requires auth" "$BASE_URL/api/v1/namespaces" "401"
|
||||
check "Auth required" "$BASE_URL/api/v1/auth/me" "401"
|
||||
|
||||
|
|
|
|||
|
|
@ -15,19 +15,9 @@ grep -Eq '^DEV_WEB_HOST[[:space:]]*\?=[[:space:]]*127\.0\.0\.1$' "$MAKEFILE" \
|
|||
grep -Fq 'pnpm exec vite --host $(DEV_WEB_HOST)' "$MAKEFILE" \
|
||||
|| fail "dev web startup must pass DEV_WEB_HOST to vite"
|
||||
|
||||
grep -Fq "cd server && bash -lc '\$(DEV_SERVER_PREPARE) && exec env \$(DEV_SERVER_SCANNER_ENV) \$(DEV_SERVER_CMD)'" "$MAKEFILE" \
|
||||
grep -Fq "cd server && /bin/sh -lc '\$(DEV_SERVER_PREPARE) && exec env \$(DEV_SERVER_SCANNER_ENV) \$(DEV_SERVER_CMD)'" "$MAKEFILE" \
|
||||
|| fail "dev-server must inject scanner upload environment"
|
||||
|
||||
if grep -Fq "/bin/sh -lc '\$(DEV_SERVER_PREPARE)" "$MAKEFILE"; then
|
||||
fail "backend dev launchers must use bash for bash-compatible login profiles"
|
||||
fi
|
||||
|
||||
make -C "$REPO_ROOT" --no-print-directory dev-server \
|
||||
DEV_SERVER_PREPARE='grep -q ready <(printf ready)' \
|
||||
DEV_SERVER_SCANNER_ENV= \
|
||||
DEV_SERVER_CMD=true >/dev/null \
|
||||
|| fail "dev-server must execute bash-compatible preparation commands"
|
||||
|
||||
if grep -Fq 'pnpm exec vite --host 0.0.0.0' "$MAKEFILE"; then
|
||||
fail "dev web startup must not bind Vite to 0.0.0.0 by default"
|
||||
fi
|
||||
|
|
|
|||
|
|
@ -92,19 +92,6 @@ grep -Fq "Generated SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET" "$stdout_generated" \
|
|||
if grep -Fq "$generated_secret" "$stdout_generated"; then
|
||||
fail "runtime must not print the generated secret value"
|
||||
fi
|
||||
grep -Fq "curl -fsSL file://$REPO_ROOT/scripts/runtime.sh | sh -s -- down --home $home_generated" "$stdout_generated" \
|
||||
|| fail "GitHub runtime should print the scripts/runtime.sh stop URL"
|
||||
|
||||
home_aliyun="$tmp/aliyun"
|
||||
stdout_aliyun="$tmp/aliyun.out"
|
||||
mkdir -p "$home_aliyun"
|
||||
run_runtime "$home_aliyun" "$bin_dir" "$stdout_aliyun" --aliyun
|
||||
|
||||
grep -Fq "curl -fsSL file://$REPO_ROOT/runtime.sh | sh -s -- down --aliyun --home $home_aliyun" "$stdout_aliyun" \
|
||||
|| fail "Aliyun runtime should preserve the root runtime.sh URL and --aliyun source mode"
|
||||
if grep -Fq "file://$REPO_ROOT/scripts/runtime.sh" "$stdout_aliyun"; then
|
||||
fail "Aliyun runtime must not print the GitHub scripts/runtime.sh path"
|
||||
fi
|
||||
|
||||
home_preserved="$tmp/preserved"
|
||||
stdout_preserved="$tmp/preserved.out"
|
||||
|
|
|
|||
|
|
@ -26,7 +26,6 @@ data=""
|
|||
cookie_in=""
|
||||
cookie_out=""
|
||||
write_code=false
|
||||
write_format=""
|
||||
output_file=""
|
||||
while (($#)); do
|
||||
case "$1" in
|
||||
|
|
@ -48,7 +47,6 @@ while (($#)); do
|
|||
;;
|
||||
-w)
|
||||
write_code=true
|
||||
write_format="$2"
|
||||
shift 2
|
||||
;;
|
||||
-o)
|
||||
|
|
@ -78,16 +76,8 @@ fi
|
|||
printf '%s\n' "$method $url $data" >>"${SMOKE_CURL_LOG:?SMOKE_CURL_LOG is required}"
|
||||
|
||||
status=200
|
||||
content_type="application/json"
|
||||
body='{}'
|
||||
case "$url" in
|
||||
https://public.example/actuator/health|https://public.example/actuator/prometheus)
|
||||
content_type="text/html"
|
||||
body='<html>SkillHub</html>'
|
||||
;;
|
||||
*/actuator/health)
|
||||
body='{"status":"UP"}'
|
||||
;;
|
||||
*/actuator/health) status=200 ;;
|
||||
*/actuator/prometheus) status=401 ;;
|
||||
*/api/v1/namespaces)
|
||||
if [[ -n "$cookie_in" && -f "$cookie_in.session" ]]; then status=200; else status=401; fi
|
||||
|
|
@ -118,33 +108,22 @@ case "$url" in
|
|||
esac
|
||||
|
||||
if [[ "$output_file" != "/dev/null" && -n "$output_file" ]]; then
|
||||
printf '%s\n' "$body" >"$output_file"
|
||||
printf '{}\n' >"$output_file"
|
||||
fi
|
||||
if [[ "$write_code" == true ]]; then
|
||||
if [[ "$write_format" == *content_type* ]]; then
|
||||
printf '%s|%s' "$status" "$content_type"
|
||||
else
|
||||
printf '%s' "$status"
|
||||
fi
|
||||
printf '%s' "$status"
|
||||
fi
|
||||
EOF
|
||||
chmod +x "$TMP_DIR/bin/curl"
|
||||
|
||||
run_smoke_at() {
|
||||
local name="$1"
|
||||
local base_url="$2"
|
||||
shift 2
|
||||
local log="$TMP_DIR/$name.curl.log"
|
||||
local out="$TMP_DIR/$name.out"
|
||||
local status=0
|
||||
env PATH="$TMP_DIR/bin:$PATH" SMOKE_CURL_LOG="$log" "$@" "$SMOKE_SCRIPT" "$base_url" >"$out" 2>&1 || status=$?
|
||||
printf '%s\n' "$status"
|
||||
}
|
||||
|
||||
run_smoke() {
|
||||
local name="$1"
|
||||
shift
|
||||
run_smoke_at "$name" http://skillhub.test "$@"
|
||||
local log="$TMP_DIR/$name.curl.log"
|
||||
local out="$TMP_DIR/$name.out"
|
||||
local status=0
|
||||
env PATH="$TMP_DIR/bin:$PATH" SMOKE_CURL_LOG="$log" "$@" "$SMOKE_SCRIPT" http://skillhub.test >"$out" 2>&1 || status=$?
|
||||
printf '%s\n' "$status"
|
||||
}
|
||||
|
||||
status="$(run_smoke skip-admin env)"
|
||||
|
|
@ -174,22 +153,4 @@ if grep -Fq 'ChangeMe!2026' "$TMP_DIR/explicit-admin.curl.log"; then
|
|||
fail "admin login must not fall back to the bootstrap default password"
|
||||
fi
|
||||
|
||||
status="$(run_smoke_at split-targets https://public.example env \
|
||||
ACTUATOR_BASE_URL=http://actuator.internal:8080 SMOKE_ADMIN_CHECKS=false)"
|
||||
[[ "$status" == "0" ]] || fail "split public and actuator targets should pass"
|
||||
grep -Fq "GET http://actuator.internal:8080/actuator/health" "$TMP_DIR/split-targets.curl.log" \
|
||||
|| fail "health check should use ACTUATOR_BASE_URL"
|
||||
grep -Fq "GET http://actuator.internal:8080/actuator/prometheus" "$TMP_DIR/split-targets.curl.log" \
|
||||
|| fail "Prometheus check should use ACTUATOR_BASE_URL"
|
||||
if grep -Fq "https://public.example/actuator/" "$TMP_DIR/split-targets.curl.log"; then
|
||||
fail "actuator checks must not use the public API target when ACTUATOR_BASE_URL is set"
|
||||
fi
|
||||
grep -Fq "GET https://public.example/api/v1/auth/me" "$TMP_DIR/split-targets.curl.log" \
|
||||
|| fail "application API checks should continue using BASE_URL"
|
||||
|
||||
status="$(run_smoke_at html-fallback https://public.example env SMOKE_ADMIN_CHECKS=false)"
|
||||
[[ "$status" != "0" ]] || fail "HTML SPA fallback must not pass as actuator health"
|
||||
grep -Fq "routing/target error: received text/html" "$TMP_DIR/html-fallback.out" \
|
||||
|| fail "HTML fallback should produce an actionable routing/target error"
|
||||
|
||||
echo "smoke-test-admin-mode-test passed"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
# Build the architecture-neutral JAR on the Buildx host. This avoids emulating
|
||||
# the complete Maven build when the target image is linux/riscv64.
|
||||
FROM --platform=$BUILDPLATFORM eclipse-temurin:21-jdk-alpine AS build
|
||||
# ---- Build Stage ----
|
||||
FROM eclipse-temurin:21-jdk-alpine AS build
|
||||
WORKDIR /app
|
||||
|
||||
# Cache dependencies
|
||||
|
|
@ -20,14 +19,8 @@ COPY . .
|
|||
RUN ./mvnw package -DskipTests -B
|
||||
|
||||
# ---- Runtime Stage ----
|
||||
# The Noble variant publishes a linux/riscv64 image; the Alpine JRE currently
|
||||
# used by this project is limited to amd64 and arm64.
|
||||
FROM eclipse-temurin:21-jre-noble
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends wget && \
|
||||
rm -rf /var/lib/apt/lists/* && \
|
||||
groupadd --system app && \
|
||||
useradd --system --gid app --create-home app
|
||||
FROM eclipse-temurin:21-jre-alpine
|
||||
RUN addgroup -S app && adduser -S app -G app
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=build /app/skillhub-app/target/*.jar app.jar
|
||||
|
|
|
|||
|
|
@ -20,10 +20,8 @@ import com.iflytek.skillhub.domain.skill.SkillVisibility;
|
|||
import com.iflytek.skillhub.domain.skill.service.SkillPublishService;
|
||||
import com.iflytek.skillhub.domain.skill.service.SkillQueryService;
|
||||
import com.iflytek.skillhub.domain.social.SkillStarService;
|
||||
import com.iflytek.skillhub.dto.SkillLabelDto;
|
||||
import com.iflytek.skillhub.dto.SkillSummaryResponse;
|
||||
import com.iflytek.skillhub.observability.RequestIdAccessor;
|
||||
import com.iflytek.skillhub.service.SkillLabelProjectionService;
|
||||
import com.iflytek.skillhub.service.SkillSearchAppService;
|
||||
import java.io.IOException;
|
||||
import java.util.HashMap;
|
||||
|
|
@ -53,7 +51,6 @@ public class ClawHubCompatAppService {
|
|||
private final CompatSkillLookupService compatSkillLookupService;
|
||||
private final SkillStarService skillStarService;
|
||||
private final RequestIdAccessor requestIdAccessor;
|
||||
private final SkillLabelProjectionService skillLabelProjectionService;
|
||||
|
||||
public ClawHubCompatAppService(CanonicalSlugMapper mapper,
|
||||
SkillSearchAppService skillSearchAppService,
|
||||
|
|
@ -64,8 +61,7 @@ public class ClawHubCompatAppService {
|
|||
AuditLogService auditLogService,
|
||||
CompatSkillLookupService compatSkillLookupService,
|
||||
SkillStarService skillStarService,
|
||||
RequestIdAccessor requestIdAccessor,
|
||||
SkillLabelProjectionService skillLabelProjectionService) {
|
||||
RequestIdAccessor requestIdAccessor) {
|
||||
this.mapper = mapper;
|
||||
this.skillSearchAppService = skillSearchAppService;
|
||||
this.skillQueryService = skillQueryService;
|
||||
|
|
@ -76,7 +72,6 @@ public class ClawHubCompatAppService {
|
|||
this.compatSkillLookupService = compatSkillLookupService;
|
||||
this.skillStarService = skillStarService;
|
||||
this.requestIdAccessor = requestIdAccessor;
|
||||
this.skillLabelProjectionService = skillLabelProjectionService;
|
||||
}
|
||||
|
||||
public ClawHubSearchResponse search(String q,
|
||||
|
|
@ -200,15 +195,6 @@ public class ClawHubCompatAppService {
|
|||
String sort,
|
||||
String userId,
|
||||
Map<Long, NamespaceRole> userNsRoles) {
|
||||
return listSkills(page, limit, sort, false, userId, userNsRoles);
|
||||
}
|
||||
|
||||
public ClawHubSkillListResponse listSkills(int page,
|
||||
int limit,
|
||||
String sort,
|
||||
boolean includeLabels,
|
||||
String userId,
|
||||
Map<Long, NamespaceRole> userNsRoles) {
|
||||
String sortBy = sort != null ? sort : "newest";
|
||||
SkillSearchAppService.SearchResponse response = skillSearchAppService.search(
|
||||
"",
|
||||
|
|
@ -220,15 +206,8 @@ public class ClawHubCompatAppService {
|
|||
userNsRoles
|
||||
);
|
||||
|
||||
Map<Long, List<SkillLabelDto>> labelsBySkillId = includeLabels
|
||||
? skillLabelProjectionService.labelsBySkillIds(
|
||||
response.items().stream().map(SkillSummaryResponse::id).toList())
|
||||
: Map.of();
|
||||
|
||||
List<ClawHubSkillListResponse.SkillListItem> items = response.items().stream()
|
||||
.map(item -> toSkillListItem(
|
||||
item,
|
||||
includeLabels ? labelsBySkillId.getOrDefault(item.id(), List.of()) : null))
|
||||
.map(this::toSkillListItem)
|
||||
.toList();
|
||||
|
||||
String nextCursor = null;
|
||||
|
|
@ -404,8 +383,7 @@ public class ClawHubCompatAppService {
|
|||
return new ClawHubResolveResponse(matchVersion, latestVersion);
|
||||
}
|
||||
|
||||
private ClawHubSkillListResponse.SkillListItem toSkillListItem(SkillSummaryResponse item,
|
||||
List<SkillLabelDto> labels) {
|
||||
private ClawHubSkillListResponse.SkillListItem toSkillListItem(SkillSummaryResponse item) {
|
||||
long createdAt = 0;
|
||||
long updatedAt = item.updatedAt() != null ? item.updatedAt().toEpochMilli() : 0;
|
||||
|
||||
|
|
@ -435,8 +413,7 @@ public class ClawHubCompatAppService {
|
|||
stats,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
latestVersion,
|
||||
labels
|
||||
latestVersion
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,13 +10,10 @@ import com.iflytek.skillhub.compat.dto.ClawHubSkillResponse;
|
|||
import com.iflytek.skillhub.compat.dto.ClawHubStarResponse;
|
||||
import com.iflytek.skillhub.compat.dto.ClawHubUnstarResponse;
|
||||
import com.iflytek.skillhub.compat.dto.ClawHubWhoamiResponse;
|
||||
import com.iflytek.skillhub.controller.support.IncludeOptions;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
|
||||
import com.iflytek.skillhub.ratelimit.RateLimit;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
|
|
@ -96,12 +93,9 @@ public class ClawHubCompatController {
|
|||
public ClawHubSkillListResponse listSkills(@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "25") int limit,
|
||||
@RequestParam(required = false) String sort,
|
||||
@Parameter(description = "Optional response expansions. Supported value: labels")
|
||||
@RequestParam(name = "include", required = false) List<String> include,
|
||||
@RequestAttribute(value = "userId", required = false) String userId,
|
||||
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
|
||||
return clawHubCompatAppService.listSkills(
|
||||
page, limit, sort, IncludeOptions.includesLabels(include), userId, userNsRoles);
|
||||
return clawHubCompatAppService.listSkills(page, limit, sort, userId, userNsRoles);
|
||||
}
|
||||
|
||||
@RateLimit(category = "skills", authenticated = 60, anonymous = 20)
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import com.iflytek.skillhub.auth.token.ApiTokenAuthenticationFilter;
|
|||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
|
|
@ -25,11 +24,12 @@ public class ClawHubRegistrySecurityConfig {
|
|||
http
|
||||
.securityMatcher(
|
||||
new OrRequestMatcher(
|
||||
new AntPathRequestMatcher("/api/v1/labels", HttpMethod.GET.name()),
|
||||
new AntPathRequestMatcher("/api/web/labels", HttpMethod.GET.name())
|
||||
new AntPathRequestMatcher("/api/v1/labels"),
|
||||
new AntPathRequestMatcher("/api/web/labels")
|
||||
)
|
||||
)
|
||||
.authorizeHttpRequests(auth -> auth.anyRequest().permitAll())
|
||||
.csrf(csrf -> csrf.disable())
|
||||
.requestCache(cache -> cache.disable())
|
||||
.securityContext(context -> context.disable())
|
||||
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
|
||||
|
|
|
|||
|
|
@ -3,21 +3,15 @@ package com.iflytek.skillhub.compat;
|
|||
import com.iflytek.skillhub.domain.namespace.Namespace;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceType;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException;
|
||||
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.SkillVisibility;
|
||||
import com.iflytek.skillhub.domain.skill.VisibilityChecker;
|
||||
import com.iflytek.skillhub.domain.skill.service.SkillSlugResolutionService;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
|
|
@ -27,10 +21,6 @@ import org.springframework.stereotype.Service;
|
|||
@Service
|
||||
public class CompatSkillLookupService {
|
||||
|
||||
private static final int LEGACY_SLUG_PUBLISHED_SCORE = 1_000;
|
||||
private static final int LEGACY_SLUG_PUBLIC_SCORE = 100;
|
||||
private static final int LEGACY_SLUG_GLOBAL_SCORE = 10;
|
||||
|
||||
private final SkillRepository skillRepository;
|
||||
private final NamespaceRepository namespaceRepository;
|
||||
private final SkillVersionRepository skillVersionRepository;
|
||||
|
|
@ -50,27 +40,10 @@ public class CompatSkillLookupService {
|
|||
}
|
||||
|
||||
public CompatSkillContext findByLegacySlug(String slug) {
|
||||
List<Skill> skills = skillRepository.findBySlug(slug);
|
||||
if (skills.isEmpty()) {
|
||||
throw new DomainNotFoundException("error.skill.notFound", slug);
|
||||
}
|
||||
// Batch-fetch all candidate namespaces in a single query to avoid N+1 lookups.
|
||||
List<Long> namespaceIds = skills.stream()
|
||||
.map(Skill::getNamespaceId)
|
||||
.distinct()
|
||||
.toList();
|
||||
Map<Long, Namespace> namespacesById = namespaceIds.isEmpty()
|
||||
? Map.of()
|
||||
: namespaceRepository.findByIdIn(namespaceIds).stream()
|
||||
.collect(Collectors.toMap(Namespace::getId, Function.identity()));
|
||||
Skill skill = skills.stream()
|
||||
.min(Comparator.<Skill>comparingInt(s -> -legacySlugCandidateScore(s, namespacesById))
|
||||
.thenComparing(Skill::getId))
|
||||
.orElse(skills.get(0));
|
||||
Namespace namespace = namespacesById.get(skill.getNamespaceId());
|
||||
if (namespace == null) {
|
||||
throw new DomainNotFoundException("error.namespace.notFound", skill.getNamespaceId());
|
||||
}
|
||||
Skill skill = skillRepository.findBySlug(slug).stream().findFirst()
|
||||
.orElseThrow(() -> new DomainNotFoundException("error.skill.notFound", slug));
|
||||
Namespace namespace = namespaceRepository.findById(skill.getNamespaceId())
|
||||
.orElseThrow(() -> new DomainNotFoundException("error.namespace.notFound", skill.getNamespaceId()));
|
||||
return new CompatSkillContext(namespace, skill, findLatestVersion(skill));
|
||||
}
|
||||
|
||||
|
|
@ -113,16 +86,6 @@ public class CompatSkillLookupService {
|
|||
return skillVersionRepository.findById(skill.getLatestVersionId());
|
||||
}
|
||||
|
||||
private static int legacySlugCandidateScore(Skill skill, Map<Long, Namespace> namespacesById) {
|
||||
Namespace namespace = namespacesById.get(skill.getNamespaceId());
|
||||
int score = 0;
|
||||
// Bare-slug CLI resolution should prefer installable candidates before namespace defaults.
|
||||
if (skill.getLatestVersionId() != null) score += LEGACY_SLUG_PUBLISHED_SCORE;
|
||||
if (skill.getVisibility() == SkillVisibility.PUBLIC) score += LEGACY_SLUG_PUBLIC_SCORE;
|
||||
if (namespace != null && namespace.getType() == NamespaceType.GLOBAL) score += LEGACY_SLUG_GLOBAL_SCORE;
|
||||
return score;
|
||||
}
|
||||
|
||||
private Skill resolveVisibleSkill(Long namespaceId, String slug, String currentUserId) {
|
||||
try {
|
||||
return skillSlugResolutionService.resolve(
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
package com.iflytek.skillhub.compat.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.iflytek.skillhub.dto.SkillLabelDto;
|
||||
import java.util.List;
|
||||
|
||||
public record ClawHubSkillListResponse(
|
||||
|
|
@ -16,27 +14,8 @@ public record ClawHubSkillListResponse(
|
|||
Object stats,
|
||||
long createdAt,
|
||||
long updatedAt,
|
||||
LatestVersion latestVersion,
|
||||
/**
|
||||
* Labels attached to the skill, present only when the caller passes
|
||||
* {@code include=labels}. Omitted otherwise, so the legacy payload is unchanged.
|
||||
*/
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
List<SkillLabelDto> labels
|
||||
LatestVersion latestVersion
|
||||
) {
|
||||
|
||||
public SkillListItem(
|
||||
String slug,
|
||||
String displayName,
|
||||
String summary,
|
||||
Object tags,
|
||||
Object stats,
|
||||
long createdAt,
|
||||
long updatedAt,
|
||||
LatestVersion latestVersion) {
|
||||
this(slug, displayName, summary, tags, stats, createdAt, updatedAt, latestVersion, null);
|
||||
}
|
||||
|
||||
public record LatestVersion(
|
||||
String version,
|
||||
long createdAt,
|
||||
|
|
|
|||
|
|
@ -1,93 +0,0 @@
|
|||
package com.iflytek.skillhub.config;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Runtime-configurable overrides for request rate limiting.
|
||||
*
|
||||
* <p>The compile-time {@link com.iflytek.skillhub.ratelimit.RateLimit} annotation on each endpoint
|
||||
* supplies the built-in defaults. Values set here — typically via {@code SKILLHUB_RATELIMIT_*}
|
||||
* environment variables — override those defaults per {@code category}, and {@code enabled=false}
|
||||
* turns request rate limiting off entirely. When nothing is configured the annotation defaults are
|
||||
* used unchanged, so existing deployments behave exactly as before.
|
||||
*
|
||||
* <p>An override applies to every endpoint that shares the same {@code category}. Only the fields
|
||||
* you set are overridden; the rest fall back to the annotation.
|
||||
*/
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "skillhub.ratelimit")
|
||||
public class RateLimitProperties {
|
||||
|
||||
/** Master switch. When {@code false} the interceptor performs no quota checks. */
|
||||
private boolean enabled = true;
|
||||
|
||||
/** Per-category overrides keyed by {@code RateLimit#category} (e.g. "search", "download", "publish"). */
|
||||
private Map<String, CategoryLimit> categories = new HashMap<>();
|
||||
|
||||
public boolean isEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
public void setEnabled(boolean enabled) {
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
public Map<String, CategoryLimit> getCategories() {
|
||||
return categories;
|
||||
}
|
||||
|
||||
public void setCategories(Map<String, CategoryLimit> categories) {
|
||||
this.categories = categories;
|
||||
}
|
||||
|
||||
/** Configured authenticated quota for {@code category}, or {@code fallback} when unset. */
|
||||
public int authenticatedFor(String category, int fallback) {
|
||||
CategoryLimit c = categories.get(category);
|
||||
return c != null && c.getAuthenticated() != null ? c.getAuthenticated() : fallback;
|
||||
}
|
||||
|
||||
/** Configured anonymous quota for {@code category}, or {@code fallback} when unset. */
|
||||
public int anonymousFor(String category, int fallback) {
|
||||
CategoryLimit c = categories.get(category);
|
||||
return c != null && c.getAnonymous() != null ? c.getAnonymous() : fallback;
|
||||
}
|
||||
|
||||
/** Configured window (seconds) for {@code category}, or {@code fallback} when unset. */
|
||||
public int windowSecondsFor(String category, int fallback) {
|
||||
CategoryLimit c = categories.get(category);
|
||||
return c != null && c.getWindowSeconds() != null ? c.getWindowSeconds() : fallback;
|
||||
}
|
||||
|
||||
public static class CategoryLimit {
|
||||
private Integer authenticated;
|
||||
private Integer anonymous;
|
||||
private Integer windowSeconds;
|
||||
|
||||
public Integer getAuthenticated() {
|
||||
return authenticated;
|
||||
}
|
||||
|
||||
public void setAuthenticated(Integer authenticated) {
|
||||
this.authenticated = authenticated;
|
||||
}
|
||||
|
||||
public Integer getAnonymous() {
|
||||
return anonymous;
|
||||
}
|
||||
|
||||
public void setAnonymous(Integer anonymous) {
|
||||
this.anonymous = anonymous;
|
||||
}
|
||||
|
||||
public Integer getWindowSeconds() {
|
||||
return windowSeconds;
|
||||
}
|
||||
|
||||
public void setWindowSeconds(Integer windowSeconds) {
|
||||
this.windowSeconds = windowSeconds;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,151 +0,0 @@
|
|||
package com.iflytek.skillhub.controller.admin;
|
||||
|
||||
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
|
||||
import com.iflytek.skillhub.controller.BaseApiController;
|
||||
import com.iflytek.skillhub.dto.AdminNamespaceDetailResponse;
|
||||
import com.iflytek.skillhub.dto.AdminNamespaceListResponse;
|
||||
import com.iflytek.skillhub.dto.ApiResponse;
|
||||
import com.iflytek.skillhub.dto.ApiResponseFactory;
|
||||
import com.iflytek.skillhub.dto.BatchMemberRequest;
|
||||
import com.iflytek.skillhub.dto.BatchMemberResponse;
|
||||
import com.iflytek.skillhub.dto.MemberRequest;
|
||||
import com.iflytek.skillhub.dto.MemberResponse;
|
||||
import com.iflytek.skillhub.dto.MessageResponse;
|
||||
import com.iflytek.skillhub.dto.NamespaceCandidateUserResponse;
|
||||
import com.iflytek.skillhub.dto.NamespaceLifecycleRequest;
|
||||
import com.iflytek.skillhub.dto.PageResponse;
|
||||
import com.iflytek.skillhub.dto.TransferOwnershipRequest;
|
||||
import com.iflytek.skillhub.dto.UpdateMemberRoleRequest;
|
||||
import com.iflytek.skillhub.service.AdminNamespaceAppService;
|
||||
import com.iflytek.skillhub.service.AuditRequestContext;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.validation.Valid;
|
||||
import java.util.List;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/admin/namespaces")
|
||||
@PreAuthorize("hasRole('SUPER_ADMIN')")
|
||||
public class AdminNamespaceController extends BaseApiController {
|
||||
|
||||
private final AdminNamespaceAppService adminNamespaceAppService;
|
||||
|
||||
public AdminNamespaceController(AdminNamespaceAppService adminNamespaceAppService,
|
||||
ApiResponseFactory responseFactory) {
|
||||
super(responseFactory);
|
||||
this.adminNamespaceAppService = adminNamespaceAppService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public ApiResponse<AdminNamespaceListResponse> listNamespaces(
|
||||
@RequestParam(required = false) String keyword,
|
||||
@RequestParam(required = false) String status,
|
||||
@RequestParam(required = false) String type,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "20") int size,
|
||||
@AuthenticationPrincipal PlatformPrincipal principal) {
|
||||
return ok("response.success.read",
|
||||
adminNamespaceAppService.list(keyword, status, type, page, size, principal.userId()));
|
||||
}
|
||||
|
||||
@GetMapping("/{slug}")
|
||||
public ApiResponse<AdminNamespaceDetailResponse> getNamespace(@PathVariable String slug,
|
||||
@AuthenticationPrincipal PlatformPrincipal principal) {
|
||||
return ok("response.success.read", adminNamespaceAppService.detail(slug, principal.userId()));
|
||||
}
|
||||
|
||||
@GetMapping("/{slug}/members")
|
||||
public ApiResponse<PageResponse<MemberResponse>> listMembers(@PathVariable String slug,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "20") int size) {
|
||||
return ok("response.success.read", adminNamespaceAppService.listMembers(slug, page, size));
|
||||
}
|
||||
|
||||
@GetMapping("/{slug}/member-candidates")
|
||||
public ApiResponse<List<NamespaceCandidateUserResponse>> searchMemberCandidates(
|
||||
@PathVariable String slug,
|
||||
@RequestParam String search,
|
||||
@RequestParam(defaultValue = "10") int size) {
|
||||
return ok("response.success.read", adminNamespaceAppService.searchMemberCandidates(slug, search, size));
|
||||
}
|
||||
|
||||
@PostMapping("/{slug}/members")
|
||||
public ApiResponse<MemberResponse> addMember(@PathVariable String slug,
|
||||
@Valid @RequestBody MemberRequest request,
|
||||
@AuthenticationPrincipal PlatformPrincipal principal) {
|
||||
return ok("response.success.created", adminNamespaceAppService.addMember(slug, request, principal.userId()));
|
||||
}
|
||||
|
||||
@PostMapping("/{slug}/members/batch")
|
||||
public ApiResponse<BatchMemberResponse> batchAddMembers(@PathVariable String slug,
|
||||
@Valid @RequestBody BatchMemberRequest request,
|
||||
@AuthenticationPrincipal PlatformPrincipal principal) {
|
||||
return ok("response.success.created", adminNamespaceAppService.batchAddMembers(slug, request, principal.userId()));
|
||||
}
|
||||
|
||||
@PutMapping("/{slug}/members/{userId}/role")
|
||||
public ApiResponse<MemberResponse> updateMemberRole(@PathVariable String slug,
|
||||
@PathVariable String userId,
|
||||
@Valid @RequestBody UpdateMemberRoleRequest request,
|
||||
@AuthenticationPrincipal PlatformPrincipal principal) {
|
||||
return ok("response.success.updated", adminNamespaceAppService.updateMemberRole(slug, userId, request, principal.userId()));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{slug}/members/{userId}")
|
||||
public ApiResponse<MessageResponse> removeMember(@PathVariable String slug,
|
||||
@PathVariable String userId,
|
||||
@AuthenticationPrincipal PlatformPrincipal principal) {
|
||||
return ok("response.success.deleted", adminNamespaceAppService.removeMember(slug, userId, principal.userId()));
|
||||
}
|
||||
|
||||
@PostMapping("/{slug}/transfer-ownership")
|
||||
public ApiResponse<MessageResponse> transferOwnership(@PathVariable String slug,
|
||||
@Valid @RequestBody TransferOwnershipRequest request,
|
||||
@AuthenticationPrincipal PlatformPrincipal principal) {
|
||||
return ok("response.success.updated", adminNamespaceAppService.transferOwnership(slug, request.newOwnerId(), principal.userId()));
|
||||
}
|
||||
|
||||
@PostMapping("/{slug}/freeze")
|
||||
public ApiResponse<AdminNamespaceDetailResponse> freezeNamespace(@PathVariable String slug,
|
||||
@RequestBody(required = false) NamespaceLifecycleRequest request,
|
||||
@AuthenticationPrincipal PlatformPrincipal principal,
|
||||
HttpServletRequest httpRequest) {
|
||||
return ok("response.success.updated",
|
||||
adminNamespaceAppService.freeze(slug, request, principal.userId(), AuditRequestContext.from(httpRequest)));
|
||||
}
|
||||
|
||||
@PostMapping("/{slug}/unfreeze")
|
||||
public ApiResponse<AdminNamespaceDetailResponse> unfreezeNamespace(@PathVariable String slug,
|
||||
@AuthenticationPrincipal PlatformPrincipal principal,
|
||||
HttpServletRequest httpRequest) {
|
||||
return ok("response.success.updated",
|
||||
adminNamespaceAppService.unfreeze(slug, principal.userId(), AuditRequestContext.from(httpRequest)));
|
||||
}
|
||||
|
||||
@PostMapping("/{slug}/archive")
|
||||
public ApiResponse<AdminNamespaceDetailResponse> archiveNamespace(@PathVariable String slug,
|
||||
@RequestBody(required = false) NamespaceLifecycleRequest request,
|
||||
@AuthenticationPrincipal PlatformPrincipal principal,
|
||||
HttpServletRequest httpRequest) {
|
||||
return ok("response.success.updated",
|
||||
adminNamespaceAppService.archive(slug, request, principal.userId(), AuditRequestContext.from(httpRequest)));
|
||||
}
|
||||
|
||||
@PostMapping("/{slug}/restore")
|
||||
public ApiResponse<AdminNamespaceDetailResponse> restoreNamespace(@PathVariable String slug,
|
||||
@AuthenticationPrincipal PlatformPrincipal principal,
|
||||
HttpServletRequest httpRequest) {
|
||||
return ok("response.success.updated",
|
||||
adminNamespaceAppService.restore(slug, principal.userId(), AuditRequestContext.from(httpRequest)));
|
||||
}
|
||||
}
|
||||
|
|
@ -61,28 +61,23 @@ public class NamespaceController extends BaseApiController {
|
|||
@GetMapping("/namespaces")
|
||||
public ApiResponse<PageResponse<NamespaceResponse>> listNamespaces(
|
||||
Pageable pageable,
|
||||
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles,
|
||||
@AuthenticationPrincipal PlatformPrincipal principal) {
|
||||
return ok("response.success.read",
|
||||
namespacePortalQueryAppService.listNamespaces(pageable, userNsRoles, platformRoles(principal)));
|
||||
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
|
||||
return ok("response.success.read", namespacePortalQueryAppService.listNamespaces(pageable, userNsRoles));
|
||||
}
|
||||
|
||||
@GetMapping("/me/namespaces")
|
||||
public ApiResponse<List<MyNamespaceResponse>> listMyNamespaces(
|
||||
@RequestAttribute("userId") String userId,
|
||||
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles,
|
||||
@AuthenticationPrincipal PlatformPrincipal principal) {
|
||||
return ok("response.success.read",
|
||||
namespacePortalQueryAppService.listMyNamespaces(userNsRoles, platformRoles(principal)));
|
||||
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
|
||||
return ok("response.success.read", namespacePortalQueryAppService.listMyNamespaces(userNsRoles));
|
||||
}
|
||||
|
||||
@GetMapping("/namespaces/{slug}")
|
||||
public ApiResponse<NamespaceResponse> getNamespace(@PathVariable String slug,
|
||||
@RequestAttribute("userId") String userId,
|
||||
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles,
|
||||
@AuthenticationPrincipal PlatformPrincipal principal) {
|
||||
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
|
||||
return ok("response.success.read",
|
||||
namespacePortalQueryAppService.getNamespace(slug, userId, userNsRoles, platformRoles(principal)));
|
||||
namespacePortalQueryAppService.getNamespace(slug, userId, userNsRoles));
|
||||
}
|
||||
|
||||
@PostMapping("/namespaces")
|
||||
|
|
@ -163,14 +158,11 @@ public class NamespaceController extends BaseApiController {
|
|||
Pageable pageable,
|
||||
@RequestAttribute("userId") String userId,
|
||||
@AuthenticationPrincipal PlatformPrincipal principal) {
|
||||
return ok("response.success.read",
|
||||
namespacePortalQueryAppService.listMembers(slug, pageable, userId, platformRoles(principal)));
|
||||
}
|
||||
|
||||
private Set<String> platformRoles(PlatformPrincipal principal) {
|
||||
return principal != null && principal.platformRoles() != null
|
||||
Set<String> platformRoles = principal != null && principal.platformRoles() != null
|
||||
? principal.platformRoles()
|
||||
: Set.of();
|
||||
return ok("response.success.read",
|
||||
namespacePortalQueryAppService.listMembers(slug, pageable, userId, platformRoles));
|
||||
}
|
||||
|
||||
@GetMapping("/namespaces/{slug}/member-candidates")
|
||||
|
|
|
|||
|
|
@ -23,7 +23,6 @@ import com.iflytek.skillhub.dto.SkillVersionResponse;
|
|||
import com.iflytek.skillhub.metrics.SkillHubMetrics;
|
||||
import com.iflytek.skillhub.ratelimit.RateLimit;
|
||||
import com.iflytek.skillhub.service.SkillLabelAppService;
|
||||
import com.iflytek.skillhub.service.ComplianceSnapshotProjectionService;
|
||||
import org.springframework.core.io.InputStreamResource;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
|
|
@ -51,21 +50,18 @@ public class SkillController extends BaseApiController {
|
|||
private final SkillQueryService skillQueryService;
|
||||
private final SkillDownloadService skillDownloadService;
|
||||
private final SkillLabelAppService skillLabelAppService;
|
||||
private final ComplianceSnapshotProjectionService complianceSnapshotProjectionService;
|
||||
private final SkillHubMetrics metrics;
|
||||
|
||||
public SkillController(
|
||||
SkillQueryService skillQueryService,
|
||||
SkillDownloadService skillDownloadService,
|
||||
SkillLabelAppService skillLabelAppService,
|
||||
ComplianceSnapshotProjectionService complianceSnapshotProjectionService,
|
||||
SkillHubMetrics metrics,
|
||||
ApiResponseFactory responseFactory) {
|
||||
super(responseFactory);
|
||||
this.skillQueryService = skillQueryService;
|
||||
this.skillDownloadService = skillDownloadService;
|
||||
this.skillLabelAppService = skillLabelAppService;
|
||||
this.complianceSnapshotProjectionService = complianceSnapshotProjectionService;
|
||||
this.metrics = metrics;
|
||||
}
|
||||
|
||||
|
|
@ -142,8 +138,7 @@ public class SkillController extends BaseApiController {
|
|||
v.getFileCount(),
|
||||
v.getTotalSize(),
|
||||
v.getPublishedAt(),
|
||||
skillQueryService.isDownloadAvailable(v),
|
||||
complianceSnapshotProjectionService.fromParsedMetadataJson(v.getParsedMetadataJson())
|
||||
skillQueryService.isDownloadAvailable(v)
|
||||
)));
|
||||
|
||||
return ok("response.success.read", response);
|
||||
|
|
@ -178,8 +173,7 @@ public class SkillController extends BaseApiController {
|
|||
detail.totalSize(),
|
||||
detail.publishedAt(),
|
||||
detail.parsedMetadataJson(),
|
||||
detail.manifestJson(),
|
||||
complianceSnapshotProjectionService.fromParsedMetadataJson(detail.parsedMetadataJson())
|
||||
detail.manifestJson()
|
||||
);
|
||||
return ok("response.success.read", response);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,20 +1,16 @@
|
|||
package com.iflytek.skillhub.controller.portal;
|
||||
|
||||
import com.iflytek.skillhub.controller.BaseApiController;
|
||||
import com.iflytek.skillhub.controller.support.IncludeOptions;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
|
||||
import com.iflytek.skillhub.dto.ApiResponse;
|
||||
import com.iflytek.skillhub.dto.ApiResponseFactory;
|
||||
import com.iflytek.skillhub.dto.SkillLabelDto;
|
||||
import com.iflytek.skillhub.dto.SkillSummaryResponse;
|
||||
import com.iflytek.skillhub.ratelimit.RateLimit;
|
||||
import com.iflytek.skillhub.service.SkillLabelProjectionService;
|
||||
import com.iflytek.skillhub.service.SkillSearchAppService;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
|
|
@ -31,14 +27,11 @@ public class SkillSearchController extends BaseApiController {
|
|||
private static final int DEFAULT_SIZE = 20;
|
||||
|
||||
private final SkillSearchAppService skillSearchAppService;
|
||||
private final SkillLabelProjectionService skillLabelProjectionService;
|
||||
|
||||
public SkillSearchController(SkillSearchAppService skillSearchAppService,
|
||||
SkillLabelProjectionService skillLabelProjectionService,
|
||||
ApiResponseFactory responseFactory) {
|
||||
super(responseFactory);
|
||||
this.skillSearchAppService = skillSearchAppService;
|
||||
this.skillLabelProjectionService = skillLabelProjectionService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
|
|
@ -47,8 +40,6 @@ public class SkillSearchController extends BaseApiController {
|
|||
@RequestParam(required = false) String q,
|
||||
@RequestParam(required = false) String namespace,
|
||||
@RequestParam(name = "label", required = false) java.util.List<String> labels,
|
||||
@Parameter(description = "Optional response expansions. Supported value: labels")
|
||||
@RequestParam(name = "include", required = false) List<String> include,
|
||||
@Parameter(schema = @Schema(defaultValue = DEFAULT_SORT))
|
||||
@RequestParam(required = false) String sort,
|
||||
@Parameter(schema = @Schema(type = "integer", defaultValue = "0", minimum = "0"))
|
||||
|
|
@ -58,7 +49,6 @@ public class SkillSearchController extends BaseApiController {
|
|||
@RequestAttribute(value = "userId", required = false) String userId,
|
||||
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
|
||||
|
||||
boolean includeLabels = IncludeOptions.includesLabels(include);
|
||||
SkillSearchAppService.SearchResponse response = skillSearchAppService.search(
|
||||
q,
|
||||
namespace,
|
||||
|
|
@ -70,18 +60,7 @@ public class SkillSearchController extends BaseApiController {
|
|||
userNsRoles
|
||||
);
|
||||
|
||||
return ok("response.success.read", includeLabels ? withLabels(response) : response);
|
||||
}
|
||||
|
||||
private SkillSearchAppService.SearchResponse withLabels(SkillSearchAppService.SearchResponse response) {
|
||||
Map<Long, List<SkillLabelDto>> labelsBySkillId = skillLabelProjectionService.labelsBySkillIds(
|
||||
response.items().stream().map(SkillSummaryResponse::id).toList());
|
||||
|
||||
List<SkillSummaryResponse> items = response.items().stream()
|
||||
.map(item -> item.withLabels(labelsBySkillId.getOrDefault(item.id(), List.of())))
|
||||
.toList();
|
||||
|
||||
return new SkillSearchAppService.SearchResponse(items, response.total(), response.page(), response.size());
|
||||
return ok("response.success.read", response);
|
||||
}
|
||||
|
||||
private String normalizeSort(String sort) {
|
||||
|
|
|
|||
|
|
@ -1,42 +0,0 @@
|
|||
package com.iflytek.skillhub.controller.support;
|
||||
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Parses optional response expansions from {@code include=...} query parameters.
|
||||
*/
|
||||
public final class IncludeOptions {
|
||||
|
||||
private static final String LABELS = "labels";
|
||||
private static final Set<String> SUPPORTED = Set.of(LABELS);
|
||||
|
||||
private IncludeOptions() {
|
||||
}
|
||||
|
||||
public static boolean includesLabels(List<String> include) {
|
||||
if (include == null || include.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
boolean requested = false;
|
||||
for (String rawValue : include) {
|
||||
if (rawValue == null || rawValue.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
for (String rawOption : rawValue.split(",")) {
|
||||
String option = rawOption.trim().toLowerCase(Locale.ROOT);
|
||||
if (option.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
if (!SUPPORTED.contains(option)) {
|
||||
throw new DomainBadRequestException("error.request.include.unsupported", option);
|
||||
}
|
||||
requested = true;
|
||||
}
|
||||
}
|
||||
return requested;
|
||||
}
|
||||
}
|
||||
|
|
@ -44,7 +44,7 @@ public class SkillPackageArchiveExtractor {
|
|||
try (ZipInputStream zis = new ZipInputStream(file.getInputStream())) {
|
||||
ZipEntry zipEntry;
|
||||
while ((zipEntry = zis.getNextEntry()) != null) {
|
||||
if (isDirectoryEntry(zipEntry)) {
|
||||
if (zipEntry.isDirectory()) {
|
||||
zis.closeEntry();
|
||||
continue;
|
||||
}
|
||||
|
|
@ -164,14 +164,6 @@ public class SkillPackageArchiveExtractor {
|
|||
return new ExtractionResult(promoted, warnings);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link ZipEntry#isDirectory()} only recognizes the ZIP-standard forward slash. Some Windows
|
||||
* archive tools emit directory entries whose names end in a backslash instead.
|
||||
*/
|
||||
static boolean isDirectoryEntry(ZipEntry entry) {
|
||||
return entry.isDirectory() || entry.getName().endsWith("\\");
|
||||
}
|
||||
|
||||
private static boolean isOsMetadataEntry(String name) {
|
||||
String normalized = name.replace('\\', '/');
|
||||
if (normalized.startsWith("__MACOSX/") || normalized.equals("__MACOSX")) return true;
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ public class ZipPackageExtractor {
|
|||
try (ZipInputStream zis = new ZipInputStream(file.getInputStream())) {
|
||||
ZipEntry zipEntry;
|
||||
while ((zipEntry = zis.getNextEntry()) != null) {
|
||||
if (SkillPackageArchiveExtractor.isDirectoryEntry(zipEntry)) {
|
||||
if (zipEntry.isDirectory()) {
|
||||
zis.closeEntry();
|
||||
continue;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,38 +0,0 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
import com.iflytek.skillhub.domain.namespace.Namespace;
|
||||
import java.time.Instant;
|
||||
|
||||
public record AdminNamespaceDetailResponse(
|
||||
Long id,
|
||||
String slug,
|
||||
String displayName,
|
||||
String status,
|
||||
String description,
|
||||
String type,
|
||||
String avatarUrl,
|
||||
String createdBy,
|
||||
Instant createdAt,
|
||||
Instant updatedAt,
|
||||
AdminNamespaceStatsResponse stats,
|
||||
AdminNamespacePermissionsResponse permissions
|
||||
) {
|
||||
public static AdminNamespaceDetailResponse from(Namespace namespace,
|
||||
AdminNamespaceStatsResponse stats,
|
||||
AdminNamespacePermissionsResponse permissions) {
|
||||
return new AdminNamespaceDetailResponse(
|
||||
namespace.getId(),
|
||||
namespace.getSlug(),
|
||||
namespace.getDisplayName(),
|
||||
namespace.getStatus().name(),
|
||||
namespace.getDescription(),
|
||||
namespace.getType().name(),
|
||||
namespace.getAvatarUrl(),
|
||||
namespace.getCreatedBy(),
|
||||
namespace.getCreatedAt(),
|
||||
namespace.getUpdatedAt(),
|
||||
stats,
|
||||
permissions
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record AdminNamespaceListResponse(
|
||||
List<AdminNamespaceSummaryResponse> items,
|
||||
long total,
|
||||
int page,
|
||||
int size,
|
||||
AdminNamespaceListStatsResponse stats
|
||||
) {}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
public record AdminNamespaceListStatsResponse(
|
||||
long total,
|
||||
long active,
|
||||
long frozen,
|
||||
long archived
|
||||
) {}
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
import com.iflytek.skillhub.domain.namespace.Namespace;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceAccessPolicy;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceStatus;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceType;
|
||||
|
||||
public record AdminNamespacePermissionsResponse(
|
||||
NamespaceRole currentUserRole,
|
||||
boolean platformOverride,
|
||||
boolean immutable,
|
||||
boolean canManageMembers,
|
||||
boolean canGovernNamespace,
|
||||
boolean canPublish,
|
||||
boolean canTransferOwnership,
|
||||
boolean canFreeze,
|
||||
boolean canUnfreeze,
|
||||
boolean canArchive,
|
||||
boolean canRestore
|
||||
) {
|
||||
public static AdminNamespacePermissionsResponse forSuperAdmin(Namespace namespace,
|
||||
NamespaceRole currentUserRole,
|
||||
NamespaceAccessPolicy accessPolicy) {
|
||||
boolean mutableTeam = namespace.getType() == NamespaceType.TEAM;
|
||||
return new AdminNamespacePermissionsResponse(
|
||||
currentUserRole,
|
||||
true,
|
||||
accessPolicy.isImmutable(namespace),
|
||||
mutableTeam && namespace.getStatus() == NamespaceStatus.ACTIVE,
|
||||
mutableTeam,
|
||||
mutableTeam && namespace.getStatus() == NamespaceStatus.ACTIVE,
|
||||
mutableTeam && namespace.getStatus() == NamespaceStatus.ACTIVE,
|
||||
mutableTeam && namespace.getStatus() == NamespaceStatus.ACTIVE,
|
||||
mutableTeam && namespace.getStatus() == NamespaceStatus.FROZEN,
|
||||
mutableTeam && namespace.getStatus() != NamespaceStatus.ARCHIVED,
|
||||
mutableTeam && namespace.getStatus() == NamespaceStatus.ARCHIVED
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
public record AdminNamespaceStatsResponse(
|
||||
long memberCount,
|
||||
long skillCount
|
||||
) {}
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
import com.iflytek.skillhub.domain.namespace.Namespace;
|
||||
import java.time.Instant;
|
||||
|
||||
public record AdminNamespaceSummaryResponse(
|
||||
Long id,
|
||||
String slug,
|
||||
String displayName,
|
||||
String status,
|
||||
String description,
|
||||
String type,
|
||||
String avatarUrl,
|
||||
String createdBy,
|
||||
Instant createdAt,
|
||||
Instant updatedAt,
|
||||
AdminNamespaceStatsResponse stats,
|
||||
AdminNamespacePermissionsResponse permissions
|
||||
) {
|
||||
public static AdminNamespaceSummaryResponse from(Namespace namespace,
|
||||
AdminNamespaceStatsResponse stats,
|
||||
AdminNamespacePermissionsResponse permissions) {
|
||||
return new AdminNamespaceSummaryResponse(
|
||||
namespace.getId(),
|
||||
namespace.getSlug(),
|
||||
namespace.getDisplayName(),
|
||||
namespace.getStatus().name(),
|
||||
namespace.getDescription(),
|
||||
namespace.getType().name(),
|
||||
namespace.getAvatarUrl(),
|
||||
namespace.getCreatedBy(),
|
||||
namespace.getCreatedAt(),
|
||||
namespace.getUpdatedAt(),
|
||||
stats,
|
||||
permissions
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
public record ComplianceEvidenceResponse(
|
||||
String type,
|
||||
String path,
|
||||
String url,
|
||||
String sha256
|
||||
) {}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record ComplianceMappingResponse(
|
||||
String standard,
|
||||
String version,
|
||||
String controlId,
|
||||
String title,
|
||||
List<ComplianceEvidenceResponse> evidence
|
||||
) {}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record ComplianceSnapshotResponse(
|
||||
String schemaVersion,
|
||||
List<ComplianceMappingResponse> items,
|
||||
String digest
|
||||
) {}
|
||||
|
|
@ -1,9 +1,7 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
public record SkillSummaryResponse(
|
||||
Long id,
|
||||
|
|
@ -18,81 +16,9 @@ public record SkillSummaryResponse(
|
|||
Integer ratingCount,
|
||||
String namespace,
|
||||
Instant updatedAt,
|
||||
String ownerId,
|
||||
String ownerDisplayName,
|
||||
boolean canSubmitPromotion,
|
||||
SkillLifecycleVersionResponse headlineVersion,
|
||||
SkillLifecycleVersionResponse publishedVersion,
|
||||
SkillLifecycleVersionResponse ownerPreviewVersion,
|
||||
String resolutionMode,
|
||||
ComplianceSnapshotResponse complianceSnapshot,
|
||||
/**
|
||||
* Labels attached to the skill, present only when the caller asked for them.
|
||||
* Left out of the payload otherwise, so responses are unchanged for callers
|
||||
* that do not opt in.
|
||||
*/
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
List<SkillLabelDto> labels
|
||||
) {
|
||||
|
||||
/**
|
||||
* Summary without label projection.
|
||||
*/
|
||||
public SkillSummaryResponse(
|
||||
Long id,
|
||||
String slug,
|
||||
String displayName,
|
||||
String summary,
|
||||
String visibility,
|
||||
String status,
|
||||
Long downloadCount,
|
||||
Integer starCount,
|
||||
BigDecimal ratingAvg,
|
||||
Integer ratingCount,
|
||||
String namespace,
|
||||
Instant updatedAt,
|
||||
boolean canSubmitPromotion,
|
||||
SkillLifecycleVersionResponse headlineVersion,
|
||||
SkillLifecycleVersionResponse publishedVersion,
|
||||
SkillLifecycleVersionResponse ownerPreviewVersion,
|
||||
String resolutionMode,
|
||||
ComplianceSnapshotResponse complianceSnapshot) {
|
||||
this(id, slug, displayName, summary, visibility, status, downloadCount, starCount, ratingAvg,
|
||||
ratingCount, namespace, updatedAt, null, null, canSubmitPromotion, headlineVersion, publishedVersion,
|
||||
ownerPreviewVersion, resolutionMode, complianceSnapshot, null);
|
||||
}
|
||||
|
||||
/** Summary with owner information but without an optional label projection. */
|
||||
public SkillSummaryResponse(
|
||||
Long id,
|
||||
String slug,
|
||||
String displayName,
|
||||
String summary,
|
||||
String visibility,
|
||||
String status,
|
||||
Long downloadCount,
|
||||
Integer starCount,
|
||||
BigDecimal ratingAvg,
|
||||
Integer ratingCount,
|
||||
String namespace,
|
||||
Instant updatedAt,
|
||||
String ownerId,
|
||||
String ownerDisplayName,
|
||||
boolean canSubmitPromotion,
|
||||
SkillLifecycleVersionResponse headlineVersion,
|
||||
SkillLifecycleVersionResponse publishedVersion,
|
||||
SkillLifecycleVersionResponse ownerPreviewVersion,
|
||||
String resolutionMode,
|
||||
ComplianceSnapshotResponse complianceSnapshot) {
|
||||
this(id, slug, displayName, summary, visibility, status, downloadCount, starCount, ratingAvg,
|
||||
ratingCount, namespace, updatedAt, ownerId, ownerDisplayName, canSubmitPromotion,
|
||||
headlineVersion, publishedVersion, ownerPreviewVersion, resolutionMode, complianceSnapshot, null);
|
||||
}
|
||||
|
||||
public SkillSummaryResponse withLabels(List<SkillLabelDto> labels) {
|
||||
return new SkillSummaryResponse(id, slug, displayName, summary, visibility, status, downloadCount,
|
||||
starCount, ratingAvg, ratingCount, namespace, updatedAt, ownerId, ownerDisplayName,
|
||||
canSubmitPromotion, headlineVersion,
|
||||
publishedVersion, ownerPreviewVersion, resolutionMode, complianceSnapshot, labels);
|
||||
}
|
||||
}
|
||||
String resolutionMode
|
||||
) {}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,5 @@ public record SkillVersionDetailResponse(
|
|||
long totalSize,
|
||||
Instant publishedAt,
|
||||
String parsedMetadataJson,
|
||||
String manifestJson,
|
||||
ComplianceSnapshotResponse complianceSnapshot
|
||||
String manifestJson
|
||||
) {}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,5 @@ public record SkillVersionResponse(
|
|||
int fileCount,
|
||||
long totalSize,
|
||||
Instant publishedAt,
|
||||
boolean downloadAvailable,
|
||||
ComplianceSnapshotResponse complianceSnapshot
|
||||
boolean downloadAvailable
|
||||
) {}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import com.iflytek.skillhub.domain.skill.Skill;
|
|||
import com.iflytek.skillhub.domain.skill.SkillRepository;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
|
||||
import com.iflytek.skillhub.domain.social.SkillSubscriptionService;
|
||||
import com.iflytek.skillhub.domain.social.SubscriptionRecipientEligibility;
|
||||
import com.iflytek.skillhub.notification.domain.NotificationCategory;
|
||||
import com.iflytek.skillhub.notification.service.NotificationDispatcher;
|
||||
import org.slf4j.Logger;
|
||||
|
|
@ -34,7 +33,6 @@ public class NotificationEventListener {
|
|||
private final NotificationDispatcher dispatcher;
|
||||
private final SkillSubscriptionService skillSubscriptionService;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final SubscriptionRecipientEligibility subscriptionEligibility;
|
||||
|
||||
public NotificationEventListener(SkillRepository skillRepository,
|
||||
SkillVersionRepository skillVersionRepository,
|
||||
|
|
@ -42,8 +40,7 @@ public class NotificationEventListener {
|
|||
RecipientResolver recipientResolver,
|
||||
NotificationDispatcher dispatcher,
|
||||
SkillSubscriptionService skillSubscriptionService,
|
||||
ObjectMapper objectMapper,
|
||||
SubscriptionRecipientEligibility subscriptionEligibility) {
|
||||
ObjectMapper objectMapper) {
|
||||
this.skillRepository = skillRepository;
|
||||
this.skillVersionRepository = skillVersionRepository;
|
||||
this.namespaceRepository = namespaceRepository;
|
||||
|
|
@ -51,7 +48,6 @@ public class NotificationEventListener {
|
|||
this.dispatcher = dispatcher;
|
||||
this.skillSubscriptionService = skillSubscriptionService;
|
||||
this.objectMapper = objectMapper;
|
||||
this.subscriptionEligibility = subscriptionEligibility;
|
||||
}
|
||||
|
||||
@Async("skillhubEventExecutor")
|
||||
|
|
@ -78,8 +74,6 @@ public class NotificationEventListener {
|
|||
if (subscribers.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
var namespace = namespaceRepository.findById(skill.getNamespaceId()).orElse(null);
|
||||
subscribers = subscriptionEligibility.currentRecipients(skill, namespace, subscribers);
|
||||
String title = "Skill updated: " + skillDisplayName(skill);
|
||||
Map<String, Object> body = bodyWithSkill(skill);
|
||||
versionLabel(event.versionId(), body);
|
||||
|
|
@ -102,8 +96,6 @@ public class NotificationEventListener {
|
|||
if (subscribers.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
var namespace = namespaceRepository.findById(skill.getNamespaceId()).orElse(null);
|
||||
subscribers = subscriptionEligibility.yankedRecipients(skill, namespace, subscribers, event.wasPublished());
|
||||
String title = "Skill version yanked: " + skillDisplayName(skill);
|
||||
Map<String, Object> body = bodyWithSkill(skill);
|
||||
versionLabel(event.versionId(), body);
|
||||
|
|
|
|||
|
|
@ -1,40 +0,0 @@
|
|||
package com.iflytek.skillhub.listener;
|
||||
|
||||
import com.iflytek.skillhub.domain.event.UserActivatedEvent;
|
||||
import com.iflytek.skillhub.domain.namespace.PersonalNamespaceOwner;
|
||||
import com.iflytek.skillhub.domain.namespace.PersonalNamespaceProvisioningService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.event.TransactionalEventListener;
|
||||
|
||||
/**
|
||||
* Creates a newly activated account's own namespace once the account itself is committed.
|
||||
*
|
||||
* <p>Runs synchronously rather than on the event executor so the namespace exists by the time the
|
||||
* user's next request arrives, and swallows failures so a naming clash or a database hiccup costs
|
||||
* the user a namespace rather than their registration or login.
|
||||
*/
|
||||
@Component
|
||||
public class PersonalNamespaceProvisioningListener {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(PersonalNamespaceProvisioningListener.class);
|
||||
|
||||
private final PersonalNamespaceProvisioningService personalNamespaceProvisioningService;
|
||||
|
||||
public PersonalNamespaceProvisioningListener(
|
||||
PersonalNamespaceProvisioningService personalNamespaceProvisioningService) {
|
||||
this.personalNamespaceProvisioningService = personalNamespaceProvisioningService;
|
||||
}
|
||||
|
||||
@TransactionalEventListener
|
||||
public void onUserActivated(UserActivatedEvent event) {
|
||||
try {
|
||||
personalNamespaceProvisioningService.provisionFor(
|
||||
new PersonalNamespaceOwner(event.userId(), event.username(), event.email()));
|
||||
} catch (RuntimeException e) {
|
||||
log.warn("Personal namespace provisioning failed for user {}; the account is unaffected",
|
||||
event.userId(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
package com.iflytek.skillhub.ratelimit;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.iflytek.skillhub.config.RateLimitProperties;
|
||||
import com.iflytek.skillhub.dto.ApiResponse;
|
||||
import com.iflytek.skillhub.dto.ApiResponseFactory;
|
||||
import com.iflytek.skillhub.metrics.SkillHubMetrics;
|
||||
|
|
@ -29,22 +28,19 @@ public class RateLimitInterceptor implements HandlerInterceptor {
|
|||
private final ApiResponseFactory apiResponseFactory;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final SkillHubMetrics metrics;
|
||||
private final RateLimitProperties properties;
|
||||
|
||||
public RateLimitInterceptor(RateLimiter rateLimiter,
|
||||
ClientIpResolver clientIpResolver,
|
||||
AnonymousDownloadIdentityService anonymousDownloadIdentityService,
|
||||
ApiResponseFactory apiResponseFactory,
|
||||
ObjectMapper objectMapper,
|
||||
SkillHubMetrics metrics,
|
||||
RateLimitProperties properties) {
|
||||
SkillHubMetrics metrics) {
|
||||
this.rateLimiter = rateLimiter;
|
||||
this.clientIpResolver = clientIpResolver;
|
||||
this.anonymousDownloadIdentityService = anonymousDownloadIdentityService;
|
||||
this.apiResponseFactory = apiResponseFactory;
|
||||
this.objectMapper = objectMapper;
|
||||
this.metrics = metrics;
|
||||
this.properties = properties;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
@ -60,33 +56,23 @@ public class RateLimitInterceptor implements HandlerInterceptor {
|
|||
return true;
|
||||
}
|
||||
|
||||
// Master switch: when disabled, perform no quota checks at all.
|
||||
if (!properties.isEnabled()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Determine if user is authenticated
|
||||
String userId = (String) request.getAttribute("userId");
|
||||
boolean isAuthenticated = userId != null;
|
||||
|
||||
// Effective limits: runtime config overrides (per category) fall back to the
|
||||
// annotation defaults, so unconfigured deployments behave exactly as before.
|
||||
String category = rateLimit.category();
|
||||
int windowSeconds = properties.windowSecondsFor(category, rateLimit.windowSeconds());
|
||||
int limit = isAuthenticated
|
||||
? properties.authenticatedFor(category, rateLimit.authenticated())
|
||||
: properties.anonymousFor(category, rateLimit.anonymous());
|
||||
String resourceSuffix = resolveResourceSuffix(category, request);
|
||||
// Get limit based on authentication status
|
||||
int limit = isAuthenticated ? rateLimit.authenticated() : rateLimit.anonymous();
|
||||
String resourceSuffix = resolveResourceSuffix(rateLimit.category(), request);
|
||||
|
||||
boolean allowed = isAuthenticated
|
||||
? rateLimiter.tryAcquire(
|
||||
"ratelimit:" + category + ":user:" + userId + resourceSuffix,
|
||||
"ratelimit:" + rateLimit.category() + ":user:" + userId + resourceSuffix,
|
||||
limit,
|
||||
windowSeconds)
|
||||
: checkAnonymousLimit(request, response, category, limit, windowSeconds, resourceSuffix);
|
||||
rateLimit.windowSeconds())
|
||||
: checkAnonymousLimit(request, response, rateLimit, limit, resourceSuffix);
|
||||
|
||||
if (!allowed) {
|
||||
metrics.incrementRateLimitExceeded(category);
|
||||
metrics.incrementRateLimitExceeded(rateLimit.category());
|
||||
response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
|
||||
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
||||
ApiResponse<Void> body = apiResponseFactory.error(429, "error.rateLimit.exceeded");
|
||||
|
|
@ -99,15 +85,14 @@ public class RateLimitInterceptor implements HandlerInterceptor {
|
|||
|
||||
private boolean checkAnonymousLimit(HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
String category,
|
||||
RateLimit rateLimit,
|
||||
int limit,
|
||||
int windowSeconds,
|
||||
String resourceSuffix) {
|
||||
if (!"download".equals(category)) {
|
||||
if (!"download".equals(rateLimit.category())) {
|
||||
return rateLimiter.tryAcquire(
|
||||
"ratelimit:" + category + ":ip:" + clientIpResolver.resolve(request) + resourceSuffix,
|
||||
"ratelimit:" + rateLimit.category() + ":ip:" + clientIpResolver.resolve(request) + resourceSuffix,
|
||||
limit,
|
||||
windowSeconds
|
||||
rateLimit.windowSeconds()
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -116,7 +101,7 @@ public class RateLimitInterceptor implements HandlerInterceptor {
|
|||
boolean ipAllowed = rateLimiter.tryAcquire(
|
||||
"ratelimit:download:ip:" + identity.ipHash() + resourceSuffix,
|
||||
limit,
|
||||
windowSeconds
|
||||
rateLimit.windowSeconds()
|
||||
);
|
||||
if (!ipAllowed) {
|
||||
return false;
|
||||
|
|
@ -124,7 +109,7 @@ public class RateLimitInterceptor implements HandlerInterceptor {
|
|||
return rateLimiter.tryAcquire(
|
||||
"ratelimit:download:anon:" + identity.cookieHash() + resourceSuffix,
|
||||
limit,
|
||||
windowSeconds
|
||||
rateLimit.windowSeconds()
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,19 +0,0 @@
|
|||
package com.iflytek.skillhub.repository;
|
||||
|
||||
import com.iflytek.skillhub.domain.namespace.Namespace;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceStatus;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceType;
|
||||
import com.iflytek.skillhub.dto.AdminNamespaceListStatsResponse;
|
||||
import java.util.Map;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
|
||||
public interface AdminNamespaceQueryRepository {
|
||||
Page<Namespace> search(String keyword, NamespaceStatus status, NamespaceType type, Pageable pageable);
|
||||
|
||||
AdminNamespaceListStatsResponse stats();
|
||||
|
||||
Map<Long, Long> countMembersByNamespaceId(Iterable<Long> namespaceIds);
|
||||
|
||||
Map<Long, Long> countSkillsByNamespaceId(Iterable<Long> namespaceIds);
|
||||
}
|
||||
|
|
@ -1,115 +0,0 @@
|
|||
package com.iflytek.skillhub.repository;
|
||||
|
||||
import com.iflytek.skillhub.domain.namespace.Namespace;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceStatus;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceType;
|
||||
import com.iflytek.skillhub.dto.AdminNamespaceListStatsResponse;
|
||||
import jakarta.persistence.EntityManager;
|
||||
import jakarta.persistence.PersistenceContext;
|
||||
import jakarta.persistence.TypedQuery;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageImpl;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
@Repository
|
||||
public class JpaAdminNamespaceQueryRepository implements AdminNamespaceQueryRepository {
|
||||
|
||||
@PersistenceContext
|
||||
private EntityManager entityManager;
|
||||
|
||||
@Override
|
||||
public Page<Namespace> search(String keyword, NamespaceStatus status, NamespaceType type, Pageable pageable) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
String whereClause = buildWhereClause(keyword, status, type, params);
|
||||
String orderClause = " ORDER BY n.updatedAt DESC, n.slug ASC";
|
||||
|
||||
TypedQuery<Namespace> query = entityManager.createQuery(
|
||||
"SELECT n FROM Namespace n" + whereClause + orderClause,
|
||||
Namespace.class);
|
||||
params.forEach(query::setParameter);
|
||||
query.setFirstResult((int) pageable.getOffset());
|
||||
query.setMaxResults(pageable.getPageSize());
|
||||
|
||||
TypedQuery<Long> countQuery = entityManager.createQuery(
|
||||
"SELECT COUNT(n) FROM Namespace n" + whereClause,
|
||||
Long.class);
|
||||
params.forEach(countQuery::setParameter);
|
||||
|
||||
return new PageImpl<>(query.getResultList(), pageable, countQuery.getSingleResult());
|
||||
}
|
||||
|
||||
@Override
|
||||
public AdminNamespaceListStatsResponse stats() {
|
||||
long total = entityManager.createQuery("SELECT COUNT(n) FROM Namespace n", Long.class).getSingleResult();
|
||||
long active = countByStatus(NamespaceStatus.ACTIVE);
|
||||
long frozen = countByStatus(NamespaceStatus.FROZEN);
|
||||
long archived = countByStatus(NamespaceStatus.ARCHIVED);
|
||||
return new AdminNamespaceListStatsResponse(total, active, frozen, archived);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<Long, Long> countMembersByNamespaceId(Iterable<Long> namespaceIds) {
|
||||
return countByNamespaceId("""
|
||||
SELECT m.namespaceId, COUNT(m)
|
||||
FROM NamespaceMember m
|
||||
WHERE m.namespaceId IN :namespaceIds
|
||||
GROUP BY m.namespaceId
|
||||
""", namespaceIds);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<Long, Long> countSkillsByNamespaceId(Iterable<Long> namespaceIds) {
|
||||
return countByNamespaceId("""
|
||||
SELECT s.namespaceId, COUNT(s)
|
||||
FROM Skill s
|
||||
WHERE s.namespaceId IN :namespaceIds
|
||||
GROUP BY s.namespaceId
|
||||
""", namespaceIds);
|
||||
}
|
||||
|
||||
private String buildWhereClause(String keyword,
|
||||
NamespaceStatus status,
|
||||
NamespaceType type,
|
||||
Map<String, Object> params) {
|
||||
StringBuilder where = new StringBuilder(" WHERE 1 = 1");
|
||||
if (StringUtils.hasText(keyword)) {
|
||||
where.append(" AND (LOWER(n.slug) LIKE :keyword OR LOWER(n.displayName) LIKE :keyword OR LOWER(n.description) LIKE :keyword)");
|
||||
params.put("keyword", "%" + keyword.trim().toLowerCase() + "%");
|
||||
}
|
||||
if (status != null) {
|
||||
where.append(" AND n.status = :status");
|
||||
params.put("status", status);
|
||||
}
|
||||
if (type != null) {
|
||||
where.append(" AND n.type = :type");
|
||||
params.put("type", type);
|
||||
}
|
||||
return where.toString();
|
||||
}
|
||||
|
||||
private long countByStatus(NamespaceStatus status) {
|
||||
return entityManager.createQuery("SELECT COUNT(n) FROM Namespace n WHERE n.status = :status", Long.class)
|
||||
.setParameter("status", status)
|
||||
.getSingleResult();
|
||||
}
|
||||
|
||||
private Map<Long, Long> countByNamespaceId(String jpql, Iterable<Long> namespaceIds) {
|
||||
java.util.List<Long> ids = new java.util.ArrayList<>();
|
||||
namespaceIds.forEach(ids::add);
|
||||
if (ids.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
Map<Long, Long> counts = new LinkedHashMap<>();
|
||||
for (Object[] row : entityManager.createQuery(jpql, Object[].class)
|
||||
.setParameter("namespaceIds", ids)
|
||||
.getResultList()) {
|
||||
counts.put((Long) row[0], (Long) row[1]);
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
}
|
||||
|
|
@ -9,8 +9,6 @@ import com.iflytek.skillhub.domain.review.ReviewTaskStatus;
|
|||
import com.iflytek.skillhub.domain.skill.Skill;
|
||||
import com.iflytek.skillhub.domain.skill.SkillStatus;
|
||||
import com.iflytek.skillhub.domain.skill.service.SkillLifecycleProjectionService;
|
||||
import com.iflytek.skillhub.domain.user.UserAccount;
|
||||
import com.iflytek.skillhub.domain.user.UserAccountRepository;
|
||||
import com.iflytek.skillhub.dto.SkillLifecycleVersionResponse;
|
||||
import com.iflytek.skillhub.dto.SkillSummaryResponse;
|
||||
import java.util.List;
|
||||
|
|
@ -18,7 +16,6 @@ import java.util.Map;
|
|||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
|
||||
@Repository
|
||||
public class JpaMySkillQueryRepository implements MySkillQueryRepository {
|
||||
|
|
@ -26,23 +23,13 @@ public class JpaMySkillQueryRepository implements MySkillQueryRepository {
|
|||
private final NamespaceRepository namespaceRepository;
|
||||
private final PromotionRequestRepository promotionRequestRepository;
|
||||
private final SkillLifecycleProjectionService skillLifecycleProjectionService;
|
||||
private final UserAccountRepository userAccountRepository;
|
||||
|
||||
public JpaMySkillQueryRepository(NamespaceRepository namespaceRepository,
|
||||
PromotionRequestRepository promotionRequestRepository,
|
||||
SkillLifecycleProjectionService skillLifecycleProjectionService) {
|
||||
this(namespaceRepository, promotionRequestRepository, skillLifecycleProjectionService, null);
|
||||
}
|
||||
|
||||
@Autowired
|
||||
public JpaMySkillQueryRepository(NamespaceRepository namespaceRepository,
|
||||
PromotionRequestRepository promotionRequestRepository,
|
||||
SkillLifecycleProjectionService skillLifecycleProjectionService,
|
||||
UserAccountRepository userAccountRepository) {
|
||||
this.namespaceRepository = namespaceRepository;
|
||||
this.promotionRequestRepository = promotionRequestRepository;
|
||||
this.skillLifecycleProjectionService = skillLifecycleProjectionService;
|
||||
this.userAccountRepository = userAccountRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
@ -54,19 +41,14 @@ public class JpaMySkillQueryRepository implements MySkillQueryRepository {
|
|||
skills.stream().map(Skill::getNamespaceId).distinct().toList())
|
||||
.stream()
|
||||
.collect(Collectors.toMap(Namespace::getId, Function.identity()));
|
||||
Map<String, UserAccount> ownersById = userAccountRepository == null
|
||||
? Map.of()
|
||||
: userAccountRepository.findByIdIn(skills.stream().map(Skill::getOwnerId).distinct().toList())
|
||||
.stream().collect(Collectors.toMap(UserAccount::getId, Function.identity()));
|
||||
return skills.stream()
|
||||
.map(skill -> toSummaryResponse(skill, currentUserId, namespacesById, ownersById))
|
||||
.map(skill -> toSummaryResponse(skill, currentUserId, namespacesById))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private SkillSummaryResponse toSummaryResponse(Skill skill,
|
||||
String currentUserId,
|
||||
Map<Long, Namespace> namespacesById,
|
||||
Map<String, UserAccount> ownersById) {
|
||||
Map<Long, Namespace> namespacesById) {
|
||||
Namespace namespace = namespacesById.get(skill.getNamespaceId());
|
||||
SkillLifecycleProjectionService.Projection projection = skillLifecycleProjectionService.projectForViewer(
|
||||
skill,
|
||||
|
|
@ -93,17 +75,11 @@ public class JpaMySkillQueryRepository implements MySkillQueryRepository {
|
|||
skill.getRatingCount(),
|
||||
namespace != null ? namespace.getSlug() : null,
|
||||
skill.getUpdatedAt(),
|
||||
skill.getOwnerId(),
|
||||
ownersById.get(skill.getOwnerId()) != null
|
||||
? ownersById.get(skill.getOwnerId()).getDisplayName()
|
||||
: null,
|
||||
canSubmitPromotion(skill, publishedVersion, namespace),
|
||||
toLifecycleVersion(headlineVersion),
|
||||
toLifecycleVersion(publishedVersion),
|
||||
toLifecycleVersion(ownerPreviewVersion),
|
||||
projection.resolutionMode().name(),
|
||||
null,
|
||||
null
|
||||
projection.resolutionMode().name()
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,347 +0,0 @@
|
|||
package com.iflytek.skillhub.service;
|
||||
|
||||
import com.iflytek.skillhub.domain.namespace.Namespace;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceAccessPolicy;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceGovernanceService;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceMember;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceMemberService;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceService;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceStatus;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceType;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
|
||||
import com.iflytek.skillhub.domain.user.UserAccount;
|
||||
import com.iflytek.skillhub.domain.user.UserAccountRepository;
|
||||
import com.iflytek.skillhub.dto.AdminNamespaceDetailResponse;
|
||||
import com.iflytek.skillhub.dto.AdminNamespaceListResponse;
|
||||
import com.iflytek.skillhub.dto.AdminNamespacePermissionsResponse;
|
||||
import com.iflytek.skillhub.dto.AdminNamespaceStatsResponse;
|
||||
import com.iflytek.skillhub.dto.AdminNamespaceSummaryResponse;
|
||||
import com.iflytek.skillhub.dto.BatchMemberRequest;
|
||||
import com.iflytek.skillhub.dto.BatchMemberResponse;
|
||||
import com.iflytek.skillhub.dto.BatchMemberResult;
|
||||
import com.iflytek.skillhub.dto.MemberRequest;
|
||||
import com.iflytek.skillhub.dto.MemberResponse;
|
||||
import com.iflytek.skillhub.dto.MessageResponse;
|
||||
import com.iflytek.skillhub.dto.NamespaceCandidateUserResponse;
|
||||
import com.iflytek.skillhub.dto.NamespaceLifecycleRequest;
|
||||
import com.iflytek.skillhub.dto.PageResponse;
|
||||
import com.iflytek.skillhub.dto.UpdateMemberRoleRequest;
|
||||
import com.iflytek.skillhub.repository.AdminNamespaceQueryRepository;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
@Service
|
||||
public class AdminNamespaceAppService {
|
||||
|
||||
private final AdminNamespaceQueryRepository adminNamespaceQueryRepository;
|
||||
private final NamespaceService namespaceService;
|
||||
private final NamespaceGovernanceService namespaceGovernanceService;
|
||||
private final NamespaceMemberService namespaceMemberService;
|
||||
private final NamespaceMemberRepository namespaceMemberRepository;
|
||||
private final NamespaceMemberCandidateService namespaceMemberCandidateService;
|
||||
private final NamespaceAccessPolicy namespaceAccessPolicy;
|
||||
private final UserAccountRepository userAccountRepository;
|
||||
|
||||
public AdminNamespaceAppService(AdminNamespaceQueryRepository adminNamespaceQueryRepository,
|
||||
NamespaceService namespaceService,
|
||||
NamespaceGovernanceService namespaceGovernanceService,
|
||||
NamespaceMemberService namespaceMemberService,
|
||||
NamespaceMemberRepository namespaceMemberRepository,
|
||||
NamespaceMemberCandidateService namespaceMemberCandidateService,
|
||||
NamespaceAccessPolicy namespaceAccessPolicy,
|
||||
UserAccountRepository userAccountRepository) {
|
||||
this.adminNamespaceQueryRepository = adminNamespaceQueryRepository;
|
||||
this.namespaceService = namespaceService;
|
||||
this.namespaceGovernanceService = namespaceGovernanceService;
|
||||
this.namespaceMemberService = namespaceMemberService;
|
||||
this.namespaceMemberRepository = namespaceMemberRepository;
|
||||
this.namespaceMemberCandidateService = namespaceMemberCandidateService;
|
||||
this.namespaceAccessPolicy = namespaceAccessPolicy;
|
||||
this.userAccountRepository = userAccountRepository;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public AdminNamespaceListResponse list(String keyword,
|
||||
String status,
|
||||
String type,
|
||||
int page,
|
||||
int size,
|
||||
String actorUserId) {
|
||||
PageRequest pageRequest = PageRequest.of(Math.max(page, 0), normalizePageSize(size));
|
||||
Page<Namespace> namespaces = adminNamespaceQueryRepository.search(
|
||||
keyword,
|
||||
parseStatus(status),
|
||||
parseType(type),
|
||||
pageRequest);
|
||||
List<Long> namespaceIds = namespaces.getContent().stream().map(Namespace::getId).toList();
|
||||
Map<Long, Long> memberCounts = adminNamespaceQueryRepository.countMembersByNamespaceId(namespaceIds);
|
||||
Map<Long, Long> skillCounts = adminNamespaceQueryRepository.countSkillsByNamespaceId(namespaceIds);
|
||||
Map<Long, NamespaceRole> roles = loadRoles(namespaceIds, actorUserId);
|
||||
|
||||
List<AdminNamespaceSummaryResponse> items = namespaces.getContent().stream()
|
||||
.map(namespace -> AdminNamespaceSummaryResponse.from(
|
||||
namespace,
|
||||
stats(namespace, memberCounts, skillCounts),
|
||||
permissions(namespace, roles.get(namespace.getId()))))
|
||||
.toList();
|
||||
|
||||
return new AdminNamespaceListResponse(
|
||||
items,
|
||||
namespaces.getTotalElements(),
|
||||
namespaces.getNumber(),
|
||||
namespaces.getSize(),
|
||||
adminNamespaceQueryRepository.stats());
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public AdminNamespaceDetailResponse detail(String slug, String actorUserId) {
|
||||
Namespace namespace = namespaceService.getNamespaceBySlug(slug);
|
||||
Map<Long, Long> memberCounts = adminNamespaceQueryRepository.countMembersByNamespaceId(List.of(namespace.getId()));
|
||||
Map<Long, Long> skillCounts = adminNamespaceQueryRepository.countSkillsByNamespaceId(List.of(namespace.getId()));
|
||||
NamespaceRole role = namespaceMemberRepository.findByNamespaceIdAndUserId(namespace.getId(), actorUserId)
|
||||
.map(NamespaceMember::getRole)
|
||||
.orElse(null);
|
||||
return AdminNamespaceDetailResponse.from(namespace, stats(namespace, memberCounts, skillCounts), permissions(namespace, role));
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public PageResponse<MemberResponse> listMembers(String slug, int page, int size) {
|
||||
Namespace namespace = namespaceService.getNamespaceBySlug(slug);
|
||||
Page<NamespaceMember> members = namespaceMemberService.listMembers(
|
||||
namespace.getId(),
|
||||
PageRequest.of(Math.max(page, 0), normalizePageSize(size)));
|
||||
|
||||
List<String> memberUserIds = members.getContent().stream()
|
||||
.map(NamespaceMember::getUserId)
|
||||
.toList();
|
||||
Map<String, UserAccount> userMap = memberUserIds.isEmpty()
|
||||
? Map.of()
|
||||
: userAccountRepository.findByIdIn(memberUserIds).stream()
|
||||
.collect(Collectors.toMap(UserAccount::getId, Function.identity()));
|
||||
|
||||
return PageResponse.from(members.map(member -> MemberResponse.from(member, userMap.get(member.getUserId()))));
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<NamespaceCandidateUserResponse> searchMemberCandidates(String slug, String search, int size) {
|
||||
return namespaceMemberCandidateService.searchCandidatesForPlatformAdmin(slug, search, size);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public MemberResponse addMember(String slug, MemberRequest request, String actorUserId) {
|
||||
Namespace namespace = requireMutableTeamNamespace(slug);
|
||||
if (request.role() == NamespaceRole.OWNER) {
|
||||
throw new DomainBadRequestException("error.namespace.member.owner.assignDirect");
|
||||
}
|
||||
if (namespaceMemberRepository.findByNamespaceIdAndUserId(namespace.getId(), request.userId()).isPresent()) {
|
||||
throw new DomainBadRequestException("error.namespace.member.alreadyExists");
|
||||
}
|
||||
NamespaceMember member = namespaceMemberRepository.save(new NamespaceMember(namespace.getId(), request.userId(), request.role()));
|
||||
return MemberResponse.from(member, userAccountRepository.findById(member.getUserId()).orElse(null));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public BatchMemberResponse batchAddMembers(String slug, BatchMemberRequest request, String actorUserId) {
|
||||
Namespace namespace = requireMutableTeamNamespace(slug);
|
||||
List<BatchMemberResult> results = new ArrayList<>();
|
||||
int successCount = 0;
|
||||
int failureCount = 0;
|
||||
|
||||
for (MemberRequest member : request.members()) {
|
||||
try {
|
||||
if (member.role() == NamespaceRole.OWNER) {
|
||||
throw new DomainBadRequestException("error.namespace.member.owner.assignDirect");
|
||||
}
|
||||
if (namespaceMemberRepository.findByNamespaceIdAndUserId(namespace.getId(), member.userId()).isPresent()) {
|
||||
throw new DomainBadRequestException("error.namespace.member.alreadyExists");
|
||||
}
|
||||
namespaceMemberRepository.save(new NamespaceMember(namespace.getId(), member.userId(), member.role()));
|
||||
results.add(BatchMemberResult.success(member.userId(), member.role().name()));
|
||||
successCount++;
|
||||
} catch (Exception e) {
|
||||
results.add(BatchMemberResult.failure(member.userId(), member.role().name(), mapBatchError(e)));
|
||||
failureCount++;
|
||||
}
|
||||
}
|
||||
return new BatchMemberResponse(request.members().size(), successCount, failureCount, results);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public MemberResponse updateMemberRole(String slug, String userId, UpdateMemberRoleRequest request, String actorUserId) {
|
||||
Namespace namespace = requireMutableTeamNamespace(slug);
|
||||
if (request.role() == NamespaceRole.OWNER) {
|
||||
throw new DomainBadRequestException("error.namespace.member.owner.setDirect");
|
||||
}
|
||||
NamespaceMember member = namespaceMemberRepository.findByNamespaceIdAndUserId(namespace.getId(), userId)
|
||||
.orElseThrow(() -> new DomainBadRequestException("error.namespace.member.notFound"));
|
||||
if (member.getRole() == NamespaceRole.OWNER) {
|
||||
throw new DomainBadRequestException("error.namespace.member.owner.setDirect");
|
||||
}
|
||||
member.setRole(request.role());
|
||||
NamespaceMember saved = namespaceMemberRepository.save(member);
|
||||
return MemberResponse.from(saved, userAccountRepository.findById(saved.getUserId()).orElse(null));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public MessageResponse removeMember(String slug, String userId, String actorUserId) {
|
||||
Namespace namespace = requireMutableTeamNamespace(slug);
|
||||
NamespaceMember member = namespaceMemberRepository.findByNamespaceIdAndUserId(namespace.getId(), userId)
|
||||
.orElseThrow(() -> new DomainBadRequestException("error.namespace.member.notFound"));
|
||||
if (member.getRole() == NamespaceRole.OWNER) {
|
||||
throw new DomainBadRequestException("error.namespace.member.owner.remove");
|
||||
}
|
||||
namespaceMemberRepository.deleteByNamespaceIdAndUserId(namespace.getId(), userId);
|
||||
return new MessageResponse("Member removed successfully");
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public MessageResponse transferOwnership(String slug, String newOwnerId, String actorUserId) {
|
||||
Namespace namespace = requireMutableTeamNamespace(slug);
|
||||
NamespaceMember currentOwner = namespaceMemberRepository.findByNamespaceIdAndRoleIn(namespace.getId(), List.of(NamespaceRole.OWNER))
|
||||
.stream()
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new DomainBadRequestException("error.namespace.owner.current.notFound"));
|
||||
NamespaceMember newOwner = namespaceMemberRepository.findByNamespaceIdAndUserId(namespace.getId(), newOwnerId)
|
||||
.orElseThrow(() -> new DomainBadRequestException("error.namespace.owner.new.notFound"));
|
||||
currentOwner.setRole(NamespaceRole.ADMIN);
|
||||
newOwner.setRole(NamespaceRole.OWNER);
|
||||
namespaceMemberRepository.save(currentOwner);
|
||||
namespaceMemberRepository.save(newOwner);
|
||||
return new MessageResponse("Ownership transferred successfully");
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AdminNamespaceDetailResponse freeze(String slug,
|
||||
NamespaceLifecycleRequest request,
|
||||
String actorUserId,
|
||||
AuditRequestContext auditContext) {
|
||||
Namespace namespace = namespaceGovernanceService.freezeNamespaceByPlatformAdmin(
|
||||
slug,
|
||||
actorUserId,
|
||||
request != null ? request.reason() : null,
|
||||
null,
|
||||
auditContext.clientIp(),
|
||||
auditContext.userAgent());
|
||||
return detail(namespace.getSlug(), actorUserId);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AdminNamespaceDetailResponse unfreeze(String slug, String actorUserId, AuditRequestContext auditContext) {
|
||||
Namespace namespace = namespaceGovernanceService.unfreezeNamespaceByPlatformAdmin(
|
||||
slug,
|
||||
actorUserId,
|
||||
null,
|
||||
auditContext.clientIp(),
|
||||
auditContext.userAgent());
|
||||
return detail(namespace.getSlug(), actorUserId);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AdminNamespaceDetailResponse archive(String slug,
|
||||
NamespaceLifecycleRequest request,
|
||||
String actorUserId,
|
||||
AuditRequestContext auditContext) {
|
||||
Namespace namespace = namespaceGovernanceService.archiveNamespaceByPlatformAdmin(
|
||||
slug,
|
||||
actorUserId,
|
||||
request != null ? request.reason() : null,
|
||||
null,
|
||||
auditContext.clientIp(),
|
||||
auditContext.userAgent());
|
||||
return detail(namespace.getSlug(), actorUserId);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AdminNamespaceDetailResponse restore(String slug, String actorUserId, AuditRequestContext auditContext) {
|
||||
Namespace namespace = namespaceGovernanceService.restoreNamespaceByPlatformAdmin(
|
||||
slug,
|
||||
actorUserId,
|
||||
null,
|
||||
auditContext.clientIp(),
|
||||
auditContext.userAgent());
|
||||
return detail(namespace.getSlug(), actorUserId);
|
||||
}
|
||||
|
||||
private AdminNamespaceStatsResponse stats(Namespace namespace,
|
||||
Map<Long, Long> memberCounts,
|
||||
Map<Long, Long> skillCounts) {
|
||||
return new AdminNamespaceStatsResponse(
|
||||
memberCounts.getOrDefault(namespace.getId(), 0L),
|
||||
skillCounts.getOrDefault(namespace.getId(), 0L));
|
||||
}
|
||||
|
||||
private AdminNamespacePermissionsResponse permissions(Namespace namespace, NamespaceRole currentUserRole) {
|
||||
return AdminNamespacePermissionsResponse.forSuperAdmin(namespace, currentUserRole, namespaceAccessPolicy);
|
||||
}
|
||||
|
||||
private Map<Long, NamespaceRole> loadRoles(List<Long> namespaceIds, String userId) {
|
||||
if (!StringUtils.hasText(userId) || namespaceIds.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
return namespaceIds.stream()
|
||||
.map(id -> namespaceMemberRepository.findByNamespaceIdAndUserId(id, userId)
|
||||
.orElse(null))
|
||||
.filter(java.util.Objects::nonNull)
|
||||
.collect(Collectors.toMap(NamespaceMember::getNamespaceId, NamespaceMember::getRole));
|
||||
}
|
||||
|
||||
private Namespace requireMutableTeamNamespace(String slug) {
|
||||
Namespace namespace = namespaceService.getNamespaceBySlug(slug);
|
||||
if (namespaceAccessPolicy.isImmutable(namespace)) {
|
||||
throw new DomainBadRequestException("error.namespace.system.immutable", namespace.getSlug());
|
||||
}
|
||||
if (!namespaceAccessPolicy.canManageMembers(namespace)) {
|
||||
throw new DomainBadRequestException("error.namespace.readonly", namespace.getSlug());
|
||||
}
|
||||
return namespace;
|
||||
}
|
||||
|
||||
private NamespaceStatus parseStatus(String status) {
|
||||
if (!StringUtils.hasText(status)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return NamespaceStatus.valueOf(status.trim().toUpperCase());
|
||||
} catch (IllegalArgumentException ex) {
|
||||
throw new DomainBadRequestException("error.namespace.status.invalid", status);
|
||||
}
|
||||
}
|
||||
|
||||
private NamespaceType parseType(String type) {
|
||||
if (!StringUtils.hasText(type)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return NamespaceType.valueOf(type.trim().toUpperCase());
|
||||
} catch (IllegalArgumentException ex) {
|
||||
throw new DomainBadRequestException("error.namespace.type.invalid", type);
|
||||
}
|
||||
}
|
||||
|
||||
private int normalizePageSize(int size) {
|
||||
if (size <= 0) {
|
||||
return 20;
|
||||
}
|
||||
return Math.min(size, 100);
|
||||
}
|
||||
|
||||
private String mapBatchError(Exception e) {
|
||||
String msg = e.getMessage();
|
||||
if (msg == null) return "UNKNOWN_ERROR";
|
||||
if (msg.contains("alreadyExists")) return "ALREADY_MEMBER";
|
||||
if (msg.contains("owner.assignDirect")) return "INVALID_ROLE";
|
||||
if (msg.contains("notFound") || msg.contains("not found")) return "USER_NOT_FOUND";
|
||||
if (msg.contains("immutable") || msg.contains("readonly")) return "NAMESPACE_READONLY";
|
||||
return "UNKNOWN_ERROR";
|
||||
}
|
||||
}
|
||||
|
|
@ -4,7 +4,6 @@ import com.iflytek.skillhub.auth.entity.Role;
|
|||
import com.iflytek.skillhub.auth.entity.UserRoleBinding;
|
||||
import com.iflytek.skillhub.auth.repository.RoleRepository;
|
||||
import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository;
|
||||
import com.iflytek.skillhub.domain.event.UserActivatedEvent;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException;
|
||||
|
|
@ -19,7 +18,6 @@ import org.springframework.data.domain.Page;
|
|||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
|
@ -46,19 +44,16 @@ public class AdminUserAppService {
|
|||
private final UserAccountRepository userAccountRepository;
|
||||
private final UserRoleBindingRepository userRoleBindingRepository;
|
||||
private final RoleRepository roleRepository;
|
||||
private final ApplicationEventPublisher eventPublisher;
|
||||
|
||||
public AdminUserAppService(
|
||||
AdminUserSearchRepository adminUserSearchRepository,
|
||||
UserAccountRepository userAccountRepository,
|
||||
UserRoleBindingRepository userRoleBindingRepository,
|
||||
RoleRepository roleRepository,
|
||||
ApplicationEventPublisher eventPublisher) {
|
||||
RoleRepository roleRepository) {
|
||||
this.adminUserSearchRepository = adminUserSearchRepository;
|
||||
this.userAccountRepository = userAccountRepository;
|
||||
this.userRoleBindingRepository = userRoleBindingRepository;
|
||||
this.roleRepository = roleRepository;
|
||||
this.eventPublisher = eventPublisher;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
|
|
@ -114,13 +109,8 @@ public class AdminUserAppService {
|
|||
UserAccount user = loadUser(userId);
|
||||
rejectSystemAccountMutation(user);
|
||||
UserStatus nextStatus = parseManageableStatus(status);
|
||||
UserStatus previousStatus = user.getStatus();
|
||||
user.setStatus(nextStatus);
|
||||
userAccountRepository.save(user);
|
||||
if (nextStatus == UserStatus.ACTIVE && previousStatus != UserStatus.ACTIVE) {
|
||||
eventPublisher.publishEvent(
|
||||
new UserActivatedEvent(user.getId(), user.getDisplayName(), user.getEmail()));
|
||||
}
|
||||
return new AdminUserMutationResponse(user.getId(), null, nextStatus.name());
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,92 +0,0 @@
|
|||
package com.iflytek.skillhub.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.iflytek.skillhub.domain.skill.metadata.ComplianceMetadataService;
|
||||
import com.iflytek.skillhub.dto.ComplianceEvidenceResponse;
|
||||
import com.iflytek.skillhub.dto.ComplianceMappingResponse;
|
||||
import com.iflytek.skillhub.dto.ComplianceSnapshotResponse;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
public class ComplianceSnapshotProjectionService {
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public ComplianceSnapshotProjectionService(ObjectMapper objectMapper) {
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
public ComplianceSnapshotResponse fromParsedMetadataJson(String parsedMetadataJson) {
|
||||
if (parsedMetadataJson == null || parsedMetadataJson.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
JsonNode root = objectMapper.readTree(parsedMetadataJson);
|
||||
JsonNode snapshot = root.path(ComplianceMetadataService.SNAPSHOT_FIELD_NAME);
|
||||
if (!snapshot.isObject()) {
|
||||
return null;
|
||||
}
|
||||
return toSnapshot(snapshot);
|
||||
} catch (Exception ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private ComplianceSnapshotResponse toSnapshot(JsonNode snapshot) {
|
||||
return new ComplianceSnapshotResponse(
|
||||
textOrNull(snapshot.path("schemaVersion")),
|
||||
toMappings(snapshot.path("items")),
|
||||
textOrNull(snapshot.path("digest"))
|
||||
);
|
||||
}
|
||||
|
||||
private List<ComplianceMappingResponse> toMappings(JsonNode items) {
|
||||
if (!items.isArray()) {
|
||||
return List.of();
|
||||
}
|
||||
List<ComplianceMappingResponse> mappings = new ArrayList<>();
|
||||
for (JsonNode item : items) {
|
||||
if (!item.isObject()) {
|
||||
continue;
|
||||
}
|
||||
mappings.add(new ComplianceMappingResponse(
|
||||
textOrNull(item.path("standard")),
|
||||
textOrNull(item.path("version")),
|
||||
textOrNull(item.path("controlId")),
|
||||
textOrNull(item.path("title")),
|
||||
toEvidence(item.path("evidence"))
|
||||
));
|
||||
}
|
||||
return List.copyOf(mappings);
|
||||
}
|
||||
|
||||
private List<ComplianceEvidenceResponse> toEvidence(JsonNode evidenceItems) {
|
||||
if (!evidenceItems.isArray()) {
|
||||
return List.of();
|
||||
}
|
||||
List<ComplianceEvidenceResponse> evidence = new ArrayList<>();
|
||||
for (JsonNode item : evidenceItems) {
|
||||
if (!item.isObject()) {
|
||||
continue;
|
||||
}
|
||||
evidence.add(new ComplianceEvidenceResponse(
|
||||
textOrNull(item.path("type")),
|
||||
textOrNull(item.path("path")),
|
||||
textOrNull(item.path("url")),
|
||||
textOrNull(item.path("sha256"))
|
||||
));
|
||||
}
|
||||
return List.copyOf(evidence);
|
||||
}
|
||||
|
||||
private String textOrNull(JsonNode node) {
|
||||
if (node == null || node.isMissingNode() || node.isNull()) {
|
||||
return null;
|
||||
}
|
||||
return node.asText();
|
||||
}
|
||||
}
|
||||
|
|
@ -72,37 +72,6 @@ public class NamespaceMemberCandidateService {
|
|||
.toList();
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<NamespaceCandidateUserResponse> searchCandidatesForPlatformAdmin(String slug, String search, int size) {
|
||||
Namespace namespace = namespaceService.getNamespaceBySlug(slug);
|
||||
if (namespaceAccessPolicy.isImmutable(namespace)) {
|
||||
throw new DomainBadRequestException("error.namespace.system.immutable", namespace.getSlug());
|
||||
}
|
||||
if (!namespaceAccessPolicy.canManageMembers(namespace)) {
|
||||
throw new DomainBadRequestException("error.namespace.readonly", namespace.getSlug());
|
||||
}
|
||||
|
||||
return findCandidates(namespace, search, size);
|
||||
}
|
||||
|
||||
private List<NamespaceCandidateUserResponse> findCandidates(Namespace namespace, String search, int size) {
|
||||
String keyword = normalizeSearch(search);
|
||||
if (keyword == null) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
int pageSize = normalizeSize(size);
|
||||
Set<String> existingMemberIds = namespaceMemberRepository.findByNamespaceId(namespace.getId(), PageRequest.of(0, 500))
|
||||
.stream()
|
||||
.map(NamespaceMember::getUserId)
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
return userAccountRepository.search(keyword, UserStatus.ACTIVE, PageRequest.of(0, pageSize)).stream()
|
||||
.filter(user -> !existingMemberIds.contains(user.getId()))
|
||||
.map(NamespaceCandidateUserResponse::from)
|
||||
.toList();
|
||||
}
|
||||
|
||||
private String normalizeSearch(String search) {
|
||||
if (!StringUtils.hasText(search)) {
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -55,14 +55,7 @@ public class NamespacePortalQueryAppService {
|
|||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public PageResponse<NamespaceResponse> listNamespaces(Pageable pageable,
|
||||
Map<Long, NamespaceRole> userNamespaceRoles,
|
||||
Set<String> platformRoles) {
|
||||
if (isSuperAdmin(platformRoles)) {
|
||||
return PageResponse.from(namespaceRepository.findByStatus(NamespaceStatus.ACTIVE, pageable)
|
||||
.map(NamespaceResponse::from));
|
||||
}
|
||||
|
||||
public PageResponse<NamespaceResponse> listNamespaces(Pageable pageable, Map<Long, NamespaceRole> userNamespaceRoles) {
|
||||
Map<Long, NamespaceRole> namespaceRoles = userNamespaceRoles != null ? userNamespaceRoles : Map.of();
|
||||
if (namespaceRoles.isEmpty()) {
|
||||
Page<NamespaceResponse> empty = new PageImpl<>(
|
||||
|
|
@ -90,8 +83,7 @@ public class NamespacePortalQueryAppService {
|
|||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<MyNamespaceResponse> listMyNamespaces(Map<Long, NamespaceRole> userNamespaceRoles,
|
||||
Set<String> platformRoles) {
|
||||
public List<MyNamespaceResponse> listMyNamespaces(Map<Long, NamespaceRole> userNamespaceRoles) {
|
||||
Map<Long, NamespaceRole> namespaceRoles = userNamespaceRoles != null ? userNamespaceRoles : Map.of();
|
||||
if (namespaceRoles.isEmpty()) {
|
||||
return List.of();
|
||||
|
|
@ -99,19 +91,16 @@ public class NamespacePortalQueryAppService {
|
|||
|
||||
return namespaceRepository.findByIdIn(namespaceRoles.keySet().stream().toList()).stream()
|
||||
.sorted(Comparator.comparing(Namespace::getSlug))
|
||||
.map(namespace -> toMyNamespaceResponse(namespace, namespaceRoles.get(namespace.getId())))
|
||||
.map(namespace -> MyNamespaceResponse.from(
|
||||
namespace,
|
||||
namespaceRoles.get(namespace.getId()),
|
||||
namespaceAccessPolicy,
|
||||
namespaceService.canDelete(namespace, namespaceRoles.get(namespace.getId()))))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public NamespaceResponse getNamespace(String slug,
|
||||
String userId,
|
||||
Map<Long, NamespaceRole> userNamespaceRoles,
|
||||
Set<String> platformRoles) {
|
||||
if (isSuperAdmin(platformRoles)) {
|
||||
return NamespaceResponse.from(namespaceService.getNamespaceBySlug(slug));
|
||||
}
|
||||
|
||||
public NamespaceResponse getNamespace(String slug, String userId, Map<Long, NamespaceRole> userNamespaceRoles) {
|
||||
Map<Long, NamespaceRole> namespaceRoles = userNamespaceRoles != null ? userNamespaceRoles : Map.of();
|
||||
Namespace namespace = namespaceService.getNamespaceBySlugForRead(
|
||||
slug,
|
||||
|
|
@ -149,16 +138,4 @@ public class NamespacePortalQueryAppService {
|
|||
MemberResponse.from(member, userMap.get(member.getUserId()))
|
||||
));
|
||||
}
|
||||
|
||||
private MyNamespaceResponse toMyNamespaceResponse(Namespace namespace, NamespaceRole role) {
|
||||
return MyNamespaceResponse.from(
|
||||
namespace,
|
||||
role,
|
||||
namespaceAccessPolicy,
|
||||
role != null && namespaceService.canDelete(namespace, role));
|
||||
}
|
||||
|
||||
private boolean isSuperAdmin(Set<String> platformRoles) {
|
||||
return platformRoles != null && platformRoles.contains("SUPER_ADMIN");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,22 +34,19 @@ public class ReviewSkillDetailAppService {
|
|||
private final RbacService rbacService;
|
||||
private final SkillQueryService skillQueryService;
|
||||
private final SkillDownloadService skillDownloadService;
|
||||
private final ComplianceSnapshotProjectionService complianceSnapshotProjectionService;
|
||||
|
||||
public ReviewSkillDetailAppService(ReviewTaskRepository reviewTaskRepository,
|
||||
NamespaceRepository namespaceRepository,
|
||||
ReviewService reviewService,
|
||||
RbacService rbacService,
|
||||
SkillQueryService skillQueryService,
|
||||
SkillDownloadService skillDownloadService,
|
||||
ComplianceSnapshotProjectionService complianceSnapshotProjectionService) {
|
||||
SkillDownloadService skillDownloadService) {
|
||||
this.reviewTaskRepository = reviewTaskRepository;
|
||||
this.namespaceRepository = namespaceRepository;
|
||||
this.reviewService = reviewService;
|
||||
this.rbacService = rbacService;
|
||||
this.skillQueryService = skillQueryService;
|
||||
this.skillDownloadService = skillDownloadService;
|
||||
this.complianceSnapshotProjectionService = complianceSnapshotProjectionService;
|
||||
}
|
||||
|
||||
public ReviewSkillDetailResponse getReviewSkillDetail(Long reviewId,
|
||||
|
|
@ -97,8 +94,7 @@ public class ReviewSkillDetailAppService {
|
|||
version.getTotalSize(),
|
||||
version.getPublishedAt(),
|
||||
version.getId().equals(snapshot.activeVersion().getId())
|
||||
|| skillQueryService.isDownloadAvailable(version),
|
||||
complianceSnapshotProjectionService.fromParsedMetadataJson(version.getParsedMetadataJson())
|
||||
|| skillQueryService.isDownloadAvailable(version)
|
||||
))
|
||||
.toList();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,94 +0,0 @@
|
|||
package com.iflytek.skillhub.service;
|
||||
|
||||
import com.iflytek.skillhub.domain.label.LabelDefinition;
|
||||
import com.iflytek.skillhub.domain.label.LabelDefinitionService;
|
||||
import com.iflytek.skillhub.domain.label.LabelTranslation;
|
||||
import com.iflytek.skillhub.domain.label.SkillLabel;
|
||||
import com.iflytek.skillhub.domain.label.SkillLabelService;
|
||||
import com.iflytek.skillhub.dto.SkillLabelDto;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* Projects skill labels for a whole page of skills in a fixed number of queries.
|
||||
*
|
||||
* <p>Listing endpoints need labels for every item they return, so resolving them one
|
||||
* skill at a time would issue three queries per row. This service batches the
|
||||
* assignment, definition, and translation lookups instead.</p>
|
||||
*/
|
||||
@Service
|
||||
public class SkillLabelProjectionService {
|
||||
|
||||
private final SkillLabelService skillLabelService;
|
||||
private final LabelDefinitionService labelDefinitionService;
|
||||
private final LabelLocalizationService labelLocalizationService;
|
||||
|
||||
public SkillLabelProjectionService(SkillLabelService skillLabelService,
|
||||
LabelDefinitionService labelDefinitionService,
|
||||
LabelLocalizationService labelLocalizationService) {
|
||||
this.skillLabelService = skillLabelService;
|
||||
this.labelDefinitionService = labelDefinitionService;
|
||||
this.labelLocalizationService = labelLocalizationService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Labels for each requested skill, keyed by skill id. Skills without labels are absent
|
||||
* from the map rather than mapped to an empty list.
|
||||
*/
|
||||
public Map<Long, List<SkillLabelDto>> labelsBySkillIds(List<Long> skillIds) {
|
||||
if (skillIds == null || skillIds.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
|
||||
List<Long> distinctSkillIds = skillIds.stream()
|
||||
.filter(java.util.Objects::nonNull)
|
||||
.distinct()
|
||||
.toList();
|
||||
if (distinctSkillIds.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
|
||||
List<SkillLabel> assignments = skillLabelService.listSkillLabelsBySkillIds(distinctSkillIds);
|
||||
if (assignments.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
|
||||
List<Long> labelIds = assignments.stream()
|
||||
.map(SkillLabel::getLabelId)
|
||||
.distinct()
|
||||
.toList();
|
||||
Map<Long, LabelDefinition> definitionsById = labelDefinitionService.listByIds(labelIds).stream()
|
||||
.collect(Collectors.toMap(LabelDefinition::getId, Function.identity()));
|
||||
Map<Long, List<LabelTranslation>> translationsByLabelId =
|
||||
labelDefinitionService.listTranslationsByLabelIds(labelIds);
|
||||
|
||||
return assignments.stream()
|
||||
.filter(assignment -> definitionsById.containsKey(assignment.getLabelId()))
|
||||
.collect(Collectors.groupingBy(
|
||||
SkillLabel::getSkillId,
|
||||
Collectors.collectingAndThen(
|
||||
Collectors.toList(),
|
||||
skillAssignments -> skillAssignments.stream()
|
||||
.map(assignment -> toDto(
|
||||
definitionsById.get(assignment.getLabelId()),
|
||||
translationsByLabelId))
|
||||
.sorted(Comparator.comparing(SkillLabelDto::type)
|
||||
.thenComparing(SkillLabelDto::slug))
|
||||
.toList())));
|
||||
}
|
||||
|
||||
private SkillLabelDto toDto(LabelDefinition definition,
|
||||
Map<Long, List<LabelTranslation>> translationsByLabelId) {
|
||||
return new SkillLabelDto(
|
||||
definition.getSlug(),
|
||||
definition.getType().name(),
|
||||
labelLocalizationService.resolveDisplayName(
|
||||
definition.getSlug(),
|
||||
translationsByLabelId.getOrDefault(definition.getId(), List.of()))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -8,8 +8,6 @@ import com.iflytek.skillhub.domain.namespace.NamespaceService;
|
|||
import com.iflytek.skillhub.domain.skill.Skill;
|
||||
import com.iflytek.skillhub.domain.skill.SkillRepository;
|
||||
import com.iflytek.skillhub.domain.skill.service.SkillLifecycleProjectionService;
|
||||
import com.iflytek.skillhub.domain.user.UserAccount;
|
||||
import com.iflytek.skillhub.domain.user.UserAccountRepository;
|
||||
import com.iflytek.skillhub.dto.SkillSummaryResponse;
|
||||
import com.iflytek.skillhub.search.SearchQuery;
|
||||
import com.iflytek.skillhub.search.SearchQueryService;
|
||||
|
|
@ -20,7 +18,6 @@ import java.util.Map;
|
|||
import java.util.Set;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
|
|
@ -39,9 +36,7 @@ public class SkillSearchAppService {
|
|||
private final NamespaceRepository namespaceRepository;
|
||||
private final NamespaceService namespaceService;
|
||||
private final SkillLifecycleProjectionService skillLifecycleProjectionService;
|
||||
private final ComplianceSnapshotProjectionService complianceSnapshotProjectionService;
|
||||
private final RbacService rbacService;
|
||||
private final UserAccountRepository userAccountRepository;
|
||||
|
||||
public SkillSearchAppService(
|
||||
SearchQueryService searchQueryService,
|
||||
|
|
@ -50,36 +45,12 @@ public class SkillSearchAppService {
|
|||
NamespaceService namespaceService,
|
||||
SkillLifecycleProjectionService skillLifecycleProjectionService,
|
||||
RbacService rbacService) {
|
||||
this(
|
||||
searchQueryService,
|
||||
skillRepository,
|
||||
namespaceRepository,
|
||||
namespaceService,
|
||||
skillLifecycleProjectionService,
|
||||
new ComplianceSnapshotProjectionService(new com.fasterxml.jackson.databind.ObjectMapper()),
|
||||
rbacService,
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
@Autowired
|
||||
public SkillSearchAppService(
|
||||
SearchQueryService searchQueryService,
|
||||
SkillRepository skillRepository,
|
||||
NamespaceRepository namespaceRepository,
|
||||
NamespaceService namespaceService,
|
||||
SkillLifecycleProjectionService skillLifecycleProjectionService,
|
||||
ComplianceSnapshotProjectionService complianceSnapshotProjectionService,
|
||||
RbacService rbacService,
|
||||
UserAccountRepository userAccountRepository) {
|
||||
this.searchQueryService = searchQueryService;
|
||||
this.skillRepository = skillRepository;
|
||||
this.namespaceRepository = namespaceRepository;
|
||||
this.namespaceService = namespaceService;
|
||||
this.skillLifecycleProjectionService = skillLifecycleProjectionService;
|
||||
this.complianceSnapshotProjectionService = complianceSnapshotProjectionService;
|
||||
this.rbacService = rbacService;
|
||||
this.userAccountRepository = userAccountRepository;
|
||||
}
|
||||
|
||||
public record SearchResponse(
|
||||
|
|
@ -221,33 +192,21 @@ public class SkillSearchAppService {
|
|||
.collect(Collectors.toMap(Namespace::getId, Function.identity()));
|
||||
Map<Long, String> namespaceSlugsById = namespacesById.entrySet().stream()
|
||||
.collect(Collectors.toMap(Map.Entry::getKey, entry -> entry.getValue().getSlug()));
|
||||
Map<String, UserAccount> ownersById = userAccountRepository == null
|
||||
? Map.of()
|
||||
: userAccountRepository.findByIdIn(matchedSkills.stream().map(Skill::getOwnerId).distinct().toList())
|
||||
.stream().collect(Collectors.toMap(UserAccount::getId, Function.identity()));
|
||||
Map<Long, SkillLifecycleProjectionService.Projection> projectionsBySkillId =
|
||||
skillLifecycleProjectionService.projectPublishedSummaries(matchedSkills);
|
||||
|
||||
return skillIds.stream()
|
||||
.map(skillsById::get)
|
||||
.filter(java.util.Objects::nonNull)
|
||||
.map(skill -> toSummaryResponse(
|
||||
skill,
|
||||
namespaceSlugsById,
|
||||
ownersById,
|
||||
projectionsBySkillId.get(skill.getId())
|
||||
))
|
||||
.map(skill -> toSummaryResponse(skill, namespaceSlugsById, projectionsBySkillId.get(skill.getId())))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private SkillSummaryResponse toSummaryResponse(
|
||||
Skill skill,
|
||||
Map<Long, String> namespaceSlugsById,
|
||||
Map<String, UserAccount> ownersById,
|
||||
SkillLifecycleProjectionService.Projection projection) {
|
||||
String namespaceSlug = namespaceSlugsById.get(skill.getNamespaceId());
|
||||
UserAccount owner = ownersById.get(skill.getOwnerId());
|
||||
SkillLifecycleProjectionService.VersionProjection headlineVersion = projection.headlineVersion();
|
||||
|
||||
return new SkillSummaryResponse(
|
||||
skill.getId(),
|
||||
|
|
@ -262,19 +221,11 @@ public class SkillSearchAppService {
|
|||
skill.getRatingCount(),
|
||||
namespaceSlug,
|
||||
skill.getUpdatedAt(),
|
||||
skill.getOwnerId(),
|
||||
owner != null
|
||||
? owner.getDisplayName()
|
||||
: null,
|
||||
false,
|
||||
toLifecycleVersion(projection.headlineVersion()),
|
||||
toLifecycleVersion(projection.publishedVersion()),
|
||||
toLifecycleVersion(projection.ownerPreviewVersion()),
|
||||
projection.resolutionMode().name(),
|
||||
headlineVersion != null
|
||||
? complianceSnapshotProjectionService.fromParsedMetadataJson(headlineVersion.parsedMetadataJson())
|
||||
: null,
|
||||
null
|
||||
projection.resolutionMode().name()
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
|
|||
import com.iflytek.skillhub.domain.skill.SkillVersionStatus;
|
||||
import com.iflytek.skillhub.observability.MessageObservationSupport;
|
||||
import com.iflytek.skillhub.storage.ObjectStorageService;
|
||||
import org.redisson.api.RLock;
|
||||
import org.redisson.api.RedissonClient;
|
||||
|
||||
import java.io.IOException;
|
||||
|
|
@ -27,7 +26,6 @@ import java.util.Map;
|
|||
public class ScanTaskConsumer extends AbstractStreamConsumer<ScanTaskConsumer.ScanTaskPayload> {
|
||||
private static final Path SCAN_TEMP_DIR = Paths.get("/tmp/skillhub-scans").toAbsolutePath().normalize();
|
||||
|
||||
private final RedissonClient redissonClient;
|
||||
private final SecurityScanner securityScanner;
|
||||
private final SecurityScanService securityScanService;
|
||||
private final SkillVersionRepository skillVersionRepository;
|
||||
|
|
@ -44,7 +42,6 @@ public class ScanTaskConsumer extends AbstractStreamConsumer<ScanTaskConsumer.Sc
|
|||
ObjectStorageService objectStorageService,
|
||||
MessageObservationSupport messageObservationSupport) {
|
||||
super(redissonClient, streamKey, groupName, messageObservationSupport);
|
||||
this.redissonClient = redissonClient;
|
||||
this.securityScanner = securityScanner;
|
||||
this.securityScanService = securityScanService;
|
||||
this.skillVersionRepository = skillVersionRepository;
|
||||
|
|
@ -75,7 +72,6 @@ public class ScanTaskConsumer extends AbstractStreamConsumer<ScanTaskConsumer.Sc
|
|||
reclaimInterval,
|
||||
messageObservationSupport
|
||||
);
|
||||
this.redissonClient = redissonClient;
|
||||
this.securityScanner = securityScanner;
|
||||
this.securityScanService = securityScanService;
|
||||
this.skillVersionRepository = skillVersionRepository;
|
||||
|
|
@ -132,49 +128,17 @@ public class ScanTaskConsumer extends AbstractStreamConsumer<ScanTaskConsumer.Sc
|
|||
|
||||
@Override
|
||||
protected void processBusiness(ScanTaskPayload payload) {
|
||||
if (securityScanService.isTaskAlreadyProcessed(payload.taskId())) {
|
||||
log.info("Skipping already processed security scan task: taskId={}, versionId={}", payload.taskId(), payload.versionId());
|
||||
return;
|
||||
}
|
||||
RLock processingLock = redissonClient.getLock("skillhub:scan:processing:" + payload.taskId());
|
||||
boolean acquired = false;
|
||||
try {
|
||||
acquired = processingLock.tryLock();
|
||||
if (!acquired) {
|
||||
log.info("Skipping concurrently processed security scan task: taskId={}, versionId={}",
|
||||
payload.taskId(), payload.versionId());
|
||||
payload.skipCleanup();
|
||||
// A normal return is treated as success by AbstractStreamConsumer and ACKs
|
||||
// the Redis entry. Requeue through the common failure path instead, so a
|
||||
// reclaimed duplicate cannot erase the only durable delivery while the active
|
||||
// scanner still owns the task lock.
|
||||
throw new ConcurrentScanInProgressException(payload.taskId());
|
||||
}
|
||||
if (securityScanService.isTaskAlreadyProcessed(payload.taskId())) {
|
||||
return;
|
||||
}
|
||||
executeScan(payload);
|
||||
} finally {
|
||||
if (acquired && processingLock.isHeldByCurrentThread()) {
|
||||
processingLock.unlock();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void executeScan(ScanTaskPayload payload) {
|
||||
String skillPath = resolveWorkingSkillPath(payload);
|
||||
SecurityScanRequest request = new SecurityScanRequest(
|
||||
payload.taskId(), payload.versionId(), skillPath, Map.of());
|
||||
payload.taskId(),
|
||||
payload.versionId(),
|
||||
skillPath,
|
||||
Map.of()
|
||||
);
|
||||
SecurityScanResponse response = securityScanner.scan(request);
|
||||
securityScanService.processScanResult(payload.versionId(), payload.scannerType(), response);
|
||||
}
|
||||
|
||||
private static final class ConcurrentScanInProgressException extends RuntimeException {
|
||||
private ConcurrentScanInProgressException(String taskId) {
|
||||
super("Security scan is already in progress: taskId=" + taskId);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void markCompleted(ScanTaskPayload payload) {
|
||||
cleanupTempPath(payload.cleanupPath());
|
||||
|
|
@ -295,7 +259,6 @@ public class ScanTaskConsumer extends AbstractStreamConsumer<ScanTaskConsumer.Sc
|
|||
private final ScannerType scannerType;
|
||||
private final int retryCount;
|
||||
private String workingSkillPath;
|
||||
private boolean cleanupEnabled = true;
|
||||
|
||||
protected ScanTaskPayload(String taskId, Long versionId, String skillPath, String bundleKey, ScannerType scannerType) {
|
||||
this(taskId, versionId, skillPath, bundleKey, scannerType, 0);
|
||||
|
|
@ -344,16 +307,9 @@ public class ScanTaskConsumer extends AbstractStreamConsumer<ScanTaskConsumer.Sc
|
|||
}
|
||||
|
||||
protected String cleanupPath() {
|
||||
if (!cleanupEnabled) {
|
||||
return null;
|
||||
}
|
||||
return workingSkillPath != null ? workingSkillPath : skillPath;
|
||||
}
|
||||
|
||||
protected void skipCleanup() {
|
||||
cleanupEnabled = false;
|
||||
}
|
||||
|
||||
protected String workingSkillPath() {
|
||||
return workingSkillPath;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,109 +0,0 @@
|
|||
package com.iflytek.skillhub.task;
|
||||
|
||||
import com.iflytek.skillhub.domain.security.ScanTaskOutbox;
|
||||
import com.iflytek.skillhub.domain.security.ScanTaskOutboxRepository;
|
||||
import com.iflytek.skillhub.domain.security.ScanTaskProducer;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVersionStatus;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
|
||||
@Component
|
||||
@ConditionalOnProperty(prefix = "skillhub.security.scanner", name = "enabled", havingValue = "true")
|
||||
public class ScanTaskOutboxDispatcher {
|
||||
private static final Logger log = LoggerFactory.getLogger(ScanTaskOutboxDispatcher.class);
|
||||
|
||||
private final ScanTaskOutboxRepository repository;
|
||||
private final ScanTaskProducer producer;
|
||||
private final SkillVersionRepository versionRepository;
|
||||
private final Clock clock;
|
||||
private final int batchSize;
|
||||
private final int maxAttempts;
|
||||
private final Duration lease;
|
||||
private final Duration maxBackoff;
|
||||
|
||||
public ScanTaskOutboxDispatcher(ScanTaskOutboxRepository repository,
|
||||
ScanTaskProducer producer,
|
||||
SkillVersionRepository versionRepository,
|
||||
Clock clock,
|
||||
@Value("${skillhub.security.outbox.batch-size:50}") int batchSize,
|
||||
@Value("${skillhub.security.outbox.max-attempts:10}") int maxAttempts,
|
||||
@Value("${skillhub.security.outbox.lease:PT2M}") Duration lease,
|
||||
@Value("${skillhub.security.outbox.max-backoff:PT5M}") Duration maxBackoff) {
|
||||
this.repository = repository;
|
||||
this.producer = producer;
|
||||
this.versionRepository = versionRepository;
|
||||
this.clock = clock;
|
||||
this.batchSize = batchSize;
|
||||
if (maxAttempts < 1) {
|
||||
throw new IllegalArgumentException("maxAttempts must be at least 1");
|
||||
}
|
||||
this.maxAttempts = maxAttempts;
|
||||
this.lease = lease;
|
||||
this.maxBackoff = maxBackoff;
|
||||
}
|
||||
|
||||
@Scheduled(fixedDelayString = "${skillhub.security.outbox.dispatch-interval-ms:5000}")
|
||||
@Transactional
|
||||
public void dispatch() {
|
||||
Instant now = Instant.now(clock);
|
||||
for (ScanTaskOutbox outbox : repository.findDispatchable(now, batchSize)) {
|
||||
if (!outbox.claim(now, lease)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
producer.publishScanTask(outbox.toScanTask());
|
||||
outbox.markSent(Instant.now(clock));
|
||||
repository.save(outbox);
|
||||
} catch (Exception e) {
|
||||
handlePublishFailure(outbox, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void handlePublishFailure(ScanTaskOutbox outbox, Exception error) {
|
||||
Instant now = Instant.now(clock);
|
||||
int nextAttempt = outbox.getRetryCount() + 1;
|
||||
if (nextAttempt >= maxAttempts) {
|
||||
outbox.markFailed(now, error.toString());
|
||||
repository.save(outbox);
|
||||
versionRepository.findById(outbox.getVersionId())
|
||||
.filter(version -> version.getStatus() == SkillVersionStatus.SCANNING)
|
||||
.ifPresent(version -> {
|
||||
version.setStatus(SkillVersionStatus.SCAN_FAILED);
|
||||
versionRepository.save(version);
|
||||
});
|
||||
log.error("Scan task publish failed permanently: taskId={}, versionId={}, attempts={}",
|
||||
outbox.getTaskId(), outbox.getVersionId(), outbox.getRetryCount(), error);
|
||||
return;
|
||||
}
|
||||
Duration delay = retryDelay(nextAttempt);
|
||||
outbox.markRetry(now, delay, error.toString());
|
||||
repository.save(outbox);
|
||||
log.warn("Failed to publish scan task; will retry taskId={}, retryCount={}, nextDelay={}",
|
||||
outbox.getTaskId(), outbox.getRetryCount(), delay, error);
|
||||
}
|
||||
|
||||
@Scheduled(cron = "0 20 2 * * ?")
|
||||
@Transactional
|
||||
public void cleanupSent() {
|
||||
int deleted = repository.deleteSentBefore(Instant.now(clock).minus(Duration.ofDays(7)));
|
||||
if (deleted > 0) {
|
||||
log.info("Cleaned up {} sent scan outbox records", deleted);
|
||||
}
|
||||
}
|
||||
|
||||
private Duration retryDelay(int retryCount) {
|
||||
long seconds = Math.min(maxBackoff.toSeconds(), 1L << Math.min(retryCount, 16));
|
||||
return Duration.ofSeconds(Math.max(seconds, 1));
|
||||
}
|
||||
}
|
||||
|
|
@ -13,7 +13,6 @@ server:
|
|||
spring:
|
||||
messages:
|
||||
basename: messages
|
||||
fallback-to-system-locale: false
|
||||
application:
|
||||
name: skillhub
|
||||
lifecycle:
|
||||
|
|
@ -72,7 +71,6 @@ spring:
|
|||
client-name: ${OAUTH2_GITLAB_DISPLAY_NAME:GitLab}
|
||||
provider:
|
||||
github:
|
||||
api-base-url: ${OAUTH2_GITHUB_API_BASE_URL:https://api.github.com}
|
||||
user-info-uri: https://api.github.com/user
|
||||
gitlab:
|
||||
authorization-uri: ${OAUTH2_GITLAB_BASE_URI:https://gitlab.com}/oauth/authorize
|
||||
|
|
@ -118,11 +116,6 @@ skillhub:
|
|||
code-expiry: ${SKILLHUB_AUTH_PASSWORD_RESET_CODE_EXPIRY:PT10M}
|
||||
email-from-address: ${SKILLHUB_AUTH_PASSWORD_RESET_FROM_ADDRESS:noreply@skillhub.local}
|
||||
email-from-name: ${SKILLHUB_AUTH_PASSWORD_RESET_FROM_NAME:SkillHub}
|
||||
namespace:
|
||||
# Whether a newly activated account gets a namespace of its own.
|
||||
# Slug and display-name templates use safe code defaults in PersonalNamespaceProvisioningProperties.
|
||||
personal-provisioning:
|
||||
enabled: ${SKILLHUB_NAMESPACE_PERSONAL_PROVISIONING_ENABLED:true}
|
||||
public:
|
||||
base-url: ${SKILLHUB_PUBLIC_BASE_URL:}
|
||||
access-policy:
|
||||
|
|
@ -158,26 +151,6 @@ skillhub:
|
|||
candidate-multiplier: 8
|
||||
max-candidates: 120
|
||||
ratelimit:
|
||||
# Master switch for per-endpoint request rate limiting. Set false (or
|
||||
# SKILLHUB_RATELIMIT_ENABLED=false) to turn quota checks off entirely.
|
||||
enabled: ${SKILLHUB_RATELIMIT_ENABLED:true}
|
||||
# Per-category threshold overrides. Unset categories use the built-in
|
||||
# @RateLimit annotation defaults, so this block is optional and changes
|
||||
# nothing until you set a value. Each override applies to every endpoint
|
||||
# sharing that category. Override via env vars, e.g.:
|
||||
# SKILLHUB_RATELIMIT_CATEGORIES_SEARCH_AUTHENTICATED=120
|
||||
# SKILLHUB_RATELIMIT_CATEGORIES_SEARCH_ANONYMOUS=40
|
||||
# SKILLHUB_RATELIMIT_CATEGORIES_PUBLISH_WINDOW_SECONDS=3600
|
||||
# categories:
|
||||
# search:
|
||||
# authenticated: 60
|
||||
# anonymous: 20
|
||||
# download:
|
||||
# authenticated: 120
|
||||
# anonymous: 30
|
||||
# publish:
|
||||
# authenticated: 10
|
||||
# window-seconds: 60
|
||||
download:
|
||||
anonymous-cookie-name: ${SKILLHUB_DOWNLOAD_ANON_COOKIE_NAME:skillhub_anon_dl}
|
||||
anonymous-cookie-max-age: ${SKILLHUB_DOWNLOAD_ANON_COOKIE_MAX_AGE:P30D}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue