@revenexx/studio-integrations 3.1.0 → 3.3.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 (32) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/module.json +1 -1
  3. package/dist/runtime/components/Integrations/CredentialCreateDialog.vue +34 -6
  4. package/dist/runtime/components/Integrations/CredentialDetailDialog.vue +42 -10
  5. package/dist/runtime/components/Integrations/CredentialNetworkSection.d.vue.ts +21 -0
  6. package/dist/runtime/components/Integrations/CredentialNetworkSection.vue +28 -0
  7. package/dist/runtime/components/Integrations/CredentialNetworkSection.vue.d.ts +21 -0
  8. package/dist/runtime/components/Integrations/NodeConfigField.vue +46 -3
  9. package/dist/runtime/components/Integrations/RunDetailPanel.vue +3 -2
  10. package/dist/runtime/components/Integrations/TemplateInstallStep.vue +28 -24
  11. package/dist/runtime/components/Integrations/VpnRoutePicker.d.vue.ts +20 -0
  12. package/dist/runtime/components/Integrations/VpnRoutePicker.vue +194 -0
  13. package/dist/runtime/components/Integrations/VpnRoutePicker.vue.d.ts +20 -0
  14. package/dist/runtime/components/Integrations/WorkflowRunReplayPanel.vue +14 -2
  15. package/dist/runtime/composables/useIntegrationsCredentials.d.ts +15 -9
  16. package/dist/runtime/composables/useIntegrationsCredentials.js +9 -4
  17. package/dist/runtime/composables/useIntegrationsRuns.d.ts +1 -1
  18. package/dist/runtime/composables/useIntegrationsTemplates.d.ts +2 -0
  19. package/dist/runtime/composables/useIntegrationsTemplates.js +15 -9
  20. package/dist/runtime/composables/useVpnConnections.d.ts +19 -0
  21. package/dist/runtime/composables/useVpnConnections.js +28 -0
  22. package/dist/runtime/i18n/messages/de.json +39 -0
  23. package/dist/runtime/i18n/messages/en.json +39 -0
  24. package/dist/runtime/pages/integrations/credentials.vue +67 -15
  25. package/dist/runtime/pages/integrations/templates/[slug].vue +21 -22
  26. package/dist/runtime/types/integrations.d.ts +74 -2
  27. package/dist/runtime/utils/configDefaults.d.ts +5 -2
  28. package/dist/runtime/utils/stepError.d.ts +13 -0
  29. package/dist/runtime/utils/stepError.js +24 -0
  30. package/dist/runtime/utils/vpnRoute.d.ts +69 -0
  31. package/dist/runtime/utils/vpnRoute.js +79 -0
  32. package/package.json +1 -1
@@ -1,5 +1,5 @@
1
1
  <script setup>
2
- import { computed, nextTick, onMounted, reactive, ref } from "vue";
2
+ import { computed, nextTick, onMounted, reactive, ref, watch } from "vue";
3
3
  import {
4
4
  RefreshCircle as Loader2,
5
5
  AddCircle as Plus,
@@ -35,9 +35,11 @@ import { apiRefusal, errorReason } from "../../utils/apiError";
35
35
  import { CREDENTIAL_STATES, credentialState, credentialStateLabel, providerStepWord } from "../../utils/credentialState";
36
36
  import { hasRevenexxSource } from "../../utils/revenexxCredentialSource";
37
37
  import { resolveLocalized } from "../../utils/localize";
38
+ import { defaultsFor } from "../../utils/configDefaults";
38
39
  import { fromModelValue, toItemValue, toModelValue } from "../../utils/selectOptions";
39
40
  import { keepOpenBeside } from "../../utils/dialog";
40
41
  import { testOutcomeView } from "../../utils/credentialTest";
42
+ import { credentialNetworkPayload, credentialVpnBadge, replacedFieldTexts } from "../../utils/vpnRoute";
41
43
  const t = useIntegrationsT();
42
44
  useHead({ title: () => t("integration.credentials.list.head.title") });
43
45
  const { credentials, loading, error, load, create, update, remove, testInline, test, oauthAuthorizeUrl } = useIntegrationsCredentials();
@@ -45,6 +47,13 @@ const { types, load: loadTypes, bySlug } = useCredentialTypes();
45
47
  onMounted(async () => {
46
48
  await Promise.all([load(), loadTypes()]);
47
49
  });
50
+ const { connections: vpnConnections, loaded: vpnLoaded, load: loadVpn } = useVpnConnections();
51
+ watch(() => credentials.value.some((c) => c.network), (has) => {
52
+ if (has) void loadVpn();
53
+ }, { immediate: true });
54
+ function vpnBadge(c) {
55
+ return credentialVpnBadge(c.network, vpnConnections.value, vpnLoaded.value);
56
+ }
48
57
  function typeFor(slug) {
49
58
  return bySlug(slug);
50
59
  }
@@ -108,12 +117,19 @@ const testing = ref(false);
108
117
  const testResult = ref(null);
109
118
  const saveRefusal = ref(null);
110
119
  const sourceActive = ref(false);
120
+ const network = ref(null);
121
+ const networkComplete = ref(true);
122
+ const replaced = computed(() => replacedFieldTexts(pickedType.value, network.value, vpnConnections.value));
123
+ function networkPayload() {
124
+ return pickedType.value?.network ? credentialNetworkPayload(network.value) : void 0;
125
+ }
111
126
  const isEdit = computed(() => editing.value !== null);
112
127
  function fieldsOf(t2) {
113
128
  return t2?.fields ?? [];
114
129
  }
115
130
  const drawnKeys = computed(() => [
116
131
  "name",
132
+ ...pickedType.value?.network ? ["network"] : [],
117
133
  ...sourceActive.value ? [] : fieldsOf(pickedType.value).map((f) => `config.${f.key}`)
118
134
  ]);
119
135
  const refusal = computed(() => apiRefusal(saveRefusal.value, drawnKeys.value));
@@ -131,20 +147,30 @@ async function focusFirstRefusedField() {
131
147
  await nextTick();
132
148
  const key = drawnKeys.value.find((k) => k in fieldErrors.value);
133
149
  if (!key) return;
134
- const id = key === "name" ? "cred-name" : `cred-field-${key.slice("config.".length)}`;
150
+ const id = key === "name" ? "cred-name" : key === "network" ? "cred-network-public" : `cred-field-${key.slice("config.".length)}`;
135
151
  document.getElementById(id)?.focus();
136
152
  }
137
153
  function clearRefusal() {
138
154
  saveRefusal.value = null;
139
155
  }
156
+ function pickCreateType(picked) {
157
+ pickedType.value = picked;
158
+ form.name = resolveLocalized(picked.name);
159
+ form.config = defaultsFor(fieldsOf(picked));
160
+ network.value = null;
161
+ testResult.value = null;
162
+ sourceActive.value = false;
163
+ clearRefusal();
164
+ }
140
165
  function openCreate(t2) {
141
166
  editing.value = null;
142
167
  pickedType.value = t2 ?? null;
143
168
  form.name = "";
144
- form.config = {};
169
+ form.config = defaultsFor(fieldsOf(pickedType.value));
145
170
  testResult.value = null;
146
171
  clearRefusal();
147
172
  sourceActive.value = false;
173
+ network.value = null;
148
174
  dialogOpen.value = true;
149
175
  }
150
176
  const detailOpen = ref(false);
@@ -180,7 +206,7 @@ async function runTest() {
180
206
  testResult.value = null;
181
207
  clearRefusal();
182
208
  try {
183
- testResult.value = isEdit.value && editing.value ? await test(editing.value.id) : await testInline(pickedType.value.slug, buildConfigPayload());
209
+ testResult.value = isEdit.value && editing.value ? await test(editing.value.id) : await testInline(pickedType.value.slug, buildConfigPayload(), networkPayload());
184
210
  } finally {
185
211
  testing.value = false;
186
212
  }
@@ -191,14 +217,15 @@ async function submit() {
191
217
  clearRefusal();
192
218
  try {
193
219
  if (isEdit.value && editing.value) {
194
- await update(editing.value.id, { name: form.name.trim(), config: buildConfigPayload() });
220
+ await update(editing.value.id, { name: form.name.trim(), config: buildConfigPayload(), network: networkPayload() });
195
221
  dialogOpen.value = false;
196
222
  toast.success(t("integration.credentials.toast.updated"));
197
223
  } else {
198
224
  const created = await create({
199
225
  credential_type_slug: pickedType.value.slug,
200
226
  name: form.name.trim(),
201
- config: buildConfigPayload()
227
+ config: buildConfigPayload(),
228
+ network: networkPayload()
202
229
  });
203
230
  dialogOpen.value = false;
204
231
  toast.success(t("integration.credentials.toast.created"));
@@ -285,7 +312,18 @@ async function confirmDelete() {
285
312
  </template>
286
313
 
287
314
  <template #cell-name="{ row: c }">
288
- <span class="text-sm truncate">{{ c.name }}</span>
315
+ <div class="flex min-w-0 items-center gap-1.5">
316
+ <span class="text-sm truncate">{{ c.name }}</span>
317
+ <!-- Reached through a VPN route: which connection, and — where the
318
+ reference no longer resolves — that it is broken, before a run says
319
+ so (specs/credential-network.md AC-7). -->
320
+ <span
321
+ v-if="vpnBadge(c)"
322
+ class="shrink-0 rounded-full border px-1.5 py-px text-[10px] font-medium"
323
+ :class="vpnBadge(c).tone"
324
+ :aria-label="t('integration.vpn.badge.aria', { name: c.name, badge: vpnBadge(c).text })"
325
+ >{{ vpnBadge(c).text }}</span>
326
+ </div>
289
327
  </template>
290
328
 
291
329
  <template #cell-status="{ row: c }">
@@ -353,11 +391,7 @@ async function confirmDelete() {
353
391
  <IntegrationsCredentialTypePicker
354
392
  v-if="!pickedType"
355
393
  :types="types"
356
- @pick="(picked) => {
357
- pickedType = picked;
358
- form.name = resolveLocalized(picked.name);
359
- clearRefusal();
360
- }"
394
+ @pick="pickCreateType"
361
395
  />
362
396
 
363
397
  <!-- Step 2: dynamic form -->
@@ -390,15 +424,33 @@ async function confirmDelete() {
390
424
  @apply="(c2) => Object.assign(form.config, c2)"
391
425
  />
392
426
 
427
+ <!-- Public internet or a VPN route — for a type that says which of its
428
+ fields a route stands in for (specs/credential-network.md). -->
429
+ <IntegrationsCredentialNetworkSection
430
+ v-model="network"
431
+ :type="pickedType"
432
+ id-prefix="cred-network"
433
+ :error="fieldErrors.network"
434
+ @update:complete="(c2) => networkComplete = c2"
435
+ />
436
+
393
437
  <!-- Each label names its own control (`for`/`id`): without it a screen
394
438
  reader reads the field as unlabelled, and the label text cannot be
395
439
  used to reach it at all. -->
396
440
  <div v-for="f in fieldsOf(pickedType)" v-show="!sourceActive" :key="f.key" class="space-y-1.5">
397
441
  <Label :for="`cred-field-${f.key}`">{{ resolveLocalized(f.label) }}{{ f.required ? " *" : "" }}</Label>
398
442
 
443
+ <!-- stood in for by the chosen VPN route: what it reaches, not editable -->
444
+ <Input
445
+ v-if="replaced[f.key]"
446
+ :id="`cred-field-${f.key}`"
447
+ :model-value="replaced[f.key]"
448
+ readonly
449
+ class="bg-muted/40"
450
+ />
399
451
  <!-- secret -->
400
452
  <Input
401
- v-if="f.type === 'secret'"
453
+ v-else-if="f.type === 'secret'"
402
454
  :id="`cred-field-${f.key}`"
403
455
  :model-value="form.config[f.key] ?? ''"
404
456
  type="password"
@@ -492,12 +544,12 @@ async function confirmDelete() {
492
544
  </Button>
493
545
  <span v-else />
494
546
  <div class="flex items-center gap-2">
495
- <Button variant="outline" :disabled="saving || testing" @click="runTest">
547
+ <Button variant="outline" :disabled="saving || testing || !networkComplete" @click="runTest">
496
548
  <Loader2 v-if="testing" :size="14" class="mr-1 animate-spin" />
497
549
  <TestIcon v-else :size="14" class="mr-1" />
498
550
  {{ t("integration.credentials.shared.action.testConnection") }}
499
551
  </Button>
500
- <Button :disabled="saving || !form.name.trim()" @click="submit">
552
+ <Button :disabled="saving || !form.name.trim() || !networkComplete" @click="submit">
501
553
  <Loader2 v-if="saving" :size="14" class="mr-1 animate-spin" />
502
554
  {{ t("integration.common.action.save") }}
503
555
  </Button>
@@ -22,7 +22,7 @@ import { keepOpenBeside } from "../../../utils/dialog";
22
22
  const t = useIntegrationsT();
23
23
  const route = useRoute();
24
24
  const router = useRouter();
25
- const { get, load, loaded, fetchDetail, requirements } = useIntegrationsTemplates();
25
+ const { get, load, loaded, fetchDetail, checkRequirements, uncheckedReason } = useIntegrationsTemplates();
26
26
  const { load: loadNodeCatalog, findManifest, resolveLocalized } = useIntegrationsNodes();
27
27
  const { keys: secretKeys, load: loadSecrets } = useIntegrationsSecrets();
28
28
  const { ofType, load: loadCredentials } = useIntegrationsCredentials();
@@ -30,10 +30,15 @@ const slug = computed(() => String(route.params.slug));
30
30
  const tpl = computed(() => get(slug.value));
31
31
  useHead({ title: () => t("integration.templates.detail.head.title", { name: tpl.value?.name ?? t("integration.templates.detail.head.fallbackName") }) });
32
32
  const reqs = ref(null);
33
+ const reqsError = ref(null);
33
34
  onMounted(async () => {
34
- await load();
35
- await Promise.all([fetchDetail(slug.value), loadNodeCatalog(false, { summary: true }), loadCredentials(), loadSecrets()]);
36
- reqs.value = await requirements(slug.value);
35
+ try {
36
+ await load();
37
+ await Promise.all([fetchDetail(slug.value), loadNodeCatalog(false, { summary: true }), loadCredentials(), loadSecrets()]);
38
+ reqs.value = await checkRequirements(slug.value);
39
+ } catch (e) {
40
+ reqsError.value = uncheckedReason(e);
41
+ }
37
42
  });
38
43
  const descriptionHtml = computed(() => renderTemplateDescription(tpl.value?.descriptionMd));
39
44
  const steps = computed(() => {
@@ -104,24 +109,14 @@ const variableColumns = computed(() => [
104
109
  ]);
105
110
  const credentialReqs = computed(() => reqs.value?.credentialTypes ?? []);
106
111
  const secretReqs = computed(() => reqs.value?.secrets ?? []);
107
- const secretChecks = computed(() => {
108
- const fromApi = secretReqs.value;
109
- if (fromApi.length) {
110
- return fromApi.map((s) => ({
111
- key: s.key,
112
- label: s.key ?? t("integration.templates.detail.secrets.chosenAtInstall"),
113
- present: s.exists || (s.key ? secretKeys.value.includes(s.key) : false)
114
- }));
115
- }
116
- return (tpl.value?.requiredSecrets ?? []).map((key) => ({
117
- key,
118
- label: key,
119
- present: secretKeys.value.includes(key)
120
- }));
121
- });
112
+ const secretChecks = computed(() => secretReqs.value.map((s) => ({
113
+ key: s.key,
114
+ label: s.key ?? t("integration.templates.detail.secrets.chosenAtInstall"),
115
+ present: s.exists || (s.key ? secretKeys.value.includes(s.key) : false)
116
+ })));
122
117
  const missingSecrets = computed(() => secretChecks.value.filter((s) => !s.present).length);
123
118
  const missingCredentials = computed(() => credentialReqs.value.filter((c) => !ofType(c.slug).length).length);
124
- const setupReady = computed(() => missingSecrets.value === 0 && missingCredentials.value === 0);
119
+ const setupReady = computed(() => reqs.value !== null && missingSecrets.value === 0 && missingCredentials.value === 0);
125
120
  const packageLabel = computed(() => tpl.value ? templatePackageLabel(tpl.value) : null);
126
121
  const categoryLabel = computed(() => templateCategoryLabel(tpl.value?.category ?? ""));
127
122
  const installOpen = ref(false);
@@ -298,11 +293,15 @@ function levelDot(level) {
298
293
  point `aria-labelledby` at. Copy this pattern only for a panel
299
294
  that genuinely has none. -->
300
295
  <div role="group" :aria-label="t('integration.templates.detail.setup.ariaLabel')" class="rounded-lg border border-black/[0.06] bg-white p-3 space-y-2.5">
301
- <div v-if="setupReady" class="flex items-center gap-1.5 text-sm text-emerald-700">
296
+ <div v-if="reqsError" class="flex items-start gap-1.5 text-sm text-amber-700" data-testid="template-setup-unchecked">
297
+ <ShieldAlert :size="14" class="mt-0.5 shrink-0" />
298
+ {{ t("integration.templates.detail.setup.unchecked", { reason: reqsError }) }}
299
+ </div>
300
+ <div v-else-if="setupReady" class="flex items-center gap-1.5 text-sm text-emerald-700">
302
301
  <CheckCircle2 :size="14" />
303
302
  {{ t("integration.templates.detail.setup.ready") }}
304
303
  </div>
305
- <div v-else class="text-sm text-amber-700 space-y-0.5">
304
+ <div v-else-if="reqs" class="text-sm text-amber-700 space-y-0.5">
306
305
  <div v-if="missingCredentials" class="flex items-center gap-1.5">
307
306
  <Key :size="12" />
308
307
  {{ t("integration.templates.detail.setup.missingCredentials", missingCredentials) }}
@@ -282,7 +282,10 @@ export interface NodeManifestOutput {
282
282
  }
283
283
  export type ConfigFieldType = 'string' | 'number' | 'boolean' | 'select' | 'multiselect' | 'object' | 'array' | 'expression' | 'secret-ref' | 'credentials-ref'
284
284
  /** Names one of the workflow's declared state namespaces (PO-374). */
285
- | 'state-ref' | 'dynamic-schema';
285
+ | 'state-ref'
286
+ /** A route of one of the tenant's VPN connections, or the public internet
287
+ * (PO-831). Stored as `VpnRouteValue | null`. */
288
+ | 'vpn-route' | 'dynamic-schema';
286
289
  export interface ConfigFieldOption {
287
290
  value: string | number | boolean;
288
291
  label: LocalizedString;
@@ -335,6 +338,11 @@ export interface ConfigField {
335
338
  * the stored value is the namespace NAME.
336
339
  */
337
340
  stateRole?: WorkflowStateRole;
341
+ /**
342
+ * Only for `type: 'vpn-route'` (PO-831): which kind of route the picker offers.
343
+ * `http` where the node declares nothing, as the SDK defaults it.
344
+ */
345
+ routeKind?: VpnRouteKind;
338
346
  /** keys of other config fields this field's options/schema depend on. */
339
347
  dependsOn?: string[];
340
348
  /** When this setting applies at all (PO-410): another setting's key, an
@@ -471,6 +479,8 @@ export interface CredentialTypeField {
471
479
  type: 'string' | 'number' | 'boolean' | 'select' | 'secret';
472
480
  label: LocalizedString;
473
481
  required?: boolean;
482
+ /** What the system uses when the operator changes nothing; a new credential's form starts from it (IN-38). */
483
+ default?: string | number | boolean;
474
484
  options?: Array<{
475
485
  label: LocalizedString;
476
486
  value: string | number | boolean;
@@ -503,6 +513,27 @@ export interface CredentialType {
503
513
  images?: CredentialTypeImage[];
504
514
  auth_kind: AuthKind;
505
515
  fields: CredentialTypeField[];
516
+ /**
517
+ * Which of the type's fields a VPN route stands in for (PO-829, the SDK's
518
+ * `ICredentialDescription.network`). Absent: the type cannot be routed through a
519
+ * VPN, and the credential forms draw no Network section for it.
520
+ */
521
+ network?: CredentialNetworkDeclaration | null;
522
+ }
523
+ /** What a credential type declares a VPN route replaces. */
524
+ export type CredentialNetworkDeclaration = {
525
+ kind: 'tcp';
526
+ hostField: string;
527
+ portField: string;
528
+ } | {
529
+ kind: 'http';
530
+ urlField: string;
531
+ };
532
+ /** How a stored credential reaches its system, when not over the public internet. */
533
+ export interface CredentialNetwork {
534
+ kind: 'vpn';
535
+ connection: string;
536
+ route: string;
506
537
  }
507
538
  export type CredentialStatus = 'active' | 'needs_reauth';
508
539
  /** A tenant's credential instance (secret fields never returned). */
@@ -517,9 +548,35 @@ export interface Credential {
517
548
  last_test_at?: string | null;
518
549
  last_test_ok?: boolean | null;
519
550
  last_test_message?: string | null;
551
+ /** The VPN route this credential is reached through; `null` or absent = public internet. */
552
+ network?: CredentialNetwork | null;
520
553
  created_at: string;
521
554
  updated_at: string;
522
555
  }
556
+ export type VpnRouteKind = 'http' | 'tcp';
557
+ /** Console's derived status of a connection; only `active` carries traffic. */
558
+ export type VpnConnectionStatus = 'provisioning' | 'failed' | 'active' | 'down' | 'standby' | 'disabled' | 'unknown';
559
+ export interface VpnRoute {
560
+ name: string;
561
+ kind: VpnRouteKind;
562
+ /** The target inside the tenant's network. */
563
+ host: string;
564
+ port: number;
565
+ proto?: string | null;
566
+ /** Where the gateway serves the route (http: a URL, tcp: `ip:port`). */
567
+ endpoint?: string | null;
568
+ }
569
+ export interface VpnConnection {
570
+ name: string;
571
+ display_name?: string | null;
572
+ status: VpnConnectionStatus | string;
573
+ routes: VpnRoute[];
574
+ }
575
+ /** What a `vpn-route` setting stores, and what a credential's `network` names. */
576
+ export interface VpnRouteValue {
577
+ connection: string;
578
+ route: string;
579
+ }
523
580
  export type ConnectionStatus = 'connected' | 'misconfigured' | 'unverified' | 'failing';
524
581
  export interface ConnectionTemplate {
525
582
  /** kebab-case slug (e.g. 'shopify'). */
@@ -698,6 +755,17 @@ export interface WorkflowRunNodeStep {
698
755
  attempt: number;
699
756
  durationMs: number | null;
700
757
  }
758
+ export interface RunStepFrame {
759
+ file: string;
760
+ line: number;
761
+ column: number;
762
+ name?: string | null;
763
+ }
764
+ export interface RunStepError {
765
+ message: string;
766
+ type?: string;
767
+ frames?: RunStepFrame[];
768
+ }
701
769
  export interface RunStepRow {
702
770
  node_id: string;
703
771
  node_slug: string;
@@ -716,7 +784,11 @@ export interface RunStepRow {
716
784
  port: string;
717
785
  } | null;
718
786
  branch?: string | null;
719
- error?: string | null;
787
+ /**
788
+ * Why the step failed. The platform sends the object; a string is what older
789
+ * rows and older mocks carry. Read it through `utils/stepError.ts`, never print it.
790
+ */
791
+ error?: RunStepError | string | null;
720
792
  input?: unknown;
721
793
  output?: unknown;
722
794
  /** Resolved node config for this step (present with include_payloads). */
@@ -9,8 +9,11 @@ import type { ConfigField } from '../types/integrations.js';
9
9
  * applied never gets to apply it. An absent key is the absence the node's
10
10
  * default answers to.
11
11
  *
12
- * Used in the two places a fresh set of values is made: a node dropped on the
13
- * canvas (WorkflowEditor), and a row added to a list setting (NodeConfigField).
12
+ * Used wherever a fresh set of values is made: a node dropped on the canvas
13
+ * (WorkflowEditor), a row added to a list setting (NodeConfigField), and a
14
+ * credential's form — a new one in both create dialogs, and a stored one's
15
+ * missing fields in the detail view (IN-38), which also compares an edit against
16
+ * it. A change here changes all of them.
14
17
  * Flat on purpose — it reads `default` at the level it is given and does not
15
18
  * descend into `properties` or `items`, so what a newly inserted node carries
16
19
  * is exactly what it carried before this became shared.
@@ -0,0 +1,13 @@
1
+ import type { RunStepError, RunStepFrame } from '../types/integrations.js';
2
+ /**
3
+ * A failed step's error as an operator reads it (IN-12).
4
+ *
5
+ * The platform sends it as an object — the message, the failure's type, and where
6
+ * the node package's own code was when it failed (`frames`, PO-447). The studio used
7
+ * to read it as text, and a template prints an object as JSON. A row from before the
8
+ * object, or a mock that still answers a string, is read as the message itself, so
9
+ * no shape ever reaches the screen as a dump.
10
+ */
11
+ export declare function stepError(error: unknown): RunStepError | null;
12
+ /** One frame as a reader names it: `src/nodes/Foo.ts:12:5` and the function, where known. */
13
+ export declare function frameLabel(frame: RunStepFrame): string;
@@ -0,0 +1,24 @@
1
+ export function stepError(error) {
2
+ if (typeof error === "string") return error.trim() === "" ? null : { message: error };
3
+ if (error === null || typeof error !== "object") return null;
4
+ const raw = error;
5
+ const message = typeof raw.message === "string" && raw.message.trim() !== "" ? raw.message : null;
6
+ if (message === null) return null;
7
+ return {
8
+ message,
9
+ ...typeof raw.type === "string" && raw.type.trim() !== "" ? { type: raw.type } : {},
10
+ ...Array.isArray(raw.frames) ? { frames: raw.frames.filter(isFrame).map(withName) } : {}
11
+ };
12
+ }
13
+ export function frameLabel(frame) {
14
+ const at = `${frame.file}:${frame.line}:${frame.column}`;
15
+ return frame.name ? `${frame.name} (${at})` : at;
16
+ }
17
+ function isFrame(value) {
18
+ if (value === null || typeof value !== "object") return false;
19
+ const f = value;
20
+ return typeof f.file === "string" && typeof f.line === "number" && typeof f.column === "number";
21
+ }
22
+ function withName(frame) {
23
+ return typeof frame.name === "string" && frame.name.trim() !== "" ? frame : { file: frame.file, line: frame.line, column: frame.column };
24
+ }
@@ -0,0 +1,69 @@
1
+ import type { CredentialNetwork, CredentialNetworkDeclaration, CredentialType, VpnConnection, VpnRoute, VpnRouteKind, VpnRouteValue } from '../types/integrations.js';
2
+ /**
3
+ * What the studio makes of a VPN route reference — a credential's `network`, or the
4
+ * value of a `vpn-route` setting — against the tenant's connections
5
+ * (specs/credential-network.md). Pure, so the surfaces that draw it agree.
6
+ */
7
+ /** The name a connection is shown under: its display name, else its own. */
8
+ export declare function connectionLabel(c: Pick<VpnConnection, 'name' | 'display_name'>): string;
9
+ /** The routes of a connection a picker filtered to `kind` offers. */
10
+ export declare function routesOfKind(c: VpnConnection | undefined, kind: VpnRouteKind): VpnRoute[];
11
+ /** Whether a stored value names a route at all, as opposed to the public internet. */
12
+ export declare function isVpnRouteValue(v: unknown): v is VpnRouteValue;
13
+ export type VpnReference =
14
+ /** The route exists; `active` says whether its connection carries traffic now. */
15
+ {
16
+ state: 'ok';
17
+ connection: VpnConnection;
18
+ route: VpnRoute;
19
+ active: boolean;
20
+ }
21
+ /** The connection or the route is gone — a broken reference. */
22
+ | {
23
+ state: 'missing';
24
+ connection?: VpnConnection;
25
+ };
26
+ /**
27
+ * Resolve a reference against the tenant's connections. A connection that is still
28
+ * there but no longer has the route counts as missing, as the backend refuses it
29
+ * with `VPN_ROUTE_MISSING` either way.
30
+ */
31
+ export declare function resolveVpnReference(connections: readonly VpnConnection[], ref: VpnRouteValue): VpnReference;
32
+ /** The keys of a credential type's fields a VPN route stands in for. */
33
+ export declare function replacedFieldKeys(decl: CredentialNetworkDeclaration | null | undefined): string[];
34
+ /**
35
+ * What a replaced field reads while a route stands in for it: `host:port via
36
+ * <connection>` for a tcp route, the gateway's endpoint for an http one — the
37
+ * address the request is actually sent to.
38
+ */
39
+ export declare function routeTargetText(route: VpnRoute, connection: VpnConnection): string;
40
+ /**
41
+ * The read-only text of every field a chosen route replaces, keyed by field. Empty
42
+ * while the credential is reached over the public internet, or while the route
43
+ * cannot be resolved — then the fields keep their stored values on screen.
44
+ */
45
+ export declare function replacedFieldTexts(type: Pick<CredentialType, 'network'> | null | undefined, value: VpnRouteValue | null, connections: readonly VpnConnection[]): Record<string, string>;
46
+ /** Whether the gateway reaches the target over TLS it does not verify. */
47
+ export declare function isHttpsTarget(route: VpnRoute | undefined): boolean;
48
+ /** The credential's `network` as the service takes it, from what the form holds. */
49
+ export declare function credentialNetworkPayload(value: VpnRouteValue | null): CredentialNetwork | null;
50
+ /** The picker's value from what a credential stores. */
51
+ export declare function fromCredentialNetwork(n: CredentialNetwork | null | undefined): VpnRouteValue | null;
52
+ /** Whether two values name the same route (both public counts as the same). */
53
+ export declare function sameVpnRoute(a: VpnRouteValue | null, b: VpnRouteValue | null): boolean;
54
+ /** A connection's status in the reader's words; an unforeseen one reads as unknown. */
55
+ export declare function connectionStatusLabel(status: string): string;
56
+ /** Badge classes for a connection status: green only for one that carries traffic. */
57
+ export declare function connectionStatusTone(status: string): string;
58
+ /**
59
+ * The VPN badge a credential carries where it is picked or listed, or `null` for one
60
+ * reached over the public internet. `connections` loaded or not decides whether a
61
+ * reference nothing resolves is called broken: before the list arrives it is only
62
+ * named.
63
+ */
64
+ export declare function credentialVpnBadge(network: CredentialNetwork | null | undefined, connections: readonly VpnConnection[], loaded: boolean): {
65
+ text: string;
66
+ broken: boolean;
67
+ active: boolean;
68
+ tone: string;
69
+ } | null;
@@ -0,0 +1,79 @@
1
+ import { useIntegrationsT } from "../composables/useIntegrationsT.js";
2
+ export function connectionLabel(c) {
3
+ return c.display_name?.trim() || c.name;
4
+ }
5
+ export function routesOfKind(c, kind) {
6
+ return (c?.routes ?? []).filter((r) => r.kind === kind);
7
+ }
8
+ export function isVpnRouteValue(v) {
9
+ return !!v && typeof v === "object" && typeof v.connection === "string" && v.connection !== "" && typeof v.route === "string" && v.route !== "";
10
+ }
11
+ export function resolveVpnReference(connections, ref) {
12
+ const connection = connections.find((c) => c.name === ref.connection);
13
+ const route = connection?.routes.find((r) => r.name === ref.route);
14
+ if (!connection || !route) return { state: "missing", connection };
15
+ return { state: "ok", connection, route, active: connection.status === "active" };
16
+ }
17
+ export function replacedFieldKeys(decl) {
18
+ if (!decl) return [];
19
+ return decl.kind === "tcp" ? [decl.hostField, decl.portField] : [decl.urlField];
20
+ }
21
+ export function routeTargetText(route, connection) {
22
+ const t = useIntegrationsT();
23
+ const target = route.kind === "http" && route.endpoint ? route.endpoint : `${route.host}:${route.port}`;
24
+ return t("integration.vpn.target", { target, connection: connectionLabel(connection) });
25
+ }
26
+ export function replacedFieldTexts(type, value, connections) {
27
+ const keys = replacedFieldKeys(type?.network);
28
+ if (!keys.length || !value) return {};
29
+ const ref = resolveVpnReference(connections, value);
30
+ if (ref.state !== "ok") return {};
31
+ const text = routeTargetText(ref.route, ref.connection);
32
+ return Object.fromEntries(keys.map((k) => [k, text]));
33
+ }
34
+ export function isHttpsTarget(route) {
35
+ return (route?.proto ?? "").toLowerCase() === "https";
36
+ }
37
+ export function credentialNetworkPayload(value) {
38
+ return value ? { kind: "vpn", connection: value.connection, route: value.route } : null;
39
+ }
40
+ export function fromCredentialNetwork(n) {
41
+ return n && n.kind === "vpn" && n.connection && n.route ? { connection: n.connection, route: n.route } : null;
42
+ }
43
+ export function sameVpnRoute(a, b) {
44
+ if (!a || !b) return !a && !b;
45
+ return a.connection === b.connection && a.route === b.route;
46
+ }
47
+ const STATUS_KEYS = ["provisioning", "failed", "active", "down", "standby", "disabled", "unknown"];
48
+ export function connectionStatusLabel(status) {
49
+ const key = STATUS_KEYS.includes(status) ? status : "unknown";
50
+ return useIntegrationsT()(`integration.vpn.status.${key}`);
51
+ }
52
+ export function connectionStatusTone(status) {
53
+ if (status === "active") return "bg-emerald-500/10 border-emerald-500/20 text-emerald-700";
54
+ if (status === "failed" || status === "down") return "bg-rose-500/10 border-rose-500/20 text-rose-700";
55
+ if (status === "provisioning" || status === "standby") return "bg-amber-500/10 border-amber-500/20 text-amber-700";
56
+ return "bg-slate-500/10 border-slate-500/20 text-slate-600";
57
+ }
58
+ export function credentialVpnBadge(network, connections, loaded) {
59
+ const value = fromCredentialNetwork(network);
60
+ if (!value) return null;
61
+ const t = useIntegrationsT();
62
+ if (!loaded) {
63
+ return { text: t("integration.vpn.badge.named", { connection: value.connection }), broken: false, active: true, tone: connectionStatusTone("unknown") };
64
+ }
65
+ const ref = resolveVpnReference(connections, value);
66
+ if (ref.state === "missing") {
67
+ return { text: t("integration.vpn.badge.missing"), broken: true, active: false, tone: connectionStatusTone("failed") };
68
+ }
69
+ const name = connectionLabel(ref.connection);
70
+ if (ref.active) {
71
+ return { text: t("integration.vpn.badge.named", { connection: name }), broken: false, active: true, tone: connectionStatusTone("active") };
72
+ }
73
+ return {
74
+ text: t("integration.vpn.badge.withState", { connection: name, status: connectionStatusLabel(String(ref.connection.status)) }),
75
+ broken: false,
76
+ active: false,
77
+ tone: connectionStatusTone(String(ref.connection.status))
78
+ };
79
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@revenexx/studio-integrations",
3
- "version": "3.1.0",
3
+ "version": "3.3.0",
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": {