@rapidd/core 2.1.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 (59) hide show
  1. package/.dockerignore +71 -0
  2. package/.env.example +70 -0
  3. package/.gitignore +11 -0
  4. package/LICENSE +15 -0
  5. package/README.md +231 -0
  6. package/bin/cli.js +145 -0
  7. package/config/app.json +166 -0
  8. package/config/rate-limit.json +12 -0
  9. package/dist/main.js +26 -0
  10. package/dockerfile +57 -0
  11. package/locales/ar_SA.json +179 -0
  12. package/locales/de_DE.json +179 -0
  13. package/locales/en_US.json +180 -0
  14. package/locales/es_ES.json +179 -0
  15. package/locales/fr_FR.json +179 -0
  16. package/locales/it_IT.json +179 -0
  17. package/locales/ja_JP.json +179 -0
  18. package/locales/pt_BR.json +179 -0
  19. package/locales/ru_RU.json +179 -0
  20. package/locales/tr_TR.json +179 -0
  21. package/main.ts +25 -0
  22. package/package.json +126 -0
  23. package/prisma/schema.prisma +9 -0
  24. package/prisma.config.ts +12 -0
  25. package/public/static/favicon.ico +0 -0
  26. package/public/static/image/logo.png +0 -0
  27. package/routes/api/v1/index.ts +113 -0
  28. package/src/app.ts +197 -0
  29. package/src/auth/Auth.ts +446 -0
  30. package/src/auth/stores/ISessionStore.ts +19 -0
  31. package/src/auth/stores/MemoryStore.ts +70 -0
  32. package/src/auth/stores/RedisStore.ts +92 -0
  33. package/src/auth/stores/index.ts +149 -0
  34. package/src/config/acl.ts +9 -0
  35. package/src/config/rls.ts +38 -0
  36. package/src/core/dmmf.ts +226 -0
  37. package/src/core/env.ts +183 -0
  38. package/src/core/errors.ts +87 -0
  39. package/src/core/i18n.ts +144 -0
  40. package/src/core/middleware.ts +123 -0
  41. package/src/core/prisma.ts +236 -0
  42. package/src/index.ts +112 -0
  43. package/src/middleware/model.ts +61 -0
  44. package/src/orm/Model.ts +881 -0
  45. package/src/orm/QueryBuilder.ts +2078 -0
  46. package/src/plugins/auth.ts +162 -0
  47. package/src/plugins/language.ts +79 -0
  48. package/src/plugins/rateLimit.ts +210 -0
  49. package/src/plugins/response.ts +80 -0
  50. package/src/plugins/rls.ts +51 -0
  51. package/src/plugins/security.ts +23 -0
  52. package/src/plugins/upload.ts +299 -0
  53. package/src/types.ts +308 -0
  54. package/src/utils/ApiClient.ts +526 -0
  55. package/src/utils/Mailer.ts +348 -0
  56. package/src/utils/index.ts +25 -0
  57. package/templates/email/example.ejs +17 -0
  58. package/templates/layouts/email.ejs +35 -0
  59. package/tsconfig.json +33 -0
@@ -0,0 +1,162 @@
1
+ import { FastifyPluginAsync, FastifyRequest } from 'fastify';
2
+ import fp from 'fastify-plugin';
3
+ import { Auth } from '../auth/Auth';
4
+ import { ErrorResponse } from '../core/errors';
5
+ import type { RapiddUser, AuthOptions, AuthStrategy, RouteAuthConfig } from '../types';
6
+
7
+ interface AuthPluginOptions {
8
+ auth?: Auth;
9
+ authOptions?: AuthOptions;
10
+ }
11
+
12
+ /**
13
+ * Authentication plugin for Fastify.
14
+ * Parses Authorization header (Basic / Bearer) and sets request.user.
15
+ * Registers /auth/* routes when a user table is detected.
16
+ * Gracefully disables auth when no user table exists in the schema.
17
+ */
18
+ const authPlugin: FastifyPluginAsync<AuthPluginOptions> = async (fastify, options) => {
19
+ const auth = options.auth || new Auth(options.authOptions);
20
+
21
+ // Initialize auth (auto-detects user model, fields, JWT secrets)
22
+ await auth.initialize();
23
+
24
+ if (!auth.isEnabled()) {
25
+ // Auth disabled — still decorate for type safety but skip routes
26
+ fastify.decorate('auth', auth);
27
+ fastify.decorate('requireAuth', function requireAuth(_request: FastifyRequest) {
28
+ throw new ErrorResponse(401, 'authentication_not_available');
29
+ });
30
+ fastify.decorate('requireRole', function requireRole(..._roles: string[]) {
31
+ return async function (_request: FastifyRequest) {
32
+ throw new ErrorResponse(401, 'authentication_not_available');
33
+ };
34
+ });
35
+ return;
36
+ }
37
+
38
+ // Parse auth on every request using configured strategies (checked in order).
39
+ // Routes can override via config.auth: { strategies, cookieName, customHeaderName }
40
+ fastify.addHook('onRequest', async (request) => {
41
+ const routeAuth = (request.routeOptions?.config as any)?.auth as RouteAuthConfig | undefined;
42
+ const strategies = routeAuth?.strategies || auth.options.strategies;
43
+ const cookieName = routeAuth?.cookieName || auth.options.cookieName;
44
+ const customHeaderName = routeAuth?.customHeaderName || auth.options.customHeaderName;
45
+
46
+ let user: RapiddUser | null = null;
47
+
48
+ for (const strategy of strategies) {
49
+ if (user) break;
50
+
51
+ switch (strategy) {
52
+ case 'bearer': {
53
+ const h = request.headers.authorization;
54
+ if (h?.startsWith('Bearer ')) {
55
+ user = await auth.handleBearerAuth(h.substring(7));
56
+ }
57
+ break;
58
+ }
59
+ case 'basic': {
60
+ const h = request.headers.authorization;
61
+ if (h?.startsWith('Basic ')) {
62
+ user = await auth.handleBasicAuth(h.substring(6));
63
+ }
64
+ break;
65
+ }
66
+ case 'cookie': {
67
+ const raw = request.cookies?.[cookieName];
68
+ if (raw) {
69
+ const val = typeof raw === 'object' ? (raw as any).value : raw;
70
+ user = await auth.handleCookieAuth(val);
71
+ }
72
+ break;
73
+ }
74
+ case 'header': {
75
+ const val = request.headers[customHeaderName.toLowerCase()] as string | undefined;
76
+ if (val) {
77
+ user = await auth.handleCustomHeaderAuth(val);
78
+ }
79
+ break;
80
+ }
81
+ }
82
+ }
83
+
84
+ if (user) {
85
+ request.user = user;
86
+ }
87
+ });
88
+
89
+ // Auth routes
90
+ fastify.post('/auth/login', async (request, reply) => {
91
+ const result = await auth.login(request.body as { user: string; password: string });
92
+
93
+ if (auth.options.strategies.includes('cookie')) {
94
+ reply.setCookie(auth.options.cookieName, result.accessToken, {
95
+ path: '/',
96
+ httpOnly: true,
97
+ secure: process.env.NODE_ENV === 'production',
98
+ sameSite: 'strict',
99
+ signed: !!process.env.COOKIE_SECRET,
100
+ });
101
+ }
102
+
103
+ return reply.send(result);
104
+ });
105
+
106
+ fastify.post('/auth/logout', async (request, reply) => {
107
+ const result = await auth.logout(request.headers.authorization);
108
+
109
+ if (auth.options.strategies.includes('cookie')) {
110
+ reply.clearCookie(auth.options.cookieName, { path: '/' });
111
+ }
112
+
113
+ return reply.send(result);
114
+ });
115
+
116
+ fastify.post('/auth/refresh', async (request, reply) => {
117
+ const result = await auth.refresh(request.body as { refreshToken: string });
118
+ return reply.send(result);
119
+ });
120
+
121
+ fastify.get('/auth/me', async (request, reply) => {
122
+ const result = await auth.me(request.user);
123
+ return reply.send(result);
124
+ });
125
+
126
+ // Expose auth instance and helpers on fastify
127
+ fastify.decorate('auth', auth);
128
+ fastify.decorate('requireAuth', function requireAuth(request: FastifyRequest) {
129
+ if (!request.user) {
130
+ throw new ErrorResponse(401, 'authentication_required');
131
+ }
132
+ });
133
+ fastify.decorate('requireRole', function requireRole(...roles: string[]) {
134
+ const allowedRoles = roles.flat().map((r: string) => r.toLowerCase());
135
+
136
+ return async function (request: FastifyRequest) {
137
+ if (!request.user) {
138
+ throw new ErrorResponse(401, 'authentication_required');
139
+ }
140
+ const userRole = (request.user.role as string)?.toLowerCase();
141
+ if (!userRole || !allowedRoles.includes(userRole)) {
142
+ throw new ErrorResponse(403, 'insufficient_permissions');
143
+ }
144
+ };
145
+ });
146
+ };
147
+
148
+ export default fp(authPlugin, { name: 'rapidd-auth' });
149
+ export { Auth };
150
+
151
+ // Fastify type augmentation for auth decorators
152
+ declare module 'fastify' {
153
+ interface FastifyInstance {
154
+ auth: Auth;
155
+ requireAuth(request: FastifyRequest): void;
156
+ requireRole(...roles: string[]): (request: FastifyRequest) => Promise<void>;
157
+ }
158
+
159
+ interface FastifyContextConfig {
160
+ auth?: RouteAuthConfig;
161
+ }
162
+ }
@@ -0,0 +1,79 @@
1
+ import path from 'path';
2
+ import { FastifyPluginAsync } from 'fastify';
3
+ import fp from 'fastify-plugin';
4
+
5
+ const ALLOWED_LANGUAGES: string[] = (() => {
6
+ try {
7
+ return require(path.join(process.cwd(), 'config', 'app.json')).languages || ['en_US'];
8
+ } catch {
9
+ return ['en_US'];
10
+ }
11
+ })();
12
+
13
+ const SUPPORTED_LANGUAGES: string[] = (() => {
14
+ try {
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+ const stringsPath = process.env.STRINGS_PATH || './locales';
18
+ return fs.readdirSync(stringsPath).map((e: string) => path.parse(e).name);
19
+ } catch {
20
+ return ['en_US'];
21
+ }
22
+ })();
23
+
24
+ /**
25
+ * Parse Accept-Language header and return the best matching language
26
+ */
27
+ function resolveLanguage(headerValue: string): string {
28
+ const defaultLang = ALLOWED_LANGUAGES.find((allowed: string) =>
29
+ SUPPORTED_LANGUAGES.find((avail: string) => avail.toLowerCase() === allowed.toLowerCase())
30
+ ) || 'en_US';
31
+
32
+ if (!headerValue || typeof headerValue !== 'string') return defaultLang;
33
+
34
+ try {
35
+ const languages = headerValue
36
+ .toLowerCase()
37
+ .split(',')
38
+ .map((lang: string) => {
39
+ const parts = lang.trim().split(';');
40
+ const code = parts[0].trim();
41
+ const quality = parts[1] ? parseFloat(parts[1].replace('q=', '')) : 1.0;
42
+ return { code, quality };
43
+ })
44
+ .sort((a, b) => b.quality - a.quality);
45
+
46
+ // Exact match
47
+ for (const lang of languages) {
48
+ const match = ALLOWED_LANGUAGES.find((a: string) => a.toLowerCase() === lang.code);
49
+ if (match) return match;
50
+ }
51
+
52
+ // Language family match (e.g. "en-GB" → "en_US")
53
+ for (const lang of languages) {
54
+ const prefix = lang.code.split('-')[0];
55
+ const match = ALLOWED_LANGUAGES.find((a: string) => a.toLowerCase().startsWith(prefix + '-'));
56
+ if (match) return match;
57
+ }
58
+ } catch {
59
+ /* fall through to default */
60
+ }
61
+
62
+ return defaultLang;
63
+ }
64
+
65
+ /**
66
+ * Language resolution plugin.
67
+ * Sets request.language based on cookie or Accept-Language header.
68
+ */
69
+ const languagePlugin: FastifyPluginAsync = async (fastify) => {
70
+ fastify.decorateRequest('language', 'en_US');
71
+
72
+ fastify.addHook('onRequest', async (request) => {
73
+ const cookieLang = (request as any).cookies?.['lang'];
74
+ request.language = cookieLang || resolveLanguage(request.headers['accept-language'] || '');
75
+ });
76
+ };
77
+
78
+ export default fp(languagePlugin, { name: 'rapidd-language' });
79
+ export { resolveLanguage, ALLOWED_LANGUAGES };
@@ -0,0 +1,210 @@
1
+ import { FastifyPluginAsync, FastifyRequest, FastifyReply } from 'fastify';
2
+ import fp from 'fastify-plugin';
3
+ import Redis from 'ioredis';
4
+ import { ErrorResponse } from '../core/errors';
5
+ import type { RateLimitPathConfig, RateLimitResult } from '../types';
6
+
7
+ const REDIS_MAX_TIMEOUT = 1000 * 10;
8
+ const REDIS_MAX_RETRIES = 60;
9
+
10
+ interface RateLimiterOptions {
11
+ windowMs?: number;
12
+ maxRequests?: number;
13
+ configPath?: string;
14
+ }
15
+
16
+ class RateLimiter {
17
+ private useRedis: boolean;
18
+ private defaultWindowMs: number;
19
+ private defaultMaxRequests: number;
20
+ private rateLimits: Record<string, RateLimitPathConfig>;
21
+ private redis: Redis | null = null;
22
+ private requests: Map<string, { count: number; resetTime: number }> | null = null;
23
+
24
+ constructor() {
25
+ this.useRedis = !!process.env.REDIS_HOST;
26
+ this.defaultWindowMs = Number(process.env.RATE_LIMIT_WINDOW_MS) || 15 * 60 * 1000;
27
+ this.defaultMaxRequests = Number(process.env.RATE_LIMIT_MAX_REQUESTS) || 100;
28
+
29
+ try {
30
+ const path = require('path');
31
+ this.rateLimits = require(path.join(process.cwd(), 'config', 'rate-limit.json'));
32
+ } catch {
33
+ this.rateLimits = {};
34
+ }
35
+
36
+ if (this.useRedis) {
37
+ this.redis = new Redis({
38
+ host: process.env.REDIS_HOST || 'localhost',
39
+ port: Number(process.env.REDIS_PORT || 6379),
40
+ db: Number(process.env.REDIS_DB_RATE_LIMIT || 0),
41
+ lazyConnect: true,
42
+ connectTimeout: 1000,
43
+ retryStrategy: (times: number) => {
44
+ if (times <= REDIS_MAX_RETRIES) {
45
+ return Math.min(times * 1000, REDIS_MAX_TIMEOUT);
46
+ }
47
+ this.redis?.quit();
48
+ this.fallbackToMemory();
49
+ return null;
50
+ },
51
+ maxRetriesPerRequest: 3,
52
+ });
53
+
54
+ this.redis.on('connect', () => {
55
+ this.useRedis = true;
56
+ });
57
+
58
+ this.redis.connect().catch(() => {
59
+ this.fallbackToMemory();
60
+ });
61
+
62
+ this.redis.on('error', () => {
63
+ this.fallbackToMemory();
64
+ });
65
+ } else {
66
+ this.fallbackToMemory();
67
+ }
68
+ }
69
+
70
+ private getPathConfig(reqPath: string): RateLimitPathConfig {
71
+ return this.rateLimits[reqPath] || {
72
+ maxRequests: this.defaultMaxRequests,
73
+ windowMs: this.defaultWindowMs,
74
+ ignoreSuccessfulRequests: false,
75
+ };
76
+ }
77
+
78
+ private fallbackToMemory(): void {
79
+ this.useRedis = false;
80
+ if (!this.requests) this.requests = new Map();
81
+ }
82
+
83
+ private isRedisAvailable(): boolean {
84
+ return this.useRedis && this.redis !== null && this.redis.status === 'ready';
85
+ }
86
+
87
+ private setRateLimitHeaders(reply: FastifyReply, max: number, count: number, reset: number): void {
88
+ reply.header('X-RateLimit-Limit', max);
89
+ reply.header('X-RateLimit-Remaining', Math.max(0, max - count));
90
+ reply.header('X-RateLimit-Reset', reset);
91
+ }
92
+
93
+ async checkLimit(request: FastifyRequest, reply: FastifyReply): Promise<void> {
94
+ const reqPath = request.url.split('?')[0];
95
+ const pathConfig = this.getPathConfig(reqPath);
96
+ const key = `${request.ip} - ${reqPath}`;
97
+ const now = Date.now();
98
+
99
+ const windowMs = pathConfig.windowMs || this.defaultWindowMs;
100
+ const maxRequests = pathConfig.maxRequests || this.defaultMaxRequests;
101
+
102
+ let result: RateLimitResult;
103
+
104
+ if (this.isRedisAvailable()) {
105
+ result = await this.checkRateLimitRedis(key, windowMs, maxRequests, now);
106
+ } else {
107
+ result = this.checkRateLimitMemory(key, windowMs, maxRequests, now);
108
+ }
109
+
110
+ this.setRateLimitHeaders(reply, maxRequests, result.count, result.resetTime);
111
+
112
+ if (!result.allowed) {
113
+ throw new ErrorResponse(429, 'rate_limit_exceeded');
114
+ }
115
+ }
116
+
117
+ private async checkRateLimitRedis(
118
+ key: string, windowMs: number, maxRequests: number, now: number
119
+ ): Promise<RateLimitResult> {
120
+ const redisKey = `rate_limit:${key}`;
121
+ const windowStart = now - windowMs;
122
+
123
+ const luaScript = `
124
+ local key = KEYS[1]
125
+ local window_start = tonumber(ARGV[1])
126
+ local max_requests = tonumber(ARGV[2])
127
+ local now = tonumber(ARGV[3])
128
+ local window_ms = tonumber(ARGV[4])
129
+ redis.call('ZREMRANGEBYSCORE', key, 0, window_start)
130
+ local current_count = redis.call('ZCARD', key)
131
+ if current_count >= max_requests then
132
+ local reset_time = redis.call('ZRANGE', key, 0, 0, 'WITHSCORES')
133
+ local oldest_time = tonumber(reset_time[2]) or now
134
+ return {0, current_count, oldest_time + window_ms}
135
+ else
136
+ redis.call('ZADD', key, now, tostring(now) .. ":" .. redis.call('INCR', key .. ":seq"))
137
+ redis.call('EXPIRE', key, math.ceil(window_ms / 1000))
138
+ return {1, current_count + 1, now + window_ms}
139
+ end
140
+ `;
141
+
142
+ const result = await this.redis!.eval(luaScript, 1, redisKey, windowStart, maxRequests, now, windowMs) as number[];
143
+ return { allowed: result[0] === 1, count: result[1], resetTime: result[2] };
144
+ }
145
+
146
+ private checkRateLimitMemory(
147
+ key: string, windowMs: number, maxRequests: number, now: number
148
+ ): RateLimitResult {
149
+ this.cleanup(now);
150
+ const userData = this.requests!.get(key);
151
+
152
+ if (!userData) {
153
+ this.requests!.set(key, { count: 1, resetTime: now + windowMs });
154
+ return { allowed: true, count: 1, resetTime: now + windowMs };
155
+ }
156
+
157
+ if (now > userData.resetTime) {
158
+ userData.count = 1;
159
+ userData.resetTime = now + windowMs;
160
+ return { allowed: true, count: 1, resetTime: userData.resetTime };
161
+ }
162
+
163
+ if (userData.count >= maxRequests) {
164
+ return { allowed: false, count: userData.count, resetTime: userData.resetTime };
165
+ }
166
+
167
+ userData.count++;
168
+ return { allowed: true, count: userData.count, resetTime: userData.resetTime };
169
+ }
170
+
171
+ private cleanup(now: number): void {
172
+ if (!this.requests) return;
173
+ for (const [key, value] of this.requests.entries()) {
174
+ if (now > value.resetTime) {
175
+ this.requests.delete(key);
176
+ }
177
+ }
178
+ }
179
+
180
+ async close(): Promise<void> {
181
+ if (this.redis) {
182
+ await this.redis.quit();
183
+ }
184
+ }
185
+ }
186
+
187
+ let globalRateLimiter: RateLimiter | null = null;
188
+
189
+ function getRateLimiter(): RateLimiter {
190
+ if (!globalRateLimiter) {
191
+ globalRateLimiter = new RateLimiter();
192
+ }
193
+ return globalRateLimiter;
194
+ }
195
+
196
+ const rateLimiterPlugin: FastifyPluginAsync<RateLimiterOptions> = async (fastify) => {
197
+ const limiter = getRateLimiter();
198
+
199
+ fastify.addHook('onRequest', async (request, reply) => {
200
+ if (process.env.NODE_ENV !== 'production') return;
201
+ await limiter.checkLimit(request, reply);
202
+ });
203
+
204
+ fastify.addHook('onClose', async () => {
205
+ await limiter.close();
206
+ });
207
+ };
208
+
209
+ export default fp(rateLimiterPlugin, { name: 'rapidd-rate-limiter' });
210
+ export { RateLimiter, getRateLimiter };
@@ -0,0 +1,80 @@
1
+ import { FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify';
2
+ import fp from 'fastify-plugin';
3
+ import { ErrorResponse, ErrorBasicResponse } from '../core/errors';
4
+ import { LanguageDict } from '../core/i18n';
5
+ import type { ListMeta } from '../types';
6
+
7
+ /**
8
+ * API utilities plugin.
9
+ * Decorates reply with sendList, sendError, sendResponse.
10
+ * Decorates request with getTranslation.
11
+ * Registers a global error handler for ErrorResponse.
12
+ */
13
+ const responsePlugin: FastifyPluginAsync = async (fastify) => {
14
+ // Decorate request
15
+ fastify.decorateRequest('user', null);
16
+ fastify.decorateRequest('remoteAddress', '');
17
+ fastify.decorateRequest('getTranslation', function (this: FastifyRequest, key: string, data?: Record<string, unknown> | null, language?: string) {
18
+ return LanguageDict.get(key, data ?? null, language || this.language || 'en_US');
19
+ });
20
+
21
+ // Decorate reply
22
+ fastify.decorateReply('sendList', function (this: FastifyReply, data: unknown[], meta: ListMeta) {
23
+ const body = {
24
+ data,
25
+ meta: {
26
+ ...(meta.total != null ? { total: meta.total } : {}),
27
+ count: (data as unknown[]).length,
28
+ limit: meta.take,
29
+ offset: meta.skip,
30
+ ...(meta.total != null ? { hasMore: meta.skip + meta.take < meta.total } : {}),
31
+ },
32
+ };
33
+ return this.send(body);
34
+ });
35
+
36
+ fastify.decorateReply('sendError', function (this: FastifyReply, statusCode: number, message: string, data?: unknown) {
37
+ const request = this.request;
38
+ const language = request?.language || 'en_US';
39
+ const error = new ErrorResponse(statusCode, message, data as Record<string, unknown> | null);
40
+ console.error(`Error ${statusCode}: ${message}`);
41
+ return this.code(statusCode).send(error.toJSON(language));
42
+ });
43
+
44
+ fastify.decorateReply('sendResponse', function (this: FastifyReply, statusCode: number, message: string, params?: unknown) {
45
+ const request = this.request;
46
+ const language = request?.language || 'en_US';
47
+ const translatedMessage = LanguageDict.get(message, params as Record<string, unknown> | null, language);
48
+ return this.code(statusCode).send({ status_code: statusCode, message: translatedMessage });
49
+ });
50
+
51
+ // Set remote address (Fastify resolves X-Forwarded-For when trustProxy is enabled)
52
+ fastify.addHook('onRequest', async (request) => {
53
+ request.remoteAddress = request.ip;
54
+ });
55
+
56
+ // Global error handler
57
+ fastify.setErrorHandler((error, request, reply) => {
58
+ const language = request.language || 'en_US';
59
+ const status = (error as any).status_code || (error as any).statusCode || 500;
60
+
61
+ if (error instanceof ErrorResponse) {
62
+ return reply.code(status).send(error.toJSON(language));
63
+ }
64
+
65
+ if (error instanceof ErrorBasicResponse) {
66
+ return reply.code(status).send(error.toJSON());
67
+ }
68
+
69
+ const err = error as Error;
70
+ const message =
71
+ Object.getPrototypeOf(err).constructor === Error && process.env.NODE_ENV === 'production'
72
+ ? 'Something went wrong'
73
+ : err.message || String(error);
74
+
75
+ console.error(error);
76
+ return reply.code(status).send({ status_code: status, message });
77
+ });
78
+ };
79
+
80
+ export default fp(responsePlugin, { name: 'rapidd-response' });
@@ -0,0 +1,51 @@
1
+ import { FastifyPluginAsync } from 'fastify';
2
+ import fp from 'fastify-plugin';
3
+ import { requestContext, rlsEnabled } from '../core/prisma';
4
+ import rlsContextFn from '../config/rls';
5
+ import type { RLSVariables } from '../types';
6
+
7
+ /**
8
+ * RLS (Row-Level Security) context plugin.
9
+ * Calls the user-defined rlsContext() function from src/config/rls.ts
10
+ * to build SQL session variables, then stores them in AsyncLocalStorage
11
+ * so that Prisma's $allOperations extension can inject them per-query.
12
+ *
13
+ * MUST be registered AFTER the auth plugin so that request.user is available.
14
+ * Skips entirely when RLS is disabled (auto: off for MySQL, on for PostgreSQL).
15
+ *
16
+ * Uses callback-style hook to preserve AsyncLocalStorage context
17
+ * across the entire request lifecycle (preHandler → handler → onSend).
18
+ */
19
+ const rlsPlugin: FastifyPluginAsync = async (fastify) => {
20
+ if (!rlsEnabled) return;
21
+
22
+ fastify.addHook('preHandler', (request, _reply, done) => {
23
+ const result = rlsContextFn(request);
24
+
25
+ if (result instanceof Promise) {
26
+ result.then((variables) => {
27
+ runWithContext(variables, done);
28
+ }).catch(done);
29
+ } else {
30
+ runWithContext(result, done);
31
+ }
32
+ });
33
+ };
34
+
35
+ function runWithContext(variables: RLSVariables, done: () => void): void {
36
+ // Filter out null/undefined values
37
+ const filtered: RLSVariables = {};
38
+ for (const [key, value] of Object.entries(variables)) {
39
+ if (value !== null && value !== undefined) {
40
+ filtered[key] = value;
41
+ }
42
+ }
43
+
44
+ if (Object.keys(filtered).length > 0) {
45
+ requestContext.run({ variables: filtered }, () => done());
46
+ } else {
47
+ done();
48
+ }
49
+ }
50
+
51
+ export default fp(rlsPlugin, { name: 'rapidd-rls' });
@@ -0,0 +1,23 @@
1
+ import { FastifyPluginAsync } from 'fastify';
2
+ import fp from 'fastify-plugin';
3
+
4
+ const NODE_ENV = process.env.NODE_ENV;
5
+
6
+ /**
7
+ * Security headers plugin for API-only servers.
8
+ * Sets strict security headers optimized for JSON API responses.
9
+ */
10
+ const securityPlugin: FastifyPluginAsync = async (fastify) => {
11
+ fastify.addHook('onSend', async (_request, reply) => {
12
+ reply.header('X-Content-Type-Options', 'nosniff');
13
+ reply.header('Referrer-Policy', 'strict-origin-when-cross-origin');
14
+ reply.header('Content-Security-Policy', "default-src 'none'; frame-ancestors 'none'");
15
+ reply.header('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
16
+
17
+ if (NODE_ENV === 'production') {
18
+ reply.header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
19
+ }
20
+ });
21
+ };
22
+
23
+ export default fp(securityPlugin, { name: 'rapidd-security' });