@terreno/api 0.18.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.
Files changed (41) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/dist/api.test.js +18 -8
  3. package/dist/auth.d.ts +5 -5
  4. package/dist/auth.js +123 -131
  5. package/dist/configuration.test.js +289 -10
  6. package/dist/configurationApp.d.ts +72 -5
  7. package/dist/configurationApp.js +168 -48
  8. package/dist/configurationPlugin.d.ts +64 -7
  9. package/dist/configurationPlugin.js +161 -39
  10. package/dist/configurationPlugin.test.js +238 -1
  11. package/dist/expressServer.test.js +0 -1
  12. package/dist/openApi.d.ts +6 -6
  13. package/dist/openApi.js +21 -21
  14. package/dist/populate.test.js +23 -0
  15. package/dist/realtime/queryMatcher.js +0 -6
  16. package/dist/realtime/queryStore.js +3 -11
  17. package/dist/realtime/realtime.test.js +41 -34
  18. package/dist/secretProviders.d.ts +79 -2
  19. package/dist/secretProviders.js +177 -9
  20. package/dist/secretProviders.test.d.ts +1 -0
  21. package/dist/secretProviders.test.js +391 -0
  22. package/package.json +1 -1
  23. package/src/actions.openApi.test.ts +1 -1
  24. package/src/actions.ts +0 -1
  25. package/src/api.test.ts +10 -2
  26. package/src/auth.ts +19 -19
  27. package/src/configuration.test.ts +171 -7
  28. package/src/configurationApp.ts +213 -30
  29. package/src/configurationPlugin.test.ts +174 -2
  30. package/src/configurationPlugin.ts +157 -28
  31. package/src/expressServer.test.ts +0 -1
  32. package/src/openApi.ts +21 -21
  33. package/src/populate.test.ts +25 -0
  34. package/src/realtime/queryMatcher.ts +0 -6
  35. package/src/realtime/queryStore.ts +1 -10
  36. package/src/realtime/realtime.test.ts +24 -24
  37. package/src/realtime/realtimeApp.ts +0 -1
  38. package/src/realtime/registry.ts +0 -1
  39. package/src/realtime/types.ts +0 -4
  40. package/src/secretProviders.test.ts +186 -0
  41. package/src/secretProviders.ts +145 -5
@@ -90,9 +90,11 @@ var api_1 = require("./api");
90
90
  var auth_1 = require("./auth");
91
91
  var errors_1 = require("./errors");
92
92
  var logger_1 = require("./logger");
93
+ var permissions_1 = require("./permissions");
93
94
  var populate_1 = require("./populate");
94
95
  /**
95
- * Middleware that requires the user to be an admin.
96
+ * Middleware that requires the user to be an admin. Used as the default guard
97
+ * for every configuration route when no custom `permissions` are supplied.
96
98
  */
97
99
  var requireAdmin = function (req, _res, next) {
98
100
  var _a;
@@ -102,6 +104,43 @@ var requireAdmin = function (req, _res, next) {
102
104
  }
103
105
  next();
104
106
  };
107
+ /**
108
+ * Builds an Express middleware that AND-combines terreno permission functions
109
+ * (the same {@link PermissionMethod} contract used by `modelRouter`). The
110
+ * configuration singleton has no per-object ownership, so the loaded config
111
+ * document is passed as the permission object.
112
+ */
113
+ var buildPermissionMiddleware = function (perms, method, loadObj) {
114
+ return (0, api_1.asyncHandler)(function (req, _res, next) { return __awaiter(void 0, void 0, void 0, function () {
115
+ var obj, _a, allowed;
116
+ return __generator(this, function (_b) {
117
+ switch (_b.label) {
118
+ case 0:
119
+ if (!loadObj) return [3 /*break*/, 2];
120
+ return [4 /*yield*/, loadObj()];
121
+ case 1:
122
+ _a = _b.sent();
123
+ return [3 /*break*/, 3];
124
+ case 2:
125
+ _a = undefined;
126
+ _b.label = 3;
127
+ case 3:
128
+ obj = _a;
129
+ return [4 /*yield*/, (0, permissions_1.checkPermissions)(method, perms, req.user, obj)];
130
+ case 4:
131
+ allowed = _b.sent();
132
+ if (!allowed) {
133
+ throw new errors_1.APIError({
134
+ status: 403,
135
+ title: "Access to configuration denied",
136
+ });
137
+ }
138
+ next();
139
+ return [2 /*return*/];
140
+ }
141
+ });
142
+ }); });
143
+ };
105
144
  var extractFieldMeta = function (properties, required, schema, prefix, fieldOverrides) {
106
145
  var e_1, _a;
107
146
  var _b, _c, _d;
@@ -183,6 +222,49 @@ var redactSecrets = function (obj, secretFields) {
183
222
  }
184
223
  return redacted;
185
224
  };
225
+ /**
226
+ * Removes secret field values from an incoming update body so a secret value can
227
+ * never be written to the configuration document through the update path.
228
+ * Copies nodes along each secret path to avoid mutating the caller's object.
229
+ */
230
+ var stripSecretFields = function (obj, secretFields) {
231
+ var e_3, _a;
232
+ var stripped = __assign({}, obj);
233
+ try {
234
+ for (var secretFields_2 = __values(secretFields), secretFields_2_1 = secretFields_2.next(); !secretFields_2_1.done; secretFields_2_1 = secretFields_2.next()) {
235
+ var field = secretFields_2_1.value;
236
+ var parts = field.path.split(".");
237
+ var current = stripped;
238
+ for (var i = 0; i < parts.length - 1; i++) {
239
+ if (!current) {
240
+ break;
241
+ }
242
+ var part = parts[i];
243
+ var nested = current[part];
244
+ if (nested != null && typeof nested === "object") {
245
+ var copy = __assign({}, nested);
246
+ current[part] = copy;
247
+ current = copy;
248
+ }
249
+ else {
250
+ current = null;
251
+ break;
252
+ }
253
+ }
254
+ if (current != null) {
255
+ delete current[parts[parts.length - 1]];
256
+ }
257
+ }
258
+ }
259
+ catch (e_3_1) { e_3 = { error: e_3_1 }; }
260
+ finally {
261
+ try {
262
+ if (secretFields_2_1 && !secretFields_2_1.done && (_a = secretFields_2.return)) _a.call(secretFields_2);
263
+ }
264
+ finally { if (e_3) throw e_3.error; }
265
+ }
266
+ return stripped;
267
+ };
186
268
  /**
187
269
  * Converts a camelCase or PascalCase string into a display-friendly title.
188
270
  */
@@ -197,11 +279,21 @@ var toDisplayName = function (name) {
197
279
  *
198
280
  * Inspects the Mongoose configuration model to auto-generate:
199
281
  * - `GET {basePath}/meta` — Schema metadata (sections, fields, types, descriptions)
200
- * - `GET {basePath}` — Current configuration values
201
- * - `PATCH {basePath}` — Update configuration values
202
- * - `POST {basePath}/refresh-secrets` — Trigger secret refresh (if provider configured)
282
+ * - `GET {basePath}` — Current configuration values (secret values redacted)
283
+ * - `PATCH {basePath}` — Update configuration values (secret fields stripped; never written)
284
+ * - `POST {basePath}/list-secrets` (alias `POST {basePath}/validate-secrets`)
285
+ * Read-only status of each secret field (whether the provider can resolve it).
286
+ * This endpoint never resolves values into the document and returns no secret values.
287
+ *
288
+ * By default all endpoints require `Permissions.IsAdmin`. Supply `permissions`
289
+ * to gate routes with a consumer's own permission functions, and `preUpdate`/
290
+ * `postUpdate` hooks to validate and audit-log changes. This makes
291
+ * `ConfigurationApp` suitable as a single, consumer-owned configuration surface
292
+ * that can replace a bespoke config router.
203
293
  *
204
- * All endpoints require `Permissions.IsAdmin`.
294
+ * Secret values never touch the database, logs, audit payloads, or API
295
+ * responses: secret fields are stripped from incoming updates and redacted on
296
+ * every read.
205
297
  *
206
298
  * Nested subschemas in the model become separate sections in the metadata,
207
299
  * making them renderable as cards/accordions in the admin UI.
@@ -223,7 +315,10 @@ var toDisplayName = function (name) {
223
315
  * const AppConfig = mongoose.model("AppConfig", configSchema);
224
316
  *
225
317
  * new TerrenoApp({ userModel: User })
226
- * .configure(AppConfig)
318
+ * .configure(AppConfig, {
319
+ * permissions: {read: [IsStaff], update: [IsSuperUser]},
320
+ * postUpdate: (config, prevValue, req) => auditLog(req.user, prevValue, config),
321
+ * })
227
322
  * .start();
228
323
  * ```
229
324
  */
@@ -231,6 +326,18 @@ var ConfigurationApp = /** @class */ (function () {
231
326
  function ConfigurationApp(options) {
232
327
  this.options = options;
233
328
  }
329
+ /**
330
+ * Resolves the guard middleware for a route: the consumer's terreno permission
331
+ * functions when supplied, otherwise the default admin-only guard.
332
+ */
333
+ ConfigurationApp.prototype.guardFor = function (route, method) {
334
+ var _a;
335
+ var perms = (_a = this.options.permissions) === null || _a === void 0 ? void 0 : _a[route];
336
+ if (perms && perms.length > 0) {
337
+ return buildPermissionMiddleware(perms, method);
338
+ }
339
+ return requireAdmin;
340
+ };
234
341
  ConfigurationApp.prototype.register = function (app) {
235
342
  var _this = this;
236
343
  var _a, _b, _c;
@@ -240,14 +347,14 @@ var ConfigurationApp = /** @class */ (function () {
240
347
  // Build metadata by inspecting the schema
241
348
  var meta = this.buildMetadata(ConfigModel, schema);
242
349
  // GET /configuration/meta — schema metadata for the frontend
243
- app.get("".concat(basePath, "/meta"), (0, auth_1.authenticateMiddleware)(), requireAdmin, function (_req, res) {
350
+ app.get("".concat(basePath, "/meta"), (0, auth_1.authenticateMiddleware)(), this.guardFor("meta", "read"), function (_req, res) {
244
351
  return res.json(meta);
245
352
  });
246
353
  var ConfigStatics = ConfigModel;
247
354
  // Discover secret fields once at registration time
248
355
  var secretFields = (_c = (_b = ConfigStatics.getSecretFields) === null || _b === void 0 ? void 0 : _b.call(ConfigStatics)) !== null && _c !== void 0 ? _c : [];
249
356
  // GET /configuration — current values (secrets redacted)
250
- app.get("".concat(basePath), (0, auth_1.authenticateMiddleware)(), requireAdmin, (0, api_1.asyncHandler)(function (_req, res) { return __awaiter(_this, void 0, void 0, function () {
357
+ app.get("".concat(basePath), (0, auth_1.authenticateMiddleware)(), this.guardFor("read", "read"), (0, api_1.asyncHandler)(function (_req, res) { return __awaiter(_this, void 0, void 0, function () {
251
358
  var config, data;
252
359
  return __generator(this, function (_a) {
253
360
  switch (_a.label) {
@@ -259,61 +366,74 @@ var ConfigurationApp = /** @class */ (function () {
259
366
  }
260
367
  });
261
368
  }); }));
262
- // PATCH /configuration — update values (secrets redacted in response)
263
- app.patch("".concat(basePath), (0, auth_1.authenticateMiddleware)(), requireAdmin, (0, api_1.asyncHandler)(function (req, res) { return __awaiter(_this, void 0, void 0, function () {
264
- var _a, _s, _i, _v, safeBody, config, data;
369
+ // PATCH /configuration — update values (secret fields stripped; secrets redacted in response)
370
+ app.patch("".concat(basePath), (0, auth_1.authenticateMiddleware)(), this.guardFor("update", "update"), (0, api_1.asyncHandler)(function (req, res) { return __awaiter(_this, void 0, void 0, function () {
371
+ var _a, _s, _i, _v, rest, safeBody, prevValue, before, config, data;
265
372
  var _b, _c;
266
373
  return __generator(this, function (_d) {
267
374
  switch (_d.label) {
268
375
  case 0:
269
- _a = req.body, _s = _a._singleton, _i = _a._id, _v = _a.__v, safeBody = __rest(_a, ["_singleton", "_id", "__v"]);
270
- return [4 /*yield*/, ConfigStatics.updateConfig(safeBody)];
376
+ _a = req.body, _s = _a._singleton, _i = _a._id, _v = _a.__v, rest = __rest(_a, ["_singleton", "_id", "__v"]);
377
+ safeBody = stripSecretFields(rest, secretFields);
378
+ if (!this.options.preUpdate) return [3 /*break*/, 2];
379
+ return [4 /*yield*/, this.options.preUpdate(safeBody, req)];
271
380
  case 1:
381
+ safeBody = _d.sent();
382
+ // Re-strip after the hook: preUpdate receives the raw request and could
383
+ // otherwise (re)introduce secret paths. Secrets must never persist here.
384
+ safeBody = stripSecretFields(safeBody, secretFields);
385
+ _d.label = 2;
386
+ case 2:
387
+ prevValue = {};
388
+ if (!this.options.postUpdate) return [3 /*break*/, 4];
389
+ return [4 /*yield*/, ConfigStatics.getConfig()];
390
+ case 3:
391
+ before = _d.sent();
392
+ prevValue = redactSecrets(before.toJSON(), secretFields);
393
+ _d.label = 4;
394
+ case 4: return [4 /*yield*/, ConfigStatics.updateConfig(safeBody)];
395
+ case 5:
272
396
  config = _d.sent();
273
397
  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"));
274
398
  data = redactSecrets(config.toJSON(), secretFields);
275
- return [2 /*return*/, res.json({ data: data })];
399
+ if (!this.options.postUpdate) return [3 /*break*/, 7];
400
+ return [4 /*yield*/, this.options.postUpdate(data, prevValue, req)];
401
+ case 6:
402
+ _d.sent();
403
+ _d.label = 7;
404
+ case 7: return [2 /*return*/, res.json({ data: data })];
276
405
  }
277
406
  });
278
407
  }); }));
279
- // POST /configuration/list-secrets — list secret fields and optionally resolve from provider
280
- 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 () {
281
- var resolved, updates, resolved_1, resolved_1_1, _a, path, value;
282
- var e_3, _b;
283
- return __generator(this, function (_c) {
284
- switch (_c.label) {
408
+ // POST /configuration/list-secrets — read-only status of each secret field.
409
+ // Never resolves values into the document and never returns secret values.
410
+ var validateSecretsHandler = (0, api_1.asyncHandler)(function (_req, res) { return __awaiter(_this, void 0, void 0, function () {
411
+ var resolved, fields;
412
+ return __generator(this, function (_a) {
413
+ switch (_a.label) {
285
414
  case 0: return [4 /*yield*/, ConfigStatics.resolveSecrets()];
286
415
  case 1:
287
- resolved = _c.sent();
288
- if (!(resolved.size > 0)) return [3 /*break*/, 3];
289
- updates = {};
290
- try {
291
- for (resolved_1 = __values(resolved), resolved_1_1 = resolved_1.next(); !resolved_1_1.done; resolved_1_1 = resolved_1.next()) {
292
- _a = __read(resolved_1_1.value, 2), path = _a[0], value = _a[1];
293
- updates[path] = value;
294
- }
295
- }
296
- catch (e_3_1) { e_3 = { error: e_3_1 }; }
297
- finally {
298
- try {
299
- if (resolved_1_1 && !resolved_1_1.done && (_b = resolved_1.return)) _b.call(resolved_1);
300
- }
301
- finally { if (e_3) throw e_3.error; }
302
- }
303
- return [4 /*yield*/, ConfigStatics.updateConfig(updates)];
304
- case 2:
305
- _c.sent();
306
- logger_1.logger.info("Refreshed ".concat(resolved.size, "/").concat(secretFields.length, " secrets"));
307
- _c.label = 3;
308
- case 3: return [2 /*return*/, res.json({
309
- message: "Resolved ".concat(resolved.size, "/").concat(secretFields.length, " secrets."),
310
- resolved: resolved.size,
311
- secretFields: secretFields.map(function (s) { return ({ path: s.path, secretName: s.secretName }); }),
312
- total: secretFields.length,
313
- })];
416
+ resolved = _a.sent();
417
+ fields = secretFields.map(function (s) { return ({
418
+ isConfigured: resolved.has(s.path),
419
+ path: s.path,
420
+ resolvable: resolved.has(s.path),
421
+ secretName: s.secretName,
422
+ version: s.version,
423
+ }); });
424
+ logger_1.logger.info("Validated ".concat(resolved.size, "/").concat(secretFields.length, " secrets (read-only)"));
425
+ return [2 /*return*/, res.json({
426
+ message: "".concat(resolved.size, "/").concat(secretFields.length, " secrets resolvable."),
427
+ resolved: resolved.size,
428
+ secretFields: fields,
429
+ total: secretFields.length,
430
+ })];
314
431
  }
315
432
  });
316
- }); }));
433
+ }); });
434
+ app.post("".concat(basePath, "/list-secrets"), (0, auth_1.authenticateMiddleware)(), this.guardFor("listSecrets", "read"), validateSecretsHandler);
435
+ // Accurate alias for the read-only validation semantics.
436
+ app.post("".concat(basePath, "/validate-secrets"), (0, auth_1.authenticateMiddleware)(), this.guardFor("listSecrets", "read"), validateSecretsHandler);
317
437
  logger_1.logger.info("Configuration routes mounted at ".concat(basePath));
318
438
  };
319
439
  /**
@@ -6,13 +6,27 @@ export interface SecretFieldMeta {
6
6
  path: string;
7
7
  secretProvider?: string;
8
8
  secretName: string;
9
+ /**
10
+ * Optional secret version to pin resolution to. When omitted the provider
11
+ * resolves the latest version. Discovered from the `secretVersion` schema
12
+ * path option.
13
+ */
14
+ version?: string;
9
15
  }
10
16
  /**
11
17
  * Interface for adapters that resolve secret values from external providers.
12
18
  */
13
19
  export interface SecretProvider {
14
20
  name: string;
15
- getSecret(secretName: string): Promise<string | null>;
21
+ /**
22
+ * Resolve a secret value by name. Returns `null` when the secret is not found.
23
+ *
24
+ * @param secretName - The secret identifier (short name or provider-specific path).
25
+ * @param version - Optional version to pin resolution to. Providers that do not
26
+ * support versioning (e.g. environment variables) ignore this parameter. When
27
+ * omitted, the latest version is resolved.
28
+ */
29
+ getSecret(secretName: string, version?: string): Promise<string | null>;
16
30
  }
17
31
  /**
18
32
  * Options passed to configurationPlugin.
@@ -23,6 +37,18 @@ export interface ConfigurationPluginOptions {
23
37
  * Typically set during app startup so the model can resolve secrets on demand.
24
38
  */
25
39
  secretProvider?: SecretProvider;
40
+ /**
41
+ * When `true`, adds a `_singleton` sentinel field with a unique index to
42
+ * enforce the singleton constraint at the database level.
43
+ *
44
+ * Defaults to `false`. Leave this off when the consuming app already enforces
45
+ * a single non-deleted document via the pre-save guard (the default behavior)
46
+ * or via its own indexes/soft-delete plugin, to avoid double-enforcement and
47
+ * conflicting indexes.
48
+ *
49
+ * @defaultValue false
50
+ */
51
+ enforceSingletonIndex?: boolean;
26
52
  }
27
53
  /**
28
54
  * All dot-notation paths for a type T.
@@ -50,14 +76,26 @@ export interface ConfigurationStatics<T extends object> {
50
76
  getConfig(): Promise<T & Document>;
51
77
  /** Get a specific value by dot-notation key. */
52
78
  getConfig<P extends Paths<T>>(key: P): Promise<PathValue<T, P>>;
53
- /** Update the singleton configuration document (deep merge). */
79
+ /**
80
+ * Update the singleton configuration document.
81
+ *
82
+ * The patch is flattened into MongoDB dotted paths and applied with
83
+ * `findOneAndUpdate({$set})`. This preserves sibling fields inside nested
84
+ * subdocuments when a partial nested patch is supplied, and tolerates legacy /
85
+ * out-of-schema fields already persisted on the document (unlike a full
86
+ * `doc.save()`, which throws under `strict: "throw"`).
87
+ */
54
88
  updateConfig(updates: DeepPartial<T>): Promise<T & Document>;
55
89
  /** Get secret field metadata discovered from the schema. */
56
90
  getSecretFields(): SecretFieldMeta[];
57
91
  /**
58
92
  * Resolve all secret field values from a provider.
59
93
  * Uses the provider passed here, or falls back to the one configured in the plugin options.
60
- * Returns a map of path -> value.
94
+ * Returns an **in-memory** map of path -> value for programmatic use (startup
95
+ * self-checks, request-time resolution).
96
+ *
97
+ * This method never persists resolved values. Secret material must never be
98
+ * written to the configuration document.
61
99
  */
62
100
  resolveSecrets(provider?: SecretProvider): Promise<Map<string, string>>;
63
101
  }
@@ -77,17 +115,35 @@ export interface ConfigurationStatics<T extends object> {
77
115
  */
78
116
  export interface ConfigurationModel<T extends object> extends Model<T>, ConfigurationStatics<T> {
79
117
  }
118
+ /**
119
+ * Flattens a nested patch into MongoDB-style dotted paths, recursing into plain
120
+ * objects only; arrays and other values are treated as leaves.
121
+ *
122
+ * @example
123
+ * flattenToDotPaths({a: {b: 1}}) // => [["a.b", 1]]
124
+ */
125
+ export declare const flattenToDotPaths: (obj: Record<string, unknown>, prefix?: string) => Array<[string, unknown]>;
80
126
  /**
81
127
  * Mongoose schema plugin that adds singleton configuration behavior.
82
128
  *
83
129
  * Adds:
84
- * - Pre-save hook enforcing exactly one document
130
+ * - Pre-save hook enforcing exactly one non-deleted document (soft-delete aware
131
+ * when the schema has a `deleted` path, e.g. via `isDeletedPlugin`)
85
132
  * - `getConfig()` static: fetches or creates the singleton (full doc or keyed value)
86
- * - `updateConfig(updates)` static: patches the singleton
133
+ * - `updateConfig(updates)` static: patches the singleton via `findOneAndUpdate({$set})`
134
+ * with dotted paths (preserves sibling subdoc fields; tolerates legacy fields)
87
135
  * - `getSecretFields()` static: returns metadata for fields with `secret: true`
88
- * - `resolveSecrets(provider?)` static: fetches secret values, using the plugin provider by default
136
+ * - `resolveSecrets(provider?)` static: resolves secret values into an in-memory map,
137
+ * using the plugin provider by default (never persists values)
138
+ * - Hard-delete blockers (`deleteOne`/`deleteMany`/`findOneAndDelete`); soft deletes
139
+ * (setting `deleted: true`) are allowed
140
+ *
141
+ * Soft deletes are allowed and a soft-deleted document does not block creating a
142
+ * new singleton. The `_singleton` unique index is opt-in via
143
+ * `enforceSingletonIndex` (default off).
89
144
  *
90
- * Mark fields as secrets using schema path options:
145
+ * Mark fields as secrets using schema path options. Pin a version with the
146
+ * optional `secretVersion` option:
91
147
  * ```typescript
92
148
  * const configSchema = new Schema({
93
149
  * apiKey: {
@@ -95,6 +151,7 @@ export interface ConfigurationModel<T extends object> extends Model<T>, Configur
95
151
  * description: "Third-party API key",
96
152
  * secret: true,
97
153
  * secretName: "my-api-key",
154
+ * secretVersion: "3", // optional — resolves "latest" when omitted
98
155
  * },
99
156
  * });
100
157
  * configSchema.plugin(configurationPlugin, {secretProvider: new EnvSecretProvider()});