@checkstack/integration-backend 0.2.0 → 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,109 @@
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
+
3
107
  ## 0.2.0
4
108
 
5
109
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/integration-backend",
3
- "version": "0.2.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.5.0",
19
- "@checkstack/backend-api": "0.17.1",
20
- "@checkstack/signal-common": "0.2.4",
21
- "@checkstack/queue-api": "0.3.5",
22
- "@checkstack/common": "0.11.0",
23
- "@checkstack/command-backend": "0.1.30",
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.30",
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
+ }
@@ -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
- ): ConnectionStore {
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 setConnectionConfig(providerId, id, config);
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 setConnectionConfig(providerId, connectionId, updatedConfig);
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 ({ logger, rpc, config, database }) => {
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: extractErrorMessage(error),
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: config,
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: (updates.config ?? {}) as Record<string, unknown>,
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: extractErrorMessage(error),
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
+ });
package/tsconfig.json CHANGED
@@ -22,6 +22,12 @@
22
22
  {
23
23
  "path": "../queue-api"
24
24
  },
25
+ {
26
+ "path": "../secrets-backend"
27
+ },
28
+ {
29
+ "path": "../secrets-common"
30
+ },
25
31
  {
26
32
  "path": "../signal-common"
27
33
  },