@checkstack/integration-backend 0.1.30 → 0.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,193 @@
1
1
  # @checkstack/integration-backend
2
2
 
3
+ ## 0.3.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 270ef29: Fix suspend/resume durability + complete the run-wide secret-masking guarantee.
8
+
9
+ A panel review confirmed several defects in the automation dispatch engine's suspend/resume durability and in the run-wide masking choke point. These survived because the unit suite stubbed the seam under test; the fixes ship with tests that exercise the real suspend / sweep / resume paths.
10
+
11
+ Suspend/resume durability:
12
+
13
+ - **Stalled sweeper no longer re-runs intentional waits.** `findStalledRunIds` now joins `automation_runs` and returns only `status = 'running'` runs, and suspend-finalisation no longer clobbers the run's `lastActionPath` checkpoint to `null`. Previously any wait longer than the stale window (>60s) was re-walked from the top every sweep cycle, re-firing pre-wait side effects and leaking wait locks. The wait-aware sweeps now also run before the stalled-run sweep.
14
+ - **Stalled recovery refuses a run holding a live wait lock.** `recoverStalledRun` now only recovers a genuinely-`running` run with no wait lock; a crash-mid-wait recovery is left to the wait/resume paths instead of re-walking from the top and creating a duplicate lock + duplicate delay job.
15
+ - **Cancelled runs can no longer resurrect.** `resumeRun` guards on `status === 'waiting'` (mirroring `checkWaitUntil`) and drops any stale lock for a non-waiting run, so `wakeWaitingRuns` / delay-expiry / a racing queue job can't wake a cancelled or terminal run. `cancelActiveRuns` (restart mode) now deletes the cancelled runs' wait locks + run-state in the same operation.
16
+ - **Concurrency check-then-create is serialized.** The `mode` check + `createRun` now run under a transaction-scoped advisory lock keyed on `(automationId, scope)`, so two concurrent fires can't both pass a `single`-mode "no active run" check and double-run.
17
+
18
+ Masking guarantee (now genuinely covers scope + artifacts):
19
+
20
+ - **The run-wide masking choke point now also masks the durable scope snapshot and produced artifacts.** The `RunSecretRegistry` is threaded into `RunStateStore.upsert` (masks `scopeSnapshot`) and `ArtifactStore.record` (masks `data`) so a resolved connection credential threaded into `scope.variables` or surfaced into an artifact is redacted before persist - and therefore cannot reach a read-only user via `getRunScopeForReplay`. **GUARANTEE CHANGE**: run-wide masking now covers step output, run error, scope snapshot, and artifact data for every action.
21
+ - **`testConnection` / `testProviderConnection` mask provider errors.** These RPCs run outside a dispatch run, so they build a per-call mask set from the resolved/submitted connection config and run any provider error through it before returning, so a provider error echoing a token can't cross back to the browser.
22
+ - **Short secrets surface a warning.** `setSecret` now warns when a value is shorter than `MIN_MASKABLE_LENGTH` (4) that it cannot be auto-redacted (the threshold is intentionally not lowered).
23
+
24
+ Internal:
25
+
26
+ - `@checkstack/backend-api`: `withXactLock`'s `fn` now receives the transaction handle `tx` so a critical section can run on the locked connection; the doc clarifies why running on the pool inside the lock window is still safe. The incident dedup caller's comment is corrected accordingly. `RunStore` gains `findWaitLocksByRun`.
27
+
28
+ - 270ef29: Secrets platform Phase 5b: route integration connection credentials through the ONE secrets channel.
29
+
30
+ Connection credentials now resolve through the same secrets channel as
31
+ everything else, so a credential can originate from Vault and there is no
32
+ parallel credential-resolution code to drift. Two entry forms, both walked
33
+ by the shared `walkSecretFields` machinery (acting only on the provider
34
+ `connectionSchema`'s `x-secret` fields):
35
+
36
+ - Reference form: a `${{ secrets.NAME }}` template resolves through the
37
+ ACTIVE backend (local or Vault) via `secretResolverRef`.
38
+ - Inline form: an operator-typed value is extracted into an internal
39
+ secret on the local backend; the stored config keeps only a reference
40
+ marker, resolved via `internalSecretsRef`.
41
+
42
+ The `ConnectionStore` public API is unchanged: `listConnections` /
43
+ `getConnection` stay redacted; `getConnectionWithCredentials` inflates via
44
+ the unified channel. A one-time, idempotent, parity-verified, REVERSIBLE
45
+ migration (backup ConfigService entry per connection; rewrites only after
46
+ the platform copy reads back identically) moves existing inline
47
+ credentials onto the platform without breaking live connections.
48
+
49
+ `secrets-backend` exports `walkSecretFields` (the shared schema-walk behind
50
+ `resolveSecretsBySchema`, reused for the migration extract + inflate).
51
+
52
+ BREAKING CHANGES: a connection's stored credential fields may now hold a
53
+ `${{ secrets.NAME }}` reference or an internal-reference marker instead of
54
+ an inline value. Resolution is transparent (`getConnectionWithCredentials`
55
+ returns the same plaintext); a legacy inline value still resolves until the
56
+ one-time migration converts it.
57
+
58
+ ### Patch Changes
59
+
60
+ - 270ef29: Secrets platform Phase 5: internal-secret consolidation (registry token) + connection-credential leak hardening.
61
+
62
+ - New `internalSecretsRef`: platform-internal secrets (not user-managed
63
+ named secrets) stored under a reserved `__internal__:` prefix, ALWAYS on
64
+ the local (always-writable, AES-GCM) backend so internal writes never
65
+ break when Vault is the active backend. Excluded from the user-facing
66
+ Secrets list.
67
+ - The script-package registry auth token is consolidated onto
68
+ `internalSecretsRef`. The `authSecretRef` column now holds a stable
69
+ marker; a one-time, idempotent, parity-verified migration moves legacy
70
+ inline ciphertext into the platform and only rewrites the column once the
71
+ platform copy reads back identically (legacy value never dropped early).
72
+ Resolution stays backward-compatible with legacy ciphertext.
73
+ - Integration: `createConnection` / `updateConnection` now return the
74
+ redacted connection preview instead of echoing the submitted credential
75
+ fields back in the response (leak hardening). Non-breaking — the frontend
76
+ refetches the redacted list and ignores the returned preview.
77
+
78
+ NOTE: integration connection-credential STORAGE is intentionally NOT
79
+ migrated onto the secrets platform. Connection creds are co-mingled
80
+ secret/non-secret config stored per-provider via `ConfigService` (which
81
+ already uses the same AES-GCM crypto + per-field redaction); splitting them
82
+ out would require per-provider schema-walking and a lossy migration across
83
+ live integrations for no real gain. The `ConnectionStore` API + storage are
84
+ unchanged.
85
+
86
+ - Updated dependencies [270ef29]
87
+ - Updated dependencies [270ef29]
88
+ - Updated dependencies [270ef29]
89
+ - Updated dependencies [b995afb]
90
+ - Updated dependencies [270ef29]
91
+ - Updated dependencies [270ef29]
92
+ - Updated dependencies [270ef29]
93
+ - Updated dependencies [b995afb]
94
+ - Updated dependencies [270ef29]
95
+ - Updated dependencies [270ef29]
96
+ - Updated dependencies [270ef29]
97
+ - Updated dependencies [270ef29]
98
+ - Updated dependencies [270ef29]
99
+ - Updated dependencies [270ef29]
100
+ - Updated dependencies [b995afb]
101
+ - @checkstack/backend-api@0.19.0
102
+ - @checkstack/secrets-backend@0.1.0
103
+ - @checkstack/secrets-common@0.1.0
104
+ - @checkstack/command-backend@0.1.32
105
+ - @checkstack/queue-api@0.3.7
106
+
107
+ ## 0.2.0
108
+
109
+ ### Minor Changes
110
+
111
+ - 41c77f4: feat(automation): one-time migration of webhook subscriptions + remove legacy integration backend
112
+
113
+ **BREAKING CHANGES** (platform is in BETA — no major bump):
114
+
115
+ - `IntegrationProvider` no longer carries `config` (subscription
116
+ config) or `deliver`. The interface now models a connection provider
117
+ only: connection schema + `getConnectionOptions` + `testConnection`.
118
+ - The legacy subscription / delivery-log / event endpoints
119
+ (`listSubscriptions`, `createSubscription`, `getDeliveryLogs`,
120
+ `listEventTypes`, …) are removed from `integrationContract`.
121
+ - `delivery-coordinator`, `hook-subscriber`, `event-registry`, and the
122
+ `integrationEventExtensionPoint` are deleted. Plugins that
123
+ previously called `integrationEvents.registerEvent(...)` now
124
+ register their hooks as automation triggers via
125
+ `automationTriggerExtensionPoint.registerTrigger(...)`.
126
+ - Frontend pages `IntegrationsPage` and `DeliveryLogsPage` are gone;
127
+ the integration plugin's only remaining UI is connection
128
+ management. Subscription management lives under `/automation/...`.
129
+ - `webhook_subscriptions` and `delivery_logs` tables stay in the
130
+ database for one release as a safety net (no code reads or writes
131
+ them), and will be dropped in a follow-up migration.
132
+
133
+ **New**:
134
+
135
+ - `jira.create_issue`, `teams.post_message`, `webex.post_message`,
136
+ `webhook.send`, `integration-script.run_shell`, and
137
+ `integration-script.run_script` actions registered against the
138
+ Automation Platform with matching `*.message`, `*.delivery`,
139
+ `shell.result`, and `script.result` artifact types. The script
140
+ plugin exposes **two** actions — `run_shell` runs bash via the
141
+ shared `ShellScriptRunner` (Monaco `shell` editor), `run_script`
142
+ runs an ESM module in a Bun subprocess via `EsmScriptRunner`
143
+ (Monaco `typescript` editor + `defineIntegration` helper) — to
144
+ preserve the legacy provider split. `jira.create_issue` keeps the
145
+ dynamic field-mapping dropdown (driven by
146
+ `JIRA_RESOLVERS.FIELD_OPTIONS`).
147
+ - One-time data migration runs on boot in
148
+ `automation-backend.afterPluginsReady`. It reads
149
+ `webhook_subscriptions` via a new service RPC
150
+ `IntegrationApi.listLegacySubscriptions`, translates each row into
151
+ a single-trigger / single-action automation (marked with
152
+ `managed_by = "migrated-subscription:<id>"`), and is idempotent
153
+ across restarts.
154
+ - Failed translations are recorded in a new
155
+ `automation_migration_failures` table and surfaced via
156
+ `AutomationApi.listMigrationFailures` /
157
+ `acknowledgeMigrationFailure` so admins can review and re-create
158
+ failed entries by hand.
159
+
160
+ ### Patch Changes
161
+
162
+ - 41c77f4: feat(jira): register Jira automation actions + `jira.issue` artifact type
163
+
164
+ Adds three Jira actions to the Automation platform:
165
+
166
+ - `jira.create_issue` — produces the new `jira.issue` artifact type
167
+ (`issueKey`, `projectKey`, `issueUrl`, `id`, `status?`)
168
+ - `jira.transition_issue` — consumes `jira.issue` (or accepts an
169
+ explicit `issueKey`), idempotent against already-applied transitions
170
+ - `jira.add_comment` — consumes `jira.issue` (or accepts an explicit
171
+ `issueKey`)
172
+
173
+ Extends the Jira client with `getTransitions`, `getIssueStatus`,
174
+ `transitionIssue` (handles 204 No Content, comment in ADF for Cloud /
175
+ plain text for Data Center), and `addComment`. Adds a new
176
+ `JIRA_RESOLVERS.TRANSITION_OPTIONS` cascading dropdown driven by
177
+ `connectionId` + `issueKey`. `@checkstack/integration-backend` now
178
+ re-exports the `ConnectionStore` interface so action plugins can take
179
+ it as a typed dep.
180
+
181
+ - Updated dependencies [41c77f4]
182
+ - Updated dependencies [6d52276]
183
+ - Updated dependencies [35bc682]
184
+ - @checkstack/integration-common@0.6.0
185
+ - @checkstack/common@0.12.0
186
+ - @checkstack/backend-api@0.18.0
187
+ - @checkstack/command-backend@0.1.31
188
+ - @checkstack/signal-common@0.2.5
189
+ - @checkstack/queue-api@0.3.6
190
+
3
191
  ## 0.1.30
4
192
 
5
193
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/integration-backend",
3
- "version": "0.1.30",
3
+ "version": "0.3.0",
4
4
  "license": "Elastic-2.0",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -15,21 +15,23 @@
15
15
  "test": "bun test"
16
16
  },
17
17
  "dependencies": {
18
- "@checkstack/integration-common": "0.5.0",
19
- "@checkstack/backend-api": "0.17.0",
20
- "@checkstack/signal-common": "0.2.4",
21
- "@checkstack/queue-api": "0.3.4",
22
- "@checkstack/common": "0.11.0",
23
- "@checkstack/command-backend": "0.1.29",
18
+ "@checkstack/integration-common": "0.6.0",
19
+ "@checkstack/backend-api": "0.18.0",
20
+ "@checkstack/signal-common": "0.2.5",
21
+ "@checkstack/queue-api": "0.3.6",
22
+ "@checkstack/common": "0.12.0",
23
+ "@checkstack/command-backend": "0.1.31",
24
+ "@checkstack/secrets-common": "0.0.1",
25
+ "@checkstack/secrets-backend": "0.0.1",
24
26
  "drizzle-orm": "^0.45.0",
25
27
  "zod": "^4.2.1",
26
28
  "@orpc/server": "^1.13.2"
27
29
  },
28
30
  "devDependencies": {
29
31
  "@checkstack/drizzle-helper": "0.0.5",
30
- "@checkstack/scripts": "0.3.3",
32
+ "@checkstack/scripts": "0.3.4",
31
33
  "@checkstack/tsconfig": "0.0.7",
32
- "@checkstack/test-utils-backend": "0.1.29",
34
+ "@checkstack/test-utils-backend": "0.1.31",
33
35
  "@types/node": "^20.0.0",
34
36
  "drizzle-kit": "^0.31.10",
35
37
  "typescript": "^5.0.0"
@@ -0,0 +1,171 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import { z } from "zod";
3
+ import { configString } from "@checkstack/backend-api";
4
+ import { createMockLogger } from "@checkstack/test-utils-backend";
5
+ import type {
6
+ Logger,
7
+ ConfigService,
8
+ } from "@checkstack/backend-api";
9
+ import type {
10
+ SecretResolverService,
11
+ InternalSecretsService,
12
+ } from "@checkstack/secrets-backend";
13
+ import {
14
+ migrateConnectionCredentials,
15
+ type ConnectionForMigration,
16
+ } from "./connection-credentials-migration";
17
+ import { inflateConnectionCredentials } from "./connection-credentials";
18
+
19
+ const schema = z.object({
20
+ baseUrl: z.string(),
21
+ apiToken: configString({ "x-secret": true }),
22
+ });
23
+
24
+ function fakeInternal(): InternalSecretsService & { store: Map<string, string> } {
25
+ const store = new Map<string, string>();
26
+ const key = (parts: string[]) => parts.join("|");
27
+ return {
28
+ store,
29
+ set: async ({ parts, value }) => {
30
+ store.set(key(parts), value);
31
+ },
32
+ get: async ({ parts }) => store.get(key(parts)),
33
+ delete: async ({ parts }) => {
34
+ store.delete(key(parts));
35
+ },
36
+ };
37
+ }
38
+
39
+ const noopResolver: SecretResolverService = {
40
+ resolveSecret: async () => "",
41
+ resolveBySchema: async ({ value }) => ({ resolved: value, warnings: [] }),
42
+ resolveForRun: async () => ({
43
+ env: {},
44
+ masking: { size: 0, maskText: (t) => t, maskDeep: (v) => v },
45
+ }),
46
+ };
47
+
48
+ /** Minimal in-memory ConfigService for the backup entries. */
49
+ function fakeConfigService(): ConfigService {
50
+ const store = new Map<string, unknown>();
51
+ return {
52
+ get: async (id) => store.get(id) as never,
53
+ getRedacted: async (id) => store.get(id) as never,
54
+ set: async (id, _s, _v, data) => {
55
+ store.set(id, data);
56
+ },
57
+ delete: async (id) => {
58
+ store.delete(id);
59
+ },
60
+ list: async () => [...store.keys()],
61
+ };
62
+ }
63
+
64
+ const PROVIDER = "integration-jira.jira";
65
+
66
+ describe("migrateConnectionCredentials", () => {
67
+ it("migrates an inline credential, backs it up, and is parity-verified + reversible", async () => {
68
+ const internal = fakeInternal();
69
+ const configService = fakeConfigService();
70
+ const persisted = new Map<string, Record<string, unknown>>();
71
+
72
+ const conn: ConnectionForMigration = {
73
+ providerId: PROVIDER,
74
+ connectionId: "c1",
75
+ schema,
76
+ schemaVersion: 1,
77
+ config: { baseUrl: "https://x", apiToken: "inline-secret-1" },
78
+ };
79
+
80
+ const result = await migrateConnectionCredentials({
81
+ configService,
82
+ internalSecrets: internal,
83
+ secretResolver: noopResolver,
84
+ logger: createMockLogger() as Logger,
85
+ loadConnections: async () => [conn],
86
+ persistConfig: async ({ connectionId, config }) => {
87
+ persisted.set(connectionId, config);
88
+ },
89
+ });
90
+
91
+ expect(result).toEqual({ total: 1, migrated: 1, skipped: 0, failed: 0 });
92
+
93
+ // Stored config is reference-ized (no plaintext).
94
+ const stored = persisted.get("c1")!;
95
+ expect(JSON.stringify(stored)).not.toContain("inline-secret-1");
96
+
97
+ // Reversible: a backup of the original raw config exists.
98
+ const backup = await configService.get(
99
+ "integration_connection_credbackup_integration-jira_jira_c1",
100
+ z.object({ config: z.record(z.string(), z.unknown()) }),
101
+ 1,
102
+ );
103
+ expect((backup as { config: Record<string, unknown> }).config.apiToken).toBe(
104
+ "inline-secret-1",
105
+ );
106
+
107
+ // Parity: inflating the stored form yields the original plaintext.
108
+ const { config: inflated } = await inflateConnectionCredentials({
109
+ providerId: PROVIDER,
110
+ connectionId: "c1",
111
+ config: stored,
112
+ schema,
113
+ deps: { internalSecrets: internal, secretResolver: noopResolver },
114
+ });
115
+ expect(inflated).toEqual(conn.config);
116
+ });
117
+
118
+ it("is idempotent: re-running over the already-migrated (marker) config does nothing", async () => {
119
+ const internal = fakeInternal();
120
+ const configService = fakeConfigService();
121
+
122
+ // First pass.
123
+ let stored: Record<string, unknown> = {
124
+ baseUrl: "https://x",
125
+ apiToken: "inline-secret-2",
126
+ };
127
+ const run = (config: Record<string, unknown>) =>
128
+ migrateConnectionCredentials({
129
+ configService,
130
+ internalSecrets: internal,
131
+ secretResolver: noopResolver,
132
+ logger: createMockLogger() as Logger,
133
+ loadConnections: async () => [
134
+ { providerId: PROVIDER, connectionId: "c2", schema, schemaVersion: 1, config },
135
+ ],
136
+ persistConfig: async ({ config: c }) => {
137
+ stored = c;
138
+ },
139
+ });
140
+
141
+ const first = await run(stored);
142
+ expect(first.migrated).toBe(1);
143
+ const second = await run(stored); // now reference-ized
144
+ expect(second.migrated).toBe(0);
145
+ expect(second.skipped).toBe(1);
146
+ });
147
+
148
+ it("skips a connection with no inline secret (already a reference)", async () => {
149
+ const internal = fakeInternal();
150
+ const result = await migrateConnectionCredentials({
151
+ configService: fakeConfigService(),
152
+ internalSecrets: internal,
153
+ secretResolver: noopResolver,
154
+ logger: createMockLogger() as Logger,
155
+ loadConnections: async () => [
156
+ {
157
+ providerId: PROVIDER,
158
+ connectionId: "c3",
159
+ schema,
160
+ schemaVersion: 1,
161
+ config: { baseUrl: "https://x", apiToken: "${{ secrets.jira }}" },
162
+ },
163
+ ],
164
+ persistConfig: async () => {
165
+ throw new Error("should not persist a reference-only connection");
166
+ },
167
+ });
168
+ expect(result.skipped).toBe(1);
169
+ expect(result.migrated).toBe(0);
170
+ });
171
+ });
@@ -0,0 +1,182 @@
1
+ /**
2
+ * One-time, idempotent, parity-verified, REVERSIBLE migration that moves
3
+ * inline connection credentials onto the secrets platform.
4
+ *
5
+ * For each existing connection of each provider that has a
6
+ * `connectionSchema`:
7
+ * 1. Back up the current raw config to a backup ConfigService entry
8
+ * (only if no backup exists yet) so the pre-migration value is
9
+ * recoverable.
10
+ * 2. Extract inline `x-secret` values into internal secrets (local
11
+ * backend) via `extractInlineCredentials`, producing a config whose
12
+ * secret fields are internal-ref markers.
13
+ * 3. Verify parity: inflate the rewritten config back through the unified
14
+ * channel and confirm every secret field resolves to the SAME value as
15
+ * the original. Only if parity holds, rewrite the stored config.
16
+ *
17
+ * Idempotent: a config already reference-ized (markers / `${{ }}`) extracts
18
+ * nothing and is left as-is. Guarded across boots by the per-connection
19
+ * backup entry + the marker form. Never drops a value until the platform
20
+ * copy reads back identically.
21
+ */
22
+ import { z } from "zod";
23
+ import type { ConfigService, Logger } from "@checkstack/backend-api";
24
+ import { extractErrorMessage } from "@checkstack/common";
25
+ import type {
26
+ SecretResolverService,
27
+ InternalSecretsService,
28
+ } from "@checkstack/secrets-backend";
29
+ import {
30
+ extractInlineCredentials,
31
+ inflateConnectionCredentials,
32
+ } from "./connection-credentials";
33
+
34
+ const BACKUP_VERSION = 1;
35
+
36
+ /** Backup ConfigService key for a connection's pre-migration raw config. */
37
+ function backupKey(providerId: string, connectionId: string): string {
38
+ const sanitized = providerId.replaceAll(".", "_");
39
+ return `integration_connection_credbackup_${sanitized}_${connectionId}`;
40
+ }
41
+
42
+ const BackupSchema = z.object({
43
+ config: z.record(z.string(), z.unknown()),
44
+ migratedAt: z.coerce.date(),
45
+ });
46
+
47
+ export interface ConnectionForMigration {
48
+ providerId: string;
49
+ connectionId: string;
50
+ /** Provider connection schema (Zod) used to find x-secret fields. */
51
+ schema: z.ZodTypeAny;
52
+ /** Provider connectionSchema version (for ConfigService set). */
53
+ schemaVersion: number;
54
+ /** Current raw stored config. */
55
+ config: Record<string, unknown>;
56
+ }
57
+
58
+ export interface MigrationResult {
59
+ total: number;
60
+ migrated: number;
61
+ skipped: number;
62
+ failed: number;
63
+ }
64
+
65
+ /**
66
+ * Run the credential migration over the given connections.
67
+ *
68
+ * `loadConnections` yields every connection (with its provider schema) so
69
+ * this stays decoupled from the store internals; `persistConfig` writes the
70
+ * rewritten config back through the store's normal path.
71
+ */
72
+ export async function migrateConnectionCredentials(params: {
73
+ configService: ConfigService;
74
+ internalSecrets: InternalSecretsService;
75
+ secretResolver: SecretResolverService;
76
+ logger: Logger;
77
+ loadConnections: () => Promise<ConnectionForMigration[]>;
78
+ persistConfig: (input: {
79
+ providerId: string;
80
+ connectionId: string;
81
+ schemaVersion: number;
82
+ config: Record<string, unknown>;
83
+ }) => Promise<void>;
84
+ }): Promise<MigrationResult> {
85
+ const {
86
+ configService,
87
+ internalSecrets,
88
+ secretResolver,
89
+ logger,
90
+ loadConnections,
91
+ persistConfig,
92
+ } = params;
93
+
94
+ const connections = await loadConnections();
95
+ const result: MigrationResult = {
96
+ total: connections.length,
97
+ migrated: 0,
98
+ skipped: 0,
99
+ failed: 0,
100
+ };
101
+
102
+ for (const conn of connections) {
103
+ try {
104
+ // 1. Back up the original raw config (idempotent — only once).
105
+ const bKey = backupKey(conn.providerId, conn.connectionId);
106
+ const existingBackup = await configService.get(
107
+ bKey,
108
+ BackupSchema,
109
+ BACKUP_VERSION,
110
+ );
111
+ if (!existingBackup) {
112
+ await configService.set(bKey, BackupSchema, BACKUP_VERSION, {
113
+ config: conn.config,
114
+ migratedAt: new Date(),
115
+ });
116
+ }
117
+
118
+ // 2. Extract inline secret values into internal secrets.
119
+ const { config: rewritten, extracted } = await extractInlineCredentials({
120
+ providerId: conn.providerId,
121
+ connectionId: conn.connectionId,
122
+ config: conn.config,
123
+ schema: conn.schema,
124
+ internalSecrets,
125
+ });
126
+
127
+ if (extracted === 0) {
128
+ // Already reference-ized (or no secret fields) — nothing to do.
129
+ result.skipped++;
130
+ continue;
131
+ }
132
+
133
+ // 3. Parity check: inflate the rewritten config and confirm the
134
+ // secret fields resolve back to the ORIGINAL values before we
135
+ // overwrite the stored config (reversible guarantee).
136
+ const original = await inflateConnectionCredentials({
137
+ providerId: conn.providerId,
138
+ connectionId: conn.connectionId,
139
+ config: conn.config,
140
+ schema: conn.schema,
141
+ deps: { secretResolver, internalSecrets },
142
+ });
143
+ const roundTripped = await inflateConnectionCredentials({
144
+ providerId: conn.providerId,
145
+ connectionId: conn.connectionId,
146
+ config: rewritten,
147
+ schema: conn.schema,
148
+ deps: { secretResolver, internalSecrets },
149
+ });
150
+ if (
151
+ JSON.stringify(original.config) !== JSON.stringify(roundTripped.config)
152
+ ) {
153
+ logger.error(
154
+ `Credential migration parity check FAILED for connection ${conn.connectionId}; leaving it unchanged.`,
155
+ );
156
+ result.failed++;
157
+ continue;
158
+ }
159
+
160
+ // Parity proven — rewrite the stored config to the reference form.
161
+ await persistConfig({
162
+ providerId: conn.providerId,
163
+ connectionId: conn.connectionId,
164
+ schemaVersion: conn.schemaVersion,
165
+ config: rewritten,
166
+ });
167
+ result.migrated++;
168
+ } catch (error) {
169
+ logger.error(
170
+ `Credential migration failed for connection ${conn.connectionId}: ${extractErrorMessage(error)}`,
171
+ );
172
+ result.failed++;
173
+ }
174
+ }
175
+
176
+ if (result.migrated > 0 || result.failed > 0) {
177
+ logger.info(
178
+ `Connection credential migration: ${result.migrated} migrated, ${result.skipped} skipped, ${result.failed} failed (of ${result.total}).`,
179
+ );
180
+ }
181
+ return result;
182
+ }