Guides by plugin kind

ui_page developer SDK

The page plugin protocol, restricted API and backend verification, with ready-to-use SDK source.

← External service and page plugin (external_service / ui_page) guide

Where the page runs

Pages are served from your own https origin; the control plane loads them at fixed mount points in <iframe sandbox="allow-scripts allow-forms"> (no allow-same-origin). The frame origin is the opaque null: no access to the control plane’s cookies, localStorage, sessionStorage or IndexedDB, its DOM, or top-level navigation. Only enabled ui_page plugin origins enter the control plane’s frame-src; disabling or uninstalling removes them on the next sync.

Handshake and messages

  1. The page reads the nonce from the URL fragment #cdnca-nonce=<nonce> and posts {protocol, type: "cdnca.hello", nonce} to parent.
  2. The host accepts only this iframe’s window and nonce, answers {type: "cdnca.ready"} and hands over a MessagePort.
  3. From then on every request uses that port: {protocol, nonce, id, method, params}, answered with {protocol, nonce, id, ok, result | error}.
MethodMeaning
context.getUser ID / display name / role, tenant scope, site ID, locale, light or dark theme; no credentials
navigate{path}: an in-app relative path; sign-in, password recovery, API keys, profile and similar pages are refused
api.request{method, path, query?, body?}: call your api_base_url through the control plane proxy

Any other message answers message_not_allowed.

Restricted API and backend verification

api.request goes to the control plane’s POST /v1/plugin-ui/{plugin_id}/proxy, which forwards it to api_base_url: public addresses only, no redirects, JSON only, 120 per user per plugin per minute. The control plane never forwards the user’s session or token; it adds three headers instead:

HeaderContent
X-Cdnca-Plugin-Contextbase64url JSON (cdnca.plugin-ui-context/v1), valid 60 s, with a nonce
X-Cdnca-Plugin-SignatureEd25519 signature by the installation key (base64url)
X-Cdnca-Plugin-Key-IdInstallation key ID
"cdnca-plugin-ui-proxy/v1\n" + METHOD + "\n" + path?query + "\n" + hex(sha256(body)) + "\n" + context

path is relative to the path part of api_base_url. Your backend verifies with the installation public key, checks validity and remembers the nonce. The customer’s administrator copies the installation public key to you from “Plugin market → plugin detail → Installation signing key” (GET /v1/plugin-ui/signing-key; key_id is X-Cdnca-Plugin-Key-Id). Writes and refused calls are audited as plugin.ui_page.proxy.

Installation confirmation

When the customer’s administrator confirms an install or upgrade, the dialog checks the version on the plugin market by the manifest fingerprint the licence service signed and lists the menus, page origins and plugin backend it adds; if the fingerprint does not match or the market is unreachable it only asks to verify with the platform and never shows another version’s declaration.

SDK files and self-test

  • cdnca-plugin-ui.mjs: the in-frame SDK, no dependencies. connect() does the handshake and returns context() / navigate(path) / request({method, path, query, body}); errors are PluginUIError with a code.
  • index.html: a sample page.
  • verify-backend.mjs: Node.js 18+ backend verifier (signature, key_id, validity, nonce replay).
  • proxy-vector.json: the golden vector the control plane test TestDeveloperSDKProxyVector produces.

The files are in docs/developer/examples/plugin-ui-sdk/ in the repository; self-test:

node --test docs/developer/examples/plugin-ui-sdk/sdk.test.mjs

cdnca-plugin-ui.mjs (in-frame SDK)

// cdnca ui_page plugin SDK (inside the frame), protocol cdnca.plugin-ui/v1.
// See docs/developer/plugin-integration.md §8.3. No dependencies; serve it
// from your own page origin and load it as an ES module:
//
//   import { connect } from './cdnca-plugin-ui.mjs'
//   const host = await connect()
//   const ctx = await host.context()           // user / tenant / site / locale / theme, no credentials
//   const res = await host.request({ method: 'GET', path: '/reports', query: 'range=7d' })
//   // res = { status, body }: your backend's answer, relayed by the control plane
//   await host.navigate('/site/list')          // in-app path; auth pages are refused
//
// The page runs in <iframe sandbox="allow-scripts allow-forms"> (opaque
// origin): no cookies or storage of the control plane, and no
// credentials of the user. Everything goes through one private MessagePort
// the host hands over after "cdnca.hello"; the host answers only these
// three methods (anything else: error "message_not_allowed").

export const PROTOCOL = 'cdnca.plugin-ui/v1'

export class PluginUIError extends Error {
  constructor(code) {
    super(code)
    this.name = 'PluginUIError'
    this.code = code
  }
}

/** The mount nonce the host put in the URL fragment (#cdnca-nonce=...). */
export function readNonce(hash = globalThis.location?.hash ?? '') {
  const value = new URLSearchParams(String(hash).replace(/^#/, '')).get('cdnca-nonce')
  if (!value || !/^[A-Za-z0-9_-]{16,64}$/.test(value)) throw new PluginUIError('nonce_missing')
  return value
}

/**
 * connect performs the hello handshake and resolves to the host API.
 * options: { window, timeoutMs, requestTimeoutMs } (window for tests).
 */
export function connect(options = {}) {
  const win = options.window ?? globalThis.window
  const timeoutMs = options.timeoutMs ?? 10000
  const requestTimeoutMs = options.requestTimeoutMs ?? 30000
  const nonce = readNonce(win.location.hash)
  return new Promise((resolve, reject) => {
    const timer = setTimeout(() => {
      win.removeEventListener('message', onReady)
      reject(new PluginUIError('handshake_timeout'))
    }, timeoutMs)
    function onReady(event) {
      // Only the parent (the control plane web app) hands over the port.
      const data = event.data
      if (event.source !== win.parent || !data || data.protocol !== PROTOCOL || data.type !== 'cdnca.ready' ||
        data.nonce !== nonce || !event.ports || event.ports.length !== 1) return
      clearTimeout(timer)
      win.removeEventListener('message', onReady)
      resolve(createHost(event.ports[0], nonce, requestTimeoutMs))
    }
    win.addEventListener('message', onReady)
    // The host's origin is the control plane domain the customer runs; the
    // hello carries nothing secret, the port it answers with is private.
    win.parent.postMessage({ protocol: PROTOCOL, type: 'cdnca.hello', nonce }, '*')
  })
}

function createHost(port, nonce, requestTimeoutMs) {
  const pending = new Map()
  let sequence = 0
  port.onmessage = (event) => {
    const data = event.data
    if (!data || data.protocol !== PROTOCOL || data.nonce !== nonce || !pending.has(data.id)) return
    const { resolve, reject, timer } = pending.get(data.id)
    pending.delete(data.id)
    clearTimeout(timer)
    if (data.ok) resolve(data.result)
    else reject(new PluginUIError(typeof data.error === 'string' ? data.error : 'request_failed'))
  }
  port.start?.()
  function call(method, params) {
    const id = `r${++sequence}`
    return new Promise((resolve, reject) => {
      const timer = setTimeout(() => {
        pending.delete(id)
        reject(new PluginUIError('request_timeout'))
      }, requestTimeoutMs)
      pending.set(id, { resolve, reject, timer })
      port.postMessage({ protocol: PROTOCOL, nonce, id, method, ...(params === undefined ? {} : { params }) })
    })
  }
  return {
    /** Non-sensitive context: { plugin_id, page_id, user, tenant, site_id, locale, theme }. */
    context: () => call('context.get'),
    /** Move the host to an in-app relative path. */
    navigate: (path) => call('navigate', { path }),
    /**
     * Call your own backend (api_base_url + path) through the control plane.
     * request: { method: GET|POST|PUT|PATCH|DELETE, path: '/x', query?: 'a=1', body?: JSON (not with GET) }.
     */
    request: (request) => call('api.request', request),
    close: () => port.close(),
  }
}

verify-backend.mjs (backend verifier)

// Plugin backend side (Node.js 18+): verify a call the cdnca control plane
// proxied from a ui_page (docs/developer/plugin-integration.md §8.3).
//
//   const verifier = createVerifier({ publicKey, keyId, basePath: '/v1' })
//   const context = verifier.verify({ method, pathWithQuery, body, headers })
//
// publicKey / keyId: shown on the control plane's plugin installation detail
// ("安装签名公钥", GET /v1/plugin-ui/signing-key). basePath is the path part
// of your api_base_url: the signature covers the path relative to it.
// verify throws on any failure; on success it returns the decoded context
// (installation_id, plugin_id, page_id, user_id, role, tenant_scope_id,
// site_id, locale, request_id, nonce, issued_at, expires_at).

import { createHash, createPublicKey, verify as verifySignature } from 'node:crypto'

export const SIGNATURE_DOMAIN = 'cdnca-plugin-ui-proxy/v1'
export const CONTEXT_SCHEMA = 'cdnca.plugin-ui-context/v1'

export function signatureMessage(method, target, body, contextHeader) {
  const digest = createHash('sha256').update(body).digest('hex')
  return Buffer.from(`${SIGNATURE_DOMAIN}\n${method}\n${target}\n${digest}\n${contextHeader}`, 'utf8')
}

function header(headers, name) {
  const value = typeof headers.get === 'function' ? headers.get(name) : headers[name.toLowerCase()] ?? headers[name]
  if (typeof value !== 'string' || !value) throw new Error(`missing ${name}`)
  return value
}

export function createVerifier({ publicKey, keyId, basePath = '', now = () => Date.now(), nonceTTLms = 120000 }) {
  const key = createPublicKey({ key: { kty: 'OKP', crv: 'Ed25519', x: publicKey }, format: 'jwk' })
  const prefix = basePath.replace(/\/$/, '')
  // Each nonce is accepted once within its validity (use a shared store if
  // you run several backend instances).
  const seen = new Map()
  return {
    verify({ method, pathWithQuery, body = Buffer.alloc(0), headers }) {
      if (header(headers, 'X-Cdnca-Plugin-Key-Id') !== keyId) throw new Error('unknown key id')
      if (prefix && !pathWithQuery.startsWith(`${prefix}/`)) throw new Error('path outside api_base_url')
      const target = pathWithQuery.slice(prefix.length)
      const contextHeader = header(headers, 'X-Cdnca-Plugin-Context')
      const signature = Buffer.from(header(headers, 'X-Cdnca-Plugin-Signature'), 'base64url')
      const raw = Buffer.isBuffer(body) ? body : Buffer.from(body)
      if (!verifySignature(null, signatureMessage(method.toUpperCase(), target, raw, contextHeader), key, signature)) {
        throw new Error('bad signature')
      }
      const context = JSON.parse(Buffer.from(contextHeader, 'base64url').toString('utf8'))
      const current = now()
      if (context.schema !== CONTEXT_SCHEMA) throw new Error('unknown context schema')
      if (Date.parse(context.issued_at) > current + 30000 || Date.parse(context.expires_at) <= current) {
        throw new Error('context expired')
      }
      for (const [nonce, expires] of seen) if (expires <= current) seen.delete(nonce)
      if (seen.has(context.nonce)) throw new Error('replayed nonce')
      seen.set(context.nonce, current + nonceTTLms)
      return context
    },
  }
}