@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.
- package/LICENSE +21 -0
- package/README.md +879 -0
- package/dist/app.d.ts +4 -0
- package/dist/app.js +11 -0
- package/dist/config.d.ts +18 -0
- package/dist/config.js +34 -0
- package/dist/internal/components/ask-assistant-bridge.d.ts +10 -0
- package/dist/internal/components/ask-assistant-bridge.js +50 -0
- package/dist/internal/components/dashboard-grid.d.ts +45 -0
- package/dist/internal/components/dashboard-grid.js +84 -0
- package/dist/internal/components/dev-toolbar.d.ts +15 -0
- package/dist/internal/components/dev-toolbar.js +155 -0
- package/dist/internal/components/harness-providers.d.ts +16 -0
- package/dist/internal/components/harness-providers.js +83 -0
- package/dist/internal/components/harness-widget-provider.d.ts +60 -0
- package/dist/internal/components/harness-widget-provider.js +84 -0
- package/dist/internal/components/i18n-provider.d.ts +9 -0
- package/dist/internal/components/i18n-provider.js +9 -0
- package/dist/internal/components/row-groups.d.ts +23 -0
- package/dist/internal/components/row-groups.js +37 -0
- package/dist/internal/components/surface-nav.d.ts +4 -0
- package/dist/internal/components/surface-nav.js +70 -0
- package/dist/internal/components/viewport-frame.d.ts +14 -0
- package/dist/internal/components/viewport-frame.js +27 -0
- package/dist/internal/components/widget-boundary.d.ts +25 -0
- package/dist/internal/components/widget-boundary.js +44 -0
- package/dist/internal/components/widget-surface.d.ts +20 -0
- package/dist/internal/components/widget-surface.js +76 -0
- package/dist/internal/lib/fonts.d.ts +2 -0
- package/dist/internal/lib/fonts.js +22 -0
- package/dist/internal/lib/harness-fetch-interceptor.d.ts +89 -0
- package/dist/internal/lib/harness-fetch-interceptor.js +101 -0
- package/dist/internal/lib/harness-live-fetch.d.ts +66 -0
- package/dist/internal/lib/harness-live-fetch.js +121 -0
- package/dist/internal/lib/harness-query-client.d.ts +43 -0
- package/dist/internal/lib/harness-query-client.js +103 -0
- package/dist/internal/lib/http-fixtures.d.ts +145 -0
- package/dist/internal/lib/http-fixtures.js +256 -0
- package/dist/internal/lib/i18n.d.ts +2 -0
- package/dist/internal/lib/i18n.js +17 -0
- package/dist/internal/lib/redact.d.ts +33 -0
- package/dist/internal/lib/redact.js +43 -0
- package/dist/internal/lib/toolbar-context.d.ts +59 -0
- package/dist/internal/lib/toolbar-context.js +124 -0
- package/dist/internal/registry-context.d.ts +27 -0
- package/dist/internal/registry-context.js +51 -0
- package/dist/internal/routes/activation-page.d.ts +33 -0
- package/dist/internal/routes/activation-page.js +242 -0
- package/dist/internal/routes/index-page.d.ts +13 -0
- package/dist/internal/routes/index-page.js +62 -0
- package/dist/internal/routes/integration-layout.d.ts +9 -0
- package/dist/internal/routes/integration-layout.js +84 -0
- package/dist/internal/routes/root-layout.d.ts +34 -0
- package/dist/internal/routes/root-layout.js +39 -0
- package/dist/internal/routes/single-widget-page.d.ts +6 -0
- package/dist/internal/routes/single-widget-page.js +30 -0
- package/dist/internal/routes/tile-page.d.ts +1 -0
- package/dist/internal/routes/tile-page.js +88 -0
- package/dist/internal/routes/triggers-page.d.ts +1 -0
- package/dist/internal/routes/triggers-page.js +386 -0
- package/dist/internal/routes/widgets-page.d.ts +1 -0
- package/dist/internal/routes/widgets-page.js +19 -0
- package/dist/internal/surfaces.d.ts +25 -0
- package/dist/internal/surfaces.js +29 -0
- package/dist/mocks/team-account-workspace.d.ts +78 -0
- package/dist/mocks/team-account-workspace.js +86 -0
- package/dist/registry.d.ts +418 -0
- package/dist/registry.js +82 -0
- package/dist/routes.d.ts +24 -0
- package/dist/routes.js +15 -0
- package/dist/styles.css +236 -0
- package/package.json +101 -0
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
class FixtureRequestError extends Error {
|
|
2
|
+
constructor(message) {
|
|
3
|
+
super(message);
|
|
4
|
+
this.name = "FixtureRequestError";
|
|
5
|
+
}
|
|
6
|
+
}
|
|
7
|
+
const METHODS = /* @__PURE__ */ new Set([
|
|
8
|
+
"GET",
|
|
9
|
+
"HEAD",
|
|
10
|
+
"POST",
|
|
11
|
+
"PUT",
|
|
12
|
+
"PATCH",
|
|
13
|
+
"DELETE",
|
|
14
|
+
"OPTIONS"
|
|
15
|
+
]);
|
|
16
|
+
function splitPath(pathname) {
|
|
17
|
+
return pathname.split("/").filter((segment) => segment.length > 0);
|
|
18
|
+
}
|
|
19
|
+
function compileFixture(fixture, egress, selfOrigin) {
|
|
20
|
+
const trimmed = fixture.request.trim();
|
|
21
|
+
const firstSpace = trimmed.indexOf(" ");
|
|
22
|
+
if (firstSpace === -1) {
|
|
23
|
+
throw new FixtureRequestError(
|
|
24
|
+
`Fixture request ${JSON.stringify(fixture.request)} has no method. Write it as '<METHOD> <url>', for example 'GET https://api.example.com/v1/things'.`
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
const method = trimmed.slice(0, firstSpace).toUpperCase();
|
|
28
|
+
const target = trimmed.slice(firstSpace + 1).trim();
|
|
29
|
+
if (!METHODS.has(method)) {
|
|
30
|
+
throw new FixtureRequestError(
|
|
31
|
+
`Fixture request ${JSON.stringify(fixture.request)} starts with ${JSON.stringify(method)}, which is not an HTTP method. Expected one of ${[...METHODS].join(", ")}.`
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
let url;
|
|
35
|
+
if (/^https?:\/\//i.test(target)) {
|
|
36
|
+
url = new URL(target);
|
|
37
|
+
} else {
|
|
38
|
+
if (!target.startsWith("/")) {
|
|
39
|
+
throw new FixtureRequestError(
|
|
40
|
+
`Fixture request ${JSON.stringify(fixture.request)} is neither an absolute URL nor a path starting with '/'.`
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
if (target.startsWith("/api/")) {
|
|
44
|
+
if (selfOrigin === void 0) {
|
|
45
|
+
throw new FixtureRequestError(
|
|
46
|
+
`Fixture request ${JSON.stringify(fixture.request)} addresses the host-route namespace (\`/api/\u2026\`), which resolves against the harness's own origin \u2014 and none was supplied. This is a harness wiring fault, not something to fix in your registry entry.`
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
url = new URL(target, selfOrigin);
|
|
50
|
+
return {
|
|
51
|
+
method,
|
|
52
|
+
origin: url.origin,
|
|
53
|
+
segments: splitPath(url.pathname),
|
|
54
|
+
query: trimmed.includes("?") ? [...url.searchParams.entries()].map(
|
|
55
|
+
([key, value]) => [key, value]
|
|
56
|
+
) : null,
|
|
57
|
+
fixture
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
if (egress.length === 0) {
|
|
61
|
+
throw new FixtureRequestError(
|
|
62
|
+
`Fixture request ${JSON.stringify(fixture.request)} uses the path-only form, which resolves against your integration's \`egress\` \u2014 and your definition declares none.
|
|
63
|
+
|
|
64
|
+
Either write the absolute form:
|
|
65
|
+
{ request: '${method} https://api.example.com${target}' }
|
|
66
|
+
|
|
67
|
+
or declare the origin in \`defineIntegration({ egress })\`, after which the shorthand resolves against it.`
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
if (egress.length > 1) {
|
|
71
|
+
throw new FixtureRequestError(
|
|
72
|
+
`Fixture request ${JSON.stringify(fixture.request)} is path-only, but this integration declares ${egress.length} egress origins (${egress.join(", ")}). The shorthand is only unambiguous with exactly one, so name the origin you meant:
|
|
73
|
+
{ request: '${method} ${egress[0]}${target}' }`
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
url = new URL(target, egress[0]);
|
|
77
|
+
}
|
|
78
|
+
const query = trimmed.includes("?") ? [...url.searchParams.entries()].map(
|
|
79
|
+
([key, value]) => [key, value]
|
|
80
|
+
) : null;
|
|
81
|
+
return {
|
|
82
|
+
method,
|
|
83
|
+
origin: url.origin,
|
|
84
|
+
segments: splitPath(url.pathname),
|
|
85
|
+
query,
|
|
86
|
+
fixture
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
function needsSelfOrigin(fixture) {
|
|
90
|
+
const trimmed = fixture.request.trim();
|
|
91
|
+
const firstSpace = trimmed.indexOf(" ");
|
|
92
|
+
if (firstSpace === -1) return false;
|
|
93
|
+
return trimmed.slice(firstSpace + 1).trim().startsWith("/api/");
|
|
94
|
+
}
|
|
95
|
+
function compileFixtures(fixtures, egress, selfOrigin) {
|
|
96
|
+
return fixtures.map((fixture) => compileFixture(fixture, egress, selfOrigin));
|
|
97
|
+
}
|
|
98
|
+
function pathMatches(pattern, actual) {
|
|
99
|
+
for (let index = 0; index < pattern.length; index++) {
|
|
100
|
+
const segment = pattern[index];
|
|
101
|
+
if (segment === "*") return true;
|
|
102
|
+
const candidate = actual[index];
|
|
103
|
+
if (candidate === void 0) return false;
|
|
104
|
+
if (segment.startsWith(":")) continue;
|
|
105
|
+
if (segment !== candidate) return false;
|
|
106
|
+
}
|
|
107
|
+
return pattern.length === actual.length;
|
|
108
|
+
}
|
|
109
|
+
function matchHttpFixture(method, requestUrl, compiled) {
|
|
110
|
+
const url = new URL(requestUrl);
|
|
111
|
+
const upper = method.toUpperCase();
|
|
112
|
+
const segments = splitPath(url.pathname);
|
|
113
|
+
for (const candidate of compiled) {
|
|
114
|
+
if (candidate.method !== upper) continue;
|
|
115
|
+
if (candidate.origin !== url.origin) continue;
|
|
116
|
+
if (!pathMatches(candidate.segments, segments)) continue;
|
|
117
|
+
if (candidate.query !== null && !candidate.query.every(
|
|
118
|
+
([key, value]) => url.searchParams.get(key) === value
|
|
119
|
+
)) {
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
return candidate.fixture;
|
|
123
|
+
}
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
function fixtureResponse(fixture) {
|
|
127
|
+
const hasBody = fixture.response !== void 0;
|
|
128
|
+
const status = fixture.status ?? (hasBody ? 200 : 204);
|
|
129
|
+
if (!hasBody || status === 204 || status === 304) {
|
|
130
|
+
return new Response(null, { status, headers: fixture.headers });
|
|
131
|
+
}
|
|
132
|
+
const body = typeof fixture.response === "string" ? fixture.response : JSON.stringify(fixture.response);
|
|
133
|
+
return new Response(body, {
|
|
134
|
+
status,
|
|
135
|
+
headers: {
|
|
136
|
+
"content-type": typeof fixture.response === "string" ? "text/plain; charset=utf-8" : "application/json",
|
|
137
|
+
...fixture.headers
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
const REDACTED_QUERY = /\?…$/;
|
|
142
|
+
function noRecordedResponseMessage(method, url, deferredToClientRender) {
|
|
143
|
+
const hadQuery = REDACTED_QUERY.test(url);
|
|
144
|
+
const withoutRedaction = url.replace(REDACTED_QUERY, "");
|
|
145
|
+
const asPath = withoutRedaction.replace(/^https?:\/\/[^/]+/, "");
|
|
146
|
+
const quotable = asPath.startsWith("/api/") ? asPath : withoutRedaction;
|
|
147
|
+
if (deferredToClientRender) {
|
|
148
|
+
const path = quotable.replace(/^https?:\/\/[^/]+/, "");
|
|
149
|
+
const mechanism = [
|
|
150
|
+
"This request has no origin to resolve against here: either it is",
|
|
151
|
+
"relative, or it is a host-route (`/api/\u2026`) fixture, and both resolve",
|
|
152
|
+
"against the harness's own origin \u2014 which a server render does not have.",
|
|
153
|
+
"They are the only shape not compiled for that pass; every other fixture",
|
|
154
|
+
"is."
|
|
155
|
+
];
|
|
156
|
+
if (!path.startsWith("/api/")) {
|
|
157
|
+
return [
|
|
158
|
+
`No recorded response for ${method} ${url} \u2014 on the SERVER render.`,
|
|
159
|
+
"",
|
|
160
|
+
"There is no fixture to write for this one. On the client the harness",
|
|
161
|
+
"passes same-origin traffic outside `/api/` straight through, as the",
|
|
162
|
+
"framework plumbing it usually is, so it is never refused there. Only",
|
|
163
|
+
"the server pass has nowhere to send it.",
|
|
164
|
+
"",
|
|
165
|
+
...mechanism,
|
|
166
|
+
"",
|
|
167
|
+
"If it was meant to reach a vendor, give it an absolute URL and it",
|
|
168
|
+
"becomes an ordinary fixture in both passes."
|
|
169
|
+
].join("\n");
|
|
170
|
+
}
|
|
171
|
+
return [
|
|
172
|
+
`No recorded response for ${method} ${url} \u2014 on the SERVER render.`,
|
|
173
|
+
"",
|
|
174
|
+
"If you have already recorded this one, nothing is wrong with your",
|
|
175
|
+
"registry entry and there is nothing to change in it: the client render",
|
|
176
|
+
"compiles it and answers it, so what you see after hydration is the real",
|
|
177
|
+
"result.",
|
|
178
|
+
"",
|
|
179
|
+
...mechanism,
|
|
180
|
+
"",
|
|
181
|
+
"If you have NOT recorded it, this is the line:",
|
|
182
|
+
"",
|
|
183
|
+
` { request: '${method} ${path}' }`,
|
|
184
|
+
"",
|
|
185
|
+
"The path form on purpose. It resolves against whatever origin the",
|
|
186
|
+
"harness is served from, so it keeps matching when the dev server picks",
|
|
187
|
+
"a different port; an absolute line would pin it to one.",
|
|
188
|
+
"",
|
|
189
|
+
"Answering the SERVER pass too \u2014 a widget that fetches during render",
|
|
190
|
+
"rather than in an effect \u2014 is the one case that needs an absolute line",
|
|
191
|
+
"on BOTH sides, fixture and widget, and that does mean hardcoding your",
|
|
192
|
+
"dev server origin. Worth it only if the server render is something you",
|
|
193
|
+
"need to see."
|
|
194
|
+
].join("\n");
|
|
195
|
+
}
|
|
196
|
+
return [
|
|
197
|
+
`No recorded response for ${method} ${url}.`,
|
|
198
|
+
"",
|
|
199
|
+
"Fixtures mode answers every request from your registry entry and",
|
|
200
|
+
"never leaves the machine, so this call was refused rather than sent.",
|
|
201
|
+
"Record it:",
|
|
202
|
+
"",
|
|
203
|
+
" fixtures: {",
|
|
204
|
+
` default: [{ request: '${method} ${quotable}', response: /* \u2026 */ }],`,
|
|
205
|
+
" }",
|
|
206
|
+
"",
|
|
207
|
+
"To exercise the failure branch instead, give it a status:",
|
|
208
|
+
"",
|
|
209
|
+
` { request: '${method} ${quotable}', status: 403 }`,
|
|
210
|
+
"",
|
|
211
|
+
...hadQuery ? [
|
|
212
|
+
"The query string is hidden above because it can carry",
|
|
213
|
+
"credentials. It is left off the line to paste on purpose: a",
|
|
214
|
+
"request with no `?` matches whatever query the widget sends.",
|
|
215
|
+
"Add `?key=value` only to pin params you want matched.",
|
|
216
|
+
""
|
|
217
|
+
] : [],
|
|
218
|
+
"If this URL looks unfamiliar, log it from the code that builds it \u2014",
|
|
219
|
+
"the path and any pinned query params must match exactly."
|
|
220
|
+
].join("\n");
|
|
221
|
+
}
|
|
222
|
+
class NoRecordedResponseError extends Error {
|
|
223
|
+
constructor(method, url, deferredToClientRender = false) {
|
|
224
|
+
super(noRecordedResponseMessage(method, url, deferredToClientRender));
|
|
225
|
+
this.method = method;
|
|
226
|
+
this.url = url;
|
|
227
|
+
this.deferredToClientRender = deferredToClientRender;
|
|
228
|
+
this.name = "NoRecordedResponseError";
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
function fixturesToMockHandlers(compiled) {
|
|
232
|
+
return [
|
|
233
|
+
{
|
|
234
|
+
// Every URL. Egress is checked before handlers run, so this cannot widen
|
|
235
|
+
// what a handler may reach.
|
|
236
|
+
match: /^/,
|
|
237
|
+
respond: (request) => {
|
|
238
|
+
const fixture = matchHttpFixture(request.method, request.url, compiled);
|
|
239
|
+
if (!fixture) {
|
|
240
|
+
throw new NoRecordedResponseError(request.method, request.url);
|
|
241
|
+
}
|
|
242
|
+
return fixtureResponse(fixture);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
];
|
|
246
|
+
}
|
|
247
|
+
export {
|
|
248
|
+
FixtureRequestError,
|
|
249
|
+
NoRecordedResponseError,
|
|
250
|
+
compileFixture,
|
|
251
|
+
compileFixtures,
|
|
252
|
+
fixtureResponse,
|
|
253
|
+
fixturesToMockHandlers,
|
|
254
|
+
matchHttpFixture,
|
|
255
|
+
needsSelfOrigin
|
|
256
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import i18next from "i18next";
|
|
3
|
+
import { initReactI18next } from "react-i18next";
|
|
4
|
+
if (!i18next.isInitialized) {
|
|
5
|
+
void i18next.use(initReactI18next).init({
|
|
6
|
+
lng: "en",
|
|
7
|
+
fallbackLng: "en",
|
|
8
|
+
supportedLngs: ["en"],
|
|
9
|
+
resources: { en: {} },
|
|
10
|
+
interpolation: { escapeValue: false },
|
|
11
|
+
react: { useSuspense: false }
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
var i18n_default = i18next;
|
|
15
|
+
export {
|
|
16
|
+
i18n_default as default
|
|
17
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
3
|
+
* REDACTION FOR THINGS THE HARNESS SHOWS YOU
|
|
4
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
5
|
+
*
|
|
6
|
+
* The harness deliberately surfaces payloads: the activation page's promise is
|
|
7
|
+
* "submitting shows you what your form handed back instead of posting it", and
|
|
8
|
+
* the fixture activation actions log their input. That is the feature.
|
|
9
|
+
*
|
|
10
|
+
* An activation form is also exactly where an API token is typed. So the same
|
|
11
|
+
* payload that a partner wants to inspect is one that ends up in a devtools
|
|
12
|
+
* console, in a screen share, and pasted into an issue. Defaulting to redacted
|
|
13
|
+
* costs a click and removes a whole category of accident.
|
|
14
|
+
*
|
|
15
|
+
* This is a KEY-NAME heuristic, not a classifier: it cannot recognise a secret
|
|
16
|
+
* that is stored under an innocuous name, and it does not try. It covers the
|
|
17
|
+
* names credentials are actually given.
|
|
18
|
+
*/
|
|
19
|
+
export declare function isSensitiveKey(key: string): boolean;
|
|
20
|
+
/**
|
|
21
|
+
* Deep copy with sensitive values replaced.
|
|
22
|
+
*
|
|
23
|
+
* Cycles are tolerated (a payload built from form state can hold one), and
|
|
24
|
+
* anything not JSON-shaped is passed through untouched rather than mangled.
|
|
25
|
+
*/
|
|
26
|
+
export declare function redactSensitive(value: unknown, seen?: WeakSet<WeakKey>): unknown;
|
|
27
|
+
/**
|
|
28
|
+
* Whether anything in this value WOULD be redacted.
|
|
29
|
+
*
|
|
30
|
+
* Drives the "some fields are hidden" affordance, so the reader knows there is
|
|
31
|
+
* something behind the toggle rather than assuming the payload is complete.
|
|
32
|
+
*/
|
|
33
|
+
export declare function hasSensitiveValues(value: unknown, seen?: WeakSet<WeakKey>): boolean;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
const SENSITIVE_KEY_PATTERN = /(^|[^a-z])(token|secret|password|passwd|pwd|api[-_]?key|access[-_]?key|private[-_]?key|credential|auth|authorization|bearer|signature|sig|session|cookie|salt|otp|pin)([^a-z]|$)/i;
|
|
2
|
+
const ALLOWED_KEYS = /* @__PURE__ */ new Set(["queryKey", "publicKey", "keyName", "authType"]);
|
|
3
|
+
function isSensitiveKey(key) {
|
|
4
|
+
if (ALLOWED_KEYS.has(key)) return false;
|
|
5
|
+
const snake = key.replace(/([a-z0-9])([A-Z])/g, "$1_$2");
|
|
6
|
+
return SENSITIVE_KEY_PATTERN.test(key) || SENSITIVE_KEY_PATTERN.test(snake);
|
|
7
|
+
}
|
|
8
|
+
function placeholder(value) {
|
|
9
|
+
if (typeof value === "string") {
|
|
10
|
+
return `\u2039redacted \u2014 ${value.length}-char string\u203A`;
|
|
11
|
+
}
|
|
12
|
+
return `\u2039redacted \u2014 ${typeof value}\u203A`;
|
|
13
|
+
}
|
|
14
|
+
function redactSensitive(value, seen = /* @__PURE__ */ new WeakSet()) {
|
|
15
|
+
if (value === null || typeof value !== "object") return value;
|
|
16
|
+
if (seen.has(value)) return "\u2039circular\u203A";
|
|
17
|
+
seen.add(value);
|
|
18
|
+
if (Array.isArray(value)) {
|
|
19
|
+
return value.map((entry) => redactSensitive(entry, seen));
|
|
20
|
+
}
|
|
21
|
+
return Object.fromEntries(
|
|
22
|
+
Object.entries(value).map(([key, entry]) => [
|
|
23
|
+
key,
|
|
24
|
+
isSensitiveKey(key) ? placeholder(entry) : redactSensitive(entry, seen)
|
|
25
|
+
])
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
function hasSensitiveValues(value, seen = /* @__PURE__ */ new WeakSet()) {
|
|
29
|
+
if (value === null || typeof value !== "object") return false;
|
|
30
|
+
if (seen.has(value)) return false;
|
|
31
|
+
seen.add(value);
|
|
32
|
+
if (Array.isArray(value)) {
|
|
33
|
+
return value.some((entry) => hasSensitiveValues(entry, seen));
|
|
34
|
+
}
|
|
35
|
+
return Object.entries(value).some(
|
|
36
|
+
([key, entry]) => isSensitiveKey(key) || hasSensitiveValues(entry, seen)
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
export {
|
|
40
|
+
hasSensitiveValues,
|
|
41
|
+
isSensitiveKey,
|
|
42
|
+
redactSensitive
|
|
43
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
import type { WidgetRenderState } from '@ekanos/sdk/components';
|
|
3
|
+
import { type DataMode, type FixtureVariant } from '../../registry.js';
|
|
4
|
+
export declare const RENDER_STATES: readonly ["active", "loading", "error", "inactive", "disabled"];
|
|
5
|
+
export declare const VIEWPORTS: {
|
|
6
|
+
readonly mobile: {
|
|
7
|
+
readonly label: "Mobile";
|
|
8
|
+
readonly width: 390;
|
|
9
|
+
};
|
|
10
|
+
readonly tablet: {
|
|
11
|
+
readonly label: "Tablet";
|
|
12
|
+
readonly width: 820;
|
|
13
|
+
};
|
|
14
|
+
readonly desktop: {
|
|
15
|
+
readonly label: "Desktop";
|
|
16
|
+
readonly width: 1280;
|
|
17
|
+
};
|
|
18
|
+
readonly full: {
|
|
19
|
+
readonly label: "Full";
|
|
20
|
+
readonly width: null;
|
|
21
|
+
};
|
|
22
|
+
};
|
|
23
|
+
export type ViewportKey = keyof typeof VIEWPORTS;
|
|
24
|
+
export interface ToolbarState {
|
|
25
|
+
renderState: WidgetRenderState;
|
|
26
|
+
/**
|
|
27
|
+
* Where a widget's data comes from. `fixtures` seeds the react-query cache
|
|
28
|
+
* and blocks every request; `live` lets the widget's own query functions run
|
|
29
|
+
* against the real third-party API, through a fetch bound to the
|
|
30
|
+
* integration's declared egress allowlist. Fusion stays mocked in both.
|
|
31
|
+
*
|
|
32
|
+
* Only honoured for integrations that declare a `live` block in the registry.
|
|
33
|
+
*/
|
|
34
|
+
dataMode: DataMode;
|
|
35
|
+
variant: FixtureVariant;
|
|
36
|
+
viewport: ViewportKey;
|
|
37
|
+
collapsed: boolean;
|
|
38
|
+
/**
|
|
39
|
+
* Simulates an integration whose health probe is failing after having
|
|
40
|
+
* previously succeeded — the one condition that makes
|
|
41
|
+
* `DashboardWidgetProvider` downgrade an otherwise-active widget to
|
|
42
|
+
* `disabled`. Off by default so the forced render state above is always the
|
|
43
|
+
* state you actually get.
|
|
44
|
+
*/
|
|
45
|
+
unhealthy: boolean;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Kept as a provider component even though the store is module-level, so the
|
|
49
|
+
* harness has one obvious place to mount toolbar state and callers use a hook
|
|
50
|
+
* rather than reaching into module internals.
|
|
51
|
+
*/
|
|
52
|
+
export declare function ToolbarProvider({ children }: {
|
|
53
|
+
children: ReactNode;
|
|
54
|
+
}): import("react").JSX.Element;
|
|
55
|
+
export declare function useToolbar(): {
|
|
56
|
+
state: ToolbarState;
|
|
57
|
+
setState: (patch: Partial<ToolbarState>) => void;
|
|
58
|
+
reset: () => void;
|
|
59
|
+
};
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { Fragment, jsx } from "react/jsx-runtime";
|
|
3
|
+
import {
|
|
4
|
+
useCallback,
|
|
5
|
+
useMemo,
|
|
6
|
+
useSyncExternalStore
|
|
7
|
+
} from "react";
|
|
8
|
+
import {
|
|
9
|
+
DATA_MODES,
|
|
10
|
+
FIXTURE_VARIANTS
|
|
11
|
+
} from "../../registry.js";
|
|
12
|
+
const RENDER_STATES = [
|
|
13
|
+
"active",
|
|
14
|
+
"loading",
|
|
15
|
+
"error",
|
|
16
|
+
"inactive",
|
|
17
|
+
"disabled"
|
|
18
|
+
];
|
|
19
|
+
const VIEWPORTS = {
|
|
20
|
+
mobile: { label: "Mobile", width: 390 },
|
|
21
|
+
tablet: { label: "Tablet", width: 820 },
|
|
22
|
+
desktop: { label: "Desktop", width: 1280 },
|
|
23
|
+
full: { label: "Full", width: null }
|
|
24
|
+
};
|
|
25
|
+
const DEFAULT_STATE = {
|
|
26
|
+
renderState: "active",
|
|
27
|
+
// Fixtures by default, always. Opening the harness must never fire a request
|
|
28
|
+
// at somebody's API just because a previous session left a switch flipped for
|
|
29
|
+
// a different integration.
|
|
30
|
+
dataMode: "fixtures",
|
|
31
|
+
variant: "default",
|
|
32
|
+
viewport: "full",
|
|
33
|
+
collapsed: false,
|
|
34
|
+
unhealthy: false
|
|
35
|
+
};
|
|
36
|
+
const STORAGE_KEY = "fusion-harness-toolbar";
|
|
37
|
+
function isRenderState(value) {
|
|
38
|
+
return RENDER_STATES.includes(value);
|
|
39
|
+
}
|
|
40
|
+
function isVariant(value) {
|
|
41
|
+
return FIXTURE_VARIANTS.includes(value);
|
|
42
|
+
}
|
|
43
|
+
function isDataMode(value) {
|
|
44
|
+
return DATA_MODES.includes(value);
|
|
45
|
+
}
|
|
46
|
+
function isViewport(value) {
|
|
47
|
+
return Object.hasOwn(VIEWPORTS, value);
|
|
48
|
+
}
|
|
49
|
+
function readStored() {
|
|
50
|
+
try {
|
|
51
|
+
const raw = window.localStorage.getItem(STORAGE_KEY);
|
|
52
|
+
if (!raw) return DEFAULT_STATE;
|
|
53
|
+
const parsed = JSON.parse(raw);
|
|
54
|
+
if (typeof parsed !== "object" || parsed === null) return DEFAULT_STATE;
|
|
55
|
+
const candidate = parsed;
|
|
56
|
+
return {
|
|
57
|
+
renderState: isRenderState(candidate.renderState) ? candidate.renderState : DEFAULT_STATE.renderState,
|
|
58
|
+
dataMode: isDataMode(candidate.dataMode) ? candidate.dataMode : DEFAULT_STATE.dataMode,
|
|
59
|
+
variant: isVariant(candidate.variant) ? candidate.variant : DEFAULT_STATE.variant,
|
|
60
|
+
viewport: isViewport(candidate.viewport) ? candidate.viewport : DEFAULT_STATE.viewport,
|
|
61
|
+
collapsed: typeof candidate.collapsed === "boolean" ? candidate.collapsed : DEFAULT_STATE.collapsed,
|
|
62
|
+
unhealthy: typeof candidate.unhealthy === "boolean" ? candidate.unhealthy : DEFAULT_STATE.unhealthy
|
|
63
|
+
};
|
|
64
|
+
} catch (error) {
|
|
65
|
+
console.warn("[harness] Could not read stored toolbar state:", error);
|
|
66
|
+
return DEFAULT_STATE;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
let cached = null;
|
|
70
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
71
|
+
function getSnapshot() {
|
|
72
|
+
cached ??= readStored();
|
|
73
|
+
return cached;
|
|
74
|
+
}
|
|
75
|
+
function getServerSnapshot() {
|
|
76
|
+
return DEFAULT_STATE;
|
|
77
|
+
}
|
|
78
|
+
function subscribe(listener) {
|
|
79
|
+
listeners.add(listener);
|
|
80
|
+
const onStorage = (event) => {
|
|
81
|
+
if (event.key !== STORAGE_KEY) return;
|
|
82
|
+
cached = null;
|
|
83
|
+
listeners.forEach((l) => l());
|
|
84
|
+
};
|
|
85
|
+
window.addEventListener("storage", onStorage);
|
|
86
|
+
return () => {
|
|
87
|
+
listeners.delete(listener);
|
|
88
|
+
window.removeEventListener("storage", onStorage);
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
function writeState(next) {
|
|
92
|
+
cached = next;
|
|
93
|
+
try {
|
|
94
|
+
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
|
95
|
+
} catch (error) {
|
|
96
|
+
console.warn("[harness] Could not persist toolbar state:", error);
|
|
97
|
+
}
|
|
98
|
+
listeners.forEach((listener) => listener());
|
|
99
|
+
}
|
|
100
|
+
function ToolbarProvider({ children }) {
|
|
101
|
+
return /* @__PURE__ */ jsx(Fragment, { children });
|
|
102
|
+
}
|
|
103
|
+
function useToolbar() {
|
|
104
|
+
const state = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
|
105
|
+
const setState = useCallback((patch) => {
|
|
106
|
+
writeState({ ...getSnapshot(), ...patch });
|
|
107
|
+
}, []);
|
|
108
|
+
const reset = useCallback(() => {
|
|
109
|
+
try {
|
|
110
|
+
window.localStorage.removeItem(STORAGE_KEY);
|
|
111
|
+
} catch (error) {
|
|
112
|
+
console.warn("[harness] Could not clear toolbar state:", error);
|
|
113
|
+
}
|
|
114
|
+
cached = DEFAULT_STATE;
|
|
115
|
+
listeners.forEach((listener) => listener());
|
|
116
|
+
}, []);
|
|
117
|
+
return useMemo(() => ({ state, setState, reset }), [state, setState, reset]);
|
|
118
|
+
}
|
|
119
|
+
export {
|
|
120
|
+
RENDER_STATES,
|
|
121
|
+
ToolbarProvider,
|
|
122
|
+
VIEWPORTS,
|
|
123
|
+
useToolbar
|
|
124
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
import type { HarnessIntegration } from '../registry.js';
|
|
3
|
+
export declare function HarnessRegistryProvider({ integrations, children, }: {
|
|
4
|
+
integrations: HarnessIntegration[];
|
|
5
|
+
children: ReactNode;
|
|
6
|
+
}): import("react").JSX.Element;
|
|
7
|
+
/**
|
|
8
|
+
* The registry the shell injected.
|
|
9
|
+
*
|
|
10
|
+
* Throws rather than returning `[]` when the provider is missing. An empty
|
|
11
|
+
* registry is a legitimate state — a partner who has not registered anything
|
|
12
|
+
* yet — and the index page renders a specific "nothing registered" panel for
|
|
13
|
+
* it. Conflating that with "the shell forgot to mount the provider" would turn
|
|
14
|
+
* a wiring bug into a page that looks like it is working correctly.
|
|
15
|
+
*/
|
|
16
|
+
export declare function useHarnessRegistry(): HarnessIntegration[];
|
|
17
|
+
/**
|
|
18
|
+
* The integration named by the current `[slug]` segment, or `null`.
|
|
19
|
+
*
|
|
20
|
+
* `useParams()` is how a route component under `app/[slug]/` reads its own
|
|
21
|
+
* segment now that the shell's route files are one-line re-exports with no
|
|
22
|
+
* `params` prop to forward. It returns the same value the `params` promise
|
|
23
|
+
* used to resolve to, without the `use()` unwrap.
|
|
24
|
+
*/
|
|
25
|
+
export declare function useHarnessIntegration(): HarnessIntegration | null;
|
|
26
|
+
/** The `[widgetId]` segment of the current route. */
|
|
27
|
+
export declare function useHarnessWidgetId(): string;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx } from "react/jsx-runtime";
|
|
3
|
+
import { createContext, use, useEffect } from "react";
|
|
4
|
+
import { useParams } from "next/navigation";
|
|
5
|
+
import { findIntegration } from "../registry.js";
|
|
6
|
+
const HarnessRegistryContext = createContext(null);
|
|
7
|
+
function warnAboutBundledSecrets(integrations) {
|
|
8
|
+
const withSecrets = integrations.filter((integration) => {
|
|
9
|
+
const secrets = integration.triggerMocks?.secrets;
|
|
10
|
+
return Object.keys(secrets?.account ?? {}).length > 0 || Object.keys(secrets?.admin ?? {}).length > 0;
|
|
11
|
+
}).map((integration) => integration.slug);
|
|
12
|
+
if (withSecrets.length === 0) return;
|
|
13
|
+
console.warn(
|
|
14
|
+
`[harness] ${withSecrets.join(", ")} seed triggerMocks.secrets, and those literals are compiled into the browser bundle and written into the .next build output \u2014 the registry has to reach the client, so this is inherent, not a bug. Keep them fake, and do not commit or deploy the build directory.`
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
let warnedAboutSecrets = false;
|
|
18
|
+
function HarnessRegistryProvider({
|
|
19
|
+
integrations,
|
|
20
|
+
children
|
|
21
|
+
}) {
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
if (warnedAboutSecrets) return;
|
|
24
|
+
warnedAboutSecrets = true;
|
|
25
|
+
warnAboutBundledSecrets(integrations);
|
|
26
|
+
}, [integrations]);
|
|
27
|
+
return /* @__PURE__ */ jsx(HarnessRegistryContext, { value: integrations, children });
|
|
28
|
+
}
|
|
29
|
+
function useHarnessRegistry() {
|
|
30
|
+
const integrations = use(HarnessRegistryContext);
|
|
31
|
+
if (integrations === null) {
|
|
32
|
+
throw new Error(
|
|
33
|
+
"useHarnessRegistry() was called outside HarnessRegistryProvider. The shell's app/harness-shell.tsx must render `<RootLayout integrations={harnessIntegrations}>` from @ekanos/harness/app. (app/layout.tsx only re-exports it \u2014 the injection lives in harness-shell.tsx, which is `use client` because the registry holds component references that cannot cross the server boundary.)"
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
return integrations;
|
|
37
|
+
}
|
|
38
|
+
function useHarnessIntegration() {
|
|
39
|
+
const integrations = useHarnessRegistry();
|
|
40
|
+
const params = useParams();
|
|
41
|
+
return findIntegration(integrations, params.slug);
|
|
42
|
+
}
|
|
43
|
+
function useHarnessWidgetId() {
|
|
44
|
+
return useParams().widgetId;
|
|
45
|
+
}
|
|
46
|
+
export {
|
|
47
|
+
HarnessRegistryProvider,
|
|
48
|
+
useHarnessIntegration,
|
|
49
|
+
useHarnessRegistry,
|
|
50
|
+
useHarnessWidgetId
|
|
51
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
3
|
+
* ACTIVATION — the real connect FLOW, not just the form
|
|
4
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
5
|
+
*
|
|
6
|
+
* In production a user never sees an activation form on a page of its own. They
|
|
7
|
+
* see the connect MODAL on `/integrations`
|
|
8
|
+
* (the host's integration connect dialog),
|
|
9
|
+
* which wraps the partner's form in four things the form itself does not own:
|
|
10
|
+
*
|
|
11
|
+
* 1. dialog chrome — logo/monogram, integration name, one-line summary;
|
|
12
|
+
* 2. "What it does" — rendered from `definition.capabilities`;
|
|
13
|
+
* 3. "What it can access" — rendered from `definition.permissions`, with a
|
|
14
|
+
* Read/Write badge per row. This is the SCOPE DISCLOSURE a user consents
|
|
15
|
+
* against, so a partner needs to see how their declaration actually reads;
|
|
16
|
+
* 4. the connected state — once activated, the form is replaced by a manage
|
|
17
|
+
* view whose only action is Disconnect.
|
|
18
|
+
*
|
|
19
|
+
* Rendering the bare form told a partner nothing about (2) and (3), which are
|
|
20
|
+
* generated entirely from their own definition and are the two things most
|
|
21
|
+
* likely to read badly. So this surface reproduces the whole flow: connect →
|
|
22
|
+
* connected → disconnect, against the same declarations the host reads.
|
|
23
|
+
*
|
|
24
|
+
* What stays harness-specific is the payload readout below the dialog. There is
|
|
25
|
+
* no backend to post to, and the `ActivationResult` the form handed back is the
|
|
26
|
+
* exact object the real server action would have persisted — so showing it is
|
|
27
|
+
* more useful here than swallowing it.
|
|
28
|
+
*
|
|
29
|
+
* NOTE: the dialog renders through a portal on `<body>`, so the toolbar's
|
|
30
|
+
* viewport presets cannot constrain it — the same is true of the real modal, and
|
|
31
|
+
* it is why this surface has no `ViewportFrame`.
|
|
32
|
+
*/
|
|
33
|
+
export declare function ActivationPage(): import("react").JSX.Element;
|