@ekanos/harness 0.1.0

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 (72) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +879 -0
  3. package/dist/app.d.ts +4 -0
  4. package/dist/app.js +11 -0
  5. package/dist/config.d.ts +18 -0
  6. package/dist/config.js +34 -0
  7. package/dist/internal/components/ask-assistant-bridge.d.ts +10 -0
  8. package/dist/internal/components/ask-assistant-bridge.js +50 -0
  9. package/dist/internal/components/dashboard-grid.d.ts +45 -0
  10. package/dist/internal/components/dashboard-grid.js +84 -0
  11. package/dist/internal/components/dev-toolbar.d.ts +15 -0
  12. package/dist/internal/components/dev-toolbar.js +155 -0
  13. package/dist/internal/components/harness-providers.d.ts +16 -0
  14. package/dist/internal/components/harness-providers.js +83 -0
  15. package/dist/internal/components/harness-widget-provider.d.ts +60 -0
  16. package/dist/internal/components/harness-widget-provider.js +84 -0
  17. package/dist/internal/components/i18n-provider.d.ts +9 -0
  18. package/dist/internal/components/i18n-provider.js +9 -0
  19. package/dist/internal/components/row-groups.d.ts +23 -0
  20. package/dist/internal/components/row-groups.js +37 -0
  21. package/dist/internal/components/surface-nav.d.ts +4 -0
  22. package/dist/internal/components/surface-nav.js +70 -0
  23. package/dist/internal/components/viewport-frame.d.ts +14 -0
  24. package/dist/internal/components/viewport-frame.js +27 -0
  25. package/dist/internal/components/widget-boundary.d.ts +25 -0
  26. package/dist/internal/components/widget-boundary.js +44 -0
  27. package/dist/internal/components/widget-surface.d.ts +20 -0
  28. package/dist/internal/components/widget-surface.js +76 -0
  29. package/dist/internal/lib/fonts.d.ts +2 -0
  30. package/dist/internal/lib/fonts.js +22 -0
  31. package/dist/internal/lib/harness-fetch-interceptor.d.ts +89 -0
  32. package/dist/internal/lib/harness-fetch-interceptor.js +101 -0
  33. package/dist/internal/lib/harness-live-fetch.d.ts +66 -0
  34. package/dist/internal/lib/harness-live-fetch.js +121 -0
  35. package/dist/internal/lib/harness-query-client.d.ts +43 -0
  36. package/dist/internal/lib/harness-query-client.js +103 -0
  37. package/dist/internal/lib/http-fixtures.d.ts +145 -0
  38. package/dist/internal/lib/http-fixtures.js +256 -0
  39. package/dist/internal/lib/i18n.d.ts +2 -0
  40. package/dist/internal/lib/i18n.js +17 -0
  41. package/dist/internal/lib/redact.d.ts +33 -0
  42. package/dist/internal/lib/redact.js +43 -0
  43. package/dist/internal/lib/toolbar-context.d.ts +59 -0
  44. package/dist/internal/lib/toolbar-context.js +124 -0
  45. package/dist/internal/registry-context.d.ts +27 -0
  46. package/dist/internal/registry-context.js +51 -0
  47. package/dist/internal/routes/activation-page.d.ts +33 -0
  48. package/dist/internal/routes/activation-page.js +242 -0
  49. package/dist/internal/routes/index-page.d.ts +13 -0
  50. package/dist/internal/routes/index-page.js +62 -0
  51. package/dist/internal/routes/integration-layout.d.ts +9 -0
  52. package/dist/internal/routes/integration-layout.js +84 -0
  53. package/dist/internal/routes/root-layout.d.ts +34 -0
  54. package/dist/internal/routes/root-layout.js +39 -0
  55. package/dist/internal/routes/single-widget-page.d.ts +6 -0
  56. package/dist/internal/routes/single-widget-page.js +30 -0
  57. package/dist/internal/routes/tile-page.d.ts +1 -0
  58. package/dist/internal/routes/tile-page.js +88 -0
  59. package/dist/internal/routes/triggers-page.d.ts +1 -0
  60. package/dist/internal/routes/triggers-page.js +386 -0
  61. package/dist/internal/routes/widgets-page.d.ts +1 -0
  62. package/dist/internal/routes/widgets-page.js +19 -0
  63. package/dist/internal/surfaces.d.ts +25 -0
  64. package/dist/internal/surfaces.js +29 -0
  65. package/dist/mocks/team-account-workspace.d.ts +78 -0
  66. package/dist/mocks/team-account-workspace.js +86 -0
  67. package/dist/registry.d.ts +418 -0
  68. package/dist/registry.js +82 -0
  69. package/dist/routes.d.ts +24 -0
  70. package/dist/routes.js +15 -0
  71. package/dist/styles.css +236 -0
  72. package/package.json +101 -0
package/README.md ADDED
@@ -0,0 +1,879 @@
1
+ # @ekanos/harness
2
+
3
+ The Ekanos integration dev harness: every surface of an integration rendered in
4
+ real Fusion chrome — the actual dashboard grid, the actual `Widget.*` compound
5
+ components, the actual design tokens — from fixtures, with no Supabase, no auth
6
+ and no network.
7
+
8
+ **New here? Start with the `@ekanos/sdk` README** — it is the authoring
9
+ reference for everything you declare (widgets, tools, webhooks, schedules,
10
+ OAuth, storage, egress). This document covers only the local dev harness that
11
+ renders those surfaces.
12
+
13
+ > **Not published yet.** `@ekanos/harness` and `@ekanos/cli` are not on npm, so
14
+ > the commands below do not resolve today. `@ekanos/sdk`, `@ekanos/ui` and
15
+ > `@ekanos/integration-schema` are (`0.1.2`), and everything in the SDK README
16
+ > works without this package — `defineIntegration()` validates at import and
17
+ > the testing helpers need only vitest. What you cannot do until this ships is
18
+ > *see* your surfaces rendered. This document describes what will ship.
19
+
20
+ You do not normally install this by hand. `ekanos dev` scaffolds a shell around
21
+ it and adds it to your `package.json`.
22
+
23
+ ```bash
24
+ pnpm --filter <your project> exec ekanos dev
25
+ ```
26
+
27
+ - [Why it is a package](#why-it-is-a-package)
28
+ - [The registry is injected, never imported](#the-registry-is-injected-never-imported)
29
+ - [The registry — `harness.config.ts`](#the-registry--harnessconfigts)
30
+ - [Fixtures](#fixtures)
31
+ - [`HttpFixture` — the request line](#httpfixture--the-request-line)
32
+ - [When there is no fixture for a request](#when-there-is-no-fixture-for-a-request)
33
+ - [`seeds` — the react-query fast path](#seeds--the-react-query-fast-path)
34
+ - [Live mode — real requests to your own API](#live-mode--real-requests-to-your-own-api)
35
+ - [`FetchProvider` — the part you have to write yourself](#fetchprovider--the-part-you-have-to-write-yourself)
36
+ - [Triggers — running webhooks, schedules and OAuth locally](#triggers--running-webhooks-schedules-and-oauth-locally)
37
+ - [`triggerMocks`](#triggermocks)
38
+ - [What ends up in the browser bundle](#what-ends-up-in-the-browser-bundle)
39
+ - [Exports](#exports)
40
+ - [The shell contract](#the-shell-contract)
41
+
42
+ The surfaces this renders — widgets, the tile, the activation form, and the
43
+ webhook / schedule / OAuth declarations behind Triggers — are documented in the
44
+ `@ekanos/sdk` README. This one covers the harness's own contract.
45
+
46
+ ## Why it is a package
47
+
48
+ So that a harness bugfix reaches you as a version bump you pick up with
49
+ `pnpm up`, with zero edits to any file you own. The harness is a dependency,
50
+ not code you copied.
51
+
52
+ | Path | Who owns it |
53
+ |---|---|
54
+ | `harness.config.ts` | **you** — the registry, and the only file you have to edit |
55
+ | your integration source | **you** |
56
+ | the generated `app/**`, `styles/globals.css`, `next.config.mjs` | the CLI; regenerated on upgrade |
57
+ | `@ekanos/harness` | us |
58
+
59
+ ## The registry is injected, never imported
60
+
61
+ A package inside `node_modules` cannot reach a file in your project, so nothing
62
+ here imports your config. The shell passes your array down instead:
63
+
64
+ ```tsx
65
+ // app/harness-shell.tsx — generated
66
+ 'use client';
67
+
68
+ import { RootLayout } from '@ekanos/harness/app';
69
+
70
+ import { harnessIntegrations } from '~/harness.config';
71
+
72
+ export default function HarnessShell(props: { children: React.ReactNode }) {
73
+ return <RootLayout integrations={harnessIntegrations} {...props} />;
74
+ }
75
+ ```
76
+
77
+ `RootLayout` mounts a context provider; every route reads the registry back out
78
+ of it and takes its own `[slug]` from `useParams()`. That is why the generated
79
+ route files are one line each:
80
+
81
+ ```tsx
82
+ export { WidgetsPage as default } from '@ekanos/harness/routes';
83
+ ```
84
+
85
+ `RootLayout` is the client boundary of the whole harness, and has to be: a
86
+ `HarnessIntegration` holds live React component references and MCP `run`
87
+ functions, which cannot be serialized across a server/client edge.
88
+
89
+ ## The registry — `harness.config.ts`
90
+
91
+ This is the only file you edit. It exports an array of `HarnessIntegration`,
92
+ one entry per integration, and each entry lights up four tabbed surfaces at
93
+ `/<slug>` — `widgets`, `tile`, `activation` and `triggers` — plus a
94
+ single-widget page at `/<slug>/widgets/<widgetId>`, which the widget grid links
95
+ into for isolating one widget.
96
+
97
+ ```ts
98
+ import { defineHarnessConfig } from '@ekanos/harness/config';
99
+
100
+ import { acmePayments } from './harness/acme-payments';
101
+
102
+ export const harnessIntegrations = defineHarnessConfig([acmePayments]);
103
+ ```
104
+
105
+ `defineHarnessConfig()` is an identity function. It exists for the inference —
106
+ nothing else.
107
+
108
+ ### `HarnessIntegration`
109
+
110
+ | Field | Required | What it does |
111
+ |---|---|---|
112
+ | `slug` | yes | URL segment, and the id you would ship as your product slug |
113
+ | `name`, `description` | yes | Header chrome and the index page |
114
+ | `widgets` | yes | `HarnessWidget[]` — may be empty |
115
+ | `fixtures` | no | `Partial<Record<FixtureVariant, HttpFixture[]>>` — the recorded HTTP exchanges, per variant. **This is how data gets in** |
116
+ | `tile` | no | `ComponentType<MarketplaceTileProps>` — the marketplace card |
117
+ | `activationForm` | no | `ComponentType<ActivationFormProps>`, rendered with `inline` so it does not open a dialog |
118
+ | `definition` | no | Your validated `defineIntegration()` output. Powers Triggers |
119
+ | `triggerMocks` | no | Seeds for the Triggers surface's mock context |
120
+ | `live` | no | Opt in to real third-party requests |
121
+
122
+ ### `HarnessWidget`
123
+
124
+ | Field | Required | What it does |
125
+ |---|---|---|
126
+ | `id` | yes | Stable id; the URL segment on `/<slug>/widgets/<widgetId>` |
127
+ | `title` | yes | Rendered in the widget header chrome |
128
+ | `component` | yes | `ComponentType<IntegrationComponentProps>` |
129
+ | `width` | no | `'half'` (default) or `'full'`. Runs of `half` balance across two columns |
130
+ | `isCollapsible` | no | Renders the collapse button. Off unless set |
131
+ | `isPinnable` | no | Reaches `WidgetContext`; nothing draws a pin — see below |
132
+ | `aiFooterEnabled` | no | Renders the "Ask about this" AI footer bar. Off unless set |
133
+ | `seeds` | no | `Partial<Record<FixtureVariant, FixtureSeed[]>>` — react-query cache seeds for this widget, per variant. The fast path, not the default; see [Fixtures](#fixtures) |
134
+
135
+ All three chrome flags are opt-in and behave as the type reads: omit one and
136
+ you get nothing. The harness applies the host's own default (`?? false`) in its
137
+ copy of the host's grid, so what renders here is what renders in production.
138
+
139
+ One of them, though, renders nothing even when you do set it:
140
+
141
+ **`isPinnable` is plumbed end to end with no consumer.** It reaches
142
+ `WidgetContext` as `meta.isPinnable`, the current value is `state.pinned`, and
143
+ `actions.togglePinned()` works — but no shipped `Widget.*` component draws a
144
+ pin control.
145
+
146
+ **This is faithful, not a harness gap.** The real dashboard does not render a
147
+ pin in widget chrome either; it drives pinning from its own loader and
148
+ customize panel, outside the widget. So if you want a pin *inside* your widget,
149
+ it is yours to build, in the harness and in production alike:
150
+
151
+ ```tsx
152
+ const ctx = use(WidgetContext);
153
+ {ctx?.meta.isPinnable && (
154
+ <button onClick={ctx.actions.togglePinned}>
155
+ {ctx.state.pinned ? 'Unpin' : 'Pin'}
156
+ </button>
157
+ )}
158
+ ```
159
+
160
+ ### Derive the widget list — don't write it twice
161
+
162
+ `HarnessWidget` repeats `id`, `component`, `isCollapsible`, `isPinnable` and
163
+ `aiFooterEnabled` from `components.widgets[]` in your `defineIntegration()`
164
+ output, renaming `name` to `title`. Writing both by hand is two declarations of
165
+ the same thing with nothing detecting drift, so don't:
166
+
167
+ ```ts HarnessIntegration
168
+ import { harnessWidgetsFromDefinition } from '@ekanos/harness/registry';
169
+
170
+ widgets: harnessWidgetsFromDefinition(acmeDefinition, {
171
+ 'acme-payments-summary': { width: 'full' },
172
+ }),
173
+ ```
174
+
175
+ The definition supplies id, title, component and the chrome flags. The
176
+ overrides map supplies what is genuinely harness-only — `width`, and per-widget
177
+ `seeds` if you use them — and is **keyed by widget id**, so `id` is not a field
178
+ inside the override value. An override wins over the definition where both
179
+ speak.
180
+
181
+ Your HTTP fixtures are not in here. They sit on the integration
182
+ (`HarnessIntegration.fixtures`), one level up, because a URL is not owned by
183
+ one widget.
184
+
185
+ Pass your definition straight in, generic and all — unlike the `definition:`
186
+ field on the same entry, this does **not** need `asHarnessDefinition()` (the
187
+ generic-erasing wrapper the Triggers section below explains). That
188
+ asymmetry is real and easy to trip on: the field stores the definition in a
189
+ heterogeneous array and so needs the generic erased, while the helper only
190
+ reads it.
191
+
192
+ Two properties worth knowing, both deliberate:
193
+
194
+ - **An override key that matches no declared widget throws**, naming the key
195
+ and listing the ids that do exist. A typo'd id would otherwise produce a
196
+ widget rendering with none of its overrides and no complaint — the same
197
+ silent-drift class the helper exists to kill.
198
+ - **A chrome flag your definition does not declare is omitted, not defaulted**,
199
+ so it still lands on the host's default rather than one the helper invented.
200
+
201
+ **Call this unless you have a reason not to.** The manual form is fully
202
+ supported and `widgets` is still an ordinary `HarnessWidget[]` — the helper
203
+ just returns one, so you can spread it and add entries. That is the reason to
204
+ reach for the manual form: a scratch widget or a variant you are trying out
205
+ that is deliberately *not* in your definition yet. The harness is exactly where
206
+ you should be able to try something before declaring it.
207
+
208
+ ```ts HarnessIntegration
209
+ widgets: [
210
+ ...harnessWidgetsFromDefinition(acmeDefinition, { /* … */ }),
211
+ { id: 'scratch-experiment', title: 'Scratch', component: Experiment },
212
+ ],
213
+ ```
214
+
215
+ **Why any of this is needed: the harness never reads
216
+ `definition.components.widgets`.** It touches your definition in four places —
217
+ the egress cross-check, twice in the Triggers surface, and once on the
218
+ activation page for `capabilities`/`permissions`. Widgets are not among them,
219
+ so on this surface the registry is the *only* declaration and the definition's
220
+ widget flags are inert. The helper is how you make the definition the source
221
+ anyway.
222
+
223
+ One thing the helper does **not** unify, because the two are genuinely
224
+ different: **widget sizing is two unrelated systems.**
225
+ `components.widgets[].layouts` (`lg`/`md`/`sm` grid rectangles, in the
226
+ definition) is what the real dashboard reads. `HarnessWidget.width`
227
+ (`'half' | 'full'`, in the registry) is what the harness reads. Setting
228
+ `layouts` changes nothing here and setting `width` changes nothing in
229
+ production, so set both and expect neither to validate the other.
230
+
231
+ Your widgets are rendered with `HARNESS_ACCOUNT_ID`, `HARNESS_SOURCE_ID` and
232
+ `HARNESS_ACCOUNT_SLUG`, all exported from `@ekanos/harness/registry`. They are
233
+ plausible UUIDs rather than sentinels like `'preview-mode'`, so a widget that
234
+ validates the shape of its `accountId` is happy. Nothing reads them — there is
235
+ no database.
236
+
237
+ ## Fixtures
238
+
239
+ **In fixtures mode the harness answers every request from recorded HTTP
240
+ exchanges and refuses the network.** It patches `globalThis.fetch` itself, so
241
+ this holds however your widgets fetch — react-query, SWR, a bare `useEffect`, a
242
+ promise started in render and read with `use()`. There is no data layer to opt
243
+ into and nothing to wire.
244
+
245
+ You declare those exchanges on the **integration**, keyed by variant:
246
+
247
+ ```ts
248
+ import type { HarnessIntegration } from '@ekanos/harness/registry';
249
+
250
+ import { TidepoolForecast, TidepoolNow, TidepoolNowEmpty } from './fixtures';
251
+ import { ForecastWidget, NowWidget } from './widgets';
252
+
253
+ const API = 'https://api.tidepool.example.com';
254
+
255
+ export const tidepool: HarnessIntegration = {
256
+ slug: 'tidepool',
257
+ name: 'Tidepool',
258
+ description: 'Tide readings and forecast.',
259
+
260
+ fixtures: {
261
+ default: [
262
+ { request: `GET ${API}/v1/tides/current`, response: TidepoolNow },
263
+ { request: `GET ${API}/v1/tides/forecast`, response: TidepoolForecast },
264
+ ],
265
+ empty: [
266
+ { request: `GET ${API}/v1/tides/current`, response: TidepoolNowEmpty },
267
+ {
268
+ request: `GET ${API}/v1/tides/forecast`,
269
+ response: { station: '9414290', entries: [] },
270
+ },
271
+ ],
272
+ error: [
273
+ { request: `GET ${API}/v1/tides/current`, status: 503 },
274
+ { request: `GET ${API}/v1/tides/forecast`, status: 503 },
275
+ ],
276
+ },
277
+
278
+ widgets: [
279
+ { id: 'tidepool-now', title: 'Tide Now', component: NowWidget },
280
+ { id: 'tidepool-forecast', title: 'Tide Forecast', component: ForecastWidget },
281
+ ],
282
+ };
283
+ ```
284
+
285
+ **They sit on the integration, not the widget, because a URL is not owned by
286
+ one.** The same `GET /v1/payouts` may answer one widget, a second widget, and a
287
+ webhook handler on the Triggers surface — and it should be written once. The
288
+ Triggers surface reads this same list, so `ctx.fetch` inside a webhook or
289
+ schedule handler resolves against the fixtures your widgets already use.
290
+
291
+ There are exactly three variants, switched from the dev toolbar:
292
+
293
+ | Variant | For |
294
+ |---|---|
295
+ | `default` | the populated happy path — declare this one |
296
+ | `empty` | the zero-rows state |
297
+ | `error` | the failure state, as ordinary responses with a failure `status` |
298
+
299
+ `empty` and `error` are optional, and an omitted variant is **not** a fallback
300
+ to `default`: the list for that variant is empty, so every request is refused
301
+ and each widget shows the refusal. That is deliberate — a variant that silently
302
+ served the happy path would look like your empty state working.
303
+
304
+ **Switching the variant only reaches a widget that re-fetches.** The harness
305
+ tears down and rebuilds the whole subtree on a switch, so a widget fetching in
306
+ an effect or through react-query asks again and gets the new recording. A widget
307
+ that starts its promise during render and caches it at MODULE scope — the
308
+ obvious way to stop `use()` re-firing on every render — does not: the module
309
+ outlives the remount, so it keeps serving the first variant's answer while the
310
+ toolbar says `error`. Measured on our own tidepool example, whose forecast
311
+ widget is exactly that shape: three switches, one `forecast` request, happy-path
312
+ data showing under every variant.
313
+
314
+ **The fix is where the promise is created, not how.** Moving the cache into the
315
+ component that calls `use()` — `const [p] = useState(() => fetch(…))` right above
316
+ the `use(p)` — looks right and is worse: a render that suspends is discarded
317
+ before it commits, so the state it created is thrown away and the initializer
318
+ runs again on every retry. Measured on the same three switches, that turns one
319
+ request into twenty-six, and the widget still displays correctly the whole time.
320
+
321
+ Create the promise in the non-suspending **parent** and pass it across the
322
+ Suspense boundary:
323
+
324
+ ```tsx
325
+ function ForecastWidget() {
326
+ const [request] = useState(() => fetchForecast(FORECAST_URL));
327
+
328
+ return (
329
+ <Suspense fallback={<Loading />}>
330
+ <ForecastBody request={request} />
331
+ </Suspense>
332
+ );
333
+ }
334
+ ```
335
+
336
+ The parent commits, so its state survives; the child suspends and retries
337
+ against the same promise. It still starts during render, so nothing about the
338
+ archetype changes.
339
+
340
+ This is a widget-side trap rather than a fixtures one — the recording was
341
+ swapped correctly; nothing asked for it — but it looks exactly like a fixture
342
+ that did not take, so it is worth recognising. Note what does NOT distinguish
343
+ the three states: the rendered output. Stuck, storming and correct all look
344
+ identical on screen, and only a request count tells them apart.
345
+
346
+ ### `HttpFixture` — the request line
347
+
348
+ ```ts-mirror HttpFixture
349
+ interface HttpFixture {
350
+ request: string; // '<METHOD> <url-or-path>'
351
+ response?: unknown; // body; JSON unless already a string
352
+ status?: number; // default 200 with a body, 204 without
353
+ headers?: Record<string, string>;
354
+ }
355
+ ```
356
+
357
+ `request` is a method and a target separated by a space. The method is
358
+ case-insensitive and must be one of `GET`, `HEAD`, `POST`, `PUT`, `PATCH`,
359
+ `DELETE`, `OPTIONS`. The target is one of three forms:
360
+
361
+ | Form | Example | Resolves against |
362
+ |---|---|---|
363
+ | Absolute URL | `GET https://api.acme.com/v1/payouts` | itself — always unambiguous |
364
+ | Path only | `GET /v1/payouts` | your `defineIntegration({ egress })` origin |
365
+ | Host route | `GET /api/integrations/acme/summary` | the harness's own origin |
366
+
367
+ The path-only form exists so you do not maintain a second `baseUrl` beside your
368
+ egress declaration, and it is allowed **only when your definition declares
369
+ exactly one origin**. With none or with two, the harness cannot make a
370
+ defensible guess, so `compileFixture` throws while the surface renders — naming
371
+ your fixture and printing the absolute form to write instead. It never guesses
372
+ and it never skips a fixture it cannot parse.
373
+
374
+ `/api/…` is the exception to both rules: it is the host-route namespace. A
375
+ first-party integration's widgets call `/api/integrations/<slug>/…` rather than
376
+ the vendor directly, because that is where the credential lives. The harness
377
+ serves no backend, so those would 404; a fixture is the only thing that can
378
+ answer one, and it is answered like any other.
379
+
380
+ Matching, in the order the rules apply:
381
+
382
+ - **Method and origin must be equal.**
383
+ - **Path segments must agree in count.** `:param` matches exactly one segment,
384
+ `*` matches the rest (including nothing). So `/v1/payouts` does *not* match
385
+ `/v1/payouts/42`, but `/v1/payouts/:id` and `/v1/payouts/*` both do.
386
+ - **Query is a subset match, or ignored entirely.** If your request line has a
387
+ `?`, every param in it must be present on the real request with the same
388
+ value — so you can pin the two that matter and ignore the other nine. If it
389
+ has no `?`, query is not consulted at all. That is what makes
390
+ `GET https://api.acme.com/v1/payouts` answer a request carrying a page
391
+ cursor, a locale and an API key.
392
+ - **First declaration wins.** Write the specific fixture above the general one.
393
+
394
+ The response side has defaults chosen so the common cases are one line.
395
+ `{ request: 'DELETE /v1/thing/:id' }` is a complete fixture: no body, so 204. A
396
+ `response` that is a string is sent as-is with `text/plain`; anything else is
397
+ JSON-serialized with `application/json`. `status` overrides the default, which
398
+ is how an error variant is written — `{ request: 'GET /v1/payouts', status: 503 }`
399
+ is an ordinary recorded response rather than a special code path.
400
+
401
+ One constraint comes from the shell rather than from fixtures:
402
+ `harness.config.ts` is imported from both a server component and a client
403
+ component, so **everything reachable from it must be importable in both
404
+ graphs.** A fixture module carrying a `'use client'` directive compiles and then
405
+ fails at request time with `Attempted to call … from the server`. Keep fixture
406
+ data and URL constants in a plain module with no directive; put hooks and
407
+ components in the client ones.
408
+
409
+ ### When there is no fixture for a request
410
+
411
+ The request is **refused, not sent** — that is the promise fixtures mode makes,
412
+ and it is unconditional. Three things happen, and they are worth being able to
413
+ recognise:
414
+
415
+ 1. **The console gets a line naming the URL**, from the harness rather than
416
+ from your code:
417
+
418
+ ```
419
+ [harness:tidepool] refused GET https://api.tidepool.example.com/v1/tides/current?…
420
+ ```
421
+
422
+ Every third-party call is logged this way — `fixture`, `network` (live mode
423
+ only) or `refused` — so the console is the fastest place to see what a
424
+ widget actually asked for versus what you recorded.
425
+
426
+ 2. **The `fetch` call rejects with `NoRecordedResponseError`**, whose message
427
+ quotes the fixture to paste:
428
+
429
+ ```
430
+ No recorded response for GET https://api.tidepool.example.com/v1/tides/current?….
431
+
432
+ Fixtures mode answers every request from your registry entry and
433
+ never leaves the machine, so this call was refused rather than sent.
434
+ Record it:
435
+
436
+ fixtures: {
437
+ default: [{ request: 'GET https://api.tidepool.example.com/v1/tides/current', response: /* … */ }],
438
+ }
439
+
440
+ To exercise the failure branch instead, give it a status:
441
+
442
+ { request: 'GET https://api.tidepool.example.com/v1/tides/current', status: 403 }
443
+
444
+ The query string is hidden above because it can carry
445
+ credentials. It is left off the line to paste on purpose: a
446
+ request with no `?` matches whatever query the widget sends.
447
+ Add `?key=value` only to pin params you want matched.
448
+
449
+ If this URL looks unfamiliar, log it from the code that builds it —
450
+ the path and any pinned query params must match exactly.
451
+ ```
452
+
453
+ 3. **Where you see it depends on your own error handling**, because it is an
454
+ ordinary rejected `fetch`. A react-query widget lands in its error state
455
+ (the harness sets `retry: false`, so immediately rather than after three
456
+ backoffs). A widget that starts a promise in render and reads it with
457
+ `use()` throws, and if it does not catch it itself the harness's per-widget
458
+ error boundary renders the whole message in the card — one broken widget,
459
+ not a broken page.
460
+
461
+ **Credentials are redacted from what is printed.** Any userinfo in the URL is
462
+ stripped and the entire query string is replaced with `?…`, in the console line
463
+ and in the error alike, because an API key in a query param is exactly the thing
464
+ that ends up in a screenshot. The quoted `request:` line therefore has no query
465
+ on it — which is correct as written, since a fixture with no `?` matches
466
+ whatever query your widget sends. Add `?key=value` back only to pin a param you
467
+ want matched.
468
+
469
+ When the refused request was a **host route**, the quoted line is the path form
470
+ (`GET /api/integrations/acme/summary`) even though the URL above it is absolute.
471
+ That is deliberate, not an inconsistency: `/api/…` resolves against whatever port
472
+ this harness happens to run on, and quoting that back would pin your fixture to
473
+ a port that is ours and not yours. Paste it as printed.
474
+
475
+ **A URL you do not recognise is usually the real answer.** Log it from the code
476
+ that builds it rather than guessing: the path and any pinned query params have
477
+ to match exactly, and a trailing slash or an extra segment is enough to miss.
478
+
479
+ One thing is deliberately **not** intercepted: same-origin traffic outside
480
+ `/api/`. That is `/_next/static`, the document itself and the `?_rsc=` payloads
481
+ client navigation fetches — the framework's own plumbing, none of it your
482
+ integration talking to an API.
483
+
484
+ **The server render is a partial pass, not an exempt one.** It matters because a
485
+ widget that starts its fetch during render and reads it with `use()` runs there
486
+ too. Every fixture is compiled for that pass **except the `/api/` host-route
487
+ shape**, which is the only one that resolves against the harness's own origin —
488
+ and a server render has none. Absolute and path-only fixtures need no origin, so
489
+ they answer on the server exactly as they do in the browser.
490
+
491
+ A host-route request during a server render is therefore refused, with a message
492
+ that says so and tells you there is nothing to change in your registry entry —
493
+ the client render compiles that fixture and answers it, so what you see after
494
+ hydration is the real result. If you need it answered on the server too, write
495
+ the fixture and the widget's URL both absolute, at the cost of hardcoding the
496
+ dev server's origin.
497
+
498
+ Same-origin traffic **outside** `/api/` gets a third message, and it is the one
499
+ worth reading carefully: there is no fixture to write for it. The client passes
500
+ that traffic straight through as framework plumbing, so it is never refused in
501
+ the browser — only the server pass has nowhere to send it. If the request was
502
+ meant to reach a vendor, give it an absolute URL and it becomes an ordinary
503
+ fixture in both passes.
504
+
505
+ ### `seeds` — the react-query fast path
506
+
507
+ `HarnessWidget.seeds` writes straight into the react-query cache, so a widget
508
+ paints populated with **no request at all**:
509
+
510
+ ```ts HarnessIntegration
511
+ widgets: [
512
+ {
513
+ id: 'acme-payments-summary',
514
+ title: 'Payments Summary',
515
+ component: AcmeSummaryWidget,
516
+ seeds: {
517
+ default: [{ queryKey: summaryKey, data: acmeSummaryFixture }],
518
+ empty: [{ queryKey: summaryKey, data: acmeSummaryEmptyFixture }],
519
+ error: [{ queryKey: summaryKey, error: new Error('Acme returned 503') }],
520
+ },
521
+ },
522
+ ],
523
+ ```
524
+
525
+ A `FixtureSeed` is `{ queryKey, data }` or `{ queryKey, error }`. Set both and
526
+ `error` wins.
527
+
528
+ **This is the fastest seam and the narrowest.** It only works if your data layer
529
+ is `@tanstack/react-query`, and it addresses data by query key rather than by
530
+ what your vendor returns — so the same payload has to be written again, in a
531
+ different shape, for anything that is not a widget. Reach for it when you want a
532
+ specific widget to skip the request entirely; otherwise record the exchange.
533
+
534
+ Two rules if you do use it:
535
+
536
+ - **Derive the key from the same factory your hook calls.** Retyping a key array
537
+ gives you a widget that silently renders empty when someone renames a key;
538
+ importing the factory makes it a compile error.
539
+ - **A seeded key never fetches, so an HTTP fixture behind it is unreachable.**
540
+ Seeding short-circuits the request. Use one or the other for a given key, not
541
+ both.
542
+
543
+ There is one error a widget can hit that no HTTP fixture can answer:
544
+ `MissingFixtureError`, thrown when a query has neither a seed nor a `queryFn` of
545
+ its own. Nothing makes a request in that case, so there is nothing to record —
546
+ seed the key.
547
+
548
+ Finally, `HarnessWidget.seeds` was called `fixtures` before HTTP fixtures
549
+ existed. It was renamed rather than overloaded: two fields called `fixtures` one
550
+ level apart, meaning different things, is a trap worth spending a rename to
551
+ avoid.
552
+
553
+ ### Why the format is HTTP and not query keys
554
+
555
+ Both of the problems it fixes are ones you would otherwise hit on your first
556
+ integration:
557
+
558
+ - **You authored the same vendor data twice, in two shapes, with nothing
559
+ relating them.** A seed takes `data` in the shape your hook *returns* —
560
+ domain-shaped, post-parse. A `triggerMocks.fetchHandlers` entry returns a
561
+ `Response`, so it is wire-shaped. The shipped Acme example used to carry both:
562
+ one export feeding the handler's JSON envelope, separate exports feeding the
563
+ widget seeds. Change one and the other went stale silently, and then the
564
+ widget and the webhook handler disagreed about the same account.
565
+ - **Only react-query users could express a fixture at all.** `queryKey` was the
566
+ only way to address one, so a partner fetching with SWR, in a server component
567
+ with plain `fetch`, or from a `useEffect` had an inert fixtures surface — the
568
+ harness mocking a cache they did not have rather than the network they did.
569
+ That was also the population whose requests reached the real internet, because
570
+ the interception was mounted on the same seams the fixtures were.
571
+
572
+ Recording the HTTP boundary fixes both at once: one declaration in one shape,
573
+ serving the widget and the handlers alike, with no opinion about how your
574
+ components fetch. `@tanstack/react-query` is still a hard peer of `@ekanos/sdk`
575
+ — `useActivateIntegration` and `useOAuthConnectionStatus` are built on it, and
576
+ the harness mounts `QueryClientProvider` unconditionally so the activation
577
+ surface works — so you install it either way. You just no longer have to build
578
+ on it to write a fixture.
579
+
580
+ ## Live mode — real requests to your own API
581
+
582
+ The harness mocks Fusion, not your vendor. Declare a `live` block and the
583
+ toolbar grows a fixtures ⇄ live switch: in live mode the harness stops seeding
584
+ your data keys, your widgets' own query functions run, and their requests reach
585
+ the real API. Fusion stays mocked either way — still no Supabase, no auth, no
586
+ server actions.
587
+
588
+ ```ts HarnessIntegration
589
+ live: {
590
+ // No `egress` here: the harness reads it off `definition`.
591
+ FetchProvider: AcmeFetchProvider,
592
+ seeds: [{ queryKey: settingsKey, data: acmeSettingsFixture }],
593
+ },
594
+ ```
595
+
596
+ **The guarantee: live mode enforces the egress your definition declares — not
597
+ a second list you maintain beside it.** A request to an origin your definition
598
+ did not declare throws `EgressDeniedError` on your laptop, carrying the message
599
+ a reviewer would have seen at promotion. Flipping the toolbar to live is
600
+ therefore a real test of the declaration under review, not of a convenience
601
+ copy that happens to sit next to it.
602
+
603
+ **So when you set `definition`, do not set `live.egress`.** Supplying both is a
604
+ hard error naming your slug, not a silent preference — a silent preference is
605
+ exactly how the two lists became able to disagree. The harness resolves the
606
+ allowlist from `definition.egress` centrally (`resolveEgress`), so there is only
607
+ one list and it is the one a reviewer reads.
608
+
609
+ `live.egress` survives for the one case with nothing to read: an entry with no
610
+ `definition` linked. There it is the only source, and `findEgressMismatch` —
611
+ which reports a `live.egress` wider than the definition's — is what still has
612
+ work to do. A definition declaring more than live mode exercises is fine; the
613
+ reverse is the fault, and while it stands the harness forces fixtures mode and
614
+ says so on the surface.
615
+
616
+ **`seeds` are the keys that stay mocked in live mode.** Fusion-side state your
617
+ widgets read before they can call anything: a saved location, an account
618
+ preference, whatever your own host route would have returned. Everything not
619
+ listed runs its real query function. That one line is the whole boundary the
620
+ design draws: Fusion mocked, vendor real.
621
+
622
+ An integration with no `live` block is fixtures-only; the toolbar control is
623
+ disabled for it and says why.
624
+
625
+ ### `FetchProvider` — the part you have to write yourself
626
+
627
+ > **Optional.** The harness patches `globalThis.fetch`, so it reaches your
628
+ > requests whether or not you supply this — and if you do supply one, your
629
+ > fetch still gets wrapped, because the patch sits underneath it. It is not an
630
+ > escape hatch in either direction.
631
+ >
632
+ > Write it anyway if the shape fits your code: it is how you keep one path
633
+ > serving `ctx.fetch` on the server and a browser fetch on the client, which
634
+ > is useful well outside the harness.
635
+
636
+
637
+ This is the awkward bit, and there is no way around it today. `ctx.fetch` — the
638
+ egress-allowlisted fetch — is handed to server-side handlers only. **The SDK
639
+ has no client-side equivalent.** So a widget that talks to a vendor API straight
640
+ from the browser has nowhere to get one, and needs a seam of its own before
641
+ live mode can hand it an allowlisted fetch.
642
+
643
+ The seam is a React context whose default is the browser's own `fetch`, so
644
+ nothing changes for a consumer that never mounts the provider. It is about
645
+ twenty lines, and this is all of them:
646
+
647
+ ```tsx
648
+ 'use client';
649
+
650
+ import { type ReactNode, createContext, use } from 'react';
651
+
652
+ import type { IntegrationFetch } from '@ekanos/sdk';
653
+
654
+ const browserFetch: IntegrationFetch = (input, init) =>
655
+ globalThis.fetch(input as RequestInfo, init);
656
+
657
+ const AcmeFetchContext = createContext<IntegrationFetch>(browserFetch);
658
+
659
+ export function AcmeFetchProvider({
660
+ fetch,
661
+ children,
662
+ }: {
663
+ fetch: IntegrationFetch;
664
+ children: ReactNode;
665
+ }) {
666
+ return <AcmeFetchContext value={fetch}>{children}</AcmeFetchContext>;
667
+ }
668
+
669
+ /** The fetch this integration's query functions should use. */
670
+ export function useAcmeFetch(): IntegrationFetch {
671
+ return use(AcmeFetchContext);
672
+ }
673
+ ```
674
+
675
+ Then have your query functions **take an `IntegrationFetch` as an argument**
676
+ rather than reaching for a global:
677
+
678
+ ```ts
679
+ import type { IntegrationFetch } from '@ekanos/sdk';
680
+
681
+ export async function fetchAcmeSummary(fetch: IntegrationFetch, accountId: string) {
682
+ const response = await fetch(`https://api.acme.example/v1/summary?a=${accountId}`);
683
+ if (!response.ok) throw new Error(`Acme returned ${response.status}.`);
684
+ return response.json();
685
+ }
686
+
687
+ // in the hook
688
+ const fetch = useAcmeFetch();
689
+ useQuery({ queryKey: acmeKeys.summary(accountId), queryFn: () => fetchAcmeSummary(fetch, accountId) });
690
+ ```
691
+
692
+ That is the value of the pattern, beyond the harness: the *same* code path
693
+ serves `ctx.fetch` on the server and an allowlisted browser fetch on the
694
+ client.
695
+
696
+ The provider's props are fixed by the type — `{ fetch: IntegrationFetch;
697
+ children: ReactNode }` — because the harness mounts it. Omit `FetchProvider`
698
+ entirely if your widgets fetch only through your own host routes: fixtures mode
699
+ answers those (record them under `/api/…`), but live mode cannot run them —
700
+ there is no Supabase and no `ctx` to execute a route with — so live mode has
701
+ nothing to offer you yet.
702
+
703
+ This should be SDK surface, not partner surface. It is on the list.
704
+
705
+ ## Triggers — running webhooks, schedules and OAuth locally
706
+
707
+ Set `definition` on the registry entry and the Triggers surface reads your
708
+ declared webhooks, schedules and OAuth straight off it. Omit it and the surface
709
+ explains what to add.
710
+
711
+ ```ts HarnessIntegration
712
+ import { asHarnessDefinition } from '@ekanos/harness/registry';
713
+
714
+ import { integration as acmeDefinition } from '@you/acme-payments/integration';
715
+
716
+ definition: asHarnessDefinition(acmeDefinition),
717
+ ```
718
+
719
+ `asHarnessDefinition()` erases the storage generic so the definition can sit in
720
+ the heterogeneous registry array — the same erasure the host performs when it
721
+ registers you. Nothing rests on the generic surviving: the mock context is built
722
+ FROM the definition's own `storage` schemas and validates every read and write
723
+ against them at runtime.
724
+
725
+ What the surface then does:
726
+
727
+ - **Webhooks** get a payload editor, seeded from `examplePayload`. Delivering
728
+ runs your REAL handler. The payload is validated against `payloadSchema`
729
+ first; an invalid one never reaches the handler. Signature verification is
730
+ logged as skipped — it is the transport's job in every environment.
731
+ - **Schedules** get a "Run now" button, which invokes the handler with
732
+ `trigger: 'manual'`.
733
+ - **OAuth** gets a read-only declaration readout plus an egress-coverage check
734
+ on both endpoint origins. There is no local authorize→callback loop yet; unit
735
+ test `onTokens` directly with `createMockContext()` until there is.
736
+
737
+ Every invocation runs against **one** `createMockContext()` per visit, so state
738
+ accumulates across invocations the way it would in a real account. Leaving the
739
+ page resets it.
740
+
741
+ ### `triggerMocks`
742
+
743
+ The mock context is derived from the definition — slug, storage schemas, egress
744
+ — and from your `fixtures`, which answer `ctx.fetch` here exactly as they answer
745
+ a widget. `triggerMocks` supplies the rest: whatever Fusion-side state your
746
+ handlers need to run a happy path.
747
+
748
+ **So do not re-author your vendor payloads as `fetchHandlers`.** That was the
749
+ double-authoring HTTP fixtures exist to kill — the same data in a domain shape
750
+ for widgets and a wire shape for handlers, with nothing relating them. Reach for
751
+ `fetchHandlers` only when a response has to be dynamic or stateful; an explicit
752
+ handler is tried first and wins for the URLs it matches, so you keep fixtures
753
+ for everything else.
754
+
755
+ One behaviour worth knowing: a handler calling an endpoint nothing answers gets
756
+ the same named `NoRecordedResponseError` a widget gets. `createMockContext()`'s
757
+ own fallback is a plausible empty `200` — the trigger-side twin of a silent
758
+ fixture miss — and the harness replaces it.
759
+
760
+ ```ts HarnessIntegration
761
+ triggerMocks: {
762
+ // Storage rows per scope, validated against your declared schemas.
763
+ storage: {
764
+ account: { config: { merchantId: 'mrc_4820193', environment: 'sandbox' } },
765
+ user: {},
766
+ },
767
+ // Secrets by tier. `account` is writable via ctx.secrets.set(); `admin`
768
+ // stands in for admin-issued credentials and rejects writes.
769
+ secrets: {
770
+ account: { acme_api_key: 'acme_test_9f2c41ab' },
771
+ },
772
+ // ONLY for a response that has to be dynamic or stateful — your `fixtures`
773
+ // already answer ctx.fetch. A string `match` matches URLs that start with
774
+ // it; a RegExp is tested against the full URL. Tried before the fixtures.
775
+ fetchHandlers: [
776
+ {
777
+ match: 'https://api.acme.example',
778
+ respond: () => Response.json({ payments: [], totalCount: 0 }),
779
+ },
780
+ ],
781
+ },
782
+ ```
783
+
784
+ There is no Supabase and no network in here either way. Keep the values fake —
785
+ see the bundle note below.
786
+
787
+ ## What ends up in the browser bundle
788
+
789
+ **Everything in your registry does, including `triggerMocks.secrets`.**
790
+
791
+ The shell's injection module is a client module — it has to be, because the
792
+ registry holds live React component references — so the whole of
793
+ `harness.config.ts` is compiled into the browser bundle and written into the
794
+ `.next` build output on disk. There is no boundary that can prevent this while
795
+ the registry still reaches the routes, so the harness warns at startup rather
796
+ than pretending otherwise:
797
+
798
+ ```
799
+ [harness] acme-widgets seed triggerMocks.secrets, and those literals are
800
+ compiled into the browser bundle and written into the .next build output …
801
+ ```
802
+
803
+ In practice this is your own mock values on your own machine, which is why it
804
+ is a warning and not a refusal. Two things follow from it:
805
+
806
+ - **Keep trigger mocks fake.** They are seeds for a local mock context, not
807
+ credentials — nothing in the harness ever authenticates.
808
+ - **Do not commit or deploy the generated build directory.** `.ekanos/` is
809
+ gitignored for you; the `.next` output inside it is a build artifact.
810
+
811
+ Related, and deliberate: the activation surface hides credential-shaped fields
812
+ in its payload readout behind a **Reveal values** toggle, and the fixture
813
+ activation actions log a redacted copy. That readout is the one place a real
814
+ API token predictably appears on screen.
815
+
816
+ ## Exports
817
+
818
+ | Subpath | Contents |
819
+ |---|---|
820
+ | `@ekanos/harness/registry` | `HarnessIntegration`, `HarnessWidget`, `HttpFixture`, `FixtureSeed`, `FixtureVariant`, `HarnessLiveMode`, `HarnessTriggerMocks`, the `HARNESS_*` ids and the pure helpers. Server-safe; this is the type surface you author against. |
821
+ | `@ekanos/harness/config` | `defineHarnessConfig()` — an identity function that exists for the inference. |
822
+ | `@ekanos/harness/app` | `RootLayout`, `IndexPage`, `harnessMetadata`. |
823
+ | `@ekanos/harness/routes` | `IntegrationLayout`, `WidgetsPage`, `SingleWidgetPage`, `TilePage`, `ActivationPage`, `TriggersPage`. |
824
+ | `@ekanos/harness/styles.css` | The harness's Tailwind layer: the Tailwind entry, the `@ekanos/ui` token preset, the Font Awesome repairs, and the `@source` globs covering everything the chrome renders. |
825
+ | `@ekanos/harness/mocks/team-account-workspace` | The stand-in for the host's `useTeamAccountWorkspace()`. The generated `next.config.mjs` aliases `@kit/team-accounts/hooks/use-team-account-workspace` onto it, so unmodified widget source runs unchanged. |
826
+
827
+ ## The shell contract
828
+
829
+ `package.json` declares `ekanos.shellContract`. `@ekanos/cli` reads it before
830
+ scaffolding and refuses if its templates generate a different one, naming which
831
+ side to upgrade.
832
+
833
+ Bump it when the SHELL this package requires changes — an export renamed or
834
+ moved between `/app` and `/routes`, the props of `RootLayout` or `IndexPage`,
835
+ the server/client split, the alias specifier, the stylesheet's ownership of the
836
+ Tailwind import, or the set of files the shell must contain. A release that only
837
+ fixes a bug inside the package leaves it alone.
838
+
839
+ **A shell change also needs a `@ekanos/cli` release.** The templates live there;
840
+ only the code they import lives here. `ekanos dev` rewrites a generated file
841
+ only when its bytes change, so bumping the harness alone regenerates nothing —
842
+ the old CLI's templates are exactly what the old CLI intends. The contract
843
+ integer is what turns that silent mismatch into a refusal.
844
+
845
+ **What it does NOT cover is your `harness.config.ts`.** The contract is checked
846
+ before the CLI writes the generated shell, so it guards `.ekanos/harness/**` —
847
+ our files. Yours is bootstrapped once and never touched again, deliberately,
848
+ because your fixtures live in it. So if we rename something in the registry
849
+ types you write against, it reaches you as a TypeScript error in a file no tool
850
+ will migrate for you. That is the right trade — we are not editing your file —
851
+ but it means **the release notes are the migration path**, and you should read
852
+ them on a minor bump rather than only on a major. The `HarnessWidget.fixtures`
853
+ → `seeds` rename — made when `HarnessIntegration.fixtures` took the name — is
854
+ exactly this shape.
855
+
856
+ ## Building it
857
+
858
+ ```bash
859
+ pnpm --filter @ekanos/harness build
860
+ ```
861
+
862
+ `@ekanos/ui` must be built first — `styles.css` imports its token preset.
863
+
864
+ The build compiles **per file** rather than bundling (`bundle: false` in
865
+ `tsup.config.ts`), and that is load-bearing in three separate ways: the
866
+ server/client boundary runs *through* the `/app` entry rather than around it,
867
+ `next/font/google` requires its call to survive as a `const` (esbuild's bundler
868
+ rewrites top-level `const` to `var`, which Next rejects outright), and there is
869
+ nothing to inline in the first place. Each of those is a bug we have already
870
+ shipped once; the build config in the source tree carries the long version.
871
+
872
+ `pnpm --filter @ekanos/harness pack:test` re-checks all of that on the actual
873
+ published tarball, in a clean room outside the workspace.
874
+
875
+ ## Licence
876
+
877
+ MIT. Font Awesome is the consuming app's responsibility: this package ships no
878
+ icon-font data, and the generated shell loads Font Awesome **Free**, because
879
+ Fusion's own Pro webfonts are commercially licensed and cannot be redistributed.