@terreno/api 0.20.1 → 0.21.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 (65) hide show
  1. package/.ai/guidelines/core.md +71 -0
  2. package/.ai/skills/mongoose-schema-safety/SKILL.md +143 -0
  3. package/README.md +54 -1
  4. package/dist/__tests__/versionCheckPlugin.test.js +29 -7
  5. package/dist/actions.openApi.test.js +13 -11
  6. package/dist/api.js +98 -11
  7. package/dist/api.query.test.js +31 -1
  8. package/dist/api.test.js +211 -0
  9. package/dist/auth.test.js +10 -10
  10. package/dist/betterAuth.d.ts +1 -1
  11. package/dist/consentApp.test.js +1 -0
  12. package/dist/example.js +4 -4
  13. package/dist/expressServer.d.ts +0 -22
  14. package/dist/expressServer.js +1 -125
  15. package/dist/expressServer.test.js +90 -91
  16. package/dist/githubAuth.test.js +22 -22
  17. package/dist/logger.d.ts +154 -0
  18. package/dist/logger.js +445 -26
  19. package/dist/logger.test.js +435 -0
  20. package/dist/middleware.d.ts +7 -0
  21. package/dist/middleware.js +58 -1
  22. package/dist/middleware.test.js +159 -0
  23. package/dist/openApi.test.js +10 -17
  24. package/dist/openApiBuilder.test.js +18 -10
  25. package/dist/realtime/changeStreamWatcher.d.ts +4 -4
  26. package/dist/realtime/changeStreamWatcher.js +2 -4
  27. package/dist/realtime/queryMatcher.d.ts +1 -1
  28. package/dist/realtime/queryMatcher.js +39 -14
  29. package/dist/realtime/types.d.ts +3 -3
  30. package/dist/requestContext.d.ts +61 -0
  31. package/dist/requestContext.js +74 -0
  32. package/dist/secretProviders.test.js +335 -0
  33. package/dist/terrenoApp.d.ts +27 -15
  34. package/dist/terrenoApp.js +24 -14
  35. package/dist/terrenoApp.test.js +52 -0
  36. package/dist/tests/bunSetup.js +61 -7
  37. package/dist/tests.js +27 -4
  38. package/package.json +1 -1
  39. package/src/__tests__/versionCheckPlugin.test.ts +43 -15
  40. package/src/actions.openApi.test.ts +12 -10
  41. package/src/api.query.test.ts +24 -1
  42. package/src/api.test.ts +169 -0
  43. package/src/api.ts +71 -0
  44. package/src/auth.test.ts +10 -10
  45. package/src/betterAuth.ts +1 -1
  46. package/src/consentApp.test.ts +1 -0
  47. package/src/example.ts +4 -4
  48. package/src/expressServer.test.ts +82 -85
  49. package/src/expressServer.ts +1 -213
  50. package/src/githubAuth.test.ts +22 -22
  51. package/src/logger.test.ts +466 -1
  52. package/src/logger.ts +477 -14
  53. package/src/middleware.test.ts +74 -2
  54. package/src/middleware.ts +57 -0
  55. package/src/openApi.test.ts +10 -17
  56. package/src/openApiBuilder.test.ts +18 -10
  57. package/src/realtime/changeStreamWatcher.ts +15 -10
  58. package/src/realtime/queryMatcher.ts +54 -27
  59. package/src/realtime/types.ts +4 -4
  60. package/src/requestContext.ts +86 -0
  61. package/src/secretProviders.test.ts +219 -1
  62. package/src/terrenoApp.test.ts +38 -0
  63. package/src/terrenoApp.ts +37 -15
  64. package/src/tests/bunSetup.ts +16 -3
  65. package/src/tests.ts +17 -4
@@ -72,6 +72,31 @@ var __values = (this && this.__values) || function(o) {
72
72
  };
73
73
  Object.defineProperty(exports, "__esModule", { value: true });
74
74
  exports.requestContextMiddleware = exports.updateRequestContextFromRequest = exports.runWithRequestContextAttributes = exports.runWithRequestContext = exports.getCurrentRequestContextAttributes = exports.setRequestContext = exports.applyRequestContextToSentry = exports.getCurrentLogContext = exports.getCurrentRequestContext = exports.getRequestContextFromAttributes = exports.getSessionIdFromJwtPayload = exports.REQUEST_CONTEXT_ATTRIBUTE_NAMES = void 0;
75
+ /**
76
+ * Request/job correlation for `@terreno/api`.
77
+ *
78
+ * Correlation is how every log line emitted while handling one request (or one background job) can
79
+ * be tied back together. It is built on Node's {@link AsyncLocalStorage}: a {@link RequestContext}
80
+ * (with `requestId`, `userId`, `traceId`, etc.) is stored for the duration of a callback, and the
81
+ * logger's Winston format reads it from there and merges it into each line. Nothing needs to be
82
+ * threaded through function arguments.
83
+ *
84
+ * Two ways a scope is established:
85
+ *
86
+ * - **HTTP**: {@link requestContextMiddleware} runs first in the middleware stack. It derives a
87
+ * `requestId` from incoming headers ({@link REQUEST_CONTEXT_ATTRIBUTE_NAMES}, `x-correlation-id`,
88
+ * Cloud Trace, or W3C `traceparent`) or generates one, echoes it back as `X-Request-ID`, and runs
89
+ * the rest of the request inside the scope.
90
+ * - **Jobs/scripts**: {@link runWithRequestContext} (or {@link runWithRequestContextAttributes})
91
+ * establishes the same scope manually so background work is just as traceable.
92
+ *
93
+ * The active context is also pushed to Sentry tags/context via {@link applyRequestContextToSentry},
94
+ * and is exposed to logging via {@link getCurrentLogContext} / {@link getCurrentRequestContext}.
95
+ *
96
+ * @see {@link runWithRequestContext}
97
+ * @see {@link getCurrentLogContext}
98
+ * @module requestContext
99
+ */
75
100
  var node_async_hooks_1 = require("node:async_hooks");
76
101
  var node_crypto_1 = require("node:crypto");
77
102
  var Sentry = __importStar(require("@sentry/bun"));
@@ -84,6 +109,11 @@ var TRACE_ID_HEADER = "x-trace-id";
84
109
  var TRACE_PARENT_HEADER = "traceparent";
85
110
  var TRACE_SAMPLED_HEADER = "x-trace-sampled";
86
111
  var USER_ID_HEADER = "x-user-id";
112
+ /**
113
+ * Canonical HTTP header names for each correlation field. Use these to propagate context to
114
+ * downstream services (pair with {@link getCurrentRequestContextAttributes}) or to read it from an
115
+ * incoming request (pair with {@link getRequestContextFromAttributes}).
116
+ */
87
117
  exports.REQUEST_CONTEXT_ATTRIBUTE_NAMES = {
88
118
  jobId: JOB_ID_HEADER,
89
119
  requestId: "x-request-id",
@@ -193,10 +223,19 @@ var getRequestContextFromAttributes = function (attributes) {
193
223
  };
194
224
  };
195
225
  exports.getRequestContextFromAttributes = getRequestContextFromAttributes;
226
+ /**
227
+ * Returns the full {@link RequestContext} for the active AsyncLocalStorage scope, or `undefined`
228
+ * when called outside any request/job scope. The logger uses this to enrich each line.
229
+ */
196
230
  var getCurrentRequestContext = function () {
197
231
  return requestContextStorage.getStore();
198
232
  };
199
233
  exports.getCurrentRequestContext = getCurrentRequestContext;
234
+ /**
235
+ * Returns the active correlation fields as a plain object (empty when outside a scope). This is the
236
+ * shape attached to Sentry log attributes and is handy when you need to log or forward the current
237
+ * context yourself.
238
+ */
200
239
  var getCurrentLogContext = function () {
201
240
  var context = (0, exports.getCurrentRequestContext)();
202
241
  if (!context) {
@@ -266,6 +305,11 @@ var setAttribute = function (attributes, name, value) {
266
305
  }
267
306
  attributes[name] = String(value);
268
307
  };
308
+ /**
309
+ * Serializes the active correlation context into HTTP header attributes (keyed by
310
+ * {@link REQUEST_CONTEXT_ATTRIBUTE_NAMES}) so it can be propagated on outbound requests to other
311
+ * services, keeping the same `requestId`/`traceId` across service boundaries.
312
+ */
269
313
  var getCurrentRequestContextAttributes = function (overrides) {
270
314
  if (overrides === void 0) { overrides = {}; }
271
315
  var context = __assign(__assign({}, (0, exports.getCurrentLogContext)()), overrides);
@@ -280,6 +324,23 @@ var getCurrentRequestContextAttributes = function (overrides) {
280
324
  return attributes;
281
325
  };
282
326
  exports.getCurrentRequestContextAttributes = getCurrentRequestContextAttributes;
327
+ /**
328
+ * Runs `callback` inside a fresh correlation scope so every log line it emits shares the same
329
+ * identifiers — the manual equivalent of {@link requestContextMiddleware} for background jobs,
330
+ * cron tasks, scripts, queue consumers, etc. A `requestId` is generated when not supplied, and the
331
+ * context is mirrored to Sentry.
332
+ *
333
+ * @example
334
+ * ```typescript
335
+ * import {createScopedLogger, runWithRequestContext} from "@terreno/api";
336
+ *
337
+ * await runWithRequestContext({jobId: "nightly-sync"}, async () => {
338
+ * const log = createScopedLogger({prefix: "[NightlySync]"});
339
+ * log.info("started"); // includes jobId + a generated requestId on every line
340
+ * await sync();
341
+ * });
342
+ * ```
343
+ */
283
344
  var runWithRequestContext = function (context, callback) {
284
345
  var _a;
285
346
  var nextContext = __assign(__assign({}, context), { requestId: (_a = context.requestId) !== null && _a !== void 0 ? _a : (0, node_crypto_1.randomUUID)() });
@@ -289,6 +350,11 @@ var runWithRequestContext = function (context, callback) {
289
350
  });
290
351
  };
291
352
  exports.runWithRequestContext = runWithRequestContext;
353
+ /**
354
+ * Like {@link runWithRequestContext}, but seeds the scope from raw header attributes (for example
355
+ * those received on an incoming message or forwarded by another service). Parses Cloud Trace / W3C
356
+ * `traceparent` into `traceId`/`spanId` via {@link getRequestContextFromAttributes}.
357
+ */
292
358
  var runWithRequestContextAttributes = function (attributes, callback) {
293
359
  if (attributes === void 0) { attributes = {}; }
294
360
  return (0, exports.runWithRequestContext)((0, exports.getRequestContextFromAttributes)(attributes), callback);
@@ -312,6 +378,14 @@ var updateRequestContextFromRequest = function (req, res) {
312
378
  }
313
379
  };
314
380
  exports.updateRequestContextFromRequest = updateRequestContextFromRequest;
381
+ /**
382
+ * Express middleware that opens a correlation scope for the request. Mounted early by `TerrenoApp` /
383
+ * `setupServer`, it resolves a `requestId` (from request-id/correlation headers, Cloud Trace, or
384
+ * W3C `traceparent`, else a new UUID), captures any `jobId`/`sessionId`/trace fields, echoes
385
+ * `X-Request-ID` back to the client, and runs the remaining middleware inside the scope so all
386
+ * downstream logs are correlated. A later auth-aware pass ({@link updateRequestContextFromRequest})
387
+ * fills in `userId`/`sessionId`.
388
+ */
315
389
  var requestContextMiddleware = function (req, res, next) {
316
390
  var cloudTraceContext = parseGoogleCloudTraceContext(getHeader(req, CLOUD_TRACE_CONTEXT_HEADER));
317
391
  var traceParentContext = parseTraceParent(getHeader(req, TRACE_PARENT_HEADER));
@@ -37,6 +37,7 @@ var __generator = (this && this.__generator) || function (thisArg, body) {
37
37
  };
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
39
  var bun_test_1 = require("bun:test");
40
+ var errors_1 = require("./errors");
40
41
  var secretProviders_1 = require("./secretProviders");
41
42
  (0, bun_test_1.describe)("EnvSecretProvider", function () {
42
43
  (0, bun_test_1.beforeEach)(function () {
@@ -389,3 +390,337 @@ var secretProviders_1 = require("./secretProviders");
389
390
  });
390
391
  }); });
391
392
  });
393
+ /** Inject a pre-built mock client into a GcpSecretProvider, bypassing getClient(). */
394
+ var injectClient = function (provider, client) {
395
+ // Bypass the private `client` field for testing — avoids the dynamic import of
396
+ // @google-cloud/secret-manager which is an optional peer dependency.
397
+ Object.defineProperty(provider, "client", { configurable: true, value: client, writable: true });
398
+ };
399
+ (0, bun_test_1.describe)("GcpSecretProvider", function () {
400
+ (0, bun_test_1.it)("has the name 'gcp'", function () {
401
+ var provider = new secretProviders_1.GcpSecretProvider({ projectId: "my-project" });
402
+ (0, bun_test_1.expect)(provider.name).toBe("gcp");
403
+ });
404
+ (0, bun_test_1.it)("throws APIError when @google-cloud/secret-manager is not installed", function () { return __awaiter(void 0, void 0, void 0, function () {
405
+ var provider, error_1;
406
+ return __generator(this, function (_a) {
407
+ switch (_a.label) {
408
+ case 0:
409
+ provider = new secretProviders_1.GcpSecretProvider({ projectId: "my-project" });
410
+ _a.label = 1;
411
+ case 1:
412
+ _a.trys.push([1, 3, , 4]);
413
+ return [4 /*yield*/, provider.getSecret("some-secret")];
414
+ case 2:
415
+ _a.sent();
416
+ bun_test_1.expect.unreachable("should have thrown");
417
+ return [3 /*break*/, 4];
418
+ case 3:
419
+ error_1 = _a.sent();
420
+ (0, bun_test_1.expect)(error_1).toBeInstanceOf(errors_1.APIError);
421
+ (0, bun_test_1.expect)(error_1.title).toContain("GcpSecretProvider requires @google-cloud/secret-manager");
422
+ return [3 /*break*/, 4];
423
+ case 4: return [2 /*return*/];
424
+ }
425
+ });
426
+ }); });
427
+ (0, bun_test_1.it)("resolves a short secret name to the full resource path with default version", function () { return __awaiter(void 0, void 0, void 0, function () {
428
+ var calls, mockClient, provider, result;
429
+ return __generator(this, function (_a) {
430
+ switch (_a.label) {
431
+ case 0:
432
+ calls = [];
433
+ mockClient = {
434
+ accessSecretVersion: function (req) { return __awaiter(void 0, void 0, void 0, function () {
435
+ return __generator(this, function (_a) {
436
+ calls.push(req.name);
437
+ return [2 /*return*/, [{ payload: { data: "secret-value" } }]];
438
+ });
439
+ }); },
440
+ };
441
+ provider = new secretProviders_1.GcpSecretProvider({ projectId: "my-project" });
442
+ injectClient(provider, mockClient);
443
+ return [4 /*yield*/, provider.getSecret("openai-api-key")];
444
+ case 1:
445
+ result = _a.sent();
446
+ (0, bun_test_1.expect)(result).toBe("secret-value");
447
+ (0, bun_test_1.expect)(calls).toEqual(["projects/my-project/secrets/openai-api-key/versions/latest"]);
448
+ return [2 /*return*/];
449
+ }
450
+ });
451
+ }); });
452
+ (0, bun_test_1.it)("resolves a short secret name with an explicit version", function () { return __awaiter(void 0, void 0, void 0, function () {
453
+ var calls, mockClient, provider, result;
454
+ return __generator(this, function (_a) {
455
+ switch (_a.label) {
456
+ case 0:
457
+ calls = [];
458
+ mockClient = {
459
+ accessSecretVersion: function (req) { return __awaiter(void 0, void 0, void 0, function () {
460
+ return __generator(this, function (_a) {
461
+ calls.push(req.name);
462
+ return [2 /*return*/, [{ payload: { data: "v3-value" } }]];
463
+ });
464
+ }); },
465
+ };
466
+ provider = new secretProviders_1.GcpSecretProvider({ projectId: "p" });
467
+ injectClient(provider, mockClient);
468
+ return [4 /*yield*/, provider.getSecret("my-key", "3")];
469
+ case 1:
470
+ result = _a.sent();
471
+ (0, bun_test_1.expect)(result).toBe("v3-value");
472
+ (0, bun_test_1.expect)(calls).toEqual(["projects/p/secrets/my-key/versions/3"]);
473
+ return [2 /*return*/];
474
+ }
475
+ });
476
+ }); });
477
+ (0, bun_test_1.it)("honors a full resource path that already contains /versions/", function () { return __awaiter(void 0, void 0, void 0, function () {
478
+ var calls, mockClient, provider, result;
479
+ return __generator(this, function (_a) {
480
+ switch (_a.label) {
481
+ case 0:
482
+ calls = [];
483
+ mockClient = {
484
+ accessSecretVersion: function (req) { return __awaiter(void 0, void 0, void 0, function () {
485
+ return __generator(this, function (_a) {
486
+ calls.push(req.name);
487
+ return [2 /*return*/, [{ payload: { data: "pinned" } }]];
488
+ });
489
+ }); },
490
+ };
491
+ provider = new secretProviders_1.GcpSecretProvider({ projectId: "ignored" });
492
+ injectClient(provider, mockClient);
493
+ return [4 /*yield*/, provider.getSecret("projects/p/secrets/s/versions/7")];
494
+ case 1:
495
+ result = _a.sent();
496
+ (0, bun_test_1.expect)(result).toBe("pinned");
497
+ (0, bun_test_1.expect)(calls).toEqual(["projects/p/secrets/s/versions/7"]);
498
+ return [2 /*return*/];
499
+ }
500
+ });
501
+ }); });
502
+ (0, bun_test_1.it)("appends /versions/latest to a full resource path without a version suffix", function () { return __awaiter(void 0, void 0, void 0, function () {
503
+ var calls, mockClient, provider, result;
504
+ return __generator(this, function (_a) {
505
+ switch (_a.label) {
506
+ case 0:
507
+ calls = [];
508
+ mockClient = {
509
+ accessSecretVersion: function (req) { return __awaiter(void 0, void 0, void 0, function () {
510
+ return __generator(this, function (_a) {
511
+ calls.push(req.name);
512
+ return [2 /*return*/, [{ payload: { data: "latest-value" } }]];
513
+ });
514
+ }); },
515
+ };
516
+ provider = new secretProviders_1.GcpSecretProvider({ projectId: "ignored" });
517
+ injectClient(provider, mockClient);
518
+ return [4 /*yield*/, provider.getSecret("projects/p/secrets/s")];
519
+ case 1:
520
+ result = _a.sent();
521
+ (0, bun_test_1.expect)(result).toBe("latest-value");
522
+ (0, bun_test_1.expect)(calls).toEqual(["projects/p/secrets/s/versions/latest"]);
523
+ return [2 /*return*/];
524
+ }
525
+ });
526
+ }); });
527
+ (0, bun_test_1.it)("appends the explicit version when full path lacks /versions/", function () { return __awaiter(void 0, void 0, void 0, function () {
528
+ var calls, mockClient, provider, result;
529
+ return __generator(this, function (_a) {
530
+ switch (_a.label) {
531
+ case 0:
532
+ calls = [];
533
+ mockClient = {
534
+ accessSecretVersion: function (req) { return __awaiter(void 0, void 0, void 0, function () {
535
+ return __generator(this, function (_a) {
536
+ calls.push(req.name);
537
+ return [2 /*return*/, [{ payload: { data: "v5" } }]];
538
+ });
539
+ }); },
540
+ };
541
+ provider = new secretProviders_1.GcpSecretProvider({ projectId: "ignored" });
542
+ injectClient(provider, mockClient);
543
+ return [4 /*yield*/, provider.getSecret("projects/p/secrets/s", "5")];
544
+ case 1:
545
+ result = _a.sent();
546
+ (0, bun_test_1.expect)(result).toBe("v5");
547
+ (0, bun_test_1.expect)(calls).toEqual(["projects/p/secrets/s/versions/5"]);
548
+ return [2 /*return*/];
549
+ }
550
+ });
551
+ }); });
552
+ (0, bun_test_1.it)("decodes a Uint8Array payload", function () { return __awaiter(void 0, void 0, void 0, function () {
553
+ var encoded, mockClient, provider, _a;
554
+ return __generator(this, function (_b) {
555
+ switch (_b.label) {
556
+ case 0:
557
+ encoded = new TextEncoder().encode("binary-secret");
558
+ mockClient = {
559
+ accessSecretVersion: function () { return __awaiter(void 0, void 0, void 0, function () { return __generator(this, function (_a) {
560
+ return [2 /*return*/, [{ payload: { data: encoded } }]];
561
+ }); }); },
562
+ };
563
+ provider = new secretProviders_1.GcpSecretProvider({ projectId: "p" });
564
+ injectClient(provider, mockClient);
565
+ _a = bun_test_1.expect;
566
+ return [4 /*yield*/, provider.getSecret("bin-key")];
567
+ case 1:
568
+ _a.apply(void 0, [_b.sent()]).toBe("binary-secret");
569
+ return [2 /*return*/];
570
+ }
571
+ });
572
+ }); });
573
+ (0, bun_test_1.it)("returns null when the payload is empty", function () { return __awaiter(void 0, void 0, void 0, function () {
574
+ var mockClient, provider, _a;
575
+ return __generator(this, function (_b) {
576
+ switch (_b.label) {
577
+ case 0:
578
+ mockClient = {
579
+ accessSecretVersion: function () { return __awaiter(void 0, void 0, void 0, function () { return __generator(this, function (_a) {
580
+ return [2 /*return*/, [{ payload: {} }]];
581
+ }); }); },
582
+ };
583
+ provider = new secretProviders_1.GcpSecretProvider({ projectId: "p" });
584
+ injectClient(provider, mockClient);
585
+ _a = bun_test_1.expect;
586
+ return [4 /*yield*/, provider.getSecret("empty-payload")];
587
+ case 1:
588
+ _a.apply(void 0, [_b.sent()]).toBeNull();
589
+ return [2 /*return*/];
590
+ }
591
+ });
592
+ }); });
593
+ (0, bun_test_1.it)("returns null when the payload field is missing entirely", function () { return __awaiter(void 0, void 0, void 0, function () {
594
+ var mockClient, provider, _a;
595
+ return __generator(this, function (_b) {
596
+ switch (_b.label) {
597
+ case 0:
598
+ mockClient = {
599
+ accessSecretVersion: function () { return __awaiter(void 0, void 0, void 0, function () { return __generator(this, function (_a) {
600
+ return [2 /*return*/, [{}]];
601
+ }); }); },
602
+ };
603
+ provider = new secretProviders_1.GcpSecretProvider({ projectId: "p" });
604
+ injectClient(provider, mockClient);
605
+ _a = bun_test_1.expect;
606
+ return [4 /*yield*/, provider.getSecret("no-payload")];
607
+ case 1:
608
+ _a.apply(void 0, [_b.sent()]).toBeNull();
609
+ return [2 /*return*/];
610
+ }
611
+ });
612
+ }); });
613
+ (0, bun_test_1.it)("returns null on NOT_FOUND (gRPC code 5)", function () { return __awaiter(void 0, void 0, void 0, function () {
614
+ var notFound, mockClient, provider, _a;
615
+ return __generator(this, function (_b) {
616
+ switch (_b.label) {
617
+ case 0:
618
+ notFound = Object.assign(new Error("NOT_FOUND"), { code: 5 });
619
+ mockClient = {
620
+ accessSecretVersion: function () { return __awaiter(void 0, void 0, void 0, function () {
621
+ return __generator(this, function (_a) {
622
+ throw notFound;
623
+ });
624
+ }); },
625
+ };
626
+ provider = new secretProviders_1.GcpSecretProvider({ projectId: "p" });
627
+ injectClient(provider, mockClient);
628
+ _a = bun_test_1.expect;
629
+ return [4 /*yield*/, provider.getSecret("missing-secret")];
630
+ case 1:
631
+ _a.apply(void 0, [_b.sent()]).toBeNull();
632
+ return [2 /*return*/];
633
+ }
634
+ });
635
+ }); });
636
+ (0, bun_test_1.it)("re-throws non-NOT_FOUND errors", function () { return __awaiter(void 0, void 0, void 0, function () {
637
+ var permissionDenied, mockClient, provider, error_2;
638
+ return __generator(this, function (_a) {
639
+ switch (_a.label) {
640
+ case 0:
641
+ permissionDenied = Object.assign(new Error("PERMISSION_DENIED"), { code: 7 });
642
+ mockClient = {
643
+ accessSecretVersion: function () { return __awaiter(void 0, void 0, void 0, function () {
644
+ return __generator(this, function (_a) {
645
+ throw permissionDenied;
646
+ });
647
+ }); },
648
+ };
649
+ provider = new secretProviders_1.GcpSecretProvider({ projectId: "p" });
650
+ injectClient(provider, mockClient);
651
+ _a.label = 1;
652
+ case 1:
653
+ _a.trys.push([1, 3, , 4]);
654
+ return [4 /*yield*/, provider.getSecret("forbidden-secret")];
655
+ case 2:
656
+ _a.sent();
657
+ bun_test_1.expect.unreachable("should have thrown");
658
+ return [3 /*break*/, 4];
659
+ case 3:
660
+ error_2 = _a.sent();
661
+ (0, bun_test_1.expect)(error_2).toBe(permissionDenied);
662
+ return [3 /*break*/, 4];
663
+ case 4: return [2 /*return*/];
664
+ }
665
+ });
666
+ }); });
667
+ (0, bun_test_1.it)("re-throws non-Error throwables", function () { return __awaiter(void 0, void 0, void 0, function () {
668
+ var mockClient, provider, error_3;
669
+ return __generator(this, function (_a) {
670
+ switch (_a.label) {
671
+ case 0:
672
+ mockClient = {
673
+ accessSecretVersion: function () { return __awaiter(void 0, void 0, void 0, function () {
674
+ return __generator(this, function (_a) {
675
+ throw "string-error";
676
+ });
677
+ }); },
678
+ };
679
+ provider = new secretProviders_1.GcpSecretProvider({ projectId: "p" });
680
+ injectClient(provider, mockClient);
681
+ _a.label = 1;
682
+ case 1:
683
+ _a.trys.push([1, 3, , 4]);
684
+ return [4 /*yield*/, provider.getSecret("x")];
685
+ case 2:
686
+ _a.sent();
687
+ bun_test_1.expect.unreachable("should have thrown");
688
+ return [3 /*break*/, 4];
689
+ case 3:
690
+ error_3 = _a.sent();
691
+ (0, bun_test_1.expect)(error_3).toBe("string-error");
692
+ return [3 /*break*/, 4];
693
+ case 4: return [2 /*return*/];
694
+ }
695
+ });
696
+ }); });
697
+ (0, bun_test_1.it)("caches the client across multiple getSecret calls", function () { return __awaiter(void 0, void 0, void 0, function () {
698
+ var callCount, mockClient, provider, _a, _b;
699
+ return __generator(this, function (_c) {
700
+ switch (_c.label) {
701
+ case 0:
702
+ callCount = 0;
703
+ mockClient = {
704
+ accessSecretVersion: function () { return __awaiter(void 0, void 0, void 0, function () {
705
+ return __generator(this, function (_a) {
706
+ callCount++;
707
+ return [2 /*return*/, [{ payload: { data: "call-".concat(callCount) } }]];
708
+ });
709
+ }); },
710
+ };
711
+ provider = new secretProviders_1.GcpSecretProvider({ projectId: "p" });
712
+ injectClient(provider, mockClient);
713
+ _a = bun_test_1.expect;
714
+ return [4 /*yield*/, provider.getSecret("a")];
715
+ case 1:
716
+ _a.apply(void 0, [_c.sent()]).toBe("call-1");
717
+ _b = bun_test_1.expect;
718
+ return [4 /*yield*/, provider.getSecret("b")];
719
+ case 2:
720
+ _b.apply(void 0, [_c.sent()]).toBe("call-2");
721
+ (0, bun_test_1.expect)(callCount).toBe(2);
722
+ return [2 /*return*/];
723
+ }
724
+ });
725
+ }); });
726
+ });
@@ -3,7 +3,7 @@ import express from "express";
3
3
  import type { ModelRouterRegistration } from "./api";
4
4
  import { type UserModel as UserMongooseModel } from "./auth";
5
5
  import { type ConfigurationAppOptions } from "./configurationApp";
6
- import { type AuthOptions } from "./expressServer";
6
+ import { type AddRoutes, type AuthOptions } from "./expressServer";
7
7
  import { type GitHubAuthOptions } from "./githubAuth";
8
8
  import { type LoggingOptions } from "./logger";
9
9
  import type { RealtimeAppOptions } from "./realtime/types";
@@ -38,27 +38,40 @@ export interface TerrenoAppOptions {
38
38
  * Set to `true` for defaults, or pass a RealtimeAppOptions object for full control.
39
39
  */
40
40
  realtime?: boolean | RealtimeAppOptions;
41
+ /**
42
+ * Runs after CORS and before the `addMiddleware` chain and JSON body parsing.
43
+ * Use to attach early middleware via `app.use(...)` before JSON parsing.
44
+ */
45
+ beforeJsonSetup?: (app: express.Application) => void;
46
+ /**
47
+ * Invoked after registered plugins/model routers and before `/auth/me`.
48
+ * Receives the Express app and OpenAPI bundle for `modelRouter` / `createOpenApiBuilder` wiring.
49
+ */
50
+ configureApp?: AddRoutes;
41
51
  }
42
52
  /**
43
53
  * Fluent API for building Express applications with Terreno framework.
44
54
  *
45
- * TerrenoApp provides an alternative to `setupServer` using a registration
46
- * pattern instead of callbacks. Build applications by registering model
47
- * routers and plugins, then calling `start()` to begin listening.
55
+ * TerrenoApp is the supported way to assemble the Terreno Express stack.
56
+ * Build applications by registering model routers and plugins (and/or
57
+ * `configureApp`), then calling `start()` to listen.
48
58
  *
49
59
  * The middleware stack is configured in this order:
50
60
  * 1. CORS
51
- * 2. Custom middleware (via addMiddleware)
52
- * 3. JSON body parser
53
- * 4. Auth routes (/auth/login, /auth/signup, etc.)
54
- * 5. JWT authentication setup
55
- * 6. Request logging
56
- * 7. Sentry scopes
57
- * 8. OpenAPI middleware
58
- * 9. /auth/me routes
61
+ * 2. Optional `beforeJsonSetup` (configure the app before JSON parsing)
62
+ * 3. Custom middleware (via addMiddleware)
63
+ * 4. JSON body parser
64
+ * 5. Auth routes (/auth/login, /auth/signup, etc.)
65
+ * 6. JWT authentication setup
66
+ * 7. Request logging
67
+ * 8. Sentry scopes
68
+ * 9. OpenAPI middleware (including JSON `requestId` on object responses)
59
69
  * 10. GitHub OAuth routes (if enabled)
60
- * 11. Registered model routers and plugins
61
- * 12. Error handling middleware
70
+ * 11. Configuration app (if any)
71
+ * 12. Registered model routers and plugins
72
+ * 13. Optional `configureApp` callback
73
+ * 14. /auth/me routes
74
+ * 15. Error handling middleware
62
75
  *
63
76
  * @example
64
77
  * ```typescript
@@ -95,7 +108,6 @@ export interface TerrenoAppOptions {
95
108
  * .start();
96
109
  * ```
97
110
  *
98
- * @see setupServer for the callback-based alternative
99
111
  * @see TerrenoPlugin for creating reusable plugins
100
112
  * @see modelRouter for creating CRUD route registrations
101
113
  */
@@ -70,6 +70,7 @@ var errors_1 = require("./errors");
70
70
  var expressServer_1 = require("./expressServer");
71
71
  var githubAuth_1 = require("./githubAuth");
72
72
  var logger_1 = require("./logger");
73
+ var middleware_1 = require("./middleware");
73
74
  var openApiCompat_1 = require("./openApiCompat");
74
75
  var openApiEtag_1 = require("./openApiEtag");
75
76
  var realtimeApp_1 = require("./realtime/realtimeApp");
@@ -78,23 +79,26 @@ var index_1 = __importDefault(require("./vendor/wesleytodd-openapi/index"));
78
79
  /**
79
80
  * Fluent API for building Express applications with Terreno framework.
80
81
  *
81
- * TerrenoApp provides an alternative to `setupServer` using a registration
82
- * pattern instead of callbacks. Build applications by registering model
83
- * routers and plugins, then calling `start()` to begin listening.
82
+ * TerrenoApp is the supported way to assemble the Terreno Express stack.
83
+ * Build applications by registering model routers and plugins (and/or
84
+ * `configureApp`), then calling `start()` to listen.
84
85
  *
85
86
  * The middleware stack is configured in this order:
86
87
  * 1. CORS
87
- * 2. Custom middleware (via addMiddleware)
88
- * 3. JSON body parser
89
- * 4. Auth routes (/auth/login, /auth/signup, etc.)
90
- * 5. JWT authentication setup
91
- * 6. Request logging
92
- * 7. Sentry scopes
93
- * 8. OpenAPI middleware
94
- * 9. /auth/me routes
88
+ * 2. Optional `beforeJsonSetup` (configure the app before JSON parsing)
89
+ * 3. Custom middleware (via addMiddleware)
90
+ * 4. JSON body parser
91
+ * 5. Auth routes (/auth/login, /auth/signup, etc.)
92
+ * 6. JWT authentication setup
93
+ * 7. Request logging
94
+ * 8. Sentry scopes
95
+ * 9. OpenAPI middleware (including JSON `requestId` on object responses)
95
96
  * 10. GitHub OAuth routes (if enabled)
96
- * 11. Registered model routers and plugins
97
- * 12. Error handling middleware
97
+ * 11. Configuration app (if any)
98
+ * 12. Registered model routers and plugins
99
+ * 13. Optional `configureApp` callback
100
+ * 14. /auth/me routes
101
+ * 15. Error handling middleware
98
102
  *
99
103
  * @example
100
104
  * ```typescript
@@ -131,7 +135,6 @@ var index_1 = __importDefault(require("./vendor/wesleytodd-openapi/index"));
131
135
  * .start();
132
136
  * ```
133
137
  *
134
- * @see setupServer for the callback-based alternative
135
138
  * @see TerrenoPlugin for creating reusable plugins
136
139
  * @see modelRouter for creating CRUD route registrations
137
140
  */
@@ -254,6 +257,9 @@ var TerrenoApp = /** @class */ (function () {
254
257
  app.set("query parser", function (str) { var _a; return qs_1.default.parse(str, { arrayLimit: (_a = options.arrayLimit) !== null && _a !== void 0 ? _a : 200 }); });
255
258
  app.use(requestContext_1.requestContextMiddleware);
256
259
  app.use((0, cors_1.default)({ credentials: true, origin: (_c = options.corsOrigin) !== null && _c !== void 0 ? _c : "*" }));
260
+ if (options.beforeJsonSetup) {
261
+ options.beforeJsonSetup(app);
262
+ }
257
263
  try {
258
264
  // Apply custom middleware before JSON parsing
259
265
  for (var _d = __values(this.middlewareFns), _e = _d.next(); !_e.done; _e = _d.next()) {
@@ -314,6 +320,7 @@ var TerrenoApp = /** @class */ (function () {
314
320
  // OpenAPI
315
321
  app.use(openApiCompat_1.openApiCompatMiddleware);
316
322
  app.use(openApiEtag_1.openApiEtagMiddleware);
323
+ app.use(middleware_1.jsonResponseRequestIdMiddleware);
317
324
  var oapi = (0, index_1.default)({
318
325
  info: {
319
326
  description: "Generated docs from an Express api",
@@ -355,6 +362,9 @@ var TerrenoApp = /** @class */ (function () {
355
362
  }
356
363
  finally { if (e_2) throw e_2.error; }
357
364
  }
365
+ if (options.configureApp) {
366
+ options.configureApp(app, { openApi: oapi });
367
+ }
358
368
  // /auth/me must be registered after plugins so that session middleware
359
369
  // (e.g. Better Auth) has a chance to populate req.user first.
360
370
  (0, auth_1.addMeRoutes)(app, options.userModel, options.authOptions);