@ekanos/sdk 0.1.2 → 0.1.3

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 (53) hide show
  1. package/README.md +1093 -140
  2. package/api-report.md +202 -0
  3. package/dist/components/index.d.ts +3 -2
  4. package/dist/components/index.js +3 -2
  5. package/dist/components/index.js.map +1 -1
  6. package/dist/components/widgets/widget-context.js.map +1 -1
  7. package/dist/context/index.d.ts +1 -1
  8. package/dist/context/index.js +1 -1
  9. package/dist/context/index.js.map +1 -1
  10. package/dist/context/types.d.ts +1 -1
  11. package/dist/context/types.js.map +1 -1
  12. package/dist/hooks/activation-actions-context.d.ts +4 -4
  13. package/dist/hooks/activation-actions-context.js +1 -1
  14. package/dist/hooks/activation-actions-context.js.map +1 -1
  15. package/dist/hooks/index.d.ts +2 -2
  16. package/dist/hooks/index.js +2 -2
  17. package/dist/hooks/index.js.map +1 -1
  18. package/dist/hooks/use-oauth-connection-status.d.ts +18 -72
  19. package/dist/hooks/use-oauth-connection-status.js +1 -1
  20. package/dist/hooks/use-oauth-connection-status.js.map +1 -1
  21. package/dist/index.d.ts +3 -2
  22. package/dist/index.js +3 -2
  23. package/dist/index.js.map +1 -1
  24. package/dist/integration/define-integration.d.ts +2 -3
  25. package/dist/integration/define-integration.js +2 -3
  26. package/dist/integration/define-integration.js.map +1 -1
  27. package/dist/integration/index.d.ts +2 -2
  28. package/dist/integration/index.js +2 -2
  29. package/dist/integration/index.js.map +1 -1
  30. package/dist/testing/index.d.ts +1 -1
  31. package/dist/testing/index.js +1 -1
  32. package/dist/testing/index.js.map +1 -1
  33. package/dist/types/index.d.ts +3 -4
  34. package/dist/types/index.js +3 -4
  35. package/dist/types/index.js.map +1 -1
  36. package/dist/types/integration.d.ts +2 -2
  37. package/dist/types/integration.js.map +1 -1
  38. package/dist/types/widget-ask-context.d.ts +1 -1
  39. package/dist/types/widget-ask-context.js.map +1 -1
  40. package/eslint.preset.mjs +2 -4
  41. package/package.json +14 -16
  42. package/dist/mcp/guards.d.ts +0 -40
  43. package/dist/mcp/guards.js +0 -99
  44. package/dist/mcp/guards.js.map +0 -1
  45. package/dist/mcp/index.d.ts +0 -22
  46. package/dist/mcp/index.js +0 -22
  47. package/dist/mcp/index.js.map +0 -1
  48. package/dist/mcp/ownership.d.ts +0 -74
  49. package/dist/mcp/ownership.js +0 -83
  50. package/dist/mcp/ownership.js.map +0 -1
  51. package/dist/mcp/types.d.ts +0 -145
  52. package/dist/mcp/types.js +0 -2
  53. package/dist/mcp/types.js.map +0 -1
package/README.md CHANGED
@@ -1,6 +1,42 @@
1
1
  # @ekanos/sdk
2
2
 
3
- The official SDK for building Ekanos integrations.
3
+ The official SDK for building Ekanos integrations — the packages that add
4
+ widgets, assistant tools, and event handlers to a Fusion dashboard.
5
+
6
+ **Start here.** This README is the complete authoring reference: everything you
7
+ can declare, with a worked example for each surface. There is no separate docs
8
+ site — if something is missing here it is missing, and `npm@govastly.com` is
9
+ where to say so.
10
+
11
+ The other packages, so you know when to leave this page: **`@ekanos/cli`** is
12
+ the toolchain (`init`, `validate`, `dev`, `test`) and owns `ekanos.json`;
13
+ **`@ekanos/harness`** is the local dev harness that renders your surfaces in
14
+ real Fusion chrome; **`@ekanos/ui`** is the component library; and
15
+ **`@ekanos/integration-schema`** is the validation contract underneath this
16
+ one. Each has its own README.
17
+
18
+ - [Install](#install)
19
+ - [The shape of an integration](#the-shape-of-an-integration)
20
+ - [The capability context (`ctx`)](#the-capability-context-ctx)
21
+ - **Declarable surfaces**
22
+ - [`storage` — schema-validated per-account state](#storage)
23
+ - [`egress` — the outbound allowlist](#egress)
24
+ - [Secrets](#secrets)
25
+ - [`tools` — assistant / MCP tools](#tools)
26
+ - [`components.widgets` — dashboard widgets](#componentswidgets)
27
+ - [`components.marketplaceTile`](#componentsmarketplacetile)
28
+ - [`components.activationForm`](#componentsactivationform)
29
+ - [`capabilities` and `permissions`](#capabilities-and-permissions)
30
+ - [`webhooks`](#webhooks)
31
+ - [`schedules`](#schedules)
32
+ - [`oauth`](#oauth)
33
+ - [`proposes` — what you suggest, the host decides](#proposes)
34
+ - [`workspaceTargets`](#workspacetargets)
35
+ - [Testing](#testing)
36
+ - [Entrypoints](#entrypoints)
37
+ - [Naming and validation rules, in one table](#naming-and-validation-rules-in-one-table)
38
+ - [Rough edges we know about](#rough-edges-we-know-about)
39
+ - [Publish pipeline](#publish-pipeline)
4
40
 
5
41
  ## Install
6
42
 
@@ -9,23 +45,182 @@ npm install @ekanos/sdk
9
45
  ```
10
46
 
11
47
  `@ekanos/integration-schema` and `@ekanos/ui` come along as dependencies. You
12
- provide the peers: `react` (^19.2.8), `zod` (^3.25.76), andif you render
13
- widgets or an activation form `@tanstack/react-query` (^5.101.4),
14
- `react-hook-form` (^7.68.0), `@hookform/resolvers` (^5.2.2), and `next`
15
- (^16.0.0).
48
+ provide six peers, and **all six are declared as required** none is marked
49
+ optional, so your package manager expects every one of them even if your
50
+ integration only ships tools:
51
+
52
+ | Peer | Version | What actually uses it |
53
+ |---|---|---|
54
+ | `react` | ^19.2.8 | everything client-side |
55
+ | `zod` | ^3.25.76 | storage schemas, webhook payload schemas, the definition parse |
56
+ | `@tanstack/react-query` | ^5.101.4 | widget data hooks, and the SDK's own `useActivateIntegration` / `useOAuthConnectionStatus` |
57
+ | `react-hook-form` | ^7.68.0 | activation forms |
58
+ | `@hookform/resolvers` | ^5.2.2 | zod validation in activation forms |
59
+ | `next` | ^16.0.0 | the host's routing primitives the components reach for |
60
+
61
+ The right-hand column is why each is there, not permission to skip it: leave
62
+ one out and you get peer warnings on install regardless of what you render.
63
+
64
+ **Why peers and not dependencies:** every one of these creates React context,
65
+ and two copies of a context-creating module is a bug that does not announce
66
+ itself. A provider renders against instance A while a consumer three files away
67
+ reads instance B, finds nothing, and silently falls back to a default. As
68
+ peers, your package manager gives everyone one copy; as dependencies, npm is
69
+ free to give you two.
70
+
71
+ This is not hypothetical, and it does not require two installs to happen —
72
+ bundling reaches it just as easily. `@ekanos/ui` shipped a version where five
73
+ built entries each inlined their own copy of the icon module, and so each got
74
+ its own `createContext()`. A consumer's `IconStyleOverrideProvider` set one
75
+ instance; the chevron inside the select entry read another, found no provider,
76
+ and fell back to a Font Awesome Pro weight that Free defines no glyph for.
77
+ Three of five icons rendered as `□`. The monorepo could not reproduce it at all
78
+ — it resolves from source, where one module means one context — so it was only
79
+ visible from an installed tarball. If you bundle `@ekanos/*` into your own
80
+ output, keep these singletons.
81
+
82
+ ### What is published, and what is not
83
+
84
+ Being straight about this up front, because the alternative is you finding out
85
+ by running a command that 404s:
86
+
87
+ | Package | On npm |
88
+ |---|---|
89
+ | `@ekanos/sdk`, `@ekanos/ui`, `@ekanos/integration-schema` | **yes** — `0.1.2` |
90
+ | `@ekanos/cli`, `@ekanos/harness` | **not yet** |
91
+
92
+ So `npx @ekanos/cli init` and `pnpm add -D @ekanos/harness` do not resolve
93
+ today. The toolchain and the local dev harness are written and documented —
94
+ their READMEs are in this repo and describe what will ship — but you cannot
95
+ install them yet.
96
+
97
+ **What that costs you is seeing, not building.** Everything in this document
98
+ works against `@ekanos/sdk` alone:
99
+
100
+ ```bash
101
+ npm install @ekanos/sdk zod
102
+ ```
103
+
104
+ - `defineIntegration()` validates at import time and throws with a remediation
105
+ message, so your definition is its own feedback loop — you do not need
106
+ `ekanos validate` to know whether it is correct.
107
+ - The whole [Testing](#testing) section needs nothing but vitest.
108
+ `createMockContext()` gives you the same context production builds, and
109
+ `invokeWebhook()` / `invokeSchedule()` run your event handlers with the same
110
+ payload validation and egress allowlist the real transports will.
16
111
 
17
- ## Quick start
112
+ **What you genuinely cannot do yet** is see any of it rendered — widgets in the
113
+ dashboard grid, the marketplace tile, the activation form, the Triggers panel.
114
+ Those are the harness, and the harness is not installable. Webhook, schedule
115
+ and OAuth work collapses back to unit tests, which is a real loss but a
116
+ recoverable one. Widget *appearance* does not: you will be writing it blind
117
+ until the harness ships.
18
118
 
19
- An integration is one call to `defineIntegration()`. It validates at import
20
- time and hands back a deep-frozen definition; the host re-parses the same
21
- object against the same schema when it registers you.
119
+ One hard stop worth naming: `ekanos.json`, the project file the CLI reads. Its
120
+ schema and the full generated-shell file list are documented in the
121
+ `@ekanos/cli` README — which ships inside the CLI tarball, so today you can
122
+ only read it if you have this repo. Nothing in this document needs that file;
123
+ if you are hand-rolling something that does, wait for the CLI.
124
+
125
+ ## The shape of an integration
126
+
127
+ An integration is one call to `defineIntegration()`. It validates at import time
128
+ and hands back a deep-frozen definition; the host re-parses the same object
129
+ against the same schema when it registers you, so a definition that loads
130
+ locally is one the platform accepts. There is no second, looser contract.
22
131
 
23
132
  ```ts
24
- // integration.ts
133
+ // src/integration.ts
25
134
  import { defineIntegration } from '@ekanos/sdk/integration';
26
- import { z } from 'zod';
27
135
 
28
- import { CurrentWeatherWidget } from './current-weather-widget';
136
+ export const integration = defineIntegration<MyStorage>({
137
+ // Identity — all four required.
138
+ slug: 'acme-payments', // kebab-case; the route segment and MCP namespace
139
+ name: 'Acme Payments',
140
+ description: 'Payment volume, recent activity, and refunds.',
141
+ version: '1.0.0', // semver; promotion diffs definitions by it
142
+
143
+ // Marketing copy for the detail page.
144
+ capabilities: [ /* … */ ],
145
+ permissions: [ /* … */ ],
146
+
147
+ // What renders.
148
+ components: { widgets: [], marketplaceTile, activationForm },
149
+
150
+ // What runs.
151
+ tools: [ /* … */ ],
152
+ webhooks: [ /* … */ ],
153
+ schedules: [ /* … */ ],
154
+ oauth: { /* … */ },
155
+
156
+ // What it may touch.
157
+ storage: { account: {}, user: {} },
158
+ egress: ['https://api.acme.example'],
159
+
160
+ // What you suggest and a reviewer confirms.
161
+ proposes: { /* … */ },
162
+ });
163
+ ```
164
+
165
+ Only `slug`, `name`, `description` and `version` are required. Every other key
166
+ is optional, and an integration that declares nothing but widgets is a perfectly
167
+ good integration.
168
+
169
+ **The export must be named `integration`.** Two things import it by that name:
170
+ the host's build, when your package is added to the platform's list of partner
171
+ integrations, and `ekanos.json` — the small file `@ekanos/cli` keeps at your
172
+ project root, holding your `slug` and the `entry` module this export lives in.
173
+ The `@ekanos/cli` README defines that file; this one assumes it.
174
+
175
+ **The schema is strict everywhere.** An unrecognized key is a hard error, not a
176
+ silently ignored one. That is deliberate: it is what makes host-assigned fields
177
+ structurally un-settable rather than merely absent from the type. You cannot
178
+ declare a `productId`, a `kind`, a trust tier, a `credentialModel`, or per-tool
179
+ `effect`/`sensitivity` — the closest you get is [`proposes`](#proposes). Widget
180
+ fields the platform populates at runtime (`productId`, `widgetConfigId`,
181
+ `workspaceId`, `collapsed`, `isPinned`, `health`, `integrationMetadata`) are
182
+ rejected the same way.
183
+
184
+ ## The capability context (`ctx`)
185
+
186
+ `ctx` is the entire platform surface a server-side handler gets — tools, webhook
187
+ handlers, schedule handlers and `oauth.onTokens` all receive the same object.
188
+ There is no raw database client, no Supabase, and no request object.
189
+
190
+ | Field | What it is |
191
+ |---|---|
192
+ | `ctx.storage.account` | `account_product_data` for this `{account, product}` — schema-validated |
193
+ | `ctx.storage.user` | `user_product_data` for this `{user, account, product}`. **Throws for machine actors** |
194
+ | `ctx.secrets` | `get(name)`, `names()`, `set(name, value)` over the resolved credential set |
195
+ | `ctx.fetch` | `fetch`, refusing anything outside `egress` before it opens a socket |
196
+ | `ctx.logger` | `debug`/`info`/`warn`/`error`, each `(context: object, message: string)` — pino-style, pre-bound with `{integration, accountId, actor}` |
197
+ | `ctx.actor` | `{ kind: 'user', userId }` or `{ kind: 'machine', tokenId, createdBy }` |
198
+
199
+ Plus read-only identity facts, all already authorized: `ctx.accountId`,
200
+ `ctx.accountSlug`, `ctx.userId` (`null` for machine actors), `ctx.sourceId`,
201
+ `ctx.integration` (`{ slug, productId }`) and `ctx.timezone`.
202
+
203
+ Everything on `ctx` is scoped before your code runs. There is no id parameter
204
+ anywhere for you to get wrong, and none for you to lie in.
205
+
206
+ The logger takes the context object **first**:
207
+
208
+ ```ts
209
+ ctx.logger.info({ paymentId, from, to }, 'Recorded a payment status change.');
210
+ ```
211
+
212
+ ## Declarable surfaces
213
+
214
+ ### `storage`
215
+
216
+ Per-account (and per-user) state, keyed by strings you declare up front. **Every
217
+ key declares a zod schema.** A key with no declaration cannot be read, written,
218
+ or deleted — that is a type error where the schema map is statically known, and
219
+ always a runtime `StorageValidationError`.
220
+
221
+ ```ts
222
+ import type { StorageSchemas } from '@ekanos/sdk';
223
+ import { z } from 'zod';
29
224
 
30
225
  const LocationSchema = z.object({
31
226
  label: z.string().min(1),
@@ -33,66 +228,252 @@ const LocationSchema = z.object({
33
228
  longitude: z.number(),
34
229
  });
35
230
 
36
- export const weather = defineIntegration({
37
- slug: 'acme-weather', // kebab-case; becomes the route + MCP namespace
38
- name: 'Acme Weather',
39
- description: 'Conditions for the places your team cares about.',
40
- version: '1.0.0', // semver; promotion diffs definitions by it
41
-
42
- // Origin-only https allowlist. `ctx.fetch` refuses anything else BEFORE it
43
- // opens a socket — redirect targets included.
44
- egress: ['https://api.open-meteo.com'],
45
-
46
- // Every storage key declares a zod schema. `clientReadable` (default false)
47
- // is what lets a widget read the key back through the host storage route.
48
- storage: {
49
- account: {
50
- 'settings/location': { schema: LocationSchema, clientReadable: true },
51
- 'cache/forecast': z.object({ fetchedAt: z.string() }),
52
- },
53
- },
231
+ export const myStorage = {
232
+ account: {
233
+ // Bare schema — server-only. This is the default and the common case.
234
+ 'cache/forecast': z.object({ fetchedAt: z.string(), body: z.unknown() }),
54
235
 
55
- components: {
56
- widgets: [
57
- {
58
- id: 'acme-weather-current',
59
- name: 'Current conditions',
60
- component: CurrentWeatherWidget,
61
- widgetState: 'active',
62
- },
63
- ],
236
+ // Descriptor form — the only way to widen exposure.
237
+ 'settings/location': { schema: LocationSchema, clientReadable: true },
238
+ },
239
+ user: {
240
+ // Reachable from server handlers only — see "Account scope only" below.
241
+ 'prefs/units': z.enum(['celsius', 'fahrenheit']),
64
242
  },
243
+ } satisfies StorageSchemas;
65
244
 
66
- tools: [
67
- {
68
- name: 'get_current_weather', // lowercase snake_case
69
- description: 'Current conditions for the saved location.',
70
- async run(ctx) {
71
- const saved = await ctx.storage.account.get('settings/location');
72
- if (!saved) return { error: 'No location saved.' };
73
-
74
- const response = await ctx.fetch(
75
- `https://api.open-meteo.com/v1/forecast?latitude=${saved.data.latitude}` +
76
- `&longitude=${saved.data.longitude}&current=temperature_2m`,
77
- );
78
-
79
- return response.json();
80
- },
81
- },
82
- ],
245
+ export type MyStorage = typeof myStorage;
246
+ ```
247
+
248
+ Pass the type as the generic — `defineIntegration<MyStorage>({ … })` — and
249
+ `ctx.storage.account.get('settings/location')` is typed to that schema's output
250
+ in every handler.
251
+
252
+ Reading and writing:
253
+
254
+ ```ts
255
+ const saved = await ctx.storage.account.get('settings/location');
256
+ // → { data, externalId, expiresAt, updatedAt } | null
257
+
258
+ await ctx.storage.account.set('cache/forecast', body, {
259
+ expiresAt: new Date(Date.now() + 600_000).toISOString(),
83
260
  });
261
+
262
+ await ctx.storage.account.delete('cache/forecast');
84
263
  ```
85
264
 
86
- `ctx` is the whole platform surface a handler gets: `ctx.storage` (already
87
- bound to this account and product, schema-validated on every read and write),
88
- `ctx.secrets`, `ctx.fetch` (egress-enforced), `ctx.logger`, plus identity
89
- facts. There is no raw database client and no request object.
265
+ All three are async. `get` returns `null` for a missing key **and** for an entry
266
+ past its `expiresAt`. A read whose stored data no longer matches the current
267
+ schema throws `StorageValidationError` rather than handing you a shape you did
268
+ not expect evolve a schema with a versioned key (`settings/location.v2`) or a
269
+ `z.union`, never by loosening it in place.
90
270
 
91
- ### A widget
271
+ **`clientReadable` is what makes a key browser-readable.** It defaults to
272
+ `false`. The generic storage route (`GET /api/integrations/[slug]/storage`)
273
+ serves only keys that opted in — so a widget can read back its own settings
274
+ without your writing a route, while cursors, cached upstream payloads and
275
+ internal bookkeeping stay server-side. The descriptor is `.strict()`, so
276
+ `clientReadible: true` is an error rather than a key whose author believes it is
277
+ exposed.
92
278
 
93
- Widgets are client components that render through the `Widget.*` compound API.
94
- Read `WidgetContext` first if you want to skip fetching while the integration
95
- is inactive.
279
+ From a widget:
280
+
281
+ ```tsx
282
+ import { fetchIntegrationStorage } from '@ekanos/sdk/hooks';
283
+
284
+ const values = await fetchIntegrationStorage('acme-payments', {
285
+ accountId,
286
+ keys: ['settings/location'],
287
+ });
288
+
289
+ const location = values['settings/location']; // unknown — narrow it yourself
290
+ ```
291
+
292
+ It requests `GET /api/integrations/<slug>/storage?accountId=…&keys=a,b` and
293
+ returns a `{ key: value }` object.
294
+
295
+ Four things the signature does not tell you:
296
+
297
+ - **A key that did not opt in is simply absent**, and so is a key that has
298
+ never been written. The response does not distinguish them, and neither can
299
+ you.
300
+ - **It throws for a failed request, never for an absent key.** A 403 covers
301
+ "not signed in", "not a member", "not activated" and "no such integration" —
302
+ deliberately indistinguishable, so do not branch on it.
303
+ - **At most 20 keys per request.** Asking for more throws before it fetches.
304
+ Split the read.
305
+ - **Account scope only, and deliberately so.** `ctx.storage.user` is not
306
+ exposed on this route, so a per-user preference is server-readable only —
307
+ a widget cannot fetch one, and you need your own route if you need it in the
308
+ browser. The reasoning is worth knowing because it tells you what to expect:
309
+ `clientReadable` works as an opt-in because an account-scope value is
310
+ something the integration's author decided to expose, and that reasoning does
311
+ not transfer to personal data, where the person it belongs to made no such
312
+ decision. Adding `&scope=user` later is additive and would default to today's
313
+ behaviour, so the door is open — it is a considered "not yet", not an
314
+ oversight.
315
+
316
+ Key names are `[a-z0-9_-]+` with at most one `/` segment: `config`,
317
+ `settings/location`, `cache/recent-payments`. Two slashes is a validation error.
318
+
319
+ ### `egress`
320
+
321
+ An origin-only https allowlist. `ctx.fetch` refuses anything not covered
322
+ **before** it opens a socket — redirect targets included.
323
+
324
+ ```ts
325
+ egress: [
326
+ 'https://api.acme.example', // exact origin
327
+ 'https://*.acme-cdn.example', // single leading-label wildcard
328
+ 'https://api.acme.example:8443', // explicit port
329
+ ],
330
+ ```
331
+
332
+ Scheme, host and optional port. No path, no query, no fragment, no credentials,
333
+ no `http:`. Wildcards are supported only as one leading `*.`. Anything else is
334
+ rejected by `defineIntegration()` with the offending entry quoted.
335
+
336
+ A denied call throws `EgressDeniedError` synchronously. Widening this list is a security-posture change a reviewer looks at during
337
+ **promotion** — the human review your integration passes before it is enabled
338
+ for real accounts, where someone reads this declaration against what your
339
+ handlers actually do. Declare the origins you call and no more.
340
+
341
+ ### Secrets
342
+
343
+ **There is no `secrets` block in the definition.** This surprises people, so:
344
+ secrets are not declared, they are *named*, and read back by name through
345
+ `ctx.secrets`. There are exactly three places a name comes from, and all three
346
+ are just strings you choose:
347
+
348
+ | Where the name comes from | The name is |
349
+ |---|---|
350
+ | Your **activation form** | the field name you posted, **verbatim** |
351
+ | `webhooks[].signature.secretName` | whatever string you put there |
352
+ | `oauth.credentials.clientIdSecretName` / `clientSecretSecretName` | whatever string you put there |
353
+
354
+ The activation form is the one every integration uses and the only one that
355
+ looks like magic, so precisely:
356
+
357
+ **Every field of `activationData` whose value is a string becomes readable as
358
+ `ctx.secrets.get('<that exact field name>')`.** There is no "credential-shaped"
359
+ detection — the platform does not inspect your field names, look for a marker,
360
+ or transform anything. No prefixing, no snake_casing. Non-string values
361
+ (numbers, booleans, nested objects) do not become secrets.
362
+
363
+ That means a form field named `apiKey` is read back as
364
+ `ctx.secrets.get('apiKey')`, **not** `acme_api_key`. It also means renaming a
365
+ form field renames the secret and breaks every handler reading it — which is
366
+ the argument for the shared constant below.
367
+
368
+ The host then resolves the name across its credential tiers (account → source
369
+ → global) plus your activation's values, and hands your handler the resolved
370
+ set. Where two tiers hold the same name, a value the *user* entered at
371
+ activation wins over an admin-issued one; a value your own code wrote with
372
+ `secrets.set()` does not, so a partner cannot shadow a credential an admin
373
+ issues later.
374
+
375
+ ```ts
376
+ const apiKey = await ctx.secrets.get('acme_api_key'); // string | null
377
+ const available = await ctx.secrets.names(); // names only, no values
378
+ await ctx.secrets.set('acme_access_token', token); // account tier only
379
+ ```
380
+
381
+ `set()` is tier-bound. It writes only to the account-tier store for this
382
+ `{account, product}`. Source- and global-tier credentials are admin-issued and
383
+ structurally unreachable from partner code — a `set()` against a name that
384
+ resolves only from those tiers throws `SecretAccessError`. This is what makes
385
+ OAuth refresh safe to hand to an integration.
386
+
387
+ Because names are strings on both sides, keep them in one module and import
388
+ them, rather than retyping the literal in the declaration and again in the
389
+ handler:
390
+
391
+ ```ts
392
+ // src/config.ts — the single source for every name that crosses a boundary
393
+ export const ACME_API_KEY = 'acme_api_key';
394
+ export const ACME_SIGNATURE_HEADER = 'x-acme-signature';
395
+ ```
396
+
397
+ Used on both sides, so the form and the handler cannot drift:
398
+
399
+ ```tsx
400
+ // the activation form — the field NAME is the secret name
401
+ const ActivationSchema = z.object({ [ACME_API_KEY]: z.string().min(1) });
402
+ <FormField name={ACME_API_KEY} … />
403
+ ```
404
+
405
+ ```ts
406
+ // any server handler — same constant, same string
407
+ const apiKey = await ctx.secrets.get(ACME_API_KEY);
408
+ ```
409
+
410
+ ### `tools`
411
+
412
+ Tools are what the Fusion assistant (and, where the host allows it, the workflow
413
+ engine) can call. Each declares a name, a description the model reads, an
414
+ optional JSON-Schema parameter object, and an async `run`.
415
+
416
+ ```ts
417
+ tools: [
418
+ {
419
+ name: 'get_current_weather', // ^[a-z][a-z0-9_]*$
420
+ description: 'Current conditions for the account’s saved location.',
421
+ parameters: {
422
+ type: 'object',
423
+ properties: { units: { type: 'string', enum: ['celsius', 'fahrenheit'] } },
424
+ required: [],
425
+ additionalProperties: false,
426
+ },
427
+ outputExample: { temperature: 12.4, units: 'celsius' },
428
+
429
+ async run(ctx, args) {
430
+ const saved = await ctx.storage.account.get('settings/location');
431
+ if (!saved) return { error: 'No location saved for this account.' };
432
+
433
+ // Same origin the definition's `egress` declares — `ctx.fetch` refuses
434
+ // anything else synchronously, before it opens a socket.
435
+ const response = await ctx.fetch(
436
+ `https://api.acme.example/v1/weather` +
437
+ `?lat=${saved.data.latitude}&lon=${saved.data.longitude}`,
438
+ );
439
+
440
+ if (!response.ok) {
441
+ ctx.logger.error({ status: response.status }, 'Forecast fetch failed.');
442
+ return { error: `Acme returned ${response.status}.` };
443
+ }
444
+
445
+ return response.json();
446
+ },
447
+ },
448
+ ],
449
+ ```
450
+
451
+ `run` returns a plain JSON-serializable value. Returning `{ error: '…' }` is the
452
+ convention for an expected failure the model should read and explain; **throw**
453
+ for a genuine fault.
454
+
455
+ `outputExample` is shown to the model as a shape hint. Keep it small and honest.
456
+
457
+ **`parameters` is a narrow slice of JSON Schema, not all of it.** The top level
458
+ is `.strict()` and accepts exactly four keys — `type` (which must be the
459
+ literal `'object'`), `properties`, `required` and `additionalProperties`.
460
+ A root-level `title`, `description`, `$schema`, `oneOf` or a non-object `type`
461
+ is a hard validation error, not an ignored key. Inside `properties` you are
462
+ unconstrained as long as the value is plain JSON, so nested objects, arrays,
463
+ enums and per-property descriptions all pass — the restriction is on the
464
+ wrapper, not the contents.
465
+
466
+ **Tool names are namespaced by slug at discovery.** The effective name the
467
+ assistant sees is your slug with hyphens replaced by underscores, then the raw
468
+ name, unless the name already carries that prefix. `acme-payments` +
469
+ `list_recent_payments` → `acme_payments_list_recent_payments`. Two collision-free
470
+ raw pairs can collapse to the same effective name, so the platform checks
471
+ effective names and refuses a duplicate outright.
472
+
473
+ ### `components.widgets`
474
+
475
+ Dashboard widgets are client components that render through the `Widget.*`
476
+ compound API.
96
477
 
97
478
  ```tsx
98
479
  'use client';
@@ -104,7 +485,8 @@ import { Widget, WidgetContext } from '@ekanos/sdk/components';
104
485
 
105
486
  export function CurrentWeatherWidget({ accountId }: IntegrationComponentProps) {
106
487
  const ctx = use(WidgetContext);
107
- const query = useMyData(accountId, ctx?.state.state === 'active');
488
+ // Skip fetching while the integration is inactive.
489
+ const query = useCurrentWeather(accountId, ctx?.state.state === 'active');
108
490
 
109
491
  return (
110
492
  <Widget.DataState loading={query.isLoading} error={query.isError}>
@@ -119,10 +501,38 @@ export function CurrentWeatherWidget({ accountId }: IntegrationComponentProps) {
119
501
  }
120
502
  ```
121
503
 
504
+ Declared like this:
505
+
506
+ ```ts
507
+ components: {
508
+ widgets: [
509
+ {
510
+ // Widget ids are GLOBAL — the dashboard resolves widget_config rows by
511
+ // them — so prefix every one with your slug.
512
+ id: 'acme-payments-summary', // kebab-case
513
+ name: 'Acme Payments',
514
+ component: AcmePaymentsSummaryWidget,
515
+ widgetState: 'active', // 'active' | 'inactive' | 'disabled'
516
+ isCollapsible: true,
517
+ isPinnable: false,
518
+ aiFooterEnabled: true, // renders the "Ask about this" footer
519
+ layouts: {
520
+ lg: { x: 0, y: 0, w: 4, h: 8 },
521
+ md: { x: 0, y: 0, w: 4, h: 8 },
522
+ sm: { x: 0, y: 0, w: 4, h: 8 },
523
+ },
524
+ },
525
+ ],
526
+ },
527
+ ```
528
+
529
+ `widgetState` is the only required field beyond `id`, `name` and `component`.
530
+ `layouts` seeds the default dashboard placement per breakpoint; `gridSize` and
531
+ `gridPosition` are the older equivalents and still accepted.
532
+
122
533
  Widget settings live in `ctx.storage`, which is server-side, so a widget reads
123
- them back over HTTP with `fetchIntegrationStorage()` from `@ekanos/sdk/hooks`
124
- only keys declared `clientReadable: true` come back, and a key that did not
125
- opt in is simply absent.
534
+ them back over HTTP with `fetchIntegrationStorage()` from `@ekanos/sdk/hooks`
535
+ subject to `clientReadable`, above.
126
536
 
127
537
  To render a widget anywhere other than the Fusion dashboard — your own app, a
128
538
  story, a component test — wrap it in `WidgetPreviewProvider`, which fills the
@@ -131,115 +541,658 @@ same `WidgetContext` the host fills, with local state and no persistence:
131
541
  ```tsx
132
542
  import { WidgetPreviewProvider } from '@ekanos/sdk/components';
133
543
 
134
- <WidgetPreviewProvider widgetId="acme-weather-current" title="Current conditions">
135
- <CurrentWeatherWidget accountId={accountId} productSlug="acme-weather" />
544
+ <WidgetPreviewProvider widgetId="acme-payments-summary" title="Payments">
545
+ <AcmePaymentsSummaryWidget accountId={accountId} productSlug="acme-payments" />
136
546
  </WidgetPreviewProvider>;
137
547
  ```
138
548
 
139
- ### Testing
549
+ ### `components.marketplaceTile`
550
+
551
+ The card a source admin sees in the marketplace before connecting. Keep it
552
+ plain: no context, no queries, no state, so it renders anywhere from props
553
+ alone.
554
+
555
+ ```tsx
556
+ 'use client';
557
+
558
+ import type { MarketplaceTileProps } from '@ekanos/sdk';
559
+ import { Badge } from '@ekanos/ui/badge';
560
+ import { Button } from '@ekanos/ui/button';
561
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@ekanos/ui/card';
562
+ import { Icon } from '@ekanos/ui/icon';
563
+
564
+ export function AcmePaymentsMarketplaceTile({
565
+ onActivate,
566
+ isActivated = false,
567
+ href,
568
+ }: MarketplaceTileProps) {
569
+ return (
570
+ <Card className="shadow-card flex h-full flex-col border-0">
571
+ <CardHeader className="gap-3">
572
+ <div className="flex items-start justify-between gap-3">
573
+ <div className="bg-muted flex h-12 w-12 items-center justify-center rounded-lg">
574
+ <Icon name="fa-light fa-credit-card" className="h-6 w-6" aria-hidden />
575
+ </div>
576
+ {isActivated && <Badge variant="secondary">Connected</Badge>}
577
+ </div>
578
+ <CardTitle className="text-lg">Acme Payments</CardTitle>
579
+ <CardDescription>Volume, recent activity, and refunds.</CardDescription>
580
+ </CardHeader>
581
+
582
+ <CardContent className="mt-auto flex items-center justify-between gap-3 pb-6">
583
+ <span className="text-muted-foreground text-sm">Payments</span>
584
+ {isActivated ? (
585
+ <Button variant="outline" size="sm" nativeButton={false} render={<a href={href ?? '#'} />}>
586
+ Manage
587
+ </Button>
588
+ ) : (
589
+ <Button size="sm" onClick={onActivate}>Connect</Button>
590
+ )}
591
+ </CardContent>
592
+ </Card>
593
+ );
594
+ }
595
+ ```
596
+
597
+ `MarketplaceTileProps` is `IntegrationComponentProps` plus `onActivate?`,
598
+ `isActivated?` and `href?`. `IntegrationComponentProps` itself is
599
+ `{ accountId, sourceId?, integrationConfig? }` plus exactly one of `productId`
600
+ or `productSlug` — a discriminated union, so passing both is a type error.
601
+
602
+ A tile is optional. Omit it and the marketplace renders a generic card from
603
+ your `name` and `description`, plus whatever logo the platform holds for your
604
+ product — the logo is assigned host-side, not declared here.
605
+
606
+ ### `components.activationForm`
607
+
608
+ What a user fills in to connect. `BaseActivationForm` owns the dialog chrome,
609
+ the submit button, and the error/success states; you supply the
610
+ `react-hook-form` instance and the fields.
611
+
612
+ ```tsx
613
+ 'use client';
614
+
615
+ import { useState } from 'react';
616
+
617
+ import type { ActivationFormProps } from '@ekanos/sdk';
618
+ import { BaseActivationForm } from '@ekanos/sdk/components';
619
+ import { useActivateIntegration } from '@ekanos/sdk/hooks';
620
+ import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@ekanos/ui/form';
621
+ import { Input } from '@ekanos/ui/input';
622
+ import { zodResolver } from '@hookform/resolvers/zod';
623
+ import { useForm } from 'react-hook-form';
624
+
625
+ import { ACME_API_KEY } from './config'; // 'acme_api_key'
626
+
627
+ // Field names ARE secret names — see the note below the example.
628
+ const ActivationSchema = z.object({
629
+ [ACME_API_KEY]: z.string().min(1, 'Required'),
630
+ merchantId: z.string().min(1, 'Required'),
631
+ });
632
+
633
+ export function AcmePaymentsActivationForm({
634
+ accountId,
635
+ sourceId,
636
+ onSuccess,
637
+ onCancel,
638
+ isLoading: externalLoading = false,
639
+ inline = false, // pass this through — see below
640
+ }: ActivationFormProps) {
641
+ const [error, setError] = useState<string | null>(null);
642
+ const [success, setSuccess] = useState(false);
643
+
644
+ const form = useForm({ resolver: zodResolver(ActivationSchema),
645
+ defaultValues: { [ACME_API_KEY]: '', merchantId: '' } });
646
+
647
+ const activate = useActivateIntegration();
648
+ const isLoading = externalLoading || activate.isPending;
649
+
650
+ const handleSubmit = async (data: Record<string, unknown>) => {
651
+ setError(null);
652
+ try {
653
+ await activate.mutateAsync({
654
+ accountId, sourceId,
655
+ integrationSlug: 'acme-payments',
656
+ activationData: data,
657
+ });
658
+ setSuccess(true);
659
+ } catch (cause) {
660
+ setError(cause instanceof Error ? cause.message : 'Could not connect.');
661
+ }
662
+ };
663
+
664
+ return (
665
+ <BaseActivationForm
666
+ isOpen inline={inline}
667
+ onClose={() => onCancel?.()}
668
+ title="Connect Acme Payments"
669
+ description="Bring volume, activity and refunds into your dashboard."
670
+ form={form}
671
+ onSubmit={handleSubmit}
672
+ isLoading={isLoading}
673
+ error={error}
674
+ success={success}
675
+ successTitle="Acme Payments is connected"
676
+ onSuccessClose={() => onSuccess?.({
677
+ success: true,
678
+ configData: { merchantId: form.getValues('merchantId') },
679
+ externalId: form.getValues('merchantId'),
680
+ })}
681
+ submitButtonText="Connect Acme Payments"
682
+ submitButtonLoadingText="Connecting…"
683
+ >
684
+ <FormField control={form.control} name={ACME_API_KEY} render={({ field }) => (
685
+ <FormItem>
686
+ <FormLabel>API key</FormLabel>
687
+ <FormControl><Input type="password" {...field} /></FormControl>
688
+ <FormMessage />
689
+ </FormItem>
690
+ )} />
691
+ </BaseActivationForm>
692
+ );
693
+ }
694
+ ```
695
+
696
+ Three things worth knowing:
697
+
698
+ - **Pass `inline` through.** Without it the form mounts its own dialog, so any
699
+ page that embeds it ends up with nested dialogs.
700
+ - **Every string field you submit is readable as a secret, under the exact
701
+ name you gave it.** There is no "credential-shaped" detection: the platform
702
+ does not inspect your field names or look for a marker. `activateIntegration`
703
+ takes whatever `activationData` object you post, and every field whose value
704
+ is a **string** becomes readable in your server handlers as
705
+ `ctx.secrets.get('<that same field name>')` and appears in
706
+ `ctx.secrets.names()`. Non-string values (numbers, booleans, nested objects)
707
+ do not.
708
+
709
+ So the form field name *is* the secret name — no prefixing, no snake_casing,
710
+ no transformation of any kind. Renaming a form field renames the secret and
711
+ breaks every handler reading it, so put the name in one constant and use it
712
+ on both sides:
713
+
714
+ ```ts
715
+ // src/config.ts — imported by the form AND by every handler
716
+ export const ACME_API_KEY = 'acme_api_key';
717
+ ```
718
+
719
+ Values you want back on the client instead go in `configData` on the
720
+ `ActivationResult` you hand `onSuccess`.
721
+ - **`useActivateIntegration` needs the host's injected actions.** It reads them
722
+ from `IntegrationActivationProvider`, which the host mounts. Outside the host
723
+ — in the dev harness, or your own app — mount that provider yourself, or the
724
+ hook has nothing to call.
725
+
726
+ `OAuthActivationForm` is the connect-button variant for OAuth integrations, and
727
+ `useOAuthConnectionStatus()` reports whether the account is connected.
728
+
729
+ A form is optional. Omit it and the host renders a generic one — a plain field
730
+ per value it needs — rather than nothing.
731
+
732
+ ### `capabilities` and `permissions`
733
+
734
+ Both are copy for the integration detail page, and both are pure data. **Every
735
+ string here is read by the person connecting the integration** — they render on
736
+ the activation dialog, not just in a catalogue listing. Write `detail` as a
737
+ sentence you would be happy for a customer's security reviewer to read, because
738
+ that is who reads it.
739
+
740
+ Exactly what appears, so you can write to the shape:
741
+
742
+ | Section | Renders |
743
+ |---|---|
744
+ | **What it does** | `capability.label`, then ` — ` + `capability.description` when present. `capability.icon` is **not** rendered here |
745
+ | **What it can access** | `permission.label`, a badge reading `Read` or `Write` from `permission.type`, then `permission.detail` on the line below |
746
+
747
+ Both sections are omitted entirely when their array is empty. `type` drives
748
+ only the badge — it does not reorder or group anything.
749
+
750
+ ```ts
751
+ capabilities: [
752
+ {
753
+ label: 'Volume at a glance',
754
+ description: 'Gross volume, trend, and success/failure counts on one card.',
755
+ // icon?: an optional component reference — accepted by the schema, but
756
+ // NOT rendered on the activation surface
757
+ },
758
+ ],
759
+
760
+ permissions: [
761
+ {
762
+ label: 'Read payments',
763
+ detail: 'Lists payments and aggregate volume for the connected merchant.',
764
+ type: 'read', // 'read' | 'write'
765
+ },
766
+ {
767
+ label: 'Issue refunds',
768
+ detail: 'Submits refunds against payments for the connected merchant.',
769
+ type: 'write',
770
+ },
771
+ ],
772
+ ```
773
+
774
+ `permissions` is a disclosure to the person connecting the integration, not an
775
+ enforcement mechanism. What actually constrains you is `egress`, the storage
776
+ schemas, and the secret tiers.
777
+
778
+ ### `webhooks`
779
+
780
+ An inbound delivery from your provider. **The declaration is the whole
781
+ contract:** the transport validates the payload against `payloadSchema`,
782
+ verifies the signature, and calls your handler with the same `ctx` every other
783
+ handler gets.
784
+
785
+ ```ts
786
+ import type { PartnerWebhookDeclaration } from '@ekanos/sdk/integration';
787
+
788
+ export const paymentUpdatedWebhook: PartnerWebhookDeclaration<MyStorage> = {
789
+ id: 'payment-updated', // kebab-case; the ingress addresses it by this
790
+ // (NOT the vendor's event name — see below)
791
+ description:
792
+ 'Acme posts one event per payment status change. Keeps the dashboard cache honest.',
793
+
794
+ // Validates every delivery BEFORE the handler runs.
795
+ payloadSchema: PaymentUpdatedPayloadSchema,
796
+
797
+ // How the TRANSPORT verifies. `'none'` states explicitly that the source
798
+ // is unsigned. Your handler never checks a signature.
799
+ signature: { header: 'x-acme-signature', secretName: 'acme_webhook_secret' },
800
+
801
+ // Seeds the harness's payload editor and documents the shape beside the
802
+ // schema. Plain JSON only.
803
+ examplePayload: {
804
+ eventType: 'payment.updated',
805
+ payment: {
806
+ id: 'pay_3RkQ9fLm2xTvB1',
807
+ customerName: 'Northwind Logistics',
808
+ amountCents: 128450,
809
+ currency: 'USD',
810
+ status: 'succeeded',
811
+ },
812
+ previousStatus: 'pending',
813
+ },
814
+
815
+ async handler(ctx, event) {
816
+ // event: { id, receivedAt, headers, payload }
817
+ // `payload` is already parsed and validated — narrow it to your type.
818
+ const { payment, previousStatus } = event.payload as PaymentUpdatedPayload;
819
+
820
+ if (payment.status === previousStatus) {
821
+ // A no-op redelivery. Acknowledged (2xx — do not retry), recorded as
822
+ // irrelevant.
823
+ return { status: 'ignored', detail: `Still "${payment.status}".` };
824
+ }
825
+
826
+ await ctx.storage.account.set('events/last-payment-update', {
827
+ payment, previousStatus, receivedAt: event.receivedAt,
828
+ });
829
+ await ctx.storage.account.delete('cache/recent-payments');
830
+
831
+ return { status: 'processed', detail: `${previousStatus} → ${payment.status}.` };
832
+ },
833
+ };
834
+ ```
835
+
836
+ The three-way result contract matters:
837
+
838
+ | Outcome | How to signal it | What the transport does |
839
+ |---|---|---|
840
+ | Handled it | `return { status: 'processed' }` | 2xx |
841
+ | Valid but irrelevant | `return { status: 'ignored' }` | 2xx — the sender must not retry |
842
+ | Could not handle a valid event | **throw** | retryable failure |
843
+
844
+ `detail` is optional on both statuses and shows up in the harness and the host's
845
+ delivery log.
846
+
847
+ **Webhook ids are kebab-case, so you cannot name one after the vendor's event.**
848
+ GitHub sends `pull_request`, Stripe sends `payment_intent.succeeded`, Slack
849
+ sends `app_mention` — none of those are valid ids. Plan on keeping an explicit
850
+ map from vendor event name to your webhook id, and put the vendor's name in
851
+ `description` so the two are readable side by side. The validation error is
852
+ clear about the rule, but it fires after you have already named things.
853
+
854
+ A payload that fails `payloadSchema` never reaches the handler at all.
855
+ Signature verification is the transport's job in every environment — the host
856
+ ingress enforces it, the local harness logs it as skipped. Do not verify it
857
+ yourself; you would be verifying a header the transport already consumed.
858
+
859
+ ### `schedules`
860
+
861
+ A cron-driven run. Same handler shape, same `ctx`.
862
+
863
+ ```ts
864
+ import type { PartnerScheduleDeclaration } from '@ekanos/sdk/integration';
865
+
866
+ export const dailyReconcile: PartnerScheduleDeclaration<MyStorage> = {
867
+ id: 'daily-reconcile', // kebab-case
868
+ description: 'Pull the latest payments each morning and store a summary.',
869
+ cron: '0 6 * * *',
870
+
871
+ async handler(ctx, invocation) {
872
+ // invocation: { scheduledFor, invokedAt, trigger }
873
+ const apiKey = await ctx.secrets.get(ACME_API_KEY); // from src/config.ts
874
+
875
+ if (!apiKey) {
876
+ // Not configured is an EXPECTED state for a schedule — the account has
877
+ // nothing to reconcile yet. Skip, don't fail.
878
+ return { status: 'skipped', detail: 'No API key configured.' };
879
+ }
880
+
881
+ const response = await ctx.fetch('https://api.acme.example/v1/payments', {
882
+ headers: { authorization: `Bearer ${apiKey}` },
883
+ });
884
+
885
+ if (!response.ok) {
886
+ // An upstream failure IS a failed run — throw so the transport records
887
+ // it and the scheduler can retry.
888
+ throw new Error(`Acme returned ${response.status} during reconcile.`);
889
+ }
890
+
891
+ await ctx.storage.account.set('reconcile/daily', summarize(await response.json()));
892
+
893
+ return { status: 'completed', detail: 'Reconciled 214 payments.' };
894
+ },
895
+ };
896
+ ```
897
+
898
+ `completed` means the run did its work; `skipped` means it correctly did nothing.
899
+ A failure throws.
900
+
901
+ **`trigger` distinguishes the scheduler from a human pressing "Run now"** (in
902
+ the harness, or an admin in the host). Handlers may branch on it — skipping an
903
+ idempotency window for a manual run, say — but must be safe under both, and
904
+ under redelivery. The example above recomputes from the API and overwrites one
905
+ row, so running it twice is harmless. Aim for that.
906
+
907
+ **Cron syntax.** Standard 5 fields, `minute hour day-of-month month
908
+ day-of-week`, validated by `defineIntegration()` against the same grammar the
909
+ host scheduler uses. Numeric values only, with `*`, lists (`1,15`), ranges
910
+ (`1-5`) and steps (`*/15`, `0-30/5`). Bounds: minute 0-59, hour 0-23,
911
+ day-of-month 1-31, month 1-12, day-of-week 0-7 (0 and 7 both Sunday). **No
912
+ names** (`JAN`, `MON`), no `@daily` macros, no seconds field.
913
+
914
+ ### `oauth`
915
+
916
+ You declare the provider; **the transport owns the flow.** Authorize redirect,
917
+ state, callback and code exchange are not yours to write. You name the
918
+ endpoints, name the client-credential secrets, and persist the tokens.
919
+
920
+ ```ts
921
+ import type { PartnerOAuthDeclaration } from '@ekanos/sdk/integration';
922
+
923
+ export const acmeOAuth: PartnerOAuthDeclaration<MyStorage> = {
924
+ provider: {
925
+ authorizationUrl: 'https://connect.acme.example/oauth/authorize',
926
+ tokenUrl: 'https://connect.acme.example/oauth/token',
927
+ scopes: ['payments:read', 'refunds:write'],
928
+ pkce: true,
929
+ },
930
+ credentials: {
931
+ clientIdSecretName: 'acme_oauth_client_id',
932
+ clientSecretSecretName: 'acme_oauth_client_secret',
933
+ },
934
+
935
+ // Called after the code exchange, and again after every refresh.
936
+ async onTokens(ctx, tokens) {
937
+ // tokens: { accessToken, refreshToken?, expiresAt?, scope?, tokenType?, raw? }
938
+ await ctx.secrets.set('acme_access_token', tokens.accessToken);
939
+
940
+ if (tokens.refreshToken) {
941
+ await ctx.secrets.set('acme_refresh_token', tokens.refreshToken);
942
+ }
943
+
944
+ ctx.logger.info(
945
+ { scope: tokens.scope ?? null, expiresAt: tokens.expiresAt ?? null },
946
+ 'Stored Acme Connect tokens.',
947
+ );
948
+ },
949
+ };
950
+ ```
951
+
952
+ Token storage policy is just the capability layer — `ctx.secrets.set()` writes
953
+ to the account tier, nothing new to learn.
954
+
955
+ **Both endpoint origins must be covered by your `egress` list.**
956
+ `defineIntegration()` rejects the declaration otherwise, with the exact origin
957
+ to add. The code exchange runs through your declared egress, so an origin the
958
+ reviewer never saw is an origin the exchange must not reach.
959
+
960
+ `raw` on the token set carries provider-specific extras verbatim, for providers
961
+ that return more than the standard fields.
962
+
963
+ **The redirect URI exists, but you do not declare it.** The schema is
964
+ `.strict()`, so `provider.redirectUri` is a hard error rather than an ignored
965
+ key — and that is correct rather than an omission: the host owns the callback
966
+ route and builds the URI from its own origin, so a value you declared would
967
+ either be ignored or aim the host's callback somewhere it should not go.
968
+
969
+ Register this with your provider, against whichever host will run the flow:
970
+
971
+ ```
972
+ https://<your-fusion-host>/api/integrations/<your-slug>/callback
973
+ ```
974
+
975
+ The dev harness does not serve that route, so you cannot complete a round trip
976
+ locally. What it *can* check is that both endpoints you declared are inside
977
+ your egress list.
978
+
979
+ **Honest limitation:** there is no local OAuth loop yet. The harness's Triggers
980
+ panel renders the declaration and checks its egress coverage, but it cannot walk
981
+ you through an authorize redirect. Until that lands, unit-test `onTokens`
982
+ directly against `createMockContext()` — it is an ordinary async function.
983
+
984
+ ### `proposes`
985
+
986
+ A suggestion block, not a settings block. Security classifications are
987
+ host-assigned; `proposes` is how you tell a reviewer what you believe is true,
988
+ and the reviewer confirms it at promotion.
989
+
990
+ ```ts
991
+ proposes: {
992
+ // 'account' | 'user' | 'source'. Declaring 'account' asserts that no
993
+ // handler reads a personal grant or a session client.
994
+ credentialModel: 'account',
995
+
996
+ tools: {
997
+ list_recent_payments: { effect: 'read', sensitivity: 'financial' },
998
+ // Honest classification: this moves money. Marking it 'read' or
999
+ // 'internal' to get past a gate is what promotion review exists to catch.
1000
+ refund_payment: { effect: 'write', sensitivity: 'financial' },
1001
+ },
1002
+ },
1003
+ ```
1004
+
1005
+ `effect` is `'read' | 'write'`. `sensitivity` is `'public' | 'internal' | 'pii'
1006
+ | 'financial'`. Both are optional per tool.
1007
+
1008
+ The keys of `proposes.tools` must match declared tool names exactly — a
1009
+ proposal for a tool you did not declare is a validation error, so a rename
1010
+ cannot leave a stale classification behind.
1011
+
1012
+ Why it matters: an undeclared `credentialModel` fails closed. The workflow
1013
+ engine's tool allowlist treats it as not machine-executable, so a tool you
1014
+ believe is workflow-callable will not be until this is declared and confirmed.
1015
+
1016
+ ### `workspaceTargets`
1017
+
1018
+ Only `kind: 'integration'` packages declare workspace targets — the named
1019
+ dashboard sections an integration ships with. This is a first-party surface
1020
+ today: composition is admin-curated, and a partner integration's widgets are
1021
+ placed individually by an admin. The field is validated if you set it (the
1022
+ `WorkspaceTargetDefinition` type is exported from the root entrypoint), but you
1023
+ almost certainly should not set it.
1024
+
1025
+ ## Testing
140
1026
 
141
1027
  `@ekanos/sdk/testing` gives you the same context production builds, in memory:
142
- same schema validation, same egress allowlist, no network.
1028
+ same schema validation, same egress allowlist, same secret tier rules, no
1029
+ network.
143
1030
 
144
1031
  ```ts
145
1032
  import { createMockContext } from '@ekanos/sdk/testing';
146
1033
 
147
1034
  const ctx = createMockContext({
148
- integration: { slug: 'acme-weather' },
149
- storageSchemas: weather.storage,
150
- egress: weather.egress,
151
- storage: { account: { 'settings/location': { label: 'HQ', latitude: 45, longitude: -122 } } },
1035
+ integration: { slug: 'acme-payments' },
1036
+ storageSchemas: myStorage,
1037
+ egress: integration.egress,
1038
+ storage: { account: { config: { merchantId: 'mrc_4820193' } } },
1039
+ secrets: {
1040
+ account: { acme_api_key: 'acme_test_9f2c41ab' }, // writable via secrets.set()
1041
+ admin: { platform_key: 'not-writable' }, // set() here throws SecretAccessError
1042
+ },
152
1043
  fetchHandlers: [
153
- { match: 'https://api.open-meteo.com', respond: () => Response.json({ current: {} }) },
1044
+ // `match` as a string matches URLs that START WITH it; a RegExp is tested
1045
+ // against the whole URL. An allowed call that matches nothing resolves 200 {}.
1046
+ { match: 'https://api.acme.example', respond: () => Response.json({ payments: [] }) },
154
1047
  ],
155
1048
  });
156
1049
 
157
- const result = await weather.tools[0].run(ctx, {});
1050
+ const result = await integration.tools[0].run(ctx, {});
158
1051
 
159
- expect(ctx.fetchCalls).toHaveLength(1);
1052
+ expect(ctx.fetchCalls).toHaveLength(1); // { url, init, denied }[]
1053
+ expect(ctx.logs.at(-1)?.level).toBe('info'); // { level, context, message }[]
1054
+ expect(ctx.dumpStorage().account).toMatchObject({ /* … */ });
160
1055
  ```
161
1056
 
162
- `invokeWebhook()` and `invokeSchedule()` do the same for declared event
163
- surfaces, building the context FROM the definition so a handler can never be
164
- tested against schemas or an allowlist it does not declare.
1057
+ `invokeWebhook()` and `invokeSchedule()` do the same for the event surfaces, and
1058
+ they build the context **from the definition** its slug, storage schemas and
1059
+ egress — so a handler can never be tested against schemas or an allowlist it
1060
+ does not declare.
165
1061
 
166
- ### Styling
1062
+ ```ts
1063
+ import { invokeSchedule, invokeWebhook } from '@ekanos/sdk/testing';
1064
+
1065
+ const { result, event, ctx } = await invokeWebhook(integration, 'payment-updated', payload, {
1066
+ contextOptions: { storage: { account: { config } }, fetchHandlers: [...] },
1067
+ headers: { 'x-acme-signature': 'ignored — the transport verifies, not you' },
1068
+ });
167
1069
 
168
- `@ekanos/ui` ships no CSS. Its components emit Tailwind class strings against
169
- Fusion's semantic tokens, and icons are Font Awesome glyphs the host loads. In
170
- your own app they render as correct but unstyled HTML, and icons render as
171
- nothing at all that is expected, not a bug in your code.
1070
+ expect(result.status).toBe('processed');
1071
+
1072
+ // Reuse one context across invocations so state accumulates, as it does in a
1073
+ // real account. Pass `context` OR `contextOptions`, never both.
1074
+ const second = await invokeSchedule(integration, 'daily-reconcile', {
1075
+ context: ctx,
1076
+ trigger: 'schedule', // defaults to 'manual'
1077
+ });
1078
+ ```
1079
+
1080
+ Both throw if the id is not declared, and `invokeWebhook` throws if the payload
1081
+ fails `payloadSchema` — the handler never runs. The local transport records the
1082
+ signature skip as one `warn` line on `ctx.logs`.
172
1083
 
173
1084
  ## Entrypoints
174
1085
 
175
1086
  | Import | Runs on | Contents |
176
1087
  |---|---|---|
177
1088
  | `@ekanos/sdk` | anywhere | Types only — the integration contract (`IntegrationMetadata`, component prop types, activation/health/data types) and the capability context types (`IntegrationContext`, `ScopedStore`, `StorageSchemas`, …) |
178
- | `@ekanos/sdk/components` | client | `Widget` compound API + `WidgetContext`, `BaseActivationForm`, `OAuthActivationForm`, `BaseMarketplaceTile`, `AiPromptChip` — implemented here (the inversion); UI primitives from `@ekanos/ui` |
179
- | `@ekanos/sdk/hooks` | client | `useActivateIntegration`/`useDeactivateIntegration` (host-injected actions via `IntegrationActivationProvider`), `useOAuthConnectionStatus`, widget event bus, `useAskAssistant` |
180
- | `@ekanos/sdk/mcp` | server | `ToolModule`/`ToolContext` contract + membership/ownership guardsimplemented here |
181
- | `@ekanos/sdk/context` | anywhere (isomorphic, dependency-pure) | Capability-context shared runtime: `EgressDeniedError`, `SecretAccessError`, `StorageValidationError`, the `isEgressAllowed` matcher, the storage validators — one implementation reused by the production wrapper and the mock |
182
- | `@ekanos/sdk/testing` | anywhere (tests) | `createMockContext()` the in-memory `IntegrationContext` harness (schema-validated stores, tiered secrets, allowlist-enforcing fetch stub, capturing logger) |
183
- | `@ekanos/sdk/integration` | anywhere (isomorphic, dependency-pure) | The partner authoring contract: `defineIntegration()` (validate + deep-freeze, storage schemas per ruling 1, https-origin egress per ruling 3, security classifications only as a `proposes` block), `validateIntegrationDefinitions()` (cross-definition slug/widget/tool collision check), and `IntegrationDefinitionSchema` for the gate/dev-mcp validators |
184
-
185
- `/context`, `/testing`, and `/integration` import nothing from `@kit/*` and
186
- carry no `server-only` marker they are self-contained by construction and
187
- the pack test typechecks all three in a clean room (alongside the root). The
188
- capability context spec is
189
- [`docs/devex/capability-context-proposal.md`](https://github.com/companydotcom/fusion/blob/dev/docs/devex/capability-context-proposal.md).
190
- Partners never extend `BaseIntegration` `defineIntegration()` is the v1
191
- contract, and the host adapts the definition via
192
- `registerPartnerIntegration` in `@kit/integrations-core` (typed there as a
193
- structural twin, held in parity by
194
- `src/integration/__tests__/partner-definition-parity.test-d.ts`).
195
-
196
- ## Rules
197
-
198
- - The root entrypoint is types-only and must stay importable from client code.
199
- Never add a value export to it.
200
- - Additions anywhere require a second consumer and an entry in
201
- [`docs/devex/sdk-export-map.md`](https://github.com/companydotcom/fusion/blob/dev/docs/devex/sdk-export-map.md) the
202
- export map is the source of truth for what is public and why.
203
- - The inversion landed: this package OWNS the `/components`, `/hooks`, and
204
- `/mcp` implementations and `@kit/integrations-core` re-exports them. Never
205
- import `@kit/*` from SDK source — UI comes from `@ekanos/ui`, host services
206
- arrive through injection seams (`IntegrationActivationProvider`,
207
- `ToolContext`). There is no `/server` entrypoint: first-party transitional
208
- accessors live in `@kit/integrations-core/server` (core depends on the SDK
209
- now, so the SDK cannot re-export core without a package cycle).
1089
+ | `@ekanos/sdk/components` | client | `Widget` compound API + `WidgetContext`, `WidgetPreviewProvider`, `BaseActivationForm`, `OAuthActivationForm`, `BaseMarketplaceTile`, `AiPromptChip` |
1090
+ | `@ekanos/sdk/hooks` | client | `useActivateIntegration`/`useDeactivateIntegration` (host-injected actions via `IntegrationActivationProvider`), `useOAuthConnectionStatus`, `fetchIntegrationStorage`, widget event bus, `useAskAssistant` |
1091
+ | `@ekanos/sdk/context` | anywhere (isomorphic, dependency-pure) | `EgressDeniedError`, `SecretAccessError`, `StorageValidationError`, the `isEgressAllowed` matcher, the storage validators one implementation shared by the production wrapper and the mock |
1092
+ | `@ekanos/sdk/testing` | anywhere (tests) | `createMockContext()`, `invokeWebhook()`, `invokeSchedule()` |
1093
+ | `@ekanos/sdk/integration` | anywhere (isomorphic, dependency-pure) | `defineIntegration()`, `validateIntegrationDefinitions()`, `IntegrationDefinitionSchema`, and every `Partner*Declaration` type |
1094
+
1095
+ `@ekanos/sdk/mcp` is **not in the published package.** It is excluded from the
1096
+ tarball (`files` carries `!dist/mcp`) and dropped from `publishConfig.exports`,
1097
+ so importing it from an installed `@ekanos/sdk` will not resolve. Tools are
1098
+ declared with `tools[]` on your definition, documented above you do not need
1099
+ this entrypoint to write one.
1100
+
1101
+ The root entrypoint is types-only and must stay importable from client code.
1102
+ `/context`, `/testing` and `/integration` are self-contained by construction and
1103
+ carry no `server-only` marker; the pack test typechecks all of them in a clean
1104
+ room.
1105
+
1106
+ Partners never extend a base class. `defineIntegration()` is the v1 contract,
1107
+ and the host adapts the definition internally.
1108
+
1109
+ ### Styling
1110
+
1111
+ `@ekanos/ui` components emit Tailwind class strings against Fusion's semantic
1112
+ tokens, and icons are Font Awesome glyphs the host loads. Import
1113
+ `@ekanos/ui/styles.css` (or the narrower `tokens.css` / `theme.css` /
1114
+ `base.css`) to get the token layer. Outside a host that loads Font Awesome,
1115
+ icons render as nothing at all — that is expected, not a bug in your code.
1116
+
1117
+ ## Naming and validation rules, in one table
1118
+
1119
+ | Field | Rule | Example |
1120
+ |---|---|---|
1121
+ | `slug` | kebab-case, `^[a-z0-9]+(-[a-z0-9]+)*$` | `acme-payments` |
1122
+ | `version` | semver, prerelease/build suffix allowed | `1.0.0`, `2.1.0-beta.3` |
1123
+ | `components.widgets[].id` | kebab-case, **globally unique** — prefix with your slug | `acme-payments-summary` |
1124
+ | `tools[].name` | `^[a-z][a-z0-9_]*$` — lowercase snake_case | `list_recent_payments` |
1125
+ | `webhooks[].id` | kebab-case | `payment-updated` |
1126
+ | `schedules[].id` | kebab-case | `daily-reconcile` |
1127
+ | `schedules[].cron` | 5-field numeric cron | `0 6 * * *` |
1128
+ | storage keys | `[a-z0-9_-]+` with at most one `/` | `settings/location` |
1129
+ | `egress[]` | https origin, optional one leading `*.`, optional port | `https://*.acme.example` |
1130
+ | OAuth endpoints | absolute https, no embedded credentials, origin in `egress` | — |
1131
+ | `examplePayload`, `outputExample` | plain JSON only | — |
1132
+
1133
+ Duplicate widget ids, tool names, webhook ids or schedule ids inside one
1134
+ definition are rejected. Collisions **across** definitions, and against
1135
+ first-party ids, are caught by `validateIntegrationDefinitions()` at
1136
+ registration.
1137
+
1138
+ ## Rough edges we know about
1139
+
1140
+ Written down rather than papered over, because hitting one and not knowing
1141
+ whether it is you or us costs an afternoon.
1142
+
1143
+ - **There is no client-side `ctx.fetch`.** `ctx.fetch` is server-side only. A
1144
+ widget that talks to a vendor API straight from the browser has nowhere to
1145
+ get an allowlisted fetch, so it needs a small seam of its own — a React
1146
+ context whose default is the browser's `fetch`. The `@ekanos/harness` README
1147
+ has the whole ~20-line pattern; the dev harness's live mode mounts it for you.
1148
+ - **No local OAuth loop.** See [`oauth`](#oauth) above.
1149
+ - **`useActivateIntegration()` is inert without the host's provider.** It reads
1150
+ server actions out of `IntegrationActivationProvider`. Nothing warns you at
1151
+ build time.
1152
+ - **Storage schema evolution is manual.** A stored value that no longer matches
1153
+ its schema throws on read. Version the key or widen with `z.union`.
1154
+ - **`ctx.storage.user` throws for machine actors.** Anything reachable from a
1155
+ workflow should use the account scope.
1156
+ - **`isPinnable` on a widget declaration renders nothing by itself.** It
1157
+ reaches `WidgetContext` as `meta.isPinnable`, with the value at
1158
+ `state.pinned` and a working `actions.togglePinned()` — but no shipped
1159
+ `Widget.*` component draws a pin control, and neither does the dashboard,
1160
+ which drives pinning from outside the widget. A pin *inside* your widget is
1161
+ yours to build from those three.
1162
+ - **Widget sizing is declared twice, in two unrelated shapes.** `layouts` here
1163
+ drives the real dashboard; the dev harness reads its own `width` instead.
1164
+ Neither validates the other.
1165
+ - **Your slug is written in two places** — `ekanos.json` and the definition.
1166
+ `ekanos validate` compares them and reports a
1167
+ `project.slug-agreement` finding if they disagree, so this is caught — but it
1168
+ is caught rather than prevented, and the slug addresses your harness route,
1169
+ product, widget-id prefix and MCP namespace, so fix it early.
210
1170
 
211
1171
  ## Publish pipeline
212
1172
 
213
- Workspace consumers resolve raw `src/*.ts` via `exports`; the published
214
- artifact is compiled ESM + declarations in `dist/`, wired through
215
- `publishConfig` (applied by `pnpm pack`/`pnpm publish`). The tarball ships
216
- `dist` + `README.md` only.
1173
+ Workspace consumers resolve raw `src/*.ts` via `exports`; the published artifact
1174
+ is compiled ESM + declarations in `dist/`, wired through `publishConfig`
1175
+ (applied by `pnpm pack`/`pnpm publish`). The tarball ships `dist`, this README,
1176
+ `LICENSE` and the ESLint preset.
217
1177
 
218
1178
  **Runtime dependencies + publish order.** The SDK depends on
219
1179
  `@ekanos/integration-schema` (the dependency-pure canonical schema — one
220
- contract shared with `@kit/integrations-core`) and `@ekanos/ui` (the
221
- published slice of the host design system that `/components` renders
222
- through). Publish **schema and ui first**, then the SDK: the packed SDK
223
- declares both as normal dependencies, so they must already be on the registry
224
- for a fresh `npm install @ekanos/sdk` to resolve. The `pack:test` proves this
225
- offline by packing all three, installing ONLY the SDK tarball (schema + ui
226
- pulled transitively) and executing a real
227
- `import("@ekanos/sdk/integration")`.
1180
+ contract shared with the host) and `@ekanos/ui` (the published slice of the host
1181
+ design system that `/components` renders through). Publish **schema and ui
1182
+ first**, then the SDK: the packed SDK declares both as normal dependencies, so
1183
+ they must already be on the registry for a fresh `npm install @ekanos/sdk` to
1184
+ resolve.
228
1185
 
229
1186
  | Script | What it does |
230
1187
  |---|---|
231
- | `pnpm --filter @ekanos/sdk build` | `tsc -p tsconfig.build.json` → `dist/` (ESM + `.d.ts` + maps; `__tests__` excluded) |
1188
+ | `pnpm --filter @ekanos/sdk build` | `tsc -p tsconfig.build.json` → `dist/` (ESM + `.d.ts`; `__tests__` excluded) |
232
1189
  | `pnpm --filter @ekanos/sdk test` | Vitest over `src/**/__tests__` — matcher semantics, storage schema enforcement, secret tier rules, mock recording surfaces |
233
1190
  | `pnpm --filter @ekanos/sdk api-report` | Regenerates [`api-report.md`](./api-report.md), the checked-in snapshot of every entrypoint's exports |
234
1191
  | `pnpm --filter @ekanos/sdk api-report:check` | Fails on drift between the surface and `api-report.md` |
235
- | `pnpm --filter @ekanos/sdk pack:test` | Clean-room test: pack the SDK (and `@ekanos/integration-schema` + `@ekanos/ui`), install ONLY the SDK tarball outside the workspace with the declared peers, typecheck a consumer of EVERY published entrypoint (`skipLibCheck: false`), esbuild-bundle the client and server graphs, grep dist for `@kit/*` import specifiers, and EXECUTE `import("@ekanos/sdk/integration")` in real Node |
1192
+ | `pnpm --filter @ekanos/sdk pack:test` | Clean-room test: pack the SDK (and `@ekanos/integration-schema` + `@ekanos/ui`), install ONLY the SDK tarball outside the workspace with the declared peers, typecheck a consumer of EVERY published entrypoint (`skipLibCheck: false`), esbuild-bundle the client and server graphs, and EXECUTE `import("@ekanos/sdk/integration")` in real Node |
236
1193
 
237
- The pack test was the forcing function for the SDK inversion
238
- ([export map](https://github.com/companydotcom/fusion/blob/dev/docs/devex/sdk-export-map.md), review outcome 6), and the
239
- inversion landed: every published entrypoint (root, `/components`, `/hooks`,
240
- `/mcp`, `/context`, `/testing`, `/integration`) is typechecked in the clean
241
- room and must be GREEN — no skips remain. Nothing is a v1 contract until it
242
- passes.
1194
+ Every published entrypoint (root, `/components`, `/hooks`, `/mcp`, `/context`,
1195
+ `/testing`, `/integration`) is typechecked in the clean room and must be GREEN —
1196
+ no skips remain. Nothing is a v1 contract until it passes.
243
1197
 
244
1198
  Publishing is a deliberate human step, gated on `pack:test` being GREEN.
245
- `publishConfig.access` is already `public`.