@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,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
- }