@fepro/workhub-app-sdk 0.1.1 → 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 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
- // Eventshost pushes these as the user navigates / themes -
106
- type Names = 'theme:change' | 'route:change' | 'tenant:switch';
107
- workhub.events.on(name: Names, cb: (payload: unknown) => void): () => void; // returns unsubscribe
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'`** by default, meaning `fetch()` to third-party origins from inside the iframe is blocked. Use `workhub.api.call(...)` to reach `/v1/*`. For external HTTP, the host would need a `proxy:fetch` scope (not in v1).
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://workhub.dev/sdk/agents.md](https://workhub.dev/sdk/agents.md))
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', ({ mode }) => document.body.dataset.theme = mode);
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
- export type WorkhubEventName = 'theme:change' | 'route:change' | 'tenant:switch';
49
- export interface WorkhubEventPayload {
50
- 'theme:change': {
51
- mode: 'light' | 'dark';
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 for host → app events. The host emits when relevant
83
- * (theme switch, route change, tenant switch). */
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;
@@ -93,6 +255,34 @@ export declare const workhub: {
93
255
  readonly api: {
94
256
  readonly call: <T = unknown>(verb: string, body?: unknown) => Promise<T>;
95
257
  };
258
+ /** Calls the developer's own workspace backend, proxied through the
259
+ * WorkHub runtime so requests carry a verifiable identity token (the
260
+ * backend SDK verifies it against /.well-known/jwks.json).
261
+ *
262
+ * Only available to apps installed under a developer workspace — for
263
+ * standalone-iframe apps this method throws because the runtime has
264
+ * no backend deployment to forward to. */
265
+ readonly backend: {
266
+ /** Issue a request against the workspace backend.
267
+ *
268
+ * `path` is the route path declared in the backend's defineWorkspace
269
+ * routes table (e.g. `/leads` or `/leads/123/notes`). Leading slash
270
+ * required. The host SDK builds the full URL `/v1/apps/runtime/<installationId>/api<path>`
271
+ * and uses same-origin credentialed fetch — no extra auth wiring. */
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>;
285
+ };
96
286
  };
97
287
  export default workhub;
98
288
  //# sourceMappingURL=index.d.ts.map
@@ -1 +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"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}