@checkstack/secrets-backend 0.2.16 → 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,84 @@
1
1
  # @checkstack/secrets-backend
2
2
 
3
+ ## 0.3.0
4
+
5
+ ### Minor Changes
6
+
7
+ - faf98f5: Security: config secrets (health-check strategy/collector credentials such as
8
+ SSH passwords, DB credentials, HTTP auth, and integration connection
9
+ credentials) ride ONE shared, domain-agnostic extraction channel instead of
10
+ being stored as plaintext or re-implemented per plugin.
11
+
12
+ New primitive and shared service:
13
+
14
+ - `configSecret({ id })` (in `@checkstack/backend-api`) declares an
15
+ extraction-channel secret keyed by a STABLE `id`, independent of field name or
16
+ position, so renaming or reordering a field never orphans its value. Use it
17
+ (not `configString({ "x-secret": true })`) for any credential whose config is
18
+ relayed to a satellite, projected to AI, or diffed by GitOps. `validateSecretIds`
19
+ rejects, at plugin registration, an `x-secret` field with no `id`, a duplicate
20
+ `id`, or a secret nested in an un-keyable container (array / record / tuple /
21
+ map) - so a mis-keyable schema fails boot rather than at run time.
22
+ - `ConfigSecretChannel` (in `@checkstack/secrets-backend`) is the single
23
+ extract / inflate / collect / redact / merge / delete / prune implementation.
24
+ Health-checks and integration connections both BIND it to their own scope
25
+ (marker prefix + internal-secret key layout); neither re-implements the walk.
26
+
27
+ Lifecycle (both bindings):
28
+
29
+ - **Write**: an inline value is extracted into the encrypted internal secret
30
+ store; the stored config keeps only an opaque marker. `${{ secrets.NAME }}`
31
+ references are stored verbatim and resolve through the active backend (local
32
+ or Vault) at run time.
33
+ - **Read**: configuration and connection reads strip `x-secret` values and
34
+ internal markers while keeping `${{ secrets.NAME }}` references visible; the
35
+ AI `getConfigurations` tool and create/update responses are redacted too. A
36
+ value never reaches a browser or an AI model context.
37
+ - **Run**: the core executor inflates markers/references in memory just before
38
+ the client is built. Satellites receive markers only and fetch values
39
+ just-in-time over the authenticated WS channel, per run, never persisted, then
40
+ fail CLOSED if any marker/reference survives resolution.
41
+ - **No orphan**: clearing a secret, removing a field/collector, swapping an
42
+ inline value for a reference, updating a connection, or deleting a
43
+ configuration/connection deletes the now-unreferenced internal secret. Cleanup
44
+ is schema-free (scans markers by prefix) and best-effort on delete, so it works
45
+ even when the owning plugin is uninstalled and never blocks a delete.
46
+ - **Forged-marker safe**: extract/inflate key each internal secret by the
47
+ SCHEMA leaf's stable `id`, never by an id parsed out of a stored marker string,
48
+ so a crafted marker can never resolve or delete another scope's secret.
49
+
50
+ Health-checks additionally get an idempotent, advisory-locked backfill that
51
+ moves pre-existing plaintext values into the internal store, and per-config-id
52
+ locking so concurrent writers across pods can never leave a dangling marker.
53
+ Integration connection credentials keep their released `__connref__:` marker
54
+ prefix and key layout (id equals the flat field name), so existing stored
55
+ connections are byte-compatible.
56
+
57
+ BREAKING CHANGES:
58
+
59
+ - Configuration and connection reads no longer include `x-secret` field values
60
+ (clients must treat blank-on-save as keep-existing; the bundled editors
61
+ already do).
62
+ - Satellites must be upgraded together with the core: an old satellite cannot
63
+ resolve the markers a new core stores, so its credentialed checks fail until
64
+ upgraded.
65
+
66
+ ### Patch Changes
67
+
68
+ - Updated dependencies [faf98f5]
69
+ - @checkstack/backend-api@0.29.0
70
+ - @checkstack/secrets-common@0.3.0
71
+ - @checkstack/common@0.20.0
72
+ - @checkstack/command-backend@0.2.18
73
+
74
+ ## 0.2.17
75
+
76
+ ### Patch Changes
77
+
78
+ - Updated dependencies [e819276]
79
+ - @checkstack/backend-api@0.28.0
80
+ - @checkstack/command-backend@0.2.17
81
+
3
82
  ## 0.2.16
4
83
 
5
84
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/secrets-backend",
3
- "version": "0.2.16",
3
+ "version": "0.3.0",
4
4
  "description": "Secrets platform backend: resolver service, masking, backend extension point, RPC router",
5
5
  "author": "Checkstack contributors",
6
6
  "license": "Elastic-2.0",
@@ -25,22 +25,22 @@
25
25
  "test": "bun test"
26
26
  },
27
27
  "dependencies": {
28
- "@checkstack/backend-api": "0.27.1",
29
- "@checkstack/command-backend": "0.2.16",
30
- "@checkstack/common": "0.19.0",
31
- "@checkstack/secrets-common": "0.2.8",
28
+ "@checkstack/backend-api": "0.29.0",
29
+ "@checkstack/command-backend": "0.2.18",
30
+ "@checkstack/common": "0.20.0",
31
+ "@checkstack/secrets-common": "0.3.0",
32
32
  "@orpc/server": "^1.14.4",
33
33
  "drizzle-orm": "^0.45.0",
34
34
  "uuid": "^14.0.0",
35
35
  "zod": "^4.2.1"
36
36
  },
37
37
  "devDependencies": {
38
- "@checkstack/scripts": "0.7.0",
39
- "@checkstack/dev-server": "2.2.5",
40
- "@checkstack/backend": "0.23.1",
38
+ "@checkstack/scripts": "0.7.1",
39
+ "@checkstack/dev-server": "2.2.6",
40
+ "@checkstack/backend": "0.23.3",
41
41
  "@checkstack/tsconfig": "0.0.7",
42
42
  "@checkstack/drizzle-helper": "0.0.6",
43
- "@checkstack/test-utils-backend": "0.1.50",
43
+ "@checkstack/test-utils-backend": "0.1.52",
44
44
  "@types/bun": "^1.3.5",
45
45
  "@types/node": "^20.0.0",
46
46
  "drizzle-kit": "^0.31.10",
@@ -0,0 +1,483 @@
1
+ import { z } from "zod";
2
+ import { isSecretSchema } from "@checkstack/backend-api";
3
+ import { isSecretClearSentinel } from "@checkstack/common";
4
+ import {
5
+ isSecretReference,
6
+ configSecretMarker,
7
+ isConfigSecretMarker,
8
+ readConfigSecretMarkerId,
9
+ } from "@checkstack/secrets-common";
10
+ import { walkSecretFields } from "./walk-secret-fields";
11
+ import type { InternalSecretsService } from "./internal-secrets-service";
12
+ import type { SecretResolverService } from "./resolver-service";
13
+
14
+ /**
15
+ * The ONE config-secret extraction channel, shared by every plugin that accepts
16
+ * INLINE secrets in a schema-described config (health-check strategy/collector
17
+ * configs, integration connection configs).
18
+ *
19
+ * A field marked with {@link configSecret} holds exactly one of:
20
+ * - an INLINE operator-typed value → extracted into an internal secret on write,
21
+ * leaving an opaque MARKER (`${markerPrefix}${secretId}`) in the stored config;
22
+ * - a `${{ secrets.NAME }}` REFERENCE → kept verbatim, resolved through the
23
+ * active backend at run time;
24
+ * - a legacy bare literal (pre-extraction rows) → passed through.
25
+ *
26
+ * The internal secret is keyed by the field's STABLE `x-secret-id`, never by its
27
+ * name or position - so renaming/moving a field never strands its secret, and a
28
+ * forged marker (one whose embedded id points elsewhere) can only ever resolve
29
+ * the field's OWN slot (extract/inflate key by the schema's id at that leaf).
30
+ *
31
+ * Do NOT confuse this with ConfigService's `configString({ "x-secret": true })`,
32
+ * which encrypts a secret in place for singleton/admin config. See the
33
+ * "secret handling" architecture doc for which mechanism to use.
34
+ */
35
+ export interface ConfigSecretChannel {
36
+ /**
37
+ * Marker prefix for this channel. Stored markers are
38
+ * `${markerPrefix}${secretId}`. Choose a channel-unique prefix and NEVER
39
+ * change a released one (existing stored markers would stop resolving).
40
+ */
41
+ markerPrefix: string;
42
+ /**
43
+ * Map a field's stable secret id to the internal-secret name parts. The
44
+ * caller binds the surrounding scope (e.g. config id, provider/connection,
45
+ * collector entry) so the returned parts are globally unique.
46
+ */
47
+ keyParts: (secretId: string) => string[];
48
+ }
49
+
50
+ /** Thrown when an extraction-channel secret field has no `x-secret-id`. */
51
+ export class MissingSecretIdError extends Error {
52
+ constructor(path: string) {
53
+ super(
54
+ `Config-secret field "${path}" has no x-secret-id. Declare it with ` +
55
+ `configSecret({ id }) instead of configString({ "x-secret": true }).`,
56
+ );
57
+ this.name = "MissingSecretIdError";
58
+ }
59
+ }
60
+
61
+ // ============================================================================
62
+ // EXTRACT (write path)
63
+ // ============================================================================
64
+
65
+ /**
66
+ * Extract inline secrets from ONE config object into internal secrets, replacing
67
+ * each with a marker. References and this field's OWN marker pass through
68
+ * (idempotent). Returns the rewritten config and how many values moved.
69
+ */
70
+ export async function extractScopeSecrets({
71
+ channel,
72
+ schema,
73
+ config,
74
+ internalSecrets,
75
+ }: {
76
+ channel: ConfigSecretChannel;
77
+ schema: z.ZodTypeAny;
78
+ config: Record<string, unknown>;
79
+ internalSecrets: InternalSecretsService;
80
+ }): Promise<{ config: Record<string, unknown>; extracted: number }> {
81
+ let extracted = 0;
82
+ const rewritten = await walkSecretFields({
83
+ value: config,
84
+ schema,
85
+ visit: async ({ path, secretId, value }) => {
86
+ if (secretId === undefined) throw new MissingSecretIdError(path);
87
+ const ownMarker = configSecretMarker(channel.markerPrefix, secretId);
88
+ // Pass through ONLY this field's own marker (a merge/backfill round-trip)
89
+ // or a `${{ secrets.* }}` reference. A marker-shaped value whose embedded
90
+ // id is NOT this field's id is a FORGED marker; extract it into THIS
91
+ // field's slot so it can never resolve another field's secret.
92
+ if (value === ownMarker || isSecretReference(value)) return value;
93
+ if (value.length === 0) return value;
94
+ await internalSecrets.set({ parts: channel.keyParts(secretId), value });
95
+ extracted++;
96
+ return ownMarker;
97
+ },
98
+ });
99
+ return { config: rewritten as Record<string, unknown>, extracted };
100
+ }
101
+
102
+ // ============================================================================
103
+ // INFLATE (run path)
104
+ // ============================================================================
105
+
106
+ /**
107
+ * Inflate ONE stored config's secret fields to real values: a marker resolves
108
+ * from the internal store (keyed by the SCHEMA's id at that leaf, never the
109
+ * marker's embedded id), a reference through the active backend, a bare literal
110
+ * passes through. Returns the inflated config plus every resolved value (for
111
+ * output masking). Fail-closed: a marker whose internal secret is missing throws.
112
+ */
113
+ export async function inflateScopeSecrets({
114
+ channel,
115
+ schema,
116
+ config,
117
+ internalSecrets,
118
+ secretResolver,
119
+ }: {
120
+ channel: ConfigSecretChannel;
121
+ schema: z.ZodTypeAny;
122
+ config: Record<string, unknown>;
123
+ internalSecrets: InternalSecretsService;
124
+ secretResolver: Pick<SecretResolverService, "resolveForRun">;
125
+ }): Promise<{ config: Record<string, unknown>; values: string[] }> {
126
+ const values: string[] = [];
127
+ const inflated = await walkSecretFields({
128
+ value: config,
129
+ schema,
130
+ visit: async ({ path, secretId, value }) => {
131
+ let resolved = value;
132
+ if (isConfigSecretMarker(channel.markerPrefix, value)) {
133
+ if (secretId === undefined) throw new MissingSecretIdError(path);
134
+ const got = await internalSecrets.get({ parts: channel.keyParts(secretId) });
135
+ if (got === undefined) {
136
+ throw new Error(`Internal secret for "${secretId}" not found.`);
137
+ }
138
+ resolved = got;
139
+ } else if (isSecretReference(value)) {
140
+ const { env } = await secretResolver.resolveForRun({
141
+ secretEnv: { CRED: value },
142
+ });
143
+ resolved = env.CRED;
144
+ }
145
+ if (resolved.length > 0) values.push(resolved);
146
+ return resolved;
147
+ },
148
+ });
149
+ return { config: inflated as Record<string, unknown>, values };
150
+ }
151
+
152
+ /**
153
+ * Resolve a config's secret fields to a `path -> value` map WITHOUT rewriting
154
+ * the config - the shape a just-in-time channel (e.g. the satellite reply) ships
155
+ * so the remote side can apply values by field position. Only markers and
156
+ * references produce entries. Fail-closed like inflation.
157
+ */
158
+ export async function collectScopeSecretValues({
159
+ channel,
160
+ schema,
161
+ config,
162
+ internalSecrets,
163
+ secretResolver,
164
+ }: {
165
+ channel: ConfigSecretChannel;
166
+ schema: z.ZodTypeAny;
167
+ config: Record<string, unknown>;
168
+ internalSecrets: InternalSecretsService;
169
+ secretResolver: Pick<SecretResolverService, "resolveForRun">;
170
+ }): Promise<Record<string, string>> {
171
+ const values: Record<string, string> = {};
172
+ await walkSecretFields({
173
+ value: config,
174
+ schema,
175
+ visit: async ({ path, secretId, value }) => {
176
+ if (isConfigSecretMarker(channel.markerPrefix, value)) {
177
+ if (secretId === undefined) throw new MissingSecretIdError(path);
178
+ const got = await internalSecrets.get({ parts: channel.keyParts(secretId) });
179
+ if (got === undefined) {
180
+ throw new Error(`Internal secret for "${secretId}" not found.`);
181
+ }
182
+ values[path] = got;
183
+ } else if (isSecretReference(value)) {
184
+ const { env } = await secretResolver.resolveForRun({
185
+ secretEnv: { CRED: value },
186
+ });
187
+ values[path] = env.CRED;
188
+ }
189
+ return value;
190
+ },
191
+ });
192
+ return values;
193
+ }
194
+
195
+ // ============================================================================
196
+ // REDACT / MERGE / SIGNAL (schema-only, channel-agnostic)
197
+ // ============================================================================
198
+
199
+ function unwrapZod(schema: z.ZodTypeAny): z.ZodTypeAny {
200
+ let current = schema;
201
+ for (;;) {
202
+ if (current instanceof z.ZodOptional || current instanceof z.ZodNullable) {
203
+ current = current.unwrap() as z.ZodTypeAny;
204
+ continue;
205
+ }
206
+ if (current instanceof z.ZodDefault) {
207
+ current = current.def.innerType as z.ZodTypeAny;
208
+ continue;
209
+ }
210
+ return current;
211
+ }
212
+ }
213
+
214
+ /**
215
+ * Resolve the concrete union branch a value inhabits, mirroring
216
+ * `walkSecretFields`: a discriminated union picks by discriminator literal, a
217
+ * plain union by the first option that parses. Undefined for a non-union schema.
218
+ */
219
+ function matchUnionOption(
220
+ schema: z.ZodTypeAny,
221
+ value: unknown,
222
+ ): z.ZodTypeAny | undefined {
223
+ if (typeof value !== "object" || value === null) return undefined;
224
+ if (schema instanceof z.ZodDiscriminatedUnion) {
225
+ const discriminator = (schema.def as { discriminator: string }).discriminator;
226
+ const discriminatorValue = (value as Record<string, unknown>)[discriminator];
227
+ const options = schema.options as z.ZodObject<z.ZodRawShape>[];
228
+ return options.find((option) => {
229
+ const discField = option.shape[discriminator];
230
+ return discField instanceof z.ZodLiteral
231
+ ? discField.value === discriminatorValue
232
+ : false;
233
+ });
234
+ }
235
+ if (schema instanceof z.ZodUnion) {
236
+ const options = schema.options as z.ZodTypeAny[];
237
+ return options.find((option) => option.safeParse(value).success);
238
+ }
239
+ return undefined;
240
+ }
241
+
242
+ /**
243
+ * Remove every `x-secret` field from a config (recursing objects, arrays, and
244
+ * unions), KEEPING a `${{ secrets.NAME }}` reference verbatim (a pointer, not a
245
+ * value). The redacted shape is what UI/AI reads receive; the editor treats a
246
+ * blank secret as "keep existing". Neither a value nor an internal marker leaks.
247
+ */
248
+ export function redactSecretFields({
249
+ schema,
250
+ config,
251
+ }: {
252
+ schema: z.ZodTypeAny;
253
+ config: Record<string, unknown>;
254
+ }): Record<string, unknown> {
255
+ const walk = (nodeSchema: z.ZodTypeAny, value: unknown): unknown => {
256
+ if (value === null || value === undefined) return value;
257
+ const unwrapped = unwrapZod(nodeSchema);
258
+
259
+ if (
260
+ unwrapped instanceof z.ZodObject &&
261
+ typeof value === "object" &&
262
+ !Array.isArray(value)
263
+ ) {
264
+ const shape = unwrapped.shape as Record<string, z.ZodTypeAny>;
265
+ const result: Record<string, unknown> = {
266
+ ...(value as Record<string, unknown>),
267
+ };
268
+ for (const [key, fieldSchema] of Object.entries(shape)) {
269
+ if (!(key in result)) continue;
270
+ if (isSecretSchema(fieldSchema)) {
271
+ const fieldValue = result[key];
272
+ const isReference =
273
+ typeof fieldValue === "string" && isSecretReference(fieldValue);
274
+ if (!isReference) delete result[key];
275
+ continue;
276
+ }
277
+ result[key] = walk(fieldSchema, result[key]);
278
+ }
279
+ return result;
280
+ }
281
+
282
+ if (unwrapped instanceof z.ZodArray && Array.isArray(value)) {
283
+ const elementSchema = unwrapped.element as z.ZodTypeAny;
284
+ return value.map((item) => walk(elementSchema, item));
285
+ }
286
+
287
+ const option = matchUnionOption(unwrapped, value);
288
+ if (option) return walk(option, value);
289
+
290
+ return value;
291
+ };
292
+
293
+ return walk(schema, config) as Record<string, unknown>;
294
+ }
295
+
296
+ /**
297
+ * Restore stored secret values into an incoming (edited) config. A blank/absent
298
+ * secret means "keep existing"; a non-empty value wins; the CLEAR sentinel drops
299
+ * the field (positively remove). Runs before validation and extraction. Descends
300
+ * objects, index-paired arrays, and unions.
301
+ */
302
+ export function mergeSecretFields({
303
+ schema,
304
+ incoming,
305
+ stored,
306
+ }: {
307
+ schema: z.ZodTypeAny;
308
+ incoming: Record<string, unknown>;
309
+ stored: Record<string, unknown> | undefined;
310
+ }): Record<string, unknown> {
311
+ const walk = (
312
+ nodeSchema: z.ZodTypeAny,
313
+ incomingValue: unknown,
314
+ storedValue: unknown,
315
+ ): unknown => {
316
+ const unwrapped = unwrapZod(nodeSchema);
317
+
318
+ if (isSecretSchema(nodeSchema)) {
319
+ if (isSecretClearSentinel(incomingValue)) return undefined;
320
+ const incomingIsSet =
321
+ typeof incomingValue === "string" && incomingValue.trim() !== "";
322
+ if (incomingIsSet) return incomingValue;
323
+ const storedIsSet =
324
+ typeof storedValue === "string" && storedValue.trim() !== "";
325
+ return storedIsSet ? storedValue : incomingValue;
326
+ }
327
+
328
+ if (
329
+ unwrapped instanceof z.ZodObject &&
330
+ typeof incomingValue === "object" &&
331
+ incomingValue !== null &&
332
+ !Array.isArray(incomingValue)
333
+ ) {
334
+ const storedRecord =
335
+ typeof storedValue === "object" &&
336
+ storedValue !== null &&
337
+ !Array.isArray(storedValue)
338
+ ? (storedValue as Record<string, unknown>)
339
+ : undefined;
340
+ const shape = unwrapped.shape as Record<string, z.ZodTypeAny>;
341
+ const result: Record<string, unknown> = {
342
+ ...(incomingValue as Record<string, unknown>),
343
+ };
344
+ for (const [key, fieldSchema] of Object.entries(shape)) {
345
+ const merged = walk(fieldSchema, result[key], storedRecord?.[key]);
346
+ if (merged === undefined && !(key in result)) continue;
347
+ result[key] = merged;
348
+ }
349
+ return result;
350
+ }
351
+
352
+ if (unwrapped instanceof z.ZodArray && Array.isArray(incomingValue)) {
353
+ const storedArray = Array.isArray(storedValue) ? storedValue : [];
354
+ const elementSchema = unwrapped.element as z.ZodTypeAny;
355
+ return incomingValue.map((item, index) =>
356
+ walk(elementSchema, item, storedArray[index]),
357
+ );
358
+ }
359
+
360
+ const option = matchUnionOption(unwrapped, incomingValue);
361
+ if (option) return walk(option, incomingValue, storedValue);
362
+
363
+ return incomingValue;
364
+ };
365
+
366
+ return walk(schema, incoming, stored) as Record<string, unknown>;
367
+ }
368
+
369
+ /**
370
+ * The TOP-LEVEL `x-secret` field keys of a config that actually hold a stored
371
+ * value (marker, reference, or legacy literal - any non-empty string). Lets a
372
+ * UI tell a stored secret from a never-set optional one. Keys only, never values.
373
+ */
374
+ export function listPopulatedSecretKeys({
375
+ schema,
376
+ config,
377
+ }: {
378
+ schema: z.ZodTypeAny;
379
+ config: Record<string, unknown>;
380
+ }): string[] {
381
+ const obj = unwrapZod(schema);
382
+ if (!(obj instanceof z.ZodObject)) return [];
383
+ const shape = obj.shape as Record<string, z.ZodTypeAny>;
384
+ return Object.entries(shape)
385
+ .filter(([key, fieldSchema]) => {
386
+ if (!isSecretSchema(fieldSchema)) return false;
387
+ const value = config[key];
388
+ return typeof value === "string" && value.length > 0;
389
+ })
390
+ .map(([key]) => key);
391
+ }
392
+
393
+ // ============================================================================
394
+ // CLEANUP (schema-free: delete + orphan prune)
395
+ // ============================================================================
396
+
397
+ /**
398
+ * Scan ONE config for markers WITHOUT a schema, keying each by the STABLE id
399
+ * embedded in the marker (via the channel). Schema-free is load-bearing for
400
+ * cleanup: an unregistered plugin has no schema to walk, but its stored markers
401
+ * still index live internal secrets that must be deleted, never orphaned.
402
+ */
403
+ function scanScopeMarkers({
404
+ channel,
405
+ value,
406
+ }: {
407
+ channel: ConfigSecretChannel;
408
+ value: unknown;
409
+ }): string[][] {
410
+ const parts: string[][] = [];
411
+ const walk = (node: unknown): void => {
412
+ if (typeof node === "string") {
413
+ if (isConfigSecretMarker(channel.markerPrefix, node)) {
414
+ parts.push(
415
+ channel.keyParts(readConfigSecretMarkerId(channel.markerPrefix, node)),
416
+ );
417
+ }
418
+ return;
419
+ }
420
+ if (Array.isArray(node)) {
421
+ for (const item of node) walk(item);
422
+ return;
423
+ }
424
+ if (node && typeof node === "object") {
425
+ for (const child of Object.values(node)) walk(child);
426
+ }
427
+ };
428
+ walk(value);
429
+ return parts;
430
+ }
431
+
432
+ const partsKey = (parts: string[]): string => JSON.stringify(parts);
433
+
434
+ /**
435
+ * Delete every internal secret ONE config's markers point at. Schema-free, so it
436
+ * cleans up even when the owning plugin is uninstalled. Idempotent.
437
+ */
438
+ export async function deleteScopeSecrets({
439
+ channel,
440
+ config,
441
+ internalSecrets,
442
+ }: {
443
+ channel: ConfigSecretChannel;
444
+ config: Record<string, unknown>;
445
+ internalSecrets: InternalSecretsService;
446
+ }): Promise<void> {
447
+ for (const parts of scanScopeMarkers({ channel, value: config })) {
448
+ await internalSecrets.delete({ parts });
449
+ }
450
+ }
451
+
452
+ /**
453
+ * Delete internal secrets ORPHANED by an update to ONE config scope: an old
454
+ * marker whose exact internal-secret coordinates are absent from the new config.
455
+ * Compares by exact `parts` (injective in the secret id), so two fields whose
456
+ * ids are in a prefix relationship never collide. Schema-free on both sides, so
457
+ * a marker preserved under an uninstalled plugin is correctly kept. Returns the
458
+ * number deleted.
459
+ */
460
+ export async function pruneScopeSecrets({
461
+ channel,
462
+ oldConfig,
463
+ newConfig,
464
+ internalSecrets,
465
+ }: {
466
+ channel: ConfigSecretChannel;
467
+ oldConfig: Record<string, unknown>;
468
+ newConfig: Record<string, unknown>;
469
+ internalSecrets: InternalSecretsService;
470
+ }): Promise<number> {
471
+ const newKeys = new Set(
472
+ scanScopeMarkers({ channel, value: newConfig }).map((parts) =>
473
+ partsKey(parts),
474
+ ),
475
+ );
476
+ let deleted = 0;
477
+ for (const parts of scanScopeMarkers({ channel, value: oldConfig })) {
478
+ if (newKeys.has(partsKey(parts))) continue;
479
+ await internalSecrets.delete({ parts });
480
+ deleted++;
481
+ }
482
+ return deleted;
483
+ }
package/src/index.ts CHANGED
@@ -235,7 +235,22 @@ export {
235
235
  type SecretStore,
236
236
  type SecretResolutionResult,
237
237
  } from "./secret-resolver";
238
- export { walkSecretFields } from "./walk-secret-fields";
238
+ export {
239
+ walkSecretFields,
240
+ type SecretFieldVisit,
241
+ } from "./walk-secret-fields";
242
+ export {
243
+ extractScopeSecrets,
244
+ inflateScopeSecrets,
245
+ collectScopeSecretValues,
246
+ redactSecretFields,
247
+ mergeSecretFields,
248
+ listPopulatedSecretKeys,
249
+ deleteScopeSecrets,
250
+ pruneScopeSecrets,
251
+ MissingSecretIdError,
252
+ type ConfigSecretChannel,
253
+ } from "./config-secret-channel";
239
254
  export {
240
255
  createSecretResolverService,
241
256
  type SecretResolverService,
@@ -1,5 +1,19 @@
1
1
  import { z } from "zod";
2
- import { isSecretSchema } from "@checkstack/backend-api";
2
+ import { isSecretSchema, getSecretId } from "@checkstack/backend-api";
3
+
4
+ /** The information the walk hands a visitor for each `x-secret` string leaf. */
5
+ export interface SecretFieldVisit {
6
+ /** Dot/bracket walk path, for diagnostics (`"auth.password"`, `"hosts[0]"`). */
7
+ path: string;
8
+ /**
9
+ * The field's stable extraction id (`x-secret-id`), or `undefined` for a
10
+ * plain `configString({ "x-secret": true })`. Extraction-channel callers key
11
+ * the internal secret by this - never by `path`.
12
+ */
13
+ secretId: string | undefined;
14
+ /** The current string value at this leaf. */
15
+ value: string;
16
+ }
3
17
 
4
18
  /**
5
19
  * Generic schema-driven walk over `x-secret`-annotated string fields.
@@ -20,7 +34,7 @@ import { isSecretSchema } from "@checkstack/backend-api";
20
34
  export async function walkSecretFields(params: {
21
35
  value: unknown;
22
36
  schema: z.ZodTypeAny;
23
- visit: (input: { path: string; value: string }) => Promise<string>;
37
+ visit: (input: SecretFieldVisit) => Promise<string>;
24
38
  }): Promise<unknown> {
25
39
  return walk({
26
40
  value: params.value,
@@ -33,7 +47,7 @@ export async function walkSecretFields(params: {
33
47
  async function walk(params: {
34
48
  value: unknown;
35
49
  schema: z.ZodTypeAny;
36
- visit: (input: { path: string; value: string }) => Promise<string>;
50
+ visit: (input: SecretFieldVisit) => Promise<string>;
37
51
  path: string;
38
52
  }): Promise<unknown> {
39
53
  const { value, visit, path } = params;
@@ -46,7 +60,11 @@ async function walk(params: {
46
60
  // x-secret string leaf: hand it to the visitor.
47
61
  if (isSecretSchema(schema)) {
48
62
  if (typeof value === "string") {
49
- return visit({ path: path || "(root)", value });
63
+ return visit({
64
+ path: path || "(root)",
65
+ secretId: getSecretId(schema),
66
+ value,
67
+ });
50
68
  }
51
69
  return value;
52
70
  }
@@ -127,14 +145,21 @@ async function walk(params: {
127
145
 
128
146
  function unwrapZod(schema: z.ZodTypeAny): z.ZodTypeAny {
129
147
  let unwrapped = schema;
130
- if (unwrapped instanceof z.ZodOptional) {
131
- unwrapped = unwrapped.unwrap() as z.ZodTypeAny;
132
- }
133
- if (unwrapped instanceof z.ZodDefault) {
134
- unwrapped = unwrapped.def.innerType as z.ZodTypeAny;
135
- }
136
- if (unwrapped instanceof z.ZodNullable) {
137
- unwrapped = unwrapped.unwrap() as z.ZodTypeAny;
148
+ // Loop so multi-level wrappers (e.g. `.optional().default({})`,
149
+ // `.default().nullable()`) are fully peeled. A single fixed-order pass leaves
150
+ // an inner wrapper on some combos, which would make a secret-bearing CONTAINER
151
+ // (object/array/union) miss its `instanceof` check and skip extraction -
152
+ // leaving the inline secret plaintext at rest. Mirrors the looping unwrap in
153
+ // zod-config / config-secret-channel so all walkers agree on field identity.
154
+ for (;;) {
155
+ if (unwrapped instanceof z.ZodOptional || unwrapped instanceof z.ZodNullable) {
156
+ unwrapped = unwrapped.unwrap() as z.ZodTypeAny;
157
+ continue;
158
+ }
159
+ if (unwrapped instanceof z.ZodDefault) {
160
+ unwrapped = unwrapped.def.innerType as z.ZodTypeAny;
161
+ continue;
162
+ }
163
+ return unwrapped;
138
164
  }
139
- return unwrapped;
140
165
  }