@revenexx/studio-integrations 3.0.3 → 3.2.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 (39) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/module.json +1 -1
  3. package/dist/runtime/components/Integrations/CredentialCreateDialog.vue +3 -2
  4. package/dist/runtime/components/Integrations/CredentialDetailDialog.vue +3 -2
  5. package/dist/runtime/components/Integrations/NodeInspector.vue +1 -1
  6. package/dist/runtime/components/Integrations/RunDetailPanel.vue +3 -2
  7. package/dist/runtime/components/Integrations/TemplateFlowPreview.vue +1 -1
  8. package/dist/runtime/components/Integrations/TemplateInstallStep.vue +55 -33
  9. package/dist/runtime/components/Integrations/WorkflowRunReplayPanel.vue +14 -2
  10. package/dist/runtime/composables/useIntegrationsAudit.d.ts +7 -2
  11. package/dist/runtime/composables/useIntegrationsCredentials.d.ts +11 -3
  12. package/dist/runtime/composables/useIntegrationsCredentials.js +9 -5
  13. package/dist/runtime/composables/useIntegrationsRuns.js +8 -4
  14. package/dist/runtime/composables/useIntegrationsSchedules.js +6 -3
  15. package/dist/runtime/composables/useIntegrationsSecrets.d.ts +6 -5
  16. package/dist/runtime/composables/useIntegrationsSecrets.js +3 -2
  17. package/dist/runtime/composables/useIntegrationsTemplates.d.ts +3 -1
  18. package/dist/runtime/composables/useIntegrationsTemplates.js +20 -15
  19. package/dist/runtime/composables/useIntegrationsWorkflows.js +4 -4
  20. package/dist/runtime/composables/useNodeRetirement.js +5 -5
  21. package/dist/runtime/i18n/messages/de.json +20 -3
  22. package/dist/runtime/i18n/messages/en.json +20 -3
  23. package/dist/runtime/pages/integrations/audit.vue +43 -9
  24. package/dist/runtime/pages/integrations/credentials.vue +3 -2
  25. package/dist/runtime/pages/integrations/runs.vue +3 -1
  26. package/dist/runtime/pages/integrations/secrets.vue +3 -1
  27. package/dist/runtime/pages/integrations/templates/[slug].vue +22 -23
  28. package/dist/runtime/types/integrations.d.ts +25 -7
  29. package/dist/runtime/utils/apiEnvelope.d.ts +15 -5
  30. package/dist/runtime/utils/apiEnvelope.js +11 -8
  31. package/dist/runtime/utils/credentialTest.d.ts +17 -0
  32. package/dist/runtime/utils/credentialTest.js +14 -0
  33. package/dist/runtime/utils/runEnvelope.d.ts +22 -24
  34. package/dist/runtime/utils/runEnvelope.js +2 -4
  35. package/dist/runtime/utils/stepError.d.ts +13 -0
  36. package/dist/runtime/utils/stepError.js +24 -0
  37. package/package.json +1 -1
  38. package/dist/runtime/data/integrationWorkflowSamples.d.ts +0 -18
  39. package/dist/runtime/data/integrationWorkflowSamples.js +0 -318
package/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # @revenexx/studio-integrations
2
2
 
3
+ ## 3.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 60e6143: A template whose requirements the platform could not answer for is no longer shown as ready to install. The template page's setup rail and the install step now say they could not check what the template needs, with the platform's reason, instead of "Everything is ready to install" and "This template needs no credentials"; installing stays possible. While the answer is still on its way, the rail claims nothing.
8
+
9
+ For hosts: `useIntegrationsTemplates()` gains `checkRequirements(slug)`, which rejects with the platform's reason where `requirements(slug)` resolves to `null`, and `uncheckedReason(error)`, which turns that rejection into the sentence the studio shows. `requirements` still behaves as before but is now deprecated: its `null` cannot tell "could not check" from "needs nothing", so a host that reads it as an empty answer shows an unchecked template as ready. Switch to `checkRequirements`.
10
+
11
+ ### Patch Changes
12
+
13
+ - e62ae58: A failed step now says why it failed in one sentence. The run record's step list showed the whole error the platform sends, as a block of JSON; it now shows the message. The replay's debug panel shows the message and the failure's type, and the places in the node's own code where it failed as a list that stays folded until opened.
14
+
15
+ ## 3.1.0
16
+
17
+ ### Minor Changes
18
+
19
+ - 23bb093: The audit log offers every kind of change and of thing a tenant's log can hold — a cancelled or resumed run, a retired or restored node version, deleted stored state — and names a trigger by its kind and workflow and a run by its workflow, since the platform records no name for either. A connection test the platform could not run (its test broker was busy, or the reader may not try changed values) now reads "Not tested" instead of a failed connection. A secret key may be anything the platform stores — lower case and hyphens included, up to 191 characters; a runs search is cut to the platform's 200 characters instead of being refused; an install name stops at 255. Installing a template wires a step with two credential settings setting by setting,, a template's industry outside the six the studio used to know is kept and found by search, and a template started by a webhook says which method it answers rather than a path it never carries. An answer the schedules page cannot read is reported as a failed load, not as a tenant with no schedules.
20
+
21
+ For hosts: single workflows, runs and credentials are now read only in the bare shape the Integrations service sends; a `data`-wrapped answer is refused. `instantiate()` also accepts `{ nodeId: { settingKey: credentialId } }` beside a bare id. Three exported types widen to what the service sends — `WorkflowTemplate.industries` is `string[]`, `CredentialType.icon` and `description` may be `null`, and a credential field option's `label` may be localised.
22
+
23
+ ### Patch Changes
24
+
25
+ - ef3f3eb: A dead letter's `reason` is typed as the string the platform always sends (empty when nothing was recorded) and its `trigger_id` as the number it is. Rotating a webhook's endpoint secret reads the platform's answer in the one shape it has ever sent. Nothing else a host sees changes: the rest of this release brings the browser tests' mock in line with what the Integrations service actually answers.
26
+
3
27
  ## 3.0.3
4
28
 
5
29
  ### Patch Changes
package/dist/module.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@revenexx/studio-integrations",
3
3
  "configKey": "studioIntegrations",
4
- "version": "3.0.3",
4
+ "version": "3.2.0",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.3",
7
7
  "unbuild": "unknown"
@@ -23,6 +23,7 @@ import { resolveLocalized } from "../../utils/localize";
23
23
  import { fromModelValue, toItemValue, toModelValue } from "../../utils/selectOptions";
24
24
  import { errorReason } from "../../utils/apiError";
25
25
  import { keepOpenBeside } from "../../utils/dialog";
26
+ import { testOutcomeView } from "../../utils/credentialTest";
26
27
  const props = defineProps({
27
28
  open: { type: Boolean, required: true },
28
29
  credentialType: { type: [String, Array], required: false }
@@ -247,12 +248,12 @@ async function submit() {
247
248
  <div
248
249
  v-if="testResult"
249
250
  class="rounded-md px-3 py-2 text-sm"
250
- :class="testResult.ok ? 'bg-emerald-500/10 text-emerald-700' : 'bg-rose-500/10 text-rose-700'"
251
+ :class="testOutcomeView(testResult, t).toneClass"
251
252
  >
252
253
  <!-- The type's own sentence, either way. This is the surface where it is
253
254
  worth the most: nothing is stored yet, so the operator is deciding
254
255
  whether these access data reach what they meant (specs/credential-test.md). -->
255
- {{ testResult.ok ? testResult.message ?? t("integration.credentials.shared.test.ok") : testResult.error ?? testResult.message ?? t("integration.credentials.shared.test.failed") }}
256
+ {{ testOutcomeView(testResult, t).text }}
256
257
  </div>
257
258
  </div>
258
259
 
@@ -39,6 +39,7 @@ import { hasRevenexxSource } from "../../utils/revenexxCredentialSource";
39
39
  import { resolveLocalized } from "../../utils/localize";
40
40
  import { fromModelValue, toItemValue, toModelValue } from "../../utils/selectOptions";
41
41
  import { keepOpenBeside } from "../../utils/dialog";
42
+ import { testOutcomeView } from "../../utils/credentialTest";
42
43
  const props = defineProps({
43
44
  open: { type: Boolean, required: true },
44
45
  credential: { type: [Object, null], required: true },
@@ -375,9 +376,9 @@ async function connect() {
375
376
  <div
376
377
  v-if="liveTest"
377
378
  class="rounded-md px-3 py-2 text-sm"
378
- :class="liveTest.ok ? 'bg-emerald-500/10 text-emerald-700' : 'bg-rose-500/10 text-rose-700'"
379
+ :class="testOutcomeView(liveTest, t).toneClass"
379
380
  >
380
- {{ liveTest.ok ? liveTest.message ?? t("integration.credentials.shared.test.ok") : liveTest.error ?? liveTest.message ?? t("integration.credentials.shared.test.failed") }}
381
+ {{ testOutcomeView(liveTest, t).text }}
381
382
  </div>
382
383
 
383
384
  </div>
@@ -472,7 +472,7 @@ async function rotateEndpointSecret() {
472
472
  const api2 = useIntegrationsApi();
473
473
  const res = await api2.post(`/workflows/${props.workflowId}/triggers/${wt.id}/rotate-secret`, {});
474
474
  const body = res ?? {};
475
- const secret = body.inbound_secret ?? body.data?.inbound_secret ?? null;
475
+ const secret = body.inbound_secret ?? null;
476
476
  if (secret) {
477
477
  rotatedSecret.value = secret;
478
478
  rotateOpen.value = true;
@@ -4,6 +4,7 @@ import { Download as DownloadIcon, RefreshCircle as RefreshIcon } from "#solar-i
4
4
  import { Badge, Button, Dialog, DialogContent, DialogHeader, DialogTitle } from "@revenexx/studio";
5
5
  import { runStatusClass, runStatusLabel } from "../../utils/statusStyle";
6
6
  import { isSkippedStep, skipReasonMessage, stepStatusLabel } from "../../utils/skippedStep";
7
+ import { stepError } from "../../utils/stepError";
7
8
  import { IntegrationsApiError, errorReason } from "../../utils/apiError";
8
9
  import { createPollBudget, SAFETY_POLL_MS } from "../../utils/pollBudget";
9
10
  import { formatDuration } from "../../utils/formatDuration";
@@ -764,8 +765,8 @@ async function confirmAction() {
764
765
  <span class="text-xs text-muted-foreground tabular-nums shrink-0">{{ fmtMs(st.duration_ms) }}</span>
765
766
  <span v-if="!isSkippedStep(st)" class="text-[10px] text-muted-foreground shrink-0">{{ expandedStep === stepKey(st, i) ? "\u25BC" : "\u25B6" }}</span>
766
767
  </button>
767
- <div v-if="st.error" class="px-3 pb-2 -mt-1">
768
- <div class="rounded-md border border-rose-500/20 bg-rose-500/5 px-2 py-1 text-xs text-rose-700 break-words">{{ st.error }}</div>
768
+ <div v-if="stepError(st.error)" class="px-3 pb-2 -mt-1">
769
+ <div class="rounded-md border border-rose-500/20 bg-rose-500/5 px-2 py-1 text-xs text-rose-700 break-words whitespace-pre-wrap" data-testid="step-error">{{ stepError(st.error).message }}</div>
769
770
  </div>
770
771
  <div v-if="expandedStep === stepKey(st, i)" class="px-3 pb-3 space-y-2">
771
772
  <template v-if="hasPayload(st)">
@@ -30,7 +30,7 @@ const boxes = computed(() => {
30
30
  triggers.forEach((t2, i) => {
31
31
  const pos = blob.triggerPositions?.[t2.id] ?? { x: 40, y: 40 + i * 160 };
32
32
  const template = TRIGGER_TEMPLATES.find((tt) => tt.type === t2.type);
33
- const subtitle = t2.type === "schedule" && t2.config?.cron ? describeCron(String(t2.config.cron)) ?? String(t2.config.cron) : t2.type === "webhook" && t2.config?.path ? String(t2.config.path) : resolveLocalized(template?.description ?? "");
33
+ const subtitle = t2.type === "schedule" && t2.config?.cron ? describeCron(String(t2.config.cron)) ?? String(t2.config.cron) : t2.type === "webhook" && t2.config?.method ? String(t2.config.method).toUpperCase() : resolveLocalized(template?.description ?? "");
34
34
  out.push({
35
35
  key: `t-${t2.id}`,
36
36
  kind: "trigger",
@@ -20,64 +20,77 @@ const props = defineProps({
20
20
  });
21
21
  const emit = defineEmits(["installed", "update:loading", "update:installing"]);
22
22
  const t = useIntegrationsT();
23
- const { get, load, fetchDetail, requirements, instantiate } = useIntegrationsTemplates();
23
+ const { get, load, fetchDetail, checkRequirements, uncheckedReason, instantiate } = useIntegrationsTemplates();
24
24
  const { keys: secretKeys, load: loadSecrets } = useIntegrationsSecrets();
25
25
  const { ofType, refresh: refreshCredentials } = useIntegrationsCredentials();
26
26
  const reqs = ref(null);
27
+ const reqsError = ref(null);
27
28
  const loading = ref(true);
28
29
  const installing = ref(false);
29
30
  const error = ref(null);
30
31
  const name = ref("");
31
32
  const bindings = reactive({});
33
+ const slotKey = (nodeId, configKey) => `${nodeId}\0${configKey}`;
32
34
  let run = 0;
33
35
  watch(() => props.slug, async (slug) => {
34
36
  const mine = ++run;
35
37
  loading.value = true;
36
38
  reqs.value = null;
39
+ reqsError.value = null;
37
40
  error.value = null;
38
41
  name.value = "";
39
42
  for (const k of Object.keys(bindings)) delete bindings[k];
40
- await load();
41
- if (mine !== run) return;
42
- if (name.value === "") name.value = get(slug)?.name ?? "";
43
- await Promise.all([fetchDetail(slug), refreshCredentials(), loadSecrets()]);
44
- if (mine !== run) return;
45
- const answer = await requirements(slug);
43
+ let answer = null;
44
+ let failure = null;
45
+ try {
46
+ await load();
47
+ if (mine !== run) return;
48
+ if (name.value === "") name.value = get(slug)?.name ?? "";
49
+ await Promise.all([fetchDetail(slug), refreshCredentials(), loadSecrets()]);
50
+ if (mine !== run) return;
51
+ answer = await checkRequirements(slug);
52
+ } catch (e) {
53
+ failure = uncheckedReason(e);
54
+ }
46
55
  if (mine !== run) return;
47
56
  reqs.value = answer;
57
+ reqsError.value = failure;
48
58
  loading.value = false;
49
59
  }, { immediate: true });
50
60
  watch(loading, (v) => emit("update:loading", v), { immediate: true });
51
61
  watch(installing, (v) => emit("update:installing", v));
52
62
  onBeforeUnmount(() => emit("update:loading", false));
53
- const tpl = computed(() => get(props.slug));
54
63
  const credentialReqs = computed(() => reqs.value?.credentialTypes ?? []);
55
64
  const secretReqs = computed(() => reqs.value?.secrets ?? []);
56
- const secretChecks = computed(() => {
57
- const fromApi = secretReqs.value;
58
- if (fromApi.length) {
59
- return fromApi.map((s) => ({
60
- key: s.key,
61
- label: s.key ?? t("integration.templates.detail.secrets.chosenAtInstall"),
62
- present: s.exists || (s.key ? secretKeys.value.includes(s.key) : false)
63
- }));
64
- }
65
- return (tpl.value?.requiredSecrets ?? []).map((key) => ({
66
- key,
67
- label: key,
68
- present: secretKeys.value.includes(key)
65
+ const secretChecks = computed(() => secretReqs.value.map((s) => ({
66
+ key: s.key,
67
+ label: s.key ?? t("integration.templates.detail.secrets.chosenAtInstall"),
68
+ present: s.exists || (s.key ? secretKeys.value.includes(s.key) : false)
69
+ })));
70
+ function slotsFor(usedBy) {
71
+ const all = credentialReqs.value.flatMap((r) => r.usedBy);
72
+ const seen = /* @__PURE__ */ new Set();
73
+ return usedBy.filter((u) => {
74
+ const key = slotKey(u.nodeId, u.configKey);
75
+ if (seen.has(key)) return false;
76
+ seen.add(key);
77
+ return true;
78
+ }).map((u) => ({
79
+ key: slotKey(u.nodeId, u.configKey),
80
+ nodeId: u.nodeId,
81
+ configKey: u.configKey,
82
+ label: new Set(all.filter((o) => o.nodeId === u.nodeId).map((o) => o.configKey)).size > 1 ? t("integration.templates.detail.install.settingLabel", { id: u.nodeId, setting: u.configKey }) : t("integration.templates.detail.install.nodeLabel", { id: u.nodeId })
69
83
  }));
70
- });
71
- function nodesFor(usedBy) {
72
- return [...new Set(usedBy.map((u) => u.nodeId))];
73
84
  }
74
85
  async function confirmInstall() {
75
86
  installing.value = true;
76
87
  error.value = null;
77
88
  try {
78
89
  const credentialBindings = {};
79
- for (const [nodeId, credId] of Object.entries(bindings)) {
80
- if (credId && credId !== SELECT_LATER) credentialBindings[nodeId] = credId;
90
+ for (const [key, credId] of Object.entries(bindings)) {
91
+ if (!credId || credId === SELECT_LATER) continue;
92
+ const [nodeId, configKey] = key.split("\0");
93
+ (credentialBindings[nodeId] ??= {})[configKey] = credId;
81
94
  }
82
95
  const id = await instantiate(props.slug, {
83
96
  name: name.value.trim(),
@@ -98,7 +111,8 @@ async function confirmInstall() {
98
111
  <div class="space-y-4 py-1 max-h-[28rem] overflow-y-auto pr-1">
99
112
  <div class="space-y-1.5">
100
113
  <Label for="tpl-install-name">{{ t("integration.templates.detail.install.nameLabel") }}</Label>
101
- <Input id="tpl-install-name" v-model="name" class="bg-white" :placeholder="t('integration.templates.detail.install.namePlaceholder')" />
114
+ <!-- 255: the platform's limit on a workflow's name (InstantiateTemplateRequest). -->
115
+ <Input id="tpl-install-name" v-model="name" class="bg-white" :maxlength="255" :placeholder="t('integration.templates.detail.install.namePlaceholder')" />
102
116
  </div>
103
117
 
104
118
  <div v-if="loading" class="flex items-center gap-2 py-4 text-sm text-muted-foreground">
@@ -112,7 +126,13 @@ async function confirmInstall() {
112
126
  <div class="text-mono text-muted-foreground border-b border-black/[0.06] pb-1.5">
113
127
  {{ t("integration.templates.detail.credentials.title") }}
114
128
  </div>
115
- <p v-if="!credentialReqs.length" class="text-sm text-muted-foreground italic">
129
+ <!-- Two lines, not one sentence around {reason}: the platform's message
130
+ ends in a full stop or not, and a template cannot know which. -->
131
+ <div v-if="reqsError" class="rounded-md border-l-2 border-amber-500/40 bg-amber-500/5 px-2.5 py-1.5 text-sm text-amber-700" data-testid="template-install-unchecked">
132
+ <p>{{ t("integration.templates.detail.setup.unchecked", { reason: reqsError }) }}</p>
133
+ <p>{{ t("integration.templates.detail.install.stillInstallable") }}</p>
134
+ </div>
135
+ <p v-else-if="!credentialReqs.length" class="text-sm text-muted-foreground italic">
116
136
  {{ t("integration.templates.detail.install.noCredentials") }}
117
137
  </p>
118
138
  <div v-for="req in credentialReqs" :key="req.slug" class="space-y-2">
@@ -131,15 +151,17 @@ async function confirmInstall() {
131
151
  <NuxtLink to="/integrations/credentials" class="text-primary hover:underline">{{ t("integration.templates.detail.install.createOneLink") }}</NuxtLink>
132
152
  {{ t("integration.templates.detail.install.orInstallLater") }}
133
153
  </p>
134
- <div v-for="nodeId in nodesFor(req.usedBy)" v-else :key="nodeId" class="space-y-1">
135
- <Label :for="`tpl-cred-${nodeId}`" class="text-xs text-muted-foreground font-mono">{{ t("integration.templates.detail.install.nodeLabel", { id: nodeId }) }}</Label>
154
+ <div v-for="(slot, si) in slotsFor(req.usedBy)" v-else :key="slot.key" class="space-y-1">
155
+ <!-- By position, not by joining two free strings: `a-b`+`c` and `a`+`b-c`
156
+ would share an id, and a label would name the wrong select. -->
157
+ <Label :for="`tpl-cred-${req.slug}-${si}`" class="text-xs text-muted-foreground font-mono">{{ slot.label }}</Label>
136
158
  <Select
137
- :model-value="bindings[nodeId] ?? SELECT_LATER"
159
+ :model-value="bindings[slot.key] ?? SELECT_LATER"
138
160
  @update:model-value="(v) => {
139
- bindings[nodeId] = v ?? SELECT_LATER;
161
+ bindings[slot.key] = v ?? SELECT_LATER;
140
162
  }"
141
163
  >
142
- <SelectTrigger :id="`tpl-cred-${nodeId}`" class="bg-white">
164
+ <SelectTrigger :id="`tpl-cred-${req.slug}-${si}`" class="bg-white">
143
165
  <SelectValue :placeholder="t('integration.templates.detail.install.selectLater')" />
144
166
  </SelectTrigger>
145
167
  <SelectContent>
@@ -31,6 +31,7 @@ import { formatDuration } from "../../utils/formatDuration";
31
31
  import { SAFETY_POLL_MS } from "../../utils/pollBudget";
32
32
  import { runStatusClass, runStatusLabel } from "../../utils/statusStyle";
33
33
  import { executedNodeIds, isSkippedStep, skipReasonMessage, stepStatusLabel } from "../../utils/skippedStep";
34
+ import { frameLabel, stepError } from "../../utils/stepError";
34
35
  import { errorReason } from "../../utils/apiError";
35
36
  const props = defineProps({
36
37
  workflow: { type: Object, required: true },
@@ -108,6 +109,7 @@ const playing = ref(false);
108
109
  let playTimer = null;
109
110
  const safeCursor = computed(() => steps.value.length === 0 ? 0 : Math.min(cursor.value, steps.value.length - 1));
110
111
  const activeStep = computed(() => steps.value[safeCursor.value]);
112
+ const activeError = computed(() => stepError(activeStep.value?.error));
111
113
  function seek(i) {
112
114
  playing.value = false;
113
115
  cursor.value = Math.max(0, Math.min(i, steps.value.length - 1));
@@ -529,9 +531,19 @@ watch(() => props.initialRunId, (id) => {
529
531
  <code class="rounded bg-black/[0.05] px-1.5 py-0.5 font-mono">{{ activeStep.branch }}</code>
530
532
  </div>
531
533
 
532
- <div v-if="activeStep.error" class="text-sm">
534
+ <div v-if="activeError" class="text-sm" data-testid="replay-step-error">
533
535
  <div class="text-mono text-muted-foreground mb-1">{{ t("integration.replay.debug.field.error") }}</div>
534
- <pre class="max-h-40 overflow-auto rounded-md border border-rose-500/20 bg-rose-500/5 p-2 font-mono text-sm text-rose-700">{{ activeStep.error }}</pre>
536
+ <div class="max-h-40 overflow-auto rounded-md border border-rose-500/20 bg-rose-500/5 p-2 text-sm text-rose-700 break-words whitespace-pre-wrap">{{ activeError.message }}</div>
537
+ <div v-if="activeError.type" class="mt-1 font-mono text-xs text-muted-foreground">{{ activeError.type }}</div>
538
+ <!-- Where the node's own code failed (PO-447): for whoever maintains the
539
+ node, so folded rather than beside the reason (IN-12). Keyed on the step so
540
+ it folds again on the next failed one. -->
541
+ <details v-if="activeError.frames?.length" :key="safeCursor" class="mt-1">
542
+ <summary class="cursor-pointer text-xs text-muted-foreground">{{ t("integration.replay.debug.field.frames") }}</summary>
543
+ <ol class="mt-1 space-y-0.5 font-mono text-xs">
544
+ <li v-for="(frame, fi) in activeError.frames" :key="fi">{{ frameLabel(frame) }}</li>
545
+ </ol>
546
+ </details>
535
547
  </div>
536
548
 
537
549
  <div v-if="activeStep.input !== void 0 && activeStep.input !== null" class="text-sm">
@@ -1,5 +1,3 @@
1
- export type AuditResourceType = 'credential' | 'secret' | 'workflow' | 'trigger' | 'workflow_run';
2
- export type AuditAction = 'create' | 'update' | 'delete' | 'run.started' | 'run.finished' | 'run.failed';
3
1
  export interface AuditEntry {
4
2
  id: number;
5
3
  org_id?: string;
@@ -8,12 +6,19 @@ export interface AuditEntry {
8
6
  /** As the actor's sign-in stated it when the entry was written; null for the platform, and for entries from before names were recorded (PO-609). */
9
7
  actor_name?: string | null;
10
8
  actor_email?: string | null;
9
+ /** Whether the platform wrote the entry with no person behind it, and by which route it arrived. Sent on every row; not shown yet. */
10
+ via_system?: boolean;
11
+ channel?: string;
11
12
  action: string;
12
13
  resource_type: string;
13
14
  resource_id?: string;
14
15
  metadata?: Record<string, unknown> | null;
15
16
  created_at: string;
16
17
  }
18
+ /**
19
+ * The narrowing the feed itself accepts. The page sends none of it today — it
20
+ * narrows in the browser over what it has loaded (specs/audit-log.md `## Gaps`).
21
+ */
17
22
  export interface AuditFilters {
18
23
  sub?: string;
19
24
  resource_type?: string;
@@ -8,11 +8,13 @@ import { IntegrationsApiError } from '../utils/apiError.js';
8
8
  * GET /credentials[?type=slug] → list
9
9
  * POST /credentials → create { credential_type_slug, name, config }
10
10
  * GET /credentials/{id} → show (masked)
11
- * PATCH /credentials/{id} → update { name?, config? } (blank secret = keep)
11
+ * PATCH /credentials/{id} → update { name?, config? } (blank secret = keep,
12
+ * unless the same edit changes any other value:
13
+ * then 422 at config.<secret>, PO-765)
12
14
  * DELETE /credentials/{id} → remove
13
15
  * POST /credentials/test → inline test { credential_type_slug, config }
14
- * POST /credentials/{id}/test → test saved instance
15
- * POST /credentials/{id}/oauth/authorize-url → { authorize_url, state }
16
+ * POST /credentials/{id}/test → test { config? } → { ok, message, recorded, last_test_at, last_test_ok }
17
+ * POST /credentials/{id}/oauth/authorize-url → { authorize_url }
16
18
  */
17
19
  /**
18
20
  * `message` is what the credential type said about the test, whichever way it went —
@@ -33,6 +35,12 @@ export interface CredentialTestResult {
33
35
  * test of an edit that tried values other than the stored ones (PO-535).
34
36
  */
35
37
  recorded?: boolean;
38
+ /**
39
+ * The test did not happen, so it says nothing about the credential: the platform's
40
+ * test broker had no room for it, or the reader may not try changed values (IN-22).
41
+ * Drawn neither as a pass nor as a failure.
42
+ */
43
+ untested?: true;
36
44
  /**
37
45
  * The refusal the service answered instead of testing, held as it arrived: values it
38
46
  * would not try as given — a stored secret kept for a changed server (PO-765). A form
@@ -2,7 +2,7 @@ import { computed } from "vue";
2
2
  import { useIntegrationsApi } from "./integrationsApi.js";
3
3
  import { useIntegrationsT } from "./useIntegrationsT.js";
4
4
  import { IntegrationsApiError, errorReason } from "../utils/apiError.js";
5
- import { unwrapList, unwrapRecord } from "../utils/apiEnvelope.js";
5
+ import { bareRecord, unwrapList } from "../utils/apiEnvelope.js";
6
6
  import { useLoadGate } from "./useLoadGate.js";
7
7
  export function useIntegrationsCredentials() {
8
8
  const credentials = useState("integrations.credentials", () => []);
@@ -33,15 +33,15 @@ export function useIntegrationsCredentials() {
33
33
  return credentials.value.filter((c) => c.credential_type_slug === slug);
34
34
  }
35
35
  async function get(id) {
36
- return unwrapRecord(await api.get(`/credentials/${encodeURIComponent(id)}`));
36
+ return bareRecord(await api.get(`/credentials/${encodeURIComponent(id)}`));
37
37
  }
38
38
  async function create(input) {
39
- const created = unwrapRecord(await api.post("/credentials", input));
39
+ const created = bareRecord(await api.post("/credentials", input));
40
40
  await load(true);
41
41
  return created;
42
42
  }
43
43
  async function update(id, input) {
44
- const updated = unwrapRecord(await api.patch(`/credentials/${encodeURIComponent(id)}`, input));
44
+ const updated = bareRecord(await api.patch(`/credentials/${encodeURIComponent(id)}`, input));
45
45
  await load(true);
46
46
  return updated;
47
47
  }
@@ -63,15 +63,19 @@ export function useIntegrationsCredentials() {
63
63
  `/credentials/${encodeURIComponent(id)}/test`,
64
64
  config ? { config } : void 0
65
65
  );
66
+ if (!config && res?.ok === false && res.recorded === false) return { ...res, untested: true };
66
67
  return res?.ok === false ? res : { ok: true, message: res?.message, recorded: res?.recorded };
67
68
  } catch (e) {
69
+ if (config && e instanceof IntegrationsApiError && e.status === 403) {
70
+ return { ok: false, untested: true, error: t("integration.credentials.shared.test.notAllowed") };
71
+ }
68
72
  const refusal = e instanceof IntegrationsApiError && e.status === 422 ? e : void 0;
69
73
  return { ok: false, error: errorReason(e, t("integration.credentials.error.testFailed")), refusal };
70
74
  }
71
75
  }
72
76
  async function oauthAuthorizeUrl(id) {
73
77
  const res = await api.post(`/credentials/${encodeURIComponent(id)}/oauth/authorize-url`);
74
- const authorizeUrl = unwrapRecord(res).authorize_url;
78
+ const authorizeUrl = bareRecord(res).authorize_url;
75
79
  if (!authorizeUrl) throw new IntegrationsApiError(0, res, t("integration.credentials.error.noAuthorizeUrl"));
76
80
  return authorizeUrl;
77
81
  }
@@ -2,7 +2,7 @@ import { computed } from "vue";
2
2
  import { useIntegrationsApi } from "./integrationsApi.js";
3
3
  import { useIntegrationsT } from "./useIntegrationsT.js";
4
4
  import { errorReason } from "../utils/apiError.js";
5
- import { unwrapRecord } from "../utils/apiEnvelope.js";
5
+ import { bareRecord } from "../utils/apiEnvelope.js";
6
6
  import { RUN_OWN_FIELDS } from "../utils/runEnvelope.js";
7
7
  import { useLoadGate } from "./useLoadGate.js";
8
8
  import {
@@ -58,6 +58,10 @@ export function useIntegrationsRuns() {
58
58
  // The platform reports what started the run. This was hardcoded to
59
59
  // `manual` because the payload carried nothing, which left the trigger
60
60
  // column and its filter with one populated value in production.
61
+ // Null — the trigger was deleted, or the run predates trigger_handle, or the
62
+ // row came from a per-workflow read, which sends no trigger at all — is read
63
+ // as `manual` here although the platform said nothing (IN-9, api-contract
64
+ // runs.feed F6 and runs.execution F2).
61
65
  triggerType: r.trigger_type ?? "manual",
62
66
  startedAt: r.started_at ?? "",
63
67
  finishedAt: r.completed_at ?? null,
@@ -187,11 +191,11 @@ export function useIntegrationsRuns() {
187
191
  async function trigger(workflowId, payload = {}, mode) {
188
192
  const body = { trigger_payload: payload };
189
193
  if (mode) body.mode = mode;
190
- return unwrapRecord(await api.post(`/workflows/${workflowId}/runs`, body));
194
+ return bareRecord(await api.post(`/workflows/${workflowId}/runs`, body), RUN_OWN_FIELDS);
191
195
  }
192
196
  async function runAction(workflowId, runId, action, reason) {
193
197
  const body = reason ? { reason } : {};
194
- return unwrapRecord(await api.post(`/workflows/${workflowId}/runs/${runId}/${action}`, body));
198
+ return bareRecord(await api.post(`/workflows/${workflowId}/runs/${runId}/${action}`, body), RUN_OWN_FIELDS);
195
199
  }
196
200
  const cancelRun = (workflowId, runId, reason) => runAction(workflowId, runId, "cancel", reason);
197
201
  const terminateRun = (workflowId, runId, reason) => runAction(workflowId, runId, "terminate", reason);
@@ -199,7 +203,7 @@ export function useIntegrationsRuns() {
199
203
  const resumeRun = (workflowId, runId, reason) => runAction(workflowId, runId, "resume", reason);
200
204
  const getRun = (workflowId, runId) => api.get(`/workflows/${workflowId}/runs/${runId}`);
201
205
  async function fetchRun(workflowId, runId, workflowName) {
202
- const apiRun = unwrapRecord(await getRun(workflowId, runId), RUN_OWN_FIELDS);
206
+ const apiRun = bareRecord(await getRun(workflowId, runId), RUN_OWN_FIELDS);
203
207
  return mapRun(apiRun, workflowName);
204
208
  }
205
209
  const getDetails = (workflowId, runId) => api.get(`/workflows/${workflowId}/runs/${runId}/details`);
@@ -2,7 +2,8 @@ import { computed } from "vue";
2
2
  import { useIntegrationsApi } from "./integrationsApi.js";
3
3
  import { useIntegrationsT } from "./useIntegrationsT.js";
4
4
  import { useLoadGate } from "./useLoadGate.js";
5
- import { errorReason } from "../utils/apiError.js";
5
+ import { errorReason, IntegrationsApiError } from "../utils/apiError.js";
6
+ import { unwrapList } from "../utils/apiEnvelope.js";
6
7
  import { scheduleTimezone } from "../utils/schedule.js";
7
8
  export function useIntegrationsSchedules() {
8
9
  const api = useIntegrationsApi();
@@ -66,8 +67,10 @@ export function useIntegrationsSchedules() {
66
67
  error.value = null;
67
68
  try {
68
69
  const res = await api.get("/schedules");
69
- entries.value = (res?.data ?? []).map(mapEntry);
70
- meta.value = res?.meta ?? { total: 0, live_count: 0, timezones: [] };
70
+ const rows = unwrapList(res);
71
+ if (!res?.meta) throw new IntegrationsApiError(200, res, "");
72
+ entries.value = rows.map(mapEntry);
73
+ meta.value = res.meta;
71
74
  loaded.value = true;
72
75
  } catch (e) {
73
76
  error.value = errorReason(e, t("integration.schedules.error.loadFailed"));
@@ -1,12 +1,13 @@
1
1
  import type { SecretRecord } from '../types/integrations.js';
2
+ export declare const SECRET_KEY_MAX = 191;
2
3
  /**
3
4
  * Per-tenant encrypted key/value store, backed by the Integrations service
4
- * (`/api/v1/secrets`). The backend NEVER returns values — only keys +
5
- * timestamps — so the list is keys-only by design.
5
+ * (`/api/v1/secrets`). The backend NEVER returns a value.
6
6
  *
7
- * GET /secrets → list (keys + timestamps)
8
- * POST /secrets → upsert { key, value }
9
- * DELETE /secrets/{key} → remove
7
+ * GET /secrets → { data: [{ key, created_at, updated_at }] }, key-ascending
8
+ * POST /secrets → upsert { key, value } — 201 created, 200 replaced, bare record
9
+ * PATCH /secrets/{key} → replace the value (404 unknown key) — not used by this UI
10
+ * DELETE /secrets/{key} → 204, or 404 for a key this tenant does not hold
10
11
  */
11
12
  export declare function useIntegrationsSecrets(): {
12
13
  secrets: any;
@@ -4,7 +4,8 @@ import { useIntegrationsT } from "./useIntegrationsT.js";
4
4
  import { errorReason } from "../utils/apiError.js";
5
5
  import { unwrapList } from "../utils/apiEnvelope.js";
6
6
  import { useLoadGate } from "./useLoadGate.js";
7
- const KEY_PATTERN = /^[A-Z0-9_]+$/;
7
+ const KEY_PATTERN = /^[\w-]+$/;
8
+ export const SECRET_KEY_MAX = 191;
8
9
  export function useIntegrationsSecrets() {
9
10
  const secrets = useState("integrations.secrets", () => []);
10
11
  const loading = useState("integrations.secrets.loading", () => false);
@@ -14,7 +15,7 @@ export function useIntegrationsSecrets() {
14
15
  const api = useIntegrationsApi();
15
16
  const t = useIntegrationsT();
16
17
  function isValidKey(key) {
17
- return KEY_PATTERN.test(key);
18
+ return KEY_PATTERN.test(key) && [...key].length <= SECRET_KEY_MAX;
18
19
  }
19
20
  function normalize(res) {
20
21
  const obj = res;
@@ -12,9 +12,11 @@ export declare function useIntegrationsTemplates(): {
12
12
  fetchDetail: (slug: string) => Promise<WorkflowTemplate | null>;
13
13
  ensureDetails: (slugs: string[], _concurrency?: number) => Promise<void>;
14
14
  requirements: (slug: string) => Promise<TemplateRequirements | null>;
15
+ checkRequirements: (slug: string) => Promise<TemplateRequirements>;
16
+ uncheckedReason: (err: unknown) => string;
15
17
  instantiate: (slug: string, opts?: {
16
18
  name?: string;
17
- credentialBindings?: Record<string, string>;
19
+ credentialBindings?: Record<string, string | Record<string, string>>;
18
20
  }) => Promise<number | null>;
19
21
  install: (slug: string) => Promise<number | null>;
20
22
  };
@@ -1,12 +1,11 @@
1
1
  import { computed } from "vue";
2
2
  import { useIntegrationsApi } from "./integrationsApi.js";
3
3
  import { errorReason } from "../utils/apiError.js";
4
- import { unwrapList, unwrapRecord } from "../utils/apiEnvelope.js";
4
+ import { bareRecord, unwrapList, unwrapRecord } from "../utils/apiEnvelope.js";
5
5
  import { resolveLocalized } from "../utils/localize.js";
6
6
  import { useLoadGate } from "./useLoadGate.js";
7
7
  import { createDetailLoader, keyedBatch } from "../utils/detailLoader.js";
8
8
  const BLOB_VERSION = "v0-draft";
9
- const INDUSTRY_VALUES = ["mechanical-engineering", "chemicals", "wholesale", "manufacturing", "retail", "any"];
10
9
  function plainExcerpt(md) {
11
10
  for (const raw of md.split("\n")) {
12
11
  const line = raw.trim();
@@ -21,7 +20,7 @@ function publisherFor(slug, vendors) {
21
20
  return { name: ns, kind };
22
21
  }
23
22
  function mapIndustries(arr) {
24
- return (arr ?? []).filter((i) => INDUSTRY_VALUES.includes(i));
23
+ return (arr ?? []).filter((i) => typeof i === "string" && i.trim() !== "").map((i) => i.trim());
25
24
  }
26
25
  function emptyBlob(name) {
27
26
  return { nodeManifestVersion: BLOB_VERSION, name, nodes: [], edges: [], triggerPositions: {}, triggers: [] };
@@ -58,7 +57,7 @@ function mapListItem(api) {
58
57
  function applyDetail(base, api) {
59
58
  const def = api.definition ?? {};
60
59
  const triggers = (api.triggers ?? []).map((t) => ({
61
- id: t.handle ?? String(t.id ?? ""),
60
+ id: t.handle,
62
61
  type: t.type,
63
62
  name: typeof t.name === "string" ? t.name : void 0,
64
63
  active: t.active,
@@ -166,16 +165,22 @@ export function useIntegrationsTemplates() {
166
165
  if (!wanted.length) return;
167
166
  remember(await loader.many(wanted));
168
167
  }
168
+ async function checkRequirements(slug) {
169
+ const raw = unwrapRecord(await api.get(`/templates/${slug}/requirements`));
170
+ return {
171
+ ...raw,
172
+ credentialTypes: (raw.credentialTypes ?? []).map((c) => ({
173
+ ...c,
174
+ name: c.name ? resolveLocalized(c.name) : c.name
175
+ }))
176
+ };
177
+ }
178
+ function uncheckedReason(err) {
179
+ return errorReason(err, t("integration.templates.detail.setup.uncheckedFallback"));
180
+ }
169
181
  async function requirements(slug) {
170
182
  try {
171
- const raw = unwrapRecord(await api.get(`/templates/${slug}/requirements`));
172
- return {
173
- ...raw,
174
- credentialTypes: (raw.credentialTypes ?? []).map((c) => ({
175
- ...c,
176
- name: c.name ? resolveLocalized(c.name) : c.name
177
- }))
178
- };
183
+ return await checkRequirements(slug);
179
184
  } catch {
180
185
  return null;
181
186
  }
@@ -184,10 +189,10 @@ export function useIntegrationsTemplates() {
184
189
  const body = {};
185
190
  if (opts.name) body.name = opts.name;
186
191
  const bindings = Object.fromEntries(
187
- Object.entries(opts.credentialBindings ?? {}).filter(([, v]) => v)
192
+ Object.entries(opts.credentialBindings ?? {}).map(([nodeId, b]) => [nodeId, typeof b === "string" ? b : Object.fromEntries(Object.entries(b).filter(([, id]) => id))]).filter(([, b]) => typeof b === "string" ? b !== "" : Object.keys(b).length > 0)
188
193
  );
189
194
  if (Object.keys(bindings).length) body.credential_bindings = bindings;
190
- const created = unwrapRecord(await api.post(`/templates/${slug}/instantiate`, body));
195
+ const created = bareRecord(await api.post(`/templates/${slug}/instantiate`, body));
191
196
  return Number(created.id);
192
197
  }
193
198
  async function install(slug) {
@@ -195,5 +200,5 @@ export function useIntegrationsTemplates() {
195
200
  }
196
201
  const total = computed(() => templates.value.length);
197
202
  const startingPoints = computed(() => templates.value.slice(0, 4));
198
- return { templates, loaded, loading, error, total, startingPoints, load, list, get, fetchDetail, ensureDetails, requirements, instantiate, install };
203
+ return { templates, loaded, loading, error, total, startingPoints, load, list, get, fetchDetail, ensureDetails, requirements, checkRequirements, uncheckedReason, instantiate, install };
199
204
  }