@fepro/workhub-app-sdk 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Future Edge Technologies
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,99 @@
1
+ # @fepro/workhub-app-sdk
2
+
3
+ Client SDK for apps embedded inside the WorkHub portal.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install @fepro/workhub-app-sdk
9
+ # or
10
+ pnpm add @fepro/workhub-app-sdk
11
+ # or
12
+ yarn add @fepro/workhub-app-sdk
13
+ ```
14
+
15
+ ## The bundle format
16
+
17
+ A WorkHub developer-app is a `.zip` archive with this layout:
18
+
19
+ ```
20
+ my-app.zip
21
+ ├── workhub.json ← manifest (required, at root)
22
+ ├── index.html ← entry HTML (or whatever `entry` in the manifest names)
23
+ └── assets/
24
+ ├── app.js
25
+ └── app.css
26
+ ```
27
+
28
+ ### `workhub.json`
29
+
30
+ ```json
31
+ {
32
+ "key": "my-tools",
33
+ "name": "My Tools",
34
+ "version": "1.0.0",
35
+ "description": "Internal tools dashboard",
36
+ "entry": "index.html",
37
+ "icon": "assets/icon.svg",
38
+ "scopes": [
39
+ "identity:read",
40
+ "storage:read",
41
+ "storage:write",
42
+ "notifications:send",
43
+ "events:listen"
44
+ ]
45
+ }
46
+ ```
47
+
48
+ | field | purpose |
49
+ | --- | --- |
50
+ | `key` | stable identifier; new uploads with the same `key` upgrade in place |
51
+ | `version` | informational; surfaced in the upload list |
52
+ | `entry` | path to the entry HTML (default `index.html`) |
53
+ | `scopes` | capabilities the app needs; operator approves at upload |
54
+
55
+ ## Using the SDK
56
+
57
+ ```ts
58
+ import { workhub } from '@fepro/workhub-app-sdk';
59
+
60
+ // On boot
61
+ const me = await workhub.identity.current();
62
+ console.log(`Hi ${me.user?.displayName}, you're in ${me.tenant?.name}`);
63
+
64
+ // Storage — KV scoped to (tenant, installation)
65
+ await workhub.storage.set('lastView', { page: 7 });
66
+ const last = await workhub.storage.get<{ page: number }>('lastView');
67
+
68
+ // Toast
69
+ await workhub.notifications.toast('Saved!', { variant: 'success' });
70
+
71
+ // Listen for theme changes
72
+ workhub.events.on('theme:change', ({ mode }) => document.body.dataset.theme = mode);
73
+
74
+ // Escape hatch — call any /v1 endpoint
75
+ const dbs = await workhub.api.call('GET /v1/databases');
76
+ ```
77
+
78
+ ## Scopes
79
+
80
+ | scope | grants |
81
+ | --- | --- |
82
+ | `identity:read` | `workhub.identity.current()` |
83
+ | `storage:read` | `workhub.storage.get/list` |
84
+ | `storage:write` | `workhub.storage.set/delete` |
85
+ | `notifications:send` | `workhub.notifications.toast` |
86
+ | `events:listen` | `workhub.events.on` |
87
+ | `api:proxy` | `workhub.api.call(...)` — gated on the user's existing permissions |
88
+
89
+ ## Upload
90
+
91
+ Upload via the portal at **/apps/development → Upload bundle**, or via the API:
92
+
93
+ ```bash
94
+ curl -X POST https://your-host/v1/apps/dev/upload \
95
+ -H "Authorization: Bearer $TOKEN" \
96
+ -F bundle=@my-app.zip
97
+ ```
98
+
99
+ After upload, the app appears in the sidebar under **Apps** and at `/apps/<slug>`.
@@ -0,0 +1,98 @@
1
+ /**
2
+ * @fepro/workhub-app-sdk — client SDK for apps embedded
3
+ * inside the WorkHub portal.
4
+ *
5
+ * Apps run as a sandboxed iframe served from /v1/apps/runtime/<installationId>/.
6
+ * They cannot call the WorkHub API directly — every request crosses the
7
+ * iframe boundary via `window.postMessage` to the host portal, which
8
+ * validates the request against the manifest's declared scopes and forwards
9
+ * it to the API. The SDK is the typed, ergonomic wrapper for that channel.
10
+ *
11
+ * Usage:
12
+ *
13
+ * import { workhub } from '@fepro/workhub-app-sdk';
14
+ *
15
+ * const me = await workhub.identity.current();
16
+ * await workhub.storage.set('lastView', { page: 7 });
17
+ * await workhub.notifications.toast('Saved');
18
+ * workhub.events.on('theme:change', applyTheme);
19
+ *
20
+ * Capability scopes (declared in workhub.json `scopes`) are enforced by
21
+ * the host bridge, not here — the SDK fires the request, the host either
22
+ * returns a result or rejects with `ERR_SCOPE_DENIED`.
23
+ */
24
+ export interface Identity {
25
+ user: {
26
+ id: string;
27
+ displayName: string | null;
28
+ email: string | null;
29
+ } | null;
30
+ tenant: {
31
+ id: string;
32
+ name: string;
33
+ } | null;
34
+ installation: {
35
+ id: string;
36
+ slug: string;
37
+ displayName: string;
38
+ };
39
+ manifest: unknown;
40
+ scopes: string[];
41
+ }
42
+ export type StorageValue = unknown;
43
+ export interface StorageEntry {
44
+ key: string;
45
+ value: StorageValue;
46
+ updatedAt: string;
47
+ }
48
+ export type WorkhubEventName = 'theme:change' | 'route:change' | 'tenant:switch';
49
+ export interface WorkhubEventPayload {
50
+ 'theme:change': {
51
+ mode: 'light' | 'dark';
52
+ };
53
+ 'route:change': {
54
+ path: string;
55
+ };
56
+ 'tenant:switch': {
57
+ tenantId: string;
58
+ };
59
+ }
60
+ export declare const workhub: {
61
+ /** Identity — read-only user / tenant / installation context. */
62
+ readonly identity: {
63
+ readonly current: () => Promise<Identity>;
64
+ };
65
+ /** KV storage scoped to (tenant, installation). Survives reloads but
66
+ * not uninstall. ttlSeconds is optional — omit for permanent. */
67
+ readonly storage: {
68
+ readonly get: <T = unknown>(key: string) => Promise<T | null>;
69
+ readonly set: <T = unknown>(key: string, value: T, opts?: {
70
+ ttlSeconds?: number;
71
+ }) => Promise<void>;
72
+ readonly delete: (key: string) => Promise<void>;
73
+ readonly list: (prefix?: string) => Promise<StorageEntry[]>;
74
+ };
75
+ /** Host-rendered UI primitives — keep apps visually consistent without
76
+ * every app having to ship its own toast component. */
77
+ readonly notifications: {
78
+ readonly toast: (message: string, opts?: {
79
+ variant?: "info" | "success" | "warning" | "danger";
80
+ }) => Promise<void>;
81
+ };
82
+ /** Pub/sub for host → app events. The host emits when relevant
83
+ * (theme switch, route change, tenant switch). */
84
+ readonly events: {
85
+ readonly on: {
86
+ <T extends WorkhubEventName>(name: T, cb: (payload: WorkhubEventPayload[T]) => void): () => void;
87
+ (name: string, cb: (payload: unknown) => void): () => void;
88
+ };
89
+ };
90
+ /** Escape hatch — call any /v1/* verb the user has permission for, gated
91
+ * on the `api:proxy` scope at the host bridge. As we identify common
92
+ * verbs they graduate into typed SDK methods. */
93
+ readonly api: {
94
+ readonly call: <T = unknown>(verb: string, body?: unknown) => Promise<T>;
95
+ };
96
+ };
97
+ export default workhub;
98
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAIH,MAAM,WAAW,QAAQ;IACvB,IAAI,EAAI;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;QAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,GAAG,IAAI,CAAC;IAChF,MAAM,EAAE;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC;IAC5C,YAAY,EAAE;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,MAAM,CAAA;KAAE,CAAC;IAChE,QAAQ,EAAE,OAAO,CAAC;IAClB,MAAM,EAAE,MAAM,EAAE,CAAC;CAClB;AAED,MAAM,MAAM,YAAY,GAAG,OAAO,CAAC;AAEnC,MAAM,WAAW,YAAY;IAC3B,GAAG,EAAQ,MAAM,CAAC;IAClB,KAAK,EAAM,YAAY,CAAC;IACxB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,MAAM,gBAAgB,GACxB,cAAc,GACd,cAAc,GACd,eAAe,CAAC;AAEpB,MAAM,WAAW,mBAAmB;IAClC,cAAc,EAAG;QAAE,IAAI,EAAE,OAAO,GAAG,MAAM,CAAA;KAAE,CAAC;IAC5C,cAAc,EAAG;QAAE,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC;IAClC,eAAe,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC;CACvC;AAsGD,eAAO,MAAM,OAAO;IAClB,iEAAiE;;gCAEpD,OAAO,CAAC,QAAQ,CAAC;;IAK9B;sEACkE;;uBAE5D,CAAC,iBAAsB,MAAM,KAAG,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC;uBAGjD,CAAC,iBAAsB,MAAM,SAAS,CAAC,SAAS;YAAE,UAAU,CAAC,EAAE,MAAM,CAAA;SAAE,KAAG,OAAO,CAAC,IAAI,CAAC;+BAG/E,MAAM,KAAG,OAAO,CAAC,IAAI,CAAC;iCAGpB,MAAM,KAAG,OAAO,CAAC,YAAY,EAAE,CAAC;;IAKhD;4DACwD;;kCAEvC,MAAM,SAAS;YAAE,OAAO,CAAC,EAAE,MAAM,GAAG,SAAS,GAAG,SAAS,GAAG,QAAQ,CAAA;SAAE,KAAG,OAAO,CAAC,IAAI,CAAC;;IAKvG;uDACmD;;;aApDhD,CAAC,SAAS,gBAAgB,QAAQ,CAAC,MAAM,CAAC,OAAO,EAAE,mBAAmB,CAAC,CAAC,CAAC,KAAK,IAAI,GAAG,MAAM,IAAI;mBACzF,MAAM,MAAM,CAAC,OAAO,EAAE,OAAO,KAAK,IAAI,GAAG,MAAM,IAAI;;;IAwD5D;;sDAEkD;;wBAE3C,CAAC,kBAAkB,MAAM,SAAS,OAAO,KAAG,OAAO,CAAC,CAAC,CAAC;;CAIrD,CAAC;AAGX,eAAe,OAAO,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,147 @@
1
+ /**
2
+ * @fepro/workhub-app-sdk — client SDK for apps embedded
3
+ * inside the WorkHub portal.
4
+ *
5
+ * Apps run as a sandboxed iframe served from /v1/apps/runtime/<installationId>/.
6
+ * They cannot call the WorkHub API directly — every request crosses the
7
+ * iframe boundary via `window.postMessage` to the host portal, which
8
+ * validates the request against the manifest's declared scopes and forwards
9
+ * it to the API. The SDK is the typed, ergonomic wrapper for that channel.
10
+ *
11
+ * Usage:
12
+ *
13
+ * import { workhub } from '@fepro/workhub-app-sdk';
14
+ *
15
+ * const me = await workhub.identity.current();
16
+ * await workhub.storage.set('lastView', { page: 7 });
17
+ * await workhub.notifications.toast('Saved');
18
+ * workhub.events.on('theme:change', applyTheme);
19
+ *
20
+ * Capability scopes (declared in workhub.json `scopes`) are enforced by
21
+ * the host bridge, not here — the SDK fires the request, the host either
22
+ * returns a result or rejects with `ERR_SCOPE_DENIED`.
23
+ */
24
+ const RPC_TIMEOUT_MS = 10_000;
25
+ class HostChannel {
26
+ nextId = 1;
27
+ pending = new Map();
28
+ // Listeners: each event name maps to a Set of callbacks.
29
+ listeners = new Map();
30
+ // Lazy-init flag — we only attach the message listener on first use.
31
+ wired = false;
32
+ wire() {
33
+ if (this.wired)
34
+ return;
35
+ this.wired = true;
36
+ if (typeof window === 'undefined')
37
+ return;
38
+ window.addEventListener('message', (event) => {
39
+ const data = event.data;
40
+ if (!data || typeof data !== 'object' || data.__workhub === undefined)
41
+ return;
42
+ // We trust window.parent for message origin — the iframe is loaded by
43
+ // the portal, frame-ancestors CSP locks us to the portal origin, and
44
+ // we don't accept any verbs that would meaningfully harm the app from
45
+ // a malicious sibling.
46
+ if (data.__workhub === 'rpc-response') {
47
+ const pending = this.pending.get(data.id);
48
+ if (!pending)
49
+ return;
50
+ this.pending.delete(data.id);
51
+ if (data.ok)
52
+ pending.resolve(data.result);
53
+ else
54
+ pending.reject(Object.assign(new Error(data.error?.message ?? 'rpc_failed'), { code: data.error?.code ?? 'unknown' }));
55
+ }
56
+ else if (data.__workhub === 'rpc-event') {
57
+ const set = this.listeners.get(data.name);
58
+ if (!set)
59
+ return;
60
+ for (const cb of set) {
61
+ try {
62
+ cb(data.payload);
63
+ }
64
+ catch { /* swallow listener errors */ }
65
+ }
66
+ }
67
+ });
68
+ }
69
+ call(verb, payload) {
70
+ this.wire();
71
+ if (typeof window === 'undefined' || window.parent === window) {
72
+ return Promise.reject(new Error('not_embedded: workhub SDK only works inside the portal iframe'));
73
+ }
74
+ const id = String(this.nextId++);
75
+ const msg = { __workhub: 'rpc-request', id, verb, payload };
76
+ window.parent.postMessage(msg, '*');
77
+ return new Promise((resolve, reject) => {
78
+ const timeout = setTimeout(() => {
79
+ this.pending.delete(id);
80
+ reject(Object.assign(new Error(`rpc_timeout: ${verb}`), { code: 'timeout' }));
81
+ }, RPC_TIMEOUT_MS);
82
+ this.pending.set(id, {
83
+ resolve: (v) => { clearTimeout(timeout); resolve(v); },
84
+ reject: (e) => { clearTimeout(timeout); reject(e); },
85
+ });
86
+ });
87
+ }
88
+ on(name, cb) {
89
+ this.wire();
90
+ let set = this.listeners.get(name);
91
+ if (!set) {
92
+ set = new Set();
93
+ this.listeners.set(name, set);
94
+ }
95
+ set.add(cb);
96
+ return () => set.delete(cb);
97
+ }
98
+ }
99
+ const channel = new HostChannel();
100
+ // ─── Surface ────────────────────────────────────────────────────────────────
101
+ export const workhub = {
102
+ /** Identity — read-only user / tenant / installation context. */
103
+ identity: {
104
+ current() {
105
+ return channel.call('identity.current', {});
106
+ },
107
+ },
108
+ /** KV storage scoped to (tenant, installation). Survives reloads but
109
+ * not uninstall. ttlSeconds is optional — omit for permanent. */
110
+ storage: {
111
+ get(key) {
112
+ return channel.call('storage.get', { key });
113
+ },
114
+ set(key, value, opts) {
115
+ return channel.call('storage.set', { key, value, ttlSeconds: opts?.ttlSeconds });
116
+ },
117
+ delete(key) {
118
+ return channel.call('storage.delete', { key });
119
+ },
120
+ list(prefix) {
121
+ return channel.call('storage.list', { prefix });
122
+ },
123
+ },
124
+ /** Host-rendered UI primitives — keep apps visually consistent without
125
+ * every app having to ship its own toast component. */
126
+ notifications: {
127
+ toast(message, opts) {
128
+ return channel.call('notifications.toast', { message, variant: opts?.variant ?? 'info' });
129
+ },
130
+ },
131
+ /** Pub/sub for host → app events. The host emits when relevant
132
+ * (theme switch, route change, tenant switch). */
133
+ events: {
134
+ on: channel.on.bind(channel),
135
+ },
136
+ /** Escape hatch — call any /v1/* verb the user has permission for, gated
137
+ * on the `api:proxy` scope at the host bridge. As we identify common
138
+ * verbs they graduate into typed SDK methods. */
139
+ api: {
140
+ call(verb, body) {
141
+ return channel.call('api.call', { verb, body });
142
+ },
143
+ },
144
+ };
145
+ // Default export for ergonomic single-import usage.
146
+ export default workhub;
147
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@fepro/workhub-app-sdk",
3
+ "version": "0.1.0",
4
+ "description": "Client SDK for apps embedded in WorkHub. Use it inside an iframe app to call back into the host (identity, storage, notifications, events, API proxy).",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "module": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js",
13
+ "default": "./dist/index.js"
14
+ },
15
+ "./package.json": "./package.json"
16
+ },
17
+ "files": [
18
+ "dist",
19
+ "src",
20
+ "README.md"
21
+ ],
22
+ "scripts": {
23
+ "build": "tsc -p tsconfig.json",
24
+ "prepublishOnly": "npm run build"
25
+ },
26
+ "keywords": [
27
+ "workhub",
28
+ "sdk",
29
+ "iframe",
30
+ "postmessage",
31
+ "tenant",
32
+ "saas"
33
+ ],
34
+ "homepage": "https://workhub.dev/sdk",
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "git+https://github.com/futureedgetechnologies/workhub.git",
38
+ "directory": "packages/app-sdk"
39
+ },
40
+ "license": "MIT",
41
+ "publishConfig": {
42
+ "access": "public"
43
+ },
44
+ "sideEffects": false,
45
+ "engines": {
46
+ "node": ">=18"
47
+ },
48
+ "devDependencies": {
49
+ "typescript": "^5.4.0"
50
+ }
51
+ }
package/src/index.ts ADDED
@@ -0,0 +1,204 @@
1
+ /**
2
+ * @fepro/workhub-app-sdk — client SDK for apps embedded
3
+ * inside the WorkHub portal.
4
+ *
5
+ * Apps run as a sandboxed iframe served from /v1/apps/runtime/<installationId>/.
6
+ * They cannot call the WorkHub API directly — every request crosses the
7
+ * iframe boundary via `window.postMessage` to the host portal, which
8
+ * validates the request against the manifest's declared scopes and forwards
9
+ * it to the API. The SDK is the typed, ergonomic wrapper for that channel.
10
+ *
11
+ * Usage:
12
+ *
13
+ * import { workhub } from '@fepro/workhub-app-sdk';
14
+ *
15
+ * const me = await workhub.identity.current();
16
+ * await workhub.storage.set('lastView', { page: 7 });
17
+ * await workhub.notifications.toast('Saved');
18
+ * workhub.events.on('theme:change', applyTheme);
19
+ *
20
+ * Capability scopes (declared in workhub.json `scopes`) are enforced by
21
+ * the host bridge, not here — the SDK fires the request, the host either
22
+ * returns a result or rejects with `ERR_SCOPE_DENIED`.
23
+ */
24
+
25
+ // ─── Public types ────────────────────────────────────────────────────────────
26
+
27
+ export interface Identity {
28
+ user: { id: string; displayName: string | null; email: string | null } | null;
29
+ tenant: { id: string; name: string } | null;
30
+ installation: { id: string; slug: string; displayName: string };
31
+ manifest: unknown;
32
+ scopes: string[];
33
+ }
34
+
35
+ export type StorageValue = unknown;
36
+
37
+ export interface StorageEntry {
38
+ key: string;
39
+ value: StorageValue;
40
+ updatedAt: string;
41
+ }
42
+
43
+ export type WorkhubEventName =
44
+ | 'theme:change'
45
+ | 'route:change'
46
+ | 'tenant:switch';
47
+
48
+ export interface WorkhubEventPayload {
49
+ 'theme:change': { mode: 'light' | 'dark' };
50
+ 'route:change': { path: string };
51
+ 'tenant:switch': { tenantId: string };
52
+ }
53
+
54
+ // ─── postMessage envelope ────────────────────────────────────────────────────
55
+
56
+ interface RpcRequest {
57
+ __workhub: 'rpc-request';
58
+ id: string;
59
+ verb: string;
60
+ payload: unknown;
61
+ }
62
+
63
+ interface RpcResponse {
64
+ __workhub: 'rpc-response';
65
+ id: string;
66
+ ok: boolean;
67
+ result?: unknown;
68
+ error?: { code: string; message: string };
69
+ }
70
+
71
+ interface RpcEvent {
72
+ __workhub: 'rpc-event';
73
+ name: string;
74
+ payload: unknown;
75
+ }
76
+
77
+ type IncomingMessage = RpcResponse | RpcEvent;
78
+
79
+ const RPC_TIMEOUT_MS = 10_000;
80
+
81
+ class HostChannel {
82
+ private nextId = 1;
83
+ private pending = new Map<string, { resolve: (v: unknown) => void; reject: (e: Error) => void }>();
84
+ // Listeners: each event name maps to a Set of callbacks.
85
+ private listeners = new Map<string, Set<(payload: unknown) => void>>();
86
+ // Lazy-init flag — we only attach the message listener on first use.
87
+ private wired = false;
88
+
89
+ private wire(): void {
90
+ if (this.wired) return;
91
+ this.wired = true;
92
+ if (typeof window === 'undefined') return;
93
+ window.addEventListener('message', (event: MessageEvent) => {
94
+ const data = event.data as IncomingMessage | undefined;
95
+ if (!data || typeof data !== 'object' || (data as { __workhub?: string }).__workhub === undefined) return;
96
+ // We trust window.parent for message origin — the iframe is loaded by
97
+ // the portal, frame-ancestors CSP locks us to the portal origin, and
98
+ // we don't accept any verbs that would meaningfully harm the app from
99
+ // a malicious sibling.
100
+ if (data.__workhub === 'rpc-response') {
101
+ const pending = this.pending.get(data.id);
102
+ if (!pending) return;
103
+ this.pending.delete(data.id);
104
+ if (data.ok) pending.resolve(data.result);
105
+ else pending.reject(Object.assign(new Error(data.error?.message ?? 'rpc_failed'), { code: data.error?.code ?? 'unknown' }));
106
+ } else if (data.__workhub === 'rpc-event') {
107
+ const set = this.listeners.get(data.name);
108
+ if (!set) return;
109
+ for (const cb of set) {
110
+ try { cb(data.payload); } catch { /* swallow listener errors */ }
111
+ }
112
+ }
113
+ });
114
+ }
115
+
116
+ call<T>(verb: string, payload: unknown): Promise<T> {
117
+ this.wire();
118
+ if (typeof window === 'undefined' || window.parent === window) {
119
+ return Promise.reject(new Error('not_embedded: workhub SDK only works inside the portal iframe'));
120
+ }
121
+ const id = String(this.nextId++);
122
+ const msg: RpcRequest = { __workhub: 'rpc-request', id, verb, payload };
123
+ window.parent.postMessage(msg, '*');
124
+ return new Promise<T>((resolve, reject) => {
125
+ const timeout = setTimeout(() => {
126
+ this.pending.delete(id);
127
+ reject(Object.assign(new Error(`rpc_timeout: ${verb}`), { code: 'timeout' }));
128
+ }, RPC_TIMEOUT_MS);
129
+ this.pending.set(id, {
130
+ resolve: (v) => { clearTimeout(timeout); resolve(v as T); },
131
+ reject: (e) => { clearTimeout(timeout); reject(e); },
132
+ });
133
+ });
134
+ }
135
+
136
+ on<T extends WorkhubEventName>(name: T, cb: (payload: WorkhubEventPayload[T]) => void): () => void;
137
+ on(name: string, cb: (payload: unknown) => void): () => void;
138
+ on(name: string, cb: (payload: unknown) => void): () => void {
139
+ this.wire();
140
+ let set = this.listeners.get(name);
141
+ if (!set) {
142
+ set = new Set();
143
+ this.listeners.set(name, set);
144
+ }
145
+ set.add(cb);
146
+ return () => set!.delete(cb);
147
+ }
148
+ }
149
+
150
+ const channel = new HostChannel();
151
+
152
+ // ─── Surface ────────────────────────────────────────────────────────────────
153
+
154
+ export const workhub = {
155
+ /** Identity — read-only user / tenant / installation context. */
156
+ identity: {
157
+ current(): Promise<Identity> {
158
+ return channel.call<Identity>('identity.current', {});
159
+ },
160
+ },
161
+
162
+ /** KV storage scoped to (tenant, installation). Survives reloads but
163
+ * not uninstall. ttlSeconds is optional — omit for permanent. */
164
+ storage: {
165
+ get<T = StorageValue>(key: string): Promise<T | null> {
166
+ return channel.call<T | null>('storage.get', { key });
167
+ },
168
+ set<T = StorageValue>(key: string, value: T, opts?: { ttlSeconds?: number }): Promise<void> {
169
+ return channel.call<void>('storage.set', { key, value, ttlSeconds: opts?.ttlSeconds });
170
+ },
171
+ delete(key: string): Promise<void> {
172
+ return channel.call<void>('storage.delete', { key });
173
+ },
174
+ list(prefix?: string): Promise<StorageEntry[]> {
175
+ return channel.call<StorageEntry[]>('storage.list', { prefix });
176
+ },
177
+ },
178
+
179
+ /** Host-rendered UI primitives — keep apps visually consistent without
180
+ * every app having to ship its own toast component. */
181
+ notifications: {
182
+ toast(message: string, opts?: { variant?: 'info' | 'success' | 'warning' | 'danger' }): Promise<void> {
183
+ return channel.call<void>('notifications.toast', { message, variant: opts?.variant ?? 'info' });
184
+ },
185
+ },
186
+
187
+ /** Pub/sub for host → app events. The host emits when relevant
188
+ * (theme switch, route change, tenant switch). */
189
+ events: {
190
+ on: channel.on.bind(channel),
191
+ },
192
+
193
+ /** Escape hatch — call any /v1/* verb the user has permission for, gated
194
+ * on the `api:proxy` scope at the host bridge. As we identify common
195
+ * verbs they graduate into typed SDK methods. */
196
+ api: {
197
+ call<T = unknown>(verb: string, body?: unknown): Promise<T> {
198
+ return channel.call<T>('api.call', { verb, body });
199
+ },
200
+ },
201
+ } as const;
202
+
203
+ // Default export for ergonomic single-import usage.
204
+ export default workhub;