Guides by plugin kind

Theme (theme) guide

A theme package is declarative: design tokens, brand images, layout slots and logic-less templates. The control plane renders it into same-origin static CSS/JSON; it carries no script and changes no CSP.

← All guides

Quick start

  1. Create a plugin of kind theme, with an ID such as acme.dev.ocean.
  2. Write theme.json: id must be market.<plugin id> and version equal to the plugin version.
  3. Put the SHA-256 of theme.json in spec.entrypoint.sha256, declare only platform.themes.provide, sign and upload.
{
  "schema": "cdnca.theme/v1",
  "id": "market.acme.dev.ocean",
  "version": "1.0.0",
  "name": {"zh-CN": "海蓝", "en-US": "Ocean"},
  "description": {"zh-CN": "蓝色系主控外观"},
  "tokens": {"colorPrimary": "#1769aa", "borderRadius": 6},
  "layout_slots": {"login_layout": "centered", "sidebar": "dark"},
  "templates": {
    "styles": [{"selector": ".cf-page", "declarations": {"background-color": "token:colorBgContainer"}}],
    "error_page": "<!doctype html><html><head><title>{{title}}</title></head><body><main class=\"cf-page\"><h1>{{status}} {{title}}</h1><p>{{message}}</p></main></body></html>",
    "email": "<!doctype html><html><head><title>{{subject}}</title></head><body><main class=\"cf-page\">{{#lines}}<p>{{.}}</p>{{/lines}}</main></body></html>"
  }
}

Signing and key registration are on the plugin signing page. Signing tool and steps →

Manifest example and fields

The manifest is the same as for a rule pack except for the fields below.

FieldRequirement
spec.entrypoint{"type": "theme", "path": "theme.json", "sha256": "<SHA-256 of theme.json>"}
spec.permissionsExactly one: platform.themes.provide
spec.categoriesUsually ["theme"]
metadata.versionPlain major.minor.patch, each part at most 5 digits

Protocol and interfaces

FieldMeaning
schema / id / versioncdnca.theme/v1; market.<plugin id>; equal to the plugin version
name / descriptionzh-CN required, zh-TW and en-US optional; name at most 64 characters, description at most 512
tokensWhitelisted tokens (colors, lengths, numbers, font stacks, shadows); any unlisted token name is rejected; lengths and numbers have ranges and at most two decimals
tokens_by_roleToken overrides for the admin or user console only
brand_slotsfavicon (image/x-icon, image/vnd.microsoft.icon or image/png), logo / logo_dark / app_icon (image/png), login_ad / login_background (image/jpeg); body_base64 is standard base64, at most 2 MiB each, and the file magic must match the MIME type
layout_slotslogin_layout: split / centered / background; sidebar: light / dark; optional look: classic / tech, motion: off / subtle / rich, up to 8 header_links (https only), footer_text, user_home_blocks
templatesstyles (selector + declarations; a value may be token:<name>), error_page, email; templates support only {{name}}, {{#name}}…{{/name}}, {{^name}}…{{/name}}; error-page variables are status, title, message, footer_text, email variables subject, lines (a list), footer_text

Any string containing url(, @import, expression(, javascript:, <script, <link, <iframe, <object, <embed, src=, href= or srcset= is rejected (except header_links urls and base64 images). A theme package is at most 8 MiB.

Packaging and upload fields

Form fieldRequiredContent and checks
manifestYesThe signed manifest, 1 byte to 256 KiB; it must name this plugin and namespace, and integrity.key_id must be an active key of the namespace whose signature verifies
configuration_schemaYesThe configuration JSON Schema file, 1 byte to 64 KiB; its SHA-256 must equal spec.configuration_schema.sha256
changelog_mdNoRelease notes in Markdown, at most 65536 bytes
channelNostable (default) or beta
auto_publishNotrue / false: publish automatically once approved
contentYestheme.json itself, 1 byte to 8 MiB, SHA-256 equal to spec.entrypoint.sha256
package:<platform>Must be absentA theme version carries no executable package

Endpoint: POST /seller/v1/plugins/{id}/versions, Content-Type: multipart/form-data, with exactly one Idempotency-Key header of 1–128 characters; a retry with the same key returns the same result. The body limit defaults to 200 MiB. Any field name other than those in the table and the kind-specific ones returns 400 invalid_upload.

A plugin version number cannot repeat and must be higher than every reviewed version (approved, published, hidden or delisted). After upload the version is “scanning”; the seller site’s version detail shows the scan report.

Review rules

After upload the review worker runs the validating phase (manifest, entrypoint, permissions, the kind’s own checks); any reject stops it before scanning, then the scanning phase runs. Outcome: a reject → changes requested (fix it and press “Resubmit”, at most 10 scans per version); a quarantine → human review; neither → approved directly or sent to human review under the conditions below.

  • The first version (the plugin has no reviewed version yet)
  • Permissions differ from the last reviewed version (added or removed)
  • Privileged kinds: module, engine_module, provider
  • Sellers who joined less than 90 days ago (market default, adjustable by the operator)
  • Three scan attempts in a row errored

Any one of them sends the version to human review. The full rule table (rule ID, severity, reject or quarantine, evaluation mode) is on the review rules page.

  • theme.invalid: not a valid cdnca.theme/v1 package (tokens, slots, templates, external references); detail carries code and field (reject).
  • theme.id_mismatch: id is not market.<plugin id> (reject).
  • theme.version_mismatch: the theme version differs from the plugin version (reject).

Rules for this kind are in the review rule list: metadata.* entrypoint.* permissions.* theme.*

Versions and compatibility

metadata.version is canonical SemVer (no leading v); every upload must be higher than the reviewed versions, and a version number cannot be uploaded twice.

spec.compatibility.control_api is 1–8 space-separated comparators (>=, <=, =, >, < followed by canonical SemVer), at most 128 characters; all must hold. A control plane that does not satisfy it cannot install or upgrade to the version; an installed plugin keeps its last compatible version.

"compatibility": {"control_api": ">=1.0.35 <2.0.0"}

When a new version adds permissions, the customer’s administrator must confirm them again in the control plane; any version whose permissions change goes to human review.

The theme version must equal the plugin version. When a customer enables a new theme version, the control plane republishes error pages for every tenant.

Local debugging

Review runs the control plane’s renderer, libs/themepack Render. Precheck with it from the repository root; the error code / field match the scan report:

mkdir -p libs/themepack/cmd/thc && cat > libs/themepack/cmd/thc/main.go <<'EOF'
package main

import (
	"fmt"
	"os"

	"github.com/cdnca/cdnca/libs/themepack"
)

func main() {
	raw, _ := os.ReadFile(os.Args[1])
	r, err := themepack.Render(raw)
	if err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}
	fmt.Println(r.Package.ID, r.Package.Version, r.PackageSHA256)
}
EOF
GOWORK=off go -C libs/themepack run ./cmd/thc "$PWD/theme.json"

The built-in themes in libs/themepack/builtin/*.json show a complete package (change id to market.<your plugin id>).