@tumbaland/backend-core 1.44.0 → 1.46.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 (123) hide show
  1. package/dist/fileService/client.d.ts +54 -0
  2. package/dist/fileService/client.d.ts.map +1 -0
  3. package/dist/fileService/client.js +137 -0
  4. package/dist/fileService/client.js.map +1 -0
  5. package/dist/fileService/index.d.ts +4 -0
  6. package/dist/fileService/index.d.ts.map +1 -0
  7. package/dist/fileService/index.js +40 -0
  8. package/dist/fileService/index.js.map +1 -0
  9. package/dist/index.d.ts +2 -0
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +6 -2
  12. package/dist/index.js.map +1 -1
  13. package/dist/middleware/corsMiddleware.d.ts.map +1 -1
  14. package/dist/middleware/corsMiddleware.js +4 -1
  15. package/dist/middleware/corsMiddleware.js.map +1 -1
  16. package/dist/middleware/errorHandler.d.ts.map +1 -1
  17. package/dist/middleware/errorHandler.js +5 -8
  18. package/dist/middleware/errorHandler.js.map +1 -1
  19. package/dist/utils/escapeRegex.d.ts +8 -0
  20. package/dist/utils/escapeRegex.d.ts.map +1 -0
  21. package/dist/utils/escapeRegex.js +12 -0
  22. package/dist/utils/escapeRegex.js.map +1 -0
  23. package/package.json +5 -1
  24. package/.versionrc.json +0 -7
  25. package/__mocks__/uuid.js +0 -8
  26. package/jest.config.js +0 -24
  27. package/src/apiKeys/ApiKey.test.ts +0 -142
  28. package/src/apiKeys/ApiKey.ts +0 -102
  29. package/src/apiKeys/crypto.test.ts +0 -161
  30. package/src/apiKeys/crypto.ts +0 -163
  31. package/src/apiKeys/index.test.ts +0 -72
  32. package/src/apiKeys/index.ts +0 -35
  33. package/src/apiKeys/middleware.test.ts +0 -651
  34. package/src/apiKeys/middleware.ts +0 -341
  35. package/src/apiKeys/service.test.ts +0 -401
  36. package/src/apiKeys/service.ts +0 -168
  37. package/src/apiKeys/types.test.ts +0 -41
  38. package/src/apiKeys/types.ts +0 -124
  39. package/src/app/createBaseApp.test.ts +0 -109
  40. package/src/app/createBaseApp.ts +0 -102
  41. package/src/app/shutdown.test.ts +0 -129
  42. package/src/app/shutdown.ts +0 -81
  43. package/src/audit/AuditEvent.ts +0 -123
  44. package/src/audit/actor.test.ts +0 -95
  45. package/src/audit/actor.ts +0 -68
  46. package/src/audit/context.test.ts +0 -91
  47. package/src/audit/context.ts +0 -83
  48. package/src/audit/index.ts +0 -11
  49. package/src/audit/plugin.test.ts +0 -258
  50. package/src/audit/plugin.ts +0 -254
  51. package/src/audit/reads.test.ts +0 -164
  52. package/src/audit/reads.ts +0 -88
  53. package/src/audit/service.test.ts +0 -115
  54. package/src/audit/service.ts +0 -95
  55. package/src/auth/session.test.ts +0 -89
  56. package/src/auth/session.ts +0 -75
  57. package/src/config/env.test.ts +0 -28
  58. package/src/config/env.ts +0 -13
  59. package/src/database/connection.test.ts +0 -188
  60. package/src/database/connection.ts +0 -90
  61. package/src/entitlements/UsageMeter.ts +0 -49
  62. package/src/entitlements/client.test.ts +0 -200
  63. package/src/entitlements/client.ts +0 -179
  64. package/src/entitlements/definitions.test.ts +0 -161
  65. package/src/entitlements/definitions.ts +0 -268
  66. package/src/entitlements/index.ts +0 -42
  67. package/src/entitlements/middleware.test.ts +0 -196
  68. package/src/entitlements/middleware.ts +0 -150
  69. package/src/entitlements/reconcile.test.ts +0 -333
  70. package/src/entitlements/reconcile.ts +0 -384
  71. package/src/entitlements/types.ts +0 -21
  72. package/src/entitlements/usage.test.ts +0 -314
  73. package/src/entitlements/usage.ts +0 -223
  74. package/src/errors/HttpError.test.ts +0 -76
  75. package/src/errors/HttpError.ts +0 -91
  76. package/src/groups/client.test.ts +0 -215
  77. package/src/groups/client.ts +0 -182
  78. package/src/groups/index.ts +0 -7
  79. package/src/groups/membership.test.ts +0 -84
  80. package/src/groups/membership.ts +0 -133
  81. package/src/groups/subject.test.ts +0 -85
  82. package/src/groups/subject.ts +0 -50
  83. package/src/health/createHealthCheck.test.ts +0 -89
  84. package/src/health/createHealthCheck.ts +0 -67
  85. package/src/health/healthController.test.ts +0 -113
  86. package/src/health/healthController.ts +0 -56
  87. package/src/index.ts +0 -88
  88. package/src/logging/logger.test.ts +0 -91
  89. package/src/logging/logger.ts +0 -103
  90. package/src/metrics/index.test.ts +0 -116
  91. package/src/metrics/index.ts +0 -111
  92. package/src/middleware/authMiddleware.test.ts +0 -275
  93. package/src/middleware/authMiddleware.ts +0 -91
  94. package/src/middleware/corsMiddleware.test.ts +0 -135
  95. package/src/middleware/corsMiddleware.ts +0 -65
  96. package/src/middleware/errorHandler.test.ts +0 -188
  97. package/src/middleware/errorHandler.ts +0 -103
  98. package/src/middleware/internalServiceAuth.test.ts +0 -173
  99. package/src/middleware/internalServiceAuth.ts +0 -96
  100. package/src/middleware/requestLogger.test.ts +0 -81
  101. package/src/middleware/requestLogger.ts +0 -48
  102. package/src/middleware/security.test.ts +0 -45
  103. package/src/middleware/security.ts +0 -43
  104. package/src/middleware/validate.test.ts +0 -72
  105. package/src/middleware/validate.ts +0 -23
  106. package/src/oauth/index.ts +0 -29
  107. package/src/oauth/models.ts +0 -164
  108. package/src/oauth/service.test.ts +0 -432
  109. package/src/oauth/service.ts +0 -299
  110. package/src/oauth/tokens.test.ts +0 -146
  111. package/src/oauth/tokens.ts +0 -186
  112. package/src/testing/serviceTestSetup.ts +0 -17
  113. package/src/tracing/index.test.ts +0 -272
  114. package/src/tracing/index.ts +0 -110
  115. package/src/types/auth.ts +0 -45
  116. package/src/utils/correlation.test.ts +0 -47
  117. package/src/utils/correlation.ts +0 -22
  118. package/src/utils/permissionUtils.test.ts +0 -47
  119. package/src/utils/permissionUtils.ts +0 -68
  120. package/src/utils/response.test.ts +0 -64
  121. package/src/utils/response.ts +0 -60
  122. package/tsconfig.build.json +0 -7
  123. package/tsconfig.json +0 -23
@@ -1,95 +0,0 @@
1
- import { AuditEvent, type IAuditEvent, type ToolCall } from './AuditEvent';
2
- import type { ActorKind } from './actor';
3
-
4
- export interface AuditQuery {
5
- userId: string;
6
- /** narrow to one actor — a key's id or an OAuth client id */
7
- credentialId?: string;
8
- actorKind?: ActorKind;
9
- action?: IAuditEvent['action'];
10
- resource?: string;
11
- /** narrow to one area — 'relationship-service', 'album-service' */
12
- service?: string;
13
- from?: Date;
14
- to?: Date;
15
- limit?: number;
16
- page?: number;
17
- }
18
-
19
- export interface AuditEntry {
20
- id: string;
21
- at: string;
22
- service: string;
23
- action: IAuditEvent['action'];
24
- resource: string;
25
- resourceId?: string;
26
- actorKind: ActorKind;
27
- actorLabel: string;
28
- actorCredentialId?: string;
29
- tenant: string;
30
- changes?: Record<string, { from: unknown; to: unknown }>;
31
- snapshot?: Record<string, unknown>;
32
- count?: number;
33
- /** present on 'call': which tool ran, how long it took, and what it cost */
34
- tool?: ToolCall;
35
- }
36
-
37
- const toEntry = (event: IAuditEvent): AuditEntry => ({
38
- id: String(event._id),
39
- at: event.at.toISOString(),
40
- service: event.service,
41
- action: event.action,
42
- resource: event.resource,
43
- resourceId: event.resourceId,
44
- actorKind: event.actorKind,
45
- actorLabel: event.actorLabel,
46
- actorCredentialId: event.actorCredentialId,
47
- tenant: event.tenant,
48
- changes: event.changes,
49
- snapshot: event.snapshot,
50
- count: event.count,
51
- tool: event.tool
52
- });
53
-
54
- /**
55
- * One account's trail, newest first.
56
- *
57
- * Always scoped to `userId` by the query rather than filtered afterwards, so
58
- * there is no path where a wider read is one forgotten condition away. The trail
59
- * carries old values of journal entries, which makes it as sensitive as the
60
- * journal itself.
61
- */
62
- export const listAuditEvents = async (
63
- query: AuditQuery
64
- ): Promise<{ events: AuditEntry[]; total: number; page: number; totalPages: number }> => {
65
- const limit = Math.min(query.limit ?? 50, 200);
66
- const page = Math.max(query.page ?? 1, 1);
67
-
68
- const filter: Record<string, unknown> = { userId: query.userId };
69
- if (query.credentialId) filter.actorCredentialId = query.credentialId;
70
- if (query.actorKind) filter.actorKind = query.actorKind;
71
- if (query.action) filter.action = query.action;
72
- if (query.resource) filter.resource = query.resource;
73
- if (query.service) filter.service = query.service;
74
- if (query.from || query.to) {
75
- const at: Record<string, Date> = {};
76
- if (query.from) at.$gte = query.from;
77
- if (query.to) at.$lte = query.to;
78
- filter.at = at;
79
- }
80
-
81
- const [events, total] = await Promise.all([
82
- AuditEvent.find(filter)
83
- .sort({ at: -1 })
84
- .skip((page - 1) * limit)
85
- .limit(limit),
86
- AuditEvent.countDocuments(filter)
87
- ]);
88
-
89
- return {
90
- events: events.map(toEntry),
91
- total,
92
- page,
93
- totalPages: Math.ceil(total / limit)
94
- };
95
- };
@@ -1,89 +0,0 @@
1
- const connection = { readyState: 1, collection: jest.fn() };
2
-
3
- jest.mock('mongoose', () => {
4
- const actual = jest.requireActual('mongoose');
5
- return { __esModule: true, default: { connection, Types: actual.Types } };
6
- });
7
-
8
- import mongoose from 'mongoose';
9
- import {
10
- USER_SESSIONS_COLLECTION,
11
- isCurrentSession,
12
- isLegacySessionlessToken,
13
- overrideSessionCheckForTests
14
- } from './session';
15
-
16
- const USER_ID = 'b'.repeat(24);
17
- const SESSION_ID = 'a'.repeat(24);
18
- const now = Math.floor(Date.now() / 1000);
19
-
20
- describe('isLegacySessionlessToken', () => {
21
- it('accepts a pre-sessions token that lived 24 hours at most', () => {
22
- expect(isLegacySessionlessToken({ iat: now, exp: now + 24 * 60 * 60 })).toBe(true);
23
- });
24
-
25
- it('refuses a session-less token that lives longer, or has no expiry', () => {
26
- expect(isLegacySessionlessToken({ iat: now, exp: now + 24 * 60 * 60 + 1 })).toBe(false);
27
- expect(isLegacySessionlessToken({ iat: now })).toBe(false);
28
- });
29
-
30
- it('is never true for a token that names a session', () => {
31
- expect(isLegacySessionlessToken({ sid: SESSION_ID, iat: now, exp: now + 60 })).toBe(false);
32
- });
33
- });
34
-
35
- describe('isCurrentSession', () => {
36
- const findOne = jest.fn();
37
-
38
- beforeEach(() => {
39
- overrideSessionCheckForTests();
40
- findOne.mockReset();
41
- connection.readyState = 1;
42
- connection.collection.mockReset().mockReturnValue({ findOne });
43
- });
44
-
45
- it('accepts a live session that belongs to the token user', async () => {
46
- findOne.mockResolvedValue({ _id: SESSION_ID });
47
-
48
- await expect(isCurrentSession({ id: USER_ID, sid: SESSION_ID })).resolves.toBe(true);
49
-
50
- expect(mongoose.connection.collection).toHaveBeenCalledWith(USER_SESSIONS_COLLECTION);
51
- const [query, options] = findOne.mock.calls[0];
52
- expect(query._id.toString()).toBe(SESSION_ID);
53
- expect(query.userId.toString()).toBe(USER_ID);
54
- expect(query.expiresAt.$gt).toBeInstanceOf(Date);
55
- expect(options).toMatchObject({ readPreference: 'primary' });
56
- });
57
-
58
- it('refuses a revoked or expired session', async () => {
59
- findOne.mockResolvedValue(null);
60
-
61
- await expect(isCurrentSession({ id: USER_ID, sid: SESSION_ID })).resolves.toBe(false);
62
- });
63
-
64
- it('refuses while the database is unavailable rather than letting the token through', async () => {
65
- connection.readyState = 0;
66
-
67
- await expect(isCurrentSession({ id: USER_ID, sid: SESSION_ID })).resolves.toBe(false);
68
- expect(findOne).not.toHaveBeenCalled();
69
- });
70
-
71
- it.each([
72
- [{ id: USER_ID, sid: 'not-an-id' }],
73
- [{ id: 'not-an-id', sid: SESSION_ID }],
74
- [{ id: USER_ID, sid: 42 }],
75
- [null]
76
- ])('refuses malformed claims %j without a lookup', async (claims) => {
77
- await expect(isCurrentSession(claims)).resolves.toBe(false);
78
- expect(findOne).not.toHaveBeenCalled();
79
- });
80
-
81
- it('accepts a short-lived pre-sessions token without a lookup', async () => {
82
- await expect(isCurrentSession({ id: USER_ID, iat: now, exp: now + 60 })).resolves.toBe(true);
83
- expect(findOne).not.toHaveBeenCalled();
84
- });
85
-
86
- it('refuses a long-lived token with no session', async () => {
87
- await expect(isCurrentSession({ id: USER_ID, iat: now, exp: now + 90 * 24 * 60 * 60 })).resolves.toBe(false);
88
- });
89
- });
@@ -1,75 +0,0 @@
1
- import mongoose from 'mongoose';
2
-
3
- /**
4
- * One document per signed-in device, owned and written by auth-service; every
5
- * other service only reads it here. A session token names its document in the
6
- * `sid` claim, and the token is only as good as that document: deleting it
7
- * (sign-out, "sign out this device") ends the session on its next request, in
8
- * every service, and its `expiresAt` (slid forward by auth-service while the
9
- * device is in use, never past a hard cap from sign-in) is what idle expiry
10
- * actually means.
11
- */
12
- export const USER_SESSIONS_COLLECTION = 'user_sessions';
13
-
14
- /**
15
- * Tokens minted before sessions existed carry no `sid` and lived 24 hours.
16
- * auth-service upgrades one to a session the next time `/auth/verify` sees it,
17
- * so this window closes by itself a day after the sessions release; the branch
18
- * accepting them can be deleted after that.
19
- */
20
- const LEGACY_TOKEN_MAX_LIFETIME_SECONDS = 24 * 60 * 60;
21
-
22
- const OBJECT_ID = /^[a-f\d]{24}$/i;
23
-
24
- /** True for a pre-sessions token: no `sid`, and the old 24-hour lifetime at most. */
25
- export function isLegacySessionlessToken(claims: unknown): boolean {
26
- if (!claims || typeof claims !== 'object') return false;
27
- const { sid, iat, exp } = claims as { sid?: unknown; iat?: unknown; exp?: unknown };
28
- return (
29
- sid === undefined &&
30
- Number.isSafeInteger(iat) &&
31
- Number.isSafeInteger(exp) &&
32
- (exp as number) - (iat as number) <= LEGACY_TOKEN_MAX_LIFETIME_SECONDS
33
- );
34
- }
35
-
36
- type SessionCheck = (claims: unknown) => Promise<boolean>;
37
-
38
- /**
39
- * Whether a verified session token still stands: for a token with a `sid`,
40
- * its session belongs to the token's user and has neither been revoked nor
41
- * expired. Read on the primary, uncached, so a revocation holds from the next
42
- * request.
43
- */
44
- const checkSessionInDatabase: SessionCheck = async (claims) => {
45
- if (!claims || typeof claims !== 'object') return false;
46
- const { id, sid } = claims as { id?: unknown; sid?: unknown };
47
- if (sid === undefined) return isLegacySessionlessToken(claims);
48
- if (typeof sid !== 'string' || !OBJECT_ID.test(sid)) return false;
49
- if (typeof id !== 'string' || !OBJECT_ID.test(id)) return false;
50
- // Refuse rather than queue an authentication query while the database is down.
51
- if (mongoose.connection.readyState !== 1) return false;
52
-
53
- const session = await mongoose.connection.collection(USER_SESSIONS_COLLECTION).findOne(
54
- {
55
- _id: new mongoose.Types.ObjectId(sid),
56
- userId: new mongoose.Types.ObjectId(id),
57
- expiresAt: { $gt: new Date() }
58
- },
59
- { projection: { _id: 1 }, readPreference: 'primary', maxTimeMS: 3000 }
60
- );
61
- return session !== null;
62
- };
63
-
64
- let sessionCheck: SessionCheck = checkSessionInDatabase;
65
-
66
- export const isCurrentSession = (claims: unknown): Promise<boolean> => sessionCheck(claims);
67
-
68
- /**
69
- * Replaces the check, for tests only; pass nothing to restore the real one.
70
- * Not exported from the package index: service suites reach it through the
71
- * `@tumbaland/backend-core/testing` entry point (see testing.js).
72
- */
73
- export function overrideSessionCheckForTests(check?: SessionCheck): void {
74
- sessionCheck = check ?? checkSessionInDatabase;
75
- }
@@ -1,28 +0,0 @@
1
- import { requireEnv } from './env';
2
-
3
- describe('requireEnv', () => {
4
- const ORIGINAL_ENV = process.env;
5
-
6
- beforeEach(() => {
7
- process.env = { ...ORIGINAL_ENV };
8
- });
9
-
10
- afterAll(() => {
11
- process.env = ORIGINAL_ENV;
12
- });
13
-
14
- it('returns the value when the variable is set', () => {
15
- process.env.MY_VAR = 'hello';
16
- expect(requireEnv('MY_VAR')).toBe('hello');
17
- });
18
-
19
- it('throws when the variable is missing', () => {
20
- delete process.env.MY_VAR;
21
- expect(() => requireEnv('MY_VAR')).toThrow('Missing required environment variable: MY_VAR');
22
- });
23
-
24
- it('throws when the variable is an empty string', () => {
25
- process.env.MY_VAR = '';
26
- expect(() => requireEnv('MY_VAR')).toThrow('Missing required environment variable: MY_VAR');
27
- });
28
- });
package/src/config/env.ts DELETED
@@ -1,13 +0,0 @@
1
- /**
2
- * Returns the value of a required environment variable.
3
- * Throws if the variable is missing or empty — call at service startup
4
- * (after dotenv.config()) so misconfigured services fail fast instead of
5
- * running with insecure defaults.
6
- */
7
- export function requireEnv(name: string): string {
8
- const value = process.env[name];
9
- if (!value) {
10
- throw new Error(`Missing required environment variable: ${name}`);
11
- }
12
- return value;
13
- }
@@ -1,188 +0,0 @@
1
- jest.mock('../logging/logger', () => ({
2
- __esModule: true,
3
- default: { info: jest.fn(), error: jest.fn(), warn: jest.fn(), debug: jest.fn(), http: jest.fn() }
4
- }));
5
-
6
- const connectionHandlers: Record<string, (...args: any[]) => void> = {};
7
-
8
- jest.mock('mongoose', () => ({
9
- __esModule: true,
10
- default: {
11
- connect: jest.fn(),
12
- connection: {
13
- on: jest.fn((event: string, handler: (...args: any[]) => void) => {
14
- connectionHandlers[event] = handler;
15
- }),
16
- close: jest.fn()
17
- }
18
- }
19
- }));
20
-
21
- import mongoose from 'mongoose';
22
- import logger from '../logging/logger';
23
- import { disconnectDB } from './connection';
24
-
25
- /**
26
- * `connection.ts` keeps module-level `connected`/`pending` flags, so each
27
- * connectDB scenario needs its own fresh copy of the module —
28
- * isolateModulesAsync sandboxes that without leaking into other tests in this
29
- * file the way a blanket jest.resetModules() would.
30
- *
31
- * Note that this does NOT re-create the mongoose/logger mocks: a jest.mock
32
- * factory result is shared across isolated registries, so the objects returned
33
- * here are the same instances every time. Resetting their implementations is
34
- * beforeEach's job — see the comment there.
35
- */
36
- async function freshConnectDB() {
37
- let connectDBFn!: typeof import('./connection').connectDB;
38
- let mongooseMock!: typeof mongoose;
39
- let loggerMock!: typeof logger;
40
-
41
- await jest.isolateModulesAsync(async () => {
42
- mongooseMock = (await import('mongoose')).default as unknown as typeof mongoose;
43
- loggerMock = (await import('../logging/logger')).default as unknown as typeof logger;
44
- connectDBFn = (await import('./connection')).connectDB;
45
- });
46
-
47
- return { connectDB: connectDBFn, mongoose: mongooseMock, logger: loggerMock };
48
- }
49
-
50
- describe('connectDB', () => {
51
- const ORIGINAL_ENV = process.env;
52
-
53
- beforeEach(() => {
54
- process.env = { ...ORIGINAL_ENV };
55
- delete process.env.MONGODB_URI;
56
- for (const key of Object.keys(connectionHandlers)) delete connectionHandlers[key];
57
-
58
- // `clearMocks: true` only clears recorded calls, not implementations, so a
59
- // mockRejectedValue set by one test stays installed for every test after
60
- // it. Reset connect explicitly — but not connection.on, whose factory
61
- // implementation is what records into connectionHandlers.
62
- (mongoose.connect as jest.Mock).mockReset();
63
- });
64
-
65
- afterAll(() => {
66
- process.env = ORIGINAL_ENV;
67
- });
68
-
69
- it('throws when no MongoDB URI is available', async () => {
70
- const { connectDB } = await freshConnectDB();
71
- await expect(connectDB('my-service')).rejects.toThrow('MONGODB_URI environment variable is not set');
72
- });
73
-
74
- it('connects using the explicit uri argument over the env var', async () => {
75
- process.env.MONGODB_URI = 'mongodb://env-uri';
76
- const { connectDB, mongoose, logger } = await freshConnectDB();
77
-
78
- await connectDB('my-service', 'mongodb://explicit-uri');
79
-
80
- expect(mongoose.connect).toHaveBeenCalledWith('mongodb://explicit-uri');
81
- expect(logger.info).toHaveBeenCalledWith('MongoDB connected', { service: 'my-service' });
82
- });
83
-
84
- it('falls back to MONGODB_URI when no explicit uri is given', async () => {
85
- process.env.MONGODB_URI = 'mongodb://env-uri';
86
- const { connectDB, mongoose } = await freshConnectDB();
87
-
88
- await connectDB('my-service');
89
-
90
- expect(mongoose.connect).toHaveBeenCalledWith('mongodb://env-uri');
91
- });
92
-
93
- it('is a no-op on a second call once already connected', async () => {
94
- process.env.MONGODB_URI = 'mongodb://env-uri';
95
- const { connectDB, mongoose } = await freshConnectDB();
96
-
97
- await connectDB('my-service');
98
- await connectDB('my-service');
99
-
100
- expect(mongoose.connect).toHaveBeenCalledTimes(1);
101
- });
102
-
103
- it('logs and rethrows when mongoose.connect fails', async () => {
104
- process.env.MONGODB_URI = 'mongodb://env-uri';
105
- const { connectDB, mongoose, logger } = await freshConnectDB();
106
- (mongoose.connect as jest.Mock).mockRejectedValueOnce(new Error('ECONNREFUSED'));
107
-
108
- await expect(connectDB('my-service')).rejects.toThrow('ECONNREFUSED');
109
- expect(logger.error).toHaveBeenCalledWith('Failed to connect to MongoDB', {
110
- service: 'my-service',
111
- error: 'ECONNREFUSED'
112
- });
113
- });
114
-
115
- /**
116
- * The driver retries internally for serverSelectionTimeoutMS; anything that
117
- * reaches us is a real fault, so one failure is one rejection. Callers exit
118
- * non-zero and the container restart policy takes it from there.
119
- */
120
- it('does not retry a failed connection', async () => {
121
- process.env.MONGODB_URI = 'mongodb://env-uri';
122
- const { connectDB, mongoose } = await freshConnectDB();
123
- (mongoose.connect as jest.Mock).mockRejectedValue(new Error('auth failed'));
124
-
125
- await expect(connectDB('my-service')).rejects.toThrow('auth failed');
126
-
127
- expect(mongoose.connect).toHaveBeenCalledTimes(1);
128
- });
129
-
130
- it('clears the in-flight promise so a later call can retry', async () => {
131
- process.env.MONGODB_URI = 'mongodb://env-uri';
132
- const { connectDB, mongoose } = await freshConnectDB();
133
- (mongoose.connect as jest.Mock).mockRejectedValueOnce(new Error('not ready'));
134
-
135
- await expect(connectDB('my-service')).rejects.toThrow('not ready');
136
- await connectDB('my-service');
137
-
138
- expect(mongoose.connect).toHaveBeenCalledTimes(2);
139
- });
140
-
141
- it('lets concurrent callers share one connection attempt', async () => {
142
- process.env.MONGODB_URI = 'mongodb://env-uri';
143
- const { connectDB, mongoose } = await freshConnectDB();
144
-
145
- await Promise.all([connectDB('my-service'), connectDB('my-service')]);
146
-
147
- expect(mongoose.connect).toHaveBeenCalledTimes(1);
148
- });
149
-
150
- it('registers error/disconnected/reconnected handlers that log through the shared logger', async () => {
151
- process.env.MONGODB_URI = 'mongodb://env-uri';
152
- const { connectDB, logger } = await freshConnectDB();
153
-
154
- await connectDB('my-service');
155
-
156
- connectionHandlers.error(new Error('boom'));
157
- expect(logger.error).toHaveBeenCalledWith('MongoDB connection error', { service: 'my-service', error: 'boom' });
158
-
159
- connectionHandlers.disconnected();
160
- expect(logger.warn).toHaveBeenCalledWith('MongoDB disconnected', { service: 'my-service' });
161
-
162
- connectionHandlers.reconnected();
163
- expect(logger.info).toHaveBeenCalledWith('MongoDB reconnected', { service: 'my-service' });
164
- });
165
- });
166
-
167
- describe('disconnectDB', () => {
168
- beforeEach(() => {
169
- jest.clearAllMocks();
170
- });
171
-
172
- it('closes the connection and logs success', async () => {
173
- await disconnectDB('my-service');
174
-
175
- expect(mongoose.connection.close).toHaveBeenCalled();
176
- expect(logger.info).toHaveBeenCalledWith('MongoDB connection closed', { service: 'my-service' });
177
- });
178
-
179
- it('logs and rethrows when closing fails', async () => {
180
- (mongoose.connection.close as jest.Mock).mockRejectedValueOnce(new Error('close failed'));
181
-
182
- await expect(disconnectDB('my-service')).rejects.toThrow('close failed');
183
- expect(logger.error).toHaveBeenCalledWith('Error closing MongoDB connection', {
184
- service: 'my-service',
185
- error: 'close failed'
186
- });
187
- });
188
- });
@@ -1,90 +0,0 @@
1
- import mongoose from 'mongoose';
2
- import logger from '../logging/logger';
3
-
4
- let connected = false;
5
- /**
6
- * Set while a connection attempt is in flight so concurrent callers await that
7
- * attempt instead of opening a second one against the same mongoose singleton.
8
- */
9
- let pending: Promise<void> | null = null;
10
- let listenersRegistered = false;
11
-
12
- /**
13
- * Connects once and throws on failure. There is deliberately no retry loop here.
14
- *
15
- * MongoDB is Atlas (`mongodb+srv://`), not a sidecar container, so there is no
16
- * boot race to ride out — and the driver already retries for us: `connect()`
17
- * keeps doing server selection for `serverSelectionTimeoutMS` (30s by default)
18
- * before it rejects, so a transient DNS or network blip is absorbed there.
19
- * Anything that survives that is a real fault — bad credentials, an IP missing
20
- * from the Atlas allowlist, a dead cluster — and no amount of retrying fixes it.
21
- *
22
- * Callers exit non-zero when this throws, which hands the problem to the
23
- * container restart policy (`restart: unless-stopped`) that already supervises
24
- * every service. One supervisor, and a service that can't reach its database
25
- * shows up as a restarting container instead of a healthy one serving errors.
26
- */
27
- export const connectDB = async (serviceName?: string, uri?: string): Promise<void> => {
28
- if (connected) return;
29
- if (pending) return pending;
30
-
31
- const mongoUri = uri || process.env.MONGODB_URI;
32
- if (!mongoUri) {
33
- throw new Error('MONGODB_URI environment variable is not set');
34
- }
35
-
36
- pending = connectOnce(mongoUri, serviceName).finally(() => {
37
- pending = null;
38
- });
39
-
40
- return pending;
41
- };
42
-
43
- async function connectOnce(mongoUri: string, serviceName: string | undefined): Promise<void> {
44
- try {
45
- await mongoose.connect(mongoUri);
46
- connected = true;
47
-
48
- registerConnectionListeners(serviceName);
49
- logger.info('MongoDB connected', { service: serviceName });
50
- } catch (error) {
51
- logger.error('Failed to connect to MongoDB', {
52
- service: serviceName,
53
- error: (error as Error).message
54
- });
55
- throw error;
56
- }
57
- }
58
-
59
- /**
60
- * Mongoose reconnects on its own once an initial connection has succeeded, so
61
- * these only report state — but without them a drop is silent and the only
62
- * symptom is the health endpoint flipping to "disconnected".
63
- */
64
- function registerConnectionListeners(serviceName?: string): void {
65
- if (listenersRegistered) return;
66
- listenersRegistered = true;
67
-
68
- mongoose.connection.on('error', (err: Error) => {
69
- logger.error('MongoDB connection error', { service: serviceName, error: err.message });
70
- });
71
-
72
- mongoose.connection.on('disconnected', () => {
73
- logger.warn('MongoDB disconnected', { service: serviceName });
74
- });
75
-
76
- mongoose.connection.on('reconnected', () => {
77
- logger.info('MongoDB reconnected', { service: serviceName });
78
- });
79
- }
80
-
81
- export const disconnectDB = async (serviceName?: string): Promise<void> => {
82
- try {
83
- await mongoose.connection.close();
84
- connected = false;
85
- logger.info('MongoDB connection closed', { service: serviceName });
86
- } catch (error) {
87
- logger.error('Error closing MongoDB connection', { service: serviceName, error: (error as Error).message });
88
- throw error;
89
- }
90
- };
@@ -1,49 +0,0 @@
1
- import { Schema, model, models, Document, Model } from 'mongoose';
2
- import { MeterKey, MeterPeriod, meterPeriod } from './definitions';
3
-
4
- /**
5
- * One counter per (subject, meter, period). Lives in the shared database, so
6
- * album-service can write `storageBytes` while payment-service reads it back
7
- * for the pricing page without another round trip.
8
- *
9
- * `subjectId` is whoever the usage is billed to — an email for personal
10
- * meters, the group owner's email for pooled ones. It is deliberately not a
11
- * user ObjectId: every service already has the email on the JWT, and none of
12
- * them share a users collection.
13
- */
14
- export interface IUsageMeter extends Document {
15
- subjectId: string;
16
- meter: MeterKey;
17
- /** `ALL` for cumulative meters, `YYYY-MM` for monthly ones. */
18
- period: string;
19
- count: number;
20
- createdAt: Date;
21
- updatedAt: Date;
22
- }
23
-
24
- const UsageMeterSchema = new Schema<IUsageMeter>(
25
- {
26
- subjectId: { type: String, required: true, lowercase: true, trim: true },
27
- meter: { type: String, required: true },
28
- period: { type: String, required: true, default: 'ALL' },
29
- count: { type: Number, required: true, default: 0 }
30
- },
31
- { timestamps: true }
32
- );
33
-
34
- UsageMeterSchema.index({ subjectId: 1, meter: 1, period: 1 }, { unique: true });
35
-
36
- /**
37
- * Registered through `models` first: several services import this module into
38
- * the same mongoose singleton during tests, and a second `model()` call with
39
- * the same name throws `OverwriteModelError`.
40
- */
41
- export const UsageMeter: Model<IUsageMeter> =
42
- (models.UsageMeter as Model<IUsageMeter>) || model<IUsageMeter>('UsageMeter', UsageMeterSchema);
43
-
44
- /** The period bucket a meter counts into right now. Monthly meters roll over at 00:00 UTC on the 1st. */
45
- export function currentPeriod(meter: MeterKey, now: Date = new Date()): string {
46
- const period: MeterPeriod = meterPeriod(meter);
47
- if (period === 'ALL') return 'ALL';
48
- return now.toISOString().slice(0, 7); // YYYY-MM
49
- }