@terreno/api 0.20.2 → 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
package/src/middleware.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import * as Sentry from "@sentry/bun";
2
2
  import type {NextFunction, Request, Response} from "express";
3
3
 
4
+ import {getCurrentRequestContext} from "./requestContext";
5
+
4
6
  /**
5
7
  * Express middleware that captures the app version from the request header
6
8
  * and adds it as a tag to the current Sentry scope.
@@ -20,3 +22,58 @@ export const sentryAppVersionMiddleware = (
20
22
  }
21
23
  next();
22
24
  };
25
+
26
+ /**
27
+ * OpenAPI vendor routes that must return pristine JSON (no injected requestId).
28
+ * Matches @wesleytodd/openapi: main spec, per-component JSON, and validate payload.
29
+ */
30
+ const isOpenApiToolingJsonRequest = (req: Request): boolean => {
31
+ if (req.method !== "GET") {
32
+ return false;
33
+ }
34
+ const {path} = req;
35
+ if (path === "/openapi.json") {
36
+ return true;
37
+ }
38
+ if (path === "/openapi/validate") {
39
+ return true;
40
+ }
41
+ if (path.startsWith("/openapi/components/") && path.endsWith(".json")) {
42
+ return true;
43
+ }
44
+ return false;
45
+ };
46
+
47
+ /**
48
+ * TerrenoApp middleware: augments `res.json` so plain-object payloads include
49
+ * `requestId` for client correlation. Skips OpenAPI tooling GET JSON routes
50
+ * (`/openapi.json`, `/openapi/components/...json`, `/openapi/validate`) so
51
+ * machine-consumed payloads stay valid. Does not wrap arrays or primitives.
52
+ */
53
+ export const jsonResponseRequestIdMiddleware = (
54
+ req: Request,
55
+ res: Response,
56
+ next: NextFunction
57
+ ): void => {
58
+ const originalJson = res.json.bind(res);
59
+ res.json = (body?: unknown): Response => {
60
+ if (isOpenApiToolingJsonRequest(req)) {
61
+ return originalJson(body);
62
+ }
63
+
64
+ const requestId =
65
+ (req as Request & {requestId?: string}).requestId ?? getCurrentRequestContext()?.requestId;
66
+
67
+ if (!requestId) {
68
+ return originalJson(body);
69
+ }
70
+
71
+ if (body !== null && body !== undefined && typeof body === "object" && !Array.isArray(body)) {
72
+ return originalJson({...(body as Record<string, unknown>), requestId});
73
+ }
74
+
75
+ return originalJson(body);
76
+ };
77
+
78
+ next();
79
+ };
@@ -6,8 +6,6 @@ import supertest from "supertest";
6
6
  import type TestAgent from "supertest/lib/agent";
7
7
 
8
8
  import {type ModelRouterOptions, modelRouter} from "./api";
9
- import {addAuthRoutes, setupAuth} from "./auth";
10
- import {setupServer} from "./expressServer";
11
9
  import {
12
10
  createOpenApiMiddleware,
13
11
  deleteOpenApiMiddleware,
@@ -17,6 +15,7 @@ import {
17
15
  readOpenApiMiddleware,
18
16
  } from "./openApi";
19
17
  import {Permissions} from "./permissions";
18
+ import {TerrenoApp} from "./terrenoApp";
20
19
  import {FoodModel, setupDb, UserModel} from "./tests";
21
20
 
22
21
  function getMessageSummaryOpenApiMiddleware(options: Partial<ModelRouterOptions<any>>): any {
@@ -90,13 +89,11 @@ describe("openApi", () => {
90
89
  process.env.REFRESH_TOKEN_SECRET = "testsecret1234";
91
90
  process.env.ENABLE_SWAGGER = "true";
92
91
 
93
- app = setupServer({
94
- addRoutes,
92
+ app = new TerrenoApp({
93
+ configureApp: addRoutes,
95
94
  skipListen: true,
96
95
  userModel: UserModel as any,
97
- });
98
- setupAuth(app, UserModel as any);
99
- addAuthRoutes(app, UserModel as any);
96
+ }).build();
100
97
  });
101
98
 
102
99
  it("gets the openapi.json", async () => {
@@ -246,13 +243,11 @@ describe("openApi without swagger", () => {
246
243
  process.env.REFRESH_TOKEN_SECRET = "testsecret1234";
247
244
  process.env.ENABLE_SWAGGER = "false";
248
245
 
249
- app = setupServer({
250
- addRoutes,
246
+ app = new TerrenoApp({
247
+ configureApp: addRoutes,
251
248
  skipListen: true,
252
249
  userModel: UserModel as any,
253
- });
254
- setupAuth(app, UserModel as any);
255
- addAuthRoutes(app, UserModel as any);
250
+ }).build();
256
251
  });
257
252
 
258
253
  it("does not have the swagger ui", async () => {
@@ -268,13 +263,11 @@ describe("openApi populate", () => {
268
263
  beforeEach(async () => {
269
264
  process.env.REFRESH_TOKEN_SECRET = "testsecret1234";
270
265
 
271
- app = setupServer({
272
- addRoutes: addRoutesPopulate,
266
+ app = new TerrenoApp({
267
+ configureApp: addRoutesPopulate,
273
268
  skipListen: true,
274
269
  userModel: UserModel as any,
275
- });
276
- setupAuth(app, UserModel as any);
277
- addAuthRoutes(app, UserModel as any);
270
+ }).build();
278
271
  });
279
272
 
280
273
  it("gets the openapi.json with populate", async () => {
@@ -6,10 +6,9 @@ import supertest from "supertest";
6
6
  import type TestAgent from "supertest/lib/agent";
7
7
 
8
8
  import {type ModelRouterOptions, modelRouter} from "./api";
9
- import {addAuthRoutes, setupAuth} from "./auth";
10
- import {setupServer} from "./expressServer";
11
9
  import {createOpenApiBuilder, OpenApiMiddlewareBuilder} from "./openApiBuilder";
12
10
  import {Permissions} from "./permissions";
11
+ import {TerrenoApp} from "./terrenoApp";
13
12
  import {FoodModel, UserModel} from "./tests";
14
13
 
15
14
  function addRoutesWithBuilder(router: Router, options?: Partial<ModelRouterOptions<any>>): void {
@@ -145,13 +144,11 @@ describe("OpenApiMiddlewareBuilder", () => {
145
144
  process.env.REFRESH_TOKEN_SECRET = "testsecret1234";
146
145
  process.env.ENABLE_SWAGGER = "true";
147
146
 
148
- app = setupServer({
149
- addRoutes: addRoutesWithBuilder,
147
+ app = new TerrenoApp({
148
+ configureApp: addRoutesWithBuilder,
150
149
  skipListen: true,
151
150
  userModel: UserModel as any,
152
- });
153
- setupAuth(app, UserModel as any);
154
- addAuthRoutes(app, UserModel as any);
151
+ }).build();
155
152
  });
156
153
 
157
154
  describe("builder pattern", () => {
@@ -302,7 +299,11 @@ describe("OpenApiMiddlewareBuilder", () => {
302
299
  it("stats endpoint returns correct data", async () => {
303
300
  server = supertest(app);
304
301
  const res = await server.get("/food/stats").expect(200);
305
- expect(res.body).toEqual({avgCalories: 250, count: 10});
302
+ expect(res.body).toEqual({
303
+ avgCalories: 250,
304
+ count: 10,
305
+ requestId: res.headers["x-request-id"],
306
+ });
306
307
  });
307
308
 
308
309
  it("reports endpoint returns correct data", async () => {
@@ -311,7 +312,10 @@ describe("OpenApiMiddlewareBuilder", () => {
311
312
  .post("/food/reports")
312
313
  .send({endDate: "2024-12-31", startDate: "2024-01-01"})
313
314
  .expect(201);
314
- expect(res.body).toEqual({reportId: "report-123"});
315
+ expect(res.body).toEqual({
316
+ reportId: "report-123",
317
+ requestId: res.headers["x-request-id"],
318
+ });
315
319
  });
316
320
 
317
321
  it("categories endpoint returns array data", async () => {
@@ -325,7 +329,11 @@ describe("OpenApiMiddlewareBuilder", () => {
325
329
  it("category by id endpoint returns correct data", async () => {
326
330
  server = supertest(app);
327
331
  const res = await server.get("/food/categories/cat-123").expect(200);
328
- expect(res.body).toEqual({id: "cat-123", name: "Fruits"});
332
+ expect(res.body).toEqual({
333
+ id: "cat-123",
334
+ name: "Fruits",
335
+ requestId: res.headers["x-request-id"],
336
+ });
329
337
  });
330
338
  });
331
339
 
@@ -1,4 +1,3 @@
1
- // biome-ignore-all lint/suspicious/noExplicitAny: change stream and socket handlers use dynamic document shapes
2
1
  import * as Sentry from "@sentry/bun";
3
2
  import type express from "express";
4
3
  import {DateTime} from "luxon";
@@ -98,7 +97,7 @@ const getSocketsInRoom = (io: Server, room: string): RealtimeSocketWithAuth[] =>
98
97
  const canReadDocument = async (
99
98
  entry: RealtimeRegistryEntry,
100
99
  user?: User,
101
- doc?: any
100
+ doc?: Record<string, unknown>
102
101
  ): Promise<boolean> => {
103
102
  return checkPermissions("read", entry.options.permissions.read, user, doc);
104
103
  };
@@ -107,7 +106,11 @@ const canReadDocument = async (
107
106
  * Determine which Socket.io rooms to emit to based on the room strategy.
108
107
  * Exported for testing.
109
108
  */
110
- export const resolveRooms = (entry: RealtimeRegistryEntry, doc: any, method: string): string[] => {
109
+ export const resolveRooms = (
110
+ entry: RealtimeRegistryEntry,
111
+ doc: Record<string, unknown>,
112
+ method: string
113
+ ): string[] => {
111
114
  const {roomStrategy} = entry.config;
112
115
  // Use the collection tag (e.g. "todos") for model rooms, matching what the frontend subscribes to
113
116
  const collectionTag = getCollectionTag(entry.routePath);
@@ -120,7 +123,7 @@ export const resolveRooms = (entry: RealtimeRegistryEntry, doc: any, method: str
120
123
 
121
124
  switch (roomStrategy) {
122
125
  case "owner": {
123
- const ownerId = doc?.ownerId?.toString?.() ?? doc?.ownerId;
126
+ const ownerId = doc?.ownerId != null ? String(doc.ownerId) : undefined;
124
127
  if (ownerId) {
125
128
  return [`user:${ownerId}`];
126
129
  }
@@ -169,10 +172,10 @@ export const ensureApiId = (data: unknown): unknown => {
169
172
  */
170
173
  export const serializeDoc = async (
171
174
  entry: RealtimeRegistryEntry,
172
- doc: any,
175
+ doc: Record<string, unknown>,
173
176
  method: "create" | "update" | "delete",
174
177
  user?: User
175
- ): Promise<any> => {
178
+ ): Promise<unknown> => {
176
179
  if (entry.config.realtimeResponseHandler) {
177
180
  try {
178
181
  return ensureApiId(await entry.config.realtimeResponseHandler(doc, method));
@@ -203,7 +206,9 @@ export const serializeDoc = async (
203
206
  }
204
207
  }
205
208
 
206
- return ensureApiId(typeof doc.toJSON === "function" ? doc.toJSON() : doc);
209
+ return ensureApiId(
210
+ typeof doc.toJSON === "function" ? (doc as {toJSON: () => unknown}).toJSON() : doc
211
+ );
207
212
  };
208
213
 
209
214
  export const emitToAuthorizedRoom = async (
@@ -211,7 +216,7 @@ export const emitToAuthorizedRoom = async (
211
216
  room: string,
212
217
  event: RealtimeEvent,
213
218
  entry: RealtimeRegistryEntry,
214
- fullDocument: any,
219
+ fullDocument: Record<string, unknown> | undefined,
215
220
  logDebug: (msg: string) => void
216
221
  ): Promise<void> => {
217
222
  const sockets = getSocketsInRoom(io, room);
@@ -263,7 +268,7 @@ export const emitToDocumentAndQueryRooms = async (
263
268
  io: Server,
264
269
  collection: string,
265
270
  event: RealtimeEvent,
266
- fullDocument: any,
271
+ fullDocument: Record<string, unknown> | undefined,
267
272
  logDebug: (msg: string) => void,
268
273
  entry?: RealtimeRegistryEntry
269
274
  ): Promise<void> => {
@@ -495,7 +500,7 @@ export const startChangeStreamWatcher = (
495
500
  rooms = [`model:${collectionTag}`];
496
501
  }
497
502
  } else {
498
- rooms = resolveRooms(entry, fullDocument, method);
503
+ rooms = resolveRooms(entry, fullDocument ?? {}, method);
499
504
  }
500
505
 
501
506
  const collection = getCollectionTag(entry.routePath);
@@ -1,4 +1,3 @@
1
- // biome-ignore-all lint/suspicious/noExplicitAny: MongoDB query matcher evaluates dynamic filter shapes
2
1
  /**
3
2
  * Simple in-memory MongoDB query matcher.
4
3
  * Evaluates a MongoDB-style query object against a document without hitting the database.
@@ -6,35 +5,52 @@
6
5
  * Supports: equality, $eq, $ne, $gt, $gte, $lt, $lte, $in, $nin, $exists, $and, $or, $not.
7
6
  */
8
7
 
9
- const getNestedValue = (doc: any, path: string): any => {
8
+ const getNestedValue = (doc: Record<string, unknown>, path: string): unknown => {
10
9
  const parts = path.split(".");
11
- let current = doc;
10
+ let current: unknown = doc;
12
11
  for (const part of parts) {
13
12
  if (current === null || current === undefined) {
14
13
  return undefined;
15
14
  }
16
- current = current[part];
15
+ current = (current as Record<string, unknown>)[part];
17
16
  }
18
17
  return current;
19
18
  };
20
19
 
21
- const normalize = (value: any): any => {
20
+ const normalize = (value: unknown): unknown => {
22
21
  if (value === null || value === undefined) {
23
22
  return value;
24
23
  }
25
24
  // Handle ObjectId-like objects with toString
26
- if (
27
- typeof value === "object" &&
28
- typeof value.toString === "function" &&
29
- value.constructor?.name !== "Object" &&
30
- !Array.isArray(value)
31
- ) {
32
- return value.toString();
25
+ if (typeof value === "object" && !Array.isArray(value)) {
26
+ const obj = value as Record<string, unknown>;
27
+ const ctorName = (obj.constructor as {name?: string} | undefined)?.name;
28
+ if (typeof obj.toString === "function" && ctorName !== "Object") {
29
+ return String(value);
30
+ }
33
31
  }
34
32
  return value;
35
33
  };
36
34
 
37
- const matchesCondition = (rawValue: any, condition: any): boolean => {
35
+ /**
36
+ * JS abstract relational comparison on unknown values.
37
+ * Numeric operands compare numerically; everything else compares as strings.
38
+ * This mirrors the coercion behaviour of `>` / `<` on the `any`-typed values
39
+ * that MongoDB in-memory matching historically received.
40
+ */
41
+ const compareValues = (a: unknown, b: unknown): number => {
42
+ if (typeof a === "number" && typeof b === "number") {
43
+ return a - b;
44
+ }
45
+ if (typeof a === "string" && typeof b === "string") {
46
+ return a < b ? -1 : a > b ? 1 : 0;
47
+ }
48
+ const numA = Number(a);
49
+ const numB = Number(b);
50
+ return numA - numB;
51
+ };
52
+
53
+ const matchesCondition = (rawValue: unknown, condition: unknown): boolean => {
38
54
  const value = normalize(rawValue);
39
55
 
40
56
  // Direct equality (non-object condition)
@@ -49,7 +65,7 @@ const matchesCondition = (rawValue: any, condition: any): boolean => {
49
65
  }
50
66
 
51
67
  // Operator object
52
- for (const [op, operand] of Object.entries(condition)) {
68
+ for (const [op, operand] of Object.entries(condition as Record<string, unknown>)) {
53
69
  const normOp = normalize(operand);
54
70
 
55
71
  switch (op) {
@@ -63,32 +79,40 @@ const matchesCondition = (rawValue: any, condition: any): boolean => {
63
79
  return false;
64
80
  }
65
81
  break;
66
- case "$gt":
67
- if (!(value > normOp)) {
82
+ case "$gt": {
83
+ const cmp = compareValues(value, normOp);
84
+ if (Number.isNaN(cmp) || cmp <= 0) {
68
85
  return false;
69
86
  }
70
87
  break;
71
- case "$gte":
72
- if (!(value >= normOp)) {
88
+ }
89
+ case "$gte": {
90
+ const cmp = compareValues(value, normOp);
91
+ if (Number.isNaN(cmp) || cmp < 0) {
73
92
  return false;
74
93
  }
75
94
  break;
76
- case "$lt":
77
- if (!(value < normOp)) {
95
+ }
96
+ case "$lt": {
97
+ const cmp = compareValues(value, normOp);
98
+ if (Number.isNaN(cmp) || cmp >= 0) {
78
99
  return false;
79
100
  }
80
101
  break;
81
- case "$lte":
82
- if (!(value <= normOp)) {
102
+ }
103
+ case "$lte": {
104
+ const cmp = compareValues(value, normOp);
105
+ if (Number.isNaN(cmp) || cmp > 0) {
83
106
  return false;
84
107
  }
85
108
  break;
109
+ }
86
110
  case "$in": {
87
111
  if (!Array.isArray(operand)) {
88
112
  return false;
89
113
  }
90
114
  const inValues = operand.map(normalize);
91
- if (!inValues.some((v: any) => v === value || String(v) === String(value))) {
115
+ if (!inValues.some((v) => v === value || String(v) === String(value))) {
92
116
  return false;
93
117
  }
94
118
  break;
@@ -98,7 +122,7 @@ const matchesCondition = (rawValue: any, condition: any): boolean => {
98
122
  return false;
99
123
  }
100
124
  const ninValues = operand.map(normalize);
101
- if (ninValues.some((v: any) => v === value || String(v) === String(value))) {
125
+ if (ninValues.some((v) => v === value || String(v) === String(value))) {
102
126
  return false;
103
127
  }
104
128
  break;
@@ -132,14 +156,17 @@ const matchesCondition = (rawValue: any, condition: any): boolean => {
132
156
  * @param query - MongoDB-style query object
133
157
  * @returns true if the document matches all query conditions
134
158
  */
135
- export const matchesQuery = (doc: any, query: Record<string, any>): boolean => {
159
+ export const matchesQuery = (
160
+ doc: Record<string, unknown>,
161
+ query: Record<string, unknown>
162
+ ): boolean => {
136
163
  for (const [key, condition] of Object.entries(query)) {
137
164
  if (key === "$and") {
138
165
  if (!Array.isArray(condition)) {
139
166
  return false;
140
167
  }
141
168
  for (const subQuery of condition) {
142
- if (!matchesQuery(doc, subQuery)) {
169
+ if (!matchesQuery(doc, subQuery as Record<string, unknown>)) {
143
170
  return false;
144
171
  }
145
172
  }
@@ -152,7 +179,7 @@ export const matchesQuery = (doc: any, query: Record<string, any>): boolean => {
152
179
  }
153
180
  let matched = false;
154
181
  for (const subQuery of condition) {
155
- if (matchesQuery(doc, subQuery)) {
182
+ if (matchesQuery(doc, subQuery as Record<string, unknown>)) {
156
183
  matched = true;
157
184
  break;
158
185
  }
@@ -1,4 +1,3 @@
1
- // biome-ignore-all lint/suspicious/noExplicitAny: realtime config callbacks receive dynamic document shapes
2
1
  import type express from "express";
3
2
 
4
3
  /**
@@ -19,9 +18,9 @@ export interface RealtimeConfig {
19
18
  | "owner"
20
19
  | "model"
21
20
  | "broadcast"
22
- | ((doc: any, method: string, req: express.Request) => string[]);
21
+ | ((doc: Record<string, unknown>, method: string, req: express.Request) => string[]);
23
22
  /** Custom serializer for real-time events. Falls back to the modelRouter responseHandler. */
24
- realtimeResponseHandler?: (doc: any, method: string) => any;
23
+ realtimeResponseHandler?: (doc: Record<string, unknown>, method: string) => unknown;
25
24
  }
26
25
 
27
26
  /**
@@ -37,6 +36,7 @@ export interface RealtimeEvent {
37
36
  /** Document ID */
38
37
  id: string;
39
38
  /** Serialized document data (omitted for hard deletes) */
39
+ // biome-ignore lint/suspicious/noExplicitAny: noExplicitAny: event data is a serialized document whose shape varies by model; consumers must narrow to their specific type
40
40
  data?: any;
41
41
  /** Fields that were updated (for update events from change streams) */
42
42
  updatedFields?: string[];
@@ -102,7 +102,7 @@ export interface QuerySubscription {
102
102
  /** Collection tag (e.g. "todos") */
103
103
  collection: string;
104
104
  /** MongoDB-style query filter (e.g. {completed: false}) */
105
- query: Record<string, any>;
105
+ query: Record<string, unknown>;
106
106
  /** Client-provided queryId (ignored — server computes a canonical ID) */
107
107
  queryId?: string;
108
108
  }
@@ -1,3 +1,28 @@
1
+ /**
2
+ * Request/job correlation for `@terreno/api`.
3
+ *
4
+ * Correlation is how every log line emitted while handling one request (or one background job) can
5
+ * be tied back together. It is built on Node's {@link AsyncLocalStorage}: a {@link RequestContext}
6
+ * (with `requestId`, `userId`, `traceId`, etc.) is stored for the duration of a callback, and the
7
+ * logger's Winston format reads it from there and merges it into each line. Nothing needs to be
8
+ * threaded through function arguments.
9
+ *
10
+ * Two ways a scope is established:
11
+ *
12
+ * - **HTTP**: {@link requestContextMiddleware} runs first in the middleware stack. It derives a
13
+ * `requestId` from incoming headers ({@link REQUEST_CONTEXT_ATTRIBUTE_NAMES}, `x-correlation-id`,
14
+ * Cloud Trace, or W3C `traceparent`) or generates one, echoes it back as `X-Request-ID`, and runs
15
+ * the rest of the request inside the scope.
16
+ * - **Jobs/scripts**: {@link runWithRequestContext} (or {@link runWithRequestContextAttributes})
17
+ * establishes the same scope manually so background work is just as traceable.
18
+ *
19
+ * The active context is also pushed to Sentry tags/context via {@link applyRequestContextToSentry},
20
+ * and is exposed to logging via {@link getCurrentLogContext} / {@link getCurrentRequestContext}.
21
+ *
22
+ * @see {@link runWithRequestContext}
23
+ * @see {@link getCurrentLogContext}
24
+ * @module requestContext
25
+ */
1
26
  import {AsyncLocalStorage} from "node:async_hooks";
2
27
  import {randomUUID} from "node:crypto";
3
28
  import * as Sentry from "@sentry/bun";
@@ -14,18 +39,35 @@ const TRACE_PARENT_HEADER = "traceparent";
14
39
  const TRACE_SAMPLED_HEADER = "x-trace-sampled";
15
40
  const USER_ID_HEADER = "x-user-id";
16
41
 
42
+ /**
43
+ * Correlation fields stored in AsyncLocalStorage for the lifetime of a request or job. Every log
44
+ * line emitted inside the scope is enriched with these. `requestId` is the only required field; the
45
+ * rest are populated when headers, trace context, or auth supply them.
46
+ */
17
47
  export interface RequestContext {
48
+ /** Background job identifier (from `x-job-id` or set via {@link runWithRequestContext}). */
18
49
  jobId?: string;
50
+ /** Stable id shared by all log lines for one request/job; echoed to clients as `X-Request-ID`. */
19
51
  requestId: string;
52
+ /** Auth session id, resolved from the JWT/Better Auth session or `x-session-id`. */
20
53
  sessionId?: string;
54
+ /** Distributed-tracing span id, parsed from Cloud Trace or W3C `traceparent`. */
21
55
  spanId?: string;
56
+ /** Distributed-tracing trace id, parsed from Cloud Trace or W3C `traceparent`. */
22
57
  traceId?: string;
58
+ /** Whether the trace is sampled, per the incoming trace headers. */
23
59
  traceSampled?: boolean;
60
+ /** Authenticated user id, populated after auth middleware runs. */
24
61
  userId?: string;
25
62
  }
26
63
 
27
64
  export type RequestContextAttributes = Record<string, string>;
28
65
 
66
+ /**
67
+ * Canonical HTTP header names for each correlation field. Use these to propagate context to
68
+ * downstream services (pair with {@link getCurrentRequestContextAttributes}) or to read it from an
69
+ * incoming request (pair with {@link getRequestContextFromAttributes}).
70
+ */
29
71
  export const REQUEST_CONTEXT_ATTRIBUTE_NAMES = {
30
72
  jobId: JOB_ID_HEADER,
31
73
  requestId: "x-request-id",
@@ -161,10 +203,19 @@ export const getRequestContextFromAttributes = (
161
203
  };
162
204
  };
163
205
 
206
+ /**
207
+ * Returns the full {@link RequestContext} for the active AsyncLocalStorage scope, or `undefined`
208
+ * when called outside any request/job scope. The logger uses this to enrich each line.
209
+ */
164
210
  export const getCurrentRequestContext = (): RequestContext | undefined => {
165
211
  return requestContextStorage.getStore();
166
212
  };
167
213
 
214
+ /**
215
+ * Returns the active correlation fields as a plain object (empty when outside a scope). This is the
216
+ * shape attached to Sentry log attributes and is handy when you need to log or forward the current
217
+ * context yourself.
218
+ */
168
219
  export const getCurrentLogContext = (): Partial<RequestContext> => {
169
220
  const context = getCurrentRequestContext();
170
221
  if (!context) {
@@ -254,6 +305,11 @@ const setAttribute = (
254
305
  attributes[name] = String(value);
255
306
  };
256
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
+ */
257
313
  export const getCurrentRequestContextAttributes = (
258
314
  overrides: Partial<RequestContext> = {}
259
315
  ): RequestContextAttributes => {
@@ -269,6 +325,23 @@ export const getCurrentRequestContextAttributes = (
269
325
  return attributes;
270
326
  };
271
327
 
328
+ /**
329
+ * Runs `callback` inside a fresh correlation scope so every log line it emits shares the same
330
+ * identifiers — the manual equivalent of {@link requestContextMiddleware} for background jobs,
331
+ * cron tasks, scripts, queue consumers, etc. A `requestId` is generated when not supplied, and the
332
+ * context is mirrored to Sentry.
333
+ *
334
+ * @example
335
+ * ```typescript
336
+ * import {createScopedLogger, runWithRequestContext} from "@terreno/api";
337
+ *
338
+ * await runWithRequestContext({jobId: "nightly-sync"}, async () => {
339
+ * const log = createScopedLogger({prefix: "[NightlySync]"});
340
+ * log.info("started"); // includes jobId + a generated requestId on every line
341
+ * await sync();
342
+ * });
343
+ * ```
344
+ */
272
345
  export const runWithRequestContext = <T>(
273
346
  context: Partial<RequestContext>,
274
347
  callback: () => T
@@ -284,6 +357,11 @@ export const runWithRequestContext = <T>(
284
357
  });
285
358
  };
286
359
 
360
+ /**
361
+ * Like {@link runWithRequestContext}, but seeds the scope from raw header attributes (for example
362
+ * those received on an incoming message or forwarded by another service). Parses Cloud Trace / W3C
363
+ * `traceparent` into `traceId`/`spanId` via {@link getRequestContextFromAttributes}.
364
+ */
287
365
  export const runWithRequestContextAttributes = <T>(
288
366
  attributes: Record<string, string | undefined> = {},
289
367
  callback: () => T
@@ -324,6 +402,14 @@ export const updateRequestContextFromRequest = (
324
402
  }
325
403
  };
326
404
 
405
+ /**
406
+ * Express middleware that opens a correlation scope for the request. Mounted early by `TerrenoApp` /
407
+ * `setupServer`, it resolves a `requestId` (from request-id/correlation headers, Cloud Trace, or
408
+ * W3C `traceparent`, else a new UUID), captures any `jobId`/`sessionId`/trace fields, echoes
409
+ * `X-Request-ID` back to the client, and runs the remaining middleware inside the scope so all
410
+ * downstream logs are correlated. A later auth-aware pass ({@link updateRequestContextFromRequest})
411
+ * fills in `userId`/`sessionId`.
412
+ */
327
413
  export const requestContextMiddleware = (
328
414
  req: express.Request,
329
415
  res: express.Response,