Guides by plugin kind

Provider (provider) guide

A provider is an HTTPS service on your own server implementing cdnca.provider/v1 to give the control plane a DNS, SMS, mail, ICP lookup or payment channel. The control plane calls you with signed requests; your code never runs inside it.

← All guides

Quick start

  1. Create a plugin of kind provider.
  2. Generate an Ed25519 response key pair; put the public key (unpadded base64url, 43 characters) in provider_response_key and keep the private key in your service.
  3. Implement /cdnca-provider/v1/{kind}/{action} under base_url: verify first, then the kind’s actions.
  4. In sandbox mode (sandbox=true in the body) answer from test data only and never touch a customer account.
  5. Sign the manifest and upload the version (with at least one platform package). After a customer installs it, the control plane runs the installation contract against your sandbox; the administrator can enable it only when every case passes.

Open now: dns, sms, mail, icp, payment. Notification channels (notify) are coming soon: the manifest may declare it and market review accepts it, but the control plane does not enable notify yet and ignores the declaration; third-party CDN (cdn) is official only.

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

Manifest example and fields

"spec": {
  "categories": ["dns_provider"],
  "compatibility": {"control_api": ">=1.0.35 <2.0.0"},
  "entrypoint": {
    "type": "external_https",
    "base_url": "https://dns.acme.example/api",
    "health_path": "/healthz",
    "provider_response_key": "<响应签名公钥,43 字符 base64url>"
  },
  "provides": [{"kind": "dns", "protocol_version": "1"}],
  "permissions": ["providers.dns.provide"],
  "configuration_schema": {"path": "schemas/config.schema.json", "sha256": "<SHA-256>"},
  "supply_chain": {"sbom_sha256": "<SHA-256>", "provenance_sha256": "<SHA-256>"}
}
FieldRequirement
spec.entrypoint.typeexternal_https
spec.entrypoint.base_urlhttps, a DNS host name (no IP literal or localhost), default port, optional path, no query, fragment or trailing /
spec.entrypoint.health_pathStarts with /, at most 512 bytes
spec.entrypoint.provider_response_keyResponse public key, 43 characters of unpadded base64url; required when providing dns / sms / icp / mail / payment
spec.provides[]1–8 entries with distinct kinds; protocol_version is "1"; a provider plugin declares at least one, other kinds declare none
spec.permissionsEach kind needs providers.<kind>.provide (e.g. providers.dns.provide); without it the manifest is rejected
spec.packagesLists every uploaded platform package and its SHA-256 (currently an evaluation rule)

Protocol and interfaces

Every call is POST {base_url}/cdnca-provider/v1/{kind}/{action} with a JSON body carrying "protocol": "cdnca.provider/v1" and "sandbox"; writes carry idempotency_key (a repeat must return the first result and not apply twice). Requests are at most 256 KiB, answers 1 MiB, timeout 15 s. The control plane connects to public addresses only, without proxies or redirects.

HeaderContent
X-Cdnca-Provider-InstallationControl plane installation ID
X-Cdnca-Provider-TimestampUnix seconds; reject a skew over 300 s
X-Cdnca-Provider-Nonce32 hex characters; reject any nonce seen before
X-Cdnca-Provider-SignatureEd25519 signature of the request message by the installation key, unpadded base64url
X-Cdnca-Provider-Response-Signature(response header) your response key’s signature of the response message
request  = "cdnca-provider-request/v1\n" + installation_id + "\n" + plugin_id + "\n" + kind + "\n" + action + "\n" + timestamp + "\n" + nonce + "\n" + hex(sha256(body))
response = "cdnca-provider-response/v1\n" + plugin_id + "\n" + kind + "\n" + action + "\n" + nonce + "\n" + hex(sha256(response_body))

Return HTTP 401 for a bad signature, timestamp or nonce. Otherwise always HTTP 200 with the envelope {"protocol":"cdnca.provider/v1","ok":true,"data":{…}}, or {"ok":false,"error":{"code":"…"}} on failure; code is one of invalid_request, unauthorized, not_found, conflict, zone_not_found, rate_limited, quota_exceeded, unavailable, unsupported, provider_error, invalid_signature, anything else counts as provider_error. The envelope allows no extra fields. The response signature binds the request nonce, so a captured answer cannot be replayed. The customer’s administrator copies the installation public key to you from “Installation signing key” on the plugin detail page.

kindActionsRequest and answer
dnsverify, list_records, create_record, update_record, delete_recordRecord {id, name, type, value, ttl, line}: name is the FQDN without trailing dot, line a unified line name (默认/电信/联通/移动/境外); list by zone (optional name) returns records; create returns id; an unknown zone answers zone_not_found, deleting a missing record not_found
smsverify, send{to (E.164), template_id, params, body, idempotency_key} → {delivery_id}; a non-E.164 number answers invalid_request
mailverify, health, send{to (one address, no display name), subject, text, html?, reply_to?, idempotency_key} → {message_id}; the sender is your own configuration; health means your upstream account can send now
icpverify, query{domain (lower-case punycode registrable domain)} → {status: filed | unfiled, licence?, subject?}
paymentverify, create_checkout, query_payment, verify_callback, refund, reconcile, sandbox_settleThe plugin only reports payment facts; the control plane binds each fact to its own order number, merchant, integer amount (amount_minor) and currency, and confirms a notification by calling query_payment before crediting. create_checkout returns action (redirect or qr_code) with url / qr_content; verify_callback gets the raw notification bytes the control plane received; reconcile returns one UTC day’s statement; sandbox_settle is sandbox-only and simulates the gateway callback
Sandbox valueValue
SandboxZonecontract.cdnca-sandbox.test
SandboxPhone+8613800000000
SandboxMailboxcontract@cdnca-sandbox.test
SandboxFiledDomain / SandboxUnfiledDomainfiled.cdnca-sandbox.test / unfiled.cdnca-sandbox.test
SandboxMerchantcdnca-sandbox-merchant

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
package:<platform>Yes, 1–16Field names like package:linux-amd64; the platform matches ^[a-z0-9]+-[a-z0-9_]+$ and is unique; each package defaults to at most 64 MiB, as zip, tar.gz or tar
contentMust be absentAn external_https version has no data file

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
  • provider is a privileged kind: every version gets human review

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.

  • entrypoint.provides_invalid: provides is malformed, repeated or has a bad protocol version (reject); entrypoint.provides_official_only: cdn declared (reject).
  • package.*: corrupt archive, zip bomb, path traversal, links, over the entry (10000) or unpacked size limits (32 MiB per file, 128 MiB total) and so on (reject; setuid bits quarantine).
  • secrets.*: private keys or cloud / code-hosting tokens in the package (reject); JWTs and generic credential assignments are in evaluation mode.
  • malware.detected, dependencies.malicious: malware or a known malicious dependency (reject).

Review accepts notify, but the control plane does not enable it yet (see the note in Quick start).

Rules for this kind are in the review rule list: metadata.* entrypoint.* permissions.* package.* secrets.* dependencies.* malware.* diff.*

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.

provides[].protocol_version is "1" (cdnca.provider/v1). The installation contract is cdnca.provider-contract/v1; when it changes the control plane runs the contract again.

Local debugging

libs/entitlement/pluginprovider/fake.go (FakePlugin) in the repository is the reference server: signature, time window, nonce, idempotency, signed answers and sandbox behaviour are all there. This sketch is adapted from it:

func (p *Plugin) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	// {base_url}/cdnca-provider/v1/{kind}/{action}
	i := strings.Index(r.URL.Path, "/cdnca-provider/v1/")
	kind, action, _ := strings.Cut(r.URL.Path[i+len("/cdnca-provider/v1/"):], "/")
	nonce := r.Header.Get("X-Cdnca-Provider-Nonce")
	body, _ := io.ReadAll(io.LimitReader(r.Body, 256<<10))
	ts := r.Header.Get("X-Cdnca-Provider-Timestamp")
	unix, err := strconv.ParseInt(ts, 10, 64)
	sig, _ := base64.RawURLEncoding.DecodeString(r.Header.Get("X-Cdnca-Provider-Signature"))
	skew := time.Now().Unix() - unix
	msg := "cdnca-provider-request/v1\n" + r.Header.Get("X-Cdnca-Provider-Installation") + "\n" + p.PluginID +
		"\n" + kind + "\n" + action + "\n" + ts + "\n" + nonce + "\n" + sha256hex(body)
	if i < 0 || err != nil || len(nonce) != 32 || skew > 300 || skew < -300 || p.seen(nonce) ||
		!ed25519.Verify(p.InstallationKey, []byte(msg), sig) {
		p.reply(w, kind, action, nonce, 401, "unauthorized", nil)
		return
	}
	// … 按 kind/action 处理,写操作按 idempotency_key 去重 …
	p.reply(w, kind, action, nonce, 200, "", map[string]any{})
}

func (p *Plugin) reply(w http.ResponseWriter, kind, action, nonce string, status int, code string, data any) {
	env := map[string]any{"protocol": "cdnca.provider/v1", "ok": code == ""}
	if code != "" {
		env["error"] = map[string]string{"code": code}
	}
	if data != nil {
		env["data"] = data
	}
	body, _ := json.Marshal(env)
	msg := "cdnca-provider-response/v1\n" + p.PluginID + "\n" + kind + "\n" + action + "\n" + nonce + "\n" + sha256hex(body)
	w.Header().Set("Content-Type", "application/json")
	w.Header().Set("X-Cdnca-Provider-Response-Signature",
		base64.RawURLEncoding.EncodeToString(ed25519.Sign(p.ResponseKey, []byte(msg))))
	w.WriteHeader(status)
	w.Write(body)
}

The cases the control plane runs at install time (RunContract, contract.go): every kind has verify, rejects_bad_signature, rejects_tampered_body, rejects_stale_timestamp, rejects_replayed_nonce; dns adds create_a, list_created, create_idempotent, update_a, delete_a, delete_missing_not_found, txt_line, unknown_zone; sms send, send_idempotent, invalid_number; mail health, send, send_idempotent, invalid_recipient; icp filed, unfiled, invalid_domain; payment create_checkout, query_unpaid, sandbox_settle, query_paid, verify_callback, rejects_tampered_callback, refund_idempotent, refund_over_limit, reconcile, unknown_order. Locally, follow newHarness in pluginprovider_test.go: point pluginprovider.NewClient at your service and call pluginprovider.RunContract to get the same result a customer’s control plane will.