@revenexx/studio-integrations 3.0.1 → 3.0.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/CHANGELOG.md +16 -0
- package/dist/module.json +1 -1
- package/dist/runtime/components/Integrations/CredentialDetailDialog.vue +11 -3
- package/dist/runtime/components/Integrations/NodeInspector.vue +24 -5
- package/dist/runtime/components/Integrations/WorkflowIconView.d.vue.ts +1 -1
- package/dist/runtime/components/Integrations/WorkflowIconView.vue.d.ts +1 -1
- package/dist/runtime/composables/useIntegrationsCredentials.d.ts +7 -0
- package/dist/runtime/composables/useIntegrationsCredentials.js +2 -1
- package/dist/runtime/i18n/messages/de.json +4 -1
- package/dist/runtime/i18n/messages/en.json +4 -1
- package/dist/runtime/pages/integrations/workflows/[id].vue +28 -32
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# @revenexx/studio-integrations
|
|
2
2
|
|
|
3
|
+
## 3.0.3
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 0ae03e4: A connection test the service refuses because a secret was left blank while the server or another setting changed no longer reads as a failed connection: the credential's detail view marks the secret's field with the reason and puts the cursor there, as it does for a refused save. The service no longer sends a stored secret to a server it was not entered for, so the secret has to be entered again.
|
|
8
|
+
|
|
9
|
+
A save that makes the service drop a credential's consent (other settings on an OAuth credential) now says that the credential has to be reconnected, and no longer runs a connection test that could only fail.
|
|
10
|
+
|
|
11
|
+
## 3.0.2
|
|
12
|
+
|
|
13
|
+
### Patch Changes
|
|
14
|
+
|
|
15
|
+
- 1c65e26: A run started from the editor in waiting mode that fails now says "Sync run failed", as promised, instead of passing on the service's own "Workflow execution failed.".
|
|
16
|
+
- 7c148a6: "Keep editing" in the question about leaving unsaved work is readable in every host, the cockpit included. Under `@revenexx/studio` 0.2.2 and 0.2.3 the design system painted the outline variant's white face over the label, so the fix in PO-415 only showed in the playground. The answer is now a plain filled button and still holds the keyboard when the question opens. The design-system peer moves to `^0.2.10`, the version the cockpit runs.
|
|
17
|
+
- 9277849: A step test that ends on the node's error output is announced as failed — "Test of “Fetch orders” failed" — with the node's own message under it, such as "unauthorized access" when a service refuses the token. Before, it got the same green "Test run finished" as a step that worked, and the reason stood only in the output's JSON. The notice names the step because the answer can arrive after you have moved to another one. A node that ends on its error output without a message is still announced as failed, and the notice says it gave no reason (PO-651).
|
|
18
|
+
|
|
3
19
|
## 3.0.1
|
|
4
20
|
|
|
5
21
|
### Patch Changes
|
package/dist/module.json
CHANGED
|
@@ -134,6 +134,12 @@ async function runTest() {
|
|
|
134
134
|
const payload = buildConfigPayload();
|
|
135
135
|
const edited = credentialEditDiffers(fieldsOf(props.type), payload, props.credential.public_config ?? {});
|
|
136
136
|
const result = await test(props.credential.id, edited ? payload : void 0);
|
|
137
|
+
if (result.refusal) {
|
|
138
|
+
if (opening !== viewOpening) return;
|
|
139
|
+
saveRefusal.value = result.refusal;
|
|
140
|
+
void focusFirstRefusedField();
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
137
143
|
if (result.recorded !== false) {
|
|
138
144
|
await load(true);
|
|
139
145
|
emit("changed");
|
|
@@ -156,11 +162,13 @@ async function save() {
|
|
|
156
162
|
const payload = buildConfigPayload();
|
|
157
163
|
const edited = credentialEditDiffers(fieldsOf(props.type), payload, props.credential.public_config ?? {});
|
|
158
164
|
try {
|
|
159
|
-
|
|
165
|
+
const wasConnected = props.credential.status !== "needs_reauth";
|
|
166
|
+
const updated = await update(id, { name: form.name.trim(), config: payload });
|
|
167
|
+
const lostConsent = wasConnected && updated.status === "needs_reauth";
|
|
160
168
|
emit("changed");
|
|
161
169
|
emit("update:open", false);
|
|
162
|
-
toast.success(t("integration.credentials.toast.updated"));
|
|
163
|
-
if (edited) void testSaved(id);
|
|
170
|
+
toast.success(t(lostConsent ? "integration.credentials.toast.updatedReconnect" : "integration.credentials.toast.updated"));
|
|
171
|
+
if (edited && !lostConsent) void testSaved(id);
|
|
164
172
|
} catch (e) {
|
|
165
173
|
saveRefusal.value = e;
|
|
166
174
|
void focusFirstRefusedField();
|
|
@@ -886,6 +886,17 @@ function toggleTestInput() {
|
|
|
886
886
|
if (!testInputOpen.value && !testInputDraft.value) testInputDraft.value = defaultTestInput();
|
|
887
887
|
testInputOpen.value = !testInputOpen.value;
|
|
888
888
|
}
|
|
889
|
+
function errorOutputTaken(branch, ports) {
|
|
890
|
+
if (!branch) return null;
|
|
891
|
+
if (branch === "error") return branch;
|
|
892
|
+
return ports.some((p) => p.name === branch && p.kind === "error") ? branch : null;
|
|
893
|
+
}
|
|
894
|
+
function errorOutputMessage(outputs, port) {
|
|
895
|
+
const error = outputs[port];
|
|
896
|
+
if (!error || typeof error !== "object") return null;
|
|
897
|
+
const message = error.message;
|
|
898
|
+
return typeof message === "string" ? message.trim() || null : null;
|
|
899
|
+
}
|
|
889
900
|
async function runTestStep() {
|
|
890
901
|
const wn = draftNode.value;
|
|
891
902
|
if (!wn || testRunning.value) return;
|
|
@@ -920,12 +931,20 @@ async function runTestStep() {
|
|
|
920
931
|
}
|
|
921
932
|
);
|
|
922
933
|
testLogs.value = res.logs ?? [];
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
[wn.id]: res.outputs && typeof res.outputs === "object" ? res.outputs : {}
|
|
926
|
-
};
|
|
934
|
+
const outputs = res.outputs && typeof res.outputs === "object" ? res.outputs : {};
|
|
935
|
+
testOutputs.value = { ...testOutputs.value, [wn.id]: outputs };
|
|
927
936
|
outputView.value = "json";
|
|
928
|
-
|
|
937
|
+
const nodeManifest = findManifest(wn.nodeSlug, wn.nodeVersion);
|
|
938
|
+
const failedOn = errorOutputTaken(res.branch, portsForNode(wn, nodeManifest, props.dynamicOutputs?.[wn.id]));
|
|
939
|
+
if (failedOn) {
|
|
940
|
+
const name = resolveDisplayName(wn.name) || resolveLocalized(nodeManifest?.name) || wn.nodeSlug;
|
|
941
|
+
toast.error(t("integration.nodeInspector.toast.testRunFailed", { name }), {
|
|
942
|
+
description: errorOutputMessage(outputs, failedOn) ?? t("integration.nodeInspector.toast.testRunFailedNoMessage"),
|
|
943
|
+
duration: 8e3
|
|
944
|
+
});
|
|
945
|
+
} else {
|
|
946
|
+
toast.success(t("integration.nodeInspector.toast.testRunFinished"));
|
|
947
|
+
}
|
|
929
948
|
} catch (e) {
|
|
930
949
|
const body = e.body;
|
|
931
950
|
testError.value = body?.error?.message ?? body?.message ?? e?.message ?? t("integration.nodeInspector.output.test.runFailed");
|
|
@@ -4,7 +4,7 @@ type __VLS_Props = {
|
|
|
4
4
|
size?: number;
|
|
5
5
|
};
|
|
6
6
|
declare const _default: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {
|
|
7
|
-
icon: WorkflowIcon | null;
|
|
8
7
|
size: number;
|
|
8
|
+
icon: WorkflowIcon | null;
|
|
9
9
|
}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
|
|
10
10
|
export default _default;
|
|
@@ -4,7 +4,7 @@ type __VLS_Props = {
|
|
|
4
4
|
size?: number;
|
|
5
5
|
};
|
|
6
6
|
declare const _default: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {
|
|
7
|
-
icon: WorkflowIcon | null;
|
|
8
7
|
size: number;
|
|
8
|
+
icon: WorkflowIcon | null;
|
|
9
9
|
}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
|
|
10
10
|
export default _default;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Credential } from '../types/integrations.js';
|
|
2
|
+
import { IntegrationsApiError } from '../utils/apiError.js';
|
|
2
3
|
/**
|
|
3
4
|
* Typed, per-tenant credential instances, backed by the Integrations service
|
|
4
5
|
* (`/api/v1/credentials`). Secret field values are NEVER returned — the list
|
|
@@ -32,6 +33,12 @@ export interface CredentialTestResult {
|
|
|
32
33
|
* test of an edit that tried values other than the stored ones (PO-535).
|
|
33
34
|
*/
|
|
34
35
|
recorded?: boolean;
|
|
36
|
+
/**
|
|
37
|
+
* The refusal the service answered instead of testing, held as it arrived: values it
|
|
38
|
+
* would not try as given — a stored secret kept for a changed server (PO-765). A form
|
|
39
|
+
* marks the fields it names, as it does for a refused save.
|
|
40
|
+
*/
|
|
41
|
+
refusal?: IntegrationsApiError;
|
|
35
42
|
}
|
|
36
43
|
export declare function useIntegrationsCredentials(): {
|
|
37
44
|
credentials: any;
|
|
@@ -65,7 +65,8 @@ export function useIntegrationsCredentials() {
|
|
|
65
65
|
);
|
|
66
66
|
return res?.ok === false ? res : { ok: true, message: res?.message, recorded: res?.recorded };
|
|
67
67
|
} catch (e) {
|
|
68
|
-
|
|
68
|
+
const refusal = e instanceof IntegrationsApiError && e.status === 422 ? e : void 0;
|
|
69
|
+
return { ok: false, error: errorReason(e, t("integration.credentials.error.testFailed")), refusal };
|
|
69
70
|
}
|
|
70
71
|
}
|
|
71
72
|
async function oauthAuthorizeUrl(id) {
|
|
@@ -198,7 +198,7 @@
|
|
|
198
198
|
"integration.credentials.list.action.reconnect.aria": "{name} erneut verbinden",
|
|
199
199
|
"integration.credentials.list.dialog.action.back": "Zurück",
|
|
200
200
|
"integration.credentials.list.dialog.description.create": "Wählen Sie einen Typ und geben Sie die Zugangsdaten ein.",
|
|
201
|
-
"integration.credentials.list.dialog.description.edit": "Aktualisieren Sie die Zugangsdaten. Lassen Sie Secret-Felder leer, um den aktuellen Wert zu behalten.",
|
|
201
|
+
"integration.credentials.list.dialog.description.edit": "Aktualisieren Sie die Zugangsdaten. Lassen Sie Secret-Felder leer, um den aktuellen Wert zu behalten — das geht nicht, wenn sich der Server oder eine andere Angabe ändert.",
|
|
202
202
|
"integration.credentials.list.dialog.field.name.placeholder": "z. B. Produktions-SMTP",
|
|
203
203
|
"integration.credentials.list.empty": "Noch keine Zugangsdaten — mit „{action}“ das erste System verbinden.",
|
|
204
204
|
"integration.credentials.list.head.title": "Zugangsdaten · Revenexx Cockpit",
|
|
@@ -294,6 +294,7 @@
|
|
|
294
294
|
"integration.credentials.toast.created": "Zugangsdaten erstellt",
|
|
295
295
|
"integration.credentials.toast.deleted": "Zugangsdaten gelöscht",
|
|
296
296
|
"integration.credentials.toast.updated": "Zugangsdaten aktualisiert",
|
|
297
|
+
"integration.credentials.toast.updatedReconnect": "Zugangsdaten aktualisiert — verbinden Sie sie neu, um sie wieder zu nutzen",
|
|
297
298
|
"integration.credentials.typePicker.empty": "Kein Zugangsdatentyp entspricht „{query}“.",
|
|
298
299
|
"integration.credentials.typePicker.search.aria": "Nach einer App suchen",
|
|
299
300
|
"integration.credentials.typePicker.search.placeholder": "Nach einer App suchen…",
|
|
@@ -818,6 +819,8 @@
|
|
|
818
819
|
"integration.nodeInspector.toast.samplePinned": "Beispielausgabe angeheftet",
|
|
819
820
|
"integration.nodeInspector.toast.secretCopied": "Secret kopiert",
|
|
820
821
|
"integration.nodeInspector.toast.secretCopyFailed": "Secret konnte nicht kopiert werden",
|
|
822
|
+
"integration.nodeInspector.toast.testRunFailed": "Test von „{name}“ fehlgeschlagen",
|
|
823
|
+
"integration.nodeInspector.toast.testRunFailedNoMessage": "Der Schritt endete am Fehlerausgang, ohne einen Grund zu nennen.",
|
|
821
824
|
"integration.nodeInspector.toast.testRunFinished": "Testlauf abgeschlossen",
|
|
822
825
|
"integration.nodeInspector.toast.webhookUrlCopied": "Webhook-URL kopiert",
|
|
823
826
|
"integration.nodeInspector.toast.webhookUrlCopyFailed": "URL konnte nicht kopiert werden",
|
|
@@ -198,7 +198,7 @@
|
|
|
198
198
|
"integration.credentials.list.action.reconnect.aria": "Reconnect {name}",
|
|
199
199
|
"integration.credentials.list.dialog.action.back": "Back",
|
|
200
200
|
"integration.credentials.list.dialog.description.create": "Pick a type and fill in its access data.",
|
|
201
|
-
"integration.credentials.list.dialog.description.edit": "Update the credential. Leave secret fields blank to keep the current value.",
|
|
201
|
+
"integration.credentials.list.dialog.description.edit": "Update the credential. Leave secret fields blank to keep the current value — not possible when the server or any other setting changes.",
|
|
202
202
|
"integration.credentials.list.dialog.field.name.placeholder": "e.g. Production SMTP",
|
|
203
203
|
"integration.credentials.list.empty": "No credentials yet — hit “{action}” to connect your first system.",
|
|
204
204
|
"integration.credentials.list.head.title": "Credentials · Revenexx Cockpit",
|
|
@@ -294,6 +294,7 @@
|
|
|
294
294
|
"integration.credentials.toast.created": "Credential created",
|
|
295
295
|
"integration.credentials.toast.deleted": "Credential deleted",
|
|
296
296
|
"integration.credentials.toast.updated": "Credential updated",
|
|
297
|
+
"integration.credentials.toast.updatedReconnect": "Credential updated — reconnect it to use it again",
|
|
297
298
|
"integration.credentials.typePicker.empty": "No credential type matches “{query}”.",
|
|
298
299
|
"integration.credentials.typePicker.search.aria": "Search for an app",
|
|
299
300
|
"integration.credentials.typePicker.search.placeholder": "Search for an app…",
|
|
@@ -818,6 +819,8 @@
|
|
|
818
819
|
"integration.nodeInspector.toast.samplePinned": "Sample output pinned",
|
|
819
820
|
"integration.nodeInspector.toast.secretCopied": "Secret copied",
|
|
820
821
|
"integration.nodeInspector.toast.secretCopyFailed": "Could not copy the secret",
|
|
822
|
+
"integration.nodeInspector.toast.testRunFailed": "Test of “{name}” failed",
|
|
823
|
+
"integration.nodeInspector.toast.testRunFailedNoMessage": "The step ended on its error output without saying why.",
|
|
821
824
|
"integration.nodeInspector.toast.testRunFinished": "Test run finished",
|
|
822
825
|
"integration.nodeInspector.toast.webhookUrlCopied": "Webhook URL copied",
|
|
823
826
|
"integration.nodeInspector.toast.webhookUrlCopyFailed": "Could not copy the URL",
|
|
@@ -234,6 +234,10 @@ function answerLeave(leave) {
|
|
|
234
234
|
leaveDecision?.(leave);
|
|
235
235
|
leaveDecision = null;
|
|
236
236
|
}
|
|
237
|
+
function focusStay(event) {
|
|
238
|
+
event.preventDefault();
|
|
239
|
+
event.target?.querySelector('[data-leave-answer="stay"]')?.focus();
|
|
240
|
+
}
|
|
237
241
|
let leavingOnPurpose = false;
|
|
238
242
|
async function leaveTo(path) {
|
|
239
243
|
leavingOnPurpose = true;
|
|
@@ -339,6 +343,10 @@ function extractRunId(res) {
|
|
|
339
343
|
}
|
|
340
344
|
return null;
|
|
341
345
|
}
|
|
346
|
+
function bodyRunId(e) {
|
|
347
|
+
if (!(e instanceof IntegrationsApiError)) return null;
|
|
348
|
+
return extractRunId(e.body?.run);
|
|
349
|
+
}
|
|
342
350
|
function asRunObject(res) {
|
|
343
351
|
if (!res || typeof res !== "object" || Array.isArray(res)) return null;
|
|
344
352
|
const o = res;
|
|
@@ -353,12 +361,12 @@ function viewRunFromModal(id2) {
|
|
|
353
361
|
function isRefusal(e) {
|
|
354
362
|
if (!(e instanceof IntegrationsApiError)) return false;
|
|
355
363
|
if (e.status < 400 || e.status >= 500 || e.status === 408) return false;
|
|
356
|
-
return
|
|
364
|
+
return bodyRunId(e) === null;
|
|
357
365
|
}
|
|
358
366
|
function syncErrorMessage(e) {
|
|
359
367
|
if (e instanceof IntegrationsApiError) {
|
|
360
368
|
if (e.status === 504) return t("integration.editor.run.error.syncTimeout");
|
|
361
|
-
if (e.status === 422 && e
|
|
369
|
+
if (e.status === 422 && bodyRunId(e) !== null) return t("integration.editor.run.error.syncFailed");
|
|
362
370
|
}
|
|
363
371
|
return errorReason(e, t("integration.editor.run.error.syncFailed"));
|
|
364
372
|
}
|
|
@@ -418,9 +426,7 @@ async function run(mode) {
|
|
|
418
426
|
runObject.value = asRunObject(syncResponse.value);
|
|
419
427
|
} catch (e) {
|
|
420
428
|
await reportRunFailure(e, syncErrorMessage);
|
|
421
|
-
|
|
422
|
-
runModalRunId.value = extractRunId(e.body?.run);
|
|
423
|
-
}
|
|
429
|
+
runModalRunId.value = bodyRunId(e);
|
|
424
430
|
} finally {
|
|
425
431
|
syncLoading.value = false;
|
|
426
432
|
void loadRuns(1);
|
|
@@ -1368,29 +1374,24 @@ async function confirmDeactivate() {
|
|
|
1368
1374
|
|
|
1369
1375
|
<!-- Leaving the workflow with unsaved work.
|
|
1370
1376
|
Staying is the default in the only sense that decides anything: it holds the
|
|
1371
|
-
focus when the dialog opens, so Enter or Space keeps the work
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
"Discard and leave". A breadcrumb click followed by Enter then threw away the
|
|
1375
|
-
afternoon this dialog exists to protect.
|
|
1376
|
-
`as-child` so the studio's own Button is that element rather than a second one
|
|
1377
|
-
beside it. Escape means the same thing, through `update:open`; a click beside
|
|
1378
|
-
the dialog does nothing at all, which an alert dialog is entitled to.
|
|
1377
|
+
focus when the dialog opens, so Enter or Space keeps the work — `focusStay`
|
|
1378
|
+
puts it there. Escape means the same thing, through `update:open`; a click
|
|
1379
|
+
beside the dialog does nothing at all, which an alert dialog is entitled to.
|
|
1379
1380
|
|
|
1380
|
-
The
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1381
|
+
The answer on the right is a plain filled Button, as in every other dialog in
|
|
1382
|
+
this module, and deliberately not wrapped in `AlertDialogCancel`. The cancel
|
|
1383
|
+
adds its own variant to whatever it renders, so two variants met on one
|
|
1384
|
+
element, and the design system paints a button's face as a background image
|
|
1385
|
+
over its background colour: no class written here decides what is seen. Under
|
|
1386
|
+
`@revenexx/studio` 0.2.2 and 0.2.3 the outline variant painted a white face
|
|
1387
|
+
over the label, and the answer that keeps the work was unreadable in the
|
|
1388
|
+
cockpit (PO-415, PO-598). Nothing marks this answer as the one holding the
|
|
1389
|
+
keyboard otherwise — `focus-visible` does not match after the click that
|
|
1390
|
+
opened the dialog. -->
|
|
1390
1391
|
<AlertDialog :open="leaveOpen" @update:open="(v) => {
|
|
1391
1392
|
if (!v) answerLeave(false);
|
|
1392
1393
|
}">
|
|
1393
|
-
<AlertDialogContent>
|
|
1394
|
+
<AlertDialogContent @open-auto-focus="focusStay">
|
|
1394
1395
|
<AlertDialogHeader>
|
|
1395
1396
|
<AlertDialogTitle>{{ t("integration.editor.leave.dialog.title") }}</AlertDialogTitle>
|
|
1396
1397
|
<AlertDialogDescription>
|
|
@@ -1401,14 +1402,9 @@ async function confirmDeactivate() {
|
|
|
1401
1402
|
<Button variant="outline" @click="answerLeave(true)">
|
|
1402
1403
|
{{ t("integration.editor.leave.dialog.discard") }}
|
|
1403
1404
|
</Button>
|
|
1404
|
-
<
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
@click="answerLeave(false)"
|
|
1408
|
-
>
|
|
1409
|
-
{{ t("integration.editor.leave.dialog.stay") }}
|
|
1410
|
-
</Button>
|
|
1411
|
-
</AlertDialogCancel>
|
|
1405
|
+
<Button data-leave-answer="stay" @click="answerLeave(false)">
|
|
1406
|
+
{{ t("integration.editor.leave.dialog.stay") }}
|
|
1407
|
+
</Button>
|
|
1412
1408
|
</AlertDialogFooter>
|
|
1413
1409
|
</AlertDialogContent>
|
|
1414
1410
|
</AlertDialog>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@revenexx/studio-integrations",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Integration Studio (core: workflows, node editor, runs, schedules, credentials, secrets, audit) as a standalone, publishable Nuxt module.",
|
|
6
6
|
"repository": {
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
},
|
|
35
35
|
"peerDependencies": {
|
|
36
36
|
"@dagrejs/dagre": "^3.0.0",
|
|
37
|
-
"@revenexx/studio": "^0.2.
|
|
37
|
+
"@revenexx/studio": "^0.2.10",
|
|
38
38
|
"@tiptap/core": "^3.28.0",
|
|
39
39
|
"@tiptap/extension-document": "^3.28.0",
|
|
40
40
|
"@tiptap/extension-history": "^3.28.0",
|