@fepro/workhub-app-sdk 0.2.0 → 0.4.1
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/AGENTS.md +122 -5
- package/CHANGELOG.md +52 -0
- package/README.md +92 -3
- package/dist/index.d.ts +180 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +252 -14
- package/dist/index.js.map +1 -1
- package/package.json +4 -3
- package/src/index.ts +377 -19
package/AGENTS.md
CHANGED
|
@@ -5,6 +5,63 @@
|
|
|
5
5
|
> [README.md](./README.md). Both describe the same SDK; this one is denser,
|
|
6
6
|
> includes complete worked examples, and lists the gotchas that bite agents.
|
|
7
7
|
|
|
8
|
+
## Two SDKs — pick the right one
|
|
9
|
+
|
|
10
|
+
WorkHub publishes two separate npm packages. Pick by where your code runs:
|
|
11
|
+
|
|
12
|
+
| Package | Runtime | When to use |
|
|
13
|
+
|---|---|---|
|
|
14
|
+
| **`@fepro/workhub-app-sdk`** (this doc) | Browser, inside the iframe | Frontend SPA — identity, storage, notifications, events, API proxy. Calls into the host via postMessage. |
|
|
15
|
+
| **`@fepro/workhub-app-backend`** | Node, inside a workspace deployment | Server-side routes — JWT verification, scope checks, tenant-scoped database. The frontend reaches it via `workhub.backend.fetch()`. |
|
|
16
|
+
|
|
17
|
+
A typical app uses both: the frontend SDK in the iframe SPA, the backend SDK
|
|
18
|
+
inside a workspace deployment. They pair through `workhub.backend.fetch(...)`,
|
|
19
|
+
which routes to the deployed workspace and attaches identity + scopes.
|
|
20
|
+
|
|
21
|
+
- Frontend offline copy: <https://workhubplatform.io/sdk/download>
|
|
22
|
+
- Backend offline copy: <https://workhubplatform.io/sdk/download/backend>
|
|
23
|
+
- Backend reference: see the README inside the backend package — surface,
|
|
24
|
+
examples, and the deploy lifecycle (upload → auto-build → review → deploy → install).
|
|
25
|
+
|
|
26
|
+
> **Critical for agents generating backend workspaces:** developers do **NOT**
|
|
27
|
+
> ship a Dockerfile. The platform compiles a vetted Dockerfile from
|
|
28
|
+
> `workspace.json#backend.runtime`. If the user asks you to write a backend
|
|
29
|
+
> workspace, do **not** generate a Dockerfile. The runtime + start command in
|
|
30
|
+
> `workspace.json` is the entire build contract. Supported runtimes are
|
|
31
|
+
> `node:20`, `node:22`, `bun:1`, `python:3.11`, `python:3.12`. A minimal
|
|
32
|
+
> backend `workspace.json#backend` looks like:
|
|
33
|
+
>
|
|
34
|
+
> ```json
|
|
35
|
+
> {
|
|
36
|
+
> "runtime": "node:20",
|
|
37
|
+
> "start": "node dist/index.js"
|
|
38
|
+
> }
|
|
39
|
+
> ```
|
|
40
|
+
>
|
|
41
|
+
> The container's listen port is platform-fixed. Code your server to
|
|
42
|
+
> read `process.env.PORT` (which the platform sets) and bind to it —
|
|
43
|
+
> every framework defaults to this. Don't put `port` in the manifest;
|
|
44
|
+
> it's accepted for backwards compat but ignored.
|
|
45
|
+
>
|
|
46
|
+
> The bundle layout for a backend workspace:
|
|
47
|
+
>
|
|
48
|
+
> ```
|
|
49
|
+
> my-workspace.zip
|
|
50
|
+
> ├── workspace.json
|
|
51
|
+
> ├── backend/
|
|
52
|
+
> │ ├── package.json
|
|
53
|
+
> │ ├── package-lock.json
|
|
54
|
+
> │ └── src/index.ts
|
|
55
|
+
> └── apps/<key>/workhub.json + dist/...
|
|
56
|
+
> ```
|
|
57
|
+
>
|
|
58
|
+
> Lockfile-first install caching is auto-detected: presence of
|
|
59
|
+
> `package-lock.json` → `npm ci`, `pnpm-lock.yaml` → `pnpm install --frozen-lockfile`,
|
|
60
|
+
> `yarn.lock` → `yarn install --frozen-lockfile`, `bun.lockb` → `bun install --frozen-lockfile`.
|
|
61
|
+
|
|
62
|
+
The rest of this file is about the **frontend** SDK. If you only need server
|
|
63
|
+
routes (no iframe SPA), stop here and read the backend SDK README instead.
|
|
64
|
+
|
|
8
65
|
## What you are building
|
|
9
66
|
|
|
10
67
|
A **WorkHub app** is a `.zip` archive containing a static SPA + a manifest.
|
|
@@ -96,19 +153,71 @@ workhub.storage.set<T>(key: string, value: T, opts?: { ttlSeconds?: number }): P
|
|
|
96
153
|
workhub.storage.delete(key: string): Promise<void>;
|
|
97
154
|
workhub.storage.list(prefix?: string): Promise<Array<{ key: string; value: unknown; updatedAt: string }>>;
|
|
98
155
|
|
|
156
|
+
// Files — object/blob storage (bytes), NOT the KV above (since 0.4) ----
|
|
157
|
+
// Persist the returned stable `key`; resolve back to a URL with getUrl(key).
|
|
158
|
+
workhub.files.upload(file: Blob, opts?: { filename?: string; keyHint?: string }): Promise<{ key: string }>;
|
|
159
|
+
workhub.files.presignUpload(opts: { filename: string; contentType?: string; keyHint?: string }):
|
|
160
|
+
Promise<{ url: string; key: string; headers?: Record<string, string> }>;
|
|
161
|
+
workhub.files.getUrl(key: string, opts?: { expiresInSeconds?: number }): Promise<{ url: string }>;
|
|
162
|
+
workhub.files.delete(key: string): Promise<void>;
|
|
163
|
+
workhub.files.list(prefix?: string): Promise<Array<{ key: string; size: number; lastModified: string | null }>>;
|
|
164
|
+
|
|
165
|
+
// Org units — sub-tenants / companies (multi-company, since 0.4) -------
|
|
166
|
+
workhub.org.units(): Promise<{ active: OrgUnit | null; units: OrgUnit[] }>; // OrgUnit = { id, name, parentId, path }
|
|
167
|
+
workhub.org.switch(unitId: string): Promise<void>; // portal re-issues identity + emits 'org-unit:switch'
|
|
168
|
+
|
|
99
169
|
// Notifications ---------------------------------------------
|
|
100
170
|
workhub.notifications.toast(
|
|
101
171
|
message: string,
|
|
102
172
|
opts?: { variant?: 'info' | 'success' | 'warning' | 'danger' },
|
|
103
173
|
): Promise<void>;
|
|
104
174
|
|
|
105
|
-
//
|
|
106
|
-
|
|
107
|
-
|
|
175
|
+
// Locale — the portal's active language/timezone (since 0.3) -
|
|
176
|
+
workhub.locale.current(): Promise<{ tag: string; language: string; timezone: string }>;
|
|
177
|
+
|
|
178
|
+
// Theme — full token set, not just mode (since 0.3) ---------
|
|
179
|
+
workhub.theme.current(): Promise<{
|
|
180
|
+
mode: 'light' | 'dark';
|
|
181
|
+
brand: { primary: string; secondary: string }; // #rrggbb
|
|
182
|
+
cssVars: Record<string, string>; // apply as --<name>
|
|
183
|
+
}>;
|
|
184
|
+
|
|
185
|
+
// Events — host pushes these; events.on returns an unsubscribe fn
|
|
186
|
+
// Host events:
|
|
187
|
+
// 'theme:change' → ThemeTokens (mode + brand + cssVars) [was {mode} pre-0.3]
|
|
188
|
+
// 'locale:change' → { tag, language, timezone } [since 0.3]
|
|
189
|
+
// 'route:change' → { path }
|
|
190
|
+
// 'tenant:switch' → { tenantId }
|
|
191
|
+
// 'org-unit:switch'→ { unit: OrgUnit } [since 0.4]
|
|
192
|
+
// App events (since 0.3): your backend's ctx.events.emit('customer.created', …)
|
|
193
|
+
// arrives as 'app:customer.created'. Augment WorkhubAppEventPayload for types.
|
|
194
|
+
workhub.events.on(name: string, cb: (payload: any) => void): () => void;
|
|
108
195
|
|
|
109
196
|
// Generic API escape hatch ----------------------------------
|
|
110
197
|
workhub.api.call<T>(verb: string, body?: unknown): Promise<T>;
|
|
111
198
|
// verb format: "METHOD /v1/path" e.g. "GET /v1/databases"
|
|
199
|
+
|
|
200
|
+
// Your own workspace backend (pairs with @fepro/workhub-app-backend) -
|
|
201
|
+
workhub.backend.fetch(path: string, init?: RequestInit): Promise<Response>;
|
|
202
|
+
workhub.backend.call<T>(path: string, opts?: { // JSON in/out wrapper (since 0.3)
|
|
203
|
+
method?: 'GET'|'POST'|'PUT'|'PATCH'|'DELETE';
|
|
204
|
+
body?: unknown; headers?: Record<string,string>;
|
|
205
|
+
idempotencyKey?: string; // safe retries on idempotent routes
|
|
206
|
+
}): Promise<T>;
|
|
207
|
+
// Throws `WorkhubError { code, message, status?, requestId?, details? }` on a
|
|
208
|
+
// 4xx/5xx — branch on err.code (e.g. 'rate_limited', 'not_found') not status.
|
|
209
|
+
// backend.* needs `identity:read` (it resolves the installation id first).
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
### Typing your app events (since 0.3)
|
|
213
|
+
|
|
214
|
+
```ts
|
|
215
|
+
declare module '@fepro/workhub-app-sdk' {
|
|
216
|
+
interface WorkhubAppEventPayload {
|
|
217
|
+
'app:customer.created': { id: string; displayName: string };
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
workhub.events.on('app:customer.created', (e) => e.displayName /* typed */);
|
|
112
221
|
```
|
|
113
222
|
|
|
114
223
|
## Scope → method matrix
|
|
@@ -119,9 +228,15 @@ workhub.api.call<T>(verb: string, body?: unknown): Promise<T>;
|
|
|
119
228
|
| `storage:read` | `storage.get`, `storage.list` |
|
|
120
229
|
| `storage:write` | `storage.set`, `storage.delete` |
|
|
121
230
|
| `notifications:send` | `notifications.toast` |
|
|
122
|
-
| `events:listen` | `events.on(...)`
|
|
231
|
+
| `events:listen` | `events.on(...)` — host events AND `app:*` backend events |
|
|
232
|
+
| `storage:files` | `files.upload`, `files.presignUpload`, `files.getUrl`, `files.delete`, `files.list` (since 0.4) |
|
|
233
|
+
| `org:read` | `org.units()`, `org.switch()` — multi-company (since 0.4) |
|
|
123
234
|
| `api:proxy` | `api.call(verb, body)` (still gated on the user's existing /v1 permissions) |
|
|
124
235
|
|
|
236
|
+
`locale.current()` and `theme.current()` are **ambient** — no scope needed
|
|
237
|
+
(the portal pushes theme/locale to every embedded app anyway). `backend.fetch`
|
|
238
|
+
/ `backend.call` need `identity:read` (they resolve the installation first).
|
|
239
|
+
|
|
125
240
|
## Worked examples
|
|
126
241
|
|
|
127
242
|
### Example 1 — Minimal hello app (vanilla, zero deps)
|
|
@@ -237,7 +352,7 @@ This is a **production security boundary**. A malicious bundle uploaded by tenan
|
|
|
237
352
|
6. **Storage values are JSON-serialisable.** Functions, `BigInt`, `Date` (use ISO strings), and circular references all fail. Storage payload max is whatever Postgres `jsonb` accepts (~1 GB practical, but keep entries small).
|
|
238
353
|
7. **`storage.list(prefix)`** uses `LIKE 'prefix%'` matching — pick a prefix scheme that doesn't collide.
|
|
239
354
|
8. **Events fire from the host, not from your code.** `workhub.events.on('theme:change', ...)` returns an unsubscribe function — wire it into your component cleanup if you have one.
|
|
240
|
-
9. **CSP locks `connect-src` to `'self'`**
|
|
355
|
+
9. **CSP locks `connect-src` to `'self'`** so `fetch()` to third-party origins from the iframe is blocked — and always will be (it's a security boundary). To call an external API (Stripe, a webhook, a geocoder), do it from your **workspace backend** and expose a route the frontend hits via `workhub.backend.call(...)`. The backend declares allowed destinations in `workspace.json#egress` and uses `ctx.fetch` (see the backend SDK README). Don't try to fetch third parties from the iframe.
|
|
241
356
|
10. **Bundle URLs are short-cached (60s).** Iterate by re-uploading; the bundle key includes a content hash so a fresh upload gets a fresh URL automatically.
|
|
242
357
|
11. **Always import the SDK from `/sdk.js`** — never `https://esm.sh/...`, `https://cdn.jsdelivr.net/...`, or any other CDN. The iframe's CSP blocks them. `/sdk.js` is the platform's same-origin SDK and works under sandbox.
|
|
243
358
|
|
|
@@ -251,6 +366,8 @@ This is a **production security boundary**. A malicious bundle uploaded by tenan
|
|
|
251
366
|
| `unknown_verb` | SDK method not implemented in the bridge | likely an SDK/host version mismatch |
|
|
252
367
|
| `bridge_400/4xx` | API rejected the underlying call | inspect `error.message` for details |
|
|
253
368
|
| `http_403` | `api.call` succeeded but the user lacks `/v1` perms | tenant grants more permissions to the user |
|
|
369
|
+
| `WorkhubError` | thrown by `backend.call` on a 4xx/5xx — has `.code` (your backend's AppError code), `.status`, `.requestId` | branch on `err.code`; quote `requestId` in bug reports |
|
|
370
|
+
| `not_installed` | `backend.fetch/call` but the app isn't installed under a workspace | only developer-workspace apps have a backend |
|
|
254
371
|
|
|
255
372
|
## Starter template (copy verbatim)
|
|
256
373
|
|
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# @fepro/workhub-app-sdk — Changelog
|
|
2
|
+
|
|
3
|
+
## 0.4.1
|
|
4
|
+
|
|
5
|
+
Resolves BMS ERP request #31 — `workhub.identity.current()` second-call
|
|
6
|
+
race that surfaced as `rpc_timeout: identity.current` toasts in apps that
|
|
7
|
+
called `identity.current()` more than once per session (e.g. BMS ERP
|
|
8
|
+
0.5.0 calling it from both App.svelte boot and openSse()).
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
- **`workhub.identity.current()` caches the first successful response.**
|
|
12
|
+
Identity is per-session-immutable (bundle token + JWT minted once at
|
|
13
|
+
iframe boot; user / tenant / installation cannot change without a full
|
|
14
|
+
reload), so the second call is a free synchronous return from cache.
|
|
15
|
+
Previous behaviour issued a fresh postMessage RPC every time, which
|
|
16
|
+
occasionally timed out against the host bridge under cold-start
|
|
17
|
+
contention.
|
|
18
|
+
- **In-flight de-duplication** — concurrent callers during cold start
|
|
19
|
+
(e.g. `Promise.all([identity.current(), identity.current()])`) share a
|
|
20
|
+
single RPC instead of racing it.
|
|
21
|
+
- **Cache cleared on failure** so a subsequent call actually retries
|
|
22
|
+
instead of latching onto a poisoned pending promise.
|
|
23
|
+
|
|
24
|
+
No API change; same return shape, same `Identity` type. Apps that already
|
|
25
|
+
worked keep working; apps that called it twice stop tripping the bridge
|
|
26
|
+
race.
|
|
27
|
+
|
|
28
|
+
## 0.4.0
|
|
29
|
+
|
|
30
|
+
Additive — existing apps keep working.
|
|
31
|
+
|
|
32
|
+
### Added
|
|
33
|
+
- **`workhub.org`** — organization units (sub-tenants / companies):
|
|
34
|
+
`units()` lists accessible units + the active one; `switch(unitId)` asks the
|
|
35
|
+
portal to change the active unit. Pairs with the new `org-unit:switch` host
|
|
36
|
+
event and the backend `ctx.org` claim. (BMS ERP request #22, frontend half.)
|
|
37
|
+
- **`org-unit:switch` host event** — fires when the user switches the active org
|
|
38
|
+
unit; the portal re-issues the identity token so the next `workhub.backend.*`
|
|
39
|
+
call is authorized for the new unit.
|
|
40
|
+
- **`workhub.files`** — object/file storage (bytes), distinct from `workhub.storage`
|
|
41
|
+
(small JSON KV): `presignUpload()`, `upload(file)`, `getUrl(key)`, `delete(key)`,
|
|
42
|
+
`list(prefix)`. The host bridge mints presigned URLs so the browser uploads/
|
|
43
|
+
downloads directly. Persist the returned stable `key`. (BMS ERP request #25,
|
|
44
|
+
frontend half.)
|
|
45
|
+
|
|
46
|
+
### Platform-side obligations
|
|
47
|
+
- Host-bridge verbs: `org.units`, `org.switch`, `files.presignUpload`,
|
|
48
|
+
`files.getUrl`, `files.delete`, `files.list`.
|
|
49
|
+
|
|
50
|
+
## 0.3.0
|
|
51
|
+
- Identity, KV storage, notifications/toast, theme bridge, locale, host + app
|
|
52
|
+
events (`app:*`), API proxy, `workhub.backend.fetch/call`.
|
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
Client SDK for apps embedded inside the WorkHub portal.
|
|
4
4
|
|
|
5
5
|
> **Building with an AI agent?** See [AGENTS.md](./AGENTS.md) (or fetch
|
|
6
|
-
> [https://
|
|
6
|
+
> [https://workhubplatform.io/sdk/agents.md](https://workhubplatform.io/sdk/agents.md))
|
|
7
7
|
> for a dense, machine-friendly reference with the full SDK surface, three
|
|
8
8
|
> worked examples, the gotcha catalogue, and a copy-paste starter template.
|
|
9
9
|
|
|
@@ -90,11 +90,56 @@ const last = await workhub.storage.get<{ page: number }>('lastView');
|
|
|
90
90
|
// Toast
|
|
91
91
|
await workhub.notifications.toast('Saved!', { variant: 'success' });
|
|
92
92
|
|
|
93
|
-
// Listen for theme changes
|
|
94
|
-
workhub.events.on('theme:change', (
|
|
93
|
+
// Listen for theme changes — payload now carries full token set
|
|
94
|
+
workhub.events.on('theme:change', (tokens) => {
|
|
95
|
+
document.documentElement.dataset.theme = tokens.mode;
|
|
96
|
+
for (const [k, v] of Object.entries(tokens.cssVars)) {
|
|
97
|
+
document.documentElement.style.setProperty(`--${k}`, v);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// Locale propagation — react to the user's language preference
|
|
102
|
+
const locale = await workhub.locale.current(); // { tag, language, timezone }
|
|
103
|
+
workhub.events.on('locale:change', (next) => i18n.use(next.tag));
|
|
104
|
+
|
|
105
|
+
// App-domain events — emitted by your backend via ctx.events.emit(...)
|
|
106
|
+
workhub.events.on('app:customer.created', (e) => refresh());
|
|
95
107
|
|
|
96
108
|
// Escape hatch — call any /v1 endpoint
|
|
97
109
|
const dbs = await workhub.api.call('GET /v1/databases');
|
|
110
|
+
|
|
111
|
+
// Call your own workspace backend — JSON in / JSON out, typed errors
|
|
112
|
+
try {
|
|
113
|
+
const leads = await workhub.backend.call<Lead[]>('/leads');
|
|
114
|
+
const payment = await workhub.backend.call('/payments', {
|
|
115
|
+
method: 'POST',
|
|
116
|
+
body: { amount: 1200, currency: 'usd' },
|
|
117
|
+
idempotencyKey: crypto.randomUUID(), // safe retries
|
|
118
|
+
});
|
|
119
|
+
} catch (err) {
|
|
120
|
+
if (err instanceof WorkhubError && err.code === 'rate_limited') {
|
|
121
|
+
// backoff…
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### Typed app events
|
|
127
|
+
|
|
128
|
+
The default `WorkhubAppEventPayload` interface is empty — augment it from
|
|
129
|
+
your app's types module so each `events.on('app:...')` call gets type
|
|
130
|
+
inference for the payload:
|
|
131
|
+
|
|
132
|
+
```ts
|
|
133
|
+
declare module '@fepro/workhub-app-sdk' {
|
|
134
|
+
interface WorkhubAppEventPayload {
|
|
135
|
+
'app:customer.created': { id: string; displayName: string };
|
|
136
|
+
'app:invoice.overdue': { invoiceId: string; daysLate: number };
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
workhub.events.on('app:customer.created', (e) => {
|
|
141
|
+
e.displayName; // ← inferred as string
|
|
142
|
+
});
|
|
98
143
|
```
|
|
99
144
|
|
|
100
145
|
## Scopes
|
|
@@ -108,6 +153,50 @@ const dbs = await workhub.api.call('GET /v1/databases');
|
|
|
108
153
|
| `events:listen` | `workhub.events.on` |
|
|
109
154
|
| `api:proxy` | `workhub.api.call(...)` — gated on the user's existing permissions |
|
|
110
155
|
|
|
156
|
+
## Content Security Policy & sandbox
|
|
157
|
+
|
|
158
|
+
Your app runs in a **sandboxed iframe** with a strict CSP. Knowing the rules
|
|
159
|
+
up front saves a debugging session — most "why won't my font/script load?"
|
|
160
|
+
issues are the CSP doing its job.
|
|
161
|
+
|
|
162
|
+
**Sandbox** (set by the host on the `<iframe>`): production grants only
|
|
163
|
+
`allow-scripts allow-forms`. So your app can run JS and submit forms, but
|
|
164
|
+
cannot do top-level navigation, open popups, or break out of the frame.
|
|
165
|
+
(Local dev additionally grants `allow-same-origin` so cookies flow to the
|
|
166
|
+
dev bundle server — production keeps the iframe on a separate origin and
|
|
167
|
+
omits it.)
|
|
168
|
+
|
|
169
|
+
**CSP** applied to your served bundle:
|
|
170
|
+
|
|
171
|
+
| directive | value | what it means for you |
|
|
172
|
+
| --- | --- | --- |
|
|
173
|
+
| `frame-ancestors` | the portal origin | only the WorkHub portal can embed your app |
|
|
174
|
+
| `default-src` | `'self' 'unsafe-inline' 'unsafe-eval' data: blob:` | scripts/styles must come from **your bundle** (inline is allowed); **no external origins** |
|
|
175
|
+
| `img-src` | `'self' data: blob: https:` | images may load from any `https` origin |
|
|
176
|
+
| `connect-src` | `'self'` | `fetch`/XHR/WebSocket only to your own origin |
|
|
177
|
+
|
|
178
|
+
What this means in practice:
|
|
179
|
+
|
|
180
|
+
- **Bundle everything.** External CDNs (Google Fonts, jsdelivr, unpkg, an
|
|
181
|
+
analytics snippet) are blocked — `default-src` has no external origins.
|
|
182
|
+
Self-host your fonts and vendor your scripts/styles into the bundle. Images
|
|
183
|
+
are the one exception: any `https` URL works.
|
|
184
|
+
- **`workhub.backend.fetch()` / `workhub.backend.call()` work** — they hit
|
|
185
|
+
`/v1/apps/runtime/<id>/api…` on your own origin, which `connect-src 'self'`
|
|
186
|
+
permits. No extra config.
|
|
187
|
+
- **You cannot call third-party APIs from the browser** (Stripe, a geocoder,
|
|
188
|
+
a webhook target) — `connect-src 'self'` blocks them. Call them from your
|
|
189
|
+
**workspace backend** instead and expose a route your frontend hits via
|
|
190
|
+
`workhub.backend.call(...)`. (Outbound egress from the backend is a
|
|
191
|
+
separate, server-side concern.)
|
|
192
|
+
- Inline `<script>`/`<style>` and `eval` are allowed in the default policy
|
|
193
|
+
(most SPA bundlers need them), bounded by the sandbox. A stricter
|
|
194
|
+
no-`unsafe-inline`/no-`unsafe-eval` policy is applied to apps that opt in
|
|
195
|
+
(bundles that emit hashed/nonced inline code).
|
|
196
|
+
|
|
197
|
+
CSP violations are reported to the platform (`report-uri`) and surfaced to
|
|
198
|
+
operators, so a misbehaving app shows up in the apps-anomaly view.
|
|
199
|
+
|
|
111
200
|
## Upload
|
|
112
201
|
|
|
113
202
|
Upload via the portal at **/apps/development → Upload bundle**, or via the API:
|
package/dist/index.d.ts
CHANGED
|
@@ -16,6 +16,9 @@
|
|
|
16
16
|
* await workhub.storage.set('lastView', { page: 7 });
|
|
17
17
|
* await workhub.notifications.toast('Saved');
|
|
18
18
|
* workhub.events.on('theme:change', applyTheme);
|
|
19
|
+
* workhub.events.on('app:customer.created', (e) => refresh());
|
|
20
|
+
* const { tokens } = await workhub.theme.current();
|
|
21
|
+
* const locale = await workhub.locale.current();
|
|
19
22
|
*
|
|
20
23
|
* Capability scopes (declared in workhub.json `scopes`) are enforced by
|
|
21
24
|
* the host bridge, not here — the SDK fires the request, the host either
|
|
@@ -45,23 +48,173 @@ export interface StorageEntry {
|
|
|
45
48
|
value: StorageValue;
|
|
46
49
|
updatedAt: string;
|
|
47
50
|
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
51
|
+
/** Portal-level theme tokens — the portal forwards its active theme so apps
|
|
52
|
+
* can repaint instead of guessing colors. Light/dark mode plus the tenant's
|
|
53
|
+
* brand palette and a CSS-variable map ready for `<style>` injection. */
|
|
54
|
+
export interface ThemeTokens {
|
|
55
|
+
mode: 'light' | 'dark';
|
|
56
|
+
/** Hex strings, lowercase, `#rrggbb`. */
|
|
57
|
+
brand: {
|
|
58
|
+
primary: string;
|
|
59
|
+
secondary: string;
|
|
52
60
|
};
|
|
61
|
+
/** Map of CSS variable names (without the leading `--`) to values.
|
|
62
|
+
* Apply via:
|
|
63
|
+
* for (const [k, v] of Object.entries(tokens.cssVars))
|
|
64
|
+
* document.documentElement.style.setProperty(`--${k}`, v);
|
|
65
|
+
*/
|
|
66
|
+
cssVars: Record<string, string>;
|
|
67
|
+
}
|
|
68
|
+
export interface LocaleInfo {
|
|
69
|
+
/** BCP 47 tag, e.g. `en-US`, `fr-CA`. */
|
|
70
|
+
tag: string;
|
|
71
|
+
/** Convenience — the language subtag, e.g. `en`. */
|
|
72
|
+
language: string;
|
|
73
|
+
/** IANA timezone, e.g. `America/Toronto`. */
|
|
74
|
+
timezone: string;
|
|
75
|
+
}
|
|
76
|
+
/** Organization unit (sub-tenant / company / department). Mirrors the backend
|
|
77
|
+
* SDK's `OrgUnit`. Resolves BMS ERP request #22 (frontend half). */
|
|
78
|
+
export interface OrgUnit {
|
|
79
|
+
id: string;
|
|
80
|
+
name: string;
|
|
81
|
+
parentId: string | null;
|
|
82
|
+
/** Materialized path from the root unit, or null. */
|
|
83
|
+
path: string | null;
|
|
84
|
+
}
|
|
85
|
+
export interface OrgContext {
|
|
86
|
+
/** The unit the user is currently acting as, or null. */
|
|
87
|
+
active: OrgUnit | null;
|
|
88
|
+
/** All units this user may access (includes the active one). */
|
|
89
|
+
units: OrgUnit[];
|
|
90
|
+
}
|
|
91
|
+
/** Built-in portal-emitted events. */
|
|
92
|
+
export type WorkhubHostEventName = 'theme:change' | 'route:change' | 'tenant:switch' | 'locale:change' | 'org-unit:switch';
|
|
93
|
+
export interface WorkhubHostEventPayload {
|
|
94
|
+
'theme:change': ThemeTokens;
|
|
53
95
|
'route:change': {
|
|
54
96
|
path: string;
|
|
55
97
|
};
|
|
56
98
|
'tenant:switch': {
|
|
57
99
|
tenantId: string;
|
|
58
100
|
};
|
|
101
|
+
'locale:change': LocaleInfo;
|
|
102
|
+
/** The user switched the active org unit in the portal. The platform also
|
|
103
|
+
* re-issues the identity token, so the next `workhub.backend.*` call is
|
|
104
|
+
* authorized for the new unit. Re-fetch unit-scoped data on this event. */
|
|
105
|
+
'org-unit:switch': {
|
|
106
|
+
unit: OrgUnit;
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
/** App-domain events emitted by the workspace backend via `ctx.events.emit`.
|
|
110
|
+
* Subscribe with `workhub.events.on('app:<kind>', cb)` — the prefix
|
|
111
|
+
* separates host events from app events so the channel namespace stays
|
|
112
|
+
* clean as more events graduate to the platform-emitted set.
|
|
113
|
+
*
|
|
114
|
+
* Apps SHOULD declare a global module augmentation to type their event
|
|
115
|
+
* payloads, e.g.:
|
|
116
|
+
*
|
|
117
|
+
* declare module '@fepro/workhub-app-sdk' {
|
|
118
|
+
* interface WorkhubAppEventPayload {
|
|
119
|
+
* 'app:customer.created': { id: string; displayName: string };
|
|
120
|
+
* }
|
|
121
|
+
* }
|
|
122
|
+
*/
|
|
123
|
+
export interface WorkhubAppEventPayload {
|
|
124
|
+
[key: `app:${string}`]: unknown;
|
|
125
|
+
}
|
|
126
|
+
export type WorkhubEventName = WorkhubHostEventName | `app:${string}`;
|
|
127
|
+
export type WorkhubEventPayload = WorkhubHostEventPayload & WorkhubAppEventPayload;
|
|
128
|
+
/** Typed error rethrown from RPC failures and backend.fetch() error envelopes.
|
|
129
|
+
* `code` matches the backend SDK's `AppError.code`, so apps can branch on
|
|
130
|
+
* string codes rather than http status. */
|
|
131
|
+
export declare class WorkhubError extends Error {
|
|
132
|
+
readonly code: string;
|
|
133
|
+
readonly status: number | undefined;
|
|
134
|
+
readonly requestId: string | undefined;
|
|
135
|
+
readonly details: Record<string, unknown> | undefined;
|
|
136
|
+
constructor(code: string, message: string, extras?: {
|
|
137
|
+
status?: number;
|
|
138
|
+
requestId?: string;
|
|
139
|
+
details?: Record<string, unknown>;
|
|
140
|
+
});
|
|
59
141
|
}
|
|
60
142
|
export declare const workhub: {
|
|
61
143
|
/** Identity — read-only user / tenant / installation context. */
|
|
62
144
|
readonly identity: {
|
|
63
145
|
readonly current: () => Promise<Identity>;
|
|
64
146
|
};
|
|
147
|
+
/** Session. `logout()` ends the WorkHub session by navigating the HOST (top)
|
|
148
|
+
* window to the platform sign-out — the only exit for full-page / app-only
|
|
149
|
+
* users who have no portal chrome. Fire-and-forget: the page unloads, so
|
|
150
|
+
* there's no result to await. */
|
|
151
|
+
readonly auth: {
|
|
152
|
+
readonly logout: () => void;
|
|
153
|
+
};
|
|
154
|
+
/** Locale propagation — read the portal's active locale so the app can
|
|
155
|
+
* match it. Apps SHOULD also subscribe to `locale:change` to update
|
|
156
|
+
* live when the user switches languages. */
|
|
157
|
+
readonly locale: {
|
|
158
|
+
readonly current: () => Promise<LocaleInfo>;
|
|
159
|
+
};
|
|
160
|
+
/** Organization units (sub-tenants / companies). `units()` lists what the
|
|
161
|
+
* user may access plus the active one; pair with `events.on('org-unit:switch')`
|
|
162
|
+
* to keep an in-app company switcher in sync with the portal. The active
|
|
163
|
+
* unit is also a signed claim on the backend (`ctx.org`), so authorization
|
|
164
|
+
* rides the identity token — the frontend value is for display + switching.
|
|
165
|
+
* Resolves BMS ERP request #22 (frontend half). */
|
|
166
|
+
readonly org: {
|
|
167
|
+
/** List accessible units + the active one. */
|
|
168
|
+
readonly units: () => Promise<OrgContext>;
|
|
169
|
+
/** Ask the portal to switch the active unit. The portal validates access,
|
|
170
|
+
* re-issues the identity token, and emits `org-unit:switch`. */
|
|
171
|
+
readonly switch: (unitId: string) => Promise<void>;
|
|
172
|
+
};
|
|
173
|
+
/** Theme tokens — the portal pushes light/dark mode + the active tenant's
|
|
174
|
+
* brand palette so embedded apps repaint without each maintaining their
|
|
175
|
+
* own theme store. Combine with `events.on('theme:change')` for live
|
|
176
|
+
* updates when the user toggles dark mode. */
|
|
177
|
+
readonly theme: {
|
|
178
|
+
readonly current: () => Promise<ThemeTokens>;
|
|
179
|
+
};
|
|
180
|
+
/** Object / file storage (bytes) — distinct from `storage` (small JSON KV).
|
|
181
|
+
* Backed by the workspace's platform-provisioned bucket; the host bridge
|
|
182
|
+
* mints presigned URLs so the browser uploads/downloads directly without
|
|
183
|
+
* routing bytes through the portal. Persist the returned `key` (e.g. on a
|
|
184
|
+
* row) and resolve it back to a URL with `getUrl(key)` when rendering.
|
|
185
|
+
* Resolves BMS ERP request #25 (frontend half). */
|
|
186
|
+
readonly files: {
|
|
187
|
+
/** Get a presigned PUT URL + the stable key to persist. Upload the file
|
|
188
|
+
* yourself with a plain `fetch(url, { method: 'PUT', body: file })`. */
|
|
189
|
+
readonly presignUpload: (opts: {
|
|
190
|
+
filename: string;
|
|
191
|
+
contentType?: string;
|
|
192
|
+
keyHint?: string;
|
|
193
|
+
}) => Promise<{
|
|
194
|
+
url: string;
|
|
195
|
+
key: string;
|
|
196
|
+
headers?: Record<string, string>;
|
|
197
|
+
}>;
|
|
198
|
+
/** Convenience: presign + PUT in one call. Returns the stable key. */
|
|
199
|
+
readonly upload: (file: Blob, opts?: {
|
|
200
|
+
filename?: string;
|
|
201
|
+
keyHint?: string;
|
|
202
|
+
}) => Promise<{
|
|
203
|
+
key: string;
|
|
204
|
+
}>;
|
|
205
|
+
/** Presigned, time-limited GET URL for a stored key. */
|
|
206
|
+
readonly getUrl: (key: string, opts?: {
|
|
207
|
+
expiresInSeconds?: number;
|
|
208
|
+
}) => Promise<{
|
|
209
|
+
url: string;
|
|
210
|
+
}>;
|
|
211
|
+
readonly delete: (key: string) => Promise<void>;
|
|
212
|
+
readonly list: (prefix?: string) => Promise<Array<{
|
|
213
|
+
key: string;
|
|
214
|
+
size: number;
|
|
215
|
+
lastModified: string | null;
|
|
216
|
+
}>>;
|
|
217
|
+
};
|
|
65
218
|
/** KV storage scoped to (tenant, installation). Survives reloads but
|
|
66
219
|
* not uninstall. ttlSeconds is optional — omit for permanent. */
|
|
67
220
|
readonly storage: {
|
|
@@ -79,8 +232,17 @@ export declare const workhub: {
|
|
|
79
232
|
variant?: "info" | "success" | "warning" | "danger";
|
|
80
233
|
}) => Promise<void>;
|
|
81
234
|
};
|
|
82
|
-
/** Pub/sub
|
|
83
|
-
*
|
|
235
|
+
/** Pub/sub.
|
|
236
|
+
*
|
|
237
|
+
* Host events (`theme:change`, `route:change`, `tenant:switch`,
|
|
238
|
+
* `locale:change`) come from the portal directly.
|
|
239
|
+
*
|
|
240
|
+
* App events (`app:<kind>`) are emitted by the workspace backend via
|
|
241
|
+
* `ctx.events.emit('customer.created', ...)`; the platform's tenant-event
|
|
242
|
+
* bridge forwards them to subscribed iframes. Replaces the polling shim
|
|
243
|
+
* documented in BMS ERP request #1.
|
|
244
|
+
*
|
|
245
|
+
* Returns an unsubscribe function. */
|
|
84
246
|
readonly events: {
|
|
85
247
|
readonly on: {
|
|
86
248
|
<T extends WorkhubEventName>(name: T, cb: (payload: WorkhubEventPayload[T]) => void): () => void;
|
|
@@ -108,6 +270,18 @@ export declare const workhub: {
|
|
|
108
270
|
* required. The host SDK builds the full URL `/v1/apps/runtime/<installationId>/api<path>`
|
|
109
271
|
* and uses same-origin credentialed fetch — no extra auth wiring. */
|
|
110
272
|
readonly fetch: (path: string, init?: RequestInit) => Promise<Response>;
|
|
273
|
+
/** Convenience wrapper — JSON in, JSON out, with automatic error
|
|
274
|
+
* envelope detection. A 2xx response is parsed and returned. A 4xx/5xx
|
|
275
|
+
* carrying a `__workhub_error: true` payload is rethrown as
|
|
276
|
+
* `WorkhubError` so apps can `try/catch` by code instead of inspecting
|
|
277
|
+
* the response object. Pass `idempotencyKey` to deduplicate retries on
|
|
278
|
+
* idempotent backend routes. */
|
|
279
|
+
readonly call: <T = unknown>(path: string, opts?: {
|
|
280
|
+
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
|
281
|
+
body?: unknown;
|
|
282
|
+
headers?: Record<string, string>;
|
|
283
|
+
idempotencyKey?: string;
|
|
284
|
+
}) => Promise<T>;
|
|
111
285
|
};
|
|
112
286
|
};
|
|
113
287
|
export default workhub;
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|