@terreno/api 0.20.2 → 0.22.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/.ai/guidelines/core.md +71 -0
- package/.ai/skills/mongoose-schema-safety/SKILL.md +143 -0
- package/README.md +54 -1
- package/bunfig.toml +1 -1
- package/dist/__tests__/versionCheckPlugin.test.js +29 -7
- package/dist/actions.openApi.test.js +13 -11
- package/dist/api.js +98 -11
- package/dist/api.query.test.js +31 -1
- package/dist/api.test.js +211 -0
- package/dist/auth.test.js +418 -43
- package/dist/betterAuth.d.ts +1 -1
- package/dist/consentApp.test.js +1 -0
- package/dist/example.js +4 -4
- package/dist/expressServer.d.ts +0 -22
- package/dist/expressServer.js +1 -125
- package/dist/expressServer.test.js +90 -91
- package/dist/githubAuth.test.js +22 -22
- package/dist/logger.d.ts +154 -0
- package/dist/logger.js +445 -26
- package/dist/logger.test.js +435 -0
- package/dist/middleware.d.ts +7 -0
- package/dist/middleware.js +58 -1
- package/dist/middleware.test.js +159 -0
- package/dist/models/consentForm.js +2 -1
- package/dist/models/consentResponse.js +2 -1
- package/dist/models/versionConfig.js +2 -1
- package/dist/openApi.test.js +10 -17
- package/dist/openApiBuilder.d.ts +18 -0
- package/dist/openApiBuilder.js +21 -0
- package/dist/openApiBuilder.test.js +34 -10
- package/dist/permissions.test.js +10 -43
- package/dist/populate.test.js +10 -42
- package/dist/realtime/changeStreamWatcher.d.ts +4 -4
- package/dist/realtime/changeStreamWatcher.js +2 -4
- package/dist/realtime/queryMatcher.d.ts +1 -1
- package/dist/realtime/queryMatcher.js +39 -14
- package/dist/realtime/types.d.ts +3 -3
- package/dist/requestContext.d.ts +61 -0
- package/dist/requestContext.js +74 -0
- package/dist/secretProviders.test.js +335 -0
- package/dist/syncConsents.test.js +2 -2
- package/dist/terrenoApp.d.ts +27 -15
- package/dist/terrenoApp.js +24 -14
- package/dist/terrenoApp.test.js +52 -0
- package/dist/tests/bunSetup.js +66 -262
- package/dist/tests/createTestData.d.ts +9 -0
- package/dist/tests/createTestData.js +272 -0
- package/dist/tests/models.d.ts +71 -0
- package/dist/tests/models.js +134 -0
- package/dist/tests/mongoTestSetup.d.ts +7 -0
- package/dist/tests/mongoTestSetup.js +150 -0
- package/dist/tests/testEnv.d.ts +0 -0
- package/dist/tests/testEnv.js +6 -0
- package/dist/tests/testHelper.d.ts +22 -0
- package/dist/tests/testHelper.js +115 -0
- package/dist/tests/types.d.ts +29 -0
- package/dist/tests/types.js +2 -0
- package/dist/tests.d.ts +10 -78
- package/dist/tests.js +24 -241
- package/dist/transformers.test.js +14 -50
- package/package.json +18 -4
- package/src/__snapshots__/openApiBuilder.test.ts.snap +1 -0
- package/src/__tests__/versionCheckPlugin.test.ts +43 -15
- package/src/actions.openApi.test.ts +12 -10
- package/src/api.query.test.ts +24 -1
- package/src/api.test.ts +169 -0
- package/src/api.ts +71 -0
- package/src/auth.test.ts +287 -39
- package/src/betterAuth.ts +1 -1
- package/src/consentApp.test.ts +1 -0
- package/src/example.ts +4 -4
- package/src/expressServer.test.ts +82 -85
- package/src/expressServer.ts +1 -213
- package/src/githubAuth.test.ts +22 -22
- package/src/logger.test.ts +466 -1
- package/src/logger.ts +477 -14
- package/src/middleware.test.ts +74 -2
- package/src/middleware.ts +57 -0
- package/src/models/consentForm.ts +3 -4
- package/src/models/consentResponse.ts +6 -4
- package/src/models/versionConfig.ts +3 -4
- package/src/openApi.test.ts +10 -17
- package/src/openApiBuilder.test.ts +27 -10
- package/src/openApiBuilder.ts +24 -0
- package/src/permissions.test.ts +8 -23
- package/src/populate.test.ts +7 -22
- package/src/realtime/changeStreamWatcher.ts +15 -10
- package/src/realtime/queryMatcher.ts +54 -27
- package/src/realtime/types.ts +4 -4
- package/src/requestContext.ts +86 -0
- package/src/secretProviders.test.ts +219 -1
- package/src/syncConsents.test.ts +1 -1
- package/src/terrenoApp.test.ts +38 -0
- package/src/terrenoApp.ts +37 -15
- package/src/tests/bunSetup.ts +22 -236
- package/src/tests/createTestData.ts +176 -0
- package/src/tests/models.ts +164 -0
- package/src/tests/mongoTestSetup.ts +69 -0
- package/src/tests/testEnv.ts +4 -0
- package/src/tests/testHelper.ts +57 -0
- package/src/tests/types.ts +35 -0
- package/src/tests.ts +40 -231
- package/src/transformers.test.ts +11 -30
- package/tsconfig.typedoc.json +4 -0
- package/dist/tests/index.d.ts +0 -1
- package/dist/tests/index.js +0 -17
- package/src/tests/index.ts +0 -1
|
@@ -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:
|
|
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:
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
72
|
-
|
|
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
|
-
|
|
77
|
-
|
|
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
|
-
|
|
82
|
-
|
|
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
|
|
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
|
|
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 = (
|
|
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
|
}
|
package/src/realtime/types.ts
CHANGED
|
@@ -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:
|
|
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:
|
|
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,
|
|
105
|
+
query: Record<string, unknown>;
|
|
106
106
|
/** Client-provided queryId (ignored — server computes a canonical ID) */
|
|
107
107
|
queryId?: string;
|
|
108
108
|
}
|
package/src/requestContext.ts
CHANGED
|
@@ -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,
|
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import {beforeEach, describe, expect, it} from "bun:test";
|
|
2
2
|
|
|
3
3
|
import type {SecretProvider} from "./configurationPlugin";
|
|
4
|
-
import {
|
|
4
|
+
import {APIError} from "./errors";
|
|
5
|
+
import {
|
|
6
|
+
CachingSecretProvider,
|
|
7
|
+
CompositeSecretProvider,
|
|
8
|
+
EnvSecretProvider,
|
|
9
|
+
GcpSecretProvider,
|
|
10
|
+
} from "./secretProviders";
|
|
5
11
|
|
|
6
12
|
describe("EnvSecretProvider", () => {
|
|
7
13
|
beforeEach(() => {
|
|
@@ -184,3 +190,215 @@ describe("CachingSecretProvider", () => {
|
|
|
184
190
|
expect(calls).toBe(1);
|
|
185
191
|
});
|
|
186
192
|
});
|
|
193
|
+
|
|
194
|
+
// ---------------------------------------------------------------------------
|
|
195
|
+
// GcpSecretProvider
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
interface MockSecretManagerClient {
|
|
199
|
+
accessSecretVersion: (request: {
|
|
200
|
+
name: string;
|
|
201
|
+
}) => Promise<[{payload?: {data?: string | Uint8Array}}]>;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Inject a pre-built mock client into a GcpSecretProvider, bypassing getClient(). */
|
|
205
|
+
const injectClient = (provider: GcpSecretProvider, client: MockSecretManagerClient): void => {
|
|
206
|
+
// Bypass the private `client` field for testing — avoids the dynamic import of
|
|
207
|
+
// @google-cloud/secret-manager which is an optional peer dependency.
|
|
208
|
+
Object.defineProperty(provider, "client", {configurable: true, value: client, writable: true});
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
describe("GcpSecretProvider", () => {
|
|
212
|
+
it("has the name 'gcp'", () => {
|
|
213
|
+
const provider = new GcpSecretProvider({projectId: "my-project"});
|
|
214
|
+
expect(provider.name).toBe("gcp");
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("throws APIError when @google-cloud/secret-manager is not installed", async () => {
|
|
218
|
+
const provider = new GcpSecretProvider({projectId: "my-project"});
|
|
219
|
+
try {
|
|
220
|
+
await provider.getSecret("some-secret");
|
|
221
|
+
expect.unreachable("should have thrown");
|
|
222
|
+
} catch (error) {
|
|
223
|
+
expect(error).toBeInstanceOf(APIError);
|
|
224
|
+
expect((error as APIError).title).toContain(
|
|
225
|
+
"GcpSecretProvider requires @google-cloud/secret-manager"
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("resolves a short secret name to the full resource path with default version", async () => {
|
|
231
|
+
const calls: string[] = [];
|
|
232
|
+
const mockClient: MockSecretManagerClient = {
|
|
233
|
+
accessSecretVersion: async (req) => {
|
|
234
|
+
calls.push(req.name);
|
|
235
|
+
return [{payload: {data: "secret-value"}}];
|
|
236
|
+
},
|
|
237
|
+
};
|
|
238
|
+
const provider = new GcpSecretProvider({projectId: "my-project"});
|
|
239
|
+
injectClient(provider, mockClient);
|
|
240
|
+
|
|
241
|
+
const result = await provider.getSecret("openai-api-key");
|
|
242
|
+
expect(result).toBe("secret-value");
|
|
243
|
+
expect(calls).toEqual(["projects/my-project/secrets/openai-api-key/versions/latest"]);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("resolves a short secret name with an explicit version", async () => {
|
|
247
|
+
const calls: string[] = [];
|
|
248
|
+
const mockClient: MockSecretManagerClient = {
|
|
249
|
+
accessSecretVersion: async (req) => {
|
|
250
|
+
calls.push(req.name);
|
|
251
|
+
return [{payload: {data: "v3-value"}}];
|
|
252
|
+
},
|
|
253
|
+
};
|
|
254
|
+
const provider = new GcpSecretProvider({projectId: "p"});
|
|
255
|
+
injectClient(provider, mockClient);
|
|
256
|
+
|
|
257
|
+
const result = await provider.getSecret("my-key", "3");
|
|
258
|
+
expect(result).toBe("v3-value");
|
|
259
|
+
expect(calls).toEqual(["projects/p/secrets/my-key/versions/3"]);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("honors a full resource path that already contains /versions/", async () => {
|
|
263
|
+
const calls: string[] = [];
|
|
264
|
+
const mockClient: MockSecretManagerClient = {
|
|
265
|
+
accessSecretVersion: async (req) => {
|
|
266
|
+
calls.push(req.name);
|
|
267
|
+
return [{payload: {data: "pinned"}}];
|
|
268
|
+
},
|
|
269
|
+
};
|
|
270
|
+
const provider = new GcpSecretProvider({projectId: "ignored"});
|
|
271
|
+
injectClient(provider, mockClient);
|
|
272
|
+
|
|
273
|
+
const result = await provider.getSecret("projects/p/secrets/s/versions/7");
|
|
274
|
+
expect(result).toBe("pinned");
|
|
275
|
+
expect(calls).toEqual(["projects/p/secrets/s/versions/7"]);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("appends /versions/latest to a full resource path without a version suffix", async () => {
|
|
279
|
+
const calls: string[] = [];
|
|
280
|
+
const mockClient: MockSecretManagerClient = {
|
|
281
|
+
accessSecretVersion: async (req) => {
|
|
282
|
+
calls.push(req.name);
|
|
283
|
+
return [{payload: {data: "latest-value"}}];
|
|
284
|
+
},
|
|
285
|
+
};
|
|
286
|
+
const provider = new GcpSecretProvider({projectId: "ignored"});
|
|
287
|
+
injectClient(provider, mockClient);
|
|
288
|
+
|
|
289
|
+
const result = await provider.getSecret("projects/p/secrets/s");
|
|
290
|
+
expect(result).toBe("latest-value");
|
|
291
|
+
expect(calls).toEqual(["projects/p/secrets/s/versions/latest"]);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it("appends the explicit version when full path lacks /versions/", async () => {
|
|
295
|
+
const calls: string[] = [];
|
|
296
|
+
const mockClient: MockSecretManagerClient = {
|
|
297
|
+
accessSecretVersion: async (req) => {
|
|
298
|
+
calls.push(req.name);
|
|
299
|
+
return [{payload: {data: "v5"}}];
|
|
300
|
+
},
|
|
301
|
+
};
|
|
302
|
+
const provider = new GcpSecretProvider({projectId: "ignored"});
|
|
303
|
+
injectClient(provider, mockClient);
|
|
304
|
+
|
|
305
|
+
const result = await provider.getSecret("projects/p/secrets/s", "5");
|
|
306
|
+
expect(result).toBe("v5");
|
|
307
|
+
expect(calls).toEqual(["projects/p/secrets/s/versions/5"]);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it("decodes a Uint8Array payload", async () => {
|
|
311
|
+
const encoded = new TextEncoder().encode("binary-secret");
|
|
312
|
+
const mockClient: MockSecretManagerClient = {
|
|
313
|
+
accessSecretVersion: async () => [{payload: {data: encoded}}],
|
|
314
|
+
};
|
|
315
|
+
const provider = new GcpSecretProvider({projectId: "p"});
|
|
316
|
+
injectClient(provider, mockClient);
|
|
317
|
+
|
|
318
|
+
expect(await provider.getSecret("bin-key")).toBe("binary-secret");
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it("returns null when the payload is empty", async () => {
|
|
322
|
+
const mockClient: MockSecretManagerClient = {
|
|
323
|
+
accessSecretVersion: async () => [{payload: {}}],
|
|
324
|
+
};
|
|
325
|
+
const provider = new GcpSecretProvider({projectId: "p"});
|
|
326
|
+
injectClient(provider, mockClient);
|
|
327
|
+
|
|
328
|
+
expect(await provider.getSecret("empty-payload")).toBeNull();
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it("returns null when the payload field is missing entirely", async () => {
|
|
332
|
+
const mockClient: MockSecretManagerClient = {
|
|
333
|
+
accessSecretVersion: async () => [{}],
|
|
334
|
+
};
|
|
335
|
+
const provider = new GcpSecretProvider({projectId: "p"});
|
|
336
|
+
injectClient(provider, mockClient);
|
|
337
|
+
|
|
338
|
+
expect(await provider.getSecret("no-payload")).toBeNull();
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it("returns null on NOT_FOUND (gRPC code 5)", async () => {
|
|
342
|
+
const notFound = Object.assign(new Error("NOT_FOUND"), {code: 5});
|
|
343
|
+
const mockClient: MockSecretManagerClient = {
|
|
344
|
+
accessSecretVersion: async () => {
|
|
345
|
+
throw notFound;
|
|
346
|
+
},
|
|
347
|
+
};
|
|
348
|
+
const provider = new GcpSecretProvider({projectId: "p"});
|
|
349
|
+
injectClient(provider, mockClient);
|
|
350
|
+
|
|
351
|
+
expect(await provider.getSecret("missing-secret")).toBeNull();
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it("re-throws non-NOT_FOUND errors", async () => {
|
|
355
|
+
const permissionDenied = Object.assign(new Error("PERMISSION_DENIED"), {code: 7});
|
|
356
|
+
const mockClient: MockSecretManagerClient = {
|
|
357
|
+
accessSecretVersion: async () => {
|
|
358
|
+
throw permissionDenied;
|
|
359
|
+
},
|
|
360
|
+
};
|
|
361
|
+
const provider = new GcpSecretProvider({projectId: "p"});
|
|
362
|
+
injectClient(provider, mockClient);
|
|
363
|
+
|
|
364
|
+
try {
|
|
365
|
+
await provider.getSecret("forbidden-secret");
|
|
366
|
+
expect.unreachable("should have thrown");
|
|
367
|
+
} catch (error) {
|
|
368
|
+
expect(error).toBe(permissionDenied);
|
|
369
|
+
}
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it("re-throws non-Error throwables", async () => {
|
|
373
|
+
const mockClient: MockSecretManagerClient = {
|
|
374
|
+
accessSecretVersion: async () => {
|
|
375
|
+
throw "string-error";
|
|
376
|
+
},
|
|
377
|
+
};
|
|
378
|
+
const provider = new GcpSecretProvider({projectId: "p"});
|
|
379
|
+
injectClient(provider, mockClient);
|
|
380
|
+
|
|
381
|
+
try {
|
|
382
|
+
await provider.getSecret("x");
|
|
383
|
+
expect.unreachable("should have thrown");
|
|
384
|
+
} catch (error) {
|
|
385
|
+
expect(error).toBe("string-error");
|
|
386
|
+
}
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
it("caches the client across multiple getSecret calls", async () => {
|
|
390
|
+
let callCount = 0;
|
|
391
|
+
const mockClient: MockSecretManagerClient = {
|
|
392
|
+
accessSecretVersion: async () => {
|
|
393
|
+
callCount++;
|
|
394
|
+
return [{payload: {data: `call-${callCount}`}}];
|
|
395
|
+
},
|
|
396
|
+
};
|
|
397
|
+
const provider = new GcpSecretProvider({projectId: "p"});
|
|
398
|
+
injectClient(provider, mockClient);
|
|
399
|
+
|
|
400
|
+
expect(await provider.getSecret("a")).toBe("call-1");
|
|
401
|
+
expect(await provider.getSecret("b")).toBe("call-2");
|
|
402
|
+
expect(callCount).toBe(2);
|
|
403
|
+
});
|
|
404
|
+
});
|
package/src/syncConsents.test.ts
CHANGED
|
@@ -2,7 +2,7 @@ import {afterEach, beforeEach, describe, expect, it} from "bun:test";
|
|
|
2
2
|
import {ConsentForm} from "./models/consentForm";
|
|
3
3
|
import type {ConsentFormDefinition} from "./syncConsents";
|
|
4
4
|
import {syncConsents} from "./syncConsents";
|
|
5
|
-
import {setupDb} from "./tests";
|
|
5
|
+
import {setupDb} from "./tests/testHelper";
|
|
6
6
|
|
|
7
7
|
const baseDef: ConsentFormDefinition = {
|
|
8
8
|
content: {en: "# Terms\nPlease agree."},
|
package/src/terrenoApp.test.ts
CHANGED
|
@@ -6,6 +6,7 @@ import supertest from "supertest";
|
|
|
6
6
|
import {modelRouter} from "./api";
|
|
7
7
|
import type {UserModel as UserModelType} from "./auth";
|
|
8
8
|
import {configurationPlugin} from "./configurationPlugin";
|
|
9
|
+
import {APIError} from "./errors";
|
|
9
10
|
import {Permissions} from "./permissions";
|
|
10
11
|
import {createdUpdatedPlugin} from "./plugins";
|
|
11
12
|
import {TerrenoApp} from "./terrenoApp";
|
|
@@ -42,6 +43,17 @@ describe("TerrenoApp", () => {
|
|
|
42
43
|
expect(app).toBeDefined();
|
|
43
44
|
});
|
|
44
45
|
|
|
46
|
+
it("does not add requestId to GET /openapi.json document bodies", async () => {
|
|
47
|
+
const app = new TerrenoApp({
|
|
48
|
+
skipListen: true,
|
|
49
|
+
userModel: typedUserModel,
|
|
50
|
+
}).build();
|
|
51
|
+
|
|
52
|
+
const res = await supertest(app).get("/openapi.json").expect(200);
|
|
53
|
+
expect(res.body.openapi).toBe("3.0.0");
|
|
54
|
+
expect(res.body.requestId).toBeUndefined();
|
|
55
|
+
});
|
|
56
|
+
|
|
45
57
|
it("creates server with custom corsOrigin", () => {
|
|
46
58
|
const app = new TerrenoApp({
|
|
47
59
|
corsOrigin: "https://example.com",
|
|
@@ -105,6 +117,7 @@ describe("TerrenoApp", () => {
|
|
|
105
117
|
const res = await agent.get("/food").expect(200);
|
|
106
118
|
expect(res.body.data).toHaveLength(1);
|
|
107
119
|
expect(res.body.data[0].name).toBe("Apple");
|
|
120
|
+
expect(res.body.requestId).toBe(res.headers["x-request-id"]);
|
|
108
121
|
});
|
|
109
122
|
|
|
110
123
|
it("supports chaining multiple registrations", async () => {
|
|
@@ -196,6 +209,7 @@ describe("TerrenoApp", () => {
|
|
|
196
209
|
const agent = await authAsUser(app, "admin");
|
|
197
210
|
const res = await agent.get("/configuration/meta");
|
|
198
211
|
expect(res.status).toBe(200);
|
|
212
|
+
expect(res.body.requestId).toBe(res.headers["x-request-id"]);
|
|
199
213
|
});
|
|
200
214
|
|
|
201
215
|
it("supports custom basePath via configure options", async () => {
|
|
@@ -240,6 +254,30 @@ describe("TerrenoApp", () => {
|
|
|
240
254
|
|
|
241
255
|
const res = await supertest(app).get("/trigger-fallthrough");
|
|
242
256
|
expect(res.status).toBe(500);
|
|
257
|
+
expect(res.body.requestId).toBe(res.headers["x-request-id"]);
|
|
258
|
+
expect(res.body.status).toBe(500);
|
|
259
|
+
expect(res.body.title).toBe("Internal server error");
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("adds requestId to APIError JSON responses", async () => {
|
|
263
|
+
const plugin: TerrenoPlugin = {
|
|
264
|
+
register: (pluginApp) => {
|
|
265
|
+
pluginApp.get("/api-error-route", () => {
|
|
266
|
+
throw new APIError({status: 400, title: "Bad request test"});
|
|
267
|
+
});
|
|
268
|
+
},
|
|
269
|
+
};
|
|
270
|
+
const app = new TerrenoApp({
|
|
271
|
+
skipListen: true,
|
|
272
|
+
userModel: typedUserModel,
|
|
273
|
+
})
|
|
274
|
+
.register(plugin)
|
|
275
|
+
.build();
|
|
276
|
+
|
|
277
|
+
const res = await supertest(app).get("/api-error-route").set("X-Request-ID", "api-err-rid");
|
|
278
|
+
expect(res.status).toBe(400);
|
|
279
|
+
expect(res.body.requestId).toBe("api-err-rid");
|
|
280
|
+
expect(res.body.title).toBe("Bad request test");
|
|
243
281
|
});
|
|
244
282
|
});
|
|
245
283
|
|