@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.
@@ -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
  }