@terreno/api 0.3.1 → 0.4.2
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/dist/api.js +9 -8
- package/dist/betterAuthSetup.js +1 -1
- package/dist/configuration.test.d.ts +1 -0
- package/dist/configuration.test.js +699 -0
- package/dist/configurationApp.d.ts +91 -0
- package/dist/configurationApp.js +407 -0
- package/dist/configurationPlugin.d.ts +102 -0
- package/dist/configurationPlugin.js +285 -0
- package/dist/configurationPlugin.test.d.ts +1 -0
- package/dist/configurationPlugin.test.js +509 -0
- package/dist/example.js +1 -1
- package/dist/expressServer.js +5 -1
- package/dist/githubAuth.js +2 -2
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/openApiCompat.d.ts +23 -0
- package/dist/openApiCompat.js +198 -0
- package/dist/scriptRunner.d.ts +52 -0
- package/dist/scriptRunner.js +231 -0
- package/dist/secretProviders.d.ts +47 -0
- package/dist/secretProviders.js +214 -0
- package/dist/terrenoApp.d.ts +25 -0
- package/dist/terrenoApp.js +49 -2
- package/dist/tests.d.ts +27 -9
- package/dist/tests.js +10 -1
- package/package.json +13 -13
- package/src/api.ts +9 -8
- package/src/betterAuthSetup.ts +2 -2
- package/src/configuration.test.ts +398 -0
- package/src/configurationApp.ts +359 -0
- package/src/configurationPlugin.test.ts +299 -0
- package/src/configurationPlugin.ts +288 -0
- package/src/example.ts +1 -1
- package/src/expressServer.ts +6 -1
- package/src/githubAuth.ts +4 -4
- package/src/index.ts +5 -0
- package/src/openApiCompat.ts +147 -0
- package/src/permissions.ts +1 -1
- package/src/scriptRunner.ts +219 -0
- package/src/secretProviders.ts +109 -0
- package/src/terrenoApp.ts +44 -2
- package/src/tests.ts +12 -1
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type express from "express";
|
|
2
|
+
import type { Model } from "mongoose";
|
|
3
|
+
import type { TerrenoPlugin } from "./terrenoPlugin";
|
|
4
|
+
/**
|
|
5
|
+
* Metadata for a single configuration field, sent to the frontend.
|
|
6
|
+
*/
|
|
7
|
+
interface ConfigFieldMeta {
|
|
8
|
+
type: string;
|
|
9
|
+
required: boolean;
|
|
10
|
+
description?: string;
|
|
11
|
+
enum?: string[];
|
|
12
|
+
default?: any;
|
|
13
|
+
secret?: boolean;
|
|
14
|
+
widget?: string;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Metadata for a configuration section (nested subschema).
|
|
18
|
+
*/
|
|
19
|
+
interface ConfigSectionMeta {
|
|
20
|
+
name: string;
|
|
21
|
+
displayName: string;
|
|
22
|
+
description?: string;
|
|
23
|
+
fields: Record<string, ConfigFieldMeta>;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The config metadata response shape sent to the frontend.
|
|
27
|
+
*/
|
|
28
|
+
export interface ConfigurationMetaResponse {
|
|
29
|
+
sections: ConfigSectionMeta[];
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Options for ConfigurationApp.
|
|
33
|
+
*/
|
|
34
|
+
export interface ConfigurationAppOptions {
|
|
35
|
+
/** The Mongoose model with configurationPlugin applied. */
|
|
36
|
+
model: Model<any>;
|
|
37
|
+
/** Base path for configuration routes. Defaults to "/configuration". */
|
|
38
|
+
basePath?: string;
|
|
39
|
+
/** Per-field widget overrides (e.g., {"ai.systemPrompt": "markdown"}). */
|
|
40
|
+
fieldOverrides?: Record<string, {
|
|
41
|
+
widget?: string;
|
|
42
|
+
}>;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* TerrenoPlugin that provides configuration management endpoints.
|
|
46
|
+
*
|
|
47
|
+
* Inspects the Mongoose configuration model to auto-generate:
|
|
48
|
+
* - `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)
|
|
52
|
+
*
|
|
53
|
+
* All endpoints require `Permissions.IsAdmin`.
|
|
54
|
+
*
|
|
55
|
+
* Nested subschemas in the model become separate sections in the metadata,
|
|
56
|
+
* making them renderable as cards/accordions in the admin UI.
|
|
57
|
+
*
|
|
58
|
+
* @example
|
|
59
|
+
* ```typescript
|
|
60
|
+
* import {ConfigurationApp, configurationPlugin} from "@terreno/api";
|
|
61
|
+
*
|
|
62
|
+
* const configSchema = new Schema({
|
|
63
|
+
* general: { type: new Schema({
|
|
64
|
+
* appName: { type: String, description: "App display name", default: "My App" },
|
|
65
|
+
* maintenanceMode: { type: Boolean, description: "Enable maintenance mode", default: false },
|
|
66
|
+
* })},
|
|
67
|
+
* integrations: { type: new Schema({
|
|
68
|
+
* openAiKey: { type: String, description: "OpenAI API key", secret: true, secretName: "openai-key" },
|
|
69
|
+
* })},
|
|
70
|
+
* });
|
|
71
|
+
* configSchema.plugin(configurationPlugin);
|
|
72
|
+
* const AppConfig = mongoose.model("AppConfig", configSchema);
|
|
73
|
+
*
|
|
74
|
+
* new TerrenoApp({ userModel: User })
|
|
75
|
+
* .configure(AppConfig)
|
|
76
|
+
* .start();
|
|
77
|
+
* ```
|
|
78
|
+
*/
|
|
79
|
+
export declare class ConfigurationApp implements TerrenoPlugin {
|
|
80
|
+
private options;
|
|
81
|
+
constructor(options: ConfigurationAppOptions);
|
|
82
|
+
register(app: express.Application): void;
|
|
83
|
+
/**
|
|
84
|
+
* Builds the metadata response by inspecting the model schema.
|
|
85
|
+
* Top-level fields with subschemas become sections.
|
|
86
|
+
* Top-level scalar fields go into a "General" section.
|
|
87
|
+
*/
|
|
88
|
+
private buildMetadata;
|
|
89
|
+
private mongooseTypeToString;
|
|
90
|
+
}
|
|
91
|
+
export {};
|
|
@@ -0,0 +1,407 @@
|
|
|
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
|
+
};
|
|
13
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
14
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
15
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
16
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
17
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
18
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
19
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
20
|
+
});
|
|
21
|
+
};
|
|
22
|
+
var __generator = (this && this.__generator) || function (thisArg, body) {
|
|
23
|
+
var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype);
|
|
24
|
+
return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
|
|
25
|
+
function verb(n) { return function (v) { return step([n, v]); }; }
|
|
26
|
+
function step(op) {
|
|
27
|
+
if (f) throw new TypeError("Generator is already executing.");
|
|
28
|
+
while (g && (g = 0, op[0] && (_ = 0)), _) try {
|
|
29
|
+
if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
|
|
30
|
+
if (y = 0, t) op = [op[0] & 2, t.value];
|
|
31
|
+
switch (op[0]) {
|
|
32
|
+
case 0: case 1: t = op; break;
|
|
33
|
+
case 4: _.label++; return { value: op[1], done: false };
|
|
34
|
+
case 5: _.label++; y = op[1]; op = [0]; continue;
|
|
35
|
+
case 7: op = _.ops.pop(); _.trys.pop(); continue;
|
|
36
|
+
default:
|
|
37
|
+
if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
|
|
38
|
+
if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
|
|
39
|
+
if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
|
|
40
|
+
if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
|
|
41
|
+
if (t[2]) _.ops.pop();
|
|
42
|
+
_.trys.pop(); continue;
|
|
43
|
+
}
|
|
44
|
+
op = body.call(thisArg, _);
|
|
45
|
+
} catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
|
|
46
|
+
if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
var __rest = (this && this.__rest) || function (s, e) {
|
|
50
|
+
var t = {};
|
|
51
|
+
for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0)
|
|
52
|
+
t[p] = s[p];
|
|
53
|
+
if (s != null && typeof Object.getOwnPropertySymbols === "function")
|
|
54
|
+
for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) {
|
|
55
|
+
if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i]))
|
|
56
|
+
t[p[i]] = s[p[i]];
|
|
57
|
+
}
|
|
58
|
+
return t;
|
|
59
|
+
};
|
|
60
|
+
var __values = (this && this.__values) || function(o) {
|
|
61
|
+
var s = typeof Symbol === "function" && Symbol.iterator, m = s && o[s], i = 0;
|
|
62
|
+
if (m) return m.call(o);
|
|
63
|
+
if (o && typeof o.length === "number") return {
|
|
64
|
+
next: function () {
|
|
65
|
+
if (o && i >= o.length) o = void 0;
|
|
66
|
+
return { value: o && o[i++], done: !o };
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
throw new TypeError(s ? "Object is not iterable." : "Symbol.iterator is not defined.");
|
|
70
|
+
};
|
|
71
|
+
var __read = (this && this.__read) || function (o, n) {
|
|
72
|
+
var m = typeof Symbol === "function" && o[Symbol.iterator];
|
|
73
|
+
if (!m) return o;
|
|
74
|
+
var i = m.call(o), r, ar = [], e;
|
|
75
|
+
try {
|
|
76
|
+
while ((n === void 0 || n-- > 0) && !(r = i.next()).done) ar.push(r.value);
|
|
77
|
+
}
|
|
78
|
+
catch (error) { e = { error: error }; }
|
|
79
|
+
finally {
|
|
80
|
+
try {
|
|
81
|
+
if (r && !r.done && (m = i["return"])) m.call(i);
|
|
82
|
+
}
|
|
83
|
+
finally { if (e) throw e.error; }
|
|
84
|
+
}
|
|
85
|
+
return ar;
|
|
86
|
+
};
|
|
87
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
88
|
+
exports.ConfigurationApp = void 0;
|
|
89
|
+
var api_1 = require("./api");
|
|
90
|
+
var auth_1 = require("./auth");
|
|
91
|
+
var errors_1 = require("./errors");
|
|
92
|
+
var logger_1 = require("./logger");
|
|
93
|
+
var populate_1 = require("./populate");
|
|
94
|
+
/**
|
|
95
|
+
* Middleware that requires the user to be an admin.
|
|
96
|
+
*/
|
|
97
|
+
var requireAdmin = function (req, _res, next) {
|
|
98
|
+
var _a;
|
|
99
|
+
if (!((_a = req.user) === null || _a === void 0 ? void 0 : _a.admin)) {
|
|
100
|
+
next(new errors_1.APIError({ status: 403, title: "Admin access required" }));
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
next();
|
|
104
|
+
};
|
|
105
|
+
/**
|
|
106
|
+
* Extracts field metadata from an OpenAPI properties object, augmented with
|
|
107
|
+
* secret info from the Mongoose schema.
|
|
108
|
+
*/
|
|
109
|
+
var extractFieldMeta = function (properties, required, schema, prefix, fieldOverrides) {
|
|
110
|
+
var e_1, _a;
|
|
111
|
+
var _b, _c, _d;
|
|
112
|
+
var fields = {};
|
|
113
|
+
try {
|
|
114
|
+
for (var _e = __values(Object.entries(properties)), _f = _e.next(); !_f.done; _f = _e.next()) {
|
|
115
|
+
var _g = __read(_f.value, 2), key = _g[0], prop = _g[1];
|
|
116
|
+
var fullPath = prefix ? "".concat(prefix, ".").concat(key) : key;
|
|
117
|
+
var schemaPath = schema.path(fullPath);
|
|
118
|
+
var opts = schemaPath === null || schemaPath === void 0 ? void 0 : schemaPath.options;
|
|
119
|
+
fields[key] = {
|
|
120
|
+
default: prop.default,
|
|
121
|
+
description: (_b = opts === null || opts === void 0 ? void 0 : opts.description) !== null && _b !== void 0 ? _b : prop.description,
|
|
122
|
+
enum: prop.enum,
|
|
123
|
+
required: required.includes(key),
|
|
124
|
+
secret: (opts === null || opts === void 0 ? void 0 : opts.secret) === true,
|
|
125
|
+
type: (_c = prop.type) !== null && _c !== void 0 ? _c : "string",
|
|
126
|
+
};
|
|
127
|
+
// Apply field overrides
|
|
128
|
+
if ((_d = fieldOverrides === null || fieldOverrides === void 0 ? void 0 : fieldOverrides[fullPath]) === null || _d === void 0 ? void 0 : _d.widget) {
|
|
129
|
+
fields[key].widget = fieldOverrides[fullPath].widget;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
catch (e_1_1) { e_1 = { error: e_1_1 }; }
|
|
134
|
+
finally {
|
|
135
|
+
try {
|
|
136
|
+
if (_f && !_f.done && (_a = _e.return)) _a.call(_e);
|
|
137
|
+
}
|
|
138
|
+
finally { if (e_1) throw e_1.error; }
|
|
139
|
+
}
|
|
140
|
+
return fields;
|
|
141
|
+
};
|
|
142
|
+
/**
|
|
143
|
+
* System fields to skip in configuration sections.
|
|
144
|
+
*/
|
|
145
|
+
var SYSTEM_FIELDS = new Set(["_id", "_singleton", "id", "__v", "created", "updated", "deleted"]);
|
|
146
|
+
var SECRET_REDACTED = "********";
|
|
147
|
+
/**
|
|
148
|
+
* Redacts secret field values in a configuration object.
|
|
149
|
+
* Replaces values at secret paths with a placeholder string.
|
|
150
|
+
*/
|
|
151
|
+
var redactSecrets = function (obj, secretFields) {
|
|
152
|
+
var e_2, _a;
|
|
153
|
+
var redacted = __assign({}, obj);
|
|
154
|
+
try {
|
|
155
|
+
for (var secretFields_1 = __values(secretFields), secretFields_1_1 = secretFields_1.next(); !secretFields_1_1.done; secretFields_1_1 = secretFields_1.next()) {
|
|
156
|
+
var field = secretFields_1_1.value;
|
|
157
|
+
var parts = field.path.split(".");
|
|
158
|
+
var current = redacted;
|
|
159
|
+
for (var i = 0; i < parts.length - 1; i++) {
|
|
160
|
+
if (current[parts[i]] != null && typeof current[parts[i]] === "object") {
|
|
161
|
+
current[parts[i]] = __assign({}, current[parts[i]]);
|
|
162
|
+
current = current[parts[i]];
|
|
163
|
+
}
|
|
164
|
+
else {
|
|
165
|
+
current = null;
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
var lastKey = parts[parts.length - 1];
|
|
170
|
+
if (current != null && current[lastKey] != null && current[lastKey] !== "") {
|
|
171
|
+
current[lastKey] = SECRET_REDACTED;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
catch (e_2_1) { e_2 = { error: e_2_1 }; }
|
|
176
|
+
finally {
|
|
177
|
+
try {
|
|
178
|
+
if (secretFields_1_1 && !secretFields_1_1.done && (_a = secretFields_1.return)) _a.call(secretFields_1);
|
|
179
|
+
}
|
|
180
|
+
finally { if (e_2) throw e_2.error; }
|
|
181
|
+
}
|
|
182
|
+
return redacted;
|
|
183
|
+
};
|
|
184
|
+
/**
|
|
185
|
+
* Converts a camelCase or PascalCase string into a display-friendly title.
|
|
186
|
+
*/
|
|
187
|
+
var toDisplayName = function (name) {
|
|
188
|
+
return name
|
|
189
|
+
.replace(/([A-Z])/g, " $1")
|
|
190
|
+
.replace(/^./, function (s) { return s.toUpperCase(); })
|
|
191
|
+
.trim();
|
|
192
|
+
};
|
|
193
|
+
/**
|
|
194
|
+
* TerrenoPlugin that provides configuration management endpoints.
|
|
195
|
+
*
|
|
196
|
+
* Inspects the Mongoose configuration model to auto-generate:
|
|
197
|
+
* - `GET {basePath}/meta` — Schema metadata (sections, fields, types, descriptions)
|
|
198
|
+
* - `GET {basePath}` — Current configuration values
|
|
199
|
+
* - `PATCH {basePath}` — Update configuration values
|
|
200
|
+
* - `POST {basePath}/refresh-secrets` — Trigger secret refresh (if provider configured)
|
|
201
|
+
*
|
|
202
|
+
* All endpoints require `Permissions.IsAdmin`.
|
|
203
|
+
*
|
|
204
|
+
* Nested subschemas in the model become separate sections in the metadata,
|
|
205
|
+
* making them renderable as cards/accordions in the admin UI.
|
|
206
|
+
*
|
|
207
|
+
* @example
|
|
208
|
+
* ```typescript
|
|
209
|
+
* import {ConfigurationApp, configurationPlugin} from "@terreno/api";
|
|
210
|
+
*
|
|
211
|
+
* const configSchema = new Schema({
|
|
212
|
+
* general: { type: new Schema({
|
|
213
|
+
* appName: { type: String, description: "App display name", default: "My App" },
|
|
214
|
+
* maintenanceMode: { type: Boolean, description: "Enable maintenance mode", default: false },
|
|
215
|
+
* })},
|
|
216
|
+
* integrations: { type: new Schema({
|
|
217
|
+
* openAiKey: { type: String, description: "OpenAI API key", secret: true, secretName: "openai-key" },
|
|
218
|
+
* })},
|
|
219
|
+
* });
|
|
220
|
+
* configSchema.plugin(configurationPlugin);
|
|
221
|
+
* const AppConfig = mongoose.model("AppConfig", configSchema);
|
|
222
|
+
*
|
|
223
|
+
* new TerrenoApp({ userModel: User })
|
|
224
|
+
* .configure(AppConfig)
|
|
225
|
+
* .start();
|
|
226
|
+
* ```
|
|
227
|
+
*/
|
|
228
|
+
var ConfigurationApp = /** @class */ (function () {
|
|
229
|
+
function ConfigurationApp(options) {
|
|
230
|
+
this.options = options;
|
|
231
|
+
}
|
|
232
|
+
ConfigurationApp.prototype.register = function (app) {
|
|
233
|
+
var _this = this;
|
|
234
|
+
var _a, _b, _c, _d;
|
|
235
|
+
var basePath = (_a = this.options.basePath) !== null && _a !== void 0 ? _a : "/configuration";
|
|
236
|
+
var ConfigModel = this.options.model;
|
|
237
|
+
var schema = ConfigModel.schema;
|
|
238
|
+
// Build metadata by inspecting the schema
|
|
239
|
+
var meta = this.buildMetadata(ConfigModel, schema);
|
|
240
|
+
// GET /configuration/meta — schema metadata for the frontend
|
|
241
|
+
app.get("".concat(basePath, "/meta"), (0, auth_1.authenticateMiddleware)(), requireAdmin, function (_req, res) {
|
|
242
|
+
return res.json(meta);
|
|
243
|
+
});
|
|
244
|
+
// Discover secret fields once at registration time
|
|
245
|
+
var secretFields = (_d = (_c = (_b = ConfigModel).getSecretFields) === null || _c === void 0 ? void 0 : _c.call(_b)) !== null && _d !== void 0 ? _d : [];
|
|
246
|
+
// GET /configuration — current values (secrets redacted)
|
|
247
|
+
app.get("".concat(basePath), (0, auth_1.authenticateMiddleware)(), requireAdmin, (0, api_1.asyncHandler)(function (_req, res) { return __awaiter(_this, void 0, void 0, function () {
|
|
248
|
+
var config, data;
|
|
249
|
+
return __generator(this, function (_a) {
|
|
250
|
+
switch (_a.label) {
|
|
251
|
+
case 0: return [4 /*yield*/, ConfigModel.getConfig()];
|
|
252
|
+
case 1:
|
|
253
|
+
config = _a.sent();
|
|
254
|
+
data = redactSecrets(config.toJSON(), secretFields);
|
|
255
|
+
return [2 /*return*/, res.json({ data: data })];
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
}); }));
|
|
259
|
+
// PATCH /configuration — update values (secrets redacted in response)
|
|
260
|
+
app.patch("".concat(basePath), (0, auth_1.authenticateMiddleware)(), requireAdmin, (0, api_1.asyncHandler)(function (req, res) { return __awaiter(_this, void 0, void 0, function () {
|
|
261
|
+
var _a, _s, _i, _v, safeBody, config, data;
|
|
262
|
+
var _b, _c;
|
|
263
|
+
return __generator(this, function (_d) {
|
|
264
|
+
switch (_d.label) {
|
|
265
|
+
case 0:
|
|
266
|
+
_a = req.body, _s = _a._singleton, _i = _a._id, _v = _a.__v, safeBody = __rest(_a, ["_singleton", "_id", "__v"]);
|
|
267
|
+
return [4 /*yield*/, ConfigModel.updateConfig(safeBody)];
|
|
268
|
+
case 1:
|
|
269
|
+
config = _d.sent();
|
|
270
|
+
logger_1.logger.info("Configuration updated by ".concat((_c = (_b = req.user) === null || _b === void 0 ? void 0 : _b.email) !== null && _c !== void 0 ? _c : "unknown"));
|
|
271
|
+
data = redactSecrets(config.toJSON(), secretFields);
|
|
272
|
+
return [2 /*return*/, res.json({ data: data })];
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
}); }));
|
|
276
|
+
// POST /configuration/list-secrets — list secret fields and optionally resolve from provider
|
|
277
|
+
app.post("".concat(basePath, "/list-secrets"), (0, auth_1.authenticateMiddleware)(), requireAdmin, (0, api_1.asyncHandler)(function (_req, res) { return __awaiter(_this, void 0, void 0, function () {
|
|
278
|
+
var resolved, updates, resolved_1, resolved_1_1, _a, path, value;
|
|
279
|
+
var e_3, _b;
|
|
280
|
+
return __generator(this, function (_c) {
|
|
281
|
+
switch (_c.label) {
|
|
282
|
+
case 0: return [4 /*yield*/, ConfigModel.resolveSecrets()];
|
|
283
|
+
case 1:
|
|
284
|
+
resolved = _c.sent();
|
|
285
|
+
if (!(resolved.size > 0)) return [3 /*break*/, 3];
|
|
286
|
+
updates = {};
|
|
287
|
+
try {
|
|
288
|
+
for (resolved_1 = __values(resolved), resolved_1_1 = resolved_1.next(); !resolved_1_1.done; resolved_1_1 = resolved_1.next()) {
|
|
289
|
+
_a = __read(resolved_1_1.value, 2), path = _a[0], value = _a[1];
|
|
290
|
+
updates[path] = value;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
catch (e_3_1) { e_3 = { error: e_3_1 }; }
|
|
294
|
+
finally {
|
|
295
|
+
try {
|
|
296
|
+
if (resolved_1_1 && !resolved_1_1.done && (_b = resolved_1.return)) _b.call(resolved_1);
|
|
297
|
+
}
|
|
298
|
+
finally { if (e_3) throw e_3.error; }
|
|
299
|
+
}
|
|
300
|
+
return [4 /*yield*/, ConfigModel.updateConfig(updates)];
|
|
301
|
+
case 2:
|
|
302
|
+
_c.sent();
|
|
303
|
+
logger_1.logger.info("Refreshed ".concat(resolved.size, "/").concat(secretFields.length, " secrets"));
|
|
304
|
+
_c.label = 3;
|
|
305
|
+
case 3: return [2 /*return*/, res.json({
|
|
306
|
+
message: "Resolved ".concat(resolved.size, "/").concat(secretFields.length, " secrets."),
|
|
307
|
+
resolved: resolved.size,
|
|
308
|
+
secretFields: secretFields.map(function (s) { return ({ path: s.path, secretName: s.secretName }); }),
|
|
309
|
+
total: secretFields.length,
|
|
310
|
+
})];
|
|
311
|
+
}
|
|
312
|
+
});
|
|
313
|
+
}); }));
|
|
314
|
+
logger_1.logger.info("Configuration routes mounted at ".concat(basePath));
|
|
315
|
+
};
|
|
316
|
+
/**
|
|
317
|
+
* Builds the metadata response by inspecting the model schema.
|
|
318
|
+
* Top-level fields with subschemas become sections.
|
|
319
|
+
* Top-level scalar fields go into a "General" section.
|
|
320
|
+
*/
|
|
321
|
+
ConfigurationApp.prototype.buildMetadata = function (_model, schema) {
|
|
322
|
+
var _this = this;
|
|
323
|
+
var sections = [];
|
|
324
|
+
var generalFields = {};
|
|
325
|
+
// Walk top-level paths
|
|
326
|
+
schema.eachPath(function (pathName, schemaType) {
|
|
327
|
+
var e_4, _a;
|
|
328
|
+
var _b, _c;
|
|
329
|
+
if (SYSTEM_FIELDS.has(pathName)) {
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
var subSchema = schemaType.schema;
|
|
333
|
+
if (subSchema) {
|
|
334
|
+
// This is a nested subschema — make it a section
|
|
335
|
+
var _d = (0, populate_1.getOpenApiSpecForModel)({
|
|
336
|
+
modelName: pathName,
|
|
337
|
+
schema: subSchema,
|
|
338
|
+
}), properties = _d.properties, required = _d.required;
|
|
339
|
+
// Filter out system fields from the subschema too
|
|
340
|
+
var filteredProperties = {};
|
|
341
|
+
var filteredRequired = [];
|
|
342
|
+
try {
|
|
343
|
+
for (var _e = __values(Object.entries(properties)), _f = _e.next(); !_f.done; _f = _e.next()) {
|
|
344
|
+
var _g = __read(_f.value, 2), key = _g[0], val = _g[1];
|
|
345
|
+
if (!SYSTEM_FIELDS.has(key)) {
|
|
346
|
+
filteredProperties[key] = val;
|
|
347
|
+
if (required.includes(key)) {
|
|
348
|
+
filteredRequired.push(key);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
catch (e_4_1) { e_4 = { error: e_4_1 }; }
|
|
354
|
+
finally {
|
|
355
|
+
try {
|
|
356
|
+
if (_f && !_f.done && (_a = _e.return)) _a.call(_e);
|
|
357
|
+
}
|
|
358
|
+
finally { if (e_4) throw e_4.error; }
|
|
359
|
+
}
|
|
360
|
+
var sectionFields = extractFieldMeta(filteredProperties, filteredRequired, schema, pathName, _this.options.fieldOverrides);
|
|
361
|
+
// Get description from the parent path options
|
|
362
|
+
var opts = schemaType.options;
|
|
363
|
+
sections.push({
|
|
364
|
+
description: opts === null || opts === void 0 ? void 0 : opts.description,
|
|
365
|
+
displayName: toDisplayName(pathName),
|
|
366
|
+
fields: sectionFields,
|
|
367
|
+
name: pathName,
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
else {
|
|
371
|
+
// Scalar top-level field — goes into "General" section
|
|
372
|
+
var opts = schemaType.options;
|
|
373
|
+
var fullPath = pathName;
|
|
374
|
+
generalFields[pathName] = {
|
|
375
|
+
default: opts === null || opts === void 0 ? void 0 : opts.default,
|
|
376
|
+
description: opts === null || opts === void 0 ? void 0 : opts.description,
|
|
377
|
+
enum: opts === null || opts === void 0 ? void 0 : opts.enum,
|
|
378
|
+
required: (opts === null || opts === void 0 ? void 0 : opts.required) === true,
|
|
379
|
+
secret: (opts === null || opts === void 0 ? void 0 : opts.secret) === true,
|
|
380
|
+
type: _this.mongooseTypeToString(schemaType),
|
|
381
|
+
};
|
|
382
|
+
if ((_c = (_b = _this.options.fieldOverrides) === null || _b === void 0 ? void 0 : _b[fullPath]) === null || _c === void 0 ? void 0 : _c.widget) {
|
|
383
|
+
generalFields[pathName].widget = _this.options.fieldOverrides[fullPath].widget;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
});
|
|
387
|
+
// Add general fields section if there are any
|
|
388
|
+
if (Object.keys(generalFields).length > 0) {
|
|
389
|
+
sections.unshift({
|
|
390
|
+
displayName: "General",
|
|
391
|
+
fields: generalFields,
|
|
392
|
+
name: "__root__",
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
return { sections: sections };
|
|
396
|
+
};
|
|
397
|
+
ConfigurationApp.prototype.mongooseTypeToString = function (schemaType) {
|
|
398
|
+
var _a;
|
|
399
|
+
var instance = (_a = schemaType.instance) === null || _a === void 0 ? void 0 : _a.toLowerCase();
|
|
400
|
+
if (instance === "objectid") {
|
|
401
|
+
return "string";
|
|
402
|
+
}
|
|
403
|
+
return instance !== null && instance !== void 0 ? instance : "string";
|
|
404
|
+
};
|
|
405
|
+
return ConfigurationApp;
|
|
406
|
+
}());
|
|
407
|
+
exports.ConfigurationApp = ConfigurationApp;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { Document, Model, Schema } from "mongoose";
|
|
2
|
+
/**
|
|
3
|
+
* Metadata for a secret field discovered by the configuration plugin.
|
|
4
|
+
*/
|
|
5
|
+
export interface SecretFieldMeta {
|
|
6
|
+
path: string;
|
|
7
|
+
secretProvider?: string;
|
|
8
|
+
secretName: string;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Interface for adapters that resolve secret values from external providers.
|
|
12
|
+
*/
|
|
13
|
+
export interface SecretProvider {
|
|
14
|
+
name: string;
|
|
15
|
+
getSecret(secretName: string): Promise<string | null>;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Options passed to configurationPlugin.
|
|
19
|
+
*/
|
|
20
|
+
export interface ConfigurationPluginOptions {
|
|
21
|
+
/**
|
|
22
|
+
* Secret provider used when resolveSecrets() is called without an explicit provider.
|
|
23
|
+
* Typically set during app startup so the model can resolve secrets on demand.
|
|
24
|
+
*/
|
|
25
|
+
secretProvider?: SecretProvider;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* All dot-notation paths for a type T.
|
|
29
|
+
* @example Paths<{a: {b: string}; c: number}> = "a" | "a.b" | "c"
|
|
30
|
+
*/
|
|
31
|
+
export type Paths<T extends object> = {
|
|
32
|
+
[K in keyof T & string]: T[K] extends object ? K | `${K}.${Paths<T[K]>}` : K;
|
|
33
|
+
}[keyof T & string];
|
|
34
|
+
/**
|
|
35
|
+
* The value type at a dot-notation path P within type T.
|
|
36
|
+
* @example PathValue<{a: {b: string}}, "a.b"> = string
|
|
37
|
+
*/
|
|
38
|
+
export type PathValue<T, P extends string> = P extends `${infer K}.${infer Rest}` ? K extends keyof T ? PathValue<NonNullable<T[K]>, Rest> : never : P extends keyof T ? T[P] : never;
|
|
39
|
+
/**
|
|
40
|
+
* Deeply partial version of T, for use in updateConfig.
|
|
41
|
+
*/
|
|
42
|
+
export type DeepPartial<T> = {
|
|
43
|
+
[K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* Static methods added by configurationPlugin to the Mongoose model.
|
|
47
|
+
*/
|
|
48
|
+
export interface ConfigurationStatics<T extends object> {
|
|
49
|
+
/** Get the full singleton configuration document. */
|
|
50
|
+
getConfig(): Promise<T & Document>;
|
|
51
|
+
/** Get a specific value by dot-notation key. */
|
|
52
|
+
getConfig<P extends Paths<T>>(key: P): Promise<PathValue<T, P>>;
|
|
53
|
+
/** Update the singleton configuration document (deep merge). */
|
|
54
|
+
updateConfig(updates: DeepPartial<T>): Promise<T & Document>;
|
|
55
|
+
/** Get secret field metadata discovered from the schema. */
|
|
56
|
+
getSecretFields(): SecretFieldMeta[];
|
|
57
|
+
/**
|
|
58
|
+
* Resolve all secret field values from a provider.
|
|
59
|
+
* Uses the provider passed here, or falls back to the one configured in the plugin options.
|
|
60
|
+
* Returns a map of path -> value.
|
|
61
|
+
*/
|
|
62
|
+
resolveSecrets(provider?: SecretProvider): Promise<Map<string, string>>;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Convenience type for a Mongoose model with configurationPlugin applied.
|
|
66
|
+
*
|
|
67
|
+
* Use this when declaring your configuration model to get full type safety:
|
|
68
|
+
* ```typescript
|
|
69
|
+
* export const AppConfig = mongoose.model<AppConfigDocument, ConfigurationModel<AppConfigDocument>>(
|
|
70
|
+
* "AppConfig",
|
|
71
|
+
* appConfigSchema,
|
|
72
|
+
* );
|
|
73
|
+
* // Then call:
|
|
74
|
+
* const name = await AppConfig.getConfig("general.appName"); // typed as string
|
|
75
|
+
* const full = await AppConfig.getConfig(); // typed as AppConfigDocument
|
|
76
|
+
* ```
|
|
77
|
+
*/
|
|
78
|
+
export type ConfigurationModel<T extends object> = Model<T> & ConfigurationStatics<T>;
|
|
79
|
+
/**
|
|
80
|
+
* Mongoose schema plugin that adds singleton configuration behavior.
|
|
81
|
+
*
|
|
82
|
+
* Adds:
|
|
83
|
+
* - Pre-save hook enforcing exactly one document
|
|
84
|
+
* - `getConfig()` static: fetches or creates the singleton (full doc or keyed value)
|
|
85
|
+
* - `updateConfig(updates)` static: patches the singleton
|
|
86
|
+
* - `getSecretFields()` static: returns metadata for fields with `secret: true`
|
|
87
|
+
* - `resolveSecrets(provider?)` static: fetches secret values, using the plugin provider by default
|
|
88
|
+
*
|
|
89
|
+
* Mark fields as secrets using schema path options:
|
|
90
|
+
* ```typescript
|
|
91
|
+
* const configSchema = new Schema({
|
|
92
|
+
* apiKey: {
|
|
93
|
+
* type: String,
|
|
94
|
+
* description: "Third-party API key",
|
|
95
|
+
* secret: true,
|
|
96
|
+
* secretName: "my-api-key",
|
|
97
|
+
* },
|
|
98
|
+
* });
|
|
99
|
+
* configSchema.plugin(configurationPlugin, {secretProvider: new EnvSecretProvider()});
|
|
100
|
+
* ```
|
|
101
|
+
*/
|
|
102
|
+
export declare const configurationPlugin: (schema: Schema, options?: ConfigurationPluginOptions) => void;
|