@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
package/src/api.test.ts CHANGED
@@ -2010,9 +2010,13 @@ describe("@terreno/api", () => {
2010
2010
  });
2011
2011
 
2012
2012
  it("returns 409 when precise conflict timestamp is older than doc.updated", async () => {
2013
+ const ifUnmodifiedSince = DateTime.fromISO("2025-06-15T12:00:01.000Z").toHTTP();
2014
+ if (ifUnmodifiedSince === null) {
2015
+ throw new Error("expected HTTP If-Unmodified-Since value");
2016
+ }
2013
2017
  await agent
2014
2018
  .patch(`/food/${spinach._id}`)
2015
- .set("If-Unmodified-Since", DateTime.fromISO("2025-06-15T12:00:01.000Z").toHTTP()!)
2019
+ .set("If-Unmodified-Since", ifUnmodifiedSince)
2016
2020
  .set("X-Unmodified-Since-ISO", "2025-06-15T11:59:59.500Z")
2017
2021
  .send({name: "Precise Stale"})
2018
2022
  .expect(409);
@@ -2024,9 +2028,13 @@ describe("@terreno/api", () => {
2024
2028
  {$unset: {updated: ""}}
2025
2029
  );
2026
2030
 
2031
+ const ifUnmodifiedSince = DateTime.fromISO("2025-06-15T11:59:59.999Z").toHTTP();
2032
+ if (ifUnmodifiedSince === null) {
2033
+ throw new Error("expected HTTP If-Unmodified-Since value");
2034
+ }
2027
2035
  const res = await agent
2028
2036
  .patch(`/food/${spinach._id}`)
2029
- .set("If-Unmodified-Since", DateTime.fromISO("2025-06-15T11:59:59.999Z").toHTTP()!)
2037
+ .set("If-Unmodified-Since", ifUnmodifiedSince)
2030
2038
  .send({name: "Created Fallback"})
2031
2039
  .expect(409);
2032
2040
 
package/src/auth.ts CHANGED
@@ -54,7 +54,7 @@ export interface GenerateTokensOptions {
54
54
  sessionId?: string;
55
55
  }
56
56
 
57
- export function authenticateMiddleware(anonymous = false) {
57
+ export const authenticateMiddleware = (anonymous = false) => {
58
58
  const strategies = ["jwt"];
59
59
  if (anonymous) {
60
60
  strategies.push("anonymous");
@@ -70,14 +70,14 @@ export function authenticateMiddleware(anonymous = false) {
70
70
  }
71
71
  return passportAuth(req, res, next);
72
72
  };
73
- }
73
+ };
74
74
 
75
- export async function signupUser(
75
+ export const signupUser = async (
76
76
  userModel: UserModel,
77
77
  email: string,
78
78
  password: string,
79
79
  body?: Record<string, unknown>
80
- ) {
80
+ ) => {
81
81
  // Strip email and password from the body. They can cause mongoose to throw an error if strict is
82
82
  // set.
83
83
  const {email: _email, password: _password, ...bodyRest} = body ?? {};
@@ -100,7 +100,7 @@ export async function signupUser(
100
100
  const message = errorMessage(error);
101
101
  throw new APIError({title: message});
102
102
  }
103
- }
103
+ };
104
104
 
105
105
  /**
106
106
  * Generates both an access token (JWT) and a refresh token for a given user.
@@ -126,7 +126,7 @@ export const generateTokens = async (
126
126
  ) => {
127
127
  const tokenSecretOrKey = process.env.TOKEN_SECRET;
128
128
  if (!tokenSecretOrKey) {
129
- throw new Error("TOKEN_SECRET must be set in env.");
129
+ throw new APIError({status: 500, title: "TOKEN_SECRET must be set in env."});
130
130
  }
131
131
  const tokenUser = user as {_id?: ObjectId | string} | null | undefined;
132
132
  if (!tokenUser?._id) {
@@ -186,7 +186,7 @@ export const generateTokens = async (
186
186
  };
187
187
 
188
188
  // TODO allow customization
189
- export function setupAuth(app: express.Application, userModel: UserModel) {
189
+ export const setupAuth = (app: express.Application, userModel: UserModel): void => {
190
190
  passport.use(new AnonymousStrategy());
191
191
  passport.use(userModel.createStrategy());
192
192
  passport.use(
@@ -208,7 +208,7 @@ export function setupAuth(app: express.Application, userModel: UserModel) {
208
208
  );
209
209
 
210
210
  if (!userModel.createStrategy) {
211
- throw new Error("setupAuth userModel must have .createStrategy()");
211
+ throw new APIError({status: 500, title: "setupAuth userModel must have .createStrategy()"});
212
212
  }
213
213
 
214
214
  const customTokenExtractor: JwtFromRequestFunction = (req) => {
@@ -228,7 +228,7 @@ export function setupAuth(app: express.Application, userModel: UserModel) {
228
228
 
229
229
  const secretOrKey = process.env.TOKEN_SECRET;
230
230
  if (!secretOrKey) {
231
- throw new Error("TOKEN_SECRET must be set in env.");
231
+ throw new APIError({status: 500, title: "TOKEN_SECRET must be set in env."});
232
232
  }
233
233
  const jwtOpts: StrategyOptions = {
234
234
  issuer: process.env.TOKEN_ISSUER,
@@ -264,11 +264,11 @@ export function setupAuth(app: express.Application, userModel: UserModel) {
264
264
 
265
265
  // Adds req.user to the request. This may wind up duplicating requests with passport,
266
266
  // but passport doesn't give us req.user early enough.
267
- async function decodeJWTMiddleware(
267
+ const decodeJWTMiddleware = async (
268
268
  req: express.Request,
269
269
  res: express.Response,
270
270
  next: express.NextFunction
271
- ) {
271
+ ) => {
272
272
  if (!process.env.TOKEN_SECRET) {
273
273
  return next();
274
274
  }
@@ -324,17 +324,17 @@ export function setupAuth(app: express.Application, userModel: UserModel) {
324
324
  }
325
325
  }
326
326
  return next();
327
- }
327
+ };
328
328
  app.use(decodeJWTMiddleware);
329
329
  // biome-ignore lint/suspicious/noExplicitAny: express 5 type for urlencoded doesn't match RequestHandler
330
330
  app.use(express.urlencoded({extended: false}) as any);
331
- }
331
+ };
332
332
 
333
- export function addAuthRoutes(
333
+ export const addAuthRoutes = (
334
334
  app: express.Application,
335
335
  userModel: UserModel,
336
336
  authOptions?: AuthOptions
337
- ): void {
337
+ ): void => {
338
338
  const router = express.Router();
339
339
  router.post("/login", async (req, res, next) => {
340
340
  passport.authenticate(
@@ -430,13 +430,13 @@ export function addAuthRoutes(
430
430
  }
431
431
  app.set("etag", false);
432
432
  app.use("/auth", router);
433
- }
433
+ };
434
434
 
435
- export function addMeRoutes(
435
+ export const addMeRoutes = (
436
436
  app: express.Application,
437
437
  userModel: UserModel,
438
438
  _authOptions?: AuthOptions
439
- ): void {
439
+ ): void => {
440
440
  const router = express.Router();
441
441
  router.get("/me", authenticateMiddleware(), async (req, res) => {
442
442
  if (!req.user?.id) {
@@ -483,4 +483,4 @@ export function addMeRoutes(
483
483
  app.set("etag", false);
484
484
  app.use("/auth", router);
485
485
  app.use(apiErrorMiddleware);
486
- }
486
+ };
@@ -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
+ });