@terreno/api 0.19.0 → 0.20.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.
@@ -93,16 +93,15 @@ const ScalarConfig = (mongoose.models.ScalarConfig ||
93
93
 
94
94
  const buildApp = (
95
95
  configModel: mongoose.Model<any>,
96
- options?: {basePath?: string; fieldOverrides?: Record<string, {widget?: string}>}
96
+ options?: Partial<Omit<ConstructorParameters<typeof ConfigurationApp>[0], "model">>
97
97
  ): express.Application => {
98
98
  const app = getBaseServer();
99
99
  setupAuth(app, UserModel as any);
100
100
  addAuthRoutes(app, UserModel as any);
101
101
 
102
102
  const configApp = new ConfigurationApp({
103
- basePath: options?.basePath,
104
- fieldOverrides: options?.fieldOverrides,
105
103
  model: configModel,
104
+ ...options,
106
105
  });
107
106
  configApp.register(app);
108
107
 
@@ -168,6 +167,31 @@ describe("configurationPlugin", () => {
168
167
  expect(updated.general.appName).toBe("Changed");
169
168
  expect(updated.general.maintenanceMode).toBe(true);
170
169
  });
170
+
171
+ it("preserves sibling subdoc fields on a partial nested patch", async () => {
172
+ await TestConfig.updateConfig({general: {appName: "Initial", maintenanceMode: true}});
173
+ // Patch only one field within the nested subdoc.
174
+ const updated = await TestConfig.updateConfig({general: {appName: "Renamed"}});
175
+ expect(updated.general.appName).toBe("Renamed");
176
+ // Sibling must be preserved (not clobbered back to the default).
177
+ expect(updated.general.maintenanceMode).toBe(true);
178
+ });
179
+
180
+ it("tolerates legacy/out-of-schema fields already persisted", async () => {
181
+ // Insert a document containing a field that is not in the (strict: throw) schema.
182
+ await TestConfig.getConfig();
183
+ await mongoose.connection.db
184
+ ?.collection("testconfigs")
185
+ .updateOne({}, {$set: {legacyField: "stale"}});
186
+
187
+ // A full doc.save() would throw under strict: "throw"; $set must not.
188
+ const updated = await TestConfig.updateConfig({general: {appName: "Survives"}});
189
+ expect(updated.general.appName).toBe("Survives");
190
+
191
+ // The legacy field is left untouched on the document.
192
+ const raw = await mongoose.connection.db?.collection("testconfigs").findOne({});
193
+ expect(raw?.legacyField).toBe("stale");
194
+ });
171
195
  });
172
196
 
173
197
  describe("singleton enforcement", () => {
@@ -327,12 +351,18 @@ describe("ConfigurationApp routes", () => {
327
351
  expect(res.body.data.general.appName).toBe("New Name");
328
352
  });
329
353
 
330
- it("redacts secrets in the response", async () => {
354
+ it("never persists secret field values supplied in the body", async () => {
331
355
  const res = await adminAgent
332
356
  .patch("/configuration")
333
- .send({integrations: {apiKey: "new-secret"}})
357
+ .send({integrations: {apiKey: "new-secret", webhookUrl: "https://changed.example.com"}})
334
358
  .expect(200);
335
- expect(res.body.data.integrations.apiKey).toBe("********");
359
+ // Secret stays empty (stripped); non-secret sibling is updated.
360
+ expect(res.body.data.integrations.apiKey).toBe("");
361
+ expect(res.body.data.integrations.webhookUrl).toBe("https://changed.example.com");
362
+
363
+ // Confirm the raw stored document holds no secret value.
364
+ const stored = await (TestConfig as any).getConfig();
365
+ expect(stored.integrations.apiKey).toBe("");
336
366
  });
337
367
 
338
368
  it("returns 403 for non-admin", async () => {
@@ -344,11 +374,29 @@ describe("ConfigurationApp routes", () => {
344
374
  });
345
375
 
346
376
  describe("POST /configuration/list-secrets", () => {
347
- it("returns discovered secret fields", async () => {
377
+ it("returns discovered secret fields with resolvable status", async () => {
348
378
  const res = await adminAgent.post("/configuration/list-secrets").expect(200);
349
379
  expect(res.body.secretFields).toHaveLength(1);
350
380
  expect(res.body.secretFields[0].path).toBe("integrations.apiKey");
351
381
  expect(res.body.secretFields[0].secretName).toBe("external-api-key");
382
+ // No provider configured -> not resolvable, and no value is ever returned.
383
+ expect(res.body.secretFields[0].resolvable).toBe(false);
384
+ expect(res.body.secretFields[0].isConfigured).toBe(false);
385
+ expect(JSON.stringify(res.body)).not.toContain("super-secret");
386
+ });
387
+
388
+ it("does not mutate the stored config document's secret fields", async () => {
389
+ await (TestConfig as any).getConfig();
390
+ await adminAgent.post("/configuration/list-secrets").expect(200);
391
+ const stored = await (TestConfig as any).getConfig();
392
+ // list-secrets must never write resolved values into the document.
393
+ expect(stored.integrations.apiKey).toBe("");
394
+ });
395
+
396
+ it("is also exposed as validate-secrets", async () => {
397
+ const res = await adminAgent.post("/configuration/validate-secrets").expect(200);
398
+ expect(res.body.secretFields).toHaveLength(1);
399
+ expect(res.body.secretFields[0].path).toBe("integrations.apiKey");
352
400
  });
353
401
 
354
402
  it("returns 403 for non-admin", async () => {
@@ -397,3 +445,119 @@ describe("ConfigurationApp with field overrides", () => {
397
445
  expect(intSection.fields.webhookUrl.widget).toBe("url");
398
446
  });
399
447
  });
448
+
449
+ describe("ConfigurationApp with custom permissions", () => {
450
+ let app: express.Application;
451
+ let adminAgent: TestAgent;
452
+ let notAdminAgent: TestAgent;
453
+
454
+ // Terreno-style permission: any authenticated user (not just admins).
455
+ const isAuthenticated = (_method: any, user?: any): boolean => Boolean(user?.id);
456
+
457
+ beforeEach(async () => {
458
+ await setupDb();
459
+ await mongoose.connection.db?.collection("testconfigs").deleteMany({});
460
+ app = buildApp(TestConfig, {
461
+ permissions: {
462
+ read: [isAuthenticated],
463
+ // update intentionally left default (admin-only)
464
+ },
465
+ });
466
+ adminAgent = await authAsUser(app, "admin");
467
+ notAdminAgent = await authAsUser(app, "notAdmin");
468
+ });
469
+
470
+ it("allows non-admin reads when a custom read permission is supplied", async () => {
471
+ const res = await notAdminAgent.get("/configuration").expect(200);
472
+ expect(res.body.data.general.appName).toBe("Test App");
473
+ });
474
+
475
+ it("still rejects unauthenticated reads", async () => {
476
+ await supertest(app).get("/configuration").expect(401);
477
+ });
478
+
479
+ it("keeps update admin-only by default", async () => {
480
+ await notAdminAgent
481
+ .patch("/configuration")
482
+ .send({general: {appName: "Nope"}})
483
+ .expect(403);
484
+ await adminAgent
485
+ .patch("/configuration")
486
+ .send({general: {appName: "Yes"}})
487
+ .expect(200);
488
+ });
489
+ });
490
+
491
+ describe("ConfigurationApp with update hooks", () => {
492
+ let app: express.Application;
493
+ let adminAgent: TestAgent;
494
+ let preUpdateCalls: Array<Record<string, unknown>>;
495
+ let postUpdateCalls: Array<{config: Record<string, unknown>; prev: Record<string, unknown>}>;
496
+
497
+ beforeEach(async () => {
498
+ await setupDb();
499
+ await mongoose.connection.db?.collection("testconfigs").deleteMany({});
500
+ preUpdateCalls = [];
501
+ postUpdateCalls = [];
502
+ app = buildApp(TestConfig, {
503
+ postUpdate: async (config, prevValue) => {
504
+ postUpdateCalls.push({config, prev: prevValue});
505
+ },
506
+ preUpdate: async (body) => {
507
+ preUpdateCalls.push(body);
508
+ // Normalize: force maintenanceMode on.
509
+ const general = (body.general as Record<string, unknown>) ?? {};
510
+ return {...body, general: {...general, maintenanceMode: true}};
511
+ },
512
+ });
513
+ adminAgent = await authAsUser(app, "admin");
514
+ });
515
+
516
+ it("runs preUpdate to transform the body before applying", async () => {
517
+ const res = await adminAgent
518
+ .patch("/configuration")
519
+ .send({general: {appName: "Hooked"}})
520
+ .expect(200);
521
+ expect(res.body.data.general.appName).toBe("Hooked");
522
+ expect(res.body.data.general.maintenanceMode).toBe(true);
523
+ expect(preUpdateCalls).toHaveLength(1);
524
+ });
525
+
526
+ it("does not persist secret values even if preUpdate re-introduces them", async () => {
527
+ // Build an app whose preUpdate maliciously/accidentally re-adds a secret path.
528
+ const leakyApp = buildApp(TestConfig, {
529
+ preUpdate: (body) => ({
530
+ ...body,
531
+ integrations: {...(body.integrations as Record<string, unknown>), apiKey: "leaked-secret"},
532
+ }),
533
+ });
534
+ const agent = await authAsUser(leakyApp, "admin");
535
+
536
+ const res = await agent
537
+ .patch("/configuration")
538
+ .send({general: {appName: "Safe"}})
539
+ .expect(200);
540
+ expect(res.body.data.general.appName).toBe("Safe");
541
+ expect(res.body.data.integrations.apiKey).toBe("");
542
+
543
+ const stored = await (TestConfig as any).getConfig();
544
+ expect(stored.integrations.apiKey).toBe("");
545
+ });
546
+
547
+ it("runs postUpdate with redacted config and previous value", async () => {
548
+ // Seed a secret so we can confirm it is redacted in hook payloads.
549
+ await (TestConfig as any).updateConfig({integrations: {apiKey: "should-not-leak"}});
550
+ await adminAgent
551
+ .patch("/configuration")
552
+ .send({general: {appName: "Audited"}})
553
+ .expect(200);
554
+
555
+ expect(postUpdateCalls).toHaveLength(1);
556
+ const {config, prev} = postUpdateCalls[0];
557
+ expect((config.general as any).appName).toBe("Audited");
558
+ // Secret values must be redacted in both payloads.
559
+ expect((config.integrations as any).apiKey).toBe("********");
560
+ expect((prev.integrations as any).apiKey).toBe("********");
561
+ expect(JSON.stringify(postUpdateCalls)).not.toContain("should-not-leak");
562
+ });
563
+ });
@@ -1,16 +1,18 @@
1
1
  import type express from "express";
2
2
  import type {Model, Schema} from "mongoose";
3
3
 
4
- import {asyncHandler} from "./api";
4
+ import {asyncHandler, type RESTMethod} from "./api";
5
5
  import {authenticateMiddleware} from "./auth";
6
6
  import type {SecretFieldMeta} from "./configurationPlugin";
7
7
  import {APIError} from "./errors";
8
8
  import {logger} from "./logger";
9
+ import {checkPermissions, type PermissionMethod} from "./permissions";
9
10
  import {getOpenApiSpecForModel} from "./populate";
10
11
  import type {TerrenoPlugin} from "./terrenoPlugin";
11
12
 
12
13
  /**
13
- * Middleware that requires the user to be an admin.
14
+ * Middleware that requires the user to be an admin. Used as the default guard
15
+ * for every configuration route when no custom `permissions` are supplied.
14
16
  */
15
17
  const requireAdmin = (
16
18
  req: express.Request,
@@ -24,6 +26,29 @@ const requireAdmin = (
24
26
  next();
25
27
  };
26
28
 
29
+ /**
30
+ * Builds an Express middleware that AND-combines terreno permission functions
31
+ * (the same {@link PermissionMethod} contract used by `modelRouter`). The
32
+ * configuration singleton has no per-object ownership, so the loaded config
33
+ * document is passed as the permission object.
34
+ */
35
+ const buildPermissionMiddleware = (
36
+ perms: PermissionMethod<unknown>[],
37
+ method: RESTMethod,
38
+ loadObj?: () => Promise<unknown>
39
+ ): express.RequestHandler =>
40
+ asyncHandler(async (req: express.Request, _res: express.Response, next: express.NextFunction) => {
41
+ const obj = loadObj ? await loadObj() : undefined;
42
+ const allowed = await checkPermissions(method, perms, req.user, obj);
43
+ if (!allowed) {
44
+ throw new APIError({
45
+ status: 403,
46
+ title: "Access to configuration denied",
47
+ });
48
+ }
49
+ next();
50
+ });
51
+
27
52
  /**
28
53
  * Metadata for a single configuration field, sent to the frontend.
29
54
  */
@@ -54,6 +79,54 @@ export interface ConfigurationMetaResponse {
54
79
  sections: ConfigSectionMeta[];
55
80
  }
56
81
 
82
+ /**
83
+ * Per-route permission overrides for ConfigurationApp. Each value is an array of
84
+ * terreno permission functions ({@link PermissionMethod}), AND-combined like
85
+ * `modelRouter` permissions. When a route is omitted, the default admin-only
86
+ * guard applies.
87
+ *
88
+ * @example
89
+ * ```typescript
90
+ * permissions: {
91
+ * read: [IsStaff],
92
+ * update: [IsSuperUser],
93
+ * }
94
+ * ```
95
+ */
96
+ export interface ConfigurationPermissions {
97
+ /** Guards `GET {basePath}` (current values). */
98
+ read?: PermissionMethod<unknown>[];
99
+ /** Guards `PATCH {basePath}` (update values). */
100
+ update?: PermissionMethod<unknown>[];
101
+ /** Guards `GET {basePath}/meta` (schema metadata). */
102
+ meta?: PermissionMethod<unknown>[];
103
+ /** Guards `POST {basePath}/list-secrets` and `/validate-secrets`. */
104
+ listSecrets?: PermissionMethod<unknown>[];
105
+ }
106
+
107
+ /**
108
+ * Hook invoked before a configuration update is applied. Receives the incoming
109
+ * (already system-field- and secret-field-stripped) body and the request, and
110
+ * returns the body to apply. Use it to validate or normalize input. Throw an
111
+ * {@link APIError} to reject the update.
112
+ */
113
+ export type ConfigurationPreUpdateHook = (
114
+ body: Record<string, unknown>,
115
+ req: express.Request
116
+ ) => Record<string, unknown> | Promise<Record<string, unknown>>;
117
+
118
+ /**
119
+ * Hook invoked after a configuration update is applied. Receives the updated
120
+ * configuration and the previous value (both with secret values redacted) plus
121
+ * the request, enabling audit logging of who changed what. Secret values are
122
+ * never included.
123
+ */
124
+ export type ConfigurationPostUpdateHook = (
125
+ config: Record<string, unknown>,
126
+ prevValue: Record<string, unknown>,
127
+ req: express.Request
128
+ ) => void | Promise<void>;
129
+
57
130
  /**
58
131
  * Options for ConfigurationApp.
59
132
  */
@@ -65,6 +138,16 @@ export interface ConfigurationAppOptions {
65
138
  basePath?: string;
66
139
  /** Per-field widget overrides (e.g., {"ai.systemPrompt": "markdown"}). */
67
140
  fieldOverrides?: Record<string, {widget?: string}>;
141
+ /**
142
+ * Per-route permission overrides. Defaults to admin-only for every route when
143
+ * omitted. Supply terreno permission functions (e.g. `[IsStaff]`) to expose
144
+ * configuration to a consumer's own permission system.
145
+ */
146
+ permissions?: ConfigurationPermissions;
147
+ /** Hook run before an update is applied (validation/normalization). */
148
+ preUpdate?: ConfigurationPreUpdateHook;
149
+ /** Hook run after an update is applied (audit logging). */
150
+ postUpdate?: ConfigurationPostUpdateHook;
68
151
  }
69
152
 
70
153
  /**
@@ -152,6 +235,41 @@ const redactSecrets = (
152
235
  return redacted;
153
236
  };
154
237
 
238
+ /**
239
+ * Removes secret field values from an incoming update body so a secret value can
240
+ * never be written to the configuration document through the update path.
241
+ * Copies nodes along each secret path to avoid mutating the caller's object.
242
+ */
243
+ const stripSecretFields = (
244
+ obj: Record<string, unknown>,
245
+ secretFields: SecretFieldMeta[]
246
+ ): Record<string, unknown> => {
247
+ const stripped: Record<string, unknown> = {...obj};
248
+ for (const field of secretFields) {
249
+ const parts = field.path.split(".");
250
+ let current: Record<string, unknown> | null = stripped;
251
+ for (let i = 0; i < parts.length - 1; i++) {
252
+ if (!current) {
253
+ break;
254
+ }
255
+ const part = parts[i];
256
+ const nested = current[part];
257
+ if (nested != null && typeof nested === "object") {
258
+ const copy = {...(nested as Record<string, unknown>)};
259
+ current[part] = copy;
260
+ current = copy;
261
+ } else {
262
+ current = null;
263
+ break;
264
+ }
265
+ }
266
+ if (current != null) {
267
+ delete current[parts[parts.length - 1]];
268
+ }
269
+ }
270
+ return stripped;
271
+ };
272
+
155
273
  /**
156
274
  * Converts a camelCase or PascalCase string into a display-friendly title.
157
275
  */
@@ -167,11 +285,21 @@ const toDisplayName = (name: string): string => {
167
285
  *
168
286
  * Inspects the Mongoose configuration model to auto-generate:
169
287
  * - `GET {basePath}/meta` — Schema metadata (sections, fields, types, descriptions)
170
- * - `GET {basePath}` — Current configuration values
171
- * - `PATCH {basePath}` — Update configuration values
172
- * - `POST {basePath}/refresh-secrets` — Trigger secret refresh (if provider configured)
288
+ * - `GET {basePath}` — Current configuration values (secret values redacted)
289
+ * - `PATCH {basePath}` — Update configuration values (secret fields stripped; never written)
290
+ * - `POST {basePath}/list-secrets` (alias `POST {basePath}/validate-secrets`)
291
+ * Read-only status of each secret field (whether the provider can resolve it).
292
+ * This endpoint never resolves values into the document and returns no secret values.
293
+ *
294
+ * By default all endpoints require `Permissions.IsAdmin`. Supply `permissions`
295
+ * to gate routes with a consumer's own permission functions, and `preUpdate`/
296
+ * `postUpdate` hooks to validate and audit-log changes. This makes
297
+ * `ConfigurationApp` suitable as a single, consumer-owned configuration surface
298
+ * that can replace a bespoke config router.
173
299
  *
174
- * All endpoints require `Permissions.IsAdmin`.
300
+ * Secret values never touch the database, logs, audit payloads, or API
301
+ * responses: secret fields are stripped from incoming updates and redacted on
302
+ * every read.
175
303
  *
176
304
  * Nested subschemas in the model become separate sections in the metadata,
177
305
  * making them renderable as cards/accordions in the admin UI.
@@ -193,7 +321,10 @@ const toDisplayName = (name: string): string => {
193
321
  * const AppConfig = mongoose.model("AppConfig", configSchema);
194
322
  *
195
323
  * new TerrenoApp({ userModel: User })
196
- * .configure(AppConfig)
324
+ * .configure(AppConfig, {
325
+ * permissions: {read: [IsStaff], update: [IsSuperUser]},
326
+ * postUpdate: (config, prevValue, req) => auditLog(req.user, prevValue, config),
327
+ * })
197
328
  * .start();
198
329
  * ```
199
330
  */
@@ -204,6 +335,21 @@ export class ConfigurationApp implements TerrenoPlugin {
204
335
  this.options = options;
205
336
  }
206
337
 
338
+ /**
339
+ * Resolves the guard middleware for a route: the consumer's terreno permission
340
+ * functions when supplied, otherwise the default admin-only guard.
341
+ */
342
+ private guardFor(
343
+ route: keyof ConfigurationPermissions,
344
+ method: RESTMethod
345
+ ): express.RequestHandler {
346
+ const perms = this.options.permissions?.[route];
347
+ if (perms && perms.length > 0) {
348
+ return buildPermissionMiddleware(perms, method);
349
+ }
350
+ return requireAdmin;
351
+ }
352
+
207
353
  register(app: express.Application): void {
208
354
  const basePath = this.options.basePath ?? "/configuration";
209
355
  const ConfigModel = this.options.model;
@@ -216,7 +362,7 @@ export class ConfigurationApp implements TerrenoPlugin {
216
362
  app.get(
217
363
  `${basePath}/meta`,
218
364
  authenticateMiddleware(),
219
- requireAdmin,
365
+ this.guardFor("meta", "read"),
220
366
  (_req: express.Request, res: express.Response) => {
221
367
  return res.json(meta);
222
368
  }
@@ -239,7 +385,7 @@ export class ConfigurationApp implements TerrenoPlugin {
239
385
  app.get(
240
386
  `${basePath}`,
241
387
  authenticateMiddleware(),
242
- requireAdmin,
388
+ this.guardFor("read", "read"),
243
389
  asyncHandler(async (_req: express.Request, res: express.Response) => {
244
390
  const config = await ConfigStatics.getConfig();
245
391
  const data = redactSecrets(config.toJSON(), secretFields);
@@ -247,44 +393,81 @@ export class ConfigurationApp implements TerrenoPlugin {
247
393
  })
248
394
  );
249
395
 
250
- // PATCH /configuration — update values (secrets redacted in response)
396
+ // PATCH /configuration — update values (secret fields stripped; secrets redacted in response)
251
397
  app.patch(
252
398
  `${basePath}`,
253
399
  authenticateMiddleware(),
254
- requireAdmin,
400
+ this.guardFor("update", "update"),
255
401
  asyncHandler(async (req: express.Request, res: express.Response) => {
256
- // Strip internal system fields that should never be updated via the API
257
- const {_singleton: _s, _id: _i, __v: _v, ...safeBody} = req.body;
402
+ // Strip internal system fields that should never be updated via the API.
403
+ const {_singleton: _s, _id: _i, __v: _v, ...rest} = req.body;
404
+ // Strip secret fields so a secret value can never be persisted via update.
405
+ let safeBody = stripSecretFields(rest, secretFields);
406
+
407
+ // Allow consumers to validate/normalize before applying.
408
+ if (this.options.preUpdate) {
409
+ safeBody = await this.options.preUpdate(safeBody, req);
410
+ // Re-strip after the hook: preUpdate receives the raw request and could
411
+ // otherwise (re)introduce secret paths. Secrets must never persist here.
412
+ safeBody = stripSecretFields(safeBody, secretFields);
413
+ }
414
+
415
+ // Capture the previous (redacted) value for audit hooks.
416
+ let prevValue: Record<string, unknown> = {};
417
+ if (this.options.postUpdate) {
418
+ const before = await ConfigStatics.getConfig();
419
+ prevValue = redactSecrets(before.toJSON(), secretFields);
420
+ }
421
+
258
422
  const config = await ConfigStatics.updateConfig(safeBody);
259
423
  logger.info(`Configuration updated by ${req.user?.email ?? "unknown"}`);
260
424
  const data = redactSecrets(config.toJSON(), secretFields);
425
+
426
+ if (this.options.postUpdate) {
427
+ await this.options.postUpdate(data, prevValue, req);
428
+ }
429
+
261
430
  return res.json({data});
262
431
  })
263
432
  );
264
433
 
265
- // POST /configuration/list-secrets — list secret fields and optionally resolve from provider
266
- app.post(
267
- `${basePath}/list-secrets`,
268
- authenticateMiddleware(),
269
- requireAdmin,
270
- asyncHandler(async (_req: express.Request, res: express.Response) => {
434
+ // POST /configuration/list-secrets — read-only status of each secret field.
435
+ // Never resolves values into the document and never returns secret values.
436
+ const validateSecretsHandler = asyncHandler(
437
+ async (_req: express.Request, res: express.Response) => {
438
+ // In-memory resolution only — used to report whether each secret is
439
+ // configured/resolvable. Values are never persisted or returned.
271
440
  const resolved: Map<string, string> = await ConfigStatics.resolveSecrets();
272
- if (resolved.size > 0) {
273
- const updates: Record<string, unknown> = {};
274
- for (const [path, value] of resolved) {
275
- updates[path] = value;
276
- }
277
- await ConfigStatics.updateConfig(updates);
278
- logger.info(`Refreshed ${resolved.size}/${secretFields.length} secrets`);
279
- }
441
+ const fields = secretFields.map((s) => ({
442
+ isConfigured: resolved.has(s.path),
443
+ path: s.path,
444
+ resolvable: resolved.has(s.path),
445
+ secretName: s.secretName,
446
+ version: s.version,
447
+ }));
448
+ logger.info(`Validated ${resolved.size}/${secretFields.length} secrets (read-only)`);
280
449
 
281
450
  return res.json({
282
- message: `Resolved ${resolved.size}/${secretFields.length} secrets.`,
451
+ message: `${resolved.size}/${secretFields.length} secrets resolvable.`,
283
452
  resolved: resolved.size,
284
- secretFields: secretFields.map((s) => ({path: s.path, secretName: s.secretName})),
453
+ secretFields: fields,
285
454
  total: secretFields.length,
286
455
  });
287
- })
456
+ }
457
+ );
458
+
459
+ app.post(
460
+ `${basePath}/list-secrets`,
461
+ authenticateMiddleware(),
462
+ this.guardFor("listSecrets", "read"),
463
+ validateSecretsHandler
464
+ );
465
+ // Accurate alias for the read-only validation semantics.
466
+ app.post(
467
+ `${basePath}/validate-secrets`,
468
+ authenticateMiddleware(),
469
+ this.guardFor("listSecrets", "read"),
470
+ validateSecretsHandler
288
471
  );
289
472
 
290
473
  logger.info(`Configuration routes mounted at ${basePath}`);