@ekanos/harness 0.1.2 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +91 -9
- package/dist/hooks.d.ts +12 -0
- package/dist/hooks.js +9 -0
- package/dist/internal/components/dev-toolbar.js +24 -9
- package/dist/internal/components/harness-providers.js +24 -2
- package/dist/internal/lib/harness-fetch-interceptor.d.ts +24 -9
- package/dist/internal/lib/harness-fetch-interceptor.js +11 -1
- package/dist/internal/lib/harness-hooks.d.ts +30 -0
- package/dist/internal/lib/harness-hooks.js +28 -0
- package/dist/internal/lib/live-activation-store.d.ts +49 -0
- package/dist/internal/lib/live-activation-store.js +34 -0
- package/dist/internal/lib/theme-toggle.d.ts +17 -0
- package/dist/internal/lib/theme-toggle.js +6 -0
- package/dist/internal/routes/activation-page.js +39 -14
- package/dist/internal/routes/integration-layout.js +3 -3
- package/dist/registry.d.ts +66 -3
- package/dist/registry.js +6 -0
- package/package.json +7 -3
package/README.md
CHANGED
|
@@ -419,9 +419,13 @@ recognise:
|
|
|
419
419
|
[harness:tidepool] refused GET https://api.tidepool.example.com/v1/tides/current?…
|
|
420
420
|
```
|
|
421
421
|
|
|
422
|
-
Every third-party call is logged this way — `fixture`, `network` (
|
|
423
|
-
|
|
424
|
-
widget actually asked for versus what you recorded.
|
|
422
|
+
Every third-party call is logged this way — `fixture`, `network` (a vendor
|
|
423
|
+
request in live mode) or `refused` — so the console is the fastest place to
|
|
424
|
+
see what a widget actually asked for versus what you recorded. A same-origin
|
|
425
|
+
`/api/…` request logs `fixture` or `refused` in EITHER toolbar mode: it is
|
|
426
|
+
answered from your registry entry whether the switch says Fixtures or Live
|
|
427
|
+
API, because the harness serves no backend either way. Only genuinely
|
|
428
|
+
third-party traffic can log `network`, and only in live mode.
|
|
425
429
|
|
|
426
430
|
2. **The `fetch` call rejects with `NoRecordedResponseError`**, whose message
|
|
427
431
|
quotes the fixture to paste:
|
|
@@ -622,6 +626,81 @@ design draws: Fusion mocked, vendor real.
|
|
|
622
626
|
An integration with no `live` block is fixtures-only; the toolbar control is
|
|
623
627
|
disabled for it and says why.
|
|
624
628
|
|
|
629
|
+
### Knowing the mode, and the activation-data channel
|
|
630
|
+
|
|
631
|
+
Two things a live-mode widget routinely needs and could not get before:
|
|
632
|
+
**which mode is actually running**, and **a credential to authenticate with**.
|
|
633
|
+
|
|
634
|
+
`FetchProvider` — where you already have one — receives both as props, no
|
|
635
|
+
extra plumbing required:
|
|
636
|
+
|
|
637
|
+
```tsx
|
|
638
|
+
import type { ReactNode } from 'react';
|
|
639
|
+
|
|
640
|
+
import type { IntegrationFetch } from '@ekanos/sdk';
|
|
641
|
+
import type { DataMode } from '@ekanos/harness/registry';
|
|
642
|
+
|
|
643
|
+
export function AcmeFetchProvider({
|
|
644
|
+
fetch,
|
|
645
|
+
mode,
|
|
646
|
+
activationData,
|
|
647
|
+
children,
|
|
648
|
+
}: {
|
|
649
|
+
fetch: IntegrationFetch;
|
|
650
|
+
mode: DataMode;
|
|
651
|
+
activationData: unknown;
|
|
652
|
+
children: ReactNode;
|
|
653
|
+
}) {
|
|
654
|
+
return <AcmeFetchContext value={fetch}>{children}</AcmeFetchContext>;
|
|
655
|
+
}
|
|
656
|
+
```
|
|
657
|
+
|
|
658
|
+
`mode` is `'fixtures' | 'live'` — the EFFECTIVE mode, already accounting for an
|
|
659
|
+
integration with no `live` block or a `live.egress`/definition mismatch (both
|
|
660
|
+
force `'fixtures'`, whatever the toolbar says). `activationData` is whatever
|
|
661
|
+
was last submitted to this integration's activation form — `null` before any
|
|
662
|
+
submission or after Disconnect — held **in memory only**: never written to
|
|
663
|
+
`localStorage`, a fixture, or the registry, and cleared on reload and on
|
|
664
|
+
disconnect. It is a dev-only convenience standing in for `ctx.secrets`, which
|
|
665
|
+
is what production actually hands your server-side handlers.
|
|
666
|
+
|
|
667
|
+
Threading a submitted API token into a live request looks like this:
|
|
668
|
+
|
|
669
|
+
```tsx
|
|
670
|
+
import type { ReactNode } from 'react';
|
|
671
|
+
|
|
672
|
+
import type { IntegrationFetch } from '@ekanos/sdk';
|
|
673
|
+
import type { DataMode } from '@ekanos/harness/registry';
|
|
674
|
+
|
|
675
|
+
export function AcmeFetchProvider({ fetch, activationData, children }: {
|
|
676
|
+
fetch: IntegrationFetch;
|
|
677
|
+
mode: DataMode;
|
|
678
|
+
activationData: unknown;
|
|
679
|
+
children: ReactNode;
|
|
680
|
+
}) {
|
|
681
|
+
const token = (activationData as { apiToken?: string } | null)?.apiToken;
|
|
682
|
+
|
|
683
|
+
const authedFetch: IntegrationFetch = (input, init) =>
|
|
684
|
+
fetch(input, {
|
|
685
|
+
...init,
|
|
686
|
+
headers: {
|
|
687
|
+
...(init?.headers as Record<string, string> | undefined),
|
|
688
|
+
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
689
|
+
},
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
return <AcmeFetchContext value={authedFetch}>{children}</AcmeFetchContext>;
|
|
693
|
+
}
|
|
694
|
+
```
|
|
695
|
+
|
|
696
|
+
No `FetchProvider` mounted, or need the answer somewhere else entirely? Two
|
|
697
|
+
hooks answer the same two questions from `@ekanos/harness/hooks`:
|
|
698
|
+
`useHarnessDataMode()` and `useLiveActivationData()`. Prefer the props above
|
|
699
|
+
wherever you can — they need no import from this package, and behave
|
|
700
|
+
identically whether or not `@ekanos/harness` is even installed at the call
|
|
701
|
+
site. Reach for the hooks only from code that runs nowhere but inside the dev
|
|
702
|
+
harness.
|
|
703
|
+
|
|
625
704
|
### `FetchProvider` — the part you have to write yourself
|
|
626
705
|
|
|
627
706
|
> **Optional.** The harness patches `globalThis.fetch`, so it reaches your
|
|
@@ -693,12 +772,14 @@ That is the value of the pattern, beyond the harness: the *same* code path
|
|
|
693
772
|
serves `ctx.fetch` on the server and an allowlisted browser fetch on the
|
|
694
773
|
client.
|
|
695
774
|
|
|
696
|
-
The provider's props are fixed by the type — `{ fetch: IntegrationFetch;
|
|
697
|
-
children: ReactNode }` — because the harness
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
775
|
+
The provider's props are fixed by the type — `{ fetch: IntegrationFetch; mode:
|
|
776
|
+
DataMode; activationData: unknown; children: ReactNode }` — because the harness
|
|
777
|
+
mounts it (see the previous section for what `mode` and `activationData` are
|
|
778
|
+
for). Omit `FetchProvider` entirely if your widgets fetch only through your own
|
|
779
|
+
host routes: those are answered from fixtures (record them under `/api/…`) in
|
|
780
|
+
EVERY toolbar mode, live included — there is no Supabase and no `ctx` to
|
|
781
|
+
execute a route with, so there is nothing "live" to run for that traffic
|
|
782
|
+
either way.
|
|
702
783
|
|
|
703
784
|
This should be SDK surface, not partner surface. It is on the list.
|
|
704
785
|
|
|
@@ -827,6 +908,7 @@ API token predictably appears on screen.
|
|
|
827
908
|
| `@ekanos/harness/config` | `defineHarnessConfig()` — an identity function that exists for the inference. |
|
|
828
909
|
| `@ekanos/harness/app` | `RootLayout`, `IndexPage`, `harnessMetadata`. |
|
|
829
910
|
| `@ekanos/harness/routes` | `IntegrationLayout`, `WidgetsPage`, `SingleWidgetPage`, `TilePage`, `ActivationPage`, `TriggersPage`. |
|
|
911
|
+
| `@ekanos/harness/hooks` | `useHarnessDataMode()`, `useLiveActivationData()` — see "Knowing the mode, and the activation-data channel" above. Prefer `FetchProvider`'s own `mode`/`activationData` props where you can; reach for these only from code that runs nowhere but inside the dev harness. |
|
|
830
912
|
| `@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. |
|
|
831
913
|
| `@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. |
|
|
832
914
|
|
package/dist/hooks.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public hooks for widget code that only ever runs inside the harness.
|
|
3
|
+
*
|
|
4
|
+
* Most partner code should not need this entry at all: `HarnessLiveMode`'s
|
|
5
|
+
* `FetchProvider` (see `registry.ts`) already receives `mode` and
|
|
6
|
+
* `activationData` as props, which needs no import from this package and
|
|
7
|
+
* behaves the same whether or not `@ekanos/harness` is even installed at the
|
|
8
|
+
* call site. Reach for these two hooks only from code that is genuinely
|
|
9
|
+
* harness-only — a debug banner, a dev-mode-only branch — never from a
|
|
10
|
+
* widget's shipped data-fetching path.
|
|
11
|
+
*/
|
|
12
|
+
export { useHarnessDataMode, useLiveActivationData, } from './internal/lib/harness-hooks.js';
|
package/dist/hooks.js
ADDED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
resolveEgress,
|
|
22
22
|
supportsLiveMode
|
|
23
23
|
} from "../../registry.js";
|
|
24
|
+
import { getThemeToggleAccessibleLabel } from "../lib/theme-toggle.js";
|
|
24
25
|
import {
|
|
25
26
|
RENDER_STATES,
|
|
26
27
|
VIEWPORTS,
|
|
@@ -133,16 +134,30 @@ function DevToolbar() {
|
|
|
133
134
|
size: "sm",
|
|
134
135
|
variant: "outline",
|
|
135
136
|
onClick: () => setTheme(resolvedTheme === "dark" ? "light" : "dark"),
|
|
136
|
-
"aria-label":
|
|
137
|
+
"aria-label": getThemeToggleAccessibleLabel(resolvedTheme),
|
|
137
138
|
children: [
|
|
138
|
-
/* @__PURE__ */ jsxs(
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
139
|
+
/* @__PURE__ */ jsxs(
|
|
140
|
+
"span",
|
|
141
|
+
{
|
|
142
|
+
"aria-hidden": "true",
|
|
143
|
+
className: cn("flex items-center dark:hidden"),
|
|
144
|
+
children: [
|
|
145
|
+
/* @__PURE__ */ jsx(Icon, { name: "fa-solid fa-moon", className: "mr-2 h-4 w-4" }),
|
|
146
|
+
"Dark"
|
|
147
|
+
]
|
|
148
|
+
}
|
|
149
|
+
),
|
|
150
|
+
/* @__PURE__ */ jsxs(
|
|
151
|
+
"span",
|
|
152
|
+
{
|
|
153
|
+
"aria-hidden": "true",
|
|
154
|
+
className: cn("hidden items-center dark:flex"),
|
|
155
|
+
children: [
|
|
156
|
+
/* @__PURE__ */ jsx(Icon, { name: "fa-solid fa-sun-bright", className: "mr-2 h-4 w-4" }),
|
|
157
|
+
"Light"
|
|
158
|
+
]
|
|
159
|
+
}
|
|
160
|
+
)
|
|
146
161
|
]
|
|
147
162
|
}
|
|
148
163
|
),
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
-
import { Suspense, useMemo } from "react";
|
|
3
|
+
import { Suspense, useMemo, useSyncExternalStore } from "react";
|
|
4
4
|
import {
|
|
5
5
|
IntegrationActivationProvider
|
|
6
6
|
} from "@ekanos/sdk/hooks";
|
|
@@ -18,6 +18,13 @@ import {
|
|
|
18
18
|
logHarnessFetch
|
|
19
19
|
} from "../lib/harness-live-fetch.js";
|
|
20
20
|
import { createHarnessQueryClient } from "../lib/harness-query-client.js";
|
|
21
|
+
import {
|
|
22
|
+
clearLiveActivationData,
|
|
23
|
+
getLiveActivationServerSnapshot,
|
|
24
|
+
getLiveActivationSnapshot,
|
|
25
|
+
setLiveActivationData,
|
|
26
|
+
subscribeLiveActivationData
|
|
27
|
+
} from "../lib/live-activation-store.js";
|
|
21
28
|
import { redactSensitive } from "../lib/redact.js";
|
|
22
29
|
import { AskAssistantBridge } from "./ask-assistant-bridge.js";
|
|
23
30
|
installHarnessFetch();
|
|
@@ -27,6 +34,7 @@ const fixtureActivationActions = {
|
|
|
27
34
|
"[harness] activate (fixture, not persisted):",
|
|
28
35
|
redactSensitive(input)
|
|
29
36
|
);
|
|
37
|
+
setLiveActivationData(input.integrationSlug, input.activationData ?? null);
|
|
30
38
|
return { success: true };
|
|
31
39
|
},
|
|
32
40
|
deactivate: async (input) => {
|
|
@@ -34,6 +42,7 @@ const fixtureActivationActions = {
|
|
|
34
42
|
"[harness] deactivate (fixture, not persisted):",
|
|
35
43
|
redactSensitive(input)
|
|
36
44
|
);
|
|
45
|
+
clearLiveActivationData(input.integrationSlug);
|
|
37
46
|
return { success: true };
|
|
38
47
|
}
|
|
39
48
|
};
|
|
@@ -56,6 +65,11 @@ function HarnessProviders({
|
|
|
56
65
|
}
|
|
57
66
|
return createFixturesFetch();
|
|
58
67
|
}, [mode, egress, slug]);
|
|
68
|
+
const activationData = useSyncExternalStore(
|
|
69
|
+
subscribeLiveActivationData,
|
|
70
|
+
() => getLiveActivationSnapshot(slug),
|
|
71
|
+
getLiveActivationServerSnapshot
|
|
72
|
+
);
|
|
59
73
|
useMemo(() => {
|
|
60
74
|
installHarnessFetch();
|
|
61
75
|
publishHarnessRouting({
|
|
@@ -76,7 +90,15 @@ function HarnessProviders({
|
|
|
76
90
|
/* @__PURE__ */ jsx(AskAssistantBridge, {}),
|
|
77
91
|
/* @__PURE__ */ jsx(Suspense, { fallback: null, children })
|
|
78
92
|
] }) }) }) }) });
|
|
79
|
-
return FetchProvider ? /* @__PURE__ */ jsx(
|
|
93
|
+
return FetchProvider ? /* @__PURE__ */ jsx(
|
|
94
|
+
FetchProvider,
|
|
95
|
+
{
|
|
96
|
+
fetch: harnessFetch,
|
|
97
|
+
mode,
|
|
98
|
+
activationData,
|
|
99
|
+
children: tree
|
|
100
|
+
}
|
|
101
|
+
) : tree;
|
|
80
102
|
}
|
|
81
103
|
export {
|
|
82
104
|
HarnessProviders
|
|
@@ -25,19 +25,34 @@ import { type CompiledFixture, NoRecordedResponseError } from './http-fixtures.j
|
|
|
25
25
|
*
|
|
26
26
|
* ── What is deliberately NOT intercepted ─────────────────────────────────────
|
|
27
27
|
*
|
|
28
|
-
* Same-origin traffic passes through untouched —
|
|
28
|
+
* Same-origin traffic outside `/api/` passes through untouched — in EVERY
|
|
29
|
+
* mode, live included.
|
|
29
30
|
*
|
|
30
|
-
* Measured against a running harness,
|
|
31
|
-
*
|
|
31
|
+
* Measured against a running harness, that traffic is `/_next/static`, the
|
|
32
|
+
* document, and the `?_rsc=` payloads client navigation fetches: the
|
|
32
33
|
* framework's own plumbing, none of it the integration talking to an API.
|
|
33
34
|
* Intercepting it would break the app to no purpose.
|
|
34
35
|
*
|
|
35
|
-
* `/api/` is
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
36
|
+
* ── `/api/` is answered from fixtures in EVERY mode ──────────────────────────
|
|
37
|
+
*
|
|
38
|
+
* `/api/` is the one same-origin exception, because it is the integration's
|
|
39
|
+
* own namespace. A first-party integration's widgets call
|
|
40
|
+
* `/api/integrations/<slug>/…` rather than the vendor directly — the vendor
|
|
41
|
+
* call happens server-side, where the credential lives — and our own Acme
|
|
42
|
+
* example is shaped exactly that way.
|
|
43
|
+
*
|
|
44
|
+
* This is answered from the registry's `fixtures` REGARDLESS of the toolbar's
|
|
45
|
+
* fixtures/live switch. The harness serves no backend in either mode, so there
|
|
46
|
+
* is nothing "live" to run for this traffic even when live mode is on: passing
|
|
47
|
+
* it through would guarantee a 404 whichever mode asked for it, and the useful
|
|
48
|
+
* answer — a fixture, or a refusal that names the URL and says to add one — is
|
|
49
|
+
* the one fixtures mode already gives. A miss is `NoRecordedResponseError`,
|
|
50
|
+
* unconditionally, so the failure a partner sees does not change out from
|
|
51
|
+
* under them when they flip the toolbar.
|
|
52
|
+
*
|
|
53
|
+
* Only genuinely third-party traffic — a different origin — is what "live"
|
|
54
|
+
* actually means: reaching the real vendor under the egress allowlist below,
|
|
55
|
+
* instead of a recorded response.
|
|
41
56
|
*
|
|
42
57
|
* Scoped to `/api/` rather than "everything except `/_next/`" deliberately: a
|
|
43
58
|
* rule that has to enumerate the framework's internals is a rule that breaks
|
|
@@ -50,9 +50,19 @@ function installHarnessFetch() {
|
|
|
50
50
|
} catch {
|
|
51
51
|
throw new NoRecordedResponseError(method, target, true);
|
|
52
52
|
}
|
|
53
|
-
|
|
53
|
+
const isSameOrigin = globalThis.location !== void 0 && url.origin === globalThis.location.origin;
|
|
54
|
+
if (isSameOrigin && !url.pathname.startsWith("/api/")) {
|
|
54
55
|
return original(input, init);
|
|
55
56
|
}
|
|
57
|
+
if (isSameOrigin) {
|
|
58
|
+
const fixture2 = matchHttpFixture(method, url.toString(), active.fixtures);
|
|
59
|
+
if (!fixture2) {
|
|
60
|
+
notify(url, method, "refused");
|
|
61
|
+
throw new NoRecordedResponseError(method, redact(url), false);
|
|
62
|
+
}
|
|
63
|
+
notify(url, method, "fixture");
|
|
64
|
+
return fixtureResponse(fixture2);
|
|
65
|
+
}
|
|
56
66
|
if (active.mode === "live") {
|
|
57
67
|
if (!isEgressAllowed(url.toString(), active.egress)) {
|
|
58
68
|
notify(url, method, "refused");
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { type DataMode } from '../../registry.js';
|
|
2
|
+
/**
|
|
3
|
+
* The two public hooks behind `@ekanos/harness/hooks` (see src/hooks.ts).
|
|
4
|
+
*
|
|
5
|
+
* Both exist so widget code can ask "what mode am I in / what did the user
|
|
6
|
+
* just activate with" WITHOUT reaching for `globalThis.fetch` or reimplementing
|
|
7
|
+
* the toolbar's own state. Prefer the props `HarnessLiveMode.FetchProvider`
|
|
8
|
+
* already receives (`mode`, `activationData`) wherever your `FetchProvider`
|
|
9
|
+
* is already in the tree — those need no import from this package, and behave
|
|
10
|
+
* identically whether or not `@ekanos/harness` is even installed at the call
|
|
11
|
+
* site. Reach for these hooks only from code that runs nowhere but inside the
|
|
12
|
+
* dev harness.
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* `'fixtures' | 'live'` for the integration currently rendering — the
|
|
16
|
+
* EFFECTIVE mode, not the toolbar's raw preference. See
|
|
17
|
+
* `resolveEffectiveDataMode` in `registry.ts`: an integration with no `live`
|
|
18
|
+
* block, or one whose `live.egress` mismatches its definition, reports
|
|
19
|
+
* `'fixtures'` here even with the toolbar switched to Live API.
|
|
20
|
+
*/
|
|
21
|
+
export declare function useHarnessDataMode(): DataMode;
|
|
22
|
+
/**
|
|
23
|
+
* The `activationData` most recently submitted to THIS integration's
|
|
24
|
+
* activation form — `null` before any submission, after Disconnect, or while
|
|
25
|
+
* a different integration's activation is the one currently held.
|
|
26
|
+
*
|
|
27
|
+
* See `live-activation-store.ts` for the full contract: in memory only, never
|
|
28
|
+
* persisted, cleared on reload and on disconnect.
|
|
29
|
+
*/
|
|
30
|
+
export declare function useLiveActivationData(): unknown;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useSyncExternalStore } from "react";
|
|
3
|
+
import { resolveEffectiveDataMode } from "../../registry.js";
|
|
4
|
+
import { useHarnessIntegration } from "../registry-context.js";
|
|
5
|
+
import {
|
|
6
|
+
getLiveActivationServerSnapshot,
|
|
7
|
+
getLiveActivationSnapshot,
|
|
8
|
+
subscribeLiveActivationData
|
|
9
|
+
} from "./live-activation-store.js";
|
|
10
|
+
import { useToolbar } from "./toolbar-context.js";
|
|
11
|
+
function useHarnessDataMode() {
|
|
12
|
+
const integration = useHarnessIntegration();
|
|
13
|
+
const { state } = useToolbar();
|
|
14
|
+
return resolveEffectiveDataMode(integration, state.dataMode);
|
|
15
|
+
}
|
|
16
|
+
function useLiveActivationData() {
|
|
17
|
+
const integration = useHarnessIntegration();
|
|
18
|
+
const slug = integration?.slug ?? "";
|
|
19
|
+
return useSyncExternalStore(
|
|
20
|
+
subscribeLiveActivationData,
|
|
21
|
+
() => getLiveActivationSnapshot(slug),
|
|
22
|
+
getLiveActivationServerSnapshot
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
export {
|
|
26
|
+
useHarnessDataMode,
|
|
27
|
+
useLiveActivationData
|
|
28
|
+
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
3
|
+
* THE LIVE-MODE ACTIVATION-DATA CHANNEL — in memory, never persisted
|
|
4
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
5
|
+
*
|
|
6
|
+
* A live-mode widget's own query function has nowhere to get a vendor
|
|
7
|
+
* credential from without this. `ctx.secrets` is server-only, and the
|
|
8
|
+
* activation form's payload used to die the moment its `onSuccess` callback
|
|
9
|
+
* returned. A real partner build found the same workaround every time: a
|
|
10
|
+
* bespoke module-level store, set from the activation form after
|
|
11
|
+
* `useActivateIntegration()` resolved, read by a context the widgets
|
|
12
|
+
* consulted directly (see the Priority Passport reference this generalises —
|
|
13
|
+
* `setLiveCredentials` / `useLiveCredentials`).
|
|
14
|
+
*
|
|
15
|
+
* `fixtureActivationActions.activate` in harness-providers.tsx already HOLDS
|
|
16
|
+
* the submitted `activationData` — it is the function's own argument — so
|
|
17
|
+
* capturing it here costs nothing new. What this module adds is the one thing
|
|
18
|
+
* a bespoke per-integration store cannot: ONE channel every activation flows
|
|
19
|
+
* through (the plain form, the OAuth form — anything built on
|
|
20
|
+
* `useActivateIntegration()`), keyed by `integrationSlug` so switching between
|
|
21
|
+
* two registered integrations without a reload cannot leak one's credential
|
|
22
|
+
* into the other's widgets.
|
|
23
|
+
*
|
|
24
|
+
* The invariants, stated plainly because this is exactly the kind of
|
|
25
|
+
* convenience that leaks into production if it is not:
|
|
26
|
+
*
|
|
27
|
+
* - IN MEMORY ONLY. A module-level variable, nothing else — never written to
|
|
28
|
+
* `localStorage`, a fixture, or the registry.
|
|
29
|
+
* - CLEARED ON RELOAD. There is nothing to clear: the module is
|
|
30
|
+
* re-evaluated and the variable starts back at `null`.
|
|
31
|
+
* - CLEARED ON DISCONNECT. `fixtureActivationActions.deactivate` and the
|
|
32
|
+
* Activation surface's own Disconnect / Reset flow controls all clear the
|
|
33
|
+
* entry for that slug, so a disconnected integration's widgets cannot keep
|
|
34
|
+
* using a credential the UI says is gone.
|
|
35
|
+
* - A DEV-ONLY CONVENIENCE. Production hands your handlers `ctx.secrets`
|
|
36
|
+
* instead — server-side, encrypted, never in the browser at all. This
|
|
37
|
+
* exists only because the harness has no server to hold a secret for the
|
|
38
|
+
* browser to borrow from.
|
|
39
|
+
*/
|
|
40
|
+
/** Recorded the moment an activation flow reports success. See above. */
|
|
41
|
+
export declare function setLiveActivationData(slug: string, activationData: unknown): void;
|
|
42
|
+
/** A no-op for any slug other than the one currently held. */
|
|
43
|
+
export declare function clearLiveActivationData(slug: string): void;
|
|
44
|
+
export declare function subscribeLiveActivationData(listener: () => void): () => void;
|
|
45
|
+
/** `null` when nothing has been submitted for `slug` yet, or it belongs to a different one. */
|
|
46
|
+
export declare function getLiveActivationSnapshot(slug: string): unknown;
|
|
47
|
+
export declare function getLiveActivationServerSnapshot(): null;
|
|
48
|
+
/** Test seam: forget everything, regardless of slug. */
|
|
49
|
+
export declare function resetLiveActivationData(): void;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
let entry = null;
|
|
2
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
3
|
+
function setLiveActivationData(slug, activationData) {
|
|
4
|
+
entry = { slug, activationData: activationData ?? null };
|
|
5
|
+
listeners.forEach((listener) => listener());
|
|
6
|
+
}
|
|
7
|
+
function clearLiveActivationData(slug) {
|
|
8
|
+
if (entry?.slug !== slug) return;
|
|
9
|
+
entry = null;
|
|
10
|
+
listeners.forEach((listener) => listener());
|
|
11
|
+
}
|
|
12
|
+
function subscribeLiveActivationData(listener) {
|
|
13
|
+
listeners.add(listener);
|
|
14
|
+
return () => {
|
|
15
|
+
listeners.delete(listener);
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
function getLiveActivationSnapshot(slug) {
|
|
19
|
+
return entry?.slug === slug ? entry.activationData : null;
|
|
20
|
+
}
|
|
21
|
+
function getLiveActivationServerSnapshot() {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
function resetLiveActivationData() {
|
|
25
|
+
entry = null;
|
|
26
|
+
}
|
|
27
|
+
export {
|
|
28
|
+
clearLiveActivationData,
|
|
29
|
+
getLiveActivationServerSnapshot,
|
|
30
|
+
getLiveActivationSnapshot,
|
|
31
|
+
resetLiveActivationData,
|
|
32
|
+
setLiveActivationData,
|
|
33
|
+
subscribeLiveActivationData
|
|
34
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure label logic for the dev toolbar's theme toggle button, split out of
|
|
3
|
+
* `DevToolbar` so it is testable without a DOM (this package's vitest config
|
|
4
|
+
* runs in `node` and deliberately has no component-rendering tests — see
|
|
5
|
+
* `vitest.config.ts`).
|
|
6
|
+
*
|
|
7
|
+
* The button renders BOTH "Dark"/"Light" label spans and lets CSS's `dark:`
|
|
8
|
+
* variant pick one for sighted users — necessary so the very first frame is
|
|
9
|
+
* already correct (see the comment in `dev-toolbar.tsx` on why `resolvedTheme`
|
|
10
|
+
* is not read during render for the visual label). Without an explicit
|
|
11
|
+
* `aria-label`, a screen reader concatenates BOTH spans' text into the
|
|
12
|
+
* accessible name ("DarkLight"), and the visible text names the CURRENT
|
|
13
|
+
* theme rather than the action the button performs. `getAccessibleLabel`
|
|
14
|
+
* supplies the correct accessible name instead: the ACTION the click
|
|
15
|
+
* performs, not the current state.
|
|
16
|
+
*/
|
|
17
|
+
export declare function getThemeToggleAccessibleLabel(resolvedTheme: string | undefined): string;
|
|
@@ -14,6 +14,11 @@ import {
|
|
|
14
14
|
} from "@ekanos/ui/dialog";
|
|
15
15
|
import { HARNESS_ACCOUNT_ID } from "../../registry.js";
|
|
16
16
|
import { useDefinitionMockContext } from "../lib/definition-mock-context.js";
|
|
17
|
+
import {
|
|
18
|
+
useHarnessDataMode,
|
|
19
|
+
useLiveActivationData
|
|
20
|
+
} from "../lib/harness-hooks.js";
|
|
21
|
+
import { clearLiveActivationData } from "../lib/live-activation-store.js";
|
|
17
22
|
import { hasSensitiveValues, redactSensitive } from "../lib/redact.js";
|
|
18
23
|
import {
|
|
19
24
|
runOnActivateHook
|
|
@@ -30,6 +35,8 @@ function ActivationPage() {
|
|
|
30
35
|
});
|
|
31
36
|
const integration = useHarnessIntegration();
|
|
32
37
|
const { state: toolbar } = useToolbar();
|
|
38
|
+
const mode = useHarnessDataMode();
|
|
39
|
+
const activationData = useLiveActivationData();
|
|
33
40
|
const ctx = useDefinitionMockContext(
|
|
34
41
|
integration ?? { slug: "", name: "", description: "", widgets: [] },
|
|
35
42
|
toolbar.variant
|
|
@@ -79,13 +86,16 @@ function ActivationPage() {
|
|
|
79
86
|
{
|
|
80
87
|
variant: "ghost",
|
|
81
88
|
size: "sm",
|
|
82
|
-
onClick: () =>
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
+
onClick: () => {
|
|
90
|
+
clearLiveActivationData(integration.slug);
|
|
91
|
+
setFlow({
|
|
92
|
+
open: false,
|
|
93
|
+
connected: false,
|
|
94
|
+
result: null,
|
|
95
|
+
revealed: false,
|
|
96
|
+
activationHook: null
|
|
97
|
+
});
|
|
98
|
+
},
|
|
89
99
|
children: "Reset flow"
|
|
90
100
|
}
|
|
91
101
|
)
|
|
@@ -111,13 +121,18 @@ function ActivationPage() {
|
|
|
111
121
|
ConnectedState,
|
|
112
122
|
{
|
|
113
123
|
name: integration.name,
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
124
|
+
mode,
|
|
125
|
+
hasLiveActivationData: activationData !== null,
|
|
126
|
+
onDisconnect: () => {
|
|
127
|
+
clearLiveActivationData(integration.slug);
|
|
128
|
+
setFlow({
|
|
129
|
+
open: true,
|
|
130
|
+
connected: false,
|
|
131
|
+
result: null,
|
|
132
|
+
revealed: false,
|
|
133
|
+
activationHook: null
|
|
134
|
+
});
|
|
135
|
+
}
|
|
121
136
|
}
|
|
122
137
|
) : (
|
|
123
138
|
/*
|
|
@@ -270,6 +285,8 @@ function PermissionList({
|
|
|
270
285
|
}
|
|
271
286
|
function ConnectedState({
|
|
272
287
|
name,
|
|
288
|
+
mode,
|
|
289
|
+
hasLiveActivationData,
|
|
273
290
|
onDisconnect
|
|
274
291
|
}) {
|
|
275
292
|
return /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-5", children: [
|
|
@@ -277,6 +294,14 @@ function ConnectedState({
|
|
|
277
294
|
name,
|
|
278
295
|
" is connected to your workspace."
|
|
279
296
|
] }),
|
|
297
|
+
/* @__PURE__ */ jsx(
|
|
298
|
+
"p",
|
|
299
|
+
{
|
|
300
|
+
className: "text-muted-foreground text-sm",
|
|
301
|
+
"data-test": "activation-mode-notice",
|
|
302
|
+
children: mode === "live" ? hasLiveActivationData ? "The toolbar is on Live API \u2014 this activation data is available to live-mode widgets on this visit." : "The toolbar is on Live API, but this activation submitted no data for live-mode widgets to authenticate with." : "This surface is Fusion-mocked; widgets read fixtures \u2014 switch the toolbar to Live API to exercise real requests."
|
|
303
|
+
}
|
|
304
|
+
),
|
|
280
305
|
/* @__PURE__ */ jsx(
|
|
281
306
|
Button,
|
|
282
307
|
{
|
|
@@ -5,9 +5,9 @@ import { notFound } from "next/navigation";
|
|
|
5
5
|
import { Alert, AlertDescription, AlertTitle } from "@ekanos/ui/alert";
|
|
6
6
|
import {
|
|
7
7
|
findEgressMismatch,
|
|
8
|
+
resolveEffectiveDataMode,
|
|
8
9
|
resolveEgress,
|
|
9
|
-
seedsForMode
|
|
10
|
-
supportsLiveMode
|
|
10
|
+
seedsForMode
|
|
11
11
|
} from "../../registry.js";
|
|
12
12
|
import { HarnessProviders } from "../components/harness-providers.js";
|
|
13
13
|
import { SurfaceNav } from "../components/surface-nav.js";
|
|
@@ -21,7 +21,7 @@ function IntegrationLayout({ children }) {
|
|
|
21
21
|
() => integration ? findEgressMismatch(integration) : null,
|
|
22
22
|
[integration]
|
|
23
23
|
);
|
|
24
|
-
const mode =
|
|
24
|
+
const mode = resolveEffectiveDataMode(integration, state.dataMode);
|
|
25
25
|
const seeds = useMemo(
|
|
26
26
|
() => integration ? seedsForMode(integration, state.variant, mode) : [],
|
|
27
27
|
[integration, state.variant, mode]
|
package/dist/registry.d.ts
CHANGED
|
@@ -193,12 +193,60 @@ export interface HarnessLiveMode {
|
|
|
193
193
|
* the full worked example.
|
|
194
194
|
*
|
|
195
195
|
* (This used to say to omit it if your widgets only call your own host
|
|
196
|
-
* routes, because the harness could not serve those. It can
|
|
197
|
-
* whose request is under `/api/` is answered
|
|
198
|
-
* the
|
|
196
|
+
* routes, because the harness could not serve those. It can, in EITHER
|
|
197
|
+
* mode: a fixture whose request is under `/api/` is answered from your
|
|
198
|
+
* registry entry whether the toolbar says Fixtures or Live API, which is
|
|
199
|
+
* how the Acme example's widgets work. There is nothing "live" to run for
|
|
200
|
+
* that traffic — the harness serves no backend either way.)
|
|
199
201
|
*/
|
|
200
202
|
FetchProvider?: ComponentType<{
|
|
201
203
|
fetch: IntegrationFetch;
|
|
204
|
+
/**
|
|
205
|
+
* `'fixtures' | 'live'` — which mode is actually running right now, so a
|
|
206
|
+
* partner's own provider can expose it to widgets (or just log it) with
|
|
207
|
+
* no context of its own and no import from this package. The
|
|
208
|
+
* `useHarnessDataMode()` hook in `@ekanos/harness/hooks` answers the same
|
|
209
|
+
* question for code that runs outside a mounted `FetchProvider`.
|
|
210
|
+
*/
|
|
211
|
+
mode: DataMode;
|
|
212
|
+
/**
|
|
213
|
+
* The `activationData` most recently submitted to THIS integration's
|
|
214
|
+
* activation form — `null` before any submission, after Disconnect, or
|
|
215
|
+
* while a different integration's activation is the one currently held.
|
|
216
|
+
*
|
|
217
|
+
* The harness's sanctioned live-credential channel: every activation flow
|
|
218
|
+
* (the plain form, the OAuth form — anything built on
|
|
219
|
+
* `useActivateIntegration()`) already receives this as its own argument,
|
|
220
|
+
* and the harness threads it here instead of making every partner
|
|
221
|
+
* reinvent the module-level store that motivated this field. IN MEMORY
|
|
222
|
+
* ONLY — never written to `localStorage`, a fixture, or the registry —
|
|
223
|
+
* and cleared on reload and on Disconnect. See
|
|
224
|
+
* `internal/lib/live-activation-store.ts` for the full contract.
|
|
225
|
+
*
|
|
226
|
+
* A DEV-ONLY CONVENIENCE: production hands your handlers `ctx.secrets`
|
|
227
|
+
* instead, server-side and encrypted. Nothing reaching this prop should
|
|
228
|
+
* be mistaken for that.
|
|
229
|
+
*
|
|
230
|
+
* Worked example — an activation field landing in a live request header:
|
|
231
|
+
*
|
|
232
|
+
* ```tsx
|
|
233
|
+
* export function AcmeFetchProvider({ fetch, activationData, children }: {
|
|
234
|
+
* fetch: IntegrationFetch;
|
|
235
|
+
* mode: DataMode;
|
|
236
|
+
* activationData: unknown;
|
|
237
|
+
* children: ReactNode;
|
|
238
|
+
* }) {
|
|
239
|
+
* const token = (activationData as { apiToken?: string } | null)?.apiToken;
|
|
240
|
+
* const authedFetch: IntegrationFetch = (input, init) =>
|
|
241
|
+
* fetch(input, {
|
|
242
|
+
* ...init,
|
|
243
|
+
* headers: { ...init?.headers, ...(token ? { Authorization: `Bearer ${token}` } : {}) },
|
|
244
|
+
* });
|
|
245
|
+
* return <AcmeFetchContext value={authedFetch}>{children}</AcmeFetchContext>;
|
|
246
|
+
* }
|
|
247
|
+
* ```
|
|
248
|
+
*/
|
|
249
|
+
activationData: unknown;
|
|
202
250
|
children: ReactNode;
|
|
203
251
|
}>;
|
|
204
252
|
/**
|
|
@@ -416,3 +464,18 @@ export declare function resolveEgress(integration: HarnessIntegration): readonly
|
|
|
416
464
|
export declare function supportsLiveMode(integration: HarnessIntegration | null): integration is HarnessIntegration & {
|
|
417
465
|
live: HarnessLiveMode;
|
|
418
466
|
};
|
|
467
|
+
/**
|
|
468
|
+
* The EFFECTIVE data mode for one integration — not the toolbar's raw
|
|
469
|
+
* preference.
|
|
470
|
+
*
|
|
471
|
+
* Live mode has to be forced back to `'fixtures'` whenever the toolbar switch
|
|
472
|
+
* would not mean anything: an integration with no `live` block has nothing to
|
|
473
|
+
* switch, and a `live.egress`/definition mismatch (`findEgressMismatch`) would
|
|
474
|
+
* run the switch against an allowlist production never validated — the false
|
|
475
|
+
* green live mode exists to prevent.
|
|
476
|
+
*
|
|
477
|
+
* `IntegrationLayout` and `useHarnessDataMode()` both call this rather than
|
|
478
|
+
* each recomputing it, so there is exactly one place that decides and the
|
|
479
|
+
* hook can never report a mode other than the one that actually rendered.
|
|
480
|
+
*/
|
|
481
|
+
export declare function resolveEffectiveDataMode(integration: HarnessIntegration | null, toolbarMode: DataMode): DataMode;
|
package/dist/registry.js
CHANGED
|
@@ -64,6 +64,11 @@ function resolveEgress(integration) {
|
|
|
64
64
|
function supportsLiveMode(integration) {
|
|
65
65
|
return integration?.live !== void 0;
|
|
66
66
|
}
|
|
67
|
+
function resolveEffectiveDataMode(integration, toolbarMode) {
|
|
68
|
+
if (!supportsLiveMode(integration)) return "fixtures";
|
|
69
|
+
if (findEgressMismatch(integration)) return "fixtures";
|
|
70
|
+
return toolbarMode;
|
|
71
|
+
}
|
|
67
72
|
export {
|
|
68
73
|
DATA_MODES,
|
|
69
74
|
FIXTURE_VARIANTS,
|
|
@@ -76,6 +81,7 @@ export {
|
|
|
76
81
|
findIntegration,
|
|
77
82
|
findWidget,
|
|
78
83
|
harnessWidgetsFromDefinition,
|
|
84
|
+
resolveEffectiveDataMode,
|
|
79
85
|
resolveEgress,
|
|
80
86
|
seedsForMode,
|
|
81
87
|
supportsLiveMode
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ekanos/harness",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The Ekanos integration dev harness — every surface of an integration rendered in real Fusion chrome, from fixtures, with no Supabase, auth or network.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -36,6 +36,10 @@
|
|
|
36
36
|
"types": "./dist/routes.d.ts",
|
|
37
37
|
"default": "./dist/routes.js"
|
|
38
38
|
},
|
|
39
|
+
"./hooks": {
|
|
40
|
+
"types": "./dist/hooks.d.ts",
|
|
41
|
+
"default": "./dist/hooks.js"
|
|
42
|
+
},
|
|
39
43
|
"./styles.css": "./dist/styles.css",
|
|
40
44
|
"./mocks/team-account-workspace": {
|
|
41
45
|
"types": "./dist/mocks/team-account-workspace.d.ts",
|
|
@@ -62,8 +66,8 @@
|
|
|
62
66
|
"tailwindcss": "^4.0.0"
|
|
63
67
|
},
|
|
64
68
|
"devDependencies": {
|
|
65
|
-
"@ekanos/sdk": "0.1.
|
|
66
|
-
"@ekanos/ui": "0.1.
|
|
69
|
+
"@ekanos/sdk": "0.1.5",
|
|
70
|
+
"@ekanos/ui": "0.1.5",
|
|
67
71
|
"@kit/eslint-config": "0.2.0",
|
|
68
72
|
"@kit/prettier-config": "0.1.0",
|
|
69
73
|
"@kit/tsconfig": "0.1.0",
|