@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.
- package/CHANGELOG.md +25 -0
- package/dist/api.test.js +18 -8
- package/dist/auth.d.ts +5 -5
- package/dist/auth.js +123 -131
- package/dist/configuration.test.js +289 -10
- package/dist/configurationApp.d.ts +72 -5
- package/dist/configurationApp.js +168 -48
- package/dist/configurationPlugin.d.ts +64 -7
- package/dist/configurationPlugin.js +161 -39
- package/dist/configurationPlugin.test.js +238 -1
- package/dist/expressServer.test.js +0 -1
- package/dist/openApi.d.ts +6 -6
- package/dist/openApi.js +21 -21
- package/dist/populate.test.js +23 -0
- package/dist/realtime/queryMatcher.js +0 -6
- package/dist/realtime/queryStore.js +3 -11
- package/dist/realtime/realtime.test.js +41 -34
- package/dist/secretProviders.d.ts +79 -2
- package/dist/secretProviders.js +177 -9
- package/dist/secretProviders.test.d.ts +1 -0
- package/dist/secretProviders.test.js +391 -0
- package/package.json +1 -1
- package/src/actions.openApi.test.ts +1 -1
- package/src/actions.ts +0 -1
- package/src/api.test.ts +10 -2
- package/src/auth.ts +19 -19
- package/src/configuration.test.ts +171 -7
- package/src/configurationApp.ts +213 -30
- package/src/configurationPlugin.test.ts +174 -2
- package/src/configurationPlugin.ts +157 -28
- package/src/expressServer.test.ts +0 -1
- package/src/openApi.ts +21 -21
- package/src/populate.test.ts +25 -0
- package/src/realtime/queryMatcher.ts +0 -6
- package/src/realtime/queryStore.ts +1 -10
- package/src/realtime/realtime.test.ts +24 -24
- package/src/realtime/realtimeApp.ts +0 -1
- package/src/realtime/registry.ts +0 -1
- package/src/realtime/types.ts +0 -4
- package/src/secretProviders.test.ts +186 -0
- 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",
|
|
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",
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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?:
|
|
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("
|
|
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
|
-
|
|
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
|
+
});
|