@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 +188 -0
- package/package.json +11 -9
- package/src/connection-credentials-migration.test.ts +171 -0
- package/src/connection-credentials-migration.ts +182 -0
- package/src/connection-credentials.test.ts +180 -0
- package/src/connection-credentials.ts +165 -0
- package/src/connection-store.ts +119 -6
- package/src/index.ts +55 -181
- package/src/provider-registry.test.ts +56 -286
- package/src/provider-registry.ts +5 -20
- package/src/provider-types.ts +23 -149
- package/src/router.ts +87 -608
- package/src/schema.ts +13 -59
- package/src/test-connection-masking.test.ts +98 -0
- package/tsconfig.json +6 -0
- package/src/delivery-coordinator.ts +0 -391
- package/src/event-registry.test.ts +0 -396
- package/src/event-registry.ts +0 -99
- package/src/hook-subscriber.ts +0 -105
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.
|
|
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.
|
|
19
|
-
"@checkstack/backend-api": "0.
|
|
20
|
-
"@checkstack/signal-common": "0.2.
|
|
21
|
-
"@checkstack/queue-api": "0.3.
|
|
22
|
-
"@checkstack/common": "0.
|
|
23
|
-
"@checkstack/command-backend": "0.1.
|
|
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.
|
|
32
|
+
"@checkstack/scripts": "0.3.4",
|
|
31
33
|
"@checkstack/tsconfig": "0.0.7",
|
|
32
|
-
"@checkstack/test-utils-backend": "0.1.
|
|
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
|
+
}
|