@hypequery/serve 0.13.6 → 0.14.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/dist/pipeline.d.ts.map +1 -1
- package/dist/pipeline.js +41 -15
- package/dist/utils.d.ts +9 -0
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +30 -0
- package/package.json +2 -2
package/dist/pipeline.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"pipeline.d.ts","sourceRoot":"","sources":["../src/pipeline.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAmB,MAAM,KAAK,CAAC;AAEzC,OAAO,KAAK,EACV,WAAW,EACX,YAAY,EACZ,WAAW,EAKX,cAAc,EACd,mBAAmB,EACnB,aAAa,EACb,YAAY,EACZ,mBAAmB,EACnB,eAAe,EACf,YAAY,EACZ,aAAa,EACb,YAAY,EAGb,MAAM,YAAY,CAAC;AAMpB,OAAO,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAC;AAOrD,OAAO,EAAE,KAAK,kBAAkB,EAAqB,MAAM,WAAW,CAAC;
|
|
1
|
+
{"version":3,"file":"pipeline.d.ts","sourceRoot":"","sources":["../src/pipeline.ts"],"names":[],"mappings":"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"}
|
package/dist/pipeline.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { warnTenantMisconfiguration } from './tenant.js';
|
|
3
3
|
import { applySemanticTenantRuntime } from './semantic/utils/tenant-runtime.js';
|
|
4
|
-
import { generateRequestId } from './utils.js';
|
|
4
|
+
import { generateRequestId, validateCorrelationId } from './utils.js';
|
|
5
5
|
import { buildOpenApiDocument } from './openapi.js';
|
|
6
6
|
import { buildDocsHtml } from './docs-ui.js';
|
|
7
7
|
import { ServeHttpError } from './errors.js';
|
|
@@ -134,12 +134,37 @@ const resolveContext = async (factory, request, auth) => {
|
|
|
134
134
|
}
|
|
135
135
|
return cloneContext(factory);
|
|
136
136
|
};
|
|
137
|
-
|
|
137
|
+
/**
|
|
138
|
+
* Resolve the authoritative request id and the optional external correlation id.
|
|
139
|
+
*
|
|
140
|
+
* The authoritative id is always server-generated unless a trusted in-process caller passes
|
|
141
|
+
* one explicitly; a network-supplied `x-request-id`/`x-trace-id` is NEVER treated as
|
|
142
|
+
* authoritative. Any caller-supplied value is validated and length-bounded and surfaced only
|
|
143
|
+
* as a separate correlation id, so it cannot inject control characters into logs/headers or
|
|
144
|
+
* spoof cross-request correlation.
|
|
145
|
+
*/
|
|
146
|
+
const resolveRequestContext = (request, provided) => ({
|
|
147
|
+
requestId: validateCorrelationId(provided) ?? generateRequestId(),
|
|
148
|
+
correlationId: validateCorrelationId(request.headers['x-request-id']) ??
|
|
149
|
+
validateCorrelationId(request.headers['x-trace-id']),
|
|
150
|
+
});
|
|
151
|
+
const withManagedTraceHeaders = (headers, requestId, correlationId) => ({
|
|
152
|
+
...Object.fromEntries(Object.entries(headers ?? {}).filter(([key]) => {
|
|
153
|
+
const normalized = key.toLowerCase();
|
|
154
|
+
return normalized !== 'x-request-id' && normalized !== 'x-correlation-id';
|
|
155
|
+
})),
|
|
156
|
+
'x-request-id': requestId,
|
|
157
|
+
...(correlationId ? { 'x-correlation-id': correlationId } : {}),
|
|
158
|
+
});
|
|
138
159
|
export const executeEndpoint = async (options) => {
|
|
139
160
|
const { endpoint, request, requestId: explicitRequestId, authStrategies, contextFactory, globalMiddlewares, tenantConfig, hooks = {}, queryLogger, additionalContext, verboseAuthErrors = false, // Default to secure mode for production safety
|
|
140
161
|
sanitizeErrors = true, // Default to secure mode for HTTP requests
|
|
141
162
|
} = options;
|
|
142
|
-
const requestId =
|
|
163
|
+
const { requestId, correlationId } = resolveRequestContext(request, explicitRequestId);
|
|
164
|
+
// Echoed alongside the authoritative x-request-id; only ever a validated caller value.
|
|
165
|
+
const traceHeaders = correlationId
|
|
166
|
+
? { 'x-correlation-id': correlationId }
|
|
167
|
+
: {};
|
|
143
168
|
const locals = {};
|
|
144
169
|
let cacheTtlMs = endpoint.cacheTtlMs ?? null;
|
|
145
170
|
const setCacheTtl = (ttl) => {
|
|
@@ -206,7 +231,7 @@ export const executeEndpoint = async (options) => {
|
|
|
206
231
|
...(verboseAuthErrors && { strategies_attempted: strategies.length }),
|
|
207
232
|
...(verboseAuthErrors && authErrorInfo?.details ? { auth_error: authErrorInfo.details } : {}),
|
|
208
233
|
endpoint: endpoint.metadata.path,
|
|
209
|
-
}, { 'x-request-id': requestId, 'cache-control': 'no-store' });
|
|
234
|
+
}, { 'x-request-id': requestId, ...traceHeaders, 'cache-control': 'no-store' });
|
|
210
235
|
}
|
|
211
236
|
context.auth = authContext;
|
|
212
237
|
// Check role/scope authorization after successful authentication
|
|
@@ -232,7 +257,7 @@ export const executeEndpoint = async (options) => {
|
|
|
232
257
|
actual: authzResult.actual,
|
|
233
258
|
}),
|
|
234
259
|
endpoint: endpoint.metadata.path,
|
|
235
|
-
}, { 'x-request-id': requestId, 'cache-control': 'no-store' });
|
|
260
|
+
}, { 'x-request-id': requestId, ...traceHeaders, 'cache-control': 'no-store' });
|
|
236
261
|
}
|
|
237
262
|
const resolvedContext = await resolveContext(contextFactory, request, authContext);
|
|
238
263
|
Object.assign(context, resolvedContext, additionalContext);
|
|
@@ -255,7 +280,7 @@ export const executeEndpoint = async (options) => {
|
|
|
255
280
|
return createErrorResponse(403, 'FORBIDDEN', errorMessage, {
|
|
256
281
|
reason: 'missing_tenant_context',
|
|
257
282
|
tenant_required: true,
|
|
258
|
-
}, { 'x-request-id': requestId, 'cache-control': 'no-store' });
|
|
283
|
+
}, { 'x-request-id': requestId, ...traceHeaders, 'cache-control': 'no-store' });
|
|
259
284
|
}
|
|
260
285
|
if (tenantId) {
|
|
261
286
|
context.tenantId = tenantId;
|
|
@@ -291,7 +316,7 @@ export const executeEndpoint = async (options) => {
|
|
|
291
316
|
});
|
|
292
317
|
return createErrorResponse(400, 'VALIDATION_ERROR', 'Request validation failed', {
|
|
293
318
|
issues: validationResult.error.issues,
|
|
294
|
-
}, { 'x-request-id': requestId });
|
|
319
|
+
}, { 'x-request-id': requestId, ...traceHeaders });
|
|
295
320
|
}
|
|
296
321
|
context.input = validationResult.data;
|
|
297
322
|
const pipeline = [
|
|
@@ -299,10 +324,7 @@ export const executeEndpoint = async (options) => {
|
|
|
299
324
|
...endpoint.middlewares,
|
|
300
325
|
];
|
|
301
326
|
const result = await runMiddlewares(pipeline, context, () => endpoint.handler(context));
|
|
302
|
-
const headers =
|
|
303
|
-
...(endpoint.defaultHeaders ?? {}),
|
|
304
|
-
'x-request-id': requestId,
|
|
305
|
-
};
|
|
327
|
+
const headers = withManagedTraceHeaders(endpoint.defaultHeaders, requestId, correlationId);
|
|
306
328
|
// Authenticated and tenant-aware responses must never be shared by an
|
|
307
329
|
// intermediary cache. Endpoint TTLs only opt public, tenant-independent
|
|
308
330
|
// responses into shared caching.
|
|
@@ -377,7 +399,7 @@ export const executeEndpoint = async (options) => {
|
|
|
377
399
|
'status' in error &&
|
|
378
400
|
'payload' in error) {
|
|
379
401
|
const structured = error;
|
|
380
|
-
const response = createErrorResponse(structured.status, structured.payload.type, structured.payload.message, undefined,
|
|
402
|
+
const response = createErrorResponse(structured.status, structured.payload.type, structured.payload.message, undefined, withManagedTraceHeaders(structured.headers, requestId, correlationId));
|
|
381
403
|
return response;
|
|
382
404
|
}
|
|
383
405
|
// When sanitizeErrors is enabled (default for HTTP), hide internal error
|
|
@@ -386,20 +408,24 @@ export const executeEndpoint = async (options) => {
|
|
|
386
408
|
const errorMessage = sanitizeErrors
|
|
387
409
|
? 'An unexpected error occurred'
|
|
388
410
|
: (error instanceof Error ? error.message : String(error));
|
|
389
|
-
return createErrorResponse(500, 'INTERNAL_SERVER_ERROR', errorMessage, undefined, { 'x-request-id': requestId });
|
|
411
|
+
return createErrorResponse(500, 'INTERNAL_SERVER_ERROR', errorMessage, undefined, { 'x-request-id': requestId, ...traceHeaders });
|
|
390
412
|
}
|
|
391
413
|
};
|
|
392
414
|
export const createServeHandler = ({ router, globalMiddlewares, authStrategies, tenantConfig, contextFactory, hooks, queryLogger, verboseAuthErrors = false, corsConfig, }) => {
|
|
393
415
|
return async (request) => {
|
|
416
|
+
const { requestId, correlationId } = resolveRequestContext(request);
|
|
417
|
+
const traceHeaders = correlationId
|
|
418
|
+
? { 'x-correlation-id': correlationId }
|
|
419
|
+
: {};
|
|
394
420
|
// Handle CORS preflight and compute headers for actual requests
|
|
395
421
|
const { preflightResponse, corsHeaders } = handleCorsRequest(corsConfig ?? null, request);
|
|
396
422
|
if (preflightResponse) {
|
|
423
|
+
preflightResponse.headers = withManagedTraceHeaders(preflightResponse.headers, requestId, correlationId);
|
|
397
424
|
return preflightResponse;
|
|
398
425
|
}
|
|
399
|
-
const requestId = resolveRequestId(request);
|
|
400
426
|
const endpoint = router.match(request.method, request.path);
|
|
401
427
|
if (!endpoint) {
|
|
402
|
-
const response = createErrorResponse(404, 'NOT_FOUND', `No endpoint registered for ${request.method} ${request.path}`, undefined, { 'x-request-id': requestId });
|
|
428
|
+
const response = createErrorResponse(404, 'NOT_FOUND', `No endpoint registered for ${request.method} ${request.path}`, undefined, { 'x-request-id': requestId, ...traceHeaders });
|
|
403
429
|
response.headers = { ...response.headers, ...corsHeaders };
|
|
404
430
|
return response;
|
|
405
431
|
}
|
package/dist/utils.d.ts
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
export declare const ensureArray: <T>(value: T | T[] | undefined | null) => T[];
|
|
2
2
|
export declare const mergeTags: (existing: string[], next?: string[]) => string[];
|
|
3
3
|
export declare const generateRequestId: () => string;
|
|
4
|
+
/** Upper bound on an accepted external correlation id, in UTF-8 bytes. */
|
|
5
|
+
export declare const MAX_CORRELATION_ID_BYTES = 200;
|
|
6
|
+
/**
|
|
7
|
+
* Validate a caller-supplied external correlation id (e.g. `x-request-id`). This value is
|
|
8
|
+
* never authoritative — the authoritative request id is generated server-side. Returns the
|
|
9
|
+
* trimmed value when it is a safe, bounded string, or `undefined` when absent or rejected
|
|
10
|
+
* (empty, control characters, or over {@link MAX_CORRELATION_ID_BYTES}).
|
|
11
|
+
*/
|
|
12
|
+
export declare const validateCorrelationId: (value?: string) => string | undefined;
|
|
4
13
|
export declare const normalizeHeaderMap: (headers?: Record<string, string | undefined>) => Record<string, string | undefined>;
|
|
5
14
|
//# sourceMappingURL=utils.d.ts.map
|
package/dist/utils.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../src/utils.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,WAAW,GAAI,CAAC,EAAE,OAAO,CAAC,GAAG,CAAC,EAAE,GAAG,SAAS,GAAG,IAAI,KAAG,CAAC,EAKnE,CAAC;AAEF,eAAO,MAAM,SAAS,GAAI,UAAU,MAAM,EAAE,EAAE,OAAO,MAAM,EAAE,aAG5D,CAAC;AAEF,eAAO,MAAM,iBAAiB,QAAO,MAKpC,CAAC;AAEF,eAAO,MAAM,kBAAkB,GAC7B,UAAS,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAM,KAC/C,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAInC,CAAC"}
|
|
1
|
+
{"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../src/utils.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,WAAW,GAAI,CAAC,EAAE,OAAO,CAAC,GAAG,CAAC,EAAE,GAAG,SAAS,GAAG,IAAI,KAAG,CAAC,EAKnE,CAAC;AAEF,eAAO,MAAM,SAAS,GAAI,UAAU,MAAM,EAAE,EAAE,OAAO,MAAM,EAAE,aAG5D,CAAC;AAEF,eAAO,MAAM,iBAAiB,QAAO,MAKpC,CAAC;AAEF,0EAA0E;AAC1E,eAAO,MAAM,wBAAwB,MAAM,CAAC;AAS5C;;;;;GAKG;AACH,eAAO,MAAM,qBAAqB,GAAI,QAAQ,MAAM,KAAG,MAAM,GAAG,SAU/D,CAAC;AAEF,eAAO,MAAM,kBAAkB,GAC7B,UAAS,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAM,KAC/C,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAInC,CAAC"}
|
package/dist/utils.js
CHANGED
|
@@ -14,6 +14,36 @@ export const generateRequestId = () => {
|
|
|
14
14
|
}
|
|
15
15
|
return `req_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
|
|
16
16
|
};
|
|
17
|
+
/** Upper bound on an accepted external correlation id, in UTF-8 bytes. */
|
|
18
|
+
export const MAX_CORRELATION_ID_BYTES = 200;
|
|
19
|
+
// C0 controls, DEL, and C1 controls — rejected so a caller-supplied correlation id can
|
|
20
|
+
// never inject newlines/control sequences into logs or response headers.
|
|
21
|
+
// eslint-disable-next-line no-control-regex -- control chars are exactly what we reject
|
|
22
|
+
const CORRELATION_CONTROL_CHARS = /[\u0000-\u001F\u007F-\u009F]/;
|
|
23
|
+
const CORRELATION_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]*$/;
|
|
24
|
+
const correlationIdEncoder = new TextEncoder();
|
|
25
|
+
/**
|
|
26
|
+
* Validate a caller-supplied external correlation id (e.g. `x-request-id`). This value is
|
|
27
|
+
* never authoritative — the authoritative request id is generated server-side. Returns the
|
|
28
|
+
* trimmed value when it is a safe, bounded string, or `undefined` when absent or rejected
|
|
29
|
+
* (empty, control characters, or over {@link MAX_CORRELATION_ID_BYTES}).
|
|
30
|
+
*/
|
|
31
|
+
export const validateCorrelationId = (value) => {
|
|
32
|
+
if (typeof value !== 'string')
|
|
33
|
+
return undefined;
|
|
34
|
+
const trimmed = value.trim();
|
|
35
|
+
if (trimmed.length === 0)
|
|
36
|
+
return undefined;
|
|
37
|
+
if (CORRELATION_CONTROL_CHARS.test(trimmed))
|
|
38
|
+
return undefined;
|
|
39
|
+
// Keep external identifiers in a narrow ASCII grammar so Unicode
|
|
40
|
+
// confusables and whitespace cannot produce visually ambiguous values.
|
|
41
|
+
if (!CORRELATION_ID_PATTERN.test(trimmed))
|
|
42
|
+
return undefined;
|
|
43
|
+
if (correlationIdEncoder.encode(trimmed).length > MAX_CORRELATION_ID_BYTES)
|
|
44
|
+
return undefined;
|
|
45
|
+
return trimmed;
|
|
46
|
+
};
|
|
17
47
|
export const normalizeHeaderMap = (headers = {}) => {
|
|
18
48
|
return Object.fromEntries(Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]));
|
|
19
49
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hypequery/serve",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"description": "Declarative HTTP server for exposing hypequery analytics endpoints",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"openapi-typescript": "^7.13.0",
|
|
28
28
|
"zod": "^3.23.8",
|
|
29
29
|
"zod-to-json-schema": "^3.23.5",
|
|
30
|
-
"@hypequery/datasets": "^0.
|
|
30
|
+
"@hypequery/datasets": "^0.13.0"
|
|
31
31
|
},
|
|
32
32
|
"peerDependencies": {
|
|
33
33
|
"tsx": "^4.0.0"
|