servcraft 0.1.0 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +30 -0
- package/.github/CODEOWNERS +18 -0
- package/.github/PULL_REQUEST_TEMPLATE.md +46 -0
- package/.github/dependabot.yml +59 -0
- package/.github/workflows/ci.yml +188 -0
- package/.github/workflows/release.yml +195 -0
- package/AUDIT.md +602 -0
- package/LICENSE +21 -0
- package/README.md +1102 -1
- package/dist/cli/index.cjs +2026 -2168
- package/dist/cli/index.cjs.map +1 -1
- package/dist/cli/index.js +2026 -2168
- package/dist/cli/index.js.map +1 -1
- package/dist/index.cjs +595 -616
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +114 -52
- package/dist/index.d.ts +114 -52
- package/dist/index.js +595 -616
- package/dist/index.js.map +1 -1
- package/docs/CLI-001_MULTI_DB_PLAN.md +546 -0
- package/docs/DATABASE_MULTI_ORM.md +399 -0
- package/docs/PHASE1_BREAKDOWN.md +346 -0
- package/docs/PROGRESS.md +550 -0
- package/docs/modules/ANALYTICS.md +226 -0
- package/docs/modules/API-VERSIONING.md +252 -0
- package/docs/modules/AUDIT.md +192 -0
- package/docs/modules/AUTH.md +431 -0
- package/docs/modules/CACHE.md +346 -0
- package/docs/modules/EMAIL.md +254 -0
- package/docs/modules/FEATURE-FLAG.md +291 -0
- package/docs/modules/I18N.md +294 -0
- package/docs/modules/MEDIA-PROCESSING.md +281 -0
- package/docs/modules/MFA.md +266 -0
- package/docs/modules/NOTIFICATION.md +311 -0
- package/docs/modules/OAUTH.md +237 -0
- package/docs/modules/PAYMENT.md +804 -0
- package/docs/modules/QUEUE.md +540 -0
- package/docs/modules/RATE-LIMIT.md +339 -0
- package/docs/modules/SEARCH.md +288 -0
- package/docs/modules/SECURITY.md +327 -0
- package/docs/modules/SESSION.md +382 -0
- package/docs/modules/SWAGGER.md +305 -0
- package/docs/modules/UPLOAD.md +296 -0
- package/docs/modules/USER.md +505 -0
- package/docs/modules/VALIDATION.md +294 -0
- package/docs/modules/WEBHOOK.md +270 -0
- package/docs/modules/WEBSOCKET.md +691 -0
- package/package.json +53 -38
- package/prisma/schema.prisma +395 -1
- package/src/cli/commands/add-module.ts +520 -87
- package/src/cli/commands/db.ts +3 -4
- package/src/cli/commands/docs.ts +256 -6
- package/src/cli/commands/generate.ts +12 -19
- package/src/cli/commands/init.ts +384 -214
- package/src/cli/index.ts +0 -4
- package/src/cli/templates/repository.ts +6 -1
- package/src/cli/templates/routes.ts +6 -21
- package/src/cli/utils/docs-generator.ts +6 -7
- package/src/cli/utils/env-manager.ts +717 -0
- package/src/cli/utils/field-parser.ts +16 -7
- package/src/cli/utils/interactive-prompt.ts +223 -0
- package/src/cli/utils/template-manager.ts +346 -0
- package/src/config/database.config.ts +183 -0
- package/src/config/env.ts +0 -10
- package/src/config/index.ts +0 -14
- package/src/core/server.ts +1 -1
- package/src/database/adapters/mongoose.adapter.ts +132 -0
- package/src/database/adapters/prisma.adapter.ts +118 -0
- package/src/database/connection.ts +190 -0
- package/src/database/interfaces/database.interface.ts +85 -0
- package/src/database/interfaces/index.ts +7 -0
- package/src/database/interfaces/repository.interface.ts +129 -0
- package/src/database/models/mongoose/index.ts +7 -0
- package/src/database/models/mongoose/payment.schema.ts +347 -0
- package/src/database/models/mongoose/user.schema.ts +154 -0
- package/src/database/prisma.ts +1 -4
- package/src/database/redis.ts +101 -0
- package/src/database/repositories/mongoose/index.ts +7 -0
- package/src/database/repositories/mongoose/payment.repository.ts +380 -0
- package/src/database/repositories/mongoose/user.repository.ts +255 -0
- package/src/database/seed.ts +6 -1
- package/src/index.ts +9 -20
- package/src/middleware/security.ts +2 -6
- package/src/modules/analytics/analytics.routes.ts +80 -0
- package/src/modules/analytics/analytics.service.ts +364 -0
- package/src/modules/analytics/index.ts +18 -0
- package/src/modules/analytics/types.ts +180 -0
- package/src/modules/api-versioning/index.ts +15 -0
- package/src/modules/api-versioning/types.ts +86 -0
- package/src/modules/api-versioning/versioning.middleware.ts +120 -0
- package/src/modules/api-versioning/versioning.routes.ts +54 -0
- package/src/modules/api-versioning/versioning.service.ts +189 -0
- package/src/modules/audit/audit.repository.ts +206 -0
- package/src/modules/audit/audit.service.ts +27 -59
- package/src/modules/auth/auth.controller.ts +2 -2
- package/src/modules/auth/auth.middleware.ts +3 -9
- package/src/modules/auth/auth.routes.ts +10 -107
- package/src/modules/auth/auth.service.ts +126 -23
- package/src/modules/auth/index.ts +3 -4
- package/src/modules/cache/cache.service.ts +367 -0
- package/src/modules/cache/index.ts +10 -0
- package/src/modules/cache/types.ts +44 -0
- package/src/modules/email/email.service.ts +3 -10
- package/src/modules/email/templates.ts +2 -8
- package/src/modules/feature-flag/feature-flag.repository.ts +303 -0
- package/src/modules/feature-flag/feature-flag.routes.ts +247 -0
- package/src/modules/feature-flag/feature-flag.service.ts +566 -0
- package/src/modules/feature-flag/index.ts +20 -0
- package/src/modules/feature-flag/types.ts +192 -0
- package/src/modules/i18n/i18n.middleware.ts +186 -0
- package/src/modules/i18n/i18n.routes.ts +191 -0
- package/src/modules/i18n/i18n.service.ts +456 -0
- package/src/modules/i18n/index.ts +18 -0
- package/src/modules/i18n/types.ts +118 -0
- package/src/modules/media-processing/index.ts +17 -0
- package/src/modules/media-processing/media-processing.routes.ts +111 -0
- package/src/modules/media-processing/media-processing.service.ts +245 -0
- package/src/modules/media-processing/types.ts +156 -0
- package/src/modules/mfa/index.ts +20 -0
- package/src/modules/mfa/mfa.repository.ts +206 -0
- package/src/modules/mfa/mfa.routes.ts +595 -0
- package/src/modules/mfa/mfa.service.ts +572 -0
- package/src/modules/mfa/totp.ts +150 -0
- package/src/modules/mfa/types.ts +57 -0
- package/src/modules/notification/index.ts +20 -0
- package/src/modules/notification/notification.repository.ts +356 -0
- package/src/modules/notification/notification.service.ts +483 -0
- package/src/modules/notification/types.ts +119 -0
- package/src/modules/oauth/index.ts +20 -0
- package/src/modules/oauth/oauth.repository.ts +219 -0
- package/src/modules/oauth/oauth.routes.ts +446 -0
- package/src/modules/oauth/oauth.service.ts +293 -0
- package/src/modules/oauth/providers/apple.provider.ts +250 -0
- package/src/modules/oauth/providers/facebook.provider.ts +181 -0
- package/src/modules/oauth/providers/github.provider.ts +248 -0
- package/src/modules/oauth/providers/google.provider.ts +189 -0
- package/src/modules/oauth/providers/twitter.provider.ts +214 -0
- package/src/modules/oauth/types.ts +94 -0
- package/src/modules/payment/index.ts +19 -0
- package/src/modules/payment/payment.repository.ts +733 -0
- package/src/modules/payment/payment.routes.ts +390 -0
- package/src/modules/payment/payment.service.ts +354 -0
- package/src/modules/payment/providers/mobile-money.provider.ts +274 -0
- package/src/modules/payment/providers/paypal.provider.ts +190 -0
- package/src/modules/payment/providers/stripe.provider.ts +215 -0
- package/src/modules/payment/types.ts +140 -0
- package/src/modules/queue/cron.ts +438 -0
- package/src/modules/queue/index.ts +87 -0
- package/src/modules/queue/queue.routes.ts +600 -0
- package/src/modules/queue/queue.service.ts +842 -0
- package/src/modules/queue/types.ts +222 -0
- package/src/modules/queue/workers.ts +366 -0
- package/src/modules/rate-limit/index.ts +59 -0
- package/src/modules/rate-limit/rate-limit.middleware.ts +134 -0
- package/src/modules/rate-limit/rate-limit.routes.ts +269 -0
- package/src/modules/rate-limit/rate-limit.service.ts +348 -0
- package/src/modules/rate-limit/stores/memory.store.ts +165 -0
- package/src/modules/rate-limit/stores/redis.store.ts +322 -0
- package/src/modules/rate-limit/types.ts +153 -0
- package/src/modules/search/adapters/elasticsearch.adapter.ts +326 -0
- package/src/modules/search/adapters/meilisearch.adapter.ts +261 -0
- package/src/modules/search/adapters/memory.adapter.ts +278 -0
- package/src/modules/search/index.ts +21 -0
- package/src/modules/search/search.service.ts +234 -0
- package/src/modules/search/types.ts +214 -0
- package/src/modules/security/index.ts +40 -0
- package/src/modules/security/sanitize.ts +223 -0
- package/src/modules/security/security-audit.service.ts +388 -0
- package/src/modules/security/security.middleware.ts +398 -0
- package/src/modules/session/index.ts +3 -0
- package/src/modules/session/session.repository.ts +159 -0
- package/src/modules/session/session.service.ts +340 -0
- package/src/modules/session/types.ts +38 -0
- package/src/modules/swagger/index.ts +7 -1
- package/src/modules/swagger/schema-builder.ts +16 -4
- package/src/modules/swagger/swagger.service.ts +9 -10
- package/src/modules/swagger/types.ts +0 -2
- package/src/modules/upload/index.ts +14 -0
- package/src/modules/upload/types.ts +83 -0
- package/src/modules/upload/upload.repository.ts +199 -0
- package/src/modules/upload/upload.routes.ts +311 -0
- package/src/modules/upload/upload.service.ts +448 -0
- package/src/modules/user/index.ts +3 -3
- package/src/modules/user/user.controller.ts +15 -9
- package/src/modules/user/user.repository.ts +237 -113
- package/src/modules/user/user.routes.ts +39 -164
- package/src/modules/user/user.service.ts +4 -3
- package/src/modules/validation/validator.ts +12 -17
- package/src/modules/webhook/index.ts +91 -0
- package/src/modules/webhook/retry.ts +196 -0
- package/src/modules/webhook/signature.ts +135 -0
- package/src/modules/webhook/types.ts +181 -0
- package/src/modules/webhook/webhook.repository.ts +358 -0
- package/src/modules/webhook/webhook.routes.ts +442 -0
- package/src/modules/webhook/webhook.service.ts +457 -0
- package/src/modules/websocket/features.ts +504 -0
- package/src/modules/websocket/index.ts +106 -0
- package/src/modules/websocket/middlewares.ts +298 -0
- package/src/modules/websocket/types.ts +181 -0
- package/src/modules/websocket/websocket.service.ts +692 -0
- package/src/utils/errors.ts +7 -0
- package/src/utils/pagination.ts +4 -1
- package/tests/helpers/db-check.ts +79 -0
- package/tests/integration/auth-redis.test.ts +94 -0
- package/tests/integration/cache-redis.test.ts +387 -0
- package/tests/integration/mongoose-repositories.test.ts +410 -0
- package/tests/integration/payment-prisma.test.ts +637 -0
- package/tests/integration/queue-bullmq.test.ts +417 -0
- package/tests/integration/user-prisma.test.ts +441 -0
- package/tests/integration/websocket-socketio.test.ts +552 -0
- package/tests/setup.ts +11 -9
- package/vitest.config.ts +3 -8
- package/npm-cache/_cacache/content-v2/sha512/1c/d0/03440d500a0487621aad1d6402978340698976602046db8e24fa03c01ee6c022c69b0582f969042d9442ee876ac35c038e960dd427d1e622fa24b8eb7dba +0 -0
- package/npm-cache/_cacache/content-v2/sha512/42/55/28b493ca491833e5aab0e9c3108d29ab3f36c248ca88f45d4630674fce9130959e56ae308797ac2b6328fa7f09a610b9550ed09cb971d039876d293fc69d +0 -0
- package/npm-cache/_cacache/content-v2/sha512/e0/12/f360dc9315ee5f17844a0c8c233ee6bf7c30837c4a02ea0d56c61c7f7ab21c0e958e50ed2c57c59f983c762b93056778c9009b2398ffc26def0183999b13 +0 -0
- package/npm-cache/_cacache/content-v2/sha512/ed/b0/fae1161902898f4c913c67d7f6cdf6be0665aec3b389b9c4f4f0a101ca1da59badf1b59c4e0030f5223023b8d63cfe501c46a32c20c895d4fb3f11ca2232 +0 -0
- package/npm-cache/_cacache/index-v5/58/94/c2cba79e0f16b4c10e95a87e32255741149e8222cc314a476aab67c39cc0 +0 -5
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import type { Request, Response, NextFunction } from 'express';
|
|
2
|
+
import type { VersioningService } from './versioning.service.js';
|
|
3
|
+
import type { VersionMiddlewareOptions } from './types.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Request with version info
|
|
7
|
+
*/
|
|
8
|
+
export interface VersionedRequest extends Request {
|
|
9
|
+
apiVersion?: string;
|
|
10
|
+
versionInfo?: {
|
|
11
|
+
version: string;
|
|
12
|
+
source: string;
|
|
13
|
+
isValid: boolean;
|
|
14
|
+
warning?: string;
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Create versioning middleware
|
|
20
|
+
*/
|
|
21
|
+
export function createVersioningMiddleware(
|
|
22
|
+
versioningService: VersioningService,
|
|
23
|
+
options: VersionMiddlewareOptions = {}
|
|
24
|
+
): (req: Request, res: Response, next: NextFunction) => void {
|
|
25
|
+
return (req: Request, res: Response, next: NextFunction): void => {
|
|
26
|
+
const versionedReq = req as VersionedRequest;
|
|
27
|
+
|
|
28
|
+
// Detect version
|
|
29
|
+
const detection = versioningService.detectVersion({
|
|
30
|
+
url: req.url,
|
|
31
|
+
headers: req.headers as Record<string, string | string[] | undefined>,
|
|
32
|
+
query: req.query as Record<string, string | string[] | undefined>,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
versionedReq.apiVersion = detection.version;
|
|
36
|
+
versionedReq.versionInfo = detection;
|
|
37
|
+
|
|
38
|
+
// Set response headers
|
|
39
|
+
res.setHeader('X-API-Version', detection.version);
|
|
40
|
+
|
|
41
|
+
if (detection.warning) {
|
|
42
|
+
res.setHeader('X-API-Deprecation-Warning', detection.warning);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Validation
|
|
46
|
+
if (!detection.isValid) {
|
|
47
|
+
res.status(400).json({
|
|
48
|
+
error: 'Invalid API version',
|
|
49
|
+
version: detection.version,
|
|
50
|
+
availableVersions: versioningService.getActiveVersions().map((v) => v.version),
|
|
51
|
+
});
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Check version requirements
|
|
56
|
+
if (options.requiredVersion && detection.version !== options.requiredVersion) {
|
|
57
|
+
res.status(400).json({
|
|
58
|
+
error: `This endpoint requires API version ${options.requiredVersion}`,
|
|
59
|
+
currentVersion: detection.version,
|
|
60
|
+
});
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (options.minVersion) {
|
|
65
|
+
if (versioningService.compareVersions(detection.version, options.minVersion) < 0) {
|
|
66
|
+
res.status(400).json({
|
|
67
|
+
error: `This endpoint requires at least API version ${options.minVersion}`,
|
|
68
|
+
currentVersion: detection.version,
|
|
69
|
+
});
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (options.maxVersion) {
|
|
75
|
+
if (versioningService.compareVersions(detection.version, options.maxVersion) > 0) {
|
|
76
|
+
res.status(400).json({
|
|
77
|
+
error: `This endpoint supports up to API version ${options.maxVersion}`,
|
|
78
|
+
currentVersion: detection.version,
|
|
79
|
+
});
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Check if deprecated versions are allowed
|
|
85
|
+
const versionInfo = versioningService.getVersion(detection.version);
|
|
86
|
+
if (versionInfo?.status === 'deprecated' && options.allowDeprecated === false) {
|
|
87
|
+
res.status(410).json({
|
|
88
|
+
error: `API version ${detection.version} is deprecated and no longer supported`,
|
|
89
|
+
sunsetAt: versionInfo.sunsetAt,
|
|
90
|
+
migrateToVersion: versioningService.getActiveVersions()[0]?.version,
|
|
91
|
+
});
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
next();
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Version-specific route handler
|
|
101
|
+
*/
|
|
102
|
+
export function versionedRoute(
|
|
103
|
+
versions: Record<string, (req: Request, res: Response) => void | Promise<void>>
|
|
104
|
+
): (req: Request, res: Response) => void | Promise<void> {
|
|
105
|
+
return (req: Request, res: Response): void | Promise<void> => {
|
|
106
|
+
const versionedReq = req as VersionedRequest;
|
|
107
|
+
const version = versionedReq.apiVersion || 'v1';
|
|
108
|
+
|
|
109
|
+
const handler = versions[version];
|
|
110
|
+
|
|
111
|
+
if (!handler) {
|
|
112
|
+
res.status(404).json({
|
|
113
|
+
error: `Endpoint not available in version ${version}`,
|
|
114
|
+
});
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return handler(req, res);
|
|
119
|
+
};
|
|
120
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import type { Request, Response } from 'express';
|
|
3
|
+
import type { VersioningService } from './versioning.service.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Create versioning routes
|
|
7
|
+
*/
|
|
8
|
+
export function createVersioningRoutes(versioningService: VersioningService): Router {
|
|
9
|
+
const router = Router();
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Get all API versions
|
|
13
|
+
* GET /versions
|
|
14
|
+
*/
|
|
15
|
+
router.get('/versions', (_req: Request, res: Response) => {
|
|
16
|
+
const versions = versioningService.getAllVersions();
|
|
17
|
+
res.json({
|
|
18
|
+
versions,
|
|
19
|
+
default: versioningService.getAllVersions().find((v) => v.isDefault)?.version,
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Get active versions
|
|
25
|
+
* GET /versions/active
|
|
26
|
+
*/
|
|
27
|
+
router.get('/versions/active', (_req: Request, res: Response) => {
|
|
28
|
+
const versions = versioningService.getActiveVersions();
|
|
29
|
+
res.json({ versions, count: versions.length });
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Get specific version info
|
|
34
|
+
* GET /versions/:version
|
|
35
|
+
*/
|
|
36
|
+
router.get('/versions/:version', (req: Request, res: Response) => {
|
|
37
|
+
const versionParam = req.params.version;
|
|
38
|
+
if (!versionParam) {
|
|
39
|
+
res.status(400).json({ error: 'Version parameter required' });
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const version = versioningService.getVersion(versionParam);
|
|
44
|
+
|
|
45
|
+
if (!version) {
|
|
46
|
+
res.status(404).json({ error: 'Version not found' });
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
res.json(version);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
return router;
|
|
54
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { logger } from '../../core/logger.js';
|
|
2
|
+
import type {
|
|
3
|
+
VersioningConfig,
|
|
4
|
+
ApiVersion,
|
|
5
|
+
VersionDetectionResult,
|
|
6
|
+
VersionStrategy,
|
|
7
|
+
VersionMigration,
|
|
8
|
+
} from './types.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* API Versioning Service
|
|
12
|
+
* Multiple API versions support
|
|
13
|
+
*/
|
|
14
|
+
export class VersioningService {
|
|
15
|
+
private config: VersioningConfig;
|
|
16
|
+
private migrations = new Map<string, VersionMigration>();
|
|
17
|
+
|
|
18
|
+
constructor(config: VersioningConfig) {
|
|
19
|
+
this.config = {
|
|
20
|
+
headerName: 'X-API-Version',
|
|
21
|
+
queryParam: 'version',
|
|
22
|
+
strict: true,
|
|
23
|
+
deprecationWarnings: true,
|
|
24
|
+
...config,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
logger.info(
|
|
28
|
+
{
|
|
29
|
+
strategy: this.config.strategy,
|
|
30
|
+
defaultVersion: this.config.defaultVersion,
|
|
31
|
+
versions: this.config.versions.map((v) => v.version),
|
|
32
|
+
},
|
|
33
|
+
'API versioning service initialized'
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Detect version from request
|
|
39
|
+
*/
|
|
40
|
+
detectVersion(req: {
|
|
41
|
+
url?: string;
|
|
42
|
+
headers?: Record<string, string | string[] | undefined>;
|
|
43
|
+
query?: Record<string, string | string[] | undefined>;
|
|
44
|
+
}): VersionDetectionResult {
|
|
45
|
+
let version: string | null = null;
|
|
46
|
+
let source: VersionStrategy = this.config.strategy;
|
|
47
|
+
|
|
48
|
+
// Try to detect based on strategy
|
|
49
|
+
switch (this.config.strategy) {
|
|
50
|
+
case 'url':
|
|
51
|
+
version = this.detectFromUrl(req.url || '');
|
|
52
|
+
source = 'url';
|
|
53
|
+
break;
|
|
54
|
+
|
|
55
|
+
case 'header':
|
|
56
|
+
version = this.detectFromHeader(req.headers || {});
|
|
57
|
+
source = 'header';
|
|
58
|
+
break;
|
|
59
|
+
|
|
60
|
+
case 'query':
|
|
61
|
+
version = this.detectFromQuery(req.query || {});
|
|
62
|
+
source = 'query';
|
|
63
|
+
break;
|
|
64
|
+
|
|
65
|
+
case 'accept-header':
|
|
66
|
+
version = this.detectFromAcceptHeader(req.headers || {});
|
|
67
|
+
source = 'accept-header';
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Fallback to default version
|
|
72
|
+
if (!version) {
|
|
73
|
+
version = this.config.defaultVersion;
|
|
74
|
+
source = this.config.strategy;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Validate version
|
|
78
|
+
const isValid = this.isVersionValid(version);
|
|
79
|
+
const versionInfo = this.getVersion(version);
|
|
80
|
+
|
|
81
|
+
let warning: string | undefined;
|
|
82
|
+
if (versionInfo?.status === 'deprecated' && this.config.deprecationWarnings) {
|
|
83
|
+
warning = `API version ${version} is deprecated`;
|
|
84
|
+
if (versionInfo.sunsetAt) {
|
|
85
|
+
warning += ` and will be removed on ${versionInfo.sunsetAt.toISOString().split('T')[0]}`;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
version,
|
|
91
|
+
source,
|
|
92
|
+
isValid,
|
|
93
|
+
warning,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Get version info
|
|
99
|
+
*/
|
|
100
|
+
getVersion(version: string): ApiVersion | undefined {
|
|
101
|
+
return this.config.versions.find((v) => v.version === version);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Get all versions
|
|
106
|
+
*/
|
|
107
|
+
getAllVersions(): ApiVersion[] {
|
|
108
|
+
return this.config.versions;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Get active versions
|
|
113
|
+
*/
|
|
114
|
+
getActiveVersions(): ApiVersion[] {
|
|
115
|
+
return this.config.versions.filter((v) => v.status === 'active');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Check if version is valid
|
|
120
|
+
*/
|
|
121
|
+
isVersionValid(version: string): boolean {
|
|
122
|
+
return this.config.versions.some((v) => v.version === version);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Add version migration
|
|
127
|
+
*/
|
|
128
|
+
addMigration(migration: VersionMigration): void {
|
|
129
|
+
const key = `${migration.from}-${migration.to}`;
|
|
130
|
+
this.migrations.set(key, migration);
|
|
131
|
+
logger.info({ from: migration.from, to: migration.to }, 'Migration added');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Get migration
|
|
136
|
+
*/
|
|
137
|
+
getMigration(from: string, to: string): VersionMigration | undefined {
|
|
138
|
+
return this.migrations.get(`${from}-${to}`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Compare versions
|
|
143
|
+
*/
|
|
144
|
+
compareVersions(v1: string, v2: string): number {
|
|
145
|
+
const num1 = parseInt(v1.replace(/\D/g, ''), 10);
|
|
146
|
+
const num2 = parseInt(v2.replace(/\D/g, ''), 10);
|
|
147
|
+
return num1 - num2;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Detect version from URL path
|
|
152
|
+
*/
|
|
153
|
+
private detectFromUrl(url: string): string | null {
|
|
154
|
+
const match = url.match(/\/(v\d+)\//);
|
|
155
|
+
return match?.[1] ?? null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Detect version from header
|
|
160
|
+
*/
|
|
161
|
+
private detectFromHeader(headers: Record<string, string | string[] | undefined>): string | null {
|
|
162
|
+
const headerValue = headers[this.config.headerName?.toLowerCase() || 'x-api-version'];
|
|
163
|
+
return Array.isArray(headerValue) ? (headerValue[0] ?? null) : (headerValue ?? null);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Detect version from query parameter
|
|
168
|
+
*/
|
|
169
|
+
private detectFromQuery(query: Record<string, string | string[] | undefined>): string | null {
|
|
170
|
+
const queryValue = query[this.config.queryParam || 'version'];
|
|
171
|
+
return Array.isArray(queryValue) ? (queryValue[0] ?? null) : (queryValue ?? null);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Detect version from Accept header
|
|
176
|
+
*/
|
|
177
|
+
private detectFromAcceptHeader(
|
|
178
|
+
headers: Record<string, string | string[] | undefined>
|
|
179
|
+
): string | null {
|
|
180
|
+
const accept = headers['accept'];
|
|
181
|
+
const acceptStr = Array.isArray(accept) ? accept[0] : accept;
|
|
182
|
+
|
|
183
|
+
if (!acceptStr) return null;
|
|
184
|
+
|
|
185
|
+
// Look for version in Accept header like: application/vnd.myapp.v2+json
|
|
186
|
+
const match = acceptStr.match(/\.v(\d+)\+/);
|
|
187
|
+
return match ? `v${match[1]}` : null;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Audit Repository
|
|
3
|
+
* Prisma-based persistence for audit logs
|
|
4
|
+
*/
|
|
5
|
+
import type { Prisma } from '@prisma/client';
|
|
6
|
+
import type { PrismaClient, AuditLog as PrismaAuditLog } from '@prisma/client';
|
|
7
|
+
import type { AuditLogEntry, AuditLogQuery } from './types.js';
|
|
8
|
+
import type { PaginatedResult, PaginationParams } from '../../types/index.js';
|
|
9
|
+
import { createPaginatedResult, getSkip } from '../../utils/pagination.js';
|
|
10
|
+
|
|
11
|
+
export interface AuditLogRecord extends AuditLogEntry {
|
|
12
|
+
id: string;
|
|
13
|
+
createdAt: Date;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class AuditRepository {
|
|
17
|
+
constructor(private prisma: PrismaClient) {}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Create a new audit log entry
|
|
21
|
+
*/
|
|
22
|
+
async create(entry: AuditLogEntry): Promise<AuditLogRecord> {
|
|
23
|
+
const log = await this.prisma.auditLog.create({
|
|
24
|
+
data: {
|
|
25
|
+
userId: entry.userId,
|
|
26
|
+
action: entry.action,
|
|
27
|
+
resource: entry.resource,
|
|
28
|
+
resourceId: entry.resourceId,
|
|
29
|
+
oldValue: entry.oldValue as Prisma.InputJsonValue,
|
|
30
|
+
newValue: entry.newValue as Prisma.InputJsonValue,
|
|
31
|
+
ipAddress: entry.ipAddress,
|
|
32
|
+
userAgent: entry.userAgent,
|
|
33
|
+
metadata: entry.metadata as Prisma.InputJsonValue,
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
return this.mapFromPrisma(log);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Create multiple audit log entries
|
|
42
|
+
*/
|
|
43
|
+
async createMany(entries: AuditLogEntry[]): Promise<number> {
|
|
44
|
+
const result = await this.prisma.auditLog.createMany({
|
|
45
|
+
data: entries.map((entry) => ({
|
|
46
|
+
userId: entry.userId,
|
|
47
|
+
action: entry.action,
|
|
48
|
+
resource: entry.resource,
|
|
49
|
+
resourceId: entry.resourceId,
|
|
50
|
+
oldValue: entry.oldValue as Prisma.InputJsonValue,
|
|
51
|
+
newValue: entry.newValue as Prisma.InputJsonValue,
|
|
52
|
+
ipAddress: entry.ipAddress,
|
|
53
|
+
userAgent: entry.userAgent,
|
|
54
|
+
metadata: entry.metadata as Prisma.InputJsonValue,
|
|
55
|
+
})),
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
return result.count;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Find audit log by ID
|
|
63
|
+
*/
|
|
64
|
+
async findById(id: string): Promise<AuditLogRecord | null> {
|
|
65
|
+
const log = await this.prisma.auditLog.findUnique({
|
|
66
|
+
where: { id },
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
return log ? this.mapFromPrisma(log) : null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Query audit logs with filters and pagination
|
|
74
|
+
*/
|
|
75
|
+
async query(params: AuditLogQuery): Promise<PaginatedResult<AuditLogRecord>> {
|
|
76
|
+
const { page = 1, limit = 20 } = params;
|
|
77
|
+
const pagination: PaginationParams = { page, limit };
|
|
78
|
+
|
|
79
|
+
const where: Prisma.AuditLogWhereInput = {};
|
|
80
|
+
|
|
81
|
+
if (params.userId) where.userId = params.userId;
|
|
82
|
+
if (params.action) where.action = params.action;
|
|
83
|
+
if (params.resource) where.resource = params.resource;
|
|
84
|
+
if (params.resourceId) where.resourceId = params.resourceId;
|
|
85
|
+
|
|
86
|
+
if (params.startDate || params.endDate) {
|
|
87
|
+
where.createdAt = {};
|
|
88
|
+
if (params.startDate) where.createdAt.gte = params.startDate;
|
|
89
|
+
if (params.endDate) where.createdAt.lte = params.endDate;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const [logs, total] = await Promise.all([
|
|
93
|
+
this.prisma.auditLog.findMany({
|
|
94
|
+
where,
|
|
95
|
+
orderBy: { createdAt: 'desc' },
|
|
96
|
+
skip: getSkip(pagination),
|
|
97
|
+
take: limit,
|
|
98
|
+
}),
|
|
99
|
+
this.prisma.auditLog.count({ where }),
|
|
100
|
+
]);
|
|
101
|
+
|
|
102
|
+
const data = logs.map((log) => this.mapFromPrisma(log));
|
|
103
|
+
return createPaginatedResult(data, total, pagination);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Find logs by user ID
|
|
108
|
+
*/
|
|
109
|
+
async findByUser(userId: string, limit = 50): Promise<AuditLogRecord[]> {
|
|
110
|
+
const logs = await this.prisma.auditLog.findMany({
|
|
111
|
+
where: { userId },
|
|
112
|
+
orderBy: { createdAt: 'desc' },
|
|
113
|
+
take: limit,
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
return logs.map((log) => this.mapFromPrisma(log));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Find logs by resource
|
|
121
|
+
*/
|
|
122
|
+
async findByResource(
|
|
123
|
+
resource: string,
|
|
124
|
+
resourceId?: string,
|
|
125
|
+
limit = 50
|
|
126
|
+
): Promise<AuditLogRecord[]> {
|
|
127
|
+
const where: Prisma.AuditLogWhereInput = { resource };
|
|
128
|
+
if (resourceId) where.resourceId = resourceId;
|
|
129
|
+
|
|
130
|
+
const logs = await this.prisma.auditLog.findMany({
|
|
131
|
+
where,
|
|
132
|
+
orderBy: { createdAt: 'desc' },
|
|
133
|
+
take: limit,
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
return logs.map((log) => this.mapFromPrisma(log));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Find logs by action
|
|
141
|
+
*/
|
|
142
|
+
async findByAction(action: string, limit = 50): Promise<AuditLogRecord[]> {
|
|
143
|
+
const logs = await this.prisma.auditLog.findMany({
|
|
144
|
+
where: { action },
|
|
145
|
+
orderBy: { createdAt: 'desc' },
|
|
146
|
+
take: limit,
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
return logs.map((log) => this.mapFromPrisma(log));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Count logs with optional filters
|
|
154
|
+
*/
|
|
155
|
+
async count(filters?: Partial<AuditLogQuery>): Promise<number> {
|
|
156
|
+
const where: Prisma.AuditLogWhereInput = {};
|
|
157
|
+
|
|
158
|
+
if (filters?.userId) where.userId = filters.userId;
|
|
159
|
+
if (filters?.action) where.action = filters.action;
|
|
160
|
+
if (filters?.resource) where.resource = filters.resource;
|
|
161
|
+
|
|
162
|
+
return this.prisma.auditLog.count({ where });
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Delete old audit logs (for data retention)
|
|
167
|
+
*/
|
|
168
|
+
async deleteOlderThan(days: number): Promise<number> {
|
|
169
|
+
const cutoffDate = new Date();
|
|
170
|
+
cutoffDate.setDate(cutoffDate.getDate() - days);
|
|
171
|
+
|
|
172
|
+
const result = await this.prisma.auditLog.deleteMany({
|
|
173
|
+
where: {
|
|
174
|
+
createdAt: { lt: cutoffDate },
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
return result.count;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Clear all audit logs (for testing)
|
|
183
|
+
*/
|
|
184
|
+
async clear(): Promise<void> {
|
|
185
|
+
await this.prisma.auditLog.deleteMany();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Map Prisma model to domain type
|
|
190
|
+
*/
|
|
191
|
+
private mapFromPrisma(log: PrismaAuditLog): AuditLogRecord {
|
|
192
|
+
return {
|
|
193
|
+
id: log.id,
|
|
194
|
+
userId: log.userId ?? undefined,
|
|
195
|
+
action: log.action,
|
|
196
|
+
resource: log.resource,
|
|
197
|
+
resourceId: log.resourceId ?? undefined,
|
|
198
|
+
oldValue: log.oldValue as Record<string, unknown> | undefined,
|
|
199
|
+
newValue: log.newValue as Record<string, unknown> | undefined,
|
|
200
|
+
ipAddress: log.ipAddress ?? undefined,
|
|
201
|
+
userAgent: log.userAgent ?? undefined,
|
|
202
|
+
metadata: log.metadata as Record<string, unknown> | undefined,
|
|
203
|
+
createdAt: log.createdAt,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
}
|
|
@@ -1,24 +1,20 @@
|
|
|
1
|
-
import { randomUUID } from 'crypto';
|
|
2
1
|
import { logger } from '../../core/logger.js';
|
|
3
2
|
import type { AuditLogEntry, AuditLogQuery } from './types.js';
|
|
4
3
|
import type { PaginatedResult } from '../../types/index.js';
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
// In-memory storage (will be replaced by Prisma in production)
|
|
8
|
-
const auditLogs: Map<string, AuditLogEntry & { id: string; createdAt: Date }> = new Map();
|
|
4
|
+
import { AuditRepository, type AuditLogRecord } from './audit.repository.js';
|
|
5
|
+
import { prisma } from '../../database/prisma.js';
|
|
9
6
|
|
|
10
7
|
export class AuditService {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
createdAt: new Date(),
|
|
17
|
-
};
|
|
8
|
+
private repository: AuditRepository;
|
|
9
|
+
|
|
10
|
+
constructor() {
|
|
11
|
+
this.repository = new AuditRepository(prisma);
|
|
12
|
+
}
|
|
18
13
|
|
|
19
|
-
|
|
14
|
+
async log(entry: AuditLogEntry): Promise<void> {
|
|
15
|
+
await this.repository.create(entry);
|
|
20
16
|
|
|
21
|
-
// Also log to structured logger
|
|
17
|
+
// Also log to structured logger for real-time monitoring
|
|
22
18
|
logger.info(
|
|
23
19
|
{
|
|
24
20
|
audit: true,
|
|
@@ -32,54 +28,20 @@ export class AuditService {
|
|
|
32
28
|
);
|
|
33
29
|
}
|
|
34
30
|
|
|
35
|
-
async query(
|
|
36
|
-
params
|
|
37
|
-
): Promise<PaginatedResult<AuditLogEntry & { id: string; createdAt: Date }>> {
|
|
38
|
-
const { page = 1, limit = 20 } = params;
|
|
39
|
-
let logs = Array.from(auditLogs.values());
|
|
40
|
-
|
|
41
|
-
// Apply filters
|
|
42
|
-
if (params.userId) {
|
|
43
|
-
logs = logs.filter((log) => log.userId === params.userId);
|
|
44
|
-
}
|
|
45
|
-
if (params.action) {
|
|
46
|
-
logs = logs.filter((log) => log.action === params.action);
|
|
47
|
-
}
|
|
48
|
-
if (params.resource) {
|
|
49
|
-
logs = logs.filter((log) => log.resource === params.resource);
|
|
50
|
-
}
|
|
51
|
-
if (params.resourceId) {
|
|
52
|
-
logs = logs.filter((log) => log.resourceId === params.resourceId);
|
|
53
|
-
}
|
|
54
|
-
if (params.startDate) {
|
|
55
|
-
logs = logs.filter((log) => log.createdAt >= params.startDate!);
|
|
56
|
-
}
|
|
57
|
-
if (params.endDate) {
|
|
58
|
-
logs = logs.filter((log) => log.createdAt <= params.endDate!);
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
// Sort by date descending
|
|
62
|
-
logs.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
|
63
|
-
|
|
64
|
-
const total = logs.length;
|
|
65
|
-
const skip = (page - 1) * limit;
|
|
66
|
-
const data = logs.slice(skip, skip + limit);
|
|
67
|
-
|
|
68
|
-
return createPaginatedResult(data, total, { page, limit });
|
|
31
|
+
async query(params: AuditLogQuery): Promise<PaginatedResult<AuditLogRecord>> {
|
|
32
|
+
return this.repository.query(params);
|
|
69
33
|
}
|
|
70
34
|
|
|
71
|
-
async findByUser(userId: string, limit = 50): Promise<
|
|
72
|
-
|
|
73
|
-
return result.data;
|
|
35
|
+
async findByUser(userId: string, limit = 50): Promise<AuditLogRecord[]> {
|
|
36
|
+
return this.repository.findByUser(userId, limit);
|
|
74
37
|
}
|
|
75
38
|
|
|
76
39
|
async findByResource(
|
|
77
40
|
resource: string,
|
|
78
41
|
resourceId: string,
|
|
79
42
|
limit = 50
|
|
80
|
-
): Promise<
|
|
81
|
-
|
|
82
|
-
return result.data;
|
|
43
|
+
): Promise<AuditLogRecord[]> {
|
|
44
|
+
return this.repository.findByResource(resource, resourceId, limit);
|
|
83
45
|
}
|
|
84
46
|
|
|
85
47
|
// Shortcut methods for common audit events
|
|
@@ -136,10 +98,7 @@ export class AuditService {
|
|
|
136
98
|
});
|
|
137
99
|
}
|
|
138
100
|
|
|
139
|
-
async logLogin(
|
|
140
|
-
userId: string,
|
|
141
|
-
meta?: { ipAddress?: string; userAgent?: string }
|
|
142
|
-
): Promise<void> {
|
|
101
|
+
async logLogin(userId: string, meta?: { ipAddress?: string; userAgent?: string }): Promise<void> {
|
|
143
102
|
await this.log({
|
|
144
103
|
action: 'login',
|
|
145
104
|
resource: 'auth',
|
|
@@ -172,9 +131,18 @@ export class AuditService {
|
|
|
172
131
|
});
|
|
173
132
|
}
|
|
174
133
|
|
|
134
|
+
// Data retention: delete old logs
|
|
135
|
+
async cleanupOldLogs(retentionDays: number): Promise<number> {
|
|
136
|
+
const count = await this.repository.deleteOlderThan(retentionDays);
|
|
137
|
+
if (count > 0) {
|
|
138
|
+
logger.info({ count, retentionDays }, 'Cleaned up old audit logs');
|
|
139
|
+
}
|
|
140
|
+
return count;
|
|
141
|
+
}
|
|
142
|
+
|
|
175
143
|
// Clear all logs (for testing)
|
|
176
144
|
async clear(): Promise<void> {
|
|
177
|
-
|
|
145
|
+
await this.repository.clear();
|
|
178
146
|
}
|
|
179
147
|
}
|
|
180
148
|
|