@callcorpacd/platform-bridge
v1.9.1
Published
Guest/host postMessage bridge for sandboxed partner content in the CallCorp portal.
Readme
@callcorpacd/platform-bridge
Guest/host postMessage bridge so partner content in a sandboxed iframe can call portal APIs without receiving access tokens.
Used by the Vue3 SandboxedApp control. Designed to grow into a published npm package (partners + internal apps).
Authoring a guest app (create / localhost / publish): see AUTHORING.md.
Install (local / monorepo)
Vue3 already depends on this package via file:../PlatformBridge and Vite aliases:
@callcorpacd/platform-bridge@callcorpacd/platform-bridge/client@callcorpacd/platform-bridge/host@callcorpacd/platform-bridge/protocol
Guest (inside the iframe)
import { createPlatformBridge } from '@callcorpacd/platform-bridge/client';
const platform = await createPlatformBridge();
// Document Params / Route (interpolated on the host) arrive on handshake:
const { RecordId, Mode } = platform.context.params;
const path = platform.context.route;
platform.context.subscribe((params) => {
// Live updates when host Params or Route re-evaluate
// Read platform.context.route for the current path
});
const data = await platform.api.get('api/v1/users/me/full-name');
await platform.api.post('api/v1/some-resource', { hello: 'world' });
const label = await platform.ui.translate('Save');
const canEdit = await platform.ui.hasAccess('guid-of-access-permission');
await platform.events.runEventActions('OnSave', { id: RecordId });
// Realtime feeds (display-agnostic snapshots; host owns the WebSocket):
const kpi = await platform.realtime.open({ kind: 'widget', name: 'QueueDepth' });
kpi.subscribe(({ data, revision }) => {
// render however you want — no grid dependency
});
await kpi.close();
// Server/session events (host owns SignalR + Eventing Subscribe):
const callSub = await platform.serverEvents.subscribe(
{ sessionId: platform.context.params.SessionId, names: ['CallDisconnect'] },
(event) => { /* { sessionId, name, time, content, id } */ }
);
await callSub.unsubscribe();
// Portal EventBus (subscribe-only):
const busSub = await platform.bus.subscribe('SelectedCustomerChanged', (payload) => {});
await busSub.unsubscribe();v4 surface:
platform.protocolVersion/platform.capabilities— negotiated version and host-advertised feature ids from ReadyAckplatform.api.get | cachedGet | post | put | patch | deleteplatform.context.params—Record<string, unknown>from hostControlData.Params(top-level primitives are strings; nested objects/arrays preserved)platform.context.route—stringfrom hostControlData.Route(Shared Panel SPA path)platform.context.subscribe(listener)— notified onbridge.context.update(Params and/or Route)platform.theme.get()/platform.theme.subscribe(listener)— active portal / white-label color snapshot ({ colors, name }); also on ReadyAckplatform.realtime.open({ kind, name, … })— widget or named-list feed; returns a handle withdata,status,subscribe,setQuery(lists),closeplatform.serverEvents.subscribe({ sessionId, names? }, listener)— session events; returns{ subscriptionId, unsubscribe() }platform.bus.subscribe(name, listener)— portal EventBus; returns{ subscriptionId, unsubscribe() }platform.ui.translate(text, isToolTip?)— portal translation via hostTranslateplatform.ui.hasAccess(id, name?)— UI AccessPermission check via hostutils.document_cache.hasAccessplatform.events.runEventActions(name, data?)— runs matchingControlData.Eventsactions on the hostSandboxedApp(document actions only — not EventBus/server-event pub/sub)
Apply theme CSS vars in the guest with @callcorpacd/design-tokens → applyBridgeTheme(platform).
Reserved (not implemented on the host yet): platform.navigate. To extend the bridge, see Extending the bridge.
Published API
Prefer curated /api/v1/... routes for guest demos and partner apps (e.g. api/v1/users/me/full-name). Discover the contract at:
- OpenAPI:
{apiBase}/api/v1/openapi.json - Human docs (Scalar):
{apiBase}/api/v1/docs
Allowlist published paths with AllowedApiPrefixes: ["api/v1/"] (or a narrower prefix). Legacy Apps/... remain callable when explicitly allowlisted.
Security
- Do not call the band API with
fetchand a token from guest code; use the bridge. - The host allowlists paths via
ControlData.AllowedApiPrefixesonSandboxedApp. - Realtime feeds are gated by
AllowAllRealtimeFeedsorAllowedRealtimeFeeds(see below). - Server/session events are gated by
AllowAllServerEventsorAllowedServerEventNames. - EventBus subscriptions are gated by
AllowAllBusEventsorAllowedBusEvents. - Absolute
http(s)URLs are rejected by default. - Do not put secrets or tokens in
Params.
Host (portal)
import { createHostBridgeHandler } from '@callcorpacd/platform-bridge/host';
import api from '@/Services/api';
const handler = createHostBridgeHandler({
getAllowedOrigin: () => contentOrigin,
// Required when multiple same-origin iframes are mounted — scopes Ready/Params to this frame.
getGuestWindow: () => iframeEl?.contentWindow || null,
getAllowedApiPrefixes: () => ['api/v1/', 'Document/'],
getContextParams: () => ({ RecordId: '123', Mode: 'edit' }),
getContextRoute: () => selectedMenuName || '',
getTheme: () => ({ colors: theme.getActiveTheme(), name: theme.getActiveThemeName() }),
getRealtimeSource: () => globalVars.RTData,
getAllowAllRealtimeFeeds: () => false,
getAllowedRealtimeFeeds: () => [{ kind: 'widget', name: 'QueueDepth' }],
watchRealtimeView: (viewKey, cb) => /* Vue $watch → unwatch */,
ui: {
translate: (text, isToolTip) => /* portal Translate */,
hasAccess: (id, name) => /* utils.document_cache.hasAccess(name || '', { ID: id, Enabled: true }) */,
},
events: { runEventActions: (name, data) => /* SandboxedApp.runEventActions */ },
getAllowAllServerEvents: () => false,
getAllowedServerEventNames: () => ['CallDisconnect'],
subscribeServerEvents: (opts, onEvent) => /* eventCoordinator → teardown */,
getAllowAllBusEvents: () => false,
getAllowedBusEvents: () => ['SelectedCustomerChanged'],
subscribeBus: (opts, onPayload) => /* EventBus.$on → teardown */,
api,
});
window.addEventListener('message', handler.handleMessage);
// Later, when params change:
handler.pushContextUpdate();SandboxedApp.jsx wires this for you from ControlData (Params, Route, RTEventSource, AllowAllRealtimeFeeds, AllowedRealtimeFeeds, AllowAllServerEvents, AllowedServerEventNames, AllowAllBusEvents, AllowedBusEvents).
Passing params from a UserControl document
ControlData.Params is a map of JSON-serializable values. String fields (and other top-level primitives) may use portal interpolations ({{...}} or {#...#}). The host evaluates them and delivers the result as platform.context.params.
{
"ControlType": "SandboxedApp",
"ControlData": {
"Name": "PartnerApp",
"SourceKind": "PlatformFile",
"ContentPath": "PartnerContent/hello/index.html",
"AllowedApiPrefixes": ["api/v1/"],
"Params": {
"RecordId": "{{ParamByName(\"Id\")}}",
"Mode": "edit",
"Title": "Hello {# Root.UserName #}",
"AnotherArg": {
"ChildField": "And Value",
"ChildNumber": 123
}
}
}
}Keys are static identifiers. Top-level primitives are coerced to strings (null/undefined → ''). Nested objects/arrays are preserved (including nested numbers, booleans, and null). Use platform.api.* for large or dynamic data — do not put secrets in Params.
Passing route for Shared Panel SPAs
ControlData.Route is a single interpolatable string (same {{ }} / {# #} rules as Url). The host delivers it as platform.context.route on handshake and pushes updates on bridge.context.update whenever the expression re-evaluates (for example when CallCenterHome updates Control.SelectedMenuName).
{
"ControlType": "SandboxedApp",
"ControlData": {
"Name": "PartnerSpa",
"SourceKind": "PlatformFile",
"ContentPath": "PartnerContent/spa/index.html",
"AllowedApiPrefixes": ["api/v1/"],
"Route": "{{Control.SelectedMenuName}}",
"Params": {
"Mode": "edit"
}
}
}const platform = await createPlatformBridge();
console.log(platform.context.route);
platform.context.subscribe(() => {
// Params and/or Route changed — drive vue-router / show-hide panels from context.route
router.replace(platform.context.route || '/');
});Scaffold a demo with create-callcorp-sandbox and --starter route on any stack.
Realtime feeds
platform.realtime exposes the portal RTEventHandler pipeline as data only — full snapshots, no UI opinions. The host page must still host an RTEventHandler that publishes the source (typically GlobalVars.RTData).
const agents = await platform.realtime.open({
kind: 'widget',
name: 'AgentStatusSummary',
filters: { CenterId: '...' },
});
console.log(agents.data); // opaque JSON
agents.subscribe(({ data, revision, at }) => { /* … */ });
await agents.close();
const queue = await platform.realtime.open({
kind: 'list',
name: 'ActiveInteractions',
columns: ['Id', 'State', 'StartTime'],
filters: { _any_: 'smith' },
sort: { field: 'StartTime', descending: true },
range: { start: 0, length: 50 },
});
// queue.data => { total, rows }
await queue.setQuery({ range: { start: 50, length: 50 } });
await queue.close();| Kind | Snapshot shape | RTEventHandler API |
|------|----------------|--------------------|
| widget | Opaque JSON (object or array) | AddView / RemoveView |
| list | { total: number, rows: object[] } | GetNamedListForRealTime / RemoveNamedListForRealTime |
SandboxedApp ControlData
| Field | Role |
|-------|------|
| RTEventSource | Expression for the RT accessor (default GlobalVars.RTData) |
| AllowAllRealtimeFeeds | When true, any kind/name is permitted; supersedes AllowedRealtimeFeeds |
| AllowedRealtimeFeeds | [{ kind, name }, …] allowlist when AllowAllRealtimeFeeds is false/absent |
Default both unset → no realtime feeds (not_allowed).
{
"ControlType": "SandboxedApp",
"ControlData": {
"Name": "RealtimeDashboard",
"SourceKind": "PlatformFile",
"ContentPath": "PartnerContent/realtime/index.html",
"RTEventSource": "GlobalVars.RTData",
"AllowAllRealtimeFeeds": false,
"AllowedRealtimeFeeds": [
{ "kind": "widget", "name": "AgentStatusSummary" },
{ "kind": "list", "name": "ActiveInteractions" }
],
"AllowedApiPrefixes": ["api/v1/"]
}
}Trusted embeds may set "AllowAllRealtimeFeeds": true and omit the list. See samples/realtime/ for a snapshot-logging guest.
Server events and EventBus
platform.serverEvents exposes session events from the host WebSocket / serverEventCoordinator pipeline. Guests never talk to SignalR or Apps/Eventing/* directly.
const sub = await platform.serverEvents.subscribe(
{
sessionId: platform.context.params.SessionId,
names: ['InboundAnswered', 'CallDisconnect'], // omit = all names (requires AllowAllServerEvents)
},
(event) => {
// { sessionId, name, time, content, id }
},
);
await sub.unsubscribe();platform.bus listens to the portal in-memory EventBus (subscribe-only — no guest emit):
const sub = await platform.bus.subscribe('SelectedCustomerChanged', (payload) => {});
await sub.unsubscribe();platform.events.runEventActions remains a separate document-action RPC on ControlData.Events. It is not EventBus or server-event pub/sub.
SandboxedApp ControlData
| Field | Role |
|-------|------|
| AllowAllServerEvents | When true, any session event name (including “all names”) is permitted |
| AllowedServerEventNames | string[] allowlist when allow-all is false; empty/absent → no server-event subscriptions |
| AllowAllBusEvents | When true, any bus channel name is permitted |
| AllowedBusEvents | string[] exact name allowlist when allow-all is false; empty/absent → no bus subscriptions |
Default all unset → subscriptions rejected (not_allowed). Prefer passing sessionId via interpolatable Params. See samples/events/.
{
"ControlType": "SandboxedApp",
"ControlData": {
"Name": "CallListener",
"SourceKind": "PlatformFile",
"ContentPath": "PartnerContent/events/index.html",
"Params": {
"SessionId": "{{ParamByName(\"SessionId\")}}"
},
"AllowedServerEventNames": ["InboundAnswered", "CallDisconnect"],
"AllowedBusEvents": ["SelectedCustomerChanged"],
"AllowedApiPrefixes": ["api/v1/"]
}
}Protocol v4
| Type | Direction | Purpose |
|------|-----------|---------|
| bridge.ready | guest → host | Handshake |
| bridge.ready.ack | host → guest | Handshake complete; payload includes { protocolVersion, negotiatedVersion, minProtocolVersion, capabilities, params, route, theme? } |
| bridge.ready.reject | host → guest | Handshake rejected; e.g. { code: 'version_mismatch', min, max, guestVersion } |
| bridge.context.update | host → guest | { params, route } when host Params and/or Route change |
| bridge.theme.update | host → guest | { colors, name } theme push |
| bridge.api.request | guest → host | { method, url, data? } |
| bridge.api.response | host → guest | { result } |
| bridge.api.error | host → guest | { message, code, status? } |
| bridge.realtime.open | guest → host | { kind, name, filters?, columns?, sort?, range? } |
| bridge.realtime.open.result | host → guest | { subscriptionId, snapshot } |
| bridge.realtime.setQuery | guest → host | { subscriptionId, filters?, columns?, sort?, range? } |
| bridge.realtime.setQuery.result | host → guest | { snapshot } |
| bridge.realtime.close | guest → host | { subscriptionId } |
| bridge.realtime.close.result | host → guest | {} |
| bridge.realtime.update | host → guest | { subscriptionId, snapshot, revision } (push) |
| bridge.realtime.status | host → guest | { subscriptionId?, status, message? } (push) |
| bridge.realtime.error | host → guest | { message, code } |
| bridge.serverEvents.subscribe | guest → host | { sessionId, names? } |
| bridge.serverEvents.subscribe.result | host → guest | { subscriptionId } |
| bridge.serverEvents.unsubscribe | guest → host | { subscriptionId } |
| bridge.serverEvents.unsubscribe.result | host → guest | {} |
| bridge.serverEvents.event | host → guest | { subscriptionId, event } (push) |
| bridge.serverEvents.error | host → guest | { message, code } |
| bridge.bus.subscribe | guest → host | { name } |
| bridge.bus.subscribe.result | host → guest | { subscriptionId } |
| bridge.bus.unsubscribe | guest → host | { subscriptionId } |
| bridge.bus.unsubscribe.result | host → guest | {} |
| bridge.bus.event | host → guest | { subscriptionId, payload } (push) |
| bridge.bus.error | host → guest | { message, code } |
| bridge.ui.request | guest → host | { method, args } (e.g. translate) |
| bridge.ui.response | host → guest | { result } |
| bridge.ui.error | host → guest | { message, code } |
| bridge.events.request | guest → host | { method, args } (e.g. runEventActions) |
| bridge.events.response | host → guest | { result } |
| bridge.events.error | host → guest | { message, code } |
Channel id: callcorp.platform-bridge. Version field must be in [MIN_PROTOCOL_VERSION, PROTOCOL_VERSION] (currently both 4). The host negotiates the guest's version and replies on that session version. Out-of-range handshakes get bridge.ready.reject (version_mismatch) instead of a silent timeout.
ReadyAck also includes optional capabilities (string ids) and negotiatedVersion so guests can feature-detect without a protocol bump. Older guests ignore unknown ReadyAck fields.
Extending the bridge (maintainers)
Partner guests only see the published client surface. New host capabilities (portal helpers, navigation, UI chrome, etc.) require coordinated changes across four layers:
| Layer | File | Responsibility |
|-------|------|----------------|
| Protocol | src/protocol.js | MessageType / payload shapes; bump PROTOCOL_VERSION when shapes or required behavior change |
| Guest | src/client.js | Public platform.* API; request/response correlation |
| Host | src/host.js | Origin checks, dispatch, call into injected portal callbacks |
| Portal wiring | Vue3/.../SandboxedApp.jsx | Pass host callbacks into createHostBridgeHandler (component mixins, api, params, …) |
samples/hello/ mirrors protocol/client for offline demos—update it when you change the wire format.
Design rules
- Prefer a request → response/error pair with
requestId(same pattern asbridge.api.*). Host → guest pushes (likebridge.context.update) are for broadcasts only. - Keep auth and secrets on the host. Guests receive only safe, intentional results.
- Put portal UI helpers under the reserved
platform.uinamespace; navigation underplatform.navigate. - Session/WebSocket events live under
platform.serverEvents; portal EventBus underplatform.bus. Keepplatform.eventsfor document-action RPC (runEventActions) only. - When changing the guest surface, keep scaffolded template
AGENTS.mdfiles increate-callcorp-sandbox/templates/**in sync (see AUTHORING.md andAGENTS.md). - Additive fields on existing messages (e.g.
params/capabilitieson ReadyAck) can stay on the current protocol version if older guests ignore unknown payload keys. New message types or required guest behavior → bumpPROTOCOL_VERSION; host accepts[MIN_PROTOCOL_VERSION, PROTOCOL_VERSION]and sendsbridge.ready.rejecton mismatch. RaiseMIN_PROTOCOL_VERSIONonly when deliberately force-upgrading old guests. - New optional features → ReadyAck
capabilities+ guest docs — not a protocol bump. - Wire host logic through options callbacks on
createHostBridgeHandlerso the package stays free of Vue/methods.jsimports.SandboxedAppcloses overthis.
Checklist: add a host method
- Protocol — Add message types in
MessageType(e.g.bridge.ui.request/bridge.ui.response/bridge.ui.error, or a dedicatedbridge.ui.translateif you want a one-off). Document the payload. - Client — Implement the guest method (usually
async), post the request, resolve/reject from the matching response/error. Replace anynotImplementedstub on that namespace. - Host — In
handleMessage, accept the new type (today non-ApiRequesttraffic is ignored after Ready). Validate payload, call an injected option (e.g.ui.translate), reply with result or error. Never trust the guest for authorization decisions the host must enforce. - SandboxedApp — Pass that option when creating the handler so the host can use component methods/services.
- Docs / samples / agents — Update this README’s guest surface list,
AUTHORING.mdif partners should call it,samples/**mirrors,AGENTS.md(this package), scaffold templateAGENTS.mdfiles, and in-repoUIPages/*/AGENTS.mdwhen protocol/capability text drifts. - Version — Prefer staying on the current
PROTOCOL_VERSIONfor additive work; bump package semver as needed. BumpPROTOCOL_VERSIONonly for incompatible wire breaks; adjustMIN_PROTOCOL_VERSIONonly when forcing upgrades.
Implemented: platform.ui.translate, platform.ui.hasAccess, and platform.events.runEventActions
These follow the checklist above (generic bridge.ui.* / bridge.events.* RPC).
const label = await platform.ui.translate('Save');
const canEdit = await platform.ui.hasAccess('guid-here');
// optional debug label: await platform.ui.hasAccess('guid-here', 'PartnerApp');
await platform.events.runEventActions('OnSave', { id: '123' });SandboxedApp.attachBridge injects:
ui: {
translate: (text, isToolTip) => this.Translate(text, isToolTip),
hasAccess: (id, name) => utils.document_cache.hasAccess(name || '', { ID: id, Enabled: true }),
},
events: {
runEventActions: (name, data) => this.runEventActions(name, data),
},Notes:
Translatereads portal translation lists /controlData.ShouldTranslateon the host instance—do not reimplement translation inside the iframe. Return only the translated string (or the original text).- Tooltip flag (
isToolTip) is optional and boolean-coerced on the host. hasAccessevaluates against the host permission table (Apps/Permissions/GetUIElementsPermissionTable). Pass the AccessPermissionidstring (required) and an optional debugname; the host wraps it as{ ID: id, Enabled: true }.runEventActionslooks upControlData.EventsbyNameand runs that action list; unknown names are a no-op. Guestdatais JSON-cloned before it reaches the host.
The same pattern applies to other mixin helpers or portal services: add protocol + client method + host dispatch + a thin SandboxedApp callback that closes over this / imported services.
Deploying partner content
Preferred path for new apps: scaffold with create-callcorp-sandbox, npm run build, then callcorp-sandbox publish (see AUTHORING.md). Vite dist/ uses base: './'; SandboxedApp PlatformFile mode rewrites relative JS/CSS asset URLs to authenticated blob URLs.
Content path convention: PartnerContent/{AppId}/[version/]index.html
Platform FileAPI (recommended for testing)
Upload via CLI publish or the file browser. Minimal hand-built sample: samples/hello/.
{
"ControlType": "SandboxedApp",
"ControlData": {
"Name": "PartnerApp",
"SourceKind": "PlatformFile",
"ContentPath": "PartnerContent/hello/index.html",
"AllowedApiPrefixes": ["api/v1/"],
"Params": {
"Greeting": "Hello from the host"
}
}
}Named sandbox (cross-tenant)
SourceKind: SandboxName loads content owned by another tenant via a sandbox lookup ID. The host fetches through Apps/SandboxedApp/File/{*FilePath}?ID=<SandboxName>; the server resolves the PartnerContent root. ContentPath is relative to that root (e.g. index.html).
{
"ControlType": "SandboxedApp",
"ControlData": {
"Name": "PartnerApp",
"SourceKind": "SandboxName",
"SandboxName": "f92aa3cb-257f-41ea-ab20-9f31ebd97115",
"ContentPath": "index.html",
"AllowedApiPrefixes": ["api/v1/"],
"Params": {
"Greeting": "Hello from the host"
}
}
}Live localhost (dev)
{
"ControlType": "SandboxedApp",
"ControlData": {
"Name": "PartnerAppDev",
"SourceKind": "Url",
"Url": "https://localhost:5174/",
"AllowedApiPrefixes": ["api/v1/"],
"Params": {
"Mode": "dev"
}
}
}Mass Storage / S3
{
"ControlType": "SandboxedApp",
"ControlData": {
"Name": "PartnerApp",
"SourceKind": "MassStorage",
"OwnerId": "{# Root.CustomerID #}",
"ContentPath": "PartnerContent/my-app/index.html",
"AllowedApiPrefixes": ["api/v1/", "Document/"],
"Params": {
"RecordId": "{{ParamByName(\"Id\")}}"
}
}
}Roadmap
- Expand host capabilities (
navigate, additionalui/eventshelpers) with a protocol version bump when required. - Optional row-level diff mode for large list feeds (snapshot mode is the v2 default).
- Optional VuePortal
/partner-content/...route for streaming FileBin without blob rewriting. - Designer schema: add
ControlData.Params(object,additionalPropertiesallowing string or nested object/array JSON) andControlData.Route(string) onDynamicControl_SandboxedApp(runtime already accepts them viaadditionalProperties: true).
Published for partners as public npm packages (@callcorpacd/platform-bridge, @callcorpacd/sandbox-cli, create-callcorp-sandbox). See ../create-callcorp-sandbox/PARTNER.md.
