@tumbaland/backend-core 1.44.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.
Files changed (101) hide show
  1. package/package.json +5 -1
  2. package/.versionrc.json +0 -7
  3. package/__mocks__/uuid.js +0 -8
  4. package/jest.config.js +0 -24
  5. package/src/apiKeys/ApiKey.test.ts +0 -142
  6. package/src/apiKeys/ApiKey.ts +0 -102
  7. package/src/apiKeys/crypto.test.ts +0 -161
  8. package/src/apiKeys/crypto.ts +0 -163
  9. package/src/apiKeys/index.test.ts +0 -72
  10. package/src/apiKeys/index.ts +0 -35
  11. package/src/apiKeys/middleware.test.ts +0 -651
  12. package/src/apiKeys/middleware.ts +0 -341
  13. package/src/apiKeys/service.test.ts +0 -401
  14. package/src/apiKeys/service.ts +0 -168
  15. package/src/apiKeys/types.test.ts +0 -41
  16. package/src/apiKeys/types.ts +0 -124
  17. package/src/app/createBaseApp.test.ts +0 -109
  18. package/src/app/createBaseApp.ts +0 -102
  19. package/src/app/shutdown.test.ts +0 -129
  20. package/src/app/shutdown.ts +0 -81
  21. package/src/audit/AuditEvent.ts +0 -123
  22. package/src/audit/actor.test.ts +0 -95
  23. package/src/audit/actor.ts +0 -68
  24. package/src/audit/context.test.ts +0 -91
  25. package/src/audit/context.ts +0 -83
  26. package/src/audit/index.ts +0 -11
  27. package/src/audit/plugin.test.ts +0 -258
  28. package/src/audit/plugin.ts +0 -254
  29. package/src/audit/reads.test.ts +0 -164
  30. package/src/audit/reads.ts +0 -88
  31. package/src/audit/service.test.ts +0 -115
  32. package/src/audit/service.ts +0 -95
  33. package/src/auth/session.test.ts +0 -89
  34. package/src/auth/session.ts +0 -75
  35. package/src/config/env.test.ts +0 -28
  36. package/src/config/env.ts +0 -13
  37. package/src/database/connection.test.ts +0 -188
  38. package/src/database/connection.ts +0 -90
  39. package/src/entitlements/UsageMeter.ts +0 -49
  40. package/src/entitlements/client.test.ts +0 -200
  41. package/src/entitlements/client.ts +0 -179
  42. package/src/entitlements/definitions.test.ts +0 -161
  43. package/src/entitlements/definitions.ts +0 -268
  44. package/src/entitlements/index.ts +0 -42
  45. package/src/entitlements/middleware.test.ts +0 -196
  46. package/src/entitlements/middleware.ts +0 -150
  47. package/src/entitlements/reconcile.test.ts +0 -333
  48. package/src/entitlements/reconcile.ts +0 -384
  49. package/src/entitlements/types.ts +0 -21
  50. package/src/entitlements/usage.test.ts +0 -314
  51. package/src/entitlements/usage.ts +0 -223
  52. package/src/errors/HttpError.test.ts +0 -76
  53. package/src/errors/HttpError.ts +0 -91
  54. package/src/groups/client.test.ts +0 -215
  55. package/src/groups/client.ts +0 -182
  56. package/src/groups/index.ts +0 -7
  57. package/src/groups/membership.test.ts +0 -84
  58. package/src/groups/membership.ts +0 -133
  59. package/src/groups/subject.test.ts +0 -85
  60. package/src/groups/subject.ts +0 -50
  61. package/src/health/createHealthCheck.test.ts +0 -89
  62. package/src/health/createHealthCheck.ts +0 -67
  63. package/src/health/healthController.test.ts +0 -113
  64. package/src/health/healthController.ts +0 -56
  65. package/src/index.ts +0 -88
  66. package/src/logging/logger.test.ts +0 -91
  67. package/src/logging/logger.ts +0 -103
  68. package/src/metrics/index.test.ts +0 -116
  69. package/src/metrics/index.ts +0 -111
  70. package/src/middleware/authMiddleware.test.ts +0 -275
  71. package/src/middleware/authMiddleware.ts +0 -91
  72. package/src/middleware/corsMiddleware.test.ts +0 -135
  73. package/src/middleware/corsMiddleware.ts +0 -65
  74. package/src/middleware/errorHandler.test.ts +0 -188
  75. package/src/middleware/errorHandler.ts +0 -103
  76. package/src/middleware/internalServiceAuth.test.ts +0 -173
  77. package/src/middleware/internalServiceAuth.ts +0 -96
  78. package/src/middleware/requestLogger.test.ts +0 -81
  79. package/src/middleware/requestLogger.ts +0 -48
  80. package/src/middleware/security.test.ts +0 -45
  81. package/src/middleware/security.ts +0 -43
  82. package/src/middleware/validate.test.ts +0 -72
  83. package/src/middleware/validate.ts +0 -23
  84. package/src/oauth/index.ts +0 -29
  85. package/src/oauth/models.ts +0 -164
  86. package/src/oauth/service.test.ts +0 -432
  87. package/src/oauth/service.ts +0 -299
  88. package/src/oauth/tokens.test.ts +0 -146
  89. package/src/oauth/tokens.ts +0 -186
  90. package/src/testing/serviceTestSetup.ts +0 -17
  91. package/src/tracing/index.test.ts +0 -272
  92. package/src/tracing/index.ts +0 -110
  93. package/src/types/auth.ts +0 -45
  94. package/src/utils/correlation.test.ts +0 -47
  95. package/src/utils/correlation.ts +0 -22
  96. package/src/utils/permissionUtils.test.ts +0 -47
  97. package/src/utils/permissionUtils.ts +0 -68
  98. package/src/utils/response.test.ts +0 -64
  99. package/src/utils/response.ts +0 -60
  100. package/tsconfig.build.json +0 -7
  101. package/tsconfig.json +0 -23
@@ -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
- });
@@ -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';