Compare commits

..

86 commits

Author SHA1 Message Date
XiaoSeS
c11a51c75f
docs(weekly): publish 2026 W35 report
Some checks failed
Deploy Docs / build (push) Has been cancelled
Security / Dependency Review (push) Has been cancelled
Security / CodeQL (java-kotlin) (push) Has been cancelled
Security / CodeQL (javascript-typescript) (push) Has been cancelled
Security / CodeQL (python) (push) Has been cancelled
Deploy Docs / Deploy (push) Has been cancelled
Merge reviewed W35 weekly report mirror.
2026-08-28 17:22:18 +08:00
XiaoSeS
56ed2dcadd docs(weekly): mirror 2026-W35 report
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
2026-08-28 17:18:57 +08:00
XiaoSeS
f993ad6533
Merge pull request #712 from Vast-Stars/feat/personal-namespace-provisioning
feat(namespace): auto-provision a personal namespace on registration
2026-08-28 17:04:21 +08:00
XiaoSeS
37e3c63236
Merge pull request #688 from iflytek/fix/auth-email-assurance-guard-main
fix(auth): enforce trusted OAuth identity attributes
2026-08-28 17:04:15 +08:00
XiaoSeS
eeb63613f2 fix(namespace): enable personal provisioning by default 2026-08-28 16:42:26 +08:00
XiaoSeS
ee0f0763db refactor(namespace): keep personal provisioning configuration-only 2026-08-28 15:55:29 +08:00
XiaoSeS
7beb1be356 fix(namespace): skip system accounts during provisioning 2026-08-28 15:27:39 +08:00
XiaoSeS
04bb414b37 fix(namespace): use stable random personal namespace slugs 2026-08-28 15:19:56 +08:00
XiaoSeS
dc31bb97f4 Revert "feat(namespace): backfill personal namespaces for existing accounts"
This reverts commit 2d50437e4f.
2026-08-28 15:19:56 +08:00
XiaoSeS
fbf6887e9d Revert "fix(namespace): stop the backfill from querying with a null keyword"
This reverts commit 639e081ca7.
2026-08-28 15:19:56 +08:00
XiaoSeS
e80fb986f7 Revert "fix(web): stop the settings form from reverting the saved value"
This reverts commit 4fe6948f87.
2026-08-28 15:19:56 +08:00
XiaoSeS
eba2762b5b Revert "feat(namespace): let operators choose which namespaces new accounts join"
This reverts commit a9e7f43e5a.
2026-08-28 15:19:56 +08:00
XiaoSeS
0221c17113 Revert "feat(web): pick default namespaces from a list instead of typing slugs"
This reverts commit 7247defd5d.
2026-08-28 15:19:56 +08:00
XiaoSeS
c91c2ca408 fix(auth): make GitHub email assurance resilient 2026-08-28 15:08:07 +08:00
XiaoSeS
71fbc8357a
Merge pull request #724 from 15258725278/release/cli-v0.1.10
feat(cli): add namespace workspace sync
2026-08-28 10:22:24 +08:00
XiaoSeS
c32bced109
Merge pull request #748 from myml/fix/security-csp-policy
fix(security): harden CSP policy and hide nginx server tokens
2026-08-28 10:22:14 +08:00
XiaoSeS
c825d896a4
Merge pull request #762 from FenjuFu/fix/code-scanning-alerts-2026-08
fix(security): resolve CodeQL findings
2026-08-28 10:22:04 +08:00
XiaoSeS
2e78f79e83
Merge pull request #765 from iflytek/fix/pr735-subscription-authorization-20260827
Some checks are pending
Security / Dependency Review (push) Waiting to run
Security / CodeQL (java-kotlin) (push) Waiting to run
Security / CodeQL (javascript-typescript) (push) Waiting to run
Security / CodeQL (python) (push) Waiting to run
fix(subscription): enforce current visibility for subscribers
2026-08-27 17:48:08 +08:00
XiaoSeS
7476c9e0d2
fix(skill-card): resolve author display names in summaries
Maintainer follow-up for #751; batch-loads owner display names and preserves summary constructor compatibility.
2026-08-27 17:45:34 +08:00
XiaoSeS
1544ae4775 fix(skill-card): add owner summary compatibility overload
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
2026-08-27 17:30:15 +08:00
XiaoSeS
ec9689dbc8 fix(skill-card): preserve owner fields when adding labels
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
2026-08-27 17:28:09 +08:00
XiaoSeS
41a389432d fix(skill-card): keep legacy summary constructor compatible
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
2026-08-27 17:26:30 +08:00
XiaoSeS
b0c4a154fd fix(skill-card): resolve author display names in summaries
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
2026-08-27 17:21:15 +08:00
wurongjie
f43c047a6b feat(skill-card): show author and update time in skill card
Add ownerId and ownerDisplayName fields to SkillSummaryResponse,
and render author and last update time at the bottom of each skill card.

在技能卡片底部新增作者和最近更新时间显示,搜索结果接口新增
ownerId 和 ownerDisplayName 字段。

Log: 技能卡片新增作者和更新时间
Influence: 搜索结果页技能卡片底部显示作者和更新时间信息,接口新增 ownerId/ownerDisplayName 字段。
Signed-off-by: wurongjie <wurongjie@uniontech.com>
2026-08-27 17:21:15 +08:00
XiaoSeS
a3d1b4c9c5 test(subscription): remove unrelated confirm-publish assertions
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
2026-08-27 16:56:01 +08:00
XiaoSeS
126f01d75e fix(subscription): retain yank visibility context
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
2026-08-27 16:50:37 +08:00
XiaoSeS
1331667496 fix(subscription): reuse visibility policy and localize denial
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
2026-08-27 16:45:55 +08:00
千乘妍 (Xiaoyaner)
bacfd58aa0 fix: emit confirm-publish events with permission-aware subscriber fanout
Signed-off-by: 千乘妍 (Xiaoyaner) <258399167+xiaoyaner0201@users.noreply.github.com>
2026-08-27 16:43:32 +08:00
FenjuFu
36967794d1 fix(security): resolve CodeQL findings
Use a bounded safe YAML constructor, remove the polynomial placeholder regex, keep public label access GET-only with CSRF protection, validate CLI callbacks as loopback URL objects, and use cryptographic UUIDs for E2E identities.

Signed-off-by: FenjuFu <92919259+FenjuFu@users.noreply.github.com>
2026-08-27 15:16:54 +08:00
FenjuFu
26f49e6819
docs(governance): add DPGA policy evidence
Validated documentation, links, markdown checks, and CI.
2026-08-27 15:07:42 +08:00
FenjuFu
7fc1df5043
feat(ratelimit): make thresholds runtime-configurable
Validated locally and in CI, including runtime configuration documentation.
2026-08-27 15:07:31 +08:00
XiaoSeS
7e37935da8
fix(i18n): keep Russian locale keys in sync
Merge validated locale parity fix.
2026-08-27 14:51:22 +08:00
qwn3213
412514b299
fix(web) 添加oidc的logo文件,配置oidc登录后图裂无logo太丑了 (#734)
* fix(web) 添加oidc的logo文件,配置oidc登录后图裂无logo太丑了

Signed-off-by: qwn3213 <qwn3213@gmail.com>

* fix(web) rename loge.svg to logo.svg

Signed-off-by: qwn3213 <qwn3213@gmail.com>

---------

Signed-off-by: qwn3213 <qwn3213@gmail.com>
2026-08-26 19:45:08 +08:00
FenjuFu
0587c55f8b
docs(examples): add Python client and usage examples (#702)
Add examples/python: a dependency-light (requests-only) SkillHubClient plus
runnable examples covering search, resolve, download and publish against the
REST API, and link them from the README Documentation sections. Serves the
large Python-leaning audience and doubles as a reference SDK seed (see #701).

Signed-off-by: FenjuFu <fufenjupku@gmail.com>
2026-08-26 19:43:13 +08:00
Michael
16306dd4f4
feat(i18n): add Russian locale for web UI and server messages (#700)
Signed-off-by: Mikhail Neradkov <michael.neradkov@gmail.com>
2026-08-26 19:43:07 +08:00
bbdu3
95e630c096
fix(scan): prevent scan tasks from being lost after transaction commit
Some checks are pending
Security / Dependency Review (push) Waiting to run
Security / CodeQL (java-kotlin) (push) Waiting to run
Security / CodeQL (javascript-typescript) (push) Waiting to run
Security / CodeQL (python) (push) Waiting to run
Preserve scan delivery on lock contention through the retry path and add reclaim regression coverage.
2026-08-26 17:46:16 +08:00
FenjuFu
3b5d4381a9
feat(publish): allow uploading a skill folder directly
Fix E2E ZIP picker selector after adding folder upload input.
2026-08-26 17:37:21 +08:00
XiaoSeS
4344ec6b22
Merge pull request #759 from iflytek/fix/auth-login-redirect-loop
fix(auth): prevent login page redirect loop
2026-08-26 15:57:30 +08:00
XiaoSeS
243e9b68f4 fix(auth): prevent login page redirect loop
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
2026-08-26 15:46:20 +08:00
XiaoSeS
3522bad295
Merge pull request #623 from gale-popai/fix/oauth-return-to
fix(auth): keep the OAuth return target through the provider callback
2026-08-26 15:29:48 +08:00
wrj97
d7e8c51775
fix(dashboard): remove encoding from slug params (#747)
The router handles parameter encoding automatically. Explicitly encoding
the slug results in double-encoded characters in the URL.

Signed-off-by: wurongjie <wurongjie@uniontech.com>
2026-08-26 10:23:43 +08:00
ShinyHero666
470e79d6d2
fix(smoke): support separate actuator target (#689)
Signed-off-by: ShinyHero666 <160204855+ShinyHero666@users.noreply.github.com>
2026-08-26 10:23:31 +08:00
XiaoSeS
7599dd0ca9
fix(web): keep select poppers in collision bounds (#755)
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
2026-08-25 20:07:58 +08:00
wrj97
5a95278528
fix(compat): prioritize PUBLIC/GLOBAL skills in legacy slug lookup (#750)
Some checks are pending
Security / Dependency Review (push) Waiting to run
Security / CodeQL (java-kotlin) (push) Waiting to run
Security / CodeQL (javascript-typescript) (push) Waiting to run
Security / CodeQL (python) (push) Waiting to run
* fix(compat): prioritize PUBLIC/GLOBAL skills in legacy slug lookup

When multiple skills share the same slug across namespaces,
findByLegacySlug now prefers PUBLIC visibility and GLOBAL
namespace over NAMESPACE_ONLY/PRIVATE ones, so plain slug
lookups resolve to the most accessible skill. Namespaces are
batch-fetched via findByIdIn to avoid N+1 database queries.

Signed-off-by: wurongjie <wurongjie@uniontech.com>

* fix(compat): prefer published legacy slug candidates

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

---------

Signed-off-by: wurongjie <wurongjie@uniontech.com>
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
Co-authored-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
2026-08-25 16:58:17 +08:00
FenjuFu
907d8eff90
fix(web): keep select menus within viewport (#754)
Cap shared Radix select content to the available viewport height and enable vertical scrolling so long option lists remain usable near page and dialog edges. Add a shared component regression test for the viewport and overflow classes.\n\nCloses #714

Signed-off-by: FenjuFu <92919259+FenjuFu@users.noreply.github.com>
2026-08-25 16:56:58 +08:00
FenjuFu
91d0ae1504
feat(api): return skill labels from the skill listing endpoints on request (#730)
* feat(api): return skill labels from the skill listing endpoints on request

Skill labels were reachable only one skill at a time, through
/api/{v1,web}/skills/{namespace}/{slug}/labels, so a client rendering a list had
to issue a follow-up request per row.

Add includeLabels=true to GET /api/v1/skills and GET /api/web/skills. The labels
array is populated only when the parameter is set and left out of the payload
otherwise, so existing responses are byte-identical.

Labels for the whole page are resolved by SkillLabelProjectionService in three
queries — assignments, definitions, translations — rather than three per skill.

Closes #710

Signed-off-by: FenjuFu <fufenjupku@gmail.com>

* fix(api): use include parameter for skill labels

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* test(api): reject unsupported include before search

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

---------

Signed-off-by: FenjuFu <fufenjupku@gmail.com>
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
Co-authored-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
2026-08-25 13:49:24 +08:00
FenjuFu
1c3e9be9e9
fix(web): self-host Inter and JetBrains Mono fonts (#753)
* fix(web): self-host Inter and JetBrains Mono fonts

web/index.html loaded Inter + JetBrains Mono from fonts.googleapis.com at
runtime. On networks where Google Fonts is slow or unreachable (e.g. CN),
that stylesheet blocks first paint for tens of seconds (#716).

Vendor the same woff2 (from the @fontsource distribution) under
web/public/fonts and declare them in fonts.css with latin/latin-ext
unicode-range splits and font-display: swap, then point index.html at the
local stylesheet and drop the fonts.googleapis.com / fonts.gstatic.com hosts
from the page CSP. Only Inter and JetBrains Mono were ever fetched from the
CDN, so families and weights are unchanged; no external font requests remain.

Closes #716

Signed-off-by: FenjuFu <92919259+FenjuFu@users.noreply.github.com>

* docs(web): record vendored font licenses

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

---------

Signed-off-by: FenjuFu <92919259+FenjuFu@users.noreply.github.com>
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
Co-authored-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
2026-08-25 13:49:08 +08:00
FenjuFu
954dfce7a4
fix(publish): accept Windows zip directory entries (#742)
* fix(publish): accept Windows zip directory entries

Signed-off-by: FenjuFu <92919259+FenjuFu@users.noreply.github.com>

* chore: restore repository line endings

Signed-off-by: FenjuFu <92919259+FenjuFu@users.noreply.github.com>

---------

Signed-off-by: FenjuFu <92919259+FenjuFu@users.noreply.github.com>
2026-08-24 19:58:18 +08:00
FenjuFu
d5c6411ce6
fix(i18n): disable host locale fallback (#741)
Signed-off-by: FenjuFu <92919259+FenjuFu@users.noreply.github.com>
2026-08-24 19:54:05 +08:00
wurongjie
b807fb3ee1 fix(security): harden CSP policy and hide nginx server tokens
- Remove 'unsafe-inline' and 'unsafe-eval' from script-src directive
- Restrict connect-src to 'self' only
- Disable nginx server_tokens to hide version information

Signed-off-by: wurongjie <wurongjie@uniontech.com>
2026-08-24 17:02:42 +08:00
FenjuFu
f846da230c
feat: add initial RISC-V image support (#725)
Some checks failed
Security / Dependency Review (push) Waiting to run
Security / CodeQL (java-kotlin) (push) Waiting to run
Security / CodeQL (javascript-typescript) (push) Waiting to run
Security / CodeQL (python) (push) Waiting to run
Deploy Docs / build (push) Has been cancelled
Deploy Docs / Deploy (push) Has been cancelled
* feat: add initial RISC-V image support

Signed-off-by: FenjuFu <92919259+FenjuFu@users.noreply.github.com>

* chore(ci): tighten riscv64 image guardrails

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

---------

Signed-off-by: FenjuFu <92919259+FenjuFu@users.noreply.github.com>
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
Co-authored-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
2026-08-24 16:37:34 +08:00
XiaoSeS
1b7a6d5544
chore(deps): resolve dependabot npm alerts
Resolve open npm Dependabot alerts for the web app and docs site by refreshing dependency overrides and lockfiles.

Also sets the staging web forwarded-proto default so `make staging` can render the shared Nginx template when using the bare nginx image.

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
2026-08-24 16:08:07 +08:00
XiaoSeS
9fa6c52a4d
docs(troubleshooting): broaden postgres volume permission guidance (#745)
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
2026-08-24 14:25:28 +08:00
FenjuFu
183729613c
docs(faq): add CLI namespace-not-found and PostgreSQL permission entries (#743)
Add two entries collected from community support, to both the zh docs and
the en i18n mirror:

- FAQ: installing a skill via CLI reporting `namespace not found` — set the
  registry / log in with an API token, and use the correct namespace slug
  (`@team/skill` -> `team--skill`); the web UI Install button provides a
  ready-made command.
- Troubleshooting: PostgreSQL container failing to start with
  `operation not permitted` on bind mounts — fix data volume ownership
  (`chown 999:999`), check SELinux, or use the `runtime.sh` script.

Signed-off-by: FenjuFu <92919259+FenjuFu@users.noreply.github.com>
2026-08-24 13:39:49 +08:00
XiaoSeS
e8cab7389f
Merge pull request #739 from iflytek/docs/weekly-w34-official
Some checks failed
Deploy Docs / build (push) Has been cancelled
Security / Dependency Review (push) Has been cancelled
Security / CodeQL (java-kotlin) (push) Has been cancelled
Security / CodeQL (javascript-typescript) (push) Has been cancelled
Security / CodeQL (python) (push) Has been cancelled
Deploy Docs / Deploy (push) Has been cancelled
docs(weekly): publish 2026 W34 report
2026-08-21 18:46:19 +08:00
XiaoSeS
67d39f04f6 docs(weekly): mirror reviewed W34 report
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
2026-08-21 18:42:52 +08:00
XiaoSeS
15ce199e1a
fix(publish): emit event on confirm publish (#738)
Some checks are pending
Security / Dependency Review (push) Waiting to run
Security / CodeQL (java-kotlin) (push) Waiting to run
Security / CodeQL (javascript-typescript) (push) Waiting to run
Security / CodeQL (python) (push) Waiting to run
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
2026-08-21 17:52:25 +08:00
FenjuFu
fa04d2fa23
fix(auth): close API token policy gaps against the authorization list (#729)
RouteSecurityPolicyRegistry keeps two policy lists — AUTHORIZATION_POLICIES for
session/cookie access and API_TOKEN_POLICIES for Bearer tokens — with nothing
keeping them in step. Routes the authorization list opens but the token list
never registers fall through to the catch-all and answer
API token cannot access endpoint: <path>.

Register the routes reported in #713 (/api/v1/labels, the star and rating
writes) plus the same-class gaps for /api/v1/auth/methods and paths below
/api/v1/download, and add a guard test that walks the authorization list and
fails when a route is neither token-reachable nor declared session-only.

DELETE /api/v1/skills/{id}/star also matched the SUPER_ADMIN rule for
DELETE /api/v1/skills/*/*, so un-starring was refused for ordinary accounts on
the session path too. Star and rating writes now have their own authorization
entries ahead of that rule.

Closes #713

Signed-off-by: FenjuFu <fufenjupku@gmail.com>
2026-08-21 16:08:50 +08:00
FenjuFu
51457bfa2c
fix(security): 扫描任务改为事务提交后发布 (Closes #612) (#733)
* fix(security): publish scan task after transaction commit

SecurityScanService.triggerScan is @Transactional but published the Redis
Stream scan task inline, before the transaction committed. The stream
consumer could receive the task before the skill_version / security_audit
rows were visible, fail with "SkillVersion not found" / "SecurityAudit not
found", exhaust its immediate retries while the publishing transaction was
still open, and leave the committed version stuck in SCANNING.

Defer the publish to an afterCommit transaction synchronization so the
consumer only ever sees the task once the rows are committed and visible; on
rollback the task is never published. Falls back to an inline publish when
called outside a transaction.

Closes #612

Signed-off-by: FenjuFu <fufenjupku@gmail.com>

* test(security): cover scan task after-commit publishing

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* refactor(security): hide scan publish transaction callback

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

---------

Signed-off-by: FenjuFu <fufenjupku@gmail.com>
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
Co-authored-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
2026-08-21 14:18:40 +08:00
FenjuFu
bbdc0f7a0c
fix(dev): use bash for backend launchers (#721)
Signed-off-by: FenjuFu <92919259+FenjuFu@users.noreply.github.com>
2026-08-21 09:35:52 +08:00
XiaoSeS
c3ecafb206
Merge pull request #736 from iflytek/docs/weekly-w33-official
Some checks failed
Security / Dependency Review (push) Waiting to run
Security / CodeQL (java-kotlin) (push) Waiting to run
Security / CodeQL (javascript-typescript) (push) Waiting to run
Security / CodeQL (python) (push) Waiting to run
Deploy Docs / build (push) Has been cancelled
Deploy Docs / Deploy (push) Has been cancelled
docs(weekly): mirror reviewed W33 report
2026-08-20 17:22:32 +08:00
XiaoSeS
f77e32ada5 docs(weekly): mirror reviewed W33 report
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
2026-08-20 17:19:01 +08:00
mrLi
84feb38931 feat(cli): add namespace workspace sync
Signed-off-by: mrLi <50289372+15258725278@users.noreply.github.com>
2026-08-18 18:24:11 +08:00
青柠
7247defd5d feat(web): pick default namespaces from a list instead of typing slugs
The setting took a comma-separated string, which puts the operator in the
position of remembering exact slugs and getting the punctuation right. The
server rejects a bad slug, but only after a round trip, and only for the
first mistake in the list.

Offer the active namespaces as checkboxes instead. Nothing to spell.

One case the list alone would get wrong: a slug that is configured but has
since been deleted, archived or renamed is not among the choices, so
rendering only the choices would quietly drop it on the next save. Those are
appended as their own rows, ticked and flagged, so dropping one is a decision
rather than a side effect.

The choice list is capped, and a directory larger than the cap says so rather
than presenting a partial list as complete.
2026-08-17 00:44:02 +08:00
青柠
a9e7f43e5a feat(namespace): let operators choose which namespaces new accounts join
A deployment that stands up its own organisation-wide namespace — to use
instead of the built-in global one — finds it invisible to everybody. The
namespace listing only returns namespaces the caller belongs to, and the
only thing that ever added members automatically was hard-wired to the slug
"global".

Make that list a setting. namespace.default-membership holds the slugs every
newly activated account is enrolled in, defaulting to ["global"], which is
what every deployment did before. Publishing only requires membership of any
role, so being enrolled is enough to publish there; no extra grant needed.

GlobalNamespaceMembershipService becomes DefaultNamespaceMembershipService,
since it no longer means one specific namespace.

Where the strictness sits:

- Saving validates every slug resolves to an ACTIVE namespace, so a typo
  fails at the moment an administrator makes it.
- Enrolling tolerates a slug that no longer resolves: it logs and skips.
  A namespace that was deleted or renamed must not cost somebody their
  login.

Adding a namespace to the list after people have signed up leaves them out,
the same trap the personal-namespace work hit, so this ships with the same
preview-then-apply backfill.

Verified against a real PostgreSQL, end to end: create a namespace, reject an
unknown slug, save with whitespace and duplicates and see them normalised,
preview, apply, re-preview showing nothing left, and a fresh registration
landing in the global namespace, the new shared one, and its own personal one
at once.
2026-08-16 20:31:46 +08:00
青柠
4fe6948f87 fix(web): stop the settings form from reverting the saved value
Enabling personal namespace provisioning appeared to save — the request
succeeded and the row held enabled=true — but a refresh showed it disabled
again, and the template inputs stayed greyed out.

The form mounted before the fetched settings reached it. Radix's Select
keeps a hidden native <select> for form integration, and its <option>s only
exist while the dropdown content is mounted. Changing the controlled value
from "disabled" to "enabled" therefore assigned a value that native select
had no option for, which lands on "" and fires a real change event. Radix
forwarded it as onValueChange(""), which read as "disabled" and put the
form straight back where it started.

Hold the form state as null until the settings arrive, so the Select mounts
once with the value it will keep and the controlled value never changes
underneath it. Also ignore any value that is not one of the two real
choices, so a stray event cannot decide the setting.

The page's other Select-bearing sibling never hit this because it lives in
a dialog whose form state is set before the dialog mounts.

The old test rendered with renderToStaticMarkup, which never runs effects
and so could not see this at all. The page tests now run in jsdom via
@testing-library/react; the new one was confirmed to fail against the
previous code and pass against this one. Also verified in a browser against
a real backend: enable, save, reload, disable, save, reload.
2026-08-16 19:20:28 +08:00
青柠
639e081ca7 fix(namespace): stop the backfill from querying with a null keyword
The backfill preview returned 500 on PostgreSQL:

    SQLState 42883: function lower(bytea) does not exist

It reused UserAccountRepository.search(keyword, status, pageable) with a
null keyword. That query compares the keyword with lower(...), and a null
bind leaves PostgreSQL to infer the parameter type as bytea, so lower()
has no matching signature.

Nothing had exercised that branch before: the admin user list goes through
AdminUserSearchRepository, and the member-candidate lookup always passes a
real keyword. The backfill was the first caller to pass null.

Give callers that want every account in a status a query without a keyword
to bind, rather than papering over the null with a cast or an empty string.

Neither test layer would have caught this. The unit tests mock the
repository, and the integration tests run on H2 in PostgreSQL mode, which
accepts the null-typed bind that PostgreSQL rejects. Verified instead
against a real PostgreSQL: preview, apply, and a second preview showing
alreadyProvisioned with nothing left to do, with namespace_member rows
confirming each owner holds OWNER on a TEAM namespace.
2026-08-15 18:10:48 +08:00
青柠
2d50437e4f feat(namespace): backfill personal namespaces for existing accounts
Turning provisioning on only affects accounts activated afterwards, which
on a registry that has already been running means nobody. The first person
to hit this on our deployment was the operator who enabled it: they signed
in, got no namespace, and had no way to find out why.

Two fixes.

Backfill. POST /api/v1/admin/settings/personal-namespace/backfill walks the
active accounts and gives a namespace to those without one, skipping system
accounts and anyone who already owns a non-global namespace. Details worth
knowing:

- dryRun reports the plan — each account and the slug it would take —
  without writing. The console requires a preview before it will enable the
  apply button.
- The response lists only accounts that changed or could not be placed;
  the rest are counted, so an operator reads the work rather than the whole
  directory.
- A run stops at a per-run account cap and reports truncated rather than
  looking like it covered everything.
- Slugs promised earlier in a run are reserved, so one batch cannot hand
  the same slug to two accounts.
- Not @Transactional: each namespace is created in its own transaction, so
  an account that cannot be placed does not discard the rest of the run.

Diagnosability. The skip paths — provisioning disabled, account already owns
a namespace, no slug available — were silent returns, which is why "nothing
happened and I cannot tell why" was the actual user experience. They now log
their reason; account activation is rare enough that the extra lines cost
nothing.
2026-08-15 01:11:51 +08:00
青柠
ae23d1a051 feat(namespace): auto-provision a personal namespace on registration
Self-hosted deployments want every new account to have somewhere of its
own to publish, without asking an administrator for a namespace first and
without pushing drafts into `global`.

Add an operator-controlled policy, off by default so upgrading changes no
behaviour. When enabled, an account that becomes usable gets a namespace
it owns. "Private" here means a team namespace whose only member is that
account: namespaces have no visibility flag, and skill visibility stays a
property of each skill.

Trigger points. UserActivatedEvent is published wherever an account first
becomes usable:

- LocalAuthService.register
- IdentityBindingService.bindOrCreate, for ACTIVE first logins
- AdminUserAppService.updateUserStatus, on a transition into ACTIVE

The third matters for deployments that gate access behind approval: those
accounts are created PENDING at the first OAuth attempt and only become
usable when an administrator approves them.

Why an AFTER_COMMIT listener rather than a call alongside
GlobalNamespaceMembershipService.ensureMember. Both namespace.created_by
and namespace_member.user_id reference user_account(id), which rules out
each obvious alternative:

- Joining the registration transaction lets a slug clash roll the
  registration back, so a namespace failure costs the user their account
  — or, on OAuth, their login.
- Suspending it with REQUIRES_NEW leaves the new transaction unable to see
  the uncommitted user_account row, so the foreign key check blocks on the
  outer transaction's row lock and the two wait on each other.

Provisioning after commit avoids both. The listener is deliberately not
@Async, so the namespace exists by the time the user's next request
arrives, and it swallows failures.

Naming. Two templates over ${username}, ${email_prefix} and ${user_id};
unknown placeholders are left in place so a typo is visible rather than
silently dropped. ${username} falls back to the email local part and then
to the user id. Slugs go through the existing slug rules, which is why the
console renders a live preview: underscores are not legal in a slug, so
`${username}_space` yields `alice-space`. A taken or reserved candidate
gets a numeric suffix, so `admin` becomes `admin-2`. Owning any non-global
namespace already skips provisioning, which keeps re-enabling an account
from handing out a second one.

The templates are not exposed in application.yml: they contain ${...},
which Spring would resolve as property references, and Boot 3.2 predates
placeholder escaping. Only the enable flag lives there; templates are set
in the console and default from PersonalNamespaceProvisioningProperties.

Updating the policy writes an audit entry with the before and after.
2026-08-13 18:17:25 +08:00
青柠
68f120c5e1 feat(admin): add system settings storage
SkillHub has no mechanism for settings an operator can change without a
redeploy: the only per-deployment knobs live in application.yml, and the
only stored preferences are per-user notification preferences.

Add a generic store. One row holds one setting group serialized as JSON,
so a group can gain fields without a schema migration.

Reads take the caller's defaults:

    <T> T get(String settingKey, Class<T> type, T defaults)

which gives two properties worth keeping:

- A group nobody has overridden has no row, and resolves to whatever the
  deployment configured. Configuration-file-only deployments keep working
  exactly as before, and an upgrade changes no behaviour.
- A stored document that can no longer be parsed also falls back to the
  defaults, with a warning. One malformed row must not take down the flows
  that read settings, such as login.

Groups are deserialized with unknown fields ignored so a rolling upgrade
can read documents written by a newer node.

No consumer yet; the following commit adds the first one.
2026-08-13 18:17:02 +08:00
XiaoSeS
d2403bb591
feat(namespace): add admin namespace management
Some checks failed
Security / Dependency Review (push) Has been cancelled
Security / CodeQL (java-kotlin) (push) Has been cancelled
Security / CodeQL (javascript-typescript) (push) Has been cancelled
Security / CodeQL (python) (push) Has been cancelled
Closes #580
2026-08-12 12:39:24 +08:00
XiaoSeS
81be20a6b5
fix(namespace): let super admin view namespaces (#705)
Some checks failed
Security / Dependency Review (push) Waiting to run
Security / CodeQL (java-kotlin) (push) Waiting to run
Security / CodeQL (javascript-typescript) (push) Waiting to run
Security / CodeQL (python) (push) Waiting to run
Deploy Docs / build (push) Has been cancelled
Deploy Docs / Deploy (push) Has been cancelled
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
2026-08-11 16:04:28 +08:00
XiaoSeS
76d95b615e
docs(compliance): document runtime integration contract (#704)
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
2026-08-11 13:43:42 +08:00
XiaoSeS
2fe7514a4e
feat(review): show compliance declaration diffs (#703) 2026-08-11 10:44:11 +08:00
XiaoSeS
0690e3f256
Merge pull request #698 from iflytek/fix/runtime-aliyun-stop-url
Some checks are pending
Security / Dependency Review (push) Waiting to run
Security / CodeQL (java-kotlin) (push) Waiting to run
Security / CodeQL (javascript-typescript) (push) Waiting to run
Security / CodeQL (python) (push) Waiting to run
fix(deploy): correct Aliyun runtime stop URL
2026-08-10 16:13:10 +08:00
XiaoSeS
c56e21e4bb
Merge pull request #699 from iflytek/feat/compliance-search-detail-projection
feat(search): surface compliance mappings in discovery
2026-08-10 15:00:39 +08:00
XiaoSeS
1dfe3756a9 feat(search): refine compliance discovery interactions
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
2026-08-10 10:42:19 +08:00
XiaoSeS
4efeed18c8 fix(deploy): preserve Aliyun source mode in stop command
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
2026-08-07 18:20:06 +08:00
XiaoSeS
8de293b38f fix(deploy): correct Aliyun runtime stop URL
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
2026-08-07 16:45:12 +08:00
XiaoSeS
00f55c2db3 feat(search): surface compliance mappings in discovery
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
2026-08-07 16:37:07 +08:00
XiaoSeS
460304eed8
Merge pull request #697 from iflytek/feat/compliance-metadata-snapshot
Some checks failed
Security / CodeQL (python) (push) Has been cancelled
Security / Dependency Review (push) Has been cancelled
Security / CodeQL (java-kotlin) (push) Has been cancelled
Security / CodeQL (javascript-typescript) (push) Has been cancelled
feat(metadata): add compliance snapshot mappings
2026-08-07 16:06:44 +08:00
XiaoSeS
da5c3cba3b feat(search): index compliance snapshot mappings
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
2026-08-07 15:46:06 +08:00
XiaoSeS
a7b25b072b feat(metadata): expose compliance snapshots in version views
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
2026-08-07 15:46:06 +08:00
XiaoSeS
725bbe165b feat(metadata): add x-astron compliance snapshot
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
2026-08-07 15:46:06 +08:00
ylhu16
833270bb31 fix(auth): enforce trusted OAuth identity attributes
Signed-off-by: ylhu16 <ylhu16@iflytek.com>
2026-08-05 11:21:41 +08:00
Gal Eyal
35c080b65c fix(auth): keep the OAuth return target through the provider callback
OAuth2AuthorizationRequestRedirectFilter invokes the resolver on every
request in the chain and the delegate answers null for anything that is
not an authorization request. Recording the return target on those calls
cleared it again on the next request without a returnTo parameter — the
provider callback included, which this filter processes before login
succeeds. The success handler therefore always found an empty session
attribute and fell back to the default target, so returnTo never worked.

Guard the write on a non-null authorization request. As a side effect,
anonymous API requests no longer allocate a session via getSession().

Signed-off-by: Gal Eyal <gal.e@popai.health>
2026-07-29 12:44:00 +03:00
272 changed files with 20407 additions and 437 deletions

View file

@ -13,9 +13,6 @@ permissions:
contents: read
packages: write
env:
DOCKER_PLATFORMS: linux/amd64,linux/arm64
jobs:
publish:
runs-on: ubuntu-latest
@ -31,16 +28,19 @@ 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: ${{ env.DOCKER_PLATFORMS }}
platforms: ${{ matrix.platforms }}
push: true
provenance: false
sbom: false

65
.github/workflows/riscv64-images.yml vendored Normal file
View file

@ -0,0 +1,65 @@
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

View file

@ -29,5 +29,16 @@ project spaces.
## Reporting
Report conduct issues privately to the maintainers through a private maintainer
channel. Do not use public issues for personal or sensitive reports.
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).

View file

@ -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 -- /bin/sh -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 -- bash -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 -- /bin/sh -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 -- bash -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 && /bin/sh -lc '$(DEV_SERVER_PREPARE) && exec env $(DEV_SERVER_SCANNER_ENV) $(DEV_SERVER_CMD)'
cd server && bash -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 -- /bin/sh -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 -- bash -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 \

View file

@ -64,6 +64,19 @@ 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
@ -236,7 +249,9 @@ 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 images target both `linux/amd64` and `linux/arm64`.
Published server and web images target `linux/amd64`, `linux/arm64`, and
`linux/riscv64`; the scanner image currently targets `linux/amd64` and
`linux/arm64`.
**Quick deployment with curl:**
@ -408,6 +423,18 @@ 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:

View file

@ -50,6 +50,7 @@ Skill欢迎分享给 SkillHub 社区,与大家一起丰富开放、实用
- 📖 **[用户指南](https://iflytek.github.io/skillhub/)** — 技能发布、搜索、CLI 使用等用户操作指南
- 🛠️ **[开发者文档](https://zread.ai/iflytek/skillhub)** — 架构设计、API 参考、本地开发、部署运维等技术文档
- 🐍 **[Python 示例](./examples/python)** — 使用 REST API 在 Python 中搜索、下载和发布技能
## 核心特性

View file

@ -32,6 +32,9 @@ skillhub list
# Publish skill
skillhub publish ./my-skill --namespace myspace
# Synchronize a team workspace
skillhub sync pull --namespace myspace
```
## 🌐 Registry Configuration
@ -229,11 +232,45 @@ 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

View file

@ -1,6 +1,6 @@
{
"name": "@astron-team/skillhub",
"version": "0.1.9",
"version": "0.1.10",
"description": "Manage and install skills for AI coding agents",
"keywords": [
"skillhub",

View file

@ -42,6 +42,30 @@ 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 {
@ -80,6 +104,12 @@ 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`
@ -105,10 +135,17 @@ export class SkillHubClient {
return this.deleteJson(`/skills/${namespace}/${slug}`)
}
async publish(namespace: string, file: Blob, visibility: string, fileName = 'skill.zip'): Promise<PublishResponse> {
async publish(
namespace: string,
file: Blob,
visibility: string,
fileName = 'skill.zip',
rejectExistingVersion = false
): 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`, {
@ -122,10 +159,17 @@ export class SkillHubClient {
return this.handleJsonResponse<PublishResponse>(response)
}
async validatePublish(namespace: string, file: Blob, visibility: string, fileName = 'skill.zip'): Promise<DryRunResponse> {
async validatePublish(
namespace: string,
file: Blob,
visibility: string,
fileName = 'skill.zip',
rejectExistingVersion = false
): 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`, {
@ -139,6 +183,28 @@ 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 {

View file

@ -43,6 +43,15 @@ 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]',

179
cli/src/commands/sync.ts Normal file
View file

@ -0,0 +1,179 @@
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
}

View file

@ -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.9"
export const PKG_VERSION = "0.1.10"

View file

@ -9,9 +9,11 @@ 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'
@ -245,6 +247,37 @@ 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)')

View file

@ -111,21 +111,31 @@ function findEndOfCentralDirectory(view: DataView): number {
* Returns the archive as a Blob.
* Pure JS implementation using fflate no system commands needed.
*/
export async function createZip(dirPath: string): Promise<Blob> {
export interface CreateZipOptions {
exclude?: (relativePath: string) => boolean
}
export async function createZip(dirPath: string, options: CreateZipOptions = {}): Promise<Blob> {
const entries: Record<string, Uint8Array> = {}
await collectFiles(dirPath, dirPath, entries)
await collectFiles(dirPath, dirPath, entries, options)
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>): Promise<void> {
async function collectFiles(
basePath: string,
currentPath: string,
entries: Record<string, Uint8Array>,
options: CreateZipOptions
): 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)
const relPath = relative(basePath, fullPath).split('\\').join('/')
if (options.exclude?.(relPath)) continue
if (item.isDirectory()) {
entries[relPath + '/'] = new Uint8Array(0)
await collectFiles(basePath, fullPath, entries)
await collectFiles(basePath, fullPath, entries, options)
} else if (item.isFile()) {
entries[relPath] = new Uint8Array(await readFile(fullPath))
}

View file

@ -7,7 +7,9 @@ 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
@ -18,6 +20,7 @@ export interface InstallOptions {
targets: AgentCandidate[]
force: boolean
home?: string | undefined
resolved?: ResolveResponse | undefined
}
async function preflightInstallTargets(
@ -55,7 +58,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 = await client.resolve(options.namespace, options.slug, options.version)
const resolved = options.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)
@ -71,6 +74,7 @@ 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({
@ -78,6 +82,9 @@ 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))
@ -89,14 +96,30 @@ export async function installSkill(options: InstallOptions): Promise<{ installed
})
}
if (await pathExists(skillDir) && options.force) {
await store.removeTargetsByInstallDir(skillDir)
await rm(skillDir, { recursive: true, force: true })
}
const backupDir = `${skillDir}.skillhub-backup-${process.pid}-${Date.now()}`
let backupCreated = false
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,
@ -105,14 +128,6 @@ 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(() => {})

View file

@ -0,0 +1,54 @@
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))
}

View file

@ -0,0 +1,353 @@
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(/\/+$/, '')
}

View file

@ -14,6 +14,7 @@ export interface InventoryItem {
namespace: string
slug: string
version: string
fingerprint?: string
targets: InventoryTarget[]
}
@ -117,17 +118,19 @@ export class InventoryStore {
namespace: string,
slug: string,
version: string,
target: InventoryTarget
target: InventoryTarget,
fingerprint?: string
): Promise<void> {
const inventory = await this.read()
let item = inventory.items.find(
const existing = inventory.items.find(
i => i.registry === registry && i.namespace === namespace && i.slug === slug
)
if (!item) {
item = { registry, namespace, slug, version, targets: [] }
const item: InventoryItem = existing ?? { registry, namespace, slug, version, targets: [] }
if (!existing) {
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
@ -165,4 +168,30 @@ 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)
}
}

View file

@ -0,0 +1,39 @@
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(() => {})
}
}
}

View file

@ -102,12 +102,14 @@ 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=. */
@ -125,6 +127,13 @@ export interface CapturedDelete {
token: string | null
}
export interface CapturedReview {
namespace: string
slug: string
version: string
targetVisibility: string
}
// ---------------------------------------------------------------------------
// Options
// ---------------------------------------------------------------------------
@ -137,6 +146,7 @@ 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
@ -150,6 +160,8 @@ interface FakeRegistryOptions {
deleteRemote?: FailureMode
publish?: FailureMode
validate?: FailureMode
namespaceSync?: FailureMode
submitReview?: FailureMode
}
}
@ -190,7 +202,8 @@ export async function startFakeRegistry(options: FakeRegistryOptions = {}) {
resolve: CapturedResolve | null
delete: CapturedDelete | null
validate: CapturedValidate | null
} = { publish: null, resolve: null, delete: null, validate: null }
review: CapturedReview | null
} = { publish: null, resolve: null, delete: null, validate: null, review: 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
@ -263,6 +276,31 @@ 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/...
// ------------------------------------------------------------------ //
@ -377,7 +415,12 @@ export async function startFakeRegistry(options: FakeRegistryOptions = {}) {
if (fileField instanceof File) {
fileName = fileField.name || fileName
}
state.validate = { namespace, fileName, visibility }
state.validate = {
namespace,
fileName,
visibility,
rejectExistingVersion: form.get('rejectExistingVersion') === 'true'
}
const dryRunData = options.dryRunResponse ?? {
valid: true,
@ -410,7 +453,12 @@ export async function startFakeRegistry(options: FakeRegistryOptions = {}) {
}
// Record for test assertions.
state.publish = { namespace, fileName, visibility }
state.publish = {
namespace,
fileName,
visibility,
rejectExistingVersion: form.get('rejectExistingVersion') === 'true'
}
return Response.json({
code: 0,
@ -418,12 +466,30 @@ export async function startFakeRegistry(options: FakeRegistryOptions = {}) {
namespace,
slug: fileName.replace(/\.zip$/, ''),
version: '1.0.0',
visibility
visibility,
status: options.publishStatus ?? 'PENDING_REVIEW'
}
})
})
}
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
// ------------------------------------------------------------------ //

View file

@ -0,0 +1,187 @@
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()
}
})
})

View file

@ -15,7 +15,8 @@ describe('SkillHubClient', () => {
namespace: 'team',
slug: 'custom-skill',
version: '1.0.0',
visibility: 'PRIVATE'
visibility: 'PRIVATE',
status: 'UPLOADED'
}
})
}) as unknown as typeof fetch
@ -188,6 +189,34 @@ 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 () => {

View file

@ -228,6 +228,27 @@ 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,

View file

@ -0,0 +1,23 @@
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'])
})
})

View file

@ -71,6 +71,7 @@ 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

View file

@ -136,7 +136,8 @@ skillhub/
- 开发路径:`make dev-all`。前后端在宿主机运行,`docker-compose.yml` 只负责 PostgreSQL、Redis、MinIO。
- 交付路径GitHub Actions 构建并发布 `server` / `web` 镜像;用户通过 `compose.release.yml` 在本地一键拉起前后端容器和基础服务。
- 发布镜像为多架构 manifest至少覆盖 `linux/amd64``linux/arm64`
- 发布镜像为多架构 manifest`server` / `web` 覆盖 `linux/amd64``linux/arm64`
`linux/riscv64``scanner` 暂保持 `linux/amd64``linux/arm64`
单机运行时统一入口:
- `http://localhost/` → Web 容器Nginx
@ -169,7 +170,8 @@ skillhub/
- 数据库迁移Flyway
- 认证Spring Security OAuth2 Client一期 GitHub
- 镜像发布GitHub Actions 推送至 GHCR默认维护 `edge` 与语义化版本标签
- 运行时兼容:发布镜像默认输出 `linux/amd64` + `linux/arm64` 多架构 manifest
- 运行时兼容:`server` / `web` 发布镜像默认输出 `linux/amd64` + `linux/arm64` +
`linux/riscv64` 多架构 manifest`scanner` 暂保持 `linux/amd64` + `linux/arm64`
## 11. Repository / Query Boundary 约定

View file

@ -245,6 +245,7 @@
| 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 | |
@ -252,8 +253,10 @@
- `ACTIVE`:正常使用
- `PENDING`等待管理员审批AccessPolicy 返回 PENDING_APPROVAL 时创建)
- `DISABLED`:管理员封禁,登录后拒绝所有操作,返回 403
- `MERGED`:已合并到其他账号,保留记录不物理删除,登录时自动跳转到合并目标账号
- `MERGED`:已合并到其他账号,保留记录不物理删除;登录直接拒绝,不向调用方泄露合并目标
- 授权层在每次请求时检查用户状态,非 `ACTIVE` 用户拒绝所有写操作
- system account 可按独立 Token Policy 使用非交互凭证,但不能通过本地密码或外部 OAuth
建立普通用户 Session
### identity_binding

View file

@ -94,7 +94,8 @@ astron:
- `DENY`:抛出 `OAuth2AccessDeniedException`,由 `failureHandler` 重定向到 `/access-denied` 页面。不创建用户,不建立 Session。
- `PENDING_APPROVAL`:创建 `user_account`status=`PENDING`),但不建立业务 Session。抛出 `AccountPendingException`,由 `failureHandler` 重定向到 `/pending-approval` 页面(纯静态提示页,无需登录态)。管理员在后台审批后状态变为 `ACTIVE`,用户下次 OAuth 登录才会正常建立 Session。
安全边界PENDING / DISABLED 用户绝不会拥有有效的业务 Session从根源上杜绝"待审批账号已认证"的风险。
安全边界PENDING / DISABLED / MERGED 用户和 system account 绝不会通过交互式登录获得
业务 Session。外部身份命中这些账号时在更新用户资料或加载角色前直接拒绝。
### 2.3 扩展性
@ -361,7 +362,8 @@ public class OAuthClaimsExtractor {
合并操作规则:
- 合并操作写入审计日志
- 合并后原 user_account 标记为 `MERGED`,保留记录不物理删除
- 预留扩展位:未来可配置 `astron.identity.auto-merge-on-verified-email=true` 开启基于已验证邮箱的自动合并
- 不提供按 email 自动合并;即使 Provider 声明 email 已验证,也不能替代对两个账号控制权
的分别证明。未来绑定/合并必须使用显式、可审计的重新认证流程。
## 5. CLI 认证OAuth Device Flow + 平台凭证)

View file

@ -52,9 +52,57 @@ 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 技能包目录结构
```

View file

@ -10,7 +10,8 @@
- 单机交付环境:`docker compose --env-file .env.release -f compose.release.yml up -d`
- 前端和后端都运行在容器内
- 使用 GitHub Actions 发布到 GHCR 的镜像
- 默认发布 `linux/amd64``linux/arm64` 多架构镜像
- 默认发布多架构镜像:`server` / `web` 覆盖 `linux/amd64``linux/arm64`
`linux/riscv64``scanner` 暂保持 `linux/amd64``linux/arm64`
- PostgreSQL、Redis 与应用容器一起通过 Compose 启动
不再维护本地构建整套 demo 容器的中间模式,也不再保留 `docker-compose.prod.yml`
@ -205,10 +206,41 @@ Sentinel 配置优先于 Cluster 和单机 `host`/`port`。在 Kubernetes 等 Se
- `ghcr.io/iflytek/skillhub-server`
- `ghcr.io/iflytek/skillhub-web`
5. 写入 `edge` / `vX.Y.Z` / `latest` / `sha-*` 标签
6. 同时发布 `linux/amd64``linux/arm64` manifest避免 Apple Silicon / ARM 主机依赖模拟层
6. 同时发布多架构 manifest`server` / `web` 覆盖 `linux/amd64``linux/arm64`
`linux/riscv64``scanner` 暂保持 `linux/amd64``linux/arm64`
## 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`

View file

@ -0,0 +1,85 @@
# 注册时自动创建个人命名空间
## 背景
自建部署里常见的诉求:每个新账号都应该有一块属于自己的地盘,可以直接发布技能,
而不必先向管理员申请命名空间、也不必把半成品塞进 `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`

View file

@ -0,0 +1,408 @@
# 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、搜索和外部集成。

220
docs/CONTENT_SAFETY.md Normal file
View file

@ -0,0 +1,220 @@
# 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.

View file

@ -0,0 +1,187 @@
# 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.

51
docs/RISCV64.md Normal file
View file

@ -0,0 +1,51 @@
# 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.

View file

@ -44,6 +44,7 @@ export default defineConfig({
{ text: '审核与治理', link: '/guide/review' },
{ text: '安全扫描', link: '/guide/scanner' },
{ text: '用户交互与社交', link: '/guide/social' },
{ text: 'Runtime 集成契约', link: '/guide/runtime-integration' },
],
},
{
@ -90,6 +91,7 @@ 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' },
],
},
{

View file

@ -71,6 +71,7 @@ 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
![Flow Diagram](/diagrams/review-flow.png)
@ -80,6 +81,24 @@ 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:
@ -181,6 +200,7 @@ 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

View file

@ -0,0 +1,101 @@
# 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.

View file

@ -95,6 +95,45 @@ 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**:

View file

@ -71,6 +71,7 @@ SkillHub 提供了完整的审核工作流,确保发布到注册中心的技
- 浏览文件列表
- 在线查看文件内容
- 下载完整包进行本地测试
- 查看合规声明快照和相对上一发布版本的差异
![流程图](/diagrams/review-flow.png)
@ -80,6 +81,24 @@ SkillHub 提供了完整的审核工作流,确保发布到注册中心的技
5. 添加审核意见(可选)
**审核合规声明**
如果待审核版本包含 `x-astron-compliance`,审核详情会展示版本级合规快照和差异摘要:
- 新增声明:待审版本新增了标准、控制项或证据。
- 删除声明:待审版本移除了上一发布版本已有的声明。
- 修改声明:标准、控制项标题或证据发生变化。
- 摘要变化:`complianceSnapshot.digest` 变化,表示规范化后的声明内容发生变化。
审核建议:
1. 确认声明是否与技能实际能力相关。例如安全响应技能声明 MITRE ATT&CK 技术编号时,应能在说明或证据文件中看到对应依据。
2. 点击差异项查看证据路径、外部链接和摘要,不只看声明标题。
3. 对删除或大范围修改的声明提高审核优先级,因为这会影响下游审计系统引用。
4. 如果证据缺失、路径不可访问、声明明显不匹配技能能力,建议拒绝并要求作者修正。
SkillHub 能保证的是结构正确、证据可追溯、版本快照不可变;不能替作者保证“真的合规”。
**撤回审核**
开发者发现问题,可以在审核通过前撤回提交:
@ -181,6 +200,7 @@ Content-Type: application/json
- **审核时效**:建议在 24 小时内完成审核,避免阻塞开发者
- **审核记录**:所有审核操作都会记录到审计日志
- **合规审计**:合规声明以版本快照形式记录。审核通过或拒绝时,应结合差异摘要判断风险,但 Agent 执行 trace 不由 SkillHub 记录
- **批量审核**:管理员可以批量批准多个技能包
- **审核意见**:拒绝时建议提供详细的改进建议
- **撤回限制**:只有待审核状态的技能包可以撤回

View file

@ -0,0 +1,101 @@
# 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不记录版本 IDslug 指向的是技能容器,不是不可变版本。
- 不要把 SkillHub 的合规声明当成第三方认证结果。
- 不要要求 SkillHub 记录模型输入输出;这是 Runtime 的审计边界。
## 未来可能新增的 API
如果出现明确使用方,例如 Runtime 只需要合规快照而不需要完整技能详情,可以新增不可变版本维度的接口:
```text
GET /api/skill-versions/{skillVersionId}/compliance
```
当前阶段先复用版本详情响应,避免为尚未稳定的调用方提前设计多套 API。

View file

@ -95,6 +95,45 @@ 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 接口
**发布技能包**

View file

@ -2065,9 +2065,9 @@
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.12",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
"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==",
"dev": true,
"funding": [
{
@ -2123,9 +2123,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.15",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
"version": "8.5.26",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
"integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
"dev": true,
"funding": [
{
@ -2143,7 +2143,7 @@
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.12",
"nanoid": "^3.3.17",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},

View file

@ -12,7 +12,7 @@
},
"overrides": {
"vite": "^6.4.3",
"postcss": "^8.5.10",
"postcss": "^8.5.23",
"esbuild": "^0.28.1"
}
}

View file

@ -34,6 +34,17 @@ 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

View file

@ -22,6 +22,17 @@ 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。
## 上传失败
### 技能包上传失败

View file

@ -34,6 +34,16 @@ 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?

View file

@ -22,6 +22,17 @@ 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

85
examples/python/README.md Normal file
View file

@ -0,0 +1,85 @@
# 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).

View file

@ -0,0 +1,90 @@
"""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()

View file

@ -0,0 +1 @@
requests>=2.25

View file

@ -0,0 +1,244 @@
"""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,
)
)

View file

@ -118,9 +118,13 @@ 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"
@ -396,7 +400,7 @@ Web UI: $PUBLIC_URL
Backend API: http://localhost:8080
Runtime dir: $SKILLHUB_HOME
Stop with:
curl -fsSL $SKILLHUB_RAW_BASE/scripts/runtime.sh | sh -s -- down$HOME_ARG
curl -fsSL $RUNTIME_SCRIPT_URL | sh -s -- down$RUNTIME_SOURCE_ARG$HOME_ARG
EOF
;;
down)

View file

@ -2,16 +2,18 @@
set -euo pipefail
BASE_URL="${1:-http://localhost:8080}"
ACTUATOR_BASE_URL="${ACTUATOR_BASE_URL:-$BASE_URL}"
PASS=0
FAIL=0
COOKIE_JAR="$(mktemp)"
TMP_DIR="$(mktemp -d)"
COOKIE_JAR="$TMP_DIR/cookies"
USERNAME="smoketest_$(date +%s)"
EMAIL="${USERNAME}@example.com"
PASSWORD="Smoke@2026"
NEW_PASSWORD="Smoke@2027"
cleanup() {
rm -f "$COOKIE_JAR"
rm -rf "$TMP_DIR"
}
trap cleanup EXIT
@ -31,6 +33,56 @@ 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"
@ -38,11 +90,12 @@ finish() {
}
echo "=== SkillHub Smoke Test ==="
echo "Target: $BASE_URL"
echo "API target: $BASE_URL"
echo "Actuator target: $ACTUATOR_BASE_URL"
echo
check "Health endpoint" "$BASE_URL/actuator/health" "200"
check "Prometheus metrics requires auth" "$BASE_URL/actuator/prometheus" "401"
check_health "Health endpoint" "$ACTUATOR_BASE_URL/actuator/health"
check_protected_actuator "Prometheus metrics requires auth" "$ACTUATOR_BASE_URL/actuator/prometheus"
check "Namespaces API requires auth" "$BASE_URL/api/v1/namespaces" "401"
check "Auth required" "$BASE_URL/api/v1/auth/me" "401"

View file

@ -15,9 +15,19 @@ 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 && /bin/sh -lc '\$(DEV_SERVER_PREPARE) && exec env \$(DEV_SERVER_SCANNER_ENV) \$(DEV_SERVER_CMD)'" "$MAKEFILE" \
grep -Fq "cd server && bash -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

View file

@ -92,6 +92,19 @@ 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"

View file

@ -26,6 +26,7 @@ data=""
cookie_in=""
cookie_out=""
write_code=false
write_format=""
output_file=""
while (($#)); do
case "$1" in
@ -47,6 +48,7 @@ while (($#)); do
;;
-w)
write_code=true
write_format="$2"
shift 2
;;
-o)
@ -76,8 +78,16 @@ 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
*/actuator/health) status=200 ;;
https://public.example/actuator/health|https://public.example/actuator/prometheus)
content_type="text/html"
body='<html>SkillHub</html>'
;;
*/actuator/health)
body='{"status":"UP"}'
;;
*/actuator/prometheus) status=401 ;;
*/api/v1/namespaces)
if [[ -n "$cookie_in" && -f "$cookie_in.session" ]]; then status=200; else status=401; fi
@ -108,24 +118,35 @@ case "$url" in
esac
if [[ "$output_file" != "/dev/null" && -n "$output_file" ]]; then
printf '{}\n' >"$output_file"
printf '%s\n' "$body" >"$output_file"
fi
if [[ "$write_code" == true ]]; then
printf '%s' "$status"
if [[ "$write_format" == *content_type* ]]; then
printf '%s|%s' "$status" "$content_type"
else
printf '%s' "$status"
fi
fi
EOF
chmod +x "$TMP_DIR/bin/curl"
run_smoke() {
run_smoke_at() {
local name="$1"
shift
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" http://skillhub.test >"$out" 2>&1 || status=$?
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 "$@"
}
status="$(run_smoke skip-admin env)"
[[ "$status" == "0" ]] || fail "default smoke without admin credentials should pass"
grep -Fq "SKIP: Admin label management" "$TMP_DIR/skip-admin.out" \
@ -153,4 +174,22 @@ 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"

View file

@ -1,5 +1,6 @@
# ---- Build Stage ----
FROM eclipse-temurin:21-jdk-alpine AS build
# 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
WORKDIR /app
# Cache dependencies
@ -19,8 +20,14 @@ COPY . .
RUN ./mvnw package -DskipTests -B
# ---- Runtime Stage ----
FROM eclipse-temurin:21-jre-alpine
RUN addgroup -S app && adduser -S app -G app
# 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
WORKDIR /app
COPY --from=build /app/skillhub-app/target/*.jar app.jar

View file

@ -20,8 +20,10 @@ 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;
@ -51,6 +53,7 @@ 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,
@ -61,7 +64,8 @@ public class ClawHubCompatAppService {
AuditLogService auditLogService,
CompatSkillLookupService compatSkillLookupService,
SkillStarService skillStarService,
RequestIdAccessor requestIdAccessor) {
RequestIdAccessor requestIdAccessor,
SkillLabelProjectionService skillLabelProjectionService) {
this.mapper = mapper;
this.skillSearchAppService = skillSearchAppService;
this.skillQueryService = skillQueryService;
@ -72,6 +76,7 @@ public class ClawHubCompatAppService {
this.compatSkillLookupService = compatSkillLookupService;
this.skillStarService = skillStarService;
this.requestIdAccessor = requestIdAccessor;
this.skillLabelProjectionService = skillLabelProjectionService;
}
public ClawHubSearchResponse search(String q,
@ -195,6 +200,15 @@ 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(
"",
@ -206,8 +220,15 @@ 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(this::toSkillListItem)
.map(item -> toSkillListItem(
item,
includeLabels ? labelsBySkillId.getOrDefault(item.id(), List.of()) : null))
.toList();
String nextCursor = null;
@ -383,7 +404,8 @@ public class ClawHubCompatAppService {
return new ClawHubResolveResponse(matchVersion, latestVersion);
}
private ClawHubSkillListResponse.SkillListItem toSkillListItem(SkillSummaryResponse item) {
private ClawHubSkillListResponse.SkillListItem toSkillListItem(SkillSummaryResponse item,
List<SkillLabelDto> labels) {
long createdAt = 0;
long updatedAt = item.updatedAt() != null ? item.updatedAt().toEpochMilli() : 0;
@ -413,7 +435,8 @@ public class ClawHubCompatAppService {
stats,
createdAt,
updatedAt,
latestVersion
latestVersion,
labels
);
}

View file

@ -10,10 +10,13 @@ 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;
@ -93,9 +96,12 @@ 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, userId, userNsRoles);
return clawHubCompatAppService.listSkills(
page, limit, sort, IncludeOptions.includesLabels(include), userId, userNsRoles);
}
@RateLimit(category = "skills", authenticated = 60, anonymous = 20)

View file

@ -4,6 +4,7 @@ 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;
@ -24,12 +25,11 @@ public class ClawHubRegistrySecurityConfig {
http
.securityMatcher(
new OrRequestMatcher(
new AntPathRequestMatcher("/api/v1/labels"),
new AntPathRequestMatcher("/api/web/labels")
new AntPathRequestMatcher("/api/v1/labels", HttpMethod.GET.name()),
new AntPathRequestMatcher("/api/web/labels", HttpMethod.GET.name())
)
)
.authorizeHttpRequests(auth -> auth.anyRequest().permitAll())
.csrf(csrf -> csrf.disable())
.requestCache(cache -> cache.disable())
.securityContext(context -> context.disable())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));

View file

@ -3,15 +3,21 @@ 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;
/**
@ -21,6 +27,10 @@ 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;
@ -40,10 +50,27 @@ public class CompatSkillLookupService {
}
public CompatSkillContext findByLegacySlug(String slug) {
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()));
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());
}
return new CompatSkillContext(namespace, skill, findLatestVersion(skill));
}
@ -86,6 +113,16 @@ 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(

View file

@ -1,5 +1,7 @@
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(
@ -14,8 +16,27 @@ public record ClawHubSkillListResponse(
Object stats,
long createdAt,
long updatedAt,
LatestVersion latestVersion
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
) {
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,

View file

@ -0,0 +1,93 @@
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;
}
}
}

View file

@ -0,0 +1,151 @@
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)));
}
}

View file

@ -61,23 +61,28 @@ public class NamespaceController extends BaseApiController {
@GetMapping("/namespaces")
public ApiResponse<PageResponse<NamespaceResponse>> listNamespaces(
Pageable pageable,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
return ok("response.success.read", namespacePortalQueryAppService.listNamespaces(pageable, userNsRoles));
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles,
@AuthenticationPrincipal PlatformPrincipal principal) {
return ok("response.success.read",
namespacePortalQueryAppService.listNamespaces(pageable, userNsRoles, platformRoles(principal)));
}
@GetMapping("/me/namespaces")
public ApiResponse<List<MyNamespaceResponse>> listMyNamespaces(
@RequestAttribute("userId") String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
return ok("response.success.read", namespacePortalQueryAppService.listMyNamespaces(userNsRoles));
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles,
@AuthenticationPrincipal PlatformPrincipal principal) {
return ok("response.success.read",
namespacePortalQueryAppService.listMyNamespaces(userNsRoles, platformRoles(principal)));
}
@GetMapping("/namespaces/{slug}")
public ApiResponse<NamespaceResponse> getNamespace(@PathVariable String slug,
@RequestAttribute("userId") String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles,
@AuthenticationPrincipal PlatformPrincipal principal) {
return ok("response.success.read",
namespacePortalQueryAppService.getNamespace(slug, userId, userNsRoles));
namespacePortalQueryAppService.getNamespace(slug, userId, userNsRoles, platformRoles(principal)));
}
@PostMapping("/namespaces")
@ -158,11 +163,14 @@ public class NamespaceController extends BaseApiController {
Pageable pageable,
@RequestAttribute("userId") String userId,
@AuthenticationPrincipal PlatformPrincipal principal) {
Set<String> platformRoles = principal != null && principal.platformRoles() != null
return ok("response.success.read",
namespacePortalQueryAppService.listMembers(slug, pageable, userId, platformRoles(principal)));
}
private Set<String> platformRoles(PlatformPrincipal principal) {
return 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")

View file

@ -23,6 +23,7 @@ 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;
@ -50,18 +51,21 @@ 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;
}
@ -138,7 +142,8 @@ public class SkillController extends BaseApiController {
v.getFileCount(),
v.getTotalSize(),
v.getPublishedAt(),
skillQueryService.isDownloadAvailable(v)
skillQueryService.isDownloadAvailable(v),
complianceSnapshotProjectionService.fromParsedMetadataJson(v.getParsedMetadataJson())
)));
return ok("response.success.read", response);
@ -173,7 +178,8 @@ public class SkillController extends BaseApiController {
detail.totalSize(),
detail.publishedAt(),
detail.parsedMetadataJson(),
detail.manifestJson()
detail.manifestJson(),
complianceSnapshotProjectionService.fromParsedMetadataJson(detail.parsedMetadataJson())
);
return ok("response.success.read", response);
}

View file

@ -1,16 +1,20 @@
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;
@ -27,11 +31,14 @@ 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
@ -40,6 +47,8 @@ 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"))
@ -49,6 +58,7 @@ 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,
@ -60,7 +70,18 @@ public class SkillSearchController extends BaseApiController {
userNsRoles
);
return ok("response.success.read", response);
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());
}
private String normalizeSort(String sort) {

View file

@ -0,0 +1,42 @@
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;
}
}

View file

@ -44,7 +44,7 @@ public class SkillPackageArchiveExtractor {
try (ZipInputStream zis = new ZipInputStream(file.getInputStream())) {
ZipEntry zipEntry;
while ((zipEntry = zis.getNextEntry()) != null) {
if (zipEntry.isDirectory()) {
if (isDirectoryEntry(zipEntry)) {
zis.closeEntry();
continue;
}
@ -164,6 +164,14 @@ 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;

View file

@ -40,7 +40,7 @@ public class ZipPackageExtractor {
try (ZipInputStream zis = new ZipInputStream(file.getInputStream())) {
ZipEntry zipEntry;
while ((zipEntry = zis.getNextEntry()) != null) {
if (zipEntry.isDirectory()) {
if (SkillPackageArchiveExtractor.isDirectoryEntry(zipEntry)) {
zis.closeEntry();
continue;
}

View file

@ -0,0 +1,38 @@
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
);
}
}

View file

@ -0,0 +1,11 @@
package com.iflytek.skillhub.dto;
import java.util.List;
public record AdminNamespaceListResponse(
List<AdminNamespaceSummaryResponse> items,
long total,
int page,
int size,
AdminNamespaceListStatsResponse stats
) {}

View file

@ -0,0 +1,8 @@
package com.iflytek.skillhub.dto;
public record AdminNamespaceListStatsResponse(
long total,
long active,
long frozen,
long archived
) {}

View file

@ -0,0 +1,40 @@
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
);
}
}

View file

@ -0,0 +1,6 @@
package com.iflytek.skillhub.dto;
public record AdminNamespaceStatsResponse(
long memberCount,
long skillCount
) {}

View file

@ -0,0 +1,38 @@
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
);
}
}

View file

@ -0,0 +1,8 @@
package com.iflytek.skillhub.dto;
public record ComplianceEvidenceResponse(
String type,
String path,
String url,
String sha256
) {}

View file

@ -0,0 +1,11 @@
package com.iflytek.skillhub.dto;
import java.util.List;
public record ComplianceMappingResponse(
String standard,
String version,
String controlId,
String title,
List<ComplianceEvidenceResponse> evidence
) {}

View file

@ -0,0 +1,9 @@
package com.iflytek.skillhub.dto;
import java.util.List;
public record ComplianceSnapshotResponse(
String schemaVersion,
List<ComplianceMappingResponse> items,
String digest
) {}

View file

@ -1,7 +1,9 @@
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,
@ -16,9 +18,81 @@ public record SkillSummaryResponse(
Integer ratingCount,
String namespace,
Instant updatedAt,
String ownerId,
String ownerDisplayName,
boolean canSubmitPromotion,
SkillLifecycleVersionResponse headlineVersion,
SkillLifecycleVersionResponse publishedVersion,
SkillLifecycleVersionResponse ownerPreviewVersion,
String resolutionMode
) {}
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);
}
}

View file

@ -11,5 +11,6 @@ public record SkillVersionDetailResponse(
long totalSize,
Instant publishedAt,
String parsedMetadataJson,
String manifestJson
String manifestJson,
ComplianceSnapshotResponse complianceSnapshot
) {}

View file

@ -10,5 +10,6 @@ public record SkillVersionResponse(
int fileCount,
long totalSize,
Instant publishedAt,
boolean downloadAvailable
boolean downloadAvailable,
ComplianceSnapshotResponse complianceSnapshot
) {}

View file

@ -8,6 +8,7 @@ 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;
@ -33,6 +34,7 @@ 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,
@ -40,7 +42,8 @@ public class NotificationEventListener {
RecipientResolver recipientResolver,
NotificationDispatcher dispatcher,
SkillSubscriptionService skillSubscriptionService,
ObjectMapper objectMapper) {
ObjectMapper objectMapper,
SubscriptionRecipientEligibility subscriptionEligibility) {
this.skillRepository = skillRepository;
this.skillVersionRepository = skillVersionRepository;
this.namespaceRepository = namespaceRepository;
@ -48,6 +51,7 @@ public class NotificationEventListener {
this.dispatcher = dispatcher;
this.skillSubscriptionService = skillSubscriptionService;
this.objectMapper = objectMapper;
this.subscriptionEligibility = subscriptionEligibility;
}
@Async("skillhubEventExecutor")
@ -74,6 +78,8 @@ 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);
@ -96,6 +102,8 @@ 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);

View file

@ -0,0 +1,40 @@
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);
}
}
}

View file

@ -1,6 +1,7 @@
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;
@ -28,19 +29,22 @@ 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) {
SkillHubMetrics metrics,
RateLimitProperties properties) {
this.rateLimiter = rateLimiter;
this.clientIpResolver = clientIpResolver;
this.anonymousDownloadIdentityService = anonymousDownloadIdentityService;
this.apiResponseFactory = apiResponseFactory;
this.objectMapper = objectMapper;
this.metrics = metrics;
this.properties = properties;
}
@Override
@ -56,23 +60,33 @@ 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;
// Get limit based on authentication status
int limit = isAuthenticated ? rateLimit.authenticated() : rateLimit.anonymous();
String resourceSuffix = resolveResourceSuffix(rateLimit.category(), request);
// 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);
boolean allowed = isAuthenticated
? rateLimiter.tryAcquire(
"ratelimit:" + rateLimit.category() + ":user:" + userId + resourceSuffix,
"ratelimit:" + category + ":user:" + userId + resourceSuffix,
limit,
rateLimit.windowSeconds())
: checkAnonymousLimit(request, response, rateLimit, limit, resourceSuffix);
windowSeconds)
: checkAnonymousLimit(request, response, category, limit, windowSeconds, resourceSuffix);
if (!allowed) {
metrics.incrementRateLimitExceeded(rateLimit.category());
metrics.incrementRateLimitExceeded(category);
response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
ApiResponse<Void> body = apiResponseFactory.error(429, "error.rateLimit.exceeded");
@ -85,14 +99,15 @@ public class RateLimitInterceptor implements HandlerInterceptor {
private boolean checkAnonymousLimit(HttpServletRequest request,
HttpServletResponse response,
RateLimit rateLimit,
String category,
int limit,
int windowSeconds,
String resourceSuffix) {
if (!"download".equals(rateLimit.category())) {
if (!"download".equals(category)) {
return rateLimiter.tryAcquire(
"ratelimit:" + rateLimit.category() + ":ip:" + clientIpResolver.resolve(request) + resourceSuffix,
"ratelimit:" + category + ":ip:" + clientIpResolver.resolve(request) + resourceSuffix,
limit,
rateLimit.windowSeconds()
windowSeconds
);
}
@ -101,7 +116,7 @@ public class RateLimitInterceptor implements HandlerInterceptor {
boolean ipAllowed = rateLimiter.tryAcquire(
"ratelimit:download:ip:" + identity.ipHash() + resourceSuffix,
limit,
rateLimit.windowSeconds()
windowSeconds
);
if (!ipAllowed) {
return false;
@ -109,7 +124,7 @@ public class RateLimitInterceptor implements HandlerInterceptor {
return rateLimiter.tryAcquire(
"ratelimit:download:anon:" + identity.cookieHash() + resourceSuffix,
limit,
rateLimit.windowSeconds()
windowSeconds
);
}

View file

@ -0,0 +1,19 @@
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);
}

View file

@ -0,0 +1,115 @@
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;
}
}

View file

@ -9,6 +9,8 @@ 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;
@ -16,6 +18,7 @@ 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 {
@ -23,13 +26,23 @@ 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
@ -41,14 +54,19 @@ 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))
.map(skill -> toSummaryResponse(skill, currentUserId, namespacesById, ownersById))
.toList();
}
private SkillSummaryResponse toSummaryResponse(Skill skill,
String currentUserId,
Map<Long, Namespace> namespacesById) {
Map<Long, Namespace> namespacesById,
Map<String, UserAccount> ownersById) {
Namespace namespace = namespacesById.get(skill.getNamespaceId());
SkillLifecycleProjectionService.Projection projection = skillLifecycleProjectionService.projectForViewer(
skill,
@ -75,11 +93,17 @@ 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()
projection.resolutionMode().name(),
null,
null
);
}

View file

@ -0,0 +1,347 @@
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";
}
}

View file

@ -4,6 +4,7 @@ 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;
@ -18,6 +19,7 @@ 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;
@ -44,16 +46,19 @@ 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) {
RoleRepository roleRepository,
ApplicationEventPublisher eventPublisher) {
this.adminUserSearchRepository = adminUserSearchRepository;
this.userAccountRepository = userAccountRepository;
this.userRoleBindingRepository = userRoleBindingRepository;
this.roleRepository = roleRepository;
this.eventPublisher = eventPublisher;
}
@Transactional(readOnly = true)
@ -109,8 +114,13 @@ 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());
}

View file

@ -0,0 +1,92 @@
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();
}
}

View file

@ -72,6 +72,37 @@ 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;

View file

@ -55,7 +55,14 @@ public class NamespacePortalQueryAppService {
}
@Transactional(readOnly = true)
public PageResponse<NamespaceResponse> listNamespaces(Pageable pageable, Map<Long, NamespaceRole> userNamespaceRoles) {
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));
}
Map<Long, NamespaceRole> namespaceRoles = userNamespaceRoles != null ? userNamespaceRoles : Map.of();
if (namespaceRoles.isEmpty()) {
Page<NamespaceResponse> empty = new PageImpl<>(
@ -83,7 +90,8 @@ public class NamespacePortalQueryAppService {
}
@Transactional(readOnly = true)
public List<MyNamespaceResponse> listMyNamespaces(Map<Long, NamespaceRole> userNamespaceRoles) {
public List<MyNamespaceResponse> listMyNamespaces(Map<Long, NamespaceRole> userNamespaceRoles,
Set<String> platformRoles) {
Map<Long, NamespaceRole> namespaceRoles = userNamespaceRoles != null ? userNamespaceRoles : Map.of();
if (namespaceRoles.isEmpty()) {
return List.of();
@ -91,16 +99,19 @@ public class NamespacePortalQueryAppService {
return namespaceRepository.findByIdIn(namespaceRoles.keySet().stream().toList()).stream()
.sorted(Comparator.comparing(Namespace::getSlug))
.map(namespace -> MyNamespaceResponse.from(
namespace,
namespaceRoles.get(namespace.getId()),
namespaceAccessPolicy,
namespaceService.canDelete(namespace, namespaceRoles.get(namespace.getId()))))
.map(namespace -> toMyNamespaceResponse(namespace, namespaceRoles.get(namespace.getId())))
.toList();
}
@Transactional(readOnly = true)
public NamespaceResponse getNamespace(String slug, String userId, Map<Long, NamespaceRole> userNamespaceRoles) {
public NamespaceResponse getNamespace(String slug,
String userId,
Map<Long, NamespaceRole> userNamespaceRoles,
Set<String> platformRoles) {
if (isSuperAdmin(platformRoles)) {
return NamespaceResponse.from(namespaceService.getNamespaceBySlug(slug));
}
Map<Long, NamespaceRole> namespaceRoles = userNamespaceRoles != null ? userNamespaceRoles : Map.of();
Namespace namespace = namespaceService.getNamespaceBySlugForRead(
slug,
@ -138,4 +149,16 @@ 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");
}
}

View file

@ -34,19 +34,22 @@ 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) {
SkillDownloadService skillDownloadService,
ComplianceSnapshotProjectionService complianceSnapshotProjectionService) {
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,
@ -94,7 +97,8 @@ public class ReviewSkillDetailAppService {
version.getTotalSize(),
version.getPublishedAt(),
version.getId().equals(snapshot.activeVersion().getId())
|| skillQueryService.isDownloadAvailable(version)
|| skillQueryService.isDownloadAvailable(version),
complianceSnapshotProjectionService.fromParsedMetadataJson(version.getParsedMetadataJson())
))
.toList();

View file

@ -0,0 +1,94 @@
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()))
);
}
}

View file

@ -8,6 +8,8 @@ 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;
@ -18,6 +20,7 @@ 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;
/**
@ -36,7 +39,9 @@ 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,
@ -45,12 +50,36 @@ 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(
@ -192,21 +221,33 @@ 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, projectionsBySkillId.get(skill.getId())))
.map(skill -> toSummaryResponse(
skill,
namespaceSlugsById,
ownersById,
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(),
@ -221,11 +262,19 @@ 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()
projection.resolutionMode().name(),
headlineVersion != null
? complianceSnapshotProjectionService.fromParsedMetadataJson(headlineVersion.parsedMetadataJson())
: null,
null
);
}

View file

@ -11,6 +11,7 @@ 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;
@ -26,6 +27,7 @@ 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;
@ -42,6 +44,7 @@ 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;
@ -72,6 +75,7 @@ public class ScanTaskConsumer extends AbstractStreamConsumer<ScanTaskConsumer.Sc
reclaimInterval,
messageObservationSupport
);
this.redissonClient = redissonClient;
this.securityScanner = securityScanner;
this.securityScanService = securityScanService;
this.skillVersionRepository = skillVersionRepository;
@ -128,17 +132,49 @@ 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());
@ -259,6 +295,7 @@ 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);
@ -307,9 +344,16 @@ 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;
}

View file

@ -0,0 +1,109 @@
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));
}
}

View file

@ -13,6 +13,7 @@ server:
spring:
messages:
basename: messages
fallback-to-system-locale: false
application:
name: skillhub
lifecycle:
@ -71,6 +72,7 @@ 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
@ -116,6 +118,11 @@ 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:
@ -151,6 +158,26 @@ 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