Plugin Hub — author & ops manual
For plugin authors (what a package must look like, how to publish) and operators (how to publish, revoke, and where the keys live). The full client behaviour and API contract live in
design/plugin-marketplace.md(Chinese); this page is the practical version. The Chinese page 官网插件工坊(作者/运维手册) has the same content in Chinese.
1. Two distribution channels
| Steam Workshop | Official Plugin Hub | |
|---|---|---|
| Who uses it | Steam build | Self-distributed build (TRANSBOX_DISTRIBUTION=selfpub) |
| Delivery | Valve CDN | /api/workshop/* + private R2 bucket |
| Login | Steam account | TransBox account (device-code login in the client) |
| Package | .zip (root mod.json) | .tbmod (same zip, plus a signature) |
| Trust | Steam moderation | Ed25519 signature verified against a key baked into the client |
The same plugin can ship on both channels. Load priority is fixed: local > steam > web (only one copy per id).
2. Package format: .tbmod
A plain zip with mod.json at the root (no wrapping folder, and manifest.json is not accepted).
my_mod.tbmod
├── mod.json required: id / name / version / type / **target_app_version**; optional: min_app_version …
├── terms.json glossary (type=glossary)
├── provider.py engine implementation (type=mt/asr/tts…)
└── locales/zh-CN.json plugin-local strings (recommended)Rules (violations are rejected automatically, and the client refuses to install):
idneeds at least two segments using[A-Za-z0-9_-]; reserved org segments (transbox,steam,community, …) are admin-only;versionmust be strict semver and greater than the latest published version. Both the in-app publish form (“Plugin Hub → Publish”) and the web author centre (/workshop-author) have a required Version field (defaults to mod.json): type a higher version and submit — packaging writes that value into the package's mod.json (only theversionkey). After an in-app publish the value is also synced back into your localmod.json(formatting preserved);target_app_versionis required (write"0.2"): it is how the client tells an "0.2-ready" plugin from a 0.1-era one. A package without it is refused at install time (InstallError), and dropping the folder intoextensions/will not get it recognised either — the point is to stop old plugins from being auto-installed into a new client and blowing up. See Compatibility & version gates;min_app_versionis optional: omitted (or empty) =1.0.0= “no restriction” (same convention as the client and the CLI packer); if you do declare it, it must bex.y.zor the publish is rejected. The in-app publish flow (“Plugin Hub → Publish”) fills in the default for you;- POSIX relative paths only: no
.., absolute paths, symlinks, or duplicate entries; .workshop/(client-side receipts),__pycache__/,*.pycare forbidden;- nested archives (
.zip/.7z/.rar/.tbmod) are forbidden; - files referenced by
entrypoint/icon/preview_imagemust exist in the package.
Size limits (enforced by the server, then written into the signed manifest):
data tier | script tier | |
|---|---|---|
| Archive | ≤ 5 MB | ≤ 50 MB (format limit) |
| Unpacked | ≤ 50 MB (ratio ≤ 20) | ≤ 200 MB |
| Files | ≤ 500 | ≤ 2000 |
| Single file | ≤ 5 MB | ≤ 50 MB |
⚠️ Uploads from the app or the web author centre are hard-capped at 20 MB (stricter than the format limits above): the hub runs on a Cloudflare Pages Function, and packages from ~45 MB up get cut off mid-upload — the client only sees a write failure with no useful message. Contact us for larger packages, or move the big files into
model_artifacts.
Do not bundle big models or big native runtimes — declare them with model_artifacts (HuggingFace / ModelScope); the client downloads them into its data directory, and this works for DLLs / .exe runtimes too.
Need another plugin installed first? Declare it with requires_plugins (mirrors Steam Workshop Required Items), up to 8 entries:
"requires_plugins": [
{ "id": "workshop.amd_llama", "min_version": "1.0.0", "reason": "provides the llama.cpp runtime" }
]Client behaviour: if the prerequisite is missing / disabled / too old, the plugin cannot be enabled — the card shows “Requires plugins: …” with a one-click install (code-bearing plugins are never installed silently). Locate files inside a prerequisite with host.plugin_dir(id) — never hard-code a folder name (workshop installs use the plugin id as the folder). See the client's docs/WORKSHOP_GUIDE.md §3.11.
3. Tier: data vs script
Derived from the package contents, strictly:
- contains
.py/.qml/.js/.dll/.exe/..., declarespermissions, declaresui_extensions, or itstypeis an engine type →script; - everything else (plain
terms.jsonglossaries, language packs, QML-free themes) →data.
Client behaviour:
| After install | Updates | New device sync | |
|---|---|---|---|
data | follows the user's toggle | takes effect after restart | installed normally |
script | disabled by default; confirmation dialog on first enable | takes effect after restart (new permissions ask again) | installed, not enabled |
Script plugins run with the same privileges as the host, unsandboxed. Declare what you use in
mod.json→permissions(net/clipboard/hotkey/window/audio/fs_read/fs_write/process); reviewers and users both read it.
4. Signing: workshop.json + envelope
Generated and signed by the server after review — authors do nothing:
{
"schema": 1,
"id": "web.alice.my_glossary",
"version": "1.2.0",
"sha256": "…", // sha256 of the whole .tbmod
"size": 12345,
"unpacked_size": 40960,
"max_file_size": 20480,
"min_app_version": "1.0.0",
"permissions": ["net"],
"category_tier": "data",
"files": [{ "path": "mod.json", "sha256": "…" }],
"signed_by": "wk-2026-1", // must equal the envelope key_id
"created_at": 1790999253
}{ "payload": "<raw text above>", "signature": "<Ed25519 base64>", "key_id": "wk-2026-1" }Client verification (all must pass): key_id is in the baked-in allow-list → signature verifies over the payload’s raw UTF-8 bytes → signed_by == key_id → byte count and sha256 match → after unpacking, every declared file matches and one extra file voids the install.
Key management: private key lives in a Cloudflare Pages Secret (WORKSHOP_PKG_SIGNING_KEY), the matching public key is compiled into the client (plugins/workshop/signing.py). Rotation: ship a client that trusts the new key first, then switch the server key_id.
5. Where the client puts things
- Install dir:
<install>/data/workshop/<id>/(separate from the shippedextensions/, survives app updates); - Each plugin carries a client-side receipt (
.workshop/receipt.json) plus the signed manifest (.workshop/workshop.json[.sig]) for offline re-verification; - Installs are atomic (stage → verify → swap), the previous version is kept in
.previous/; - Updates take effect after a restart;
- Auto-sync (web channel): 10 s after start, then every 24 h — revocations (ETag + monotonic
seq), subscription sync (cursor + tombstones), update check.
6. Publishing
6.1 Official plugins
# run inside the trans-box-website repo; the key file is the same one stored as a Cloudflare Secret
node scripts/workshop-publish.mjs \
--dir ../trans-box/extensions/community_theme_glassmorphism \
--official --changelog "First release" \
--private-key-file <path-to-private-key-backup>
# --all-official scans every non-example_* folder; --dry only packs and validatesThe script packs the .tbmod, validates it locally with the same server code (server/workshop-zip.js), then uploads straight to production R2 + D1 through wrangler. Proxy variables are stripped in child processes (bucket and database are in APAC).
6.2 Community uploads (available)
Open /workshop-author in a browser and sign in with your site account:
- Pick a package: a
.tbmod/.zipfile, or a whole plugin folder (rootmod.jsonrequired — the server packs it for you). The server recomputes hashes and re-validates everything on upload; bad packages are rejected immediately. - Fill in the listing: title, summary, description, English slot, changelog, visibility (public / unlisted / private), licence.
- Submit for review:
- pure-data plugins (glossaries, language packs, QML-free themes) can auto-publish;
- anything containing code goes to the review queue — an admin approves (signs and publishes) or rejects it in
/admin/workshop(the rejection reason is shown to the author).
- Manage published plugins: publish a new version (version must increase), edit metadata/visibility, or unpublish (soft delete — already-installed users are unaffected).
Quota: at most 5 versions waiting for review, at most 10 versions per author per day. You can also publish from the client: in the official build open Workshop → Plugin Hub → Publish and pick the plugin folder (it uses the workshop:publish scope of the workshop token; the server validates exactly the same way). Managing published plugins happens in the web author centre.
7. API quick reference
Prefix /api/workshop; all timestamps are Unix seconds; pages start at 1; size is bytes.
| Endpoint | Auth | Notes |
|---|---|---|
GET /items?page&page_size&q&tag&type&sort&lang&min_app&official | anonymous | catalogue (sort: updated/recent/popular/top) |
GET /items/{id} | anonymous | detail (versions, permissions, i18n slots) |
GET /items/{id}/download?app_version= | anonymous | returns {url, expires_at, size, sha256, signed}; 409 incompatible / 410 revoked |
GET /files/{token} | token | streams the .tbmod, supports Range (10 min TTL) |
POST /updates/check | anonymous | batch update/revocation check, app_version required |
GET /revocations | anonymous | signed revocation list, ETag/304 |
GET /keys | anonymous | public key info (never used to add trust) |
GET /me/subscriptions?since= | workshop token or site cookie | my subscriptions (incremental + tombstones + cursor) |
PUT / DELETE /me/subscriptions/{id} | same (workshop:subscribe) | subscribe / unsubscribe (idempotent) |
GET /me/published | same | my items (with per-version review status and rejection reason) |
POST /uploads | author (cookie or workshop:publish) | upload a package (archive, or files[]+paths[] for a folder); returns upload_id + validation summary |
POST /items / POST /items/{id}/versions | same | create an item / submit a new version from an upload_id |
PATCH / DELETE /items/{id} | same | edit metadata/visibility, unpublish |
POST /items/{id}/report | signed-in user | report (malware/copyright/illegal/spam/other) |
POST /admin/publish … | Cloudflare Access | admin: publish, visibility, review queue, approve/reject, revoke |
Client headers: Authorization: Bearer <TBW1 token>, User-Agent: TransBox/<version> (+site), X-TransBox-App-Version, X-TransBox-Distribution. A proper User-Agent is mandatory — Cloudflare answers 403 (error 1010) to the default Python-urllib.
8. Common rejections
| Symptom | Cause |
|---|---|
bad_package: mod.json must be at package root | wrapping folder, or manifest.json instead |
bad_package: forbidden path | .workshop/, __pycache__/, .pyc shipped |
reserved_id | org segment uses a reserved name |
409 conflict: version must be greater | version not bumped |
413 too_large | tier limits exceeded — use model_artifacts for models |
| Client refuses to install | key_id not trusted / bad signature / size or sha256 mismatch / extra files |
