@fepro/workhub-app-sdk 0.1.0 → 0.1.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.
Files changed (3) hide show
  1. package/AGENTS.md +318 -0
  2. package/README.md +22 -0
  3. package/package.json +3 -2
package/AGENTS.md ADDED
@@ -0,0 +1,318 @@
1
+ # AGENTS.md — building WorkHub apps with AI assistance
2
+
3
+ > Read this once, generate working apps in one shot. This file is optimised
4
+ > for AI coding agents (Claude, GPT, Aider, Cursor, etc.). Humans prefer the
5
+ > [README.md](./README.md). Both describe the same SDK; this one is denser,
6
+ > includes complete worked examples, and lists the gotchas that bite agents.
7
+
8
+ ## What you are building
9
+
10
+ A **WorkHub app** is a `.zip` archive containing a static SPA + a manifest.
11
+ The host (WorkHub portal) renders it as a sandboxed iframe inside the
12
+ tenant's `/apps/<slug>` route. Apps cannot call the WorkHub API directly —
13
+ every cross-origin call goes through `@fepro/workhub-app-sdk`, which sends
14
+ typed `postMessage` RPCs to the host bridge. The bridge enforces the
15
+ manifest's declared scopes and forwards approved calls to `/v1/*`.
16
+
17
+ ## One-shot mental model
18
+
19
+ ```
20
+ zip post bridge api
21
+ ───── ──── ────── ───
22
+ index.html ◄── iframe ── │ ◄── postMessage ── workhub.identity.current()
23
+ workhub.json │ │ ▼
24
+ assets/... │ └─── HTTP ───► /v1/apps/bridge/context
25
+ ```
26
+
27
+ The agent's job: produce `index.html` (or `dist/`) + `workhub.json`, zip them, upload.
28
+
29
+ ## Install
30
+
31
+ The platform serves the SDK from the portal origin at **`/sdk.js`** — uploaded
32
+ apps can load it directly without any build step or external CDN. This is the
33
+ recommended path for hand-written and AI-generated bundles:
34
+
35
+ ```html
36
+ <script type="module">
37
+ import { workhub } from '/sdk.js';
38
+ </script>
39
+ ```
40
+
41
+ For TypeScript projects with a build step:
42
+
43
+ ```bash
44
+ npm install @fepro/workhub-app-sdk
45
+ ```
46
+
47
+ ```ts
48
+ import { workhub } from '@fepro/workhub-app-sdk';
49
+ ```
50
+
51
+ > Do **not** use cross-origin CDNs (esm.sh, jsdelivr, unpkg, etc.). Uploaded
52
+ > apps run inside a sandboxed iframe whose CSP is `default-src 'self'`, so
53
+ > external script loads are blocked. `/sdk.js` is same-origin and works.
54
+
55
+ ## Manifest spec — `workhub.json` (zip root)
56
+
57
+ ```json
58
+ {
59
+ "key": "lower-kebab-id",
60
+ "name": "Display Name",
61
+ "version": "1.0.0",
62
+ "description": "One-line summary",
63
+ "entry": "index.html",
64
+ "icon": "assets/icon.svg",
65
+ "scopes": [
66
+ "identity:read",
67
+ "storage:read",
68
+ "storage:write",
69
+ "notifications:send",
70
+ "events:listen",
71
+ "api:proxy"
72
+ ]
73
+ }
74
+ ```
75
+
76
+ Rules:
77
+ - `key` must match `^[a-z0-9][a-z0-9-]*[a-z0-9]$` (2–80 chars). Stable identifier — re-uploads with the same key upgrade in place.
78
+ - `entry` defaults to `index.html`. Path is relative to the zip root.
79
+ - `scopes` is exact match — only declare what you actually call. Bridge rejects calls whose required scope is missing with `ERR_SCOPE_DENIED`.
80
+
81
+ ## SDK surface — every method, every type
82
+
83
+ ```ts
84
+ // Identity ---------------------------------------------------
85
+ workhub.identity.current(): Promise<{
86
+ user: { id: string; displayName: string | null; email: string | null } | null;
87
+ tenant: { id: string; name: string } | null;
88
+ installation: { id: string; slug: string; displayName: string };
89
+ manifest: unknown; // your workhub.json snapshot
90
+ scopes: string[];
91
+ }>;
92
+
93
+ // Storage — KV scoped to (tenant, installation) -------------
94
+ workhub.storage.get<T = unknown>(key: string): Promise<T | null>;
95
+ workhub.storage.set<T>(key: string, value: T, opts?: { ttlSeconds?: number }): Promise<void>;
96
+ workhub.storage.delete(key: string): Promise<void>;
97
+ workhub.storage.list(prefix?: string): Promise<Array<{ key: string; value: unknown; updatedAt: string }>>;
98
+
99
+ // Notifications ---------------------------------------------
100
+ workhub.notifications.toast(
101
+ message: string,
102
+ opts?: { variant?: 'info' | 'success' | 'warning' | 'danger' },
103
+ ): Promise<void>;
104
+
105
+ // Events — host 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
108
+
109
+ // Generic API escape hatch ----------------------------------
110
+ workhub.api.call<T>(verb: string, body?: unknown): Promise<T>;
111
+ // verb format: "METHOD /v1/path" e.g. "GET /v1/databases"
112
+ ```
113
+
114
+ ## Scope → method matrix
115
+
116
+ | scope | unlocks |
117
+ | -------------------- | ---------------------------------------- |
118
+ | `identity:read` | `identity.current()` |
119
+ | `storage:read` | `storage.get`, `storage.list` |
120
+ | `storage:write` | `storage.set`, `storage.delete` |
121
+ | `notifications:send` | `notifications.toast` |
122
+ | `events:listen` | `events.on(...)` |
123
+ | `api:proxy` | `api.call(verb, body)` (still gated on the user's existing /v1 permissions) |
124
+
125
+ ## Worked examples
126
+
127
+ ### Example 1 — Minimal hello app (vanilla, zero deps)
128
+
129
+ `workhub.json`:
130
+ ```json
131
+ {
132
+ "key": "hello",
133
+ "name": "Hello App",
134
+ "version": "1.0.0",
135
+ "scopes": ["identity:read", "notifications:send"]
136
+ }
137
+ ```
138
+
139
+ `index.html`:
140
+ ```html
141
+ <!doctype html>
142
+ <html><body>
143
+ <h1 id="greeting">Loading…</h1>
144
+ <button id="ping">Toast</button>
145
+ <script type="module">
146
+ import { workhub } from '/sdk.js';
147
+ const me = await workhub.identity.current();
148
+ greeting.textContent = `Hi ${me.user?.displayName ?? 'there'} (${me.tenant?.name})`;
149
+ ping.onclick = () => workhub.notifications.toast('Hello from your app!', { variant: 'success' });
150
+ </script>
151
+ </body></html>
152
+ ```
153
+
154
+ ### Example 2 — Persistent settings (TS + Vite)
155
+
156
+ ```ts
157
+ import { workhub } from '@fepro/workhub-app-sdk';
158
+
159
+ interface Settings { darkMode: boolean; pageSize: number }
160
+
161
+ async function loadSettings(): Promise<Settings> {
162
+ const stored = await workhub.storage.get<Settings>('settings');
163
+ return stored ?? { darkMode: false, pageSize: 25 };
164
+ }
165
+
166
+ async function saveSettings(s: Settings): Promise<void> {
167
+ await workhub.storage.set('settings', s);
168
+ await workhub.notifications.toast('Settings saved', { variant: 'success' });
169
+ }
170
+
171
+ // React to host theme changes so the app follows the portal's light/dark.
172
+ workhub.events.on('theme:change', (p: any) => {
173
+ document.documentElement.dataset.theme = p.mode;
174
+ });
175
+ ```
176
+
177
+ Manifest scopes: `["identity:read","storage:read","storage:write","notifications:send","events:listen"]`
178
+
179
+ ### Example 3 — App that lists the tenant's databases
180
+
181
+ ```ts
182
+ import { workhub } from '@fepro/workhub-app-sdk';
183
+
184
+ interface DatabaseInstance { id: string; name: string; engine: string; status: string }
185
+
186
+ async function listDatabases(): Promise<DatabaseInstance[]> {
187
+ const res = await workhub.api.call<{ items: DatabaseInstance[] }>('GET /v1/databases');
188
+ return res.items;
189
+ }
190
+
191
+ const dbs = await listDatabases();
192
+ document.body.innerHTML = `<ul>${dbs.map((d) => `<li>${d.name} (${d.engine}) — ${d.status}</li>`).join('')}</ul>`;
193
+ ```
194
+
195
+ Manifest scopes: `["api:proxy"]` — note the user must also hold `database.read` for the call to succeed; the bridge does not elevate.
196
+
197
+ ## Build + upload commands
198
+
199
+ ```bash
200
+ # 1. Build your SPA into ./dist (or the directory containing index.html + assets)
201
+ npm run build
202
+
203
+ # 2. Copy the manifest in next to the built files
204
+ cp workhub.json dist/
205
+
206
+ # 3. Zip from INSIDE dist/ — the manifest must be at zip root, not nested.
207
+ cd dist && zip -r ../my-app.zip . && cd ..
208
+
209
+ # 4. Upload via the portal
210
+ open https://your-portal/apps/development
211
+
212
+ # Or via the API
213
+ curl -X POST https://your-portal-api/v1/apps/dev/upload \
214
+ -H "Authorization: Bearer $TOKEN" \
215
+ -F bundle=@my-app.zip
216
+ ```
217
+
218
+ ## Security model — what the iframe can and cannot do
219
+
220
+ WorkHub apps run in a **fully sandboxed iframe** with `sandbox="allow-scripts allow-forms"`. That means:
221
+
222
+ - **No DOM access to the parent portal** — the iframe gets an opaque origin and cannot reach into `parent.document` or strip its own sandbox.
223
+ - **No cross-origin `fetch`** — `connect-src 'self'` in the iframe's CSP blocks calls to anything other than the runtime host. Use `workhub.api.call(...)` to reach `/v1/*` instead.
224
+ - **No external script CDNs** — `default-src 'self'` blocks loading scripts/styles from esm.sh, jsdelivr, unpkg, etc. Either bundle dependencies or import them via the platform-served `/sdk.js`.
225
+ - **`localStorage` / `IndexedDB` work** — but they're partitioned per-frame because of the opaque origin. State persists across reloads of the same installation; cross-app sharing isn't possible (use the SDK's `storage.*` for shared, server-backed KV).
226
+ - **No cookies inside the iframe** — for the same reason. Anything that needs auth goes through the SDK's `api.call(...)`, where the host parent attaches the user's session.
227
+
228
+ This is a **production security boundary**. A malicious bundle uploaded by tenant A cannot read tenant B's portal data, manipulate the parent UI, or call /v1 endpoints on the user's behalf without going through the scope-enforcing SDK bridge.
229
+
230
+ ## Gotchas (the things that bite agents)
231
+
232
+ 1. **Manifest at zip root, not nested in a folder.** `unzip -l` should show `workhub.json` directly, not `dist/workhub.json` or `my-app/workhub.json`. The upload endpoint reads the manifest from the root entry.
233
+ 2. **Don't ship `node_modules` or build configs.** Bundle the compiled output only — the upload limit is 25 MB compressed.
234
+ 3. **`postMessage` requires the iframe to be embedded.** Calling SDK methods locally in a browser tab outside the portal returns `not_embedded`. There's no offline mock in v1 — develop by uploading and iterating, or stub the SDK methods locally with mocks.
235
+ 4. **Scope enforcement is exact.** Calling `storage.set` without `storage:write` rejects with `{ code: 'ERR_SCOPE_DENIED' }` — the call never leaves the iframe.
236
+ 5. **`api.call('GET /v1/...')`** verb format is `"METHOD path"` with a single space. Lowercase methods are accepted but normalised.
237
+ 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
+ 7. **`storage.list(prefix)`** uses `LIKE 'prefix%'` matching — pick a prefix scheme that doesn't collide.
239
+ 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).
241
+ 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
+ 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
+
244
+ ## Error catalogue
245
+
246
+ | code | meaning | fix |
247
+ | ------------------ | ----------------------------------------------------- | ----------------------------------------- |
248
+ | `not_embedded` | SDK called outside an iframe | run inside the portal |
249
+ | `ERR_SCOPE_DENIED` | manifest doesn't declare the required scope | add the scope to `workhub.json` + re-upload |
250
+ | `rpc_timeout` | host bridge didn't respond in 10s | check network / portal logs |
251
+ | `unknown_verb` | SDK method not implemented in the bridge | likely an SDK/host version mismatch |
252
+ | `bridge_400/4xx` | API rejected the underlying call | inspect `error.message` for details |
253
+ | `http_403` | `api.call` succeeded but the user lacks `/v1` perms | tenant grants more permissions to the user |
254
+
255
+ ## Starter template (copy verbatim)
256
+
257
+ ```
258
+ my-app/
259
+ ├── workhub.json
260
+ ├── index.html
261
+ └── assets/
262
+ └── icon.svg
263
+ ```
264
+
265
+ `workhub.json`:
266
+ ```json
267
+ {
268
+ "key": "my-app",
269
+ "name": "My App",
270
+ "version": "0.1.0",
271
+ "entry": "index.html",
272
+ "icon": "assets/icon.svg",
273
+ "scopes": ["identity:read", "storage:read", "storage:write", "notifications:send", "events:listen"]
274
+ }
275
+ ```
276
+
277
+ `index.html`:
278
+ ```html
279
+ <!doctype html>
280
+ <html lang="en">
281
+ <head>
282
+ <meta charset="utf-8" />
283
+ <title>My App</title>
284
+ <style>
285
+ :root { color-scheme: light dark; font-family: system-ui, sans-serif; }
286
+ body { margin: 0; padding: 24px; max-width: 720px; }
287
+ [data-theme="dark"] body { background: #0e1116; color: #e6e6e6; }
288
+ button { padding: 8px 14px; border-radius: 6px; cursor: pointer; }
289
+ </style>
290
+ </head>
291
+ <body>
292
+ <h1 id="title">Loading…</h1>
293
+ <p><input id="note" placeholder="type something" /> <button id="save">Save</button></p>
294
+ <pre id="out"></pre>
295
+ <script type="module">
296
+ import { workhub } from '/sdk.js';
297
+ const me = await workhub.identity.current();
298
+ title.textContent = `Hi ${me.user?.displayName ?? 'tenant'}`;
299
+ const last = await workhub.storage.get('note');
300
+ if (last) note.value = last;
301
+ save.onclick = async () => {
302
+ await workhub.storage.set('note', note.value);
303
+ out.textContent = JSON.stringify(await workhub.storage.list(), null, 2);
304
+ await workhub.notifications.toast('Saved', { variant: 'success' });
305
+ };
306
+ workhub.events.on('theme:change', (p) => document.documentElement.dataset.theme = p.mode);
307
+ </script>
308
+ </body>
309
+ </html>
310
+ ```
311
+
312
+ Zip + upload:
313
+ ```bash
314
+ zip -r my-app.zip workhub.json index.html assets/
315
+ # then upload via /apps/development → Upload bundle
316
+ ```
317
+
318
+ That's a complete, working WorkHub app. Modify and ship.
package/README.md CHANGED
@@ -2,8 +2,25 @@
2
2
 
3
3
  Client SDK for apps embedded inside the WorkHub portal.
4
4
 
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))
7
+ > for a dense, machine-friendly reference with the full SDK surface, three
8
+ > worked examples, the gotcha catalogue, and a copy-paste starter template.
9
+
5
10
  ## Install
6
11
 
12
+ The platform serves the compiled SDK at **`/sdk.js`** on the portal origin —
13
+ uploaded apps can import it without any build step or external CDN. This is
14
+ the recommended path for hand-written and AI-generated bundles:
15
+
16
+ ```html
17
+ <script type="module">
18
+ import { workhub } from '/sdk.js';
19
+ </script>
20
+ ```
21
+
22
+ For TypeScript projects with a build step:
23
+
7
24
  ```bash
8
25
  npm install @fepro/workhub-app-sdk
9
26
  # or
@@ -12,6 +29,11 @@ pnpm add @fepro/workhub-app-sdk
12
29
  yarn add @fepro/workhub-app-sdk
13
30
  ```
14
31
 
32
+ > **Do not use cross-origin CDNs** (esm.sh, jsdelivr, unpkg, etc.). Uploaded
33
+ > apps run inside a fully sandboxed iframe whose CSP is `default-src 'self'`,
34
+ > which blocks external script loads. `/sdk.js` is the same-origin platform
35
+ > SDK and is the only supported way to load it without bundling.
36
+
15
37
  ## The bundle format
16
38
 
17
39
  A WorkHub developer-app is a `.zip` archive with this layout:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fepro/workhub-app-sdk",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
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
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -17,7 +17,8 @@
17
17
  "files": [
18
18
  "dist",
19
19
  "src",
20
- "README.md"
20
+ "README.md",
21
+ "AGENTS.md"
21
22
  ],
22
23
  "scripts": {
23
24
  "build": "tsc -p tsconfig.json",