@tumbaland/backend-core 1.43.0 → 1.45.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/package.json +5 -1
- package/.versionrc.json +0 -7
- package/__mocks__/uuid.js +0 -8
- package/jest.config.js +0 -24
- package/src/apiKeys/ApiKey.test.ts +0 -142
- package/src/apiKeys/ApiKey.ts +0 -102
- package/src/apiKeys/crypto.test.ts +0 -161
- package/src/apiKeys/crypto.ts +0 -163
- package/src/apiKeys/index.test.ts +0 -72
- package/src/apiKeys/index.ts +0 -35
- package/src/apiKeys/middleware.test.ts +0 -651
- package/src/apiKeys/middleware.ts +0 -341
- package/src/apiKeys/service.test.ts +0 -401
- package/src/apiKeys/service.ts +0 -168
- package/src/apiKeys/types.test.ts +0 -41
- package/src/apiKeys/types.ts +0 -124
- package/src/app/createBaseApp.test.ts +0 -109
- package/src/app/createBaseApp.ts +0 -102
- package/src/app/shutdown.test.ts +0 -129
- package/src/app/shutdown.ts +0 -81
- package/src/audit/AuditEvent.ts +0 -123
- package/src/audit/actor.test.ts +0 -95
- package/src/audit/actor.ts +0 -68
- package/src/audit/context.test.ts +0 -91
- package/src/audit/context.ts +0 -83
- package/src/audit/index.ts +0 -11
- package/src/audit/plugin.test.ts +0 -258
- package/src/audit/plugin.ts +0 -254
- package/src/audit/reads.test.ts +0 -164
- package/src/audit/reads.ts +0 -88
- package/src/audit/service.test.ts +0 -115
- package/src/audit/service.ts +0 -95
- package/src/auth/session.test.ts +0 -89
- package/src/auth/session.ts +0 -75
- package/src/config/env.test.ts +0 -28
- package/src/config/env.ts +0 -13
- package/src/database/connection.test.ts +0 -188
- package/src/database/connection.ts +0 -90
- package/src/entitlements/UsageMeter.ts +0 -49
- package/src/entitlements/client.test.ts +0 -200
- package/src/entitlements/client.ts +0 -179
- package/src/entitlements/definitions.test.ts +0 -161
- package/src/entitlements/definitions.ts +0 -268
- package/src/entitlements/index.ts +0 -42
- package/src/entitlements/middleware.test.ts +0 -196
- package/src/entitlements/middleware.ts +0 -150
- package/src/entitlements/reconcile.test.ts +0 -333
- package/src/entitlements/reconcile.ts +0 -384
- package/src/entitlements/types.ts +0 -21
- package/src/entitlements/usage.test.ts +0 -314
- package/src/entitlements/usage.ts +0 -223
- package/src/errors/HttpError.test.ts +0 -76
- package/src/errors/HttpError.ts +0 -91
- package/src/groups/client.test.ts +0 -215
- package/src/groups/client.ts +0 -182
- package/src/groups/index.ts +0 -7
- package/src/groups/membership.test.ts +0 -84
- package/src/groups/membership.ts +0 -133
- package/src/groups/subject.test.ts +0 -85
- package/src/groups/subject.ts +0 -50
- package/src/health/createHealthCheck.test.ts +0 -89
- package/src/health/createHealthCheck.ts +0 -67
- package/src/health/healthController.test.ts +0 -113
- package/src/health/healthController.ts +0 -56
- package/src/index.ts +0 -88
- package/src/logging/logger.test.ts +0 -91
- package/src/logging/logger.ts +0 -103
- package/src/metrics/index.test.ts +0 -116
- package/src/metrics/index.ts +0 -111
- package/src/middleware/authMiddleware.test.ts +0 -275
- package/src/middleware/authMiddleware.ts +0 -91
- package/src/middleware/corsMiddleware.test.ts +0 -135
- package/src/middleware/corsMiddleware.ts +0 -65
- package/src/middleware/errorHandler.test.ts +0 -188
- package/src/middleware/errorHandler.ts +0 -103
- package/src/middleware/internalServiceAuth.test.ts +0 -173
- package/src/middleware/internalServiceAuth.ts +0 -96
- package/src/middleware/requestLogger.test.ts +0 -81
- package/src/middleware/requestLogger.ts +0 -48
- package/src/middleware/security.test.ts +0 -45
- package/src/middleware/security.ts +0 -43
- package/src/middleware/validate.test.ts +0 -72
- package/src/middleware/validate.ts +0 -23
- package/src/oauth/index.ts +0 -29
- package/src/oauth/models.ts +0 -164
- package/src/oauth/service.test.ts +0 -432
- package/src/oauth/service.ts +0 -299
- package/src/oauth/tokens.test.ts +0 -146
- package/src/oauth/tokens.ts +0 -186
- package/src/testing/serviceTestSetup.ts +0 -17
- package/src/tracing/index.test.ts +0 -272
- package/src/tracing/index.ts +0 -110
- package/src/types/auth.ts +0 -45
- package/src/utils/correlation.test.ts +0 -47
- package/src/utils/correlation.ts +0 -22
- package/src/utils/permissionUtils.test.ts +0 -47
- package/src/utils/permissionUtils.ts +0 -68
- package/src/utils/response.test.ts +0 -64
- package/src/utils/response.ts +0 -60
- package/tsconfig.build.json +0 -7
- package/tsconfig.json +0 -23
package/src/groups/membership.ts
DELETED
|
@@ -1,133 +0,0 @@
|
|
|
1
|
-
import logger from '../logging/logger';
|
|
2
|
-
import { internalGroupUrl } from './client';
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Which groups a person belongs to, asked of group-service.
|
|
6
|
-
*
|
|
7
|
-
* This used to travel in the session token's `groups` claim, which made it only
|
|
8
|
-
* as fresh as the token: a new member could not see the group until their next
|
|
9
|
-
* renewal, and a removed one kept access until theirs, which is why membership
|
|
10
|
-
* changes used to sign people out. Asked here instead, with a short cache, a
|
|
11
|
-
* change reaches every service within CACHE_TTL_MS.
|
|
12
|
-
*
|
|
13
|
-
* Every request pays for this only once per person per service per cache
|
|
14
|
-
* window; concurrent callers share one request.
|
|
15
|
-
*/
|
|
16
|
-
const CACHE_TTL_MS = 30_000;
|
|
17
|
-
|
|
18
|
-
/** Bounded so a service under enumeration can't grow the cache without limit. */
|
|
19
|
-
const MAX_CACHE_ENTRIES = 5_000;
|
|
20
|
-
|
|
21
|
-
const REQUEST_TIMEOUT_MS = 3_000;
|
|
22
|
-
|
|
23
|
-
interface CacheEntry {
|
|
24
|
-
groups: string[];
|
|
25
|
-
expiresAt: number;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
const cache = new Map<string, CacheEntry>();
|
|
29
|
-
const inFlight = new Map<string, Promise<string[] | null>>();
|
|
30
|
-
|
|
31
|
-
function putInCache(email: string, groups: string[]): void {
|
|
32
|
-
if (!cache.has(email) && cache.size >= MAX_CACHE_ENTRIES) {
|
|
33
|
-
// Map iterates in insertion order, so the first key is the oldest write.
|
|
34
|
-
const oldest = cache.keys().next();
|
|
35
|
-
if (!oldest.done) cache.delete(oldest.value);
|
|
36
|
-
}
|
|
37
|
-
cache.set(email, { groups, expiresAt: Date.now() + CACHE_TTL_MS });
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
async function fetchUserGroupIds(email: string): Promise<string[] | null> {
|
|
41
|
-
const baseUrl = internalGroupUrl();
|
|
42
|
-
const token = process.env.INTERNAL_SERVICE_TOKEN;
|
|
43
|
-
|
|
44
|
-
if (!baseUrl || !token) {
|
|
45
|
-
logger.error('Group membership lookup not configured', {
|
|
46
|
-
hasGroupApiUrl: Boolean(baseUrl),
|
|
47
|
-
hasInternalToken: Boolean(token)
|
|
48
|
-
});
|
|
49
|
-
return null;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
try {
|
|
53
|
-
const response = await fetch(`${baseUrl}/api/groups/mine/ids?userEmail=${encodeURIComponent(email)}`, {
|
|
54
|
-
method: 'GET',
|
|
55
|
-
headers: {
|
|
56
|
-
'Content-Type': 'application/json',
|
|
57
|
-
'x-internal-token': token,
|
|
58
|
-
'x-service-id': process.env.SERVICE_NAME || 'unknown-service'
|
|
59
|
-
},
|
|
60
|
-
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
if (!response.ok) {
|
|
64
|
-
logger.error('Group membership lookup failed', { email, status: response.status });
|
|
65
|
-
return null;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
const body = (await response.json()) as { success?: boolean; data?: unknown };
|
|
69
|
-
if (!body.success || !Array.isArray(body.data)) {
|
|
70
|
-
logger.error('Group membership lookup returned no list', { email });
|
|
71
|
-
return null;
|
|
72
|
-
}
|
|
73
|
-
return body.data.filter((id): id is string => typeof id === 'string');
|
|
74
|
-
} catch (error) {
|
|
75
|
-
logger.error('Group membership lookup unreachable', { email, error: (error as Error)?.message });
|
|
76
|
-
return null;
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* The ids of the groups `email` belongs to right now.
|
|
82
|
-
*
|
|
83
|
-
* When group-service cannot be asked, the last answer is used even if it has
|
|
84
|
-
* expired, and failing that no groups at all: a person keeps their own data
|
|
85
|
-
* through an outage and loses group data until it ends, rather than being
|
|
86
|
-
* granted a group on a guess.
|
|
87
|
-
*/
|
|
88
|
-
export async function getUserGroupIds(email: string): Promise<string[]> {
|
|
89
|
-
const key = email.toLowerCase();
|
|
90
|
-
const entry = cache.get(key);
|
|
91
|
-
if (entry && entry.expiresAt > Date.now()) return entry.groups;
|
|
92
|
-
|
|
93
|
-
let pending = inFlight.get(key);
|
|
94
|
-
if (!pending) {
|
|
95
|
-
pending = fetchUserGroupIds(key).finally(() => inFlight.delete(key));
|
|
96
|
-
inFlight.set(key, pending);
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
const groups = await pending;
|
|
100
|
-
if (groups) {
|
|
101
|
-
putInCache(key, groups);
|
|
102
|
-
return groups;
|
|
103
|
-
}
|
|
104
|
-
if (entry) {
|
|
105
|
-
logger.warn('Using the last known group membership while group-service is unreachable', { email: key });
|
|
106
|
-
return entry.groups;
|
|
107
|
-
}
|
|
108
|
-
return [];
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/** For tests. */
|
|
112
|
-
export function clearMembershipCache(): void {
|
|
113
|
-
cache.clear();
|
|
114
|
-
inFlight.clear();
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
type MembershipLookup = (user: { email: string; groups?: string[] }) => Promise<string[]>;
|
|
118
|
-
|
|
119
|
-
const lookUpMembership: MembershipLookup = (user) => getUserGroupIds(user.email);
|
|
120
|
-
let membershipLookup: MembershipLookup = lookUpMembership;
|
|
121
|
-
|
|
122
|
-
/** The groups the auth middlewares put on `req.userGroups`. */
|
|
123
|
-
export const currentGroupsOf = (user: { email: string; groups?: string[] }): Promise<string[]> =>
|
|
124
|
-
membershipLookup(user);
|
|
125
|
-
|
|
126
|
-
/**
|
|
127
|
-
* Replaces the lookup, for tests only; pass nothing to restore the real one.
|
|
128
|
-
* Service suites reach it through `@tumbaland/backend-core/testing`, which makes
|
|
129
|
-
* a test token's own `groups` claim the membership, so they need no group-service.
|
|
130
|
-
*/
|
|
131
|
-
export function overrideMembershipLookupForTests(lookup?: MembershipLookup): void {
|
|
132
|
-
membershipLookup = lookup ?? lookUpMembership;
|
|
133
|
-
}
|
|
@@ -1,85 +0,0 @@
|
|
|
1
|
-
jest.mock('../logging/logger', () => ({
|
|
2
|
-
__esModule: true,
|
|
3
|
-
default: { error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn(), http: jest.fn() }
|
|
4
|
-
}));
|
|
5
|
-
|
|
6
|
-
import { Request } from 'express';
|
|
7
|
-
import logger from '../logging/logger';
|
|
8
|
-
import { clearGroupOwnerCache } from './client';
|
|
9
|
-
import { groupOwnerSubject } from './subject';
|
|
10
|
-
|
|
11
|
-
const ORIGINAL_ENV = process.env;
|
|
12
|
-
const ORIGINAL_FETCH = global.fetch;
|
|
13
|
-
|
|
14
|
-
function ownerResponse(ownerEmail = 'owner@example.com'): Response {
|
|
15
|
-
return {
|
|
16
|
-
ok: true,
|
|
17
|
-
status: 200,
|
|
18
|
-
json: async () => ({ success: true, groupId: 'group-1', ownerEmail, memberCount: 2, deleted: false })
|
|
19
|
-
} as Response;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
const request = (overrides: Partial<Request> = {}) =>
|
|
23
|
-
({ user: { id: '1', email: 'member@example.com', name: 'Member' }, originalUrl: '/api/photos', ...overrides }) as Request;
|
|
24
|
-
|
|
25
|
-
beforeEach(() => {
|
|
26
|
-
process.env = {
|
|
27
|
-
...ORIGINAL_ENV,
|
|
28
|
-
GROUP_API_URL: 'http://group-service:5006',
|
|
29
|
-
INTERNAL_SERVICE_TOKEN: 'test-internal-token'
|
|
30
|
-
};
|
|
31
|
-
clearGroupOwnerCache();
|
|
32
|
-
global.fetch = jest.fn();
|
|
33
|
-
(logger.error as jest.Mock).mockClear();
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
afterAll(() => {
|
|
37
|
-
process.env = ORIGINAL_ENV;
|
|
38
|
-
global.fetch = ORIGINAL_FETCH;
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
describe('groupOwnerSubject', () => {
|
|
42
|
-
it('bills a group resource to the group owner, not the uploader', async () => {
|
|
43
|
-
(global.fetch as jest.Mock).mockResolvedValue(ownerResponse());
|
|
44
|
-
|
|
45
|
-
const subject = groupOwnerSubject(() => 'group-1');
|
|
46
|
-
|
|
47
|
-
expect(await subject(request())).toBe('owner@example.com');
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
it('bills a personal resource to the caller and never asks group-service', async () => {
|
|
51
|
-
const subject = groupOwnerSubject(() => undefined);
|
|
52
|
-
|
|
53
|
-
expect(await subject(request())).toBe('member@example.com');
|
|
54
|
-
expect(global.fetch).not.toHaveBeenCalled();
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
it('accepts an async group-id resolver', async () => {
|
|
58
|
-
(global.fetch as jest.Mock).mockResolvedValue(ownerResponse());
|
|
59
|
-
|
|
60
|
-
const subject = groupOwnerSubject(async () => 'group-1');
|
|
61
|
-
|
|
62
|
-
expect(await subject(request())).toBe('owner@example.com');
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
it('falls back to the caller and logs when the owner cannot be resolved', async () => {
|
|
66
|
-
(global.fetch as jest.Mock).mockRejectedValue(new Error('ECONNREFUSED'));
|
|
67
|
-
|
|
68
|
-
const subject = groupOwnerSubject(() => 'group-1');
|
|
69
|
-
|
|
70
|
-
// Billing the caller checks the write against their own — typically
|
|
71
|
-
// smaller — allowance rather than refusing it outright, matching how the
|
|
72
|
-
// entitlement client degrades for the same class of outage.
|
|
73
|
-
expect(await subject(request())).toBe('member@example.com');
|
|
74
|
-
expect(logger.error).toHaveBeenCalledWith(
|
|
75
|
-
'Group owner unresolved; billing usage to the caller instead',
|
|
76
|
-
expect.objectContaining({ groupId: 'group-1', caller: 'member@example.com' })
|
|
77
|
-
);
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
it('returns undefined when there is no caller and no resolvable group, so the middleware 401s', async () => {
|
|
81
|
-
const subject = groupOwnerSubject(() => undefined);
|
|
82
|
-
|
|
83
|
-
expect(await subject(request({ user: undefined }))).toBeUndefined();
|
|
84
|
-
});
|
|
85
|
-
});
|
package/src/groups/subject.ts
DELETED
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
import { Request } from 'express';
|
|
2
|
-
import logger from '../logging/logger';
|
|
3
|
-
import { SubjectResolver } from '../entitlements/middleware';
|
|
4
|
-
import { getGroupOwner } from './client';
|
|
5
|
-
|
|
6
|
-
export type GroupIdResolver = (req: Request) => string | undefined | Promise<string | undefined>;
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Builds the `subject` resolver that `requireQuota` / `requireEntitlement`
|
|
10
|
-
* already accept, for the meters whose resource can live in a group.
|
|
11
|
-
*
|
|
12
|
-
* ```ts
|
|
13
|
-
* requireQuota('storageBytes', {
|
|
14
|
-
* subject: groupOwnerSubject(req => req.body.groupId),
|
|
15
|
-
* amount: req => req.body.sizeBytes
|
|
16
|
-
* })
|
|
17
|
-
* ```
|
|
18
|
-
*
|
|
19
|
-
* Personal rows keep billing to the caller; group rows bill to the group's
|
|
20
|
-
* owner, because Family sells *pooled* storage and seats and the uploader's own
|
|
21
|
-
* free tier is not the pool.
|
|
22
|
-
*
|
|
23
|
-
* **When the owner cannot be resolved, it bills the caller and says so.** The
|
|
24
|
-
* alternative — refusing the write — is harsher than what the entitlement
|
|
25
|
-
* client itself does for the same class of outage (it degrades to free limits
|
|
26
|
-
* rather than denying), and the error direction here is bounded in the shape
|
|
27
|
-
* that matters: the common case is a member on a cheap personal plan uploading
|
|
28
|
-
* into a group owned by someone on Family, so falling back to the caller checks
|
|
29
|
-
* against the *smaller* allowance. It can over-grant when the caller happens to
|
|
30
|
-
* hold the richer plan, which is why it logs at `error` — a run of these means
|
|
31
|
-
* group-service is down, not that a limit is wrong.
|
|
32
|
-
*/
|
|
33
|
-
export function groupOwnerSubject(resolveGroupId: GroupIdResolver): SubjectResolver {
|
|
34
|
-
return async (req: Request) => {
|
|
35
|
-
const caller = req.user?.email;
|
|
36
|
-
const groupId = await resolveGroupId(req);
|
|
37
|
-
if (!groupId) return caller;
|
|
38
|
-
|
|
39
|
-
const owner = await getGroupOwner(String(groupId));
|
|
40
|
-
if (owner) return owner.ownerEmail;
|
|
41
|
-
|
|
42
|
-
logger.error('Group owner unresolved; billing usage to the caller instead', {
|
|
43
|
-
event: 'entitlement.subject_fallback',
|
|
44
|
-
groupId: String(groupId),
|
|
45
|
-
caller,
|
|
46
|
-
route: req.originalUrl
|
|
47
|
-
});
|
|
48
|
-
return caller;
|
|
49
|
-
};
|
|
50
|
-
}
|
|
@@ -1,89 +0,0 @@
|
|
|
1
|
-
import { Request, Response } from 'express';
|
|
2
|
-
import mongoose from 'mongoose';
|
|
3
|
-
import { createHealthCheck } from './createHealthCheck';
|
|
4
|
-
|
|
5
|
-
function mockRes(): Response {
|
|
6
|
-
const res: Partial<Response> = {};
|
|
7
|
-
res.status = jest.fn().mockReturnValue(res);
|
|
8
|
-
res.json = jest.fn().mockReturnValue(res);
|
|
9
|
-
return res as Response;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
describe('createHealthCheck', () => {
|
|
13
|
-
const config = { service: 'demo-service', description: 'Demo service' };
|
|
14
|
-
|
|
15
|
-
afterEach(() => {
|
|
16
|
-
jest.restoreAllMocks();
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
it('responds 200 with dependencies.mongodb "connected" when Mongo is up', async () => {
|
|
20
|
-
jest.spyOn(mongoose, 'connection', 'get').mockReturnValue({ readyState: 1 } as never);
|
|
21
|
-
const res = mockRes();
|
|
22
|
-
|
|
23
|
-
await createHealthCheck(config)({} as Request, res);
|
|
24
|
-
|
|
25
|
-
expect(res.status).toHaveBeenCalledWith(200);
|
|
26
|
-
expect(res.json).toHaveBeenCalledWith(
|
|
27
|
-
expect.objectContaining({
|
|
28
|
-
status: 'ok',
|
|
29
|
-
service: 'demo-service',
|
|
30
|
-
type: 'backend',
|
|
31
|
-
version: '1.0.0',
|
|
32
|
-
description: 'Demo service',
|
|
33
|
-
dependencies: { mongodb: 'connected' }
|
|
34
|
-
})
|
|
35
|
-
);
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
it('responds 503 with dependencies.mongodb "disconnected" when Mongo is down', async () => {
|
|
39
|
-
jest.spyOn(mongoose, 'connection', 'get').mockReturnValue({ readyState: 0 } as never);
|
|
40
|
-
const res = mockRes();
|
|
41
|
-
|
|
42
|
-
await createHealthCheck(config)({} as Request, res);
|
|
43
|
-
|
|
44
|
-
expect(res.status).toHaveBeenCalledWith(503);
|
|
45
|
-
expect(res.json).toHaveBeenCalledWith(
|
|
46
|
-
expect.objectContaining({ status: 'error', dependencies: { mongodb: 'disconnected' } })
|
|
47
|
-
);
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
it('honors an explicit version override', async () => {
|
|
51
|
-
jest.spyOn(mongoose, 'connection', 'get').mockReturnValue({ readyState: 1 } as never);
|
|
52
|
-
const res = mockRes();
|
|
53
|
-
|
|
54
|
-
await createHealthCheck({ ...config, version: '2.3.4' })({} as Request, res);
|
|
55
|
-
|
|
56
|
-
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ version: '2.3.4' }));
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
it('responds 503 with a generic error body when reading connection state throws', async () => {
|
|
60
|
-
jest.spyOn(mongoose, 'connection', 'get').mockImplementation(() => {
|
|
61
|
-
throw new Error('boom');
|
|
62
|
-
});
|
|
63
|
-
const res = mockRes();
|
|
64
|
-
|
|
65
|
-
await createHealthCheck(config)({} as Request, res);
|
|
66
|
-
|
|
67
|
-
expect(res.status).toHaveBeenCalledWith(503);
|
|
68
|
-
expect(res.json).toHaveBeenCalledWith(
|
|
69
|
-
expect.objectContaining({ status: 'error', service: 'demo-service', error: 'Health check failed' })
|
|
70
|
-
);
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
describe('checkDatabase: false (DB-less services)', () => {
|
|
74
|
-
it('always responds 200 ok without touching Mongo or emitting dependencies', async () => {
|
|
75
|
-
const connectionSpy = jest.spyOn(mongoose, 'connection', 'get');
|
|
76
|
-
const res = mockRes();
|
|
77
|
-
|
|
78
|
-
await createHealthCheck({ ...config, checkDatabase: false })({} as Request, res);
|
|
79
|
-
|
|
80
|
-
expect(connectionSpy).not.toHaveBeenCalled();
|
|
81
|
-
expect(res.status).toHaveBeenCalledWith(200);
|
|
82
|
-
const body = (res.json as jest.Mock).mock.calls[0][0];
|
|
83
|
-
expect(body).toEqual(
|
|
84
|
-
expect.objectContaining({ status: 'ok', service: 'demo-service', type: 'backend', description: 'Demo service' })
|
|
85
|
-
);
|
|
86
|
-
expect(body).not.toHaveProperty('dependencies');
|
|
87
|
-
});
|
|
88
|
-
});
|
|
89
|
-
});
|
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
import { Request, Response } from 'express';
|
|
2
|
-
import mongoose from 'mongoose';
|
|
3
|
-
|
|
4
|
-
export interface HealthCheckConfig {
|
|
5
|
-
/** Service name reported in the body, e.g. `auth-service`. */
|
|
6
|
-
service: string;
|
|
7
|
-
/** Human-readable service description. */
|
|
8
|
-
description: string;
|
|
9
|
-
/** Reported version. Defaults to `1.0.0`. */
|
|
10
|
-
version?: string;
|
|
11
|
-
/**
|
|
12
|
-
* Whether readiness depends on the MongoDB connection. Defaults to `true`:
|
|
13
|
-
* the response reports `dependencies.mongodb` and returns 503 while
|
|
14
|
-
* disconnected. Set `false` for services with no database (e.g.
|
|
15
|
-
* public-service), which then always report `ok` with no `dependencies`.
|
|
16
|
-
*/
|
|
17
|
-
checkDatabase?: boolean;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* The single, unified `/health` readiness handler for every backend service.
|
|
22
|
-
*
|
|
23
|
-
* Response contract (identical across services):
|
|
24
|
-
* { status, service, type: 'backend', timestamp, version, description }
|
|
25
|
-
* plus, when `checkDatabase` is `true` (the default), a
|
|
26
|
-
* `dependencies.mongodb` field and a 503 while the connection is down. A
|
|
27
|
-
* DB-less service (`checkDatabase: false`) omits `dependencies` and always
|
|
28
|
-
* reports `ok`. On an unexpected failure the handler returns 503 with a
|
|
29
|
-
* generic `error: 'Health check failed'` body.
|
|
30
|
-
*
|
|
31
|
-
* `/health/live` (liveness, no dependency checks) is mounted separately by
|
|
32
|
-
* `createBaseApp`.
|
|
33
|
-
*/
|
|
34
|
-
export const createHealthCheck = ({
|
|
35
|
-
service,
|
|
36
|
-
description,
|
|
37
|
-
version = '1.0.0',
|
|
38
|
-
checkDatabase = true
|
|
39
|
-
}: HealthCheckConfig) => {
|
|
40
|
-
return async (_req: Request, res: Response): Promise<void> => {
|
|
41
|
-
const timestamp = new Date().toISOString();
|
|
42
|
-
const base = { service, type: 'backend', timestamp, version, description };
|
|
43
|
-
|
|
44
|
-
if (!checkDatabase) {
|
|
45
|
-
res.status(200).json({ status: 'ok', ...base });
|
|
46
|
-
return;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
try {
|
|
50
|
-
const isMongoHealthy = mongoose.connection.readyState === 1; // 1 = connected
|
|
51
|
-
|
|
52
|
-
res.status(isMongoHealthy ? 200 : 503).json({
|
|
53
|
-
status: isMongoHealthy ? 'ok' : 'error',
|
|
54
|
-
...base,
|
|
55
|
-
dependencies: {
|
|
56
|
-
mongodb: isMongoHealthy ? 'connected' : 'disconnected'
|
|
57
|
-
}
|
|
58
|
-
});
|
|
59
|
-
} catch {
|
|
60
|
-
res.status(503).json({
|
|
61
|
-
status: 'error',
|
|
62
|
-
...base,
|
|
63
|
-
error: 'Health check failed'
|
|
64
|
-
});
|
|
65
|
-
}
|
|
66
|
-
};
|
|
67
|
-
};
|
|
@@ -1,113 +0,0 @@
|
|
|
1
|
-
import { Request, Response } from 'express';
|
|
2
|
-
import mongoose from 'mongoose';
|
|
3
|
-
import { healthCheck, healthLive, metricsHandler } from './healthController';
|
|
4
|
-
|
|
5
|
-
function mockRes(): Response {
|
|
6
|
-
const res: Partial<Response> = {};
|
|
7
|
-
res.status = jest.fn().mockReturnValue(res);
|
|
8
|
-
res.json = jest.fn().mockReturnValue(res);
|
|
9
|
-
return res as Response;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
describe('healthLive', () => {
|
|
13
|
-
it('always responds 200 ok, independent of any dependency state', () => {
|
|
14
|
-
const res = mockRes();
|
|
15
|
-
healthLive({} as Request, res);
|
|
16
|
-
|
|
17
|
-
expect(res.status).toHaveBeenCalledWith(200);
|
|
18
|
-
expect(res.json).toHaveBeenCalledWith({ status: 'ok' });
|
|
19
|
-
});
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
describe('healthCheck', () => {
|
|
23
|
-
const ORIGINAL_ENV = process.env;
|
|
24
|
-
|
|
25
|
-
beforeEach(() => {
|
|
26
|
-
process.env = { ...ORIGINAL_ENV };
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
afterAll(() => {
|
|
30
|
-
process.env = ORIGINAL_ENV;
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
it('responds 200 with dependencies.mongodb "connected" when Mongo readyState is 1', async () => {
|
|
34
|
-
jest.spyOn(mongoose, 'connection', 'get').mockReturnValue({ readyState: 1 } as any);
|
|
35
|
-
process.env.SERVICE_NAME = 'album-service';
|
|
36
|
-
const res = mockRes();
|
|
37
|
-
|
|
38
|
-
await healthCheck({} as Request, res);
|
|
39
|
-
|
|
40
|
-
expect(res.status).toHaveBeenCalledWith(200);
|
|
41
|
-
expect(res.json).toHaveBeenCalledWith(
|
|
42
|
-
expect.objectContaining({
|
|
43
|
-
status: 'ok',
|
|
44
|
-
service: 'album-service',
|
|
45
|
-
dependencies: { mongodb: 'connected' }
|
|
46
|
-
})
|
|
47
|
-
);
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
it('responds 503 with dependencies.mongodb "disconnected" when Mongo readyState is not 1', async () => {
|
|
51
|
-
jest.spyOn(mongoose, 'connection', 'get').mockReturnValue({ readyState: 0 } as any);
|
|
52
|
-
const res = mockRes();
|
|
53
|
-
|
|
54
|
-
await healthCheck({} as Request, res);
|
|
55
|
-
|
|
56
|
-
expect(res.status).toHaveBeenCalledWith(503);
|
|
57
|
-
expect(res.json).toHaveBeenCalledWith(
|
|
58
|
-
expect.objectContaining({ status: 'error', dependencies: { mongodb: 'disconnected' } })
|
|
59
|
-
);
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
it('falls back to default service/version/description when env vars are unset', async () => {
|
|
63
|
-
delete process.env.SERVICE_NAME;
|
|
64
|
-
delete process.env.npm_package_version;
|
|
65
|
-
delete process.env.SERVICE_DESCRIPTION;
|
|
66
|
-
jest.spyOn(mongoose, 'connection', 'get').mockReturnValue({ readyState: 1 } as any);
|
|
67
|
-
const res = mockRes();
|
|
68
|
-
|
|
69
|
-
await healthCheck({} as Request, res);
|
|
70
|
-
|
|
71
|
-
expect(res.json).toHaveBeenCalledWith(
|
|
72
|
-
expect.objectContaining({
|
|
73
|
-
service: 'unknown-service',
|
|
74
|
-
version: '1.0.0',
|
|
75
|
-
description: 'Backend service'
|
|
76
|
-
})
|
|
77
|
-
);
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
it('responds 503 with a generic error body when reading connection state throws', async () => {
|
|
81
|
-
jest.spyOn(mongoose, 'connection', 'get').mockImplementation(() => {
|
|
82
|
-
throw new Error('boom');
|
|
83
|
-
});
|
|
84
|
-
const res = mockRes();
|
|
85
|
-
|
|
86
|
-
await healthCheck({} as Request, res);
|
|
87
|
-
|
|
88
|
-
expect(res.status).toHaveBeenCalledWith(503);
|
|
89
|
-
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ status: 'error', error: 'Health check failed' }));
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
it('falls back to default service/version/description in the error body when env vars are unset', async () => {
|
|
93
|
-
delete process.env.SERVICE_NAME;
|
|
94
|
-
delete process.env.npm_package_version;
|
|
95
|
-
delete process.env.SERVICE_DESCRIPTION;
|
|
96
|
-
jest.spyOn(mongoose, 'connection', 'get').mockImplementation(() => {
|
|
97
|
-
throw new Error('boom');
|
|
98
|
-
});
|
|
99
|
-
const res = mockRes();
|
|
100
|
-
|
|
101
|
-
await healthCheck({} as Request, res);
|
|
102
|
-
|
|
103
|
-
expect(res.json).toHaveBeenCalledWith(
|
|
104
|
-
expect.objectContaining({ service: 'unknown-service', version: '1.0.0', description: 'Backend service' })
|
|
105
|
-
);
|
|
106
|
-
});
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
describe('metricsHandler re-export', () => {
|
|
110
|
-
it('re-exports the same metricsHandler function from ../metrics', () => {
|
|
111
|
-
expect(typeof metricsHandler).toBe('function');
|
|
112
|
-
});
|
|
113
|
-
});
|
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
import { Request, Response } from 'express';
|
|
2
|
-
import mongoose from 'mongoose';
|
|
3
|
-
import { metricsHandler } from '../metrics';
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Health check endpoint with database connectivity
|
|
7
|
-
* Returns comprehensive health status for monitoring
|
|
8
|
-
*/
|
|
9
|
-
export const healthCheck = async (req: Request, res: Response) => {
|
|
10
|
-
try {
|
|
11
|
-
// Check MongoDB connection
|
|
12
|
-
const mongoState = mongoose.connection.readyState;
|
|
13
|
-
const isMongoHealthy = mongoState === 1; // 1 = connected
|
|
14
|
-
|
|
15
|
-
const healthResponse = {
|
|
16
|
-
status: isMongoHealthy ? 'ok' : 'error',
|
|
17
|
-
service: process.env.SERVICE_NAME || 'unknown-service',
|
|
18
|
-
type: 'backend',
|
|
19
|
-
timestamp: new Date().toISOString(),
|
|
20
|
-
version: process.env.npm_package_version || '1.0.0',
|
|
21
|
-
description: process.env.SERVICE_DESCRIPTION || 'Backend service',
|
|
22
|
-
dependencies: {
|
|
23
|
-
mongodb: isMongoHealthy ? 'connected' : 'disconnected'
|
|
24
|
-
}
|
|
25
|
-
};
|
|
26
|
-
|
|
27
|
-
res.status(isMongoHealthy ? 200 : 503).json(healthResponse);
|
|
28
|
-
} catch (error) {
|
|
29
|
-
res.status(503).json({
|
|
30
|
-
status: 'error',
|
|
31
|
-
service: process.env.SERVICE_NAME || 'unknown-service',
|
|
32
|
-
type: 'backend',
|
|
33
|
-
timestamp: new Date().toISOString(),
|
|
34
|
-
version: process.env.npm_package_version || '1.0.0',
|
|
35
|
-
description: process.env.SERVICE_DESCRIPTION || 'Backend service',
|
|
36
|
-
error: 'Health check failed'
|
|
37
|
-
});
|
|
38
|
-
}
|
|
39
|
-
};
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* Liveness check: confirms the process is up and serving HTTP, without
|
|
43
|
-
* touching MongoDB or any other dependency. Used by the Docker-level
|
|
44
|
-
* HEALTHCHECK so a transient DB outage doesn't get reported as the
|
|
45
|
-
* container itself being unhealthy — `/health` (above) is the DB-aware
|
|
46
|
-
* readiness check for dashboards/monitoring.
|
|
47
|
-
*/
|
|
48
|
-
export const healthLive = (req: Request, res: Response) => {
|
|
49
|
-
res.status(200).json({ status: 'ok' });
|
|
50
|
-
};
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Prometheus metrics endpoint
|
|
54
|
-
* Exposes application metrics for monitoring
|
|
55
|
-
*/
|
|
56
|
-
export { metricsHandler };
|
package/src/index.ts
DELETED
|
@@ -1,88 +0,0 @@
|
|
|
1
|
-
// @tumbaland/backend-core - Core shared functionality for Tumbaland backend services
|
|
2
|
-
|
|
3
|
-
// App bootstrap
|
|
4
|
-
export { createBaseApp } from './app/createBaseApp';
|
|
5
|
-
export type { CreateBaseAppOptions } from './app/createBaseApp';
|
|
6
|
-
export { registerShutdown } from './app/shutdown';
|
|
7
|
-
export type { RegisterShutdownOptions } from './app/shutdown';
|
|
8
|
-
|
|
9
|
-
// Logging
|
|
10
|
-
export { default as logger } from './logging/logger';
|
|
11
|
-
|
|
12
|
-
// Health checks
|
|
13
|
-
export { healthCheck, healthLive, metricsHandler } from './health/healthController';
|
|
14
|
-
export { createHealthCheck } from './health/createHealthCheck';
|
|
15
|
-
export type { HealthCheckConfig } from './health/createHealthCheck';
|
|
16
|
-
|
|
17
|
-
// Database
|
|
18
|
-
export { connectDB, disconnectDB } from './database/connection';
|
|
19
|
-
|
|
20
|
-
// Config
|
|
21
|
-
export { requireEnv } from './config/env';
|
|
22
|
-
|
|
23
|
-
// Errors
|
|
24
|
-
export { HttpError, BadRequestError, UnauthorizedError, ForbiddenError, NotFoundError, ConflictError, TooManyRequestsError, PaymentRequiredError } from './errors/HttpError';
|
|
25
|
-
export type { PaymentRequiredDetails } from './errors/HttpError';
|
|
26
|
-
|
|
27
|
-
// Entitlements (plan limits, usage meters, enforcement)
|
|
28
|
-
export * from './entitlements';
|
|
29
|
-
|
|
30
|
-
// Group ownership (the quota subject for anything pooled)
|
|
31
|
-
export * from './groups';
|
|
32
|
-
|
|
33
|
-
// API keys (non-browser clients: MCP servers, scripts, agents)
|
|
34
|
-
export * from './apiKeys';
|
|
35
|
-
|
|
36
|
-
// OAuth 2.1 authorization server, for assistants connecting over MCP
|
|
37
|
-
export * from './oauth';
|
|
38
|
-
export * from './audit';
|
|
39
|
-
|
|
40
|
-
// Middleware
|
|
41
|
-
export { authenticateToken, optionalAuth } from './middleware/authMiddleware';
|
|
42
|
-
export { USER_SESSIONS_COLLECTION, isCurrentSession, isLegacySessionlessToken } from './auth/session';
|
|
43
|
-
export {
|
|
44
|
-
requireInternalServiceToken,
|
|
45
|
-
internalServiceTokenBypass,
|
|
46
|
-
allowUserOrInternalService,
|
|
47
|
-
verifyInternalToken,
|
|
48
|
-
INTERNAL_TOKEN_HEADER,
|
|
49
|
-
SERVICE_ID_HEADER
|
|
50
|
-
} from './middleware/internalServiceAuth';
|
|
51
|
-
export { createCorsMiddleware } from './middleware/corsMiddleware';
|
|
52
|
-
export { errorHandler } from './middleware/errorHandler';
|
|
53
|
-
export { requestLogger, requestLoggerWithMetrics, simpleRequestLogger } from './middleware/requestLogger';
|
|
54
|
-
export { securityHeaders, createRateLimiter, standardRateLimiter, strictRateLimiter } from './middleware/security';
|
|
55
|
-
export { validate } from './middleware/validate';
|
|
56
|
-
|
|
57
|
-
// Types
|
|
58
|
-
export type { UserPayload } from './types/auth';
|
|
59
|
-
|
|
60
|
-
// Utils
|
|
61
|
-
export { generateCorrelationId, correlationMiddleware } from './utils/correlation';
|
|
62
|
-
export { createApiResponse, sendSuccess, sendError } from './utils/response';
|
|
63
|
-
export type { ApiResponse } from './utils/response';
|
|
64
|
-
export { buildAccessQuery, canAccessResource } from './utils/permissionUtils';
|
|
65
|
-
|
|
66
|
-
// Metrics
|
|
67
|
-
export {
|
|
68
|
-
metricsMiddleware,
|
|
69
|
-
httpRequestDuration,
|
|
70
|
-
httpRequestsTotal,
|
|
71
|
-
databaseQueryDuration,
|
|
72
|
-
databaseQueriesTotal,
|
|
73
|
-
businessMetrics,
|
|
74
|
-
register
|
|
75
|
-
} from './metrics';
|
|
76
|
-
|
|
77
|
-
// Tracing
|
|
78
|
-
export {
|
|
79
|
-
initTracer,
|
|
80
|
-
getTracer,
|
|
81
|
-
startSpan,
|
|
82
|
-
tracingMiddleware,
|
|
83
|
-
createChildSpan,
|
|
84
|
-
logToSpan,
|
|
85
|
-
setSpanTag,
|
|
86
|
-
injectHeaders,
|
|
87
|
-
extractSpanContext
|
|
88
|
-
} from './tracing';
|