@revenexx/studio-integrations 3.0.2 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/CHANGELOG.md +20 -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 +14 -5
  5. package/dist/runtime/components/Integrations/NodeInspector.vue +1 -1
  6. package/dist/runtime/components/Integrations/TemplateFlowPreview.vue +1 -1
  7. package/dist/runtime/components/Integrations/TemplateInstallStep.vue +28 -10
  8. package/dist/runtime/composables/useIntegrationsAudit.d.ts +7 -2
  9. package/dist/runtime/composables/useIntegrationsCredentials.d.ts +18 -3
  10. package/dist/runtime/composables/useIntegrationsCredentials.js +11 -6
  11. package/dist/runtime/composables/useIntegrationsRuns.d.ts +1 -1
  12. package/dist/runtime/composables/useIntegrationsRuns.js +8 -4
  13. package/dist/runtime/composables/useIntegrationsSchedules.js +6 -3
  14. package/dist/runtime/composables/useIntegrationsSecrets.d.ts +6 -5
  15. package/dist/runtime/composables/useIntegrationsSecrets.js +3 -2
  16. package/dist/runtime/composables/useIntegrationsTemplates.d.ts +1 -1
  17. package/dist/runtime/composables/useIntegrationsTemplates.js +5 -6
  18. package/dist/runtime/composables/useIntegrationsWorkflows.js +4 -4
  19. package/dist/runtime/composables/useNodeRetirement.js +5 -5
  20. package/dist/runtime/i18n/messages/de.json +18 -4
  21. package/dist/runtime/i18n/messages/en.json +18 -4
  22. package/dist/runtime/pages/integrations/audit.vue +43 -9
  23. package/dist/runtime/pages/integrations/credentials.vue +3 -2
  24. package/dist/runtime/pages/integrations/runs.vue +3 -1
  25. package/dist/runtime/pages/integrations/secrets.vue +3 -1
  26. package/dist/runtime/pages/integrations/templates/[slug].vue +1 -1
  27. package/dist/runtime/types/integrations.d.ts +9 -6
  28. package/dist/runtime/utils/apiEnvelope.d.ts +15 -5
  29. package/dist/runtime/utils/apiEnvelope.js +11 -8
  30. package/dist/runtime/utils/credentialTest.d.ts +17 -0
  31. package/dist/runtime/utils/credentialTest.js +14 -0
  32. package/dist/runtime/utils/runEnvelope.d.ts +22 -24
  33. package/dist/runtime/utils/runEnvelope.js +2 -4
  34. package/package.json +1 -1
  35. package/dist/runtime/data/integrationWorkflowSamples.d.ts +0 -18
  36. package/dist/runtime/data/integrationWorkflowSamples.js +0 -318
package/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # @revenexx/studio-integrations
2
2
 
3
+ ## 3.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 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.
8
+
9
+ 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.
10
+
11
+ ### Patch Changes
12
+
13
+ - 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.
14
+
15
+ ## 3.0.3
16
+
17
+ ### Patch Changes
18
+
19
+ - 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.
20
+
21
+ 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.
22
+
3
23
  ## 3.0.2
4
24
 
5
25
  ### 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.2",
4
+ "version": "3.1.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 },
@@ -134,6 +135,12 @@ async function runTest() {
134
135
  const payload = buildConfigPayload();
135
136
  const edited = credentialEditDiffers(fieldsOf(props.type), payload, props.credential.public_config ?? {});
136
137
  const result = await test(props.credential.id, edited ? payload : void 0);
138
+ if (result.refusal) {
139
+ if (opening !== viewOpening) return;
140
+ saveRefusal.value = result.refusal;
141
+ void focusFirstRefusedField();
142
+ return;
143
+ }
137
144
  if (result.recorded !== false) {
138
145
  await load(true);
139
146
  emit("changed");
@@ -156,11 +163,13 @@ async function save() {
156
163
  const payload = buildConfigPayload();
157
164
  const edited = credentialEditDiffers(fieldsOf(props.type), payload, props.credential.public_config ?? {});
158
165
  try {
159
- await update(id, { name: form.name.trim(), config: payload });
166
+ const wasConnected = props.credential.status !== "needs_reauth";
167
+ const updated = await update(id, { name: form.name.trim(), config: payload });
168
+ const lostConsent = wasConnected && updated.status === "needs_reauth";
160
169
  emit("changed");
161
170
  emit("update:open", false);
162
- toast.success(t("integration.credentials.toast.updated"));
163
- if (edited) void testSaved(id);
171
+ toast.success(t(lostConsent ? "integration.credentials.toast.updatedReconnect" : "integration.credentials.toast.updated"));
172
+ if (edited && !lostConsent) void testSaved(id);
164
173
  } catch (e) {
165
174
  saveRefusal.value = e;
166
175
  void focusFirstRefusedField();
@@ -367,9 +376,9 @@ async function connect() {
367
376
  <div
368
377
  v-if="liveTest"
369
378
  class="rounded-md px-3 py-2 text-sm"
370
- :class="liveTest.ok ? 'bg-emerald-500/10 text-emerald-700' : 'bg-rose-500/10 text-rose-700'"
379
+ :class="testOutcomeView(liveTest, t).toneClass"
371
380
  >
372
- {{ 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 }}
373
382
  </div>
374
383
 
375
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;
@@ -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",
@@ -29,6 +29,7 @@ const installing = ref(false);
29
29
  const error = ref(null);
30
30
  const name = ref("");
31
31
  const bindings = reactive({});
32
+ const slotKey = (nodeId, configKey) => `${nodeId}\0${configKey}`;
32
33
  let run = 0;
33
34
  watch(() => props.slug, async (slug) => {
34
35
  const mine = ++run;
@@ -68,16 +69,30 @@ const secretChecks = computed(() => {
68
69
  present: secretKeys.value.includes(key)
69
70
  }));
70
71
  });
71
- function nodesFor(usedBy) {
72
- return [...new Set(usedBy.map((u) => u.nodeId))];
72
+ function slotsFor(usedBy) {
73
+ const all = credentialReqs.value.flatMap((r) => r.usedBy);
74
+ const seen = /* @__PURE__ */ new Set();
75
+ return usedBy.filter((u) => {
76
+ const key = slotKey(u.nodeId, u.configKey);
77
+ if (seen.has(key)) return false;
78
+ seen.add(key);
79
+ return true;
80
+ }).map((u) => ({
81
+ key: slotKey(u.nodeId, u.configKey),
82
+ nodeId: u.nodeId,
83
+ configKey: u.configKey,
84
+ 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 })
85
+ }));
73
86
  }
74
87
  async function confirmInstall() {
75
88
  installing.value = true;
76
89
  error.value = null;
77
90
  try {
78
91
  const credentialBindings = {};
79
- for (const [nodeId, credId] of Object.entries(bindings)) {
80
- if (credId && credId !== SELECT_LATER) credentialBindings[nodeId] = credId;
92
+ for (const [key, credId] of Object.entries(bindings)) {
93
+ if (!credId || credId === SELECT_LATER) continue;
94
+ const [nodeId, configKey] = key.split("\0");
95
+ (credentialBindings[nodeId] ??= {})[configKey] = credId;
81
96
  }
82
97
  const id = await instantiate(props.slug, {
83
98
  name: name.value.trim(),
@@ -98,7 +113,8 @@ async function confirmInstall() {
98
113
  <div class="space-y-4 py-1 max-h-[28rem] overflow-y-auto pr-1">
99
114
  <div class="space-y-1.5">
100
115
  <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')" />
116
+ <!-- 255: the platform's limit on a workflow's name (InstantiateTemplateRequest). -->
117
+ <Input id="tpl-install-name" v-model="name" class="bg-white" :maxlength="255" :placeholder="t('integration.templates.detail.install.namePlaceholder')" />
102
118
  </div>
103
119
 
104
120
  <div v-if="loading" class="flex items-center gap-2 py-4 text-sm text-muted-foreground">
@@ -131,15 +147,17 @@ async function confirmInstall() {
131
147
  <NuxtLink to="/integrations/credentials" class="text-primary hover:underline">{{ t("integration.templates.detail.install.createOneLink") }}</NuxtLink>
132
148
  {{ t("integration.templates.detail.install.orInstallLater") }}
133
149
  </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>
150
+ <div v-for="(slot, si) in slotsFor(req.usedBy)" v-else :key="slot.key" class="space-y-1">
151
+ <!-- By position, not by joining two free strings: `a-b`+`c` and `a`+`b-c`
152
+ would share an id, and a label would name the wrong select. -->
153
+ <Label :for="`tpl-cred-${req.slug}-${si}`" class="text-xs text-muted-foreground font-mono">{{ slot.label }}</Label>
136
154
  <Select
137
- :model-value="bindings[nodeId] ?? SELECT_LATER"
155
+ :model-value="bindings[slot.key] ?? SELECT_LATER"
138
156
  @update:model-value="(v) => {
139
- bindings[nodeId] = v ?? SELECT_LATER;
157
+ bindings[slot.key] = v ?? SELECT_LATER;
140
158
  }"
141
159
  >
142
- <SelectTrigger :id="`tpl-cred-${nodeId}`" class="bg-white">
160
+ <SelectTrigger :id="`tpl-cred-${req.slug}-${si}`" class="bg-white">
143
161
  <SelectValue :placeholder="t('integration.templates.detail.install.selectLater')" />
144
162
  </SelectTrigger>
145
163
  <SelectContent>
@@ -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;
@@ -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
@@ -7,11 +8,13 @@ import type { Credential } from '../types/integrations.js';
7
8
  * GET /credentials[?type=slug] → list
8
9
  * POST /credentials → create { credential_type_slug, name, config }
9
10
  * GET /credentials/{id} → show (masked)
10
- * 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)
11
14
  * DELETE /credentials/{id} → remove
12
15
  * POST /credentials/test → inline test { credential_type_slug, config }
13
- * POST /credentials/{id}/test → test saved instance
14
- * 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 }
15
18
  */
16
19
  /**
17
20
  * `message` is what the credential type said about the test, whichever way it went —
@@ -32,6 +35,18 @@ export interface CredentialTestResult {
32
35
  * test of an edit that tried values other than the stored ones (PO-535).
33
36
  */
34
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;
44
+ /**
45
+ * The refusal the service answered instead of testing, held as it arrived: values it
46
+ * would not try as given — a stored secret kept for a changed server (PO-765). A form
47
+ * marks the fields it names, as it does for a refused save.
48
+ */
49
+ refusal?: IntegrationsApiError;
35
50
  }
36
51
  export declare function useIntegrationsCredentials(): {
37
52
  credentials: any;
@@ -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,14 +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) {
68
- return { ok: false, error: errorReason(e, t("integration.credentials.error.testFailed")) };
69
+ if (config && e instanceof IntegrationsApiError && e.status === 403) {
70
+ return { ok: false, untested: true, error: t("integration.credentials.shared.test.notAllowed") };
71
+ }
72
+ const refusal = e instanceof IntegrationsApiError && e.status === 422 ? e : void 0;
73
+ return { ok: false, error: errorReason(e, t("integration.credentials.error.testFailed")), refusal };
69
74
  }
70
75
  }
71
76
  async function oauthAuthorizeUrl(id) {
72
77
  const res = await api.post(`/credentials/${encodeURIComponent(id)}/oauth/authorize-url`);
73
- const authorizeUrl = unwrapRecord(res).authorize_url;
78
+ const authorizeUrl = bareRecord(res).authorize_url;
74
79
  if (!authorizeUrl) throw new IntegrationsApiError(0, res, t("integration.credentials.error.noAuthorizeUrl"));
75
80
  return authorizeUrl;
76
81
  }
@@ -44,7 +44,7 @@ export declare function useIntegrationsRuns(): {
44
44
  latestLoading: any;
45
45
  loadLatestPerWorkflow: (force?: boolean) => Promise<WorkflowRun[]>;
46
46
  lastRunFor: (workflowId: number) => WorkflowRun | null;
47
- trigger: (workflowId: number | string, payload?: Record<string, unknown>, mode?: "async" | "sync") => Promise<unknown>;
47
+ trigger: (workflowId: number | string, payload?: Record<string, unknown>, mode?: "sync" | "async") => Promise<unknown>;
48
48
  cancelRun: (workflowId: number | string, runId: number | string, reason?: string) => Promise<unknown>;
49
49
  terminateRun: (workflowId: number | string, runId: number | string, reason?: string) => Promise<unknown>;
50
50
  retryRun: (workflowId: number | string, runId: number | string) => Promise<unknown>;
@@ -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;
@@ -14,7 +14,7 @@ export declare function useIntegrationsTemplates(): {
14
14
  requirements: (slug: string) => Promise<TemplateRequirements | null>;
15
15
  instantiate: (slug: string, opts?: {
16
16
  name?: string;
17
- credentialBindings?: Record<string, string>;
17
+ credentialBindings?: Record<string, string | Record<string, string>>;
18
18
  }) => Promise<number | null>;
19
19
  install: (slug: string) => Promise<number | null>;
20
20
  };
@@ -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,
@@ -184,10 +183,10 @@ export function useIntegrationsTemplates() {
184
183
  const body = {};
185
184
  if (opts.name) body.name = opts.name;
186
185
  const bindings = Object.fromEntries(
187
- Object.entries(opts.credentialBindings ?? {}).filter(([, v]) => v)
186
+ 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
187
  );
189
188
  if (Object.keys(bindings).length) body.credential_bindings = bindings;
190
- const created = unwrapRecord(await api.post(`/templates/${slug}/instantiate`, body));
189
+ const created = bareRecord(await api.post(`/templates/${slug}/instantiate`, body));
191
190
  return Number(created.id);
192
191
  }
193
192
  async function install(slug) {
@@ -3,7 +3,7 @@ import { useIntegrationsApi } from "./integrationsApi.js";
3
3
  import { useIntegrationsT } from "./useIntegrationsT.js";
4
4
  import { assignTriggerHandles, pausedTriggers } from "../utils/workflowFile.js";
5
5
  import { errorReason } from "../utils/apiError.js";
6
- import { unwrapList, unwrapRecord } from "../utils/apiEnvelope.js";
6
+ import { bareRecord, unwrapList } from "../utils/apiEnvelope.js";
7
7
  import { useLoadGate } from "./useLoadGate.js";
8
8
  import { eventConfigForPlatform } from "../data/eventTrigger.js";
9
9
  const BLOB_VERSION = "v0-draft";
@@ -140,7 +140,7 @@ export function useIntegrationsWorkflows() {
140
140
  }
141
141
  async function get(id, { strict = false } = {}) {
142
142
  try {
143
- const wf = ingest(unwrapRecord(await api.get(`/workflows/${id}`)));
143
+ const wf = ingest(bareRecord(await api.get(`/workflows/${id}`)));
144
144
  upsertCache(wf);
145
145
  return wf;
146
146
  } catch (e) {
@@ -149,7 +149,7 @@ export function useIntegrationsWorkflows() {
149
149
  }
150
150
  }
151
151
  async function create(input) {
152
- const wf = ingest(unwrapRecord(await api.post("/workflows", toPayload({
152
+ const wf = ingest(bareRecord(await api.post("/workflows", toPayload({
153
153
  name: input.name,
154
154
  description: input.description,
155
155
  blob: input.blob,
@@ -182,7 +182,7 @@ export function useIntegrationsWorkflows() {
182
182
  throw lastError instanceof Error ? lastError : new Error(t("integration.workflows.error.duplicateFailed"));
183
183
  }
184
184
  async function update(id, patch) {
185
- const wf = ingest(unwrapRecord(await api.put(`/workflows/${id}`, toPayload({
185
+ const wf = ingest(bareRecord(await api.put(`/workflows/${id}`, toPayload({
186
186
  name: patch.name,
187
187
  description: patch.description,
188
188
  blob: patch.blob,
@@ -1,5 +1,5 @@
1
1
  import { IntegrationsApiError } from "../utils/apiError.js";
2
- import { unwrapList, unwrapRecord } from "../utils/apiEnvelope.js";
2
+ import { bareRecord, unwrapList } from "../utils/apiEnvelope.js";
3
3
  import { useIntegrationsApi } from "./integrationsApi.js";
4
4
  function versionPath(slug, version) {
5
5
  return `/nodes/${encodeURIComponent(slug)}/${encodeURIComponent(version)}`;
@@ -11,7 +11,7 @@ export function useNodeRetirement() {
11
11
  return all.filter((n) => n.package?.name === packageName);
12
12
  }
13
13
  async function usage(slug, version) {
14
- const answer = unwrapRecord(await api.get(`${versionPath(slug, version)}/usage`), ["usage"]);
14
+ const answer = bareRecord(await api.get(`${versionPath(slug, version)}/usage`));
15
15
  const counts = answer?.usage;
16
16
  if (!counts || typeof counts.workflows !== "number") {
17
17
  throw new IntegrationsApiError(200, answer, "");
@@ -19,13 +19,13 @@ export function useNodeRetirement() {
19
19
  return counts;
20
20
  }
21
21
  async function retire(slug, version, reason) {
22
- return unwrapRecord(await api.post(`${versionPath(slug, version)}/retire`, {
22
+ return bareRecord(await api.post(`${versionPath(slug, version)}/retire`, {
23
23
  confirm: `${slug}@${version}`,
24
24
  ...reason ? { reason } : {}
25
- }), ["usage"]);
25
+ }));
26
26
  }
27
27
  async function restore(slug, version) {
28
- return unwrapRecord(await api.del(`${versionPath(slug, version)}/retire`), ["usage"]);
28
+ return bareRecord(await api.del(`${versionPath(slug, version)}/retire`));
29
29
  }
30
30
  return { versionsOfPackage, usage, retire, restore };
31
31
  }