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