@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,91 +0,0 @@
1
- import fs from 'fs';
2
-
3
- /**
4
- * logger.ts builds its transport list at module-load time based on
5
- * NODE_ENV/LOG_TO_FILE, so each scenario needs a fresh module load via
6
- * isolateModulesAsync rather than a single shared import.
7
- */
8
- async function loadLogger(env: Record<string, string | undefined>) {
9
- const ORIGINAL_ENV = process.env;
10
- process.env = { ...ORIGINAL_ENV, ...env };
11
-
12
- let loggerModule!: typeof import('./logger').default;
13
- await jest.isolateModulesAsync(async () => {
14
- loggerModule = (await import('./logger')).default;
15
- });
16
-
17
- process.env = ORIGINAL_ENV;
18
- return loggerModule;
19
- }
20
-
21
- // winston's `.transports` also holds the wrapped exception/rejection-handler
22
- // transports (always present), so assert on named regular transports only.
23
- function namedTransports(logger: import('winston').Logger, name: string) {
24
- return logger.transports.filter((t) => (t as unknown as { name?: string }).name === name);
25
- }
26
-
27
- describe('logger', () => {
28
- it('logs at debug level in development', async () => {
29
- const logger = await loadLogger({ NODE_ENV: 'development', LOG_TO_FILE: undefined });
30
- expect(logger.level).toBe('debug');
31
- });
32
-
33
- it('logs at info level in production', async () => {
34
- const logger = await loadLogger({ NODE_ENV: 'production' });
35
- expect(logger.level).toBe('info');
36
- });
37
-
38
- it('defaults to development (debug level) when NODE_ENV is unset', async () => {
39
- const ORIGINAL_ENV = process.env;
40
- const merged: Record<string, string | undefined> = { ...ORIGINAL_ENV };
41
- delete merged.NODE_ENV;
42
- process.env = merged as NodeJS.ProcessEnv;
43
-
44
- let loggerModule!: typeof import('./logger').default;
45
- await jest.isolateModulesAsync(async () => {
46
- loggerModule = (await import('./logger')).default;
47
- });
48
-
49
- process.env = ORIGINAL_ENV;
50
- expect(loggerModule.level).toBe('debug');
51
- });
52
-
53
- it('does not create a file transport when LOG_TO_FILE is unset', async () => {
54
- const logger = await loadLogger({ NODE_ENV: 'development', LOG_TO_FILE: undefined });
55
- expect(namedTransports(logger, 'file')).toHaveLength(0);
56
- expect(namedTransports(logger, 'console')).toHaveLength(1);
57
- });
58
-
59
- it('does not create a file transport in production, even if LOG_TO_FILE is true', async () => {
60
- const logger = await loadLogger({ NODE_ENV: 'production', LOG_TO_FILE: 'true' });
61
- expect(namedTransports(logger, 'file')).toHaveLength(0);
62
- });
63
-
64
- describe('with LOG_TO_FILE=true in development', () => {
65
- it('creates the logs directory when it does not exist and adds a file transport', async () => {
66
- const existsSpy = jest.spyOn(fs, 'existsSync').mockReturnValue(false);
67
- const mkdirSpy = jest.spyOn(fs, 'mkdirSync').mockImplementation(() => undefined as any);
68
-
69
- const logger = await loadLogger({ NODE_ENV: 'development', LOG_TO_FILE: 'true' });
70
-
71
- expect(mkdirSpy).toHaveBeenCalledWith(expect.stringContaining('logs'), { recursive: true });
72
- expect(namedTransports(logger, 'file')).toHaveLength(1);
73
-
74
- existsSpy.mockRestore();
75
- mkdirSpy.mockRestore();
76
- });
77
-
78
- it('does not recreate the logs directory when it already exists', async () => {
79
- const existsSpy = jest.spyOn(fs, 'existsSync').mockReturnValue(true);
80
- const mkdirSpy = jest.spyOn(fs, 'mkdirSync').mockImplementation(() => undefined as any);
81
-
82
- const logger = await loadLogger({ NODE_ENV: 'development', LOG_TO_FILE: 'true' });
83
-
84
- expect(mkdirSpy).not.toHaveBeenCalled();
85
- expect(namedTransports(logger, 'file')).toHaveLength(1);
86
-
87
- existsSpy.mockRestore();
88
- mkdirSpy.mockRestore();
89
- });
90
- });
91
- });
@@ -1,103 +0,0 @@
1
- import winston from 'winston';
2
- import path from 'path';
3
- import fs from 'fs';
4
-
5
- // Define log levels
6
- const levels = {
7
- error: 0,
8
- warn: 1,
9
- info: 2,
10
- http: 3,
11
- debug: 4,
12
- };
13
-
14
- const level = () => {
15
- const env = process.env.NODE_ENV || 'development';
16
- const isDevelopment = env === 'development';
17
- return isDevelopment ? 'debug' : 'info';
18
- };
19
-
20
- // Define colors for each level
21
- const colors = {
22
- error: 'red',
23
- warn: 'yellow',
24
- info: 'green',
25
- http: 'magenta',
26
- debug: 'white',
27
- };
28
-
29
- winston.addColors(colors);
30
-
31
- // Define format for console (human-readable)
32
- const consoleFormat = winston.format.combine(
33
- winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss:ms' }),
34
- winston.format.colorize({ all: true }),
35
- winston.format.printf(
36
- (info) => `${info.timestamp} ${info.level}: ${String(info.message)}`,
37
- ),
38
- );
39
-
40
- // Define JSON format for production (structured logging)
41
- const jsonFormat = winston.format.combine(
42
- winston.format.timestamp(),
43
- winston.format.errors({ stack: true }),
44
- winston.format.json()
45
- );
46
-
47
- // Choose format based on environment
48
- const isProduction = process.env.NODE_ENV === 'production';
49
- const logFormat = isProduction ? jsonFormat : consoleFormat;
50
-
51
- // Define which transports the logger must use
52
- const transports: winston.transport[] = [
53
- // Console transport (goes to Docker stdout/stderr)
54
- new winston.transports.Console({
55
- format: logFormat,
56
- level: level()
57
- }),
58
- ];
59
-
60
- // Add file transport only in development (optional)
61
- if (!isProduction && process.env.LOG_TO_FILE === 'true') {
62
-
63
- // Create logs directory if it doesn't exist
64
- const logsDir = path.join(process.cwd(), 'logs');
65
- if (!fs.existsSync(logsDir)) {
66
- fs.mkdirSync(logsDir, { recursive: true });
67
- }
68
-
69
- transports.push(
70
- new winston.transports.File({
71
- filename: path.join(logsDir, 'app.log'),
72
- format: jsonFormat,
73
- level: 'debug'
74
- })
75
- );
76
- }
77
-
78
- // Create the logger instance
79
- const logger = winston.createLogger({
80
- level: level(),
81
- levels,
82
- format: logFormat,
83
- transports,
84
- // Handle exceptions and rejections
85
- exceptionHandlers: [
86
- new winston.transports.Console({
87
- format: winston.format.combine(
88
- winston.format.colorize(),
89
- winston.format.simple()
90
- )
91
- })
92
- ],
93
- rejectionHandlers: [
94
- new winston.transports.Console({
95
- format: winston.format.combine(
96
- winston.format.colorize(),
97
- winston.format.simple()
98
- )
99
- })
100
- ]
101
- });
102
-
103
- export default logger;
@@ -1,116 +0,0 @@
1
- import { Request, Response } from 'express';
2
- import {
3
- activeConnections,
4
- businessMetrics,
5
- httpRequestDuration,
6
- httpRequestsTotal,
7
- metricsHandler,
8
- metricsMiddleware,
9
- register
10
- } from './index';
11
-
12
- function mockRes(): Response {
13
- const res: Partial<Response> & { _finishHandler?: () => void } = {};
14
- res.set = jest.fn().mockReturnValue(res);
15
- res.end = jest.fn().mockReturnValue(res);
16
- res.status = jest.fn().mockReturnValue(res);
17
- res.statusCode = 200;
18
- res.on = jest.fn((event: string, handler: () => void) => {
19
- if (event === 'finish') res._finishHandler = handler;
20
- return res as Response;
21
- });
22
- return res as Response;
23
- }
24
-
25
- describe('metricsHandler', () => {
26
- it('writes the registry content-type header and the serialized metrics body', async () => {
27
- const res = mockRes();
28
-
29
- await metricsHandler({} as Request, res);
30
-
31
- expect(res.set).toHaveBeenCalledWith('Content-Type', register.contentType);
32
- expect(res.end).toHaveBeenCalledWith(expect.any(String));
33
- });
34
-
35
- it('responds 500 when the registry fails to serialize metrics', async () => {
36
- const res = mockRes();
37
- jest.spyOn(register, 'metrics').mockRejectedValueOnce(new Error('boom'));
38
-
39
- await metricsHandler({} as Request, res);
40
-
41
- expect(res.status).toHaveBeenCalledWith(500);
42
- expect(res.end).toHaveBeenCalledWith('Error generating metrics');
43
- });
44
- });
45
-
46
- describe('metricsMiddleware', () => {
47
- it('increments active connections immediately and decrements once the response finishes', async () => {
48
- const req = { method: 'GET', url: '/albums' } as Request;
49
- const res = mockRes() as Response & { _finishHandler?: () => void };
50
- const next = jest.fn();
51
- const before = (await activeConnections.get()).values[0]?.value ?? 0;
52
-
53
- metricsMiddleware(req, res, next);
54
-
55
- expect(next).toHaveBeenCalledTimes(1);
56
- expect(res.on).toHaveBeenCalledWith('finish', expect.any(Function));
57
- expect((await activeConnections.get()).values[0]?.value).toBe(before + 1);
58
-
59
- (res as any)._finishHandler();
60
-
61
- expect((await activeConnections.get()).values[0]?.value).toBe(before);
62
- });
63
-
64
- it('records request duration and total-count metrics labeled by method/url/status on finish', () => {
65
- const req = { method: 'POST', url: '/albums' } as Request;
66
- const res = mockRes();
67
- res.statusCode = 201;
68
- const next = jest.fn();
69
-
70
- const durationSpy = jest.spyOn(httpRequestDuration, 'labels');
71
- const totalSpy = jest.spyOn(httpRequestsTotal, 'labels');
72
-
73
- metricsMiddleware(req, res, next);
74
- (res as any)._finishHandler();
75
-
76
- expect(durationSpy).toHaveBeenCalledWith('POST', '/albums', '201');
77
- expect(totalSpy).toHaveBeenCalledWith('POST', '/albums', '201');
78
-
79
- durationSpy.mockRestore();
80
- totalSpy.mockRestore();
81
- });
82
- });
83
-
84
- describe('default labels', () => {
85
- it('falls back to app "unknown-service" and version "1.0.0" when their env vars are unset', async () => {
86
- const ORIGINAL_ENV = process.env;
87
- const merged: Record<string, string | undefined> = { ...ORIGINAL_ENV };
88
- delete merged.SERVICE_NAME;
89
- delete merged.npm_package_version;
90
- process.env = merged as NodeJS.ProcessEnv;
91
-
92
- // isolateModulesAsync sandboxes the whole module registry, including
93
- // prom-client itself — the spy must target the Registry class required
94
- // *inside* the sandbox, not the one imported at the top of this file.
95
- await jest.isolateModulesAsync(async () => {
96
- const innerPromClient = (await import('prom-client')).default;
97
- const setDefaultLabelsSpy = jest.spyOn(innerPromClient.Registry.prototype, 'setDefaultLabels');
98
-
99
- await import('./index');
100
-
101
- expect(setDefaultLabelsSpy).toHaveBeenCalledWith({ app: 'unknown-service', version: '1.0.0' });
102
- setDefaultLabelsSpy.mockRestore();
103
- });
104
-
105
- process.env = ORIGINAL_ENV;
106
- });
107
- });
108
-
109
- describe('businessMetrics', () => {
110
- it('exposes counters that can be incremented without throwing', () => {
111
- expect(() => businessMetrics.albumsCreated.inc()).not.toThrow();
112
- expect(() => businessMetrics.filesUploaded.inc({ file_type: 'image' })).not.toThrow();
113
- expect(() => businessMetrics.usersRegistered.inc()).not.toThrow();
114
- expect(() => businessMetrics.paymentsProcessed.inc({ status: 'success', method: 'card' })).not.toThrow();
115
- });
116
- });
@@ -1,111 +0,0 @@
1
- import promClient from 'prom-client';
2
- import { Request, Response, NextFunction } from 'express';
3
-
4
- // Create a Registry which registers the metrics
5
- const register = new promClient.Registry();
6
-
7
- // Add a default label which is added to all metrics
8
- register.setDefaultLabels({
9
- app: process.env.SERVICE_NAME || 'unknown-service',
10
- version: process.env.npm_package_version || '1.0.0'
11
- });
12
-
13
- // Enable the collection of default metrics
14
- promClient.collectDefaultMetrics({ register });
15
-
16
- // Custom metrics
17
- export const httpRequestDuration = new promClient.Histogram({
18
- name: 'http_request_duration_seconds',
19
- help: 'Duration of HTTP requests in seconds',
20
- labelNames: ['method', 'route', 'status_code'],
21
- buckets: [0.1, 0.5, 1, 2, 5, 10]
22
- });
23
-
24
- export const httpRequestsTotal = new promClient.Counter({
25
- name: 'http_requests_total',
26
- help: 'Total number of HTTP requests',
27
- labelNames: ['method', 'route', 'status_code']
28
- });
29
-
30
- export const activeConnections = new promClient.Gauge({
31
- name: 'active_connections',
32
- help: 'Number of active connections'
33
- });
34
-
35
- export const databaseQueryDuration = new promClient.Histogram({
36
- name: 'database_query_duration_seconds',
37
- help: 'Duration of database queries in seconds',
38
- labelNames: ['operation', 'collection'],
39
- buckets: [0.01, 0.05, 0.1, 0.5, 1, 2, 5]
40
- });
41
-
42
- export const databaseQueriesTotal = new promClient.Counter({
43
- name: 'database_queries_total',
44
- help: 'Total number of database queries',
45
- labelNames: ['operation', 'collection', 'status']
46
- });
47
-
48
- export const businessMetrics = {
49
- albumsCreated: new promClient.Counter({
50
- name: 'albums_created_total',
51
- help: 'Total number of albums created'
52
- }),
53
-
54
- filesUploaded: new promClient.Counter({
55
- name: 'files_uploaded_total',
56
- help: 'Total number of files uploaded',
57
- labelNames: ['file_type']
58
- }),
59
-
60
- usersRegistered: new promClient.Counter({
61
- name: 'users_registered_total',
62
- help: 'Total number of users registered'
63
- }),
64
-
65
- paymentsProcessed: new promClient.Counter({
66
- name: 'payments_processed_total',
67
- help: 'Total number of payments processed',
68
- labelNames: ['status', 'method']
69
- })
70
- };
71
-
72
- // Metrics endpoint handler
73
- export const metricsHandler = async (_req: Request, res: Response) => {
74
- try {
75
- res.set('Content-Type', register.contentType);
76
- const metrics = await register.metrics();
77
- res.end(metrics);
78
- } catch (error) {
79
- res.status(500).end('Error generating metrics');
80
- }
81
- };
82
-
83
- // Middleware to collect HTTP metrics
84
- export const metricsMiddleware = (req: Request, res: Response, next: NextFunction) => {
85
- const start = Date.now();
86
- const { method, url } = req;
87
-
88
- // Increment active connections
89
- activeConnections.inc();
90
-
91
- res.on('finish', () => {
92
- const duration = (Date.now() - start) / 1000; // Convert to seconds
93
- const { statusCode } = res;
94
-
95
- // Record metrics
96
- httpRequestDuration
97
- .labels(method, url, statusCode.toString())
98
- .observe(duration);
99
-
100
- httpRequestsTotal
101
- .labels(method, url, statusCode.toString())
102
- .inc();
103
-
104
- // Decrement active connections
105
- activeConnections.dec();
106
- });
107
-
108
- next();
109
- };
110
-
111
- export { register };
@@ -1,275 +0,0 @@
1
- import { Request, Response } from 'express';
2
- import jwt from 'jsonwebtoken';
3
- import { authenticateToken, optionalAuth } from './authMiddleware';
4
- import { PERSONAL_TENANT } from '../apiKeys/types';
5
- import { mintAccessToken } from '../oauth/tokens';
6
- import { UserPayload } from '../types/auth';
7
- import { overrideSessionCheckForTests } from '../auth/session';
8
- import { overrideMembershipLookupForTests } from '../groups/membership';
9
-
10
- // These tokens carry no session; the session check has its own tests below and in session.test.ts.
11
- beforeEach(() => overrideSessionCheckForTests(async () => true));
12
- afterAll(() => overrideSessionCheckForTests());
13
-
14
- function mockReq(overrides: Partial<Request> = {}): Request {
15
- return { cookies: {}, headers: {}, ...overrides } as unknown as Request;
16
- }
17
-
18
- function mockRes(): Response {
19
- const res: Partial<Response> = {};
20
- res.status = jest.fn().mockReturnValue(res);
21
- res.json = jest.fn().mockReturnValue(res);
22
- return res as Response;
23
- }
24
-
25
- describe('authenticateToken', () => {
26
- const ORIGINAL_ENV = process.env;
27
- const next = jest.fn();
28
-
29
- beforeEach(() => {
30
- process.env = { ...ORIGINAL_ENV, JWT_SECRET: 'test-secret' };
31
- next.mockClear();
32
- });
33
-
34
- afterAll(() => {
35
- process.env = ORIGINAL_ENV;
36
- });
37
-
38
- const payload: UserPayload = { id: '1', email: 'user@example.com', name: 'Test User' };
39
-
40
- it('attaches the decoded user and calls next for a valid cookie token', async () => {
41
- const token = jwt.sign(payload, 'test-secret');
42
- const req = mockReq({ cookies: { access_token: token } });
43
- const res = mockRes();
44
-
45
- await authenticateToken(req, res, next);
46
-
47
- expect(next).toHaveBeenCalledTimes(1);
48
- expect(req.user).toMatchObject({ id: '1', email: 'user@example.com' });
49
- });
50
-
51
- it('attaches an empty userGroups array when the token has no groups claim', async () => {
52
- const token = jwt.sign(payload, 'test-secret');
53
- const req = mockReq({ cookies: { access_token: token } });
54
- const res = mockRes();
55
-
56
- await authenticateToken(req, res, next);
57
-
58
- expect(req.userGroups).toEqual([]);
59
- });
60
-
61
- it("takes the person's groups from group-service, not from the token", async () => {
62
- const lookup = jest.fn(async () => ['live-group']);
63
- overrideMembershipLookupForTests(lookup);
64
- const token = jwt.sign({ ...payload, groups: ['stale-group'] }, 'test-secret');
65
- const req = mockReq({ cookies: { access_token: token } });
66
- const res = mockRes();
67
-
68
- await authenticateToken(req, res, next);
69
-
70
- expect(lookup).toHaveBeenCalledWith(expect.objectContaining({ email: 'user@example.com' }));
71
- expect(req.userGroups).toEqual(['live-group']);
72
- expect(req.user?.groups).toEqual(['live-group']);
73
- overrideMembershipLookupForTests();
74
- });
75
-
76
- it('accepts a Bearer token from the Authorization header', async () => {
77
- const token = jwt.sign(payload, 'test-secret');
78
- const req = mockReq({ headers: { authorization: `Bearer ${token}` } });
79
- const res = mockRes();
80
-
81
- await authenticateToken(req, res, next);
82
-
83
- expect(next).toHaveBeenCalledTimes(1);
84
- });
85
-
86
- it('returns 401 when no token is present', async () => {
87
- const req = mockReq();
88
- const res = mockRes();
89
-
90
- await authenticateToken(req, res, next);
91
-
92
- expect(next).not.toHaveBeenCalled();
93
- expect(res.status).toHaveBeenCalledWith(401);
94
- expect(res.json).toHaveBeenCalledWith({ success: false, message: 'Access token required' });
95
- });
96
-
97
- it('returns 401 for an invalid token', async () => {
98
- const req = mockReq({ cookies: { access_token: 'not-a-real-token' } });
99
- const res = mockRes();
100
-
101
- await authenticateToken(req, res, next);
102
-
103
- expect(next).not.toHaveBeenCalled();
104
- expect(res.status).toHaveBeenCalledWith(401);
105
- expect(res.json).toHaveBeenCalledWith({ success: false, message: 'Invalid or expired token' });
106
- });
107
-
108
- it('returns 401 for a token signed with a different secret', async () => {
109
- const token = jwt.sign(payload, 'wrong-secret');
110
- const req = mockReq({ cookies: { access_token: token } });
111
- const res = mockRes();
112
-
113
- await authenticateToken(req, res, next);
114
-
115
- expect(res.status).toHaveBeenCalledWith(401);
116
- });
117
- });
118
-
119
- describe('optionalAuth', () => {
120
- const ORIGINAL_ENV = process.env;
121
- const next = jest.fn();
122
-
123
- beforeEach(() => {
124
- process.env = { ...ORIGINAL_ENV, JWT_SECRET: 'test-secret' };
125
- next.mockClear();
126
- });
127
-
128
- afterAll(() => {
129
- process.env = ORIGINAL_ENV;
130
- });
131
-
132
- const payload: UserPayload = { id: '1', email: 'user@example.com', name: 'Test User' };
133
-
134
- it('attaches the user when the token is valid', async () => {
135
- const req = mockReq({ cookies: { access_token: jwt.sign(payload, 'test-secret') } });
136
-
137
- await optionalAuth(req, mockRes(), next);
138
-
139
- expect(next).toHaveBeenCalledTimes(1);
140
- expect(req.user).toMatchObject({ email: 'user@example.com' });
141
- });
142
-
143
- it('reads a bearer token too', async () => {
144
- const req = mockReq({ headers: { authorization: `Bearer ${jwt.sign(payload, 'test-secret')}` } });
145
-
146
- await optionalAuth(req, mockRes(), next);
147
-
148
- expect(req.user).toMatchObject({ email: 'user@example.com' });
149
- });
150
-
151
- it('continues anonymously when there is no token', async () => {
152
- const req = mockReq();
153
- const res = mockRes();
154
-
155
- await optionalAuth(req, res, next);
156
-
157
- expect(next).toHaveBeenCalledTimes(1);
158
- expect(req.user).toBeUndefined();
159
- expect(res.status).not.toHaveBeenCalled();
160
- });
161
-
162
- it('treats an expired or forged token as no token rather than as a 401', async () => {
163
- // The caller asked for public data. Refusing it because a stale cookie is
164
- // lying around would break the landing page for exactly the people most
165
- // likely to have one.
166
- const req = mockReq({ cookies: { access_token: jwt.sign(payload, 'wrong-secret') } });
167
- const res = mockRes();
168
-
169
- await optionalAuth(req, res, next);
170
-
171
- expect(next).toHaveBeenCalledTimes(1);
172
- expect(req.user).toBeUndefined();
173
- expect(res.status).not.toHaveBeenCalled();
174
- });
175
-
176
- it('continues anonymously when JWT_SECRET is not configured', async () => {
177
- delete process.env.JWT_SECRET;
178
- const req = mockReq({ cookies: { access_token: 'anything' } });
179
- const res = mockRes();
180
-
181
- await optionalAuth(req, res, next);
182
-
183
- expect(next).toHaveBeenCalledTimes(1);
184
- expect(res.status).not.toHaveBeenCalled();
185
- });
186
- });
187
-
188
- describe('OAuth access tokens are not sessions', () => {
189
- const ORIGINAL_ENV = process.env;
190
-
191
- beforeEach(() => {
192
- process.env = { ...ORIGINAL_ENV, JWT_SECRET: 'test-secret' };
193
- });
194
-
195
- afterEach(() => {
196
- process.env = ORIGINAL_ENV;
197
- });
198
-
199
- const accessToken = () =>
200
- mintAccessToken({
201
- userId: 'u1',
202
- email: 'u1@example.com',
203
- name: 'Tester',
204
- resource: 'https://mcp.example.com',
205
- issuer: 'https://auth.example.com',
206
- scopes: ['relationship:read'],
207
- tenants: [PERSONAL_TENANT],
208
- defaultTenant: PERSONAL_TENANT,
209
- tenantNames: {},
210
- clientId: 'client-1',
211
- clientName: 'Claude'
212
- }).accessToken;
213
-
214
- it('refuses one on a session-only route', async () => {
215
- const res = mockRes();
216
- const next = jest.fn();
217
- const req = mockReq({ headers: { authorization: `Bearer ${accessToken()}` } });
218
-
219
- // It verifies — same secret — so nothing but the type check stands between
220
- // an assistant and every route that has no scopes to be held to.
221
- await authenticateToken(req, res, next);
222
-
223
- expect(res.status).toHaveBeenCalledWith(403);
224
- expect(next).not.toHaveBeenCalled();
225
- expect(req.user).toBeUndefined();
226
- });
227
-
228
- it('treats one as no session at all on an optional-auth route', async () => {
229
- const next = jest.fn();
230
- const req = mockReq({ headers: { authorization: `Bearer ${accessToken()}` } });
231
-
232
- await optionalAuth(req, mockRes(), next);
233
-
234
- // The route still answers, in its public form — the same as a stale cookie.
235
- expect(next).toHaveBeenCalled();
236
- expect(req.user).toBeUndefined();
237
- });
238
- });
239
-
240
- describe('ended sessions', () => {
241
- const ORIGINAL_ENV = process.env;
242
- const payload: UserPayload = { id: '1', email: 'user@example.com', name: 'Test User', sid: 'a'.repeat(24) };
243
-
244
- beforeEach(() => {
245
- process.env = { ...ORIGINAL_ENV, JWT_SECRET: 'test-secret' };
246
- overrideSessionCheckForTests(async () => false);
247
- });
248
-
249
- afterAll(() => {
250
- process.env = ORIGINAL_ENV;
251
- });
252
-
253
- it('authenticateToken refuses a validly signed token whose session has ended', async () => {
254
- const req = mockReq({ cookies: { access_token: jwt.sign(payload, 'test-secret') } });
255
- const res = mockRes();
256
- const next = jest.fn();
257
-
258
- await authenticateToken(req, res, next);
259
-
260
- expect(res.status).toHaveBeenCalledWith(401);
261
- expect(res.json).toHaveBeenCalledWith({ success: false, message: 'Session has ended' });
262
- expect(next).not.toHaveBeenCalled();
263
- expect(req.user).toBeUndefined();
264
- });
265
-
266
- it('optionalAuth treats an ended session as no session', async () => {
267
- const req = mockReq({ cookies: { access_token: jwt.sign(payload, 'test-secret') } });
268
- const next = jest.fn();
269
-
270
- await optionalAuth(req, mockRes(), next);
271
-
272
- expect(next).toHaveBeenCalledTimes(1);
273
- expect(req.user).toBeUndefined();
274
- });
275
- });