@tumbaland/backend-core 1.16.1 → 1.18.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/README.md +61 -48
- package/dist/app/shutdown.d.ts +31 -0
- package/dist/app/shutdown.d.ts.map +1 -0
- package/dist/app/shutdown.js +58 -0
- package/dist/app/shutdown.js.map +1 -0
- package/dist/health/createHealthCheck.d.ts +32 -0
- package/dist/health/createHealthCheck.d.ts.map +1 -0
- package/dist/health/createHealthCheck.js +50 -0
- package/dist/health/createHealthCheck.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -1
- package/dist/index.js.map +1 -1
- package/dist/logging/logger.js +1 -1
- package/dist/logging/logger.js.map +1 -1
- package/dist/metrics/index.d.ts +3 -2
- package/dist/metrics/index.d.ts.map +1 -1
- package/dist/metrics/index.js +1 -1
- package/dist/metrics/index.js.map +1 -1
- package/dist/middleware/errorHandler.js.map +1 -1
- package/dist/middleware/requestLogger.d.ts +3 -2
- package/dist/middleware/requestLogger.d.ts.map +1 -1
- package/dist/middleware/requestLogger.js.map +1 -1
- package/dist/tracing/index.d.ts +9 -8
- package/dist/tracing/index.d.ts.map +1 -1
- package/dist/tracing/index.js +7 -7
- package/dist/tracing/index.js.map +1 -1
- package/dist/types/auth.d.ts +9 -1
- package/dist/types/auth.d.ts.map +1 -1
- package/dist/utils/correlation.js.map +1 -1
- package/dist/utils/permissionUtils.d.ts +31 -0
- package/dist/utils/permissionUtils.d.ts.map +1 -0
- package/dist/utils/permissionUtils.js +53 -0
- package/dist/utils/permissionUtils.js.map +1 -0
- package/dist/utils/response.d.ts +3 -3
- package/dist/utils/response.d.ts.map +1 -1
- package/package.json +6 -3
- package/src/app/shutdown.test.ts +129 -0
- package/src/app/shutdown.ts +81 -0
- package/src/health/createHealthCheck.test.ts +89 -0
- package/src/health/createHealthCheck.ts +67 -0
- package/src/index.ts +5 -0
- package/src/logging/logger.ts +1 -1
- package/src/metrics/index.ts +3 -2
- package/src/middleware/errorHandler.ts +2 -2
- package/src/middleware/requestLogger.ts +2 -1
- package/src/tracing/index.test.ts +11 -7
- package/src/tracing/index.ts +26 -13
- package/src/types/auth.ts +10 -1
- package/src/utils/correlation.ts +1 -1
- package/src/utils/permissionUtils.test.ts +47 -0
- package/src/utils/permissionUtils.ts +68 -0
- package/src/utils/response.ts +3 -3
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import morgan from 'morgan';
|
|
2
|
+
import { Request, Response, NextFunction } from 'express';
|
|
2
3
|
import logger from '../logging/logger';
|
|
3
4
|
import { metricsMiddleware } from '../metrics';
|
|
4
5
|
|
|
@@ -24,7 +25,7 @@ export const requestLoggerWithMetrics = [requestLogger, metricsMiddleware];
|
|
|
24
25
|
* Simple request logger for development
|
|
25
26
|
* Logs basic request info with correlation ID
|
|
26
27
|
*/
|
|
27
|
-
export const simpleRequestLogger = (req:
|
|
28
|
+
export const simpleRequestLogger = (req: Request, res: Response, next: NextFunction) => {
|
|
28
29
|
const start = Date.now();
|
|
29
30
|
const correlationId = req.correlationId || 'unknown';
|
|
30
31
|
|
|
@@ -6,12 +6,16 @@
|
|
|
6
6
|
* resolution, parent-span-vs-context handling, the helper wrappers), using
|
|
7
7
|
* the real (side-effect-free) @opentelemetry/api for trace/context/propagation.
|
|
8
8
|
*/
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
9
|
+
// A partial span: only the members these tests exercise are stubbed, cast to
|
|
10
|
+
// the full Span type so it satisfies the now-typed tracing helpers. The jest
|
|
11
|
+
// mocks remain reachable at runtime for the assertions below.
|
|
12
|
+
const mockSpan = () =>
|
|
13
|
+
({
|
|
14
|
+
setAttribute: jest.fn(),
|
|
15
|
+
addEvent: jest.fn(),
|
|
16
|
+
end: jest.fn(),
|
|
17
|
+
spanContext: jest.fn(() => ({ traceId: 't', spanId: 's', traceFlags: 1 }))
|
|
18
|
+
}) as unknown as Span;
|
|
15
19
|
|
|
16
20
|
const mockTracer = { startSpan: jest.fn(() => mockSpan()) };
|
|
17
21
|
const registerMock = jest.fn();
|
|
@@ -38,7 +42,7 @@ jest.mock('@opentelemetry/resources', () => ({
|
|
|
38
42
|
resourceFromAttributes: resourceFromAttributesMock
|
|
39
43
|
}));
|
|
40
44
|
|
|
41
|
-
import { context } from '@opentelemetry/api';
|
|
45
|
+
import { context, Span } from '@opentelemetry/api';
|
|
42
46
|
import { ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions';
|
|
43
47
|
|
|
44
48
|
async function loadTracing() {
|
package/src/tracing/index.ts
CHANGED
|
@@ -1,4 +1,15 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
context,
|
|
3
|
+
propagation,
|
|
4
|
+
trace,
|
|
5
|
+
SpanKind,
|
|
6
|
+
Tracer,
|
|
7
|
+
Span,
|
|
8
|
+
Context,
|
|
9
|
+
Attributes,
|
|
10
|
+
AttributeValue,
|
|
11
|
+
} from '@opentelemetry/api';
|
|
12
|
+
import { Request, Response, NextFunction } from 'express';
|
|
2
13
|
import { NodeTracerProvider, BatchSpanProcessor } from '@opentelemetry/sdk-trace-node';
|
|
3
14
|
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
|
|
4
15
|
import { resourceFromAttributes } from '@opentelemetry/resources';
|
|
@@ -36,19 +47,19 @@ export const getTracer = (): Tracer => {
|
|
|
36
47
|
};
|
|
37
48
|
|
|
38
49
|
// Start a new span. `parent` may be a Span or a Context (e.g. from extractSpanContext).
|
|
39
|
-
export const startSpan = (operationName: string,
|
|
40
|
-
if (!
|
|
50
|
+
export const startSpan = (operationName: string, parent?: Span | Context) => {
|
|
51
|
+
if (!parent) {
|
|
41
52
|
return getTracer().startSpan(operationName);
|
|
42
53
|
}
|
|
54
|
+
// A Span exposes `spanContext()`; a Context does not — use that to decide
|
|
55
|
+
// whether we need to wrap the parent span into an active context first.
|
|
43
56
|
const parentContext =
|
|
44
|
-
|
|
45
|
-
? trace.setSpan(context.active(), parentSpan)
|
|
46
|
-
: parentSpan;
|
|
57
|
+
'spanContext' in parent ? trace.setSpan(context.active(), parent) : parent;
|
|
47
58
|
return getTracer().startSpan(operationName, undefined, parentContext);
|
|
48
59
|
};
|
|
49
60
|
|
|
50
61
|
// Middleware for Express to create spans for requests
|
|
51
|
-
export const tracingMiddleware = (req:
|
|
62
|
+
export const tracingMiddleware = (req: Request, res: Response, next: NextFunction) => {
|
|
52
63
|
const parentContext = propagation.extract(context.active(), req.headers);
|
|
53
64
|
const span = getTracer().startSpan(
|
|
54
65
|
`${req.method} ${req.path}`,
|
|
@@ -70,28 +81,30 @@ export const tracingMiddleware = (req: any, res: any, next: any) => {
|
|
|
70
81
|
};
|
|
71
82
|
|
|
72
83
|
// Helper to create child spans
|
|
73
|
-
export const createChildSpan = (operationName: string,
|
|
74
|
-
return startSpan(operationName,
|
|
84
|
+
export const createChildSpan = (operationName: string, parent: Span | Context) => {
|
|
85
|
+
return startSpan(operationName, parent);
|
|
75
86
|
};
|
|
76
87
|
|
|
77
88
|
// Helper to log events to spans
|
|
78
|
-
export const logToSpan = (span:
|
|
89
|
+
export const logToSpan = (span: Span, event: string, data?: Attributes) => {
|
|
79
90
|
span.addEvent(event, data);
|
|
80
91
|
};
|
|
81
92
|
|
|
82
93
|
// Helper to set tags on spans
|
|
83
|
-
export const setSpanTag = (span:
|
|
94
|
+
export const setSpanTag = (span: Span, key: string, value: AttributeValue) => {
|
|
84
95
|
span.setAttribute(key, value);
|
|
85
96
|
};
|
|
86
97
|
|
|
87
98
|
// Helper to inject tracing headers (W3C traceparent) for downstream calls
|
|
88
|
-
export const injectHeaders = (span:
|
|
99
|
+
export const injectHeaders = (span: Span) => {
|
|
89
100
|
const headers: { [key: string]: string } = {};
|
|
90
101
|
propagation.inject(trace.setSpan(context.active(), span), headers);
|
|
91
102
|
return headers;
|
|
92
103
|
};
|
|
93
104
|
|
|
94
105
|
// Helper to extract span context from incoming headers; pass the result to startSpan as parent
|
|
95
|
-
export const extractSpanContext = (
|
|
106
|
+
export const extractSpanContext = (
|
|
107
|
+
headers: Record<string, string | string[] | undefined>
|
|
108
|
+
): Context => {
|
|
96
109
|
return propagation.extract(context.active(), headers);
|
|
97
110
|
};
|
package/src/types/auth.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
|
+
import type { Span } from '@opentelemetry/api';
|
|
2
|
+
|
|
1
3
|
export interface UserPayload {
|
|
2
4
|
id: string;
|
|
3
5
|
email: string;
|
|
4
6
|
name: string;
|
|
5
7
|
picture?: string;
|
|
6
8
|
groups?: string[];
|
|
9
|
+
roles?: string[];
|
|
7
10
|
}
|
|
8
11
|
|
|
9
12
|
/**
|
|
@@ -12,12 +15,18 @@ export interface UserPayload {
|
|
|
12
15
|
* service's own divergent local augmentation. Individual services may still
|
|
13
16
|
* carry additional request-scoped fields (e.g. auth-service's `dbUser`) —
|
|
14
17
|
* those stay local since they aren't meaningful outside that service.
|
|
18
|
+
*
|
|
19
|
+
* `correlationId` is set by `correlationMiddleware`, `span` by
|
|
20
|
+
* `tracingMiddleware`; both are declared here so the observability middleware
|
|
21
|
+
* and downstream handlers can read them without a `(req as any)` cast.
|
|
15
22
|
*/
|
|
16
23
|
declare global {
|
|
17
24
|
namespace Express {
|
|
18
25
|
interface Request {
|
|
19
26
|
user?: UserPayload;
|
|
20
27
|
userGroups?: string[];
|
|
28
|
+
correlationId?: string;
|
|
29
|
+
span?: Span;
|
|
21
30
|
}
|
|
22
31
|
}
|
|
23
32
|
}
|
|
@@ -29,5 +38,5 @@ export interface ServiceHealth {
|
|
|
29
38
|
timestamp: string;
|
|
30
39
|
version: string;
|
|
31
40
|
description: string;
|
|
32
|
-
dependencies?: Record<string,
|
|
41
|
+
dependencies?: Record<string, unknown>;
|
|
33
42
|
}
|
package/src/utils/correlation.ts
CHANGED
|
@@ -15,7 +15,7 @@ export function generateCorrelationId(): string {
|
|
|
15
15
|
export const correlationMiddleware: RequestHandler = (req: Request, res: Response, next: NextFunction) => {
|
|
16
16
|
const correlationId = req.headers['x-correlation-id'] as string || generateCorrelationId();
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
req.correlationId = correlationId;
|
|
19
19
|
res.setHeader('x-correlation-id', correlationId);
|
|
20
20
|
|
|
21
21
|
next();
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { buildAccessQuery, canAccessResource } from './permissionUtils';
|
|
2
|
+
|
|
3
|
+
describe('buildAccessQuery', () => {
|
|
4
|
+
it('always includes the user\'s own resources', () => {
|
|
5
|
+
expect(buildAccessQuery('user-1')).toEqual({ $or: [{ userId: 'user-1' }] });
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
it('adds a group-membership condition when groupIds are provided', () => {
|
|
9
|
+
expect(buildAccessQuery('user-1', ['g1', 'g2'])).toEqual({
|
|
10
|
+
$or: [{ userId: 'user-1' }, { groupId: { $in: ['g1', 'g2'] } }]
|
|
11
|
+
});
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('adds a public-resource condition when includePublic is true', () => {
|
|
15
|
+
expect(buildAccessQuery('user-1', [], true)).toEqual({
|
|
16
|
+
$or: [{ userId: 'user-1' }, { isPublic: true }]
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('combines group and public conditions', () => {
|
|
21
|
+
expect(buildAccessQuery('user-1', ['g1'], true)).toEqual({
|
|
22
|
+
$or: [{ userId: 'user-1' }, { groupId: { $in: ['g1'] } }, { isPublic: true }]
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe('canAccessResource', () => {
|
|
28
|
+
it('allows the owner', () => {
|
|
29
|
+
expect(canAccessResource('user-1', 'user-1')).toBe(true);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('allows anyone when the resource is public', () => {
|
|
33
|
+
expect(canAccessResource('user-1', 'user-2', undefined, [], true)).toBe(true);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('allows a member of the resource\'s group', () => {
|
|
37
|
+
expect(canAccessResource('user-1', 'user-2', 'g1', ['g1'])).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('denies a non-member with no public flag', () => {
|
|
41
|
+
expect(canAccessResource('user-1', 'user-2', 'g1', ['g2'])).toBe(false);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('denies when the resource has no group and belongs to someone else', () => {
|
|
45
|
+
expect(canAccessResource('user-1', 'user-2')).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Simple permission helper that builds query filters for group-based access
|
|
3
|
+
* This avoids external API calls and can be used in database queries directly
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Build a MongoDB query for resources accessible by a user
|
|
8
|
+
* @param userId - The user's ID
|
|
9
|
+
* @param groupIds - Array of group IDs the user belongs to (optional)
|
|
10
|
+
* @param includePublic - Whether to include public resources
|
|
11
|
+
*/
|
|
12
|
+
export function buildAccessQuery(
|
|
13
|
+
userId: string,
|
|
14
|
+
groupIds: string[] = [],
|
|
15
|
+
includePublic: boolean = false
|
|
16
|
+
) {
|
|
17
|
+
const conditions: Array<
|
|
18
|
+
{ userId: string } | { groupId: { $in: string[] } } | { isPublic: boolean }
|
|
19
|
+
> = [
|
|
20
|
+
{ userId } // User's own resources
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
// Add group-based access
|
|
24
|
+
if (groupIds.length > 0) {
|
|
25
|
+
conditions.push({ groupId: { $in: groupIds } });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Add public resources if requested
|
|
29
|
+
if (includePublic) {
|
|
30
|
+
conditions.push({ isPublic: true });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return { $or: conditions };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Check if a user can access a specific resource
|
|
38
|
+
* @param userId - The requesting user's ID
|
|
39
|
+
* @param resourceUserId - The resource owner's ID
|
|
40
|
+
* @param resourceGroupId - The resource's group ID (if any)
|
|
41
|
+
* @param userGroupIds - Array of group IDs the user belongs to
|
|
42
|
+
* @param isResourcePublic - Whether the resource is public
|
|
43
|
+
*/
|
|
44
|
+
export function canAccessResource(
|
|
45
|
+
userId: string,
|
|
46
|
+
resourceUserId: string,
|
|
47
|
+
resourceGroupId?: string,
|
|
48
|
+
userGroupIds: string[] = [],
|
|
49
|
+
isResourcePublic: boolean = false
|
|
50
|
+
): boolean {
|
|
51
|
+
// Owner can always access their own resources
|
|
52
|
+
if (userId === resourceUserId) {
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Public resources are accessible to anyone
|
|
57
|
+
if (isResourcePublic) {
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// If resource is associated with a group, check if user is in that group
|
|
62
|
+
if (resourceGroupId && userGroupIds.includes(resourceGroupId)) {
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// No access
|
|
67
|
+
return false;
|
|
68
|
+
}
|
package/src/utils/response.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { Response } from 'express';
|
|
|
3
3
|
/**
|
|
4
4
|
* Standard API response format
|
|
5
5
|
*/
|
|
6
|
-
export interface ApiResponse<T =
|
|
6
|
+
export interface ApiResponse<T = unknown> {
|
|
7
7
|
success: boolean;
|
|
8
8
|
data?: T;
|
|
9
9
|
message?: string;
|
|
@@ -14,7 +14,7 @@ export interface ApiResponse<T = any> {
|
|
|
14
14
|
/**
|
|
15
15
|
* Create a standardized API response
|
|
16
16
|
*/
|
|
17
|
-
export function createApiResponse<T =
|
|
17
|
+
export function createApiResponse<T = unknown>(
|
|
18
18
|
success: boolean,
|
|
19
19
|
data?: T,
|
|
20
20
|
message?: string,
|
|
@@ -34,7 +34,7 @@ export function createApiResponse<T = any>(
|
|
|
34
34
|
/**
|
|
35
35
|
* Send a success response
|
|
36
36
|
*/
|
|
37
|
-
export function sendSuccess<T =
|
|
37
|
+
export function sendSuccess<T = unknown>(
|
|
38
38
|
res: Response,
|
|
39
39
|
data?: T,
|
|
40
40
|
message?: string,
|