@callcorpacd/platform-bridge 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AUTHORING.md ADDED
@@ -0,0 +1,102 @@
1
+ # Partner App Author DX
2
+
3
+ Create → develop (mock or live portal) → build → publish → embed.
4
+
5
+ ## Quick start (partners — no Platform repo)
6
+
7
+ See [`../create-callcorp-sandbox/PARTNER.md`](../create-callcorp-sandbox/PARTNER.md). Maintainer publish steps: [`../create-callcorp-sandbox/PUBLISH.md`](../create-callcorp-sandbox/PUBLISH.md).
8
+
9
+ ## Quick start (from this monorepo)
10
+
11
+ ```bash
12
+ cd WebApps/VuePortal_new
13
+ node create-callcorp-sandbox/index.js my-app -y
14
+ cd my-app
15
+ npm install
16
+ ```
17
+
18
+ Default template is **Vue 3 + Vite**. Other stacks:
19
+
20
+ ```bash
21
+ node create-callcorp-sandbox/index.js --list-templates
22
+ node create-callcorp-sandbox/index.js my-app -y --template html-js
23
+ node create-callcorp-sandbox/index.js my-app -y --template vue-quasar
24
+ ```
25
+
26
+ See [`../create-callcorp-sandbox/templates/README.md`](../create-callcorp-sandbox/templates/README.md) for the catalog.
27
+
28
+ You may run the scaffolder from any drive (e.g. `Q:\`); it creates `./my-app` under your cwd and links `@callcorpacd/platform-bridge` with an absolute `file:C:/...` path when the monorepo is on another drive.
29
+
30
+ **SUBST / mapped drives:** If `Q:` is `SUBST`’d to a folder on `C:`, Vite can resolve modules to the real `C:\...` path while the server root is `Q:\...`. The template `vite.config.js` allows both via `server.fs.allow`. If you still see “Failed to load url /src/main.js”, run `npm run dev` from the **real** path (`C:\CallCorp\RootLocalFlowProjects\my-app`) instead of `Q:\my-app`.
31
+ ### Offline mock bridge
32
+
33
+ ```bash
34
+ npm run dev:mock
35
+ ```
36
+
37
+ ### Live portal embed (real APIs)
38
+
39
+ ```bash
40
+ npm run dev
41
+ ```
42
+
43
+ Then paste the **dev** snippet into a portal UserControl (`SourceKind: Url` → `https://localhost:5174/`). The first `npm run dev` may prompt once to install a local trusted CA (mkcert); after that the portal iframe loads without a certificate click-through.
44
+
45
+ ```bash
46
+ npx callcorp-sandbox embed-snippet --mode dev
47
+ npx callcorp-sandbox embed-snippet --mode prod
48
+ ```
49
+
50
+ ### Publish to Platform Files
51
+
52
+ If `~/.callcorp/sandbox.json` is missing, the CLI reuses a parent BuildCC workspace API key (`.buildcc/secrets.json`) when present — skip `login` in that case.
53
+
54
+ ```bash
55
+ # npx callcorp-sandbox login # only if no sandbox.json / BuildCC key
56
+ npm run build
57
+ npx callcorp-sandbox publish
58
+ ```
59
+
60
+ Use the **prod** embed snippet (`SourceKind: PlatformFile`) after publish.
61
+
62
+ Mass Storage:
63
+
64
+ ```bash
65
+ npx callcorp-sandbox publish --target mass-storage --owner-id <customerId>
66
+ ```
67
+
68
+ Scaffolded apps include a stack-specific `AGENTS.md` (bridge rules, publish, auth skip order). Agents creating a new app should read [`../create-callcorp-sandbox/AGENTS.md`](../create-callcorp-sandbox/AGENTS.md) and pick a template from [`../create-callcorp-sandbox/templates/README.md`](../create-callcorp-sandbox/templates/README.md).
69
+
70
+ Partners without Platform repo access: [`../create-callcorp-sandbox/PARTNER.md`](../create-callcorp-sandbox/PARTNER.md).
71
+
72
+ ## Packages
73
+
74
+ | Package | Path | Role |
75
+ |---------|------|------|
76
+ | `@callcorpacd/platform-bridge` | [`PlatformBridge/`](./) | Guest/host postMessage bridge |
77
+ | `create-callcorp-sandbox` | [`../create-callcorp-sandbox/`](../create-callcorp-sandbox/) | `npm create` scaffolder |
78
+ | `@callcorpacd/sandbox-cli` | [`../callcorp-sandbox-cli/`](../callcorp-sandbox-cli/) | `login` / `publish` / `embed-snippet` / `whoami` |
79
+ | Templates | [`../create-callcorp-sandbox/templates/`](../create-callcorp-sandbox/templates/) | Guest scaffolds: `vue-vite`, `html-js`, `vue-quasar` |
80
+
81
+ In the monorepo, the scaffolder wires `file:` dependencies to sibling folders when present. Outside the repo (after npm publish), scaffolded apps depend on `@callcorpacd/platform-bridge` and `@callcorpacd/sandbox-cli` from the public npm registry.
82
+
83
+ ## Live localhost requirements
84
+
85
+ - Vite serves **HTTPS** via `vite-plugin-mkcert` so the HTTPS portal can iframe it with a locally trusted cert (one-time OS trust prompt on first `npm run dev`).
86
+ - `Content-Security-Policy: frame-ancestors *` so the portal may embed the dev server.
87
+ - `SandboxedApp` allowlists the iframe origin for `postMessage` automatically from `Url`.
88
+ - If the iframe still cannot reach localhost HTTPS (locked-down trust store, etc.), `SandboxedApp` shows **Open app in new tab** / **Retry** guidance.
89
+
90
+ Do not leave `Url: https://localhost:…` in production UserControls.
91
+
92
+ ## Passing host params into the guest
93
+
94
+ Add a `Params` map on the embed `ControlData`. Values are interpolated strings evaluated by the portal (`{{ParamByName("Id")}}`, `{# Root.UserName #}`, literals). After handshake the guest reads them from `platform.context.params`; subscribe for live updates:
95
+
96
+ ```js
97
+ const platform = await createPlatformBridge();
98
+ console.log(platform.context.params);
99
+ platform.context.subscribe((params) => console.log('updated', params));
100
+ ```
101
+
102
+ See [`README.md`](./README.md#passing-params-from-a-usercontrol-document) for full embed JSON examples.
package/README.md ADDED
@@ -0,0 +1,171 @@
1
+ # @callcorpacd/platform-bridge
2
+
3
+ Guest/host **postMessage** bridge so partner content in a sandboxed iframe can call portal APIs **without** receiving access tokens.
4
+
5
+ Used by the Vue3 `SandboxedApp` control. Designed to grow into a published npm package (partners + internal apps).
6
+
7
+ **Authoring a guest app (create / localhost / publish):** see [`AUTHORING.md`](AUTHORING.md).
8
+
9
+ ## Install (local / monorepo)
10
+
11
+ Vue3 already depends on this package via `file:../PlatformBridge` and Vite aliases:
12
+
13
+ - `@callcorpacd/platform-bridge`
14
+ - `@callcorpacd/platform-bridge/client`
15
+ - `@callcorpacd/platform-bridge/host`
16
+ - `@callcorpacd/platform-bridge/protocol`
17
+
18
+ ## Guest (inside the iframe)
19
+
20
+ ```js
21
+ import { createPlatformBridge } from '@callcorpacd/platform-bridge/client';
22
+
23
+ const platform = await createPlatformBridge();
24
+
25
+ // Document Params (interpolated on the host) arrive on handshake:
26
+ const { RecordId, Mode } = platform.context.params;
27
+ platform.context.subscribe((params) => {
28
+ // Live updates when host Params re-evaluate
29
+ });
30
+
31
+ const data = await platform.api.get('Apps/User/FullName');
32
+ await platform.api.post('Some/Path', { hello: 'world' });
33
+ ```
34
+
35
+ **v1 surface:**
36
+
37
+ - `platform.api.get | cachedGet | post | put | patch | delete`
38
+ - `platform.context.params` — `Record<string, string>` from host `ControlData.Params`
39
+ - `platform.context.subscribe(listener)` — notified on `bridge.context.update`
40
+
41
+ **Reserved (not implemented on the host yet):** `platform.navigate`, `platform.ui`, `platform.events`.
42
+
43
+ ### Security
44
+
45
+ - Do **not** call the band API with `fetch` and a token from guest code; use the bridge.
46
+ - The host allowlists paths via `ControlData.AllowedApiPrefixes` on `SandboxedApp`.
47
+ - Absolute `http(s)` URLs are rejected by default.
48
+ - Do not put secrets or tokens in `Params`.
49
+
50
+ ## Host (portal)
51
+
52
+ ```js
53
+ import { createHostBridgeHandler } from '@callcorpacd/platform-bridge/host';
54
+ import api from '@/Services/api';
55
+
56
+ const handler = createHostBridgeHandler({
57
+ getAllowedOrigin: () => contentOrigin,
58
+ getAllowedApiPrefixes: () => ['Apps/User/', 'Document/'],
59
+ getContextParams: () => ({ RecordId: '123', Mode: 'edit' }),
60
+ api,
61
+ });
62
+ window.addEventListener('message', handler.handleMessage);
63
+ // Later, when params change:
64
+ handler.pushContextUpdate();
65
+ ```
66
+
67
+ `SandboxedApp.jsx` wires this for you from `ControlData.Params`.
68
+
69
+ ## Passing params from a UserControl document
70
+
71
+ `ControlData.Params` is a flat map of **string** values. Each value may use portal interpolations (`{{...}}` or `{#...#}`). The host evaluates them and delivers the result as `platform.context.params`.
72
+
73
+ ```json
74
+ {
75
+ "ControlType": "SandboxedApp",
76
+ "ControlData": {
77
+ "Name": "PartnerApp",
78
+ "SourceKind": "PlatformFile",
79
+ "ContentPath": "PartnerContent/hello/index.html",
80
+ "AllowedApiPrefixes": ["Apps/User/"],
81
+ "Params": {
82
+ "RecordId": "{{ParamByName(\"Id\")}}",
83
+ "Mode": "edit",
84
+ "Title": "Hello {# Root.UserName #}"
85
+ }
86
+ }
87
+ }
88
+ ```
89
+
90
+ Keys are static identifiers. Values are coerced to strings (`null`/`undefined` → `''`). Nested objects/arrays are out of scope—use `platform.api.*` for rich data.
91
+
92
+ ## Protocol v1
93
+
94
+ | Type | Direction | Purpose |
95
+ |------|-----------|---------|
96
+ | `bridge.ready` | guest → host | Handshake |
97
+ | `bridge.ready.ack` | host → guest | Handshake complete; payload includes `{ protocolVersion, params }` |
98
+ | `bridge.context.update` | host → guest | `{ params }` when host Params change |
99
+ | `bridge.api.request` | guest → host | `{ method, url, data? }` |
100
+ | `bridge.api.response` | host → guest | `{ result }` |
101
+ | `bridge.api.error` | host → guest | `{ message, code, status? }` |
102
+
103
+ Channel id: `callcorp.platform-bridge`. Version field must match `PROTOCOL_VERSION` (currently `1`). Adding `params` on ReadyAck is additive; older guests ignore unknown payload fields.
104
+
105
+ ## Deploying partner content
106
+
107
+ Preferred path for new apps: scaffold with `create-callcorp-sandbox`, `npm run build`, then `callcorp-sandbox publish` (see [`AUTHORING.md`](AUTHORING.md)). Vite `dist/` uses `base: './'`; `SandboxedApp` PlatformFile mode rewrites relative JS/CSS asset URLs to authenticated blob URLs.
108
+
109
+ Content path convention: `PartnerContent/{AppId}/[version/]index.html`
110
+
111
+ ### Platform FileAPI (recommended for testing)
112
+
113
+ Upload via CLI publish or the file browser. Minimal hand-built sample: [`samples/hello/`](samples/hello/).
114
+
115
+ ```json
116
+ {
117
+ "ControlType": "SandboxedApp",
118
+ "ControlData": {
119
+ "Name": "PartnerApp",
120
+ "SourceKind": "PlatformFile",
121
+ "ContentPath": "PartnerContent/hello/index.html",
122
+ "AllowedApiPrefixes": ["Apps/User/"],
123
+ "Params": {
124
+ "Greeting": "Hello from the host"
125
+ }
126
+ }
127
+ }
128
+ ```
129
+
130
+ ### Live localhost (dev)
131
+
132
+ ```json
133
+ {
134
+ "ControlType": "SandboxedApp",
135
+ "ControlData": {
136
+ "Name": "PartnerAppDev",
137
+ "SourceKind": "Url",
138
+ "Url": "https://localhost:5174/",
139
+ "AllowedApiPrefixes": ["Apps/User/"],
140
+ "Params": {
141
+ "Mode": "dev"
142
+ }
143
+ }
144
+ }
145
+ ```
146
+
147
+ ### Mass Storage / S3
148
+
149
+ ```json
150
+ {
151
+ "ControlType": "SandboxedApp",
152
+ "ControlData": {
153
+ "Name": "PartnerApp",
154
+ "SourceKind": "MassStorage",
155
+ "OwnerId": "{# Root.CustomerID #}",
156
+ "ContentPath": "PartnerContent/my-app/index.html",
157
+ "AllowedApiPrefixes": ["Apps/User/", "Document/"],
158
+ "Params": {
159
+ "RecordId": "{{ParamByName(\"Id\")}}"
160
+ }
161
+ }
162
+ }
163
+ ```
164
+
165
+ ## Roadmap
166
+
167
+ - Expand host capabilities (`navigate`, `ui`, `events`) with a protocol version bump.
168
+ - Optional VuePortal `/partner-content/...` route for streaming FileBin without blob rewriting.
169
+ - Designer schema: add `ControlData.Params` (`object`, `additionalProperties: { type: string }`) on `DynamicControl_SandboxedApp` (runtime already accepts it via `additionalProperties: true`).
170
+
171
+ Published for partners as public npm packages (`@callcorpacd/platform-bridge`, `@callcorpacd/sandbox-cli`, `create-callcorp-sandbox`). See [`../create-callcorp-sandbox/PARTNER.md`](../create-callcorp-sandbox/PARTNER.md).
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@callcorpacd/platform-bridge",
3
+ "version": "1.0.0",
4
+ "description": "Guest/host postMessage bridge for sandboxed partner content in the CallCorp portal.",
5
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "module": "src/index.js",
8
+ "exports": {
9
+ ".": "./src/index.js",
10
+ "./client": "./src/client.js",
11
+ "./host": "./src/host.js",
12
+ "./protocol": "./src/protocol.js"
13
+ },
14
+ "files": [
15
+ "src",
16
+ "samples",
17
+ "README.md",
18
+ "AUTHORING.md"
19
+ ],
20
+ "keywords": [
21
+ "callcorp",
22
+ "platform-bridge",
23
+ "iframe",
24
+ "sandbox"
25
+ ],
26
+ "license": "UNLICENSED",
27
+ "publishConfig": {
28
+ "access": "public"
29
+ }
30
+ }
@@ -0,0 +1,206 @@
1
+ import { ApiMethod, MessageType, PROTOCOL_VERSION, createMessage, isBridgeMessage } from './protocol.js';
2
+
3
+ function generateRequestId() {
4
+ if (typeof crypto !== 'undefined' && crypto.randomUUID)
5
+ return crypto.randomUUID();
6
+ return `pb-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
7
+ }
8
+
9
+ /**
10
+ * @param {unknown} raw
11
+ * @returns {Record<string, string>}
12
+ */
13
+ function normalizeParams(raw) {
14
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
15
+ return {};
16
+ const out = {};
17
+ for (const key of Object.keys(raw)) {
18
+ const value = raw[key];
19
+ out[key] = value === undefined || value === null ? '' : String(value);
20
+ }
21
+ return out;
22
+ }
23
+
24
+ /**
25
+ * Create a guest-side platform bridge that talks to the portal host via postMessage.
26
+ * Auth tokens never enter the guest; the host attaches them when proxying API calls.
27
+ *
28
+ * @param {object} [options]
29
+ * @param {Window} [options.parentWindow] Parent window (default: window.parent)
30
+ * @param {string} [options.targetOrigin='*'] Origin used when posting to the parent; prefer a concrete origin when known
31
+ * @param {number} [options.handshakeTimeoutMs=15000]
32
+ * @returns {Promise<{
33
+ * api: {
34
+ * get: (url: string) => Promise<unknown>,
35
+ * cachedGet: (url: string) => Promise<unknown>,
36
+ * post: (url: string, data?: unknown) => Promise<unknown>,
37
+ * put: (url: string, data?: unknown) => Promise<unknown>,
38
+ * patch: (url: string, data?: unknown) => Promise<unknown>,
39
+ * delete: (url: string) => Promise<unknown>,
40
+ * },
41
+ * context: {
42
+ * readonly params: Record<string, string>,
43
+ * subscribe: (listener: (params: Record<string, string>) => void) => () => void,
44
+ * },
45
+ * navigate: (...args: unknown[]) => Promise<never>,
46
+ * ui: Record<string, never>,
47
+ * events: Record<string, never>,
48
+ * dispose: () => void,
49
+ * protocolVersion: number,
50
+ * }>}
51
+ */
52
+ export function createPlatformBridge(options = {}) {
53
+ const parentWindow = options.parentWindow || (typeof window !== 'undefined' ? window.parent : null);
54
+ const targetOrigin = options.targetOrigin || '*';
55
+ const handshakeTimeoutMs = options.handshakeTimeoutMs ?? 15000;
56
+
57
+ if (!parentWindow || parentWindow === (typeof window !== 'undefined' ? window : null)) {
58
+ return Promise.reject(new Error('@callcorpacd/platform-bridge: createPlatformBridge must run inside an iframe hosted by the portal.'));
59
+ }
60
+
61
+ const pending = new Map();
62
+ const contextListeners = new Set();
63
+ let disposed = false;
64
+ let ready = false;
65
+ /** @type {Record<string, string>} */
66
+ let currentParams = {};
67
+
68
+ function applyParams(raw) {
69
+ currentParams = Object.freeze(normalizeParams(raw));
70
+ for (const listener of contextListeners) {
71
+ try {
72
+ listener(currentParams);
73
+ }
74
+ catch {
75
+ // Guest listener errors must not break the bridge.
76
+ }
77
+ }
78
+ }
79
+
80
+ function post(type, requestId, payload) {
81
+ parentWindow.postMessage(createMessage({ type, requestId, payload }), targetOrigin);
82
+ }
83
+
84
+ function onMessage(event) {
85
+ if (disposed || !isBridgeMessage(event.data))
86
+ return;
87
+ if (event.data.version > PROTOCOL_VERSION)
88
+ return;
89
+
90
+ const { type, requestId, payload } = event.data;
91
+
92
+ if (type === MessageType.ReadyAck) {
93
+ ready = true;
94
+ applyParams(payload?.params);
95
+ const handshake = pending.get('__handshake__');
96
+ if (handshake) {
97
+ pending.delete('__handshake__');
98
+ handshake.resolve();
99
+ }
100
+ return;
101
+ }
102
+
103
+ if (type === MessageType.ContextUpdate) {
104
+ applyParams(payload?.params);
105
+ return;
106
+ }
107
+
108
+ if (!requestId || !pending.has(requestId))
109
+ return;
110
+
111
+ const entry = pending.get(requestId);
112
+ pending.delete(requestId);
113
+
114
+ if (type === MessageType.ApiResponse)
115
+ entry.resolve(payload?.result);
116
+ else if (type === MessageType.ApiError)
117
+ entry.reject(Object.assign(new Error(payload?.message || 'Bridge API error'), { bridgeError: payload }));
118
+ }
119
+
120
+ if (typeof window !== 'undefined')
121
+ window.addEventListener('message', onMessage);
122
+
123
+ function dispose() {
124
+ if (disposed)
125
+ return;
126
+ disposed = true;
127
+ if (typeof window !== 'undefined')
128
+ window.removeEventListener('message', onMessage);
129
+ contextListeners.clear();
130
+ for (const [, entry] of pending) {
131
+ entry.reject(new Error('@callcorpacd/platform-bridge: disposed'));
132
+ }
133
+ pending.clear();
134
+ }
135
+
136
+ function apiRequest(method, url, data) {
137
+ if (disposed)
138
+ return Promise.reject(new Error('@callcorpacd/platform-bridge: disposed'));
139
+ if (!ready)
140
+ return Promise.reject(new Error('@callcorpacd/platform-bridge: bridge is not ready'));
141
+
142
+ const requestId = generateRequestId();
143
+ return new Promise((resolve, reject) => {
144
+ pending.set(requestId, { resolve, reject });
145
+ post(MessageType.ApiRequest, requestId, { method, url, data });
146
+ });
147
+ }
148
+
149
+ const notImplemented = (name) => async () => {
150
+ throw new Error(`@callcorpacd/platform-bridge: ${name} is reserved for a future protocol version`);
151
+ };
152
+
153
+ const platform = {
154
+ protocolVersion: PROTOCOL_VERSION,
155
+ api: {
156
+ get: (url) => apiRequest(ApiMethod.GET, url),
157
+ cachedGet: (url) => apiRequest(ApiMethod.CACHED_GET, url),
158
+ post: (url, data) => apiRequest(ApiMethod.POST, url, data),
159
+ put: (url, data) => apiRequest(ApiMethod.PUT, url, data),
160
+ patch: (url, data) => apiRequest(ApiMethod.PATCH, url, data),
161
+ delete: (url) => apiRequest(ApiMethod.DELETE, url),
162
+ },
163
+ context: {
164
+ get params() {
165
+ return currentParams;
166
+ },
167
+ /**
168
+ * @param {(params: Record<string, string>) => void} listener
169
+ * @returns {() => void} unsubscribe
170
+ */
171
+ subscribe(listener) {
172
+ if (typeof listener !== 'function')
173
+ throw new TypeError('@callcorpacd/platform-bridge: context.subscribe requires a function');
174
+ contextListeners.add(listener);
175
+ return () => contextListeners.delete(listener);
176
+ },
177
+ },
178
+ navigate: notImplemented('platform.navigate'),
179
+ ui: Object.freeze({}),
180
+ events: Object.freeze({}),
181
+ dispose,
182
+ };
183
+
184
+ return new Promise((resolve, reject) => {
185
+ const timer = setTimeout(() => {
186
+ pending.delete('__handshake__');
187
+ dispose();
188
+ reject(new Error('@callcorpacd/platform-bridge: handshake timed out waiting for host ReadyAck'));
189
+ }, handshakeTimeoutMs);
190
+
191
+ pending.set('__handshake__', {
192
+ resolve: () => {
193
+ clearTimeout(timer);
194
+ resolve(platform);
195
+ },
196
+ reject: (err) => {
197
+ clearTimeout(timer);
198
+ reject(err);
199
+ },
200
+ });
201
+
202
+ post(MessageType.Ready, undefined, { protocolVersion: PROTOCOL_VERSION });
203
+ });
204
+ }
205
+
206
+ export default createPlatformBridge;
@@ -0,0 +1,80 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <title>Platform Bridge Hello</title>
6
+ <style>
7
+ :root {
8
+ font-family: Segoe UI, system-ui, sans-serif;
9
+ color: #1a1a1a;
10
+ background: #f7f7f5;
11
+ }
12
+ body { margin: 1.5rem; }
13
+ h1 { font-size: 1.25rem; margin: 0 0 0.5rem; }
14
+ p { margin: 0 0 1rem; color: #444; }
15
+ button {
16
+ padding: 0.5rem 0.85rem;
17
+ border: 1px solid #888;
18
+ border-radius: 4px;
19
+ background: #fff;
20
+ cursor: pointer;
21
+ }
22
+ pre {
23
+ white-space: pre-wrap;
24
+ background: #fff;
25
+ border: 1px solid #ddd;
26
+ padding: 0.75rem;
27
+ min-height: 4rem;
28
+ }
29
+ .label { font-weight: 600; margin-top: 1rem; }
30
+ </style>
31
+ </head>
32
+ <body>
33
+ <h1>Sandboxed partner sample</h1>
34
+ <p>
35
+ Upload this folder (<code>index.html</code>, <code>client.js</code>, <code>protocol.js</code>)
36
+ to Platform Files, then embed with <code>SandboxedApp</code> (<code>SourceKind: PlatformFile</code>).
37
+ Optional host <code>ControlData.Params</code> appear below after handshake.
38
+ </p>
39
+ <button id="run" type="button">Call Apps/User/FullName</button>
40
+ <div class="label">Host Params (<code>platform.context.params</code>)</div>
41
+ <pre id="params">Waiting for bridge…</pre>
42
+ <div class="label">API result</div>
43
+ <pre id="out">—</pre>
44
+
45
+ <script type="module">
46
+ // Deploy client.js + protocol.js beside this file (same Platform Files folder).
47
+ import { createPlatformBridge } from './client.js';
48
+
49
+ const out = document.getElementById('out');
50
+ const paramsEl = document.getElementById('params');
51
+ const run = document.getElementById('run');
52
+
53
+ function showParams(params) {
54
+ paramsEl.textContent = JSON.stringify(params, null, 2);
55
+ }
56
+
57
+ try {
58
+ const platform = await createPlatformBridge();
59
+ out.textContent = 'Bridge ready (protocol v' + platform.protocolVersion + ').';
60
+ showParams(platform.context.params);
61
+ platform.context.subscribe(showParams);
62
+
63
+ run.addEventListener('click', async () => {
64
+ out.textContent = 'Calling…';
65
+ try {
66
+ // Host ControlData.AllowedApiPrefixes must include "Apps/User/" (or "Apps/User/FullName").
67
+ const result = await platform.api.get('Apps/User/FullName');
68
+ out.textContent = JSON.stringify(result, null, 2);
69
+ } catch (err) {
70
+ out.textContent = 'Error: ' + (err.message || String(err));
71
+ }
72
+ });
73
+ } catch (err) {
74
+ out.textContent = 'Handshake failed: ' + (err.message || String(err));
75
+ paramsEl.textContent = '—';
76
+ run.disabled = true;
77
+ }
78
+ </script>
79
+ </body>
80
+ </html>
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Versioned postMessage protocol for @callcorpacd/platform-bridge.
3
+ * Bump PROTOCOL_VERSION when message shapes or capabilities change.
4
+ */
5
+
6
+ export const PROTOCOL_VERSION = 1;
7
+ export const CHANNEL = 'callcorp.platform-bridge';
8
+
9
+ export const MessageType = Object.freeze({
10
+ Ready: 'bridge.ready',
11
+ ReadyAck: 'bridge.ready.ack',
12
+ ApiRequest: 'bridge.api.request',
13
+ ApiResponse: 'bridge.api.response',
14
+ ApiError: 'bridge.api.error',
15
+ /** Host → guest: refreshed ControlData.Params string map */
16
+ ContextUpdate: 'bridge.context.update',
17
+ });
18
+
19
+ export const ApiMethod = Object.freeze({
20
+ GET: 'GET',
21
+ POST: 'POST',
22
+ PUT: 'PUT',
23
+ PATCH: 'PATCH',
24
+ DELETE: 'DELETE',
25
+ CACHED_GET: 'CACHED_GET',
26
+ });
27
+
28
+ /**
29
+ * @param {unknown} data
30
+ * @returns {boolean}
31
+ */
32
+ export function isBridgeMessage(data) {
33
+ return !!(
34
+ data &&
35
+ typeof data === 'object' &&
36
+ data.channel === CHANNEL &&
37
+ typeof data.version === 'number' &&
38
+ typeof data.type === 'string'
39
+ );
40
+ }
41
+
42
+ /**
43
+ * @param {object} options
44
+ * @param {string} options.type
45
+ * @param {string} [options.requestId]
46
+ * @param {unknown} [options.payload]
47
+ * @returns {{ channel: string, version: number, type: string, requestId?: string, payload?: unknown }}
48
+ */
49
+ export function createMessage({ type, requestId, payload }) {
50
+ const message = {
51
+ channel: CHANNEL,
52
+ version: PROTOCOL_VERSION,
53
+ type,
54
+ };
55
+ if (requestId !== undefined)
56
+ message.requestId = requestId;
57
+ if (payload !== undefined)
58
+ message.payload = payload;
59
+ return message;
60
+ }
package/src/client.js ADDED
@@ -0,0 +1,206 @@
1
+ import { ApiMethod, MessageType, PROTOCOL_VERSION, createMessage, isBridgeMessage } from './protocol.js';
2
+
3
+ function generateRequestId() {
4
+ if (typeof crypto !== 'undefined' && crypto.randomUUID)
5
+ return crypto.randomUUID();
6
+ return `pb-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
7
+ }
8
+
9
+ /**
10
+ * @param {unknown} raw
11
+ * @returns {Record<string, string>}
12
+ */
13
+ function normalizeParams(raw) {
14
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
15
+ return {};
16
+ const out = {};
17
+ for (const key of Object.keys(raw)) {
18
+ const value = raw[key];
19
+ out[key] = value === undefined || value === null ? '' : String(value);
20
+ }
21
+ return out;
22
+ }
23
+
24
+ /**
25
+ * Create a guest-side platform bridge that talks to the portal host via postMessage.
26
+ * Auth tokens never enter the guest; the host attaches them when proxying API calls.
27
+ *
28
+ * @param {object} [options]
29
+ * @param {Window} [options.parentWindow] Parent window (default: window.parent)
30
+ * @param {string} [options.targetOrigin='*'] Origin used when posting to the parent; prefer a concrete origin when known
31
+ * @param {number} [options.handshakeTimeoutMs=15000]
32
+ * @returns {Promise<{
33
+ * api: {
34
+ * get: (url: string) => Promise<unknown>,
35
+ * cachedGet: (url: string) => Promise<unknown>,
36
+ * post: (url: string, data?: unknown) => Promise<unknown>,
37
+ * put: (url: string, data?: unknown) => Promise<unknown>,
38
+ * patch: (url: string, data?: unknown) => Promise<unknown>,
39
+ * delete: (url: string) => Promise<unknown>,
40
+ * },
41
+ * context: {
42
+ * readonly params: Record<string, string>,
43
+ * subscribe: (listener: (params: Record<string, string>) => void) => () => void,
44
+ * },
45
+ * navigate: (...args: unknown[]) => Promise<never>,
46
+ * ui: Record<string, never>,
47
+ * events: Record<string, never>,
48
+ * dispose: () => void,
49
+ * protocolVersion: number,
50
+ * }>}
51
+ */
52
+ export function createPlatformBridge(options = {}) {
53
+ const parentWindow = options.parentWindow || (typeof window !== 'undefined' ? window.parent : null);
54
+ const targetOrigin = options.targetOrigin || '*';
55
+ const handshakeTimeoutMs = options.handshakeTimeoutMs ?? 15000;
56
+
57
+ if (!parentWindow || parentWindow === (typeof window !== 'undefined' ? window : null)) {
58
+ return Promise.reject(new Error('@callcorpacd/platform-bridge: createPlatformBridge must run inside an iframe hosted by the portal.'));
59
+ }
60
+
61
+ const pending = new Map();
62
+ const contextListeners = new Set();
63
+ let disposed = false;
64
+ let ready = false;
65
+ /** @type {Record<string, string>} */
66
+ let currentParams = {};
67
+
68
+ function applyParams(raw) {
69
+ currentParams = Object.freeze(normalizeParams(raw));
70
+ for (const listener of contextListeners) {
71
+ try {
72
+ listener(currentParams);
73
+ }
74
+ catch {
75
+ // Guest listener errors must not break the bridge.
76
+ }
77
+ }
78
+ }
79
+
80
+ function post(type, requestId, payload) {
81
+ parentWindow.postMessage(createMessage({ type, requestId, payload }), targetOrigin);
82
+ }
83
+
84
+ function onMessage(event) {
85
+ if (disposed || !isBridgeMessage(event.data))
86
+ return;
87
+ if (event.data.version > PROTOCOL_VERSION)
88
+ return;
89
+
90
+ const { type, requestId, payload } = event.data;
91
+
92
+ if (type === MessageType.ReadyAck) {
93
+ ready = true;
94
+ applyParams(payload?.params);
95
+ const handshake = pending.get('__handshake__');
96
+ if (handshake) {
97
+ pending.delete('__handshake__');
98
+ handshake.resolve();
99
+ }
100
+ return;
101
+ }
102
+
103
+ if (type === MessageType.ContextUpdate) {
104
+ applyParams(payload?.params);
105
+ return;
106
+ }
107
+
108
+ if (!requestId || !pending.has(requestId))
109
+ return;
110
+
111
+ const entry = pending.get(requestId);
112
+ pending.delete(requestId);
113
+
114
+ if (type === MessageType.ApiResponse)
115
+ entry.resolve(payload?.result);
116
+ else if (type === MessageType.ApiError)
117
+ entry.reject(Object.assign(new Error(payload?.message || 'Bridge API error'), { bridgeError: payload }));
118
+ }
119
+
120
+ if (typeof window !== 'undefined')
121
+ window.addEventListener('message', onMessage);
122
+
123
+ function dispose() {
124
+ if (disposed)
125
+ return;
126
+ disposed = true;
127
+ if (typeof window !== 'undefined')
128
+ window.removeEventListener('message', onMessage);
129
+ contextListeners.clear();
130
+ for (const [, entry] of pending) {
131
+ entry.reject(new Error('@callcorpacd/platform-bridge: disposed'));
132
+ }
133
+ pending.clear();
134
+ }
135
+
136
+ function apiRequest(method, url, data) {
137
+ if (disposed)
138
+ return Promise.reject(new Error('@callcorpacd/platform-bridge: disposed'));
139
+ if (!ready)
140
+ return Promise.reject(new Error('@callcorpacd/platform-bridge: bridge is not ready'));
141
+
142
+ const requestId = generateRequestId();
143
+ return new Promise((resolve, reject) => {
144
+ pending.set(requestId, { resolve, reject });
145
+ post(MessageType.ApiRequest, requestId, { method, url, data });
146
+ });
147
+ }
148
+
149
+ const notImplemented = (name) => async () => {
150
+ throw new Error(`@callcorpacd/platform-bridge: ${name} is reserved for a future protocol version`);
151
+ };
152
+
153
+ const platform = {
154
+ protocolVersion: PROTOCOL_VERSION,
155
+ api: {
156
+ get: (url) => apiRequest(ApiMethod.GET, url),
157
+ cachedGet: (url) => apiRequest(ApiMethod.CACHED_GET, url),
158
+ post: (url, data) => apiRequest(ApiMethod.POST, url, data),
159
+ put: (url, data) => apiRequest(ApiMethod.PUT, url, data),
160
+ patch: (url, data) => apiRequest(ApiMethod.PATCH, url, data),
161
+ delete: (url) => apiRequest(ApiMethod.DELETE, url),
162
+ },
163
+ context: {
164
+ get params() {
165
+ return currentParams;
166
+ },
167
+ /**
168
+ * @param {(params: Record<string, string>) => void} listener
169
+ * @returns {() => void} unsubscribe
170
+ */
171
+ subscribe(listener) {
172
+ if (typeof listener !== 'function')
173
+ throw new TypeError('@callcorpacd/platform-bridge: context.subscribe requires a function');
174
+ contextListeners.add(listener);
175
+ return () => contextListeners.delete(listener);
176
+ },
177
+ },
178
+ navigate: notImplemented('platform.navigate'),
179
+ ui: Object.freeze({}),
180
+ events: Object.freeze({}),
181
+ dispose,
182
+ };
183
+
184
+ return new Promise((resolve, reject) => {
185
+ const timer = setTimeout(() => {
186
+ pending.delete('__handshake__');
187
+ dispose();
188
+ reject(new Error('@callcorpacd/platform-bridge: handshake timed out waiting for host ReadyAck'));
189
+ }, handshakeTimeoutMs);
190
+
191
+ pending.set('__handshake__', {
192
+ resolve: () => {
193
+ clearTimeout(timer);
194
+ resolve(platform);
195
+ },
196
+ reject: (err) => {
197
+ clearTimeout(timer);
198
+ reject(err);
199
+ },
200
+ });
201
+
202
+ post(MessageType.Ready, undefined, { protocolVersion: PROTOCOL_VERSION });
203
+ });
204
+ }
205
+
206
+ export default createPlatformBridge;
package/src/host.js ADDED
@@ -0,0 +1,241 @@
1
+ import { ApiMethod, MessageType, PROTOCOL_VERSION, createMessage, isBridgeMessage } from './protocol.js';
2
+
3
+ const SUPPORTED_METHODS = new Set(Object.values(ApiMethod));
4
+
5
+ /**
6
+ * Normalize and validate a relative API path against an allowlist of prefixes.
7
+ * Absolute http(s) URLs are rejected unless allowAbsoluteUrls is true.
8
+ *
9
+ * @param {string} url
10
+ * @param {string[]} allowedApiPrefixes
11
+ * @param {{ allowAbsoluteUrls?: boolean }} [options]
12
+ * @returns {{ ok: true, url: string } | { ok: false, reason: string }}
13
+ */
14
+ export function validateApiUrl(url, allowedApiPrefixes, options = {}) {
15
+ if (!url || typeof url !== 'string')
16
+ return { ok: false, reason: 'URL is required' };
17
+
18
+ const trimmed = url.trim();
19
+ const isAbsolute = /^https?:\/\//i.test(trimmed) || trimmed.startsWith('//');
20
+
21
+ if (isAbsolute && !options.allowAbsoluteUrls)
22
+ return { ok: false, reason: 'Absolute URLs are not allowed' };
23
+
24
+ if (isAbsolute)
25
+ return { ok: true, url: trimmed };
26
+
27
+ const path = trimmed.startsWith('/') ? trimmed.slice(1) : trimmed;
28
+ const prefixes = Array.isArray(allowedApiPrefixes) ? allowedApiPrefixes : [];
29
+
30
+ if (prefixes.length === 0)
31
+ return { ok: false, reason: 'No AllowedApiPrefixes configured' };
32
+
33
+ const allowed = prefixes.some((prefix) => {
34
+ if (prefix === undefined || prefix === null)
35
+ return false;
36
+ const normalized = String(prefix).replace(/^\//, '');
37
+ if (!normalized)
38
+ return true;
39
+ if (path === normalized)
40
+ return true;
41
+ if (normalized.endsWith('/'))
42
+ return path.startsWith(normalized);
43
+ return path.startsWith(`${normalized}/`);
44
+ });
45
+
46
+ if (!allowed)
47
+ return { ok: false, reason: `URL is not in AllowedApiPrefixes: ${path}` };
48
+
49
+ return { ok: true, url: path };
50
+ }
51
+
52
+ /**
53
+ * Resolve whether a message event origin is permitted for this sandboxed app.
54
+ *
55
+ * @param {string} eventOrigin
56
+ * @param {string | string[] | (() => string | string[] | null | undefined)} allowedOrigin
57
+ * @returns {boolean}
58
+ */
59
+ export function isAllowedOrigin(eventOrigin, allowedOrigin) {
60
+ if (!eventOrigin)
61
+ return false;
62
+
63
+ const resolved = typeof allowedOrigin === 'function' ? allowedOrigin() : allowedOrigin;
64
+ if (!resolved)
65
+ return false;
66
+
67
+ const list = Array.isArray(resolved) ? resolved : [resolved];
68
+ return list.some((origin) => origin && (origin === '*' || origin === eventOrigin));
69
+ }
70
+
71
+ /**
72
+ * @returns {Record<string, string>}
73
+ */
74
+ function normalizeParams(raw) {
75
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
76
+ return {};
77
+ const out = {};
78
+ for (const key of Object.keys(raw)) {
79
+ const value = raw[key];
80
+ out[key] = value === undefined || value === null ? '' : String(value);
81
+ }
82
+ return out;
83
+ }
84
+
85
+ /**
86
+ * Create a host-side message handler that proxies allowlisted API calls through the portal api service.
87
+ *
88
+ * @param {object} options
89
+ * @param {() => string | string[] | null | undefined} options.getAllowedOrigin
90
+ * @param {() => string[]} options.getAllowedApiPrefixes
91
+ * @param {object} options.api Portal api.jsx service (get/post/put/patch/delete_/cachedGet)
92
+ * @param {() => Record<string, string> | null | undefined} [options.getContextParams]
93
+ * @param {(source: MessageEventSource, origin: string) => void} [options.onReady]
94
+ * @param {(error: unknown, context: object) => void} [options.onError]
95
+ * @param {boolean} [options.allowAbsoluteUrls=false]
96
+ * @returns {{
97
+ * handleMessage: (event: MessageEvent) => void,
98
+ * pushContextUpdate: (params?: Record<string, string>) => void,
99
+ * dispose: () => void,
100
+ * }}
101
+ */
102
+ export function createHostBridgeHandler(options) {
103
+ const {
104
+ getAllowedOrigin,
105
+ getAllowedApiPrefixes,
106
+ api,
107
+ getContextParams,
108
+ onReady,
109
+ onError,
110
+ allowAbsoluteUrls = false,
111
+ } = options;
112
+
113
+ let disposed = false;
114
+ /** @type {MessageEventSource | null} */
115
+ let guestSource = null;
116
+ /** @type {string | null} */
117
+ let guestOrigin = null;
118
+
119
+ function reply(source, origin, type, requestId, payload) {
120
+ if (!source || typeof source.postMessage !== 'function')
121
+ return;
122
+ const target = origin && origin !== 'null' ? origin : '*';
123
+ source.postMessage(createMessage({ type, requestId, payload }), target);
124
+ }
125
+
126
+ function resolveParams(override) {
127
+ if (override !== undefined)
128
+ return normalizeParams(override);
129
+ if (typeof getContextParams === 'function')
130
+ return normalizeParams(getContextParams());
131
+ return {};
132
+ }
133
+
134
+ async function executeApi(method, url, data) {
135
+ switch (method) {
136
+ case ApiMethod.GET:
137
+ return api.get(url);
138
+ case ApiMethod.CACHED_GET:
139
+ return api.cachedGet(url);
140
+ case ApiMethod.POST:
141
+ return api.post(url, data);
142
+ case ApiMethod.PUT:
143
+ return api.put(url, data);
144
+ case ApiMethod.PATCH:
145
+ return api.patch(url, data);
146
+ case ApiMethod.DELETE:
147
+ return api.delete_(url);
148
+ default:
149
+ throw new Error(`Unsupported bridge API method: ${method}`);
150
+ }
151
+ }
152
+
153
+ async function handleMessage(event) {
154
+ if (disposed || !isBridgeMessage(event.data))
155
+ return;
156
+ if (event.data.version !== PROTOCOL_VERSION)
157
+ return;
158
+ if (!isAllowedOrigin(event.origin, getAllowedOrigin))
159
+ return;
160
+
161
+ const { type, requestId, payload } = event.data;
162
+
163
+ if (type === MessageType.Ready) {
164
+ guestSource = event.source;
165
+ guestOrigin = event.origin;
166
+ reply(event.source, event.origin, MessageType.ReadyAck, undefined, {
167
+ protocolVersion: PROTOCOL_VERSION,
168
+ params: resolveParams(),
169
+ });
170
+ if (onReady)
171
+ onReady(event.source, event.origin);
172
+ return;
173
+ }
174
+
175
+ if (type !== MessageType.ApiRequest)
176
+ return;
177
+
178
+ try {
179
+ const method = payload?.method;
180
+ const url = payload?.url;
181
+ const data = payload?.data;
182
+
183
+ if (!SUPPORTED_METHODS.has(method)) {
184
+ reply(event.source, event.origin, MessageType.ApiError, requestId, {
185
+ message: `Unsupported method: ${method}`,
186
+ code: 'unsupported_method',
187
+ });
188
+ return;
189
+ }
190
+
191
+ const validation = validateApiUrl(url, getAllowedApiPrefixes(), { allowAbsoluteUrls });
192
+ if (!validation.ok) {
193
+ reply(event.source, event.origin, MessageType.ApiError, requestId, {
194
+ message: validation.reason,
195
+ code: 'url_not_allowed',
196
+ });
197
+ return;
198
+ }
199
+
200
+ const result = await executeApi(method, validation.url, data);
201
+ reply(event.source, event.origin, MessageType.ApiResponse, requestId, { result });
202
+ }
203
+ catch (error) {
204
+ if (onError)
205
+ onError(error, { requestId, payload });
206
+
207
+ const status = error?.StatusCode ?? error?.statusCode ?? error?.status;
208
+ reply(event.source, event.origin, MessageType.ApiError, requestId, {
209
+ message: error?.message || error?.Message || String(error),
210
+ code: status === 401 ? 'unauthorized' : 'api_error',
211
+ status,
212
+ // Never forward tokens or raw headers; include a shallow server payload when present.
213
+ detail: error?.Result ?? error?.data ?? undefined,
214
+ });
215
+ }
216
+ }
217
+
218
+ /**
219
+ * Push an updated params map to the connected guest (after handshake).
220
+ * @param {Record<string, string>} [params] Defaults to getContextParams()
221
+ */
222
+ function pushContextUpdate(params) {
223
+ if (disposed || !guestSource)
224
+ return;
225
+ reply(guestSource, guestOrigin, MessageType.ContextUpdate, undefined, {
226
+ params: resolveParams(params),
227
+ });
228
+ }
229
+
230
+ return {
231
+ handleMessage,
232
+ pushContextUpdate,
233
+ dispose() {
234
+ disposed = true;
235
+ guestSource = null;
236
+ guestOrigin = null;
237
+ },
238
+ };
239
+ }
240
+
241
+ export default createHostBridgeHandler;
package/src/index.js ADDED
@@ -0,0 +1,15 @@
1
+ export {
2
+ PROTOCOL_VERSION,
3
+ CHANNEL,
4
+ MessageType,
5
+ ApiMethod,
6
+ isBridgeMessage,
7
+ createMessage,
8
+ } from './protocol.js';
9
+
10
+ export { createPlatformBridge } from './client.js';
11
+ export {
12
+ createHostBridgeHandler,
13
+ validateApiUrl,
14
+ isAllowedOrigin,
15
+ } from './host.js';
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Versioned postMessage protocol for @callcorpacd/platform-bridge.
3
+ * Bump PROTOCOL_VERSION when message shapes or capabilities change.
4
+ */
5
+
6
+ export const PROTOCOL_VERSION = 1;
7
+ export const CHANNEL = 'callcorp.platform-bridge';
8
+
9
+ export const MessageType = Object.freeze({
10
+ Ready: 'bridge.ready',
11
+ ReadyAck: 'bridge.ready.ack',
12
+ ApiRequest: 'bridge.api.request',
13
+ ApiResponse: 'bridge.api.response',
14
+ ApiError: 'bridge.api.error',
15
+ /** Host → guest: refreshed ControlData.Params string map */
16
+ ContextUpdate: 'bridge.context.update',
17
+ });
18
+
19
+ export const ApiMethod = Object.freeze({
20
+ GET: 'GET',
21
+ POST: 'POST',
22
+ PUT: 'PUT',
23
+ PATCH: 'PATCH',
24
+ DELETE: 'DELETE',
25
+ CACHED_GET: 'CACHED_GET',
26
+ });
27
+
28
+ /**
29
+ * @param {unknown} data
30
+ * @returns {boolean}
31
+ */
32
+ export function isBridgeMessage(data) {
33
+ return !!(
34
+ data &&
35
+ typeof data === 'object' &&
36
+ data.channel === CHANNEL &&
37
+ typeof data.version === 'number' &&
38
+ typeof data.type === 'string'
39
+ );
40
+ }
41
+
42
+ /**
43
+ * @param {object} options
44
+ * @param {string} options.type
45
+ * @param {string} [options.requestId]
46
+ * @param {unknown} [options.payload]
47
+ * @returns {{ channel: string, version: number, type: string, requestId?: string, payload?: unknown }}
48
+ */
49
+ export function createMessage({ type, requestId, payload }) {
50
+ const message = {
51
+ channel: CHANNEL,
52
+ version: PROTOCOL_VERSION,
53
+ type,
54
+ };
55
+ if (requestId !== undefined)
56
+ message.requestId = requestId;
57
+ if (payload !== undefined)
58
+ message.payload = payload;
59
+ return message;
60
+ }