@ekanos/sdk 0.1.1 → 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.
- package/README.md +1093 -140
- package/api-report.md +202 -0
- package/dist/components/index.d.ts +3 -2
- package/dist/components/index.js +3 -2
- package/dist/components/index.js.map +1 -1
- package/dist/components/widgets/widget-context.js.map +1 -1
- package/dist/context/index.d.ts +1 -1
- package/dist/context/index.js +1 -1
- package/dist/context/index.js.map +1 -1
- package/dist/context/types.d.ts +1 -1
- package/dist/context/types.js.map +1 -1
- package/dist/hooks/activation-actions-context.d.ts +4 -4
- package/dist/hooks/activation-actions-context.js +1 -1
- package/dist/hooks/activation-actions-context.js.map +1 -1
- package/dist/hooks/index.d.ts +2 -2
- package/dist/hooks/index.js +2 -2
- package/dist/hooks/index.js.map +1 -1
- package/dist/hooks/use-oauth-connection-status.d.ts +18 -72
- package/dist/hooks/use-oauth-connection-status.js +1 -1
- package/dist/hooks/use-oauth-connection-status.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/integration/define-integration.d.ts +2 -3
- package/dist/integration/define-integration.js +2 -3
- package/dist/integration/define-integration.js.map +1 -1
- package/dist/integration/index.d.ts +2 -2
- package/dist/integration/index.js +2 -2
- package/dist/integration/index.js.map +1 -1
- package/dist/testing/index.d.ts +1 -1
- package/dist/testing/index.js +1 -1
- package/dist/testing/index.js.map +1 -1
- package/dist/types/index.d.ts +3 -4
- package/dist/types/index.js +3 -4
- package/dist/types/index.js.map +1 -1
- package/dist/types/integration.d.ts +2 -2
- package/dist/types/integration.js.map +1 -1
- package/dist/types/widget-ask-context.d.ts +1 -1
- package/dist/types/widget-ask-context.js.map +1 -1
- package/eslint.preset.mjs +373 -0
- package/package.json +18 -18
- package/dist/mcp/guards.d.ts +0 -40
- package/dist/mcp/guards.js +0 -99
- package/dist/mcp/guards.js.map +0 -1
- package/dist/mcp/index.d.ts +0 -22
- package/dist/mcp/index.js +0 -22
- package/dist/mcp/index.js.map +0 -1
- package/dist/mcp/ownership.d.ts +0 -74
- package/dist/mcp/ownership.js +0 -83
- package/dist/mcp/ownership.js.map +0 -1
- package/dist/mcp/types.d.ts +0 -145
- package/dist/mcp/types.js +0 -2
- 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
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
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
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
`
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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-
|
|
135
|
-
<
|
|
544
|
+
<WidgetPreviewProvider widgetId="acme-payments-summary" title="Payments">
|
|
545
|
+
<AcmePaymentsSummaryWidget accountId={accountId} productSlug="acme-payments" />
|
|
136
546
|
</WidgetPreviewProvider>;
|
|
137
547
|
```
|
|
138
548
|
|
|
139
|
-
###
|
|
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
|
|
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-
|
|
149
|
-
storageSchemas:
|
|
150
|
-
egress:
|
|
151
|
-
storage: { account: {
|
|
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
|
-
|
|
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
|
|
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
|
|
163
|
-
|
|
164
|
-
tested against schemas or an allowlist it
|
|
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
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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`
|
|
179
|
-
| `@ekanos/sdk/hooks` | client | `useActivateIntegration`/`useDeactivateIntegration` (host-injected actions via `IntegrationActivationProvider`), `useOAuthConnectionStatus`, widget event bus, `useAskAssistant` |
|
|
180
|
-
| `@ekanos/sdk/
|
|
181
|
-
| `@ekanos/sdk/
|
|
182
|
-
| `@ekanos/sdk/
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
`
|
|
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
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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`.
|