按类型开发指南

ui_page 开发者 SDK

带页面插件的协议、受限 API 与后端验签,以及可直接使用的 SDK 源码。

← 外部服务与带页面的插件(external_service / ui_page)开发指南

页面运行环境

页面由你自己的 https origin 提供,主控在固定挂载点用 <iframe sandbox="allow-scripts allow-forms"> 加载(不给 allow-same-origin)。框内 origin 是不透明的 null:读不到主控的 Cookie、localStorage、sessionStorage、IndexedDB,碰不到主控 DOM,也不能导航顶层窗口。只有已启用的 ui_page 插件 origin 会进主控的 frame-src,停用或卸载后下次同步即撤下。

握手与消息

  1. 页面从 URL 片段 #cdnca-nonce=<nonce> 读 nonce,向 parent 发 {protocol, type: "cdnca.hello", nonce}。
  2. 宿主只认这个 iframe 的窗口和这个 nonce,回 {type: "cdnca.ready"} 并转交一个 MessagePort。
  3. 之后所有请求只走这个端口:{protocol, nonce, id, method, params},应答 {protocol, nonce, id, ok, result | error}。
方法说明
context.get用户 ID / 显示名 / 角色、租户作用域、站点 ID、语言、明暗主题;不含任何凭据
navigate{path}:站内相对路径;登录、找回密码、API 密钥、个人资料等页面会被拒绝
api.request{method, path, query?, body?}:经主控代理调用你的 api_base_url

其他消息一律回 message_not_allowed。

受限 API 与后端验签

api.request 由宿主转给主控 POST /v1/plugin-ui/{plugin_id}/proxy,主控再转发到 api_base_url:只连公网、不跟随重定向、只回 JSON、每用户每插件每分钟 120 次。主控不转发用户会话或 token,而是附带三个请求头:

请求头内容
X-Cdnca-Plugin-Contextbase64url JSON(cdnca.plugin-ui-context/v1),60 秒有效,含 nonce
X-Cdnca-Plugin-Signature安装私钥的 Ed25519 签名(base64url)
X-Cdnca-Plugin-Key-Id安装公钥 ID
"cdnca-plugin-ui-proxy/v1\n" + METHOD + "\n" + path?query + "\n" + hex(sha256(body)) + "\n" + context

path 是相对 api_base_url 路径部分的路径。后端用安装公钥验签、检查有效期并记住 nonce。安装公钥由客户主控管理员在“插件市场 → 插件详情 → 安装签名公钥”复制给你(接口 GET /v1/plugin-ui/signing-key,key_id 即 X-Cdnca-Plugin-Key-Id)。写操作与被拒调用记审计 plugin.ui_page.proxy。

安装确认

客户管理员确认安装或升级时,弹窗按授权端签过的清单指纹到插件市场核对该版本,列出将添加的菜单、页面 origin 与插件后端;指纹对不上或市场不可达时只提示去平台核实,不展示其他版本的声明。

SDK 文件与自测

  • cdnca-plugin-ui.mjs:框内 SDK,无依赖。connect() 完成握手,返回 context() / navigate(path) / request({method, path, query, body}),错误是带 code 的 PluginUIError。
  • index.html:示例页。
  • verify-backend.mjs:Node.js 18+ 后端验签(签名、key_id、有效期、nonce 防重放)。
  • proxy-vector.json:主控测试 TestDeveloperSDKProxyVector 生成的金标向量。

文件在仓库 docs/developer/examples/plugin-ui-sdk/,自测命令:

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

cdnca-plugin-ui.mjs(框内 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(后端验签)

// 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
    },
  }
}