@checkstack/integration-backend 0.1.30 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +188 -0
- package/package.json +11 -9
- package/src/connection-credentials-migration.test.ts +171 -0
- package/src/connection-credentials-migration.ts +182 -0
- package/src/connection-credentials.test.ts +180 -0
- package/src/connection-credentials.ts +165 -0
- package/src/connection-store.ts +119 -6
- package/src/index.ts +55 -181
- package/src/provider-registry.test.ts +56 -286
- package/src/provider-registry.ts +5 -20
- package/src/provider-types.ts +23 -149
- package/src/router.ts +87 -608
- package/src/schema.ts +13 -59
- package/src/test-connection-masking.test.ts +98 -0
- package/tsconfig.json +6 -0
- package/src/delivery-coordinator.ts +0 -391
- package/src/event-registry.test.ts +0 -396
- package/src/event-registry.ts +0 -99
- package/src/hook-subscriber.ts +0 -105
|
@@ -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
|
}
|