@checkstack/integration-backend 0.2.0 → 0.3.1
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 +114 -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 +38 -1
- package/src/router.ts +46 -4
- package/src/test-connection-masking.test.ts +98 -0
- package/tsconfig.json +6 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,119 @@
|
|
|
1
1
|
# @checkstack/integration-backend
|
|
2
2
|
|
|
3
|
+
## 0.3.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Updated dependencies [a57f7db]
|
|
8
|
+
- @checkstack/backend-api@0.20.0
|
|
9
|
+
- @checkstack/secrets-backend@0.1.1
|
|
10
|
+
- @checkstack/command-backend@0.1.33
|
|
11
|
+
- @checkstack/queue-api@0.3.8
|
|
12
|
+
|
|
13
|
+
## 0.3.0
|
|
14
|
+
|
|
15
|
+
### Minor Changes
|
|
16
|
+
|
|
17
|
+
- 270ef29: Fix suspend/resume durability + complete the run-wide secret-masking guarantee.
|
|
18
|
+
|
|
19
|
+
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.
|
|
20
|
+
|
|
21
|
+
Suspend/resume durability:
|
|
22
|
+
|
|
23
|
+
- **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.
|
|
24
|
+
- **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.
|
|
25
|
+
- **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.
|
|
26
|
+
- **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.
|
|
27
|
+
|
|
28
|
+
Masking guarantee (now genuinely covers scope + artifacts):
|
|
29
|
+
|
|
30
|
+
- **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.
|
|
31
|
+
- **`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.
|
|
32
|
+
- **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).
|
|
33
|
+
|
|
34
|
+
Internal:
|
|
35
|
+
|
|
36
|
+
- `@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`.
|
|
37
|
+
|
|
38
|
+
- 270ef29: Secrets platform Phase 5b: route integration connection credentials through the ONE secrets channel.
|
|
39
|
+
|
|
40
|
+
Connection credentials now resolve through the same secrets channel as
|
|
41
|
+
everything else, so a credential can originate from Vault and there is no
|
|
42
|
+
parallel credential-resolution code to drift. Two entry forms, both walked
|
|
43
|
+
by the shared `walkSecretFields` machinery (acting only on the provider
|
|
44
|
+
`connectionSchema`'s `x-secret` fields):
|
|
45
|
+
|
|
46
|
+
- Reference form: a `${{ secrets.NAME }}` template resolves through the
|
|
47
|
+
ACTIVE backend (local or Vault) via `secretResolverRef`.
|
|
48
|
+
- Inline form: an operator-typed value is extracted into an internal
|
|
49
|
+
secret on the local backend; the stored config keeps only a reference
|
|
50
|
+
marker, resolved via `internalSecretsRef`.
|
|
51
|
+
|
|
52
|
+
The `ConnectionStore` public API is unchanged: `listConnections` /
|
|
53
|
+
`getConnection` stay redacted; `getConnectionWithCredentials` inflates via
|
|
54
|
+
the unified channel. A one-time, idempotent, parity-verified, REVERSIBLE
|
|
55
|
+
migration (backup ConfigService entry per connection; rewrites only after
|
|
56
|
+
the platform copy reads back identically) moves existing inline
|
|
57
|
+
credentials onto the platform without breaking live connections.
|
|
58
|
+
|
|
59
|
+
`secrets-backend` exports `walkSecretFields` (the shared schema-walk behind
|
|
60
|
+
`resolveSecretsBySchema`, reused for the migration extract + inflate).
|
|
61
|
+
|
|
62
|
+
BREAKING CHANGES: a connection's stored credential fields may now hold a
|
|
63
|
+
`${{ secrets.NAME }}` reference or an internal-reference marker instead of
|
|
64
|
+
an inline value. Resolution is transparent (`getConnectionWithCredentials`
|
|
65
|
+
returns the same plaintext); a legacy inline value still resolves until the
|
|
66
|
+
one-time migration converts it.
|
|
67
|
+
|
|
68
|
+
### Patch Changes
|
|
69
|
+
|
|
70
|
+
- 270ef29: Secrets platform Phase 5: internal-secret consolidation (registry token) + connection-credential leak hardening.
|
|
71
|
+
|
|
72
|
+
- New `internalSecretsRef`: platform-internal secrets (not user-managed
|
|
73
|
+
named secrets) stored under a reserved `__internal__:` prefix, ALWAYS on
|
|
74
|
+
the local (always-writable, AES-GCM) backend so internal writes never
|
|
75
|
+
break when Vault is the active backend. Excluded from the user-facing
|
|
76
|
+
Secrets list.
|
|
77
|
+
- The script-package registry auth token is consolidated onto
|
|
78
|
+
`internalSecretsRef`. The `authSecretRef` column now holds a stable
|
|
79
|
+
marker; a one-time, idempotent, parity-verified migration moves legacy
|
|
80
|
+
inline ciphertext into the platform and only rewrites the column once the
|
|
81
|
+
platform copy reads back identically (legacy value never dropped early).
|
|
82
|
+
Resolution stays backward-compatible with legacy ciphertext.
|
|
83
|
+
- Integration: `createConnection` / `updateConnection` now return the
|
|
84
|
+
redacted connection preview instead of echoing the submitted credential
|
|
85
|
+
fields back in the response (leak hardening). Non-breaking — the frontend
|
|
86
|
+
refetches the redacted list and ignores the returned preview.
|
|
87
|
+
|
|
88
|
+
NOTE: integration connection-credential STORAGE is intentionally NOT
|
|
89
|
+
migrated onto the secrets platform. Connection creds are co-mingled
|
|
90
|
+
secret/non-secret config stored per-provider via `ConfigService` (which
|
|
91
|
+
already uses the same AES-GCM crypto + per-field redaction); splitting them
|
|
92
|
+
out would require per-provider schema-walking and a lossy migration across
|
|
93
|
+
live integrations for no real gain. The `ConnectionStore` API + storage are
|
|
94
|
+
unchanged.
|
|
95
|
+
|
|
96
|
+
- Updated dependencies [270ef29]
|
|
97
|
+
- Updated dependencies [270ef29]
|
|
98
|
+
- Updated dependencies [270ef29]
|
|
99
|
+
- Updated dependencies [b995afb]
|
|
100
|
+
- Updated dependencies [270ef29]
|
|
101
|
+
- Updated dependencies [270ef29]
|
|
102
|
+
- Updated dependencies [270ef29]
|
|
103
|
+
- Updated dependencies [b995afb]
|
|
104
|
+
- Updated dependencies [270ef29]
|
|
105
|
+
- Updated dependencies [270ef29]
|
|
106
|
+
- Updated dependencies [270ef29]
|
|
107
|
+
- Updated dependencies [270ef29]
|
|
108
|
+
- Updated dependencies [270ef29]
|
|
109
|
+
- Updated dependencies [270ef29]
|
|
110
|
+
- Updated dependencies [b995afb]
|
|
111
|
+
- @checkstack/backend-api@0.19.0
|
|
112
|
+
- @checkstack/secrets-backend@0.1.0
|
|
113
|
+
- @checkstack/secrets-common@0.1.0
|
|
114
|
+
- @checkstack/command-backend@0.1.32
|
|
115
|
+
- @checkstack/queue-api@0.3.7
|
|
116
|
+
|
|
3
117
|
## 0.2.0
|
|
4
118
|
|
|
5
119
|
### Minor Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@checkstack/integration-backend",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
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
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { configString } from "@checkstack/backend-api";
|
|
4
|
+
import type {
|
|
5
|
+
SecretResolverService,
|
|
6
|
+
InternalSecretsService,
|
|
7
|
+
} from "@checkstack/secrets-backend";
|
|
8
|
+
import {
|
|
9
|
+
inflateConnectionCredentials,
|
|
10
|
+
extractInlineCredentials,
|
|
11
|
+
internalRefMarker,
|
|
12
|
+
isInternalRefMarker,
|
|
13
|
+
connectionSecretParts,
|
|
14
|
+
} from "./connection-credentials";
|
|
15
|
+
|
|
16
|
+
// A Jira-like connection schema: baseUrl (non-secret) + apiToken (x-secret).
|
|
17
|
+
const connectionSchema = z.object({
|
|
18
|
+
baseUrl: z.string(),
|
|
19
|
+
email: z.string(),
|
|
20
|
+
apiToken: configString({ "x-secret": true }),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
/** In-memory internal-secrets fake (the local store). */
|
|
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
|
+
/** Resolver fake backed by a name->value map (simulates the active backend). */
|
|
40
|
+
function fakeResolver(values: Record<string, string>): SecretResolverService {
|
|
41
|
+
const RE = /\$\{\{\s*secrets\.([a-zA-Z0-9_-]+)\s*\}\}/g;
|
|
42
|
+
return {
|
|
43
|
+
resolveSecret: async ({ name }) => values[name] ?? "",
|
|
44
|
+
resolveBySchema: async ({ value }) => ({ resolved: value, warnings: [] }),
|
|
45
|
+
resolveForRun: async ({ secretEnv }) => {
|
|
46
|
+
const env: Record<string, string> = {};
|
|
47
|
+
for (const [k, template] of Object.entries(secretEnv)) {
|
|
48
|
+
RE.lastIndex = 0;
|
|
49
|
+
env[k] = template.replaceAll(RE, (_m, n: string) => values[n] ?? "");
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
env,
|
|
53
|
+
masking: { size: 0, maskText: (t) => t, maskDeep: (v) => v },
|
|
54
|
+
};
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const PROVIDER = "integration-jira.jira";
|
|
60
|
+
const CONN = "conn-1";
|
|
61
|
+
|
|
62
|
+
describe("extractInlineCredentials", () => {
|
|
63
|
+
it("moves an inline x-secret value into an internal secret + leaves a marker", async () => {
|
|
64
|
+
const internal = fakeInternal();
|
|
65
|
+
const { config, extracted } = await extractInlineCredentials({
|
|
66
|
+
providerId: PROVIDER,
|
|
67
|
+
connectionId: CONN,
|
|
68
|
+
config: { baseUrl: "https://x", email: "a@b.c", apiToken: "tok-INLINE" },
|
|
69
|
+
schema: connectionSchema,
|
|
70
|
+
internalSecrets: internal,
|
|
71
|
+
});
|
|
72
|
+
expect(extracted).toBe(1);
|
|
73
|
+
expect(config.baseUrl).toBe("https://x"); // non-secret untouched
|
|
74
|
+
expect(isInternalRefMarker(config.apiToken as string)).toBe(true);
|
|
75
|
+
expect(config.apiToken).toBe(internalRefMarker("apiToken"));
|
|
76
|
+
// The value moved to the internal store.
|
|
77
|
+
expect(
|
|
78
|
+
internal.store.get(
|
|
79
|
+
connectionSecretParts({
|
|
80
|
+
providerId: PROVIDER,
|
|
81
|
+
connectionId: CONN,
|
|
82
|
+
fieldPath: "apiToken",
|
|
83
|
+
}).join("|"),
|
|
84
|
+
),
|
|
85
|
+
).toBe("tok-INLINE");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("is idempotent: a marker or reference extracts nothing", async () => {
|
|
89
|
+
const internal = fakeInternal();
|
|
90
|
+
const { extracted } = await extractInlineCredentials({
|
|
91
|
+
providerId: PROVIDER,
|
|
92
|
+
connectionId: CONN,
|
|
93
|
+
config: {
|
|
94
|
+
baseUrl: "https://x",
|
|
95
|
+
email: "a@b.c",
|
|
96
|
+
apiToken: internalRefMarker("apiToken"),
|
|
97
|
+
},
|
|
98
|
+
schema: connectionSchema,
|
|
99
|
+
internalSecrets: internal,
|
|
100
|
+
});
|
|
101
|
+
expect(extracted).toBe(0);
|
|
102
|
+
|
|
103
|
+
const ref = await extractInlineCredentials({
|
|
104
|
+
providerId: PROVIDER,
|
|
105
|
+
connectionId: CONN,
|
|
106
|
+
config: { baseUrl: "https://x", email: "a@b.c", apiToken: "${{ secrets.jira }}" },
|
|
107
|
+
schema: connectionSchema,
|
|
108
|
+
internalSecrets: internal,
|
|
109
|
+
});
|
|
110
|
+
expect(ref.extracted).toBe(0);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe("inflateConnectionCredentials", () => {
|
|
115
|
+
it("inflates an internal-ref marker (inline path) from the local store", async () => {
|
|
116
|
+
const internal = fakeInternal();
|
|
117
|
+
await internal.set({
|
|
118
|
+
parts: connectionSecretParts({ providerId: PROVIDER, connectionId: CONN, fieldPath: "apiToken" }),
|
|
119
|
+
value: "tok-RESOLVED",
|
|
120
|
+
});
|
|
121
|
+
const { config, values } = await inflateConnectionCredentials({
|
|
122
|
+
providerId: PROVIDER,
|
|
123
|
+
connectionId: CONN,
|
|
124
|
+
config: { baseUrl: "https://x", email: "a@b.c", apiToken: internalRefMarker("apiToken") },
|
|
125
|
+
schema: connectionSchema,
|
|
126
|
+
deps: { internalSecrets: internal, secretResolver: fakeResolver({}) },
|
|
127
|
+
});
|
|
128
|
+
expect(config.apiToken).toBe("tok-RESOLVED");
|
|
129
|
+
expect(values).toContain("tok-RESOLVED");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("inflates a ${{ secrets.NAME }} reference (reference path) via the active backend", async () => {
|
|
133
|
+
const internal = fakeInternal();
|
|
134
|
+
const { config } = await inflateConnectionCredentials({
|
|
135
|
+
providerId: PROVIDER,
|
|
136
|
+
connectionId: CONN,
|
|
137
|
+
config: { baseUrl: "https://x", email: "a@b.c", apiToken: "${{ secrets.jira_token }}" },
|
|
138
|
+
schema: connectionSchema,
|
|
139
|
+
deps: {
|
|
140
|
+
internalSecrets: internal,
|
|
141
|
+
secretResolver: fakeResolver({ jira_token: "tok-FROM-VAULT" }),
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
expect(config.apiToken).toBe("tok-FROM-VAULT");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("round-trips: extract then inflate yields the original plaintext", async () => {
|
|
148
|
+
const internal = fakeInternal();
|
|
149
|
+
const original = { baseUrl: "https://x", email: "a@b.c", apiToken: "round-trip-tok" };
|
|
150
|
+
const { config: stored } = await extractInlineCredentials({
|
|
151
|
+
providerId: PROVIDER,
|
|
152
|
+
connectionId: CONN,
|
|
153
|
+
config: original,
|
|
154
|
+
schema: connectionSchema,
|
|
155
|
+
internalSecrets: internal,
|
|
156
|
+
});
|
|
157
|
+
// Stored form is reference-ized (no plaintext).
|
|
158
|
+
expect(JSON.stringify(stored)).not.toContain("round-trip-tok");
|
|
159
|
+
const { config: inflated } = await inflateConnectionCredentials({
|
|
160
|
+
providerId: PROVIDER,
|
|
161
|
+
connectionId: CONN,
|
|
162
|
+
config: stored,
|
|
163
|
+
schema: connectionSchema,
|
|
164
|
+
deps: { internalSecrets: internal, secretResolver: fakeResolver({}) },
|
|
165
|
+
});
|
|
166
|
+
expect(inflated).toEqual(original);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("leaves a bare legacy literal unchanged (pre-migration safety)", async () => {
|
|
170
|
+
const internal = fakeInternal();
|
|
171
|
+
const { config } = await inflateConnectionCredentials({
|
|
172
|
+
providerId: PROVIDER,
|
|
173
|
+
connectionId: CONN,
|
|
174
|
+
config: { baseUrl: "https://x", email: "a@b.c", apiToken: "legacy-inline" },
|
|
175
|
+
schema: connectionSchema,
|
|
176
|
+
deps: { internalSecrets: internal, secretResolver: fakeResolver({}) },
|
|
177
|
+
});
|
|
178
|
+
expect(config.apiToken).toBe("legacy-inline");
|
|
179
|
+
});
|
|
180
|
+
});
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified credential resolution for integration connections.
|
|
3
|
+
*
|
|
4
|
+
* Connection credential fields (the provider `connectionSchema`'s
|
|
5
|
+
* `x-secret` fields) resolve through the ONE secrets channel, two entry
|
|
6
|
+
* forms:
|
|
7
|
+
*
|
|
8
|
+
* 1. Reference form: the field holds a `${{ secrets.NAME }}` template.
|
|
9
|
+
* It resolves through the ACTIVE backend (local or Vault) via
|
|
10
|
+
* `secretResolverRef` — so a credential that "originates from Vault"
|
|
11
|
+
* just works.
|
|
12
|
+
* 2. Inline form: an operator-typed value, extracted into an INTERNAL
|
|
13
|
+
* secret on the local backend (Vault is read-through / unwritable) and
|
|
14
|
+
* represented in the stored config by an internal-reference marker.
|
|
15
|
+
* It resolves via `internalSecretsRef`.
|
|
16
|
+
*
|
|
17
|
+
* Both forms are walked with the shared `walkSecretFields` machinery
|
|
18
|
+
* (acting only on `x-secret` fields) — no per-provider logic is
|
|
19
|
+
* re-implemented. Non-secret config is never touched.
|
|
20
|
+
*/
|
|
21
|
+
import { z } from "zod";
|
|
22
|
+
import {
|
|
23
|
+
SECRET_TEMPLATE_REGEX,
|
|
24
|
+
type SecretEnvMapping,
|
|
25
|
+
} from "@checkstack/secrets-common";
|
|
26
|
+
import {
|
|
27
|
+
walkSecretFields,
|
|
28
|
+
type SecretResolverService,
|
|
29
|
+
type InternalSecretsService,
|
|
30
|
+
} from "@checkstack/secrets-backend";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Marker stored in a connection config field for an extracted INLINE
|
|
34
|
+
* credential. The actual value lives in an internal secret keyed by
|
|
35
|
+
* (providerId, connectionId, fieldPath). The marker carries the field path
|
|
36
|
+
* so the inflate can rebuild the internal-secret key.
|
|
37
|
+
*/
|
|
38
|
+
const INTERNAL_REF_PREFIX = "__connref__:";
|
|
39
|
+
|
|
40
|
+
export function internalRefMarker(fieldPath: string): string {
|
|
41
|
+
return `${INTERNAL_REF_PREFIX}${fieldPath}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function isInternalRefMarker(value: string): boolean {
|
|
45
|
+
return value.startsWith(INTERNAL_REF_PREFIX);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function fieldPathFromMarker(marker: string): string {
|
|
49
|
+
return marker.slice(INTERNAL_REF_PREFIX.length);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Internal-secret name parts for a connection credential field. */
|
|
53
|
+
export function connectionSecretParts(input: {
|
|
54
|
+
providerId: string;
|
|
55
|
+
connectionId: string;
|
|
56
|
+
fieldPath: string;
|
|
57
|
+
}): string[] {
|
|
58
|
+
return ["connection", input.providerId, input.connectionId, input.fieldPath];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Whether a string is a `${{ secrets.NAME }}` reference (whole-value). */
|
|
62
|
+
function isSecretReference(value: string): boolean {
|
|
63
|
+
SECRET_TEMPLATE_REGEX.lastIndex = 0;
|
|
64
|
+
return SECRET_TEMPLATE_REGEX.test(value);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface ConnectionCredentialDeps {
|
|
68
|
+
secretResolver: SecretResolverService;
|
|
69
|
+
internalSecrets: InternalSecretsService;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Inflate a stored connection config to full credentials by resolving its
|
|
74
|
+
* `x-secret` fields through the unified channel. A reference form resolves
|
|
75
|
+
* through the active backend; an internal-ref marker resolves the inline
|
|
76
|
+
* value from the local internal store; a bare literal (legacy, pre-migration)
|
|
77
|
+
* is returned unchanged.
|
|
78
|
+
*
|
|
79
|
+
* Returns the inflated config plus the set of resolved credential VALUES
|
|
80
|
+
* (for registering with the run-scoped masking context).
|
|
81
|
+
*/
|
|
82
|
+
export async function inflateConnectionCredentials(params: {
|
|
83
|
+
providerId: string;
|
|
84
|
+
connectionId: string;
|
|
85
|
+
config: Record<string, unknown>;
|
|
86
|
+
schema: z.ZodTypeAny;
|
|
87
|
+
deps: ConnectionCredentialDeps;
|
|
88
|
+
}): Promise<{ config: Record<string, unknown>; values: string[] }> {
|
|
89
|
+
const { providerId, connectionId, config, schema, deps } = params;
|
|
90
|
+
const values: string[] = [];
|
|
91
|
+
|
|
92
|
+
const inflated = await walkSecretFields({
|
|
93
|
+
value: config,
|
|
94
|
+
schema,
|
|
95
|
+
visit: async ({ value }) => {
|
|
96
|
+
let resolved = value;
|
|
97
|
+
if (isInternalRefMarker(value)) {
|
|
98
|
+
const fieldPath = fieldPathFromMarker(value);
|
|
99
|
+
const got = await deps.internalSecrets.get({
|
|
100
|
+
parts: connectionSecretParts({ providerId, connectionId, fieldPath }),
|
|
101
|
+
});
|
|
102
|
+
if (got === undefined) {
|
|
103
|
+
throw new Error(
|
|
104
|
+
`Connection ${connectionId}: internal credential for "${fieldPath}" not found.`,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
resolved = got;
|
|
108
|
+
} else if (isSecretReference(value)) {
|
|
109
|
+
// `${{ secrets.NAME }}` — resolve via the active backend (Vault/local).
|
|
110
|
+
const mapping: SecretEnvMapping = { CRED: value };
|
|
111
|
+
const { env } = await deps.secretResolver.resolveForRun({
|
|
112
|
+
secretEnv: mapping,
|
|
113
|
+
});
|
|
114
|
+
resolved = env.CRED;
|
|
115
|
+
}
|
|
116
|
+
// else: bare literal (legacy) — leave as-is.
|
|
117
|
+
if (resolved.length > 0) values.push(resolved);
|
|
118
|
+
return resolved;
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
return { config: inflated as Record<string, unknown>, values };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Extract INLINE `x-secret` values out of a stored connection config into
|
|
127
|
+
* internal secrets, replacing each with an internal-ref marker. Reference
|
|
128
|
+
* (`${{ secrets.NAME }}`) values and already-extracted markers are left
|
|
129
|
+
* untouched. Returns the rewritten config + how many inline values moved.
|
|
130
|
+
*
|
|
131
|
+
* Used by the one-time migration. Idempotent: re-running over an
|
|
132
|
+
* already-migrated config (markers everywhere) extracts nothing.
|
|
133
|
+
*/
|
|
134
|
+
export async function extractInlineCredentials(params: {
|
|
135
|
+
providerId: string;
|
|
136
|
+
connectionId: string;
|
|
137
|
+
config: Record<string, unknown>;
|
|
138
|
+
schema: z.ZodTypeAny;
|
|
139
|
+
internalSecrets: InternalSecretsService;
|
|
140
|
+
}): Promise<{ config: Record<string, unknown>; extracted: number }> {
|
|
141
|
+
const { providerId, connectionId, config, schema, internalSecrets } = params;
|
|
142
|
+
let extracted = 0;
|
|
143
|
+
|
|
144
|
+
const rewritten = await walkSecretFields({
|
|
145
|
+
value: config,
|
|
146
|
+
schema,
|
|
147
|
+
visit: async ({ path, value }) => {
|
|
148
|
+
// Already a marker or a reference — nothing to extract.
|
|
149
|
+
if (isInternalRefMarker(value) || isSecretReference(value)) {
|
|
150
|
+
return value;
|
|
151
|
+
}
|
|
152
|
+
// Empty literal — leave as-is (nothing to protect).
|
|
153
|
+
if (value.length === 0) return value;
|
|
154
|
+
// Inline literal: move it to an internal secret keyed by field path.
|
|
155
|
+
await internalSecrets.set({
|
|
156
|
+
parts: connectionSecretParts({ providerId, connectionId, fieldPath: path }),
|
|
157
|
+
value,
|
|
158
|
+
});
|
|
159
|
+
extracted++;
|
|
160
|
+
return internalRefMarker(path);
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
return { config: rewritten as Record<string, unknown>, extracted };
|
|
165
|
+
}
|
package/src/connection-store.ts
CHANGED
|
@@ -17,6 +17,15 @@ import type {
|
|
|
17
17
|
ProviderConnection,
|
|
18
18
|
ProviderConnectionRedacted,
|
|
19
19
|
} from "@checkstack/integration-common";
|
|
20
|
+
import {
|
|
21
|
+
inflateConnectionCredentials,
|
|
22
|
+
extractInlineCredentials,
|
|
23
|
+
type ConnectionCredentialDeps,
|
|
24
|
+
} from "./connection-credentials";
|
|
25
|
+
import {
|
|
26
|
+
migrateConnectionCredentials,
|
|
27
|
+
type ConnectionForMigration,
|
|
28
|
+
} from "./connection-credentials-migration";
|
|
20
29
|
|
|
21
30
|
// Schema for connection metadata (stored separately from config)
|
|
22
31
|
const ConnectionMetadataSchema = z.object({
|
|
@@ -105,12 +114,27 @@ interface ConnectionStoreDeps {
|
|
|
105
114
|
configService: ConfigService;
|
|
106
115
|
providerRegistry: IntegrationProviderRegistry;
|
|
107
116
|
logger: Logger;
|
|
117
|
+
/**
|
|
118
|
+
* Unified secret-credential resolution. When provided,
|
|
119
|
+
* `getConnectionWithCredentials` inflates `x-secret` fields through the
|
|
120
|
+
* ONE secrets channel: `${{ secrets.NAME }}` references resolve via the
|
|
121
|
+
* active backend (local or Vault), inline values resolve from the local
|
|
122
|
+
* internal store. Optional so tests / older boots without the secrets
|
|
123
|
+
* platform degrade to the legacy inline-config behavior.
|
|
124
|
+
*/
|
|
125
|
+
credentials?: ConnectionCredentialDeps;
|
|
108
126
|
}
|
|
109
127
|
|
|
128
|
+
/** The public store plus the internal one-time credential migration. */
|
|
129
|
+
export type ConnectionStoreWithMigration = ConnectionStore & {
|
|
130
|
+
/** Consolidate legacy inline credentials onto the secrets platform. */
|
|
131
|
+
runCredentialMigration(): Promise<void>;
|
|
132
|
+
};
|
|
133
|
+
|
|
110
134
|
export function createConnectionStore(
|
|
111
135
|
deps: ConnectionStoreDeps
|
|
112
|
-
):
|
|
113
|
-
const { configService, providerRegistry, logger } = deps;
|
|
136
|
+
): ConnectionStoreWithMigration {
|
|
137
|
+
const { configService, providerRegistry, logger, credentials } = deps;
|
|
114
138
|
|
|
115
139
|
// Cache of connectionId -> providerId for efficient lookups
|
|
116
140
|
const connectionProviderCache = new Map<string, string>();
|
|
@@ -242,6 +266,33 @@ export function createConnectionStore(
|
|
|
242
266
|
);
|
|
243
267
|
}
|
|
244
268
|
|
|
269
|
+
/**
|
|
270
|
+
* Persist a connection config, extracting any INLINE `x-secret` value into
|
|
271
|
+
* an internal secret first (so the stored config holds references /
|
|
272
|
+
* markers, never raw inline credentials). `${{ secrets.NAME }}`
|
|
273
|
+
* references are left as-is (they resolve via the active backend at use
|
|
274
|
+
* time). Falls back to plain storage when the credentials deps are absent.
|
|
275
|
+
*/
|
|
276
|
+
async function persistConnectionConfig(
|
|
277
|
+
providerId: string,
|
|
278
|
+
connectionId: string,
|
|
279
|
+
config: Record<string, unknown>
|
|
280
|
+
): Promise<void> {
|
|
281
|
+
const provider = providerRegistry.getProvider(providerId);
|
|
282
|
+
if (credentials && provider?.connectionSchema) {
|
|
283
|
+
const { config: rewritten } = await extractInlineCredentials({
|
|
284
|
+
providerId,
|
|
285
|
+
connectionId,
|
|
286
|
+
config,
|
|
287
|
+
schema: provider.connectionSchema.schema,
|
|
288
|
+
internalSecrets: credentials.internalSecrets,
|
|
289
|
+
});
|
|
290
|
+
await setConnectionConfig(providerId, connectionId, rewritten);
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
await setConnectionConfig(providerId, connectionId, config);
|
|
294
|
+
}
|
|
295
|
+
|
|
245
296
|
/**
|
|
246
297
|
* Delete connection config and metadata.
|
|
247
298
|
*/
|
|
@@ -335,11 +386,29 @@ export function createConnectionStore(
|
|
|
335
386
|
|
|
336
387
|
if (!metadata || !config) return;
|
|
337
388
|
|
|
389
|
+
// Inflate x-secret fields through the unified secrets channel:
|
|
390
|
+
// `${{ secrets.NAME }}` references resolve via the active backend
|
|
391
|
+
// (local or Vault); inline values resolve from the local internal
|
|
392
|
+
// store. When the credentials deps are absent (legacy / tests), the
|
|
393
|
+
// raw config is used as-is.
|
|
394
|
+
const provider = providerRegistry.getProvider(providerId);
|
|
395
|
+
let resolvedConfig = config;
|
|
396
|
+
if (credentials && provider?.connectionSchema) {
|
|
397
|
+
const inflated = await inflateConnectionCredentials({
|
|
398
|
+
providerId,
|
|
399
|
+
connectionId,
|
|
400
|
+
config,
|
|
401
|
+
schema: provider.connectionSchema.schema,
|
|
402
|
+
deps: credentials,
|
|
403
|
+
});
|
|
404
|
+
resolvedConfig = inflated.config;
|
|
405
|
+
}
|
|
406
|
+
|
|
338
407
|
return {
|
|
339
408
|
id: metadata.id,
|
|
340
409
|
providerId: metadata.providerId,
|
|
341
410
|
name: metadata.name,
|
|
342
|
-
config,
|
|
411
|
+
config: resolvedConfig,
|
|
343
412
|
createdAt: metadata.createdAt,
|
|
344
413
|
updatedAt: metadata.updatedAt,
|
|
345
414
|
};
|
|
@@ -357,9 +426,10 @@ export function createConnectionStore(
|
|
|
357
426
|
updatedAt: now,
|
|
358
427
|
};
|
|
359
428
|
|
|
360
|
-
// Save metadata and config separately
|
|
429
|
+
// Save metadata and config separately. Inline credentials are
|
|
430
|
+
// extracted to internal secrets so the stored config holds references.
|
|
361
431
|
await setConnectionMetadata(providerId, id, metadata);
|
|
362
|
-
await
|
|
432
|
+
await persistConnectionConfig(providerId, id, config);
|
|
363
433
|
|
|
364
434
|
// Add to index
|
|
365
435
|
const connectionIds = await getConnectionIndex(providerId);
|
|
@@ -371,6 +441,9 @@ export function createConnectionStore(
|
|
|
371
441
|
`Created connection "${name}" (${id}) for provider ${providerId}`
|
|
372
442
|
);
|
|
373
443
|
|
|
444
|
+
// Return the caller's config as submitted (so the create call's return
|
|
445
|
+
// is consistent with what was requested). The stored form is
|
|
446
|
+
// reference-ized; the router returns the redacted preview separately.
|
|
374
447
|
return { ...metadata, config };
|
|
375
448
|
},
|
|
376
449
|
|
|
@@ -404,7 +477,7 @@ export function createConnectionStore(
|
|
|
404
477
|
: existingConfig;
|
|
405
478
|
|
|
406
479
|
await setConnectionMetadata(providerId, connectionId, updatedMetadata);
|
|
407
|
-
await
|
|
480
|
+
await persistConnectionConfig(providerId, connectionId, updatedConfig);
|
|
408
481
|
|
|
409
482
|
logger.info(
|
|
410
483
|
`Updated connection "${updatedMetadata.name}" (${connectionId})`
|
|
@@ -459,5 +532,45 @@ export function createConnectionStore(
|
|
|
459
532
|
|
|
460
533
|
return;
|
|
461
534
|
},
|
|
535
|
+
|
|
536
|
+
// Not part of the public ConnectionStore interface (so callers are
|
|
537
|
+
// unchanged) — invoked once from afterPluginsReady to consolidate any
|
|
538
|
+
// legacy inline credentials onto the secrets platform.
|
|
539
|
+
async runCredentialMigration() {
|
|
540
|
+
if (!credentials) return;
|
|
541
|
+
await migrateConnectionCredentials({
|
|
542
|
+
configService,
|
|
543
|
+
internalSecrets: credentials.internalSecrets,
|
|
544
|
+
secretResolver: credentials.secretResolver,
|
|
545
|
+
logger,
|
|
546
|
+
loadConnections: async () => {
|
|
547
|
+
const out: ConnectionForMigration[] = [];
|
|
548
|
+
for (const provider of providerRegistry.getProviders()) {
|
|
549
|
+
if (!provider.connectionSchema) continue;
|
|
550
|
+
const ids = await getConnectionIndex(provider.qualifiedId);
|
|
551
|
+
for (const connectionId of ids) {
|
|
552
|
+
const config = await getConnectionConfigRaw(
|
|
553
|
+
provider.qualifiedId,
|
|
554
|
+
connectionId,
|
|
555
|
+
);
|
|
556
|
+
if (!config) continue;
|
|
557
|
+
out.push({
|
|
558
|
+
providerId: provider.qualifiedId,
|
|
559
|
+
connectionId,
|
|
560
|
+
schema: provider.connectionSchema.schema,
|
|
561
|
+
schemaVersion: provider.connectionSchema.version,
|
|
562
|
+
config,
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
return out;
|
|
567
|
+
},
|
|
568
|
+
persistConfig: async ({ providerId, connectionId, config }) => {
|
|
569
|
+
// The config is already reference-ized by the migration; store it
|
|
570
|
+
// verbatim (do not re-extract).
|
|
571
|
+
await setConnectionConfig(providerId, connectionId, config);
|
|
572
|
+
},
|
|
573
|
+
});
|
|
574
|
+
},
|
|
462
575
|
};
|
|
463
576
|
}
|
package/src/index.ts
CHANGED
|
@@ -23,6 +23,10 @@ import {
|
|
|
23
23
|
} from "./connection-store";
|
|
24
24
|
import { createIntegrationRouter } from "./router";
|
|
25
25
|
import { registerSearchProvider } from "@checkstack/command-backend";
|
|
26
|
+
import {
|
|
27
|
+
secretResolverRef,
|
|
28
|
+
internalSecretsRef,
|
|
29
|
+
} from "@checkstack/secrets-backend";
|
|
26
30
|
|
|
27
31
|
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
28
32
|
// Service References
|
|
@@ -62,6 +66,11 @@ export const integrationProviderExtensionPoint =
|
|
|
62
66
|
// Plugin Definition
|
|
63
67
|
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
64
68
|
|
|
69
|
+
interface EnvStash {
|
|
70
|
+
/** Set in `init`, used by `afterPluginsReady` for the credential migration. */
|
|
71
|
+
runCredentialMigration?: () => Promise<void>;
|
|
72
|
+
}
|
|
73
|
+
|
|
65
74
|
export default createBackendPlugin({
|
|
66
75
|
metadata: pluginMetadata,
|
|
67
76
|
|
|
@@ -85,16 +94,31 @@ export default createBackendPlugin({
|
|
|
85
94
|
logger: coreServices.logger,
|
|
86
95
|
rpc: coreServices.rpc,
|
|
87
96
|
config: coreServices.config,
|
|
97
|
+
secretResolver: secretResolverRef,
|
|
98
|
+
internalSecrets: internalSecretsRef,
|
|
88
99
|
},
|
|
89
|
-
init: async ({
|
|
100
|
+
init: async ({
|
|
101
|
+
logger,
|
|
102
|
+
rpc,
|
|
103
|
+
config,
|
|
104
|
+
database,
|
|
105
|
+
secretResolver,
|
|
106
|
+
internalSecrets,
|
|
107
|
+
}) => {
|
|
90
108
|
logger.debug("🔌 Initializing Integration Backend...");
|
|
91
109
|
|
|
92
110
|
const connectionStore = createConnectionStore({
|
|
93
111
|
configService: config,
|
|
94
112
|
providerRegistry,
|
|
95
113
|
logger,
|
|
114
|
+
// Unified credential resolution through the ONE secrets channel.
|
|
115
|
+
credentials: { secretResolver, internalSecrets },
|
|
96
116
|
});
|
|
97
117
|
env.registerService(connectionStoreRef, connectionStore);
|
|
118
|
+
// Stash the migration for afterPluginsReady (all providers + their
|
|
119
|
+
// connectionSchemas are registered only by then).
|
|
120
|
+
(env as unknown as EnvStash).runCredentialMigration = () =>
|
|
121
|
+
connectionStore.runCredentialMigration();
|
|
98
122
|
|
|
99
123
|
const router = createIntegrationRouter({
|
|
100
124
|
db: database,
|
|
@@ -123,6 +147,19 @@ export default createBackendPlugin({
|
|
|
123
147
|
`📡 Integration Backend init complete with ${providerRegistry.getProviders().length} providers`,
|
|
124
148
|
);
|
|
125
149
|
},
|
|
150
|
+
|
|
151
|
+
// Consolidate legacy inline connection credentials onto the secrets
|
|
152
|
+
// platform once every provider (and its connectionSchema) is
|
|
153
|
+
// registered. Idempotent + parity-verified + reversible (backup).
|
|
154
|
+
afterPluginsReady: async ({ logger }) => {
|
|
155
|
+
try {
|
|
156
|
+
await (env as unknown as EnvStash).runCredentialMigration?.();
|
|
157
|
+
} catch (error) {
|
|
158
|
+
logger.error(
|
|
159
|
+
`Connection credential migration failed: ${String(error)}`,
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
},
|
|
126
163
|
});
|
|
127
164
|
},
|
|
128
165
|
});
|
package/src/router.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
type SafeDatabase,
|
|
8
8
|
} from "@checkstack/backend-api";
|
|
9
9
|
import { extractErrorMessage } from "@checkstack/common";
|
|
10
|
+
import { maskSecrets } from "@checkstack/secrets-common";
|
|
10
11
|
import { integrationContract } from "@checkstack/integration-common";
|
|
11
12
|
|
|
12
13
|
import type { IntegrationProviderRegistry } from "./provider-registry";
|
|
@@ -80,9 +81,17 @@ export function createIntegrationRouter(deps: RouterDeps) {
|
|
|
80
81
|
const result = await provider.testConnection(config);
|
|
81
82
|
return result;
|
|
82
83
|
} catch (error) {
|
|
84
|
+
// Mask any credential the submitted config carries out of the
|
|
85
|
+
// provider error before returning (same guard as the saved-
|
|
86
|
+
// connection testConnection path below).
|
|
87
|
+
const values: string[] = [];
|
|
88
|
+
collectStringLeaves(config, values);
|
|
83
89
|
return {
|
|
84
90
|
success: false,
|
|
85
|
-
message:
|
|
91
|
+
message: maskSecrets({
|
|
92
|
+
text: extractErrorMessage(error),
|
|
93
|
+
values,
|
|
94
|
+
}),
|
|
86
95
|
};
|
|
87
96
|
}
|
|
88
97
|
},
|
|
@@ -158,11 +167,15 @@ export function createIntegrationRouter(deps: RouterDeps) {
|
|
|
158
167
|
|
|
159
168
|
logger.info(`Created connection "${name}" for provider ${providerId}`);
|
|
160
169
|
|
|
170
|
+
// Return the REDACTED preview (secret fields stripped) rather than
|
|
171
|
+
// echoing the raw submitted config back — credentials must never
|
|
172
|
+
// cross back to the browser, even on create.
|
|
173
|
+
const redacted = await connectionStore.getConnection(connection.id);
|
|
161
174
|
return {
|
|
162
175
|
id: connection.id,
|
|
163
176
|
providerId: connection.providerId,
|
|
164
177
|
name: connection.name,
|
|
165
|
-
configPreview:
|
|
178
|
+
configPreview: redacted?.configPreview ?? {},
|
|
166
179
|
createdAt: connection.createdAt,
|
|
167
180
|
updatedAt: connection.updatedAt,
|
|
168
181
|
};
|
|
@@ -177,11 +190,14 @@ export function createIntegrationRouter(deps: RouterDeps) {
|
|
|
177
190
|
updates,
|
|
178
191
|
});
|
|
179
192
|
|
|
193
|
+
// Return the REDACTED preview rather than echoing the submitted
|
|
194
|
+
// config — credentials must never cross back to the browser.
|
|
195
|
+
const redacted = await connectionStore.getConnection(connection.id);
|
|
180
196
|
return {
|
|
181
197
|
id: connection.id,
|
|
182
198
|
providerId: connection.providerId,
|
|
183
199
|
name: connection.name,
|
|
184
|
-
configPreview:
|
|
200
|
+
configPreview: redacted?.configPreview ?? {},
|
|
185
201
|
createdAt: connection.createdAt,
|
|
186
202
|
updatedAt: connection.updatedAt,
|
|
187
203
|
};
|
|
@@ -231,9 +247,19 @@ export function createIntegrationRouter(deps: RouterDeps) {
|
|
|
231
247
|
const result = await provider.testConnection(connection.config);
|
|
232
248
|
return result;
|
|
233
249
|
} catch (error) {
|
|
250
|
+
// The resolved connection config carries live credentials. A
|
|
251
|
+
// provider error may echo a token (e.g. "401 with Bearer <token>").
|
|
252
|
+
// There is no run-scoped secret registry on this path, so build a
|
|
253
|
+
// per-call mask set from the resolved config's string leaves and
|
|
254
|
+
// run the error through it before returning to the browser.
|
|
255
|
+
const values: string[] = [];
|
|
256
|
+
collectStringLeaves(connection.config, values);
|
|
234
257
|
return {
|
|
235
258
|
success: false,
|
|
236
|
-
message:
|
|
259
|
+
message: maskSecrets({
|
|
260
|
+
text: extractErrorMessage(error),
|
|
261
|
+
values,
|
|
262
|
+
}),
|
|
237
263
|
};
|
|
238
264
|
}
|
|
239
265
|
}),
|
|
@@ -298,3 +324,19 @@ export function createIntegrationRouter(deps: RouterDeps) {
|
|
|
298
324
|
}
|
|
299
325
|
|
|
300
326
|
export type IntegrationRouter = ReturnType<typeof createIntegrationRouter>;
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Collect every string leaf in a JSON-like value into `out`. Used to build
|
|
330
|
+
* a per-call secret mask set from a resolved/submitted connection config so
|
|
331
|
+
* a provider error echoing a credential can be redacted before it crosses
|
|
332
|
+
* back to the browser. Mirrors the dispatch engine's run-secret capture.
|
|
333
|
+
*/
|
|
334
|
+
function collectStringLeaves(value: unknown, out: string[]): void {
|
|
335
|
+
if (typeof value === "string") {
|
|
336
|
+
out.push(value);
|
|
337
|
+
} else if (Array.isArray(value)) {
|
|
338
|
+
for (const v of value) collectStringLeaves(v, out);
|
|
339
|
+
} else if (value !== null && typeof value === "object") {
|
|
340
|
+
for (const v of Object.values(value)) collectStringLeaves(v, out);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { call } from "@orpc/server";
|
|
3
|
+
import { createMockRpcContext, type SafeDatabase } from "@checkstack/backend-api";
|
|
4
|
+
import { createIntegrationRouter } from "./router";
|
|
5
|
+
import type { IntegrationProviderRegistry } from "./provider-registry";
|
|
6
|
+
import type { ConnectionStore } from "./connection-store";
|
|
7
|
+
import type * as schema from "./schema";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* M4 — `testConnection` resolves the connection WITH credentials and runs
|
|
11
|
+
* the provider's `testConnection`. A provider error that echoes the
|
|
12
|
+
* credential must be masked before it is returned to the (manage-access)
|
|
13
|
+
* browser. There is no run-scoped registry on this path, so the router
|
|
14
|
+
* builds a per-call mask set from the resolved config's string leaves.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const SECRET = "super-secret-token-abc123";
|
|
18
|
+
|
|
19
|
+
function fakeProviderRegistry(): IntegrationProviderRegistry {
|
|
20
|
+
return {
|
|
21
|
+
register() {},
|
|
22
|
+
getProviders: () => [],
|
|
23
|
+
hasProvider: () => true,
|
|
24
|
+
getProviderConnectionSchema: () => undefined,
|
|
25
|
+
getProvider: (qualifiedId: string) =>
|
|
26
|
+
qualifiedId === "demo.provider"
|
|
27
|
+
? {
|
|
28
|
+
id: "provider",
|
|
29
|
+
qualifiedId: "demo.provider",
|
|
30
|
+
ownerPluginId: "demo",
|
|
31
|
+
displayName: "Demo",
|
|
32
|
+
// Echoes the resolved token in its error — the leak vector.
|
|
33
|
+
testConnection: async () => {
|
|
34
|
+
throw new Error(
|
|
35
|
+
`401 Unauthorized using Bearer ${SECRET} against demo`,
|
|
36
|
+
);
|
|
37
|
+
},
|
|
38
|
+
}
|
|
39
|
+
: undefined,
|
|
40
|
+
} as unknown as IntegrationProviderRegistry;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function fakeConnectionStore(): ConnectionStore {
|
|
44
|
+
return {
|
|
45
|
+
listConnections: async () => [],
|
|
46
|
+
getConnection: async () => undefined,
|
|
47
|
+
getConnectionWithCredentials: async () => ({
|
|
48
|
+
id: "conn-1",
|
|
49
|
+
providerId: "demo.provider",
|
|
50
|
+
name: "Demo connection",
|
|
51
|
+
// Resolved credentials live here.
|
|
52
|
+
config: { apiToken: SECRET, baseUrl: "https://demo.example" },
|
|
53
|
+
createdAt: new Date(),
|
|
54
|
+
updatedAt: new Date(),
|
|
55
|
+
}),
|
|
56
|
+
createConnection: async () => {
|
|
57
|
+
throw new Error("nope");
|
|
58
|
+
},
|
|
59
|
+
updateConnection: async () => {
|
|
60
|
+
throw new Error("nope");
|
|
61
|
+
},
|
|
62
|
+
deleteConnection: async () => false,
|
|
63
|
+
findConnectionProvider: async () => "demo.provider",
|
|
64
|
+
} as unknown as ConnectionStore;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function buildRouter() {
|
|
68
|
+
const noopLogger = {
|
|
69
|
+
debug: () => {},
|
|
70
|
+
info: () => {},
|
|
71
|
+
warn: () => {},
|
|
72
|
+
error: () => {},
|
|
73
|
+
} as unknown as Parameters<typeof createIntegrationRouter>[0]["logger"];
|
|
74
|
+
return createIntegrationRouter({
|
|
75
|
+
db: {} as unknown as SafeDatabase<typeof schema>,
|
|
76
|
+
providerRegistry: fakeProviderRegistry(),
|
|
77
|
+
connectionStore: fakeConnectionStore(),
|
|
78
|
+
logger: noopLogger,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
describe("M4 — testConnection masks provider errors that echo credentials", () => {
|
|
83
|
+
it("redacts the resolved token in the returned error message", async () => {
|
|
84
|
+
const router = buildRouter();
|
|
85
|
+
const result = await call(
|
|
86
|
+
router.testConnection,
|
|
87
|
+
{ connectionId: "conn-1" },
|
|
88
|
+
{ context: createMockRpcContext() },
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
expect(result.success).toBe(false);
|
|
92
|
+
expect(result.message).not.toContain(SECRET);
|
|
93
|
+
expect(result.message).toContain("****");
|
|
94
|
+
expect(result.message).toBe(
|
|
95
|
+
"401 Unauthorized using Bearer **** against demo",
|
|
96
|
+
);
|
|
97
|
+
});
|
|
98
|
+
});
|