@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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,30 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.20.0
4
+
5
+ ### Changed (breaking)
6
+
7
+ - **`ConfigurationApp` `POST {basePath}/list-secrets` no longer persists secret values.** Previously it resolved secrets from the provider and wrote the resolved plaintext values into the configuration document. It is now a **read-only validation/status** endpoint: it reports, per secret field, only non-sensitive metadata (`path`, `secretName`, `version`, and a boolean `resolvable`/`isConfigured`) and never writes to the document or returns secret values. A `POST {basePath}/validate-secrets` alias with the same behavior is also registered.
8
+ - **`ConfigurationApp` `PATCH {basePath}` strips `secret: true` fields** from the incoming body, so a secret value can never be written through the update path. Secret fields are read-only via this surface.
9
+ - **`configurationPlugin` no longer adds the `_singleton` unique index by default.** It is now opt-in via `enforceSingletonIndex: true` so it does not double-enforce or conflict with consumers that already guarantee a single non-deleted document via the pre-save guard or their own soft-delete plugin/indexes.
10
+ - **`configurationPlugin` singleton semantics are now soft-delete aware.** `getConfig`, `updateConfig`, and the pre-save guard operate on `{deleted: false}` when the schema has a `deleted` path (e.g. via `isDeletedPlugin`). A soft-deleted document no longer blocks creating a new singleton; hard deletes (`deleteOne`/`deleteMany`/`findOneAndDelete`) remain blocked.
11
+ - **`configurationPlugin.updateConfig` now applies updates via `findOneAndUpdate({$set})` with dotted paths** instead of `Object.assign` + `doc.save()`. This preserves sibling fields inside nested subdocuments on partial patches and tolerates legacy/out-of-schema fields already persisted under `strict: "throw"`.
12
+
13
+ ### Added
14
+
15
+ - **`SecretProvider.getSecret(secretName, version?)`** — optional `version` parameter. `GcpSecretProvider` resolves `projects/{projectId}/secrets/{name}/versions/{version}` (default `latest`, full resource paths still honored); `EnvSecretProvider` ignores it. Secret fields can declare a `secretVersion` schema option, surfaced on `SecretFieldMeta.version` and passed through `resolveSecrets`.
16
+ - **`CompositeSecretProvider`** — composes an ordered list of providers and returns the first non-null result; a failing provider is warn-logged (secret name only) and resolution falls through to the next.
17
+ - **`CachingSecretProvider`** — wraps any provider with an in-memory TTL cache keyed by `secretName@version`, with `clear()` / `clearKey()` for rotation and tests. Caches `null` results. Never logs values.
18
+ - **`ConfigurationApp` pluggable permissions** — `permissions: {read?, update?, meta?, listSecrets?}` accepts terreno permission functions (e.g. `[IsStaff]`), AND-combined like `modelRouter`. Defaults to admin-only for every route.
19
+ - **`ConfigurationApp` lifecycle hooks** — `preUpdate(body, req)` (validate/normalize) and `postUpdate(config, prevValue, req)` (audit logging). Both payloads have secret values redacted.
20
+ - **`flattenToDotPaths`** — exported helper used by `updateConfig`.
21
+
22
+ ### Migration
23
+
24
+ - If you relied on `list-secrets` to populate secret values into the configuration document, stop. Resolve secrets on-demand at runtime via `Model.resolveSecrets(provider)` (returns an in-memory `Map`) and read them from memory; never persist them.
25
+ - If you depended on the `_singleton` unique index, pass `configurationPlugin(schema, {enforceSingletonIndex: true})`.
26
+ - For GCP-with-env-fallback and caching, compose `new CachingSecretProvider(new CompositeSecretProvider([gcp, env]), {ttlMs})`.
27
+
3
28
  ## 0.16.0
4
29
 
5
30
  ### Added
@@ -1,4 +1,15 @@
1
1
  "use strict";
2
+ var __assign = (this && this.__assign) || function () {
3
+ __assign = Object.assign || function(t) {
4
+ for (var s, i = 1, n = arguments.length; i < n; i++) {
5
+ s = arguments[i];
6
+ for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p))
7
+ t[p] = s[p];
8
+ }
9
+ return t;
10
+ };
11
+ return __assign.apply(this, arguments);
12
+ };
2
13
  var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
14
  if (k2 === undefined) k2 = k;
4
15
  var desc = Object.getOwnPropertyDescriptor(m, k);
@@ -135,11 +146,7 @@ var buildApp = function (configModel, options) {
135
146
  var app = (0, tests_1.getBaseServer)();
136
147
  (0, auth_1.setupAuth)(app, tests_1.UserModel);
137
148
  (0, auth_1.addAuthRoutes)(app, tests_1.UserModel);
138
- var configApp = new configurationApp_1.ConfigurationApp({
139
- basePath: options === null || options === void 0 ? void 0 : options.basePath,
140
- fieldOverrides: options === null || options === void 0 ? void 0 : options.fieldOverrides,
141
- model: configModel,
142
- });
149
+ var configApp = new configurationApp_1.ConfigurationApp(__assign({ model: configModel }, options));
143
150
  configApp.register(app);
144
151
  app.use(errors_1.apiUnauthorizedMiddleware);
145
152
  app.use(errors_1.apiErrorMiddleware);
@@ -268,6 +275,49 @@ var buildApp = function (configModel, options) {
268
275
  }
269
276
  });
270
277
  }); });
278
+ (0, bun_test_1.it)("preserves sibling subdoc fields on a partial nested patch", function () { return __awaiter(void 0, void 0, void 0, function () {
279
+ var updated;
280
+ return __generator(this, function (_a) {
281
+ switch (_a.label) {
282
+ case 0: return [4 /*yield*/, TestConfig.updateConfig({ general: { appName: "Initial", maintenanceMode: true } })];
283
+ case 1:
284
+ _a.sent();
285
+ return [4 /*yield*/, TestConfig.updateConfig({ general: { appName: "Renamed" } })];
286
+ case 2:
287
+ updated = _a.sent();
288
+ (0, bun_test_1.expect)(updated.general.appName).toBe("Renamed");
289
+ // Sibling must be preserved (not clobbered back to the default).
290
+ (0, bun_test_1.expect)(updated.general.maintenanceMode).toBe(true);
291
+ return [2 /*return*/];
292
+ }
293
+ });
294
+ }); });
295
+ (0, bun_test_1.it)("tolerates legacy/out-of-schema fields already persisted", function () { return __awaiter(void 0, void 0, void 0, function () {
296
+ var updated, raw;
297
+ var _a, _b;
298
+ return __generator(this, function (_c) {
299
+ switch (_c.label) {
300
+ case 0:
301
+ // Insert a document containing a field that is not in the (strict: throw) schema.
302
+ return [4 /*yield*/, TestConfig.getConfig()];
303
+ case 1:
304
+ // Insert a document containing a field that is not in the (strict: throw) schema.
305
+ _c.sent();
306
+ return [4 /*yield*/, ((_a = mongoose_1.default.connection.db) === null || _a === void 0 ? void 0 : _a.collection("testconfigs").updateOne({}, { $set: { legacyField: "stale" } }))];
307
+ case 2:
308
+ _c.sent();
309
+ return [4 /*yield*/, TestConfig.updateConfig({ general: { appName: "Survives" } })];
310
+ case 3:
311
+ updated = _c.sent();
312
+ (0, bun_test_1.expect)(updated.general.appName).toBe("Survives");
313
+ return [4 /*yield*/, ((_b = mongoose_1.default.connection.db) === null || _b === void 0 ? void 0 : _b.collection("testconfigs").findOne({}))];
314
+ case 4:
315
+ raw = _c.sent();
316
+ (0, bun_test_1.expect)(raw === null || raw === void 0 ? void 0 : raw.legacyField).toBe("stale");
317
+ return [2 /*return*/];
318
+ }
319
+ });
320
+ }); });
271
321
  });
272
322
  (0, bun_test_1.describe)("singleton enforcement", function () {
273
323
  (0, bun_test_1.it)("prevents creating a second document via save", function () { return __awaiter(void 0, void 0, void 0, function () {
@@ -567,17 +617,23 @@ var buildApp = function (configModel, options) {
567
617
  }
568
618
  });
569
619
  }); });
570
- (0, bun_test_1.it)("redacts secrets in the response", function () { return __awaiter(void 0, void 0, void 0, function () {
571
- var res;
620
+ (0, bun_test_1.it)("never persists secret field values supplied in the body", function () { return __awaiter(void 0, void 0, void 0, function () {
621
+ var res, stored;
572
622
  return __generator(this, function (_a) {
573
623
  switch (_a.label) {
574
624
  case 0: return [4 /*yield*/, adminAgent
575
625
  .patch("/configuration")
576
- .send({ integrations: { apiKey: "new-secret" } })
626
+ .send({ integrations: { apiKey: "new-secret", webhookUrl: "https://changed.example.com" } })
577
627
  .expect(200)];
578
628
  case 1:
579
629
  res = _a.sent();
580
- (0, bun_test_1.expect)(res.body.data.integrations.apiKey).toBe("********");
630
+ // Secret stays empty (stripped); non-secret sibling is updated.
631
+ (0, bun_test_1.expect)(res.body.data.integrations.apiKey).toBe("");
632
+ (0, bun_test_1.expect)(res.body.data.integrations.webhookUrl).toBe("https://changed.example.com");
633
+ return [4 /*yield*/, TestConfig.getConfig()];
634
+ case 2:
635
+ stored = _a.sent();
636
+ (0, bun_test_1.expect)(stored.integrations.apiKey).toBe("");
581
637
  return [2 /*return*/];
582
638
  }
583
639
  });
@@ -597,7 +653,7 @@ var buildApp = function (configModel, options) {
597
653
  }); });
598
654
  });
599
655
  (0, bun_test_1.describe)("POST /configuration/list-secrets", function () {
600
- (0, bun_test_1.it)("returns discovered secret fields", function () { return __awaiter(void 0, void 0, void 0, function () {
656
+ (0, bun_test_1.it)("returns discovered secret fields with resolvable status", function () { return __awaiter(void 0, void 0, void 0, function () {
601
657
  var res;
602
658
  return __generator(this, function (_a) {
603
659
  switch (_a.label) {
@@ -607,6 +663,42 @@ var buildApp = function (configModel, options) {
607
663
  (0, bun_test_1.expect)(res.body.secretFields).toHaveLength(1);
608
664
  (0, bun_test_1.expect)(res.body.secretFields[0].path).toBe("integrations.apiKey");
609
665
  (0, bun_test_1.expect)(res.body.secretFields[0].secretName).toBe("external-api-key");
666
+ // No provider configured -> not resolvable, and no value is ever returned.
667
+ (0, bun_test_1.expect)(res.body.secretFields[0].resolvable).toBe(false);
668
+ (0, bun_test_1.expect)(res.body.secretFields[0].isConfigured).toBe(false);
669
+ (0, bun_test_1.expect)(JSON.stringify(res.body)).not.toContain("super-secret");
670
+ return [2 /*return*/];
671
+ }
672
+ });
673
+ }); });
674
+ (0, bun_test_1.it)("does not mutate the stored config document's secret fields", function () { return __awaiter(void 0, void 0, void 0, function () {
675
+ var stored;
676
+ return __generator(this, function (_a) {
677
+ switch (_a.label) {
678
+ case 0: return [4 /*yield*/, TestConfig.getConfig()];
679
+ case 1:
680
+ _a.sent();
681
+ return [4 /*yield*/, adminAgent.post("/configuration/list-secrets").expect(200)];
682
+ case 2:
683
+ _a.sent();
684
+ return [4 /*yield*/, TestConfig.getConfig()];
685
+ case 3:
686
+ stored = _a.sent();
687
+ // list-secrets must never write resolved values into the document.
688
+ (0, bun_test_1.expect)(stored.integrations.apiKey).toBe("");
689
+ return [2 /*return*/];
690
+ }
691
+ });
692
+ }); });
693
+ (0, bun_test_1.it)("is also exposed as validate-secrets", function () { return __awaiter(void 0, void 0, void 0, function () {
694
+ var res;
695
+ return __generator(this, function (_a) {
696
+ switch (_a.label) {
697
+ case 0: return [4 /*yield*/, adminAgent.post("/configuration/validate-secrets").expect(200)];
698
+ case 1:
699
+ res = _a.sent();
700
+ (0, bun_test_1.expect)(res.body.secretFields).toHaveLength(1);
701
+ (0, bun_test_1.expect)(res.body.secretFields[0].path).toBe("integrations.apiKey");
610
702
  return [2 /*return*/];
611
703
  }
612
704
  });
@@ -698,3 +790,190 @@ var buildApp = function (configModel, options) {
698
790
  });
699
791
  }); });
700
792
  });
793
+ (0, bun_test_1.describe)("ConfigurationApp with custom permissions", function () {
794
+ var app;
795
+ var adminAgent;
796
+ var notAdminAgent;
797
+ // Terreno-style permission: any authenticated user (not just admins).
798
+ var isAuthenticated = function (_method, user) { return Boolean(user === null || user === void 0 ? void 0 : user.id); };
799
+ (0, bun_test_1.beforeEach)(function () { return __awaiter(void 0, void 0, void 0, function () {
800
+ var _a;
801
+ return __generator(this, function (_b) {
802
+ switch (_b.label) {
803
+ case 0: return [4 /*yield*/, (0, tests_1.setupDb)()];
804
+ case 1:
805
+ _b.sent();
806
+ return [4 /*yield*/, ((_a = mongoose_1.default.connection.db) === null || _a === void 0 ? void 0 : _a.collection("testconfigs").deleteMany({}))];
807
+ case 2:
808
+ _b.sent();
809
+ app = buildApp(TestConfig, {
810
+ permissions: {
811
+ read: [isAuthenticated],
812
+ // update intentionally left default (admin-only)
813
+ },
814
+ });
815
+ return [4 /*yield*/, (0, tests_1.authAsUser)(app, "admin")];
816
+ case 3:
817
+ adminAgent = _b.sent();
818
+ return [4 /*yield*/, (0, tests_1.authAsUser)(app, "notAdmin")];
819
+ case 4:
820
+ notAdminAgent = _b.sent();
821
+ return [2 /*return*/];
822
+ }
823
+ });
824
+ }); });
825
+ (0, bun_test_1.it)("allows non-admin reads when a custom read permission is supplied", function () { return __awaiter(void 0, void 0, void 0, function () {
826
+ var res;
827
+ return __generator(this, function (_a) {
828
+ switch (_a.label) {
829
+ case 0: return [4 /*yield*/, notAdminAgent.get("/configuration").expect(200)];
830
+ case 1:
831
+ res = _a.sent();
832
+ (0, bun_test_1.expect)(res.body.data.general.appName).toBe("Test App");
833
+ return [2 /*return*/];
834
+ }
835
+ });
836
+ }); });
837
+ (0, bun_test_1.it)("still rejects unauthenticated reads", function () { return __awaiter(void 0, void 0, void 0, function () {
838
+ return __generator(this, function (_a) {
839
+ switch (_a.label) {
840
+ case 0: return [4 /*yield*/, (0, supertest_1.default)(app).get("/configuration").expect(401)];
841
+ case 1:
842
+ _a.sent();
843
+ return [2 /*return*/];
844
+ }
845
+ });
846
+ }); });
847
+ (0, bun_test_1.it)("keeps update admin-only by default", function () { return __awaiter(void 0, void 0, void 0, function () {
848
+ return __generator(this, function (_a) {
849
+ switch (_a.label) {
850
+ case 0: return [4 /*yield*/, notAdminAgent
851
+ .patch("/configuration")
852
+ .send({ general: { appName: "Nope" } })
853
+ .expect(403)];
854
+ case 1:
855
+ _a.sent();
856
+ return [4 /*yield*/, adminAgent
857
+ .patch("/configuration")
858
+ .send({ general: { appName: "Yes" } })
859
+ .expect(200)];
860
+ case 2:
861
+ _a.sent();
862
+ return [2 /*return*/];
863
+ }
864
+ });
865
+ }); });
866
+ });
867
+ (0, bun_test_1.describe)("ConfigurationApp with update hooks", function () {
868
+ var app;
869
+ var adminAgent;
870
+ var preUpdateCalls;
871
+ var postUpdateCalls;
872
+ (0, bun_test_1.beforeEach)(function () { return __awaiter(void 0, void 0, void 0, function () {
873
+ var _a;
874
+ return __generator(this, function (_b) {
875
+ switch (_b.label) {
876
+ case 0: return [4 /*yield*/, (0, tests_1.setupDb)()];
877
+ case 1:
878
+ _b.sent();
879
+ return [4 /*yield*/, ((_a = mongoose_1.default.connection.db) === null || _a === void 0 ? void 0 : _a.collection("testconfigs").deleteMany({}))];
880
+ case 2:
881
+ _b.sent();
882
+ preUpdateCalls = [];
883
+ postUpdateCalls = [];
884
+ app = buildApp(TestConfig, {
885
+ postUpdate: function (config, prevValue) { return __awaiter(void 0, void 0, void 0, function () {
886
+ return __generator(this, function (_a) {
887
+ postUpdateCalls.push({ config: config, prev: prevValue });
888
+ return [2 /*return*/];
889
+ });
890
+ }); },
891
+ preUpdate: function (body) { return __awaiter(void 0, void 0, void 0, function () {
892
+ var general;
893
+ var _a;
894
+ return __generator(this, function (_b) {
895
+ preUpdateCalls.push(body);
896
+ general = (_a = body.general) !== null && _a !== void 0 ? _a : {};
897
+ return [2 /*return*/, __assign(__assign({}, body), { general: __assign(__assign({}, general), { maintenanceMode: true }) })];
898
+ });
899
+ }); },
900
+ });
901
+ return [4 /*yield*/, (0, tests_1.authAsUser)(app, "admin")];
902
+ case 3:
903
+ adminAgent = _b.sent();
904
+ return [2 /*return*/];
905
+ }
906
+ });
907
+ }); });
908
+ (0, bun_test_1.it)("runs preUpdate to transform the body before applying", function () { return __awaiter(void 0, void 0, void 0, function () {
909
+ var res;
910
+ return __generator(this, function (_a) {
911
+ switch (_a.label) {
912
+ case 0: return [4 /*yield*/, adminAgent
913
+ .patch("/configuration")
914
+ .send({ general: { appName: "Hooked" } })
915
+ .expect(200)];
916
+ case 1:
917
+ res = _a.sent();
918
+ (0, bun_test_1.expect)(res.body.data.general.appName).toBe("Hooked");
919
+ (0, bun_test_1.expect)(res.body.data.general.maintenanceMode).toBe(true);
920
+ (0, bun_test_1.expect)(preUpdateCalls).toHaveLength(1);
921
+ return [2 /*return*/];
922
+ }
923
+ });
924
+ }); });
925
+ (0, bun_test_1.it)("does not persist secret values even if preUpdate re-introduces them", function () { return __awaiter(void 0, void 0, void 0, function () {
926
+ var leakyApp, agent, res, stored;
927
+ return __generator(this, function (_a) {
928
+ switch (_a.label) {
929
+ case 0:
930
+ leakyApp = buildApp(TestConfig, {
931
+ preUpdate: function (body) { return (__assign(__assign({}, body), { integrations: __assign(__assign({}, body.integrations), { apiKey: "leaked-secret" }) })); },
932
+ });
933
+ return [4 /*yield*/, (0, tests_1.authAsUser)(leakyApp, "admin")];
934
+ case 1:
935
+ agent = _a.sent();
936
+ return [4 /*yield*/, agent
937
+ .patch("/configuration")
938
+ .send({ general: { appName: "Safe" } })
939
+ .expect(200)];
940
+ case 2:
941
+ res = _a.sent();
942
+ (0, bun_test_1.expect)(res.body.data.general.appName).toBe("Safe");
943
+ (0, bun_test_1.expect)(res.body.data.integrations.apiKey).toBe("");
944
+ return [4 /*yield*/, TestConfig.getConfig()];
945
+ case 3:
946
+ stored = _a.sent();
947
+ (0, bun_test_1.expect)(stored.integrations.apiKey).toBe("");
948
+ return [2 /*return*/];
949
+ }
950
+ });
951
+ }); });
952
+ (0, bun_test_1.it)("runs postUpdate with redacted config and previous value", function () { return __awaiter(void 0, void 0, void 0, function () {
953
+ var _a, config, prev;
954
+ return __generator(this, function (_b) {
955
+ switch (_b.label) {
956
+ case 0:
957
+ // Seed a secret so we can confirm it is redacted in hook payloads.
958
+ return [4 /*yield*/, TestConfig.updateConfig({ integrations: { apiKey: "should-not-leak" } })];
959
+ case 1:
960
+ // Seed a secret so we can confirm it is redacted in hook payloads.
961
+ _b.sent();
962
+ return [4 /*yield*/, adminAgent
963
+ .patch("/configuration")
964
+ .send({ general: { appName: "Audited" } })
965
+ .expect(200)];
966
+ case 2:
967
+ _b.sent();
968
+ (0, bun_test_1.expect)(postUpdateCalls).toHaveLength(1);
969
+ _a = postUpdateCalls[0], config = _a.config, prev = _a.prev;
970
+ (0, bun_test_1.expect)(config.general.appName).toBe("Audited");
971
+ // Secret values must be redacted in both payloads.
972
+ (0, bun_test_1.expect)(config.integrations.apiKey).toBe("********");
973
+ (0, bun_test_1.expect)(prev.integrations.apiKey).toBe("********");
974
+ (0, bun_test_1.expect)(JSON.stringify(postUpdateCalls)).not.toContain("should-not-leak");
975
+ return [2 /*return*/];
976
+ }
977
+ });
978
+ }); });
979
+ });
@@ -1,5 +1,6 @@
1
1
  import type express from "express";
2
2
  import type { Model } from "mongoose";
3
+ import { type PermissionMethod } from "./permissions";
3
4
  import type { TerrenoPlugin } from "./terrenoPlugin";
4
5
  /**
5
6
  * Metadata for a single configuration field, sent to the frontend.
@@ -28,6 +29,44 @@ interface ConfigSectionMeta {
28
29
  export interface ConfigurationMetaResponse {
29
30
  sections: ConfigSectionMeta[];
30
31
  }
32
+ /**
33
+ * Per-route permission overrides for ConfigurationApp. Each value is an array of
34
+ * terreno permission functions ({@link PermissionMethod}), AND-combined like
35
+ * `modelRouter` permissions. When a route is omitted, the default admin-only
36
+ * guard applies.
37
+ *
38
+ * @example
39
+ * ```typescript
40
+ * permissions: {
41
+ * read: [IsStaff],
42
+ * update: [IsSuperUser],
43
+ * }
44
+ * ```
45
+ */
46
+ export interface ConfigurationPermissions {
47
+ /** Guards `GET {basePath}` (current values). */
48
+ read?: PermissionMethod<unknown>[];
49
+ /** Guards `PATCH {basePath}` (update values). */
50
+ update?: PermissionMethod<unknown>[];
51
+ /** Guards `GET {basePath}/meta` (schema metadata). */
52
+ meta?: PermissionMethod<unknown>[];
53
+ /** Guards `POST {basePath}/list-secrets` and `/validate-secrets`. */
54
+ listSecrets?: PermissionMethod<unknown>[];
55
+ }
56
+ /**
57
+ * Hook invoked before a configuration update is applied. Receives the incoming
58
+ * (already system-field- and secret-field-stripped) body and the request, and
59
+ * returns the body to apply. Use it to validate or normalize input. Throw an
60
+ * {@link APIError} to reject the update.
61
+ */
62
+ export type ConfigurationPreUpdateHook = (body: Record<string, unknown>, req: express.Request) => Record<string, unknown> | Promise<Record<string, unknown>>;
63
+ /**
64
+ * Hook invoked after a configuration update is applied. Receives the updated
65
+ * configuration and the previous value (both with secret values redacted) plus
66
+ * the request, enabling audit logging of who changed what. Secret values are
67
+ * never included.
68
+ */
69
+ export type ConfigurationPostUpdateHook = (config: Record<string, unknown>, prevValue: Record<string, unknown>, req: express.Request) => void | Promise<void>;
31
70
  /**
32
71
  * Options for ConfigurationApp.
33
72
  */
@@ -40,17 +79,37 @@ export interface ConfigurationAppOptions {
40
79
  fieldOverrides?: Record<string, {
41
80
  widget?: string;
42
81
  }>;
82
+ /**
83
+ * Per-route permission overrides. Defaults to admin-only for every route when
84
+ * omitted. Supply terreno permission functions (e.g. `[IsStaff]`) to expose
85
+ * configuration to a consumer's own permission system.
86
+ */
87
+ permissions?: ConfigurationPermissions;
88
+ /** Hook run before an update is applied (validation/normalization). */
89
+ preUpdate?: ConfigurationPreUpdateHook;
90
+ /** Hook run after an update is applied (audit logging). */
91
+ postUpdate?: ConfigurationPostUpdateHook;
43
92
  }
44
93
  /**
45
94
  * TerrenoPlugin that provides configuration management endpoints.
46
95
  *
47
96
  * Inspects the Mongoose configuration model to auto-generate:
48
97
  * - `GET {basePath}/meta` — Schema metadata (sections, fields, types, descriptions)
49
- * - `GET {basePath}` — Current configuration values
50
- * - `PATCH {basePath}` — Update configuration values
51
- * - `POST {basePath}/refresh-secrets` — Trigger secret refresh (if provider configured)
98
+ * - `GET {basePath}` — Current configuration values (secret values redacted)
99
+ * - `PATCH {basePath}` — Update configuration values (secret fields stripped; never written)
100
+ * - `POST {basePath}/list-secrets` (alias `POST {basePath}/validate-secrets`)
101
+ * Read-only status of each secret field (whether the provider can resolve it).
102
+ * This endpoint never resolves values into the document and returns no secret values.
103
+ *
104
+ * By default all endpoints require `Permissions.IsAdmin`. Supply `permissions`
105
+ * to gate routes with a consumer's own permission functions, and `preUpdate`/
106
+ * `postUpdate` hooks to validate and audit-log changes. This makes
107
+ * `ConfigurationApp` suitable as a single, consumer-owned configuration surface
108
+ * that can replace a bespoke config router.
52
109
  *
53
- * All endpoints require `Permissions.IsAdmin`.
110
+ * Secret values never touch the database, logs, audit payloads, or API
111
+ * responses: secret fields are stripped from incoming updates and redacted on
112
+ * every read.
54
113
  *
55
114
  * Nested subschemas in the model become separate sections in the metadata,
56
115
  * making them renderable as cards/accordions in the admin UI.
@@ -72,13 +131,21 @@ export interface ConfigurationAppOptions {
72
131
  * const AppConfig = mongoose.model("AppConfig", configSchema);
73
132
  *
74
133
  * new TerrenoApp({ userModel: User })
75
- * .configure(AppConfig)
134
+ * .configure(AppConfig, {
135
+ * permissions: {read: [IsStaff], update: [IsSuperUser]},
136
+ * postUpdate: (config, prevValue, req) => auditLog(req.user, prevValue, config),
137
+ * })
76
138
  * .start();
77
139
  * ```
78
140
  */
79
141
  export declare class ConfigurationApp implements TerrenoPlugin {
80
142
  private options;
81
143
  constructor(options: ConfigurationAppOptions);
144
+ /**
145
+ * Resolves the guard middleware for a route: the consumer's terreno permission
146
+ * functions when supplied, otherwise the default admin-only guard.
147
+ */
148
+ private guardFor;
82
149
  register(app: express.Application): void;
83
150
  /**
84
151
  * Builds the metadata response by inspecting the model schema.