@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,299 @@
1
+ import fp from 'fastify-plugin';
2
+ import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
3
+ import { createWriteStream, mkdirSync, existsSync } from 'fs';
4
+ import { pipeline } from 'stream/promises';
5
+ import { Transform } from 'stream';
6
+ import { randomUUID } from 'crypto';
7
+ import path from 'path';
8
+
9
+ // ── Types ────────────────────────────────────────────────────────────────────
10
+
11
+ export interface UploadOptions {
12
+ maxFileSize?: number;
13
+ maxFiles?: number;
14
+ allowedTypes?: 'images' | 'documents' | 'media' | 'all' | AllowedType[];
15
+ tempDir?: string;
16
+ preserveExtension?: boolean;
17
+ }
18
+
19
+ export interface AllowedType {
20
+ mime: string;
21
+ extensions: string[];
22
+ }
23
+
24
+ export interface UploadedFile {
25
+ fieldname: string;
26
+ filename: string;
27
+ originalName: string;
28
+ mimetype: string;
29
+ size: number;
30
+ tempPath: string;
31
+ extension: string;
32
+ saveTo: (destination: string) => Promise<string>;
33
+ }
34
+
35
+ // ── Type Presets ─────────────────────────────────────────────────────────────
36
+
37
+ const TYPE_PRESETS: Record<string, AllowedType[]> = {
38
+ images: [
39
+ { mime: 'image/jpeg', extensions: ['.jpg', '.jpeg'] },
40
+ { mime: 'image/png', extensions: ['.png'] },
41
+ { mime: 'image/gif', extensions: ['.gif'] },
42
+ { mime: 'image/webp', extensions: ['.webp'] },
43
+ { mime: 'image/svg+xml', extensions: ['.svg'] }
44
+ ],
45
+ documents: [
46
+ { mime: 'application/pdf', extensions: ['.pdf'] },
47
+ { mime: 'text/plain', extensions: ['.txt'] },
48
+ { mime: 'application/msword', extensions: ['.doc'] },
49
+ { mime: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', extensions: ['.docx'] },
50
+ { mime: 'application/vnd.ms-excel', extensions: ['.xls'] },
51
+ { mime: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', extensions: ['.xlsx'] },
52
+ { mime: 'text/csv', extensions: ['.csv'] }
53
+ ],
54
+ media: [
55
+ { mime: 'image/jpeg', extensions: ['.jpg', '.jpeg'] },
56
+ { mime: 'image/png', extensions: ['.png'] },
57
+ { mime: 'image/gif', extensions: ['.gif'] },
58
+ { mime: 'image/webp', extensions: ['.webp'] },
59
+ { mime: 'video/mp4', extensions: ['.mp4'] },
60
+ { mime: 'video/webm', extensions: ['.webm'] },
61
+ { mime: 'audio/mpeg', extensions: ['.mp3'] },
62
+ { mime: 'audio/wav', extensions: ['.wav'] }
63
+ ],
64
+ all: []
65
+ };
66
+
67
+ // ── Defaults ─────────────────────────────────────────────────────────────────
68
+
69
+ const DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
70
+ const DEFAULT_MAX_FILES = 10;
71
+ const DEFAULT_TEMP_DIR = path.join(process.cwd(), 'temp', 'uploads');
72
+
73
+ // ── Helpers ──────────────────────────────────────────────────────────────────
74
+
75
+ function getAllowedTypes(option: UploadOptions['allowedTypes']): AllowedType[] {
76
+ if (!option || option === 'all') {
77
+ return [];
78
+ }
79
+ if (Array.isArray(option)) {
80
+ return option;
81
+ }
82
+ return TYPE_PRESETS[option] || [];
83
+ }
84
+
85
+ function validateFile(
86
+ file: { mimetype: string; filename: string },
87
+ allowedTypes: AllowedType[]
88
+ ): { valid: boolean; error?: string } {
89
+ if (file.filename.includes('..') || file.filename.includes('/') || file.filename.includes('\\')) {
90
+ return { valid: false, error: 'Invalid filename' };
91
+ }
92
+
93
+ if (allowedTypes.length === 0) {
94
+ return { valid: true };
95
+ }
96
+
97
+ const ext = path.extname(file.filename).toLowerCase();
98
+ const allowedType = allowedTypes.find(t => t.mime === file.mimetype);
99
+
100
+ if (!allowedType) {
101
+ return { valid: false, error: `File type '${file.mimetype}' not allowed` };
102
+ }
103
+
104
+ if (!allowedType.extensions.includes(ext)) {
105
+ return { valid: false, error: `Extension '${ext}' does not match MIME type '${file.mimetype}'` };
106
+ }
107
+
108
+ return { valid: true };
109
+ }
110
+
111
+ function createSizeTracker(maxSize: number): { tracker: Transform; getSize: () => number } {
112
+ let size = 0;
113
+ const tracker = new Transform({
114
+ transform(chunk, encoding, callback) {
115
+ size += chunk.length;
116
+ if (size > maxSize) {
117
+ callback(new Error(`File size exceeds limit of ${Math.round(maxSize / 1024 / 1024)}MB`));
118
+ return;
119
+ }
120
+ callback(null, chunk);
121
+ }
122
+ });
123
+ return { tracker, getSize: () => size };
124
+ }
125
+
126
+ async function saveToTemp(
127
+ stream: NodeJS.ReadableStream,
128
+ tempDir: string,
129
+ filename: string,
130
+ maxSize: number
131
+ ): Promise<{ tempPath: string; size: number }> {
132
+ if (!existsSync(tempDir)) {
133
+ mkdirSync(tempDir, { recursive: true });
134
+ }
135
+
136
+ const tempPath = path.join(tempDir, `${randomUUID()}-${filename}`);
137
+ const writeStream = createWriteStream(tempPath);
138
+ const { tracker, getSize } = createSizeTracker(maxSize);
139
+
140
+ await pipeline(stream, tracker, writeStream);
141
+
142
+ return { tempPath, size: getSize() };
143
+ }
144
+
145
+ function createSaveToFn(tempPath: string, ext: string, preserveExtension: boolean) {
146
+ return async (destination: string): Promise<string> => {
147
+ const destDir = path.isAbsolute(destination)
148
+ ? destination
149
+ : path.join(process.cwd(), destination);
150
+
151
+ if (!existsSync(destDir)) {
152
+ mkdirSync(destDir, { recursive: true });
153
+ }
154
+
155
+ const finalName = preserveExtension ? `${randomUUID()}${ext}` : randomUUID();
156
+ const finalPath = path.join(destDir, finalName);
157
+
158
+ const fs = await import('fs/promises');
159
+ await fs.rename(tempPath, finalPath);
160
+
161
+ return finalPath;
162
+ };
163
+ }
164
+
165
+ // ── Plugin ───────────────────────────────────────────────────────────────────
166
+
167
+ /**
168
+ * Fastify multipart upload plugin with security validation.
169
+ *
170
+ * @example
171
+ * // Register plugin
172
+ * app.register(uploadPlugin, {
173
+ * maxFileSize: 5 * 1024 * 1024, // 5MB
174
+ * allowedTypes: 'images',
175
+ * tempDir: '/tmp/uploads'
176
+ * });
177
+ *
178
+ * @example
179
+ * // In route - single file
180
+ * fastify.post('/upload', async (request, reply) => {
181
+ * const file = await request.uploadFile();
182
+ * if (!file) {
183
+ * return reply.sendError(400, 'no_file_uploaded');
184
+ * }
185
+ * const savedPath = await file.saveTo('./uploads');
186
+ * return { path: savedPath };
187
+ * });
188
+ *
189
+ * @example
190
+ * // In route - multiple files
191
+ * fastify.post('/upload-many', async (request, reply) => {
192
+ * const files = await request.uploadFiles();
193
+ * const paths = await Promise.all(
194
+ * files.map(f => f.saveTo('./uploads'))
195
+ * );
196
+ * return { paths };
197
+ * });
198
+ */
199
+ async function uploadPluginImpl(
200
+ fastify: FastifyInstance,
201
+ options: UploadOptions
202
+ ): Promise<void> {
203
+ const maxFileSize = options.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
204
+ const maxFiles = options.maxFiles ?? DEFAULT_MAX_FILES;
205
+ const allowedTypes = getAllowedTypes(options.allowedTypes);
206
+ const tempDir = options.tempDir ?? DEFAULT_TEMP_DIR;
207
+ const preserveExtension = options.preserveExtension ?? true;
208
+
209
+ // Register @fastify/multipart
210
+ await fastify.register(import('@fastify/multipart'), {
211
+ limits: {
212
+ fileSize: maxFileSize,
213
+ files: maxFiles
214
+ }
215
+ });
216
+
217
+ // Add upload methods via hook (avoids decorateRequest typing issues)
218
+ fastify.addHook('preHandler', async (request: FastifyRequest, _reply: FastifyReply) => {
219
+ const contentType = request.headers['content-type'] || '';
220
+
221
+ // Always attach the methods, they'll handle non-multipart gracefully
222
+ (request as any).uploadFile = async (): Promise<UploadedFile | null> => {
223
+ if (!contentType.includes('multipart/form-data')) {
224
+ return null;
225
+ }
226
+
227
+ const data = await (request as any).file();
228
+ if (!data) return null;
229
+
230
+ const validation = validateFile(data, allowedTypes);
231
+ if (!validation.valid) {
232
+ throw new Error(validation.error);
233
+ }
234
+
235
+ const { tempPath, size } = await saveToTemp(data.file, tempDir, data.filename, maxFileSize);
236
+ const ext = path.extname(data.filename);
237
+
238
+ return {
239
+ fieldname: data.fieldname,
240
+ filename: path.basename(tempPath),
241
+ originalName: data.filename,
242
+ mimetype: data.mimetype,
243
+ size,
244
+ tempPath,
245
+ extension: ext,
246
+ saveTo: createSaveToFn(tempPath, ext, preserveExtension)
247
+ };
248
+ };
249
+
250
+ (request as any).uploadFiles = async (): Promise<UploadedFile[]> => {
251
+ if (!contentType.includes('multipart/form-data')) {
252
+ return [];
253
+ }
254
+
255
+ const files: UploadedFile[] = [];
256
+ const parts = (request as any).files();
257
+
258
+ for await (const part of parts) {
259
+ if (part.type !== 'file') continue;
260
+
261
+ const validation = validateFile(part, allowedTypes);
262
+ if (!validation.valid) {
263
+ throw new Error(validation.error);
264
+ }
265
+
266
+ const { tempPath, size } = await saveToTemp(part.file, tempDir, part.filename, maxFileSize);
267
+ const ext = path.extname(part.filename);
268
+
269
+ files.push({
270
+ fieldname: part.fieldname,
271
+ filename: path.basename(tempPath),
272
+ originalName: part.filename,
273
+ mimetype: part.mimetype,
274
+ size,
275
+ tempPath,
276
+ extension: ext,
277
+ saveTo: createSaveToFn(tempPath, ext, preserveExtension)
278
+ });
279
+ }
280
+
281
+ return files;
282
+ };
283
+ });
284
+ }
285
+
286
+ // Extend FastifyRequest type
287
+ declare module 'fastify' {
288
+ interface FastifyRequest {
289
+ uploadFile: () => Promise<UploadedFile | null>;
290
+ uploadFiles: () => Promise<UploadedFile[]>;
291
+ }
292
+ }
293
+
294
+ export const uploadPlugin = fp(uploadPluginImpl, {
295
+ name: 'upload',
296
+ fastify: '5.x'
297
+ });
298
+
299
+ export default uploadPlugin;
package/src/types.ts ADDED
@@ -0,0 +1,308 @@
1
+ import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
2
+
3
+ // =====================================================
4
+ // USER & AUTH
5
+ // =====================================================
6
+
7
+ export interface RapiddUser {
8
+ id: string | number;
9
+ role: string;
10
+ [key: string]: unknown;
11
+ }
12
+
13
+ export type AuthStrategy = 'bearer' | 'basic' | 'cookie' | 'header';
14
+
15
+ export interface RouteAuthConfig {
16
+ strategies?: AuthStrategy[];
17
+ cookieName?: string;
18
+ customHeaderName?: string;
19
+ }
20
+
21
+ export interface AuthOptions {
22
+ userModel?: string;
23
+ userSelect?: Record<string, boolean> | null;
24
+ userInclude?: Record<string, boolean | object> | null;
25
+ identifierFields?: string[];
26
+ passwordField?: string;
27
+ session?: { ttl?: number; store?: string };
28
+ jwt?: {
29
+ secret?: string;
30
+ refreshSecret?: string;
31
+ accessExpiry?: string;
32
+ refreshExpiry?: string;
33
+ };
34
+ saltRounds?: number;
35
+ strategies?: AuthStrategy[];
36
+ cookieName?: string;
37
+ customHeaderName?: string;
38
+ }
39
+
40
+ // =====================================================
41
+ // ACL
42
+ // =====================================================
43
+
44
+ export interface ModelAcl {
45
+ canCreate?: (user: RapiddUser, data?: Record<string, unknown>) => boolean;
46
+ getAccessFilter?: (user: RapiddUser) => Record<string, unknown> | boolean;
47
+ getUpdateFilter?: (user: RapiddUser) => Record<string, unknown> | boolean | false;
48
+ getDeleteFilter?: (user: RapiddUser) => Record<string, unknown> | boolean | false;
49
+ getOmitFields?: (user: RapiddUser) => string[];
50
+ }
51
+
52
+ export interface AclConfig {
53
+ model: Record<string, ModelAcl>;
54
+ }
55
+
56
+ // =====================================================
57
+ // MIDDLEWARE
58
+ // =====================================================
59
+
60
+ export type MiddlewareHook = 'before' | 'after';
61
+ export type MiddlewareOperation =
62
+ | 'create' | 'update' | 'upsert' | 'upsertMany'
63
+ | 'delete' | 'get' | 'getMany' | 'count';
64
+
65
+ export interface MiddlewareContext {
66
+ model: { name: string };
67
+ operation: string;
68
+ user: RapiddUser | null;
69
+ timestamp: Date;
70
+ abort: boolean;
71
+ skip: boolean;
72
+ softDelete: boolean;
73
+ data?: Record<string, unknown>;
74
+ id?: string | number;
75
+ result?: unknown;
76
+ query?: string | Record<string, unknown>;
77
+ include?: string | Record<string, unknown>;
78
+ take?: number;
79
+ skip_offset?: number;
80
+ sortBy?: string;
81
+ sortOrder?: string;
82
+ options?: Record<string, unknown>;
83
+ fields?: string | null;
84
+ unique_key?: string | string[];
85
+ prismaOptions?: Record<string, unknown>;
86
+ [key: string]: unknown;
87
+ }
88
+
89
+ export type MiddlewareFn = (context: MiddlewareContext) => Promise<MiddlewareContext | void> | MiddlewareContext | void;
90
+
91
+ // =====================================================
92
+ // API RESPONSES
93
+ // =====================================================
94
+
95
+ export interface ListMeta {
96
+ take: number;
97
+ skip: number;
98
+ total?: number;
99
+ }
100
+
101
+ export interface ListResponseBody<T = unknown> {
102
+ data: T[];
103
+ meta: {
104
+ total?: number;
105
+ count: number;
106
+ limit: number;
107
+ offset: number;
108
+ hasMore?: boolean;
109
+ };
110
+ }
111
+
112
+ export interface ErrorResponseBody {
113
+ status_code: number;
114
+ message: string;
115
+ }
116
+
117
+ // =====================================================
118
+ // DMMF
119
+ // =====================================================
120
+
121
+ export interface DMMFField {
122
+ name: string;
123
+ kind: string;
124
+ type: string;
125
+ isList: boolean;
126
+ isRequired: boolean;
127
+ isId: boolean;
128
+ isUnique: boolean;
129
+ relationFromFields?: string[];
130
+ relationToFields?: string[];
131
+ relationName?: string;
132
+ relationOnDelete?: string;
133
+ [key: string]: unknown;
134
+ }
135
+
136
+ export interface DMMFModel {
137
+ name: string;
138
+ fields: DMMFField[];
139
+ primaryKey?: { fields: string[] } | null;
140
+ [key: string]: unknown;
141
+ }
142
+
143
+ export interface DMMF {
144
+ datamodel: {
145
+ models: DMMFModel[];
146
+ [key: string]: unknown;
147
+ };
148
+ [key: string]: unknown;
149
+ }
150
+
151
+ export interface RelationConfig {
152
+ name: string;
153
+ object: string;
154
+ isList: boolean;
155
+ field?: string;
156
+ foreignKey?: string;
157
+ fields?: string[];
158
+ foreignKeys?: string[];
159
+ relation?: RelationConfig[];
160
+ }
161
+
162
+ // =====================================================
163
+ // PRISMA / RLS
164
+ // =====================================================
165
+
166
+ export type RLSVariables = Record<string, string | number | null>;
167
+
168
+ export type RlsContextFn = (request: any) => RLSVariables | Promise<RLSVariables>;
169
+
170
+ export interface RLSConfig {
171
+ namespace: string;
172
+ }
173
+
174
+ export type DatabaseProvider = 'postgresql' | 'mysql';
175
+
176
+ export interface AdapterResult {
177
+ adapter: unknown;
178
+ pool: unknown | null;
179
+ provider: DatabaseProvider;
180
+ }
181
+
182
+ // =====================================================
183
+ // QUERY BUILDER
184
+ // =====================================================
185
+
186
+ export interface PrismaWhereClause {
187
+ [key: string]: unknown;
188
+ }
189
+
190
+ export interface PrismaIncludeClause {
191
+ [key: string]: boolean | PrismaIncludeContent;
192
+ }
193
+
194
+ export interface PrismaIncludeContent {
195
+ include?: PrismaIncludeClause;
196
+ where?: PrismaWhereClause;
197
+ omit?: Record<string, boolean>;
198
+ }
199
+
200
+ export interface PrismaOrderBy {
201
+ [key: string]: 'asc' | 'desc' | PrismaOrderBy;
202
+ }
203
+
204
+ export interface PrismaErrorInfo {
205
+ status: number;
206
+ message: string | null;
207
+ }
208
+
209
+ export interface QueryErrorResponse {
210
+ status_code: number;
211
+ message: string;
212
+ }
213
+
214
+ // =====================================================
215
+ // RATE LIMITER
216
+ // =====================================================
217
+
218
+ export interface RateLimitPathConfig {
219
+ maxRequests: number;
220
+ windowMs: number;
221
+ ignoreSuccessfulRequests?: boolean;
222
+ }
223
+
224
+ export interface RateLimitResult {
225
+ allowed: boolean;
226
+ count: number;
227
+ resetTime: number;
228
+ }
229
+
230
+ // =====================================================
231
+ // SESSION STORE
232
+ // =====================================================
233
+
234
+ export interface ISessionStore {
235
+ create(sessionId: string, data: Record<string, unknown>): Promise<void>;
236
+ get(sessionId: string): Promise<Record<string, unknown> | null>;
237
+ delete(sessionId: string): Promise<void>;
238
+ refresh(sessionId: string): Promise<void>;
239
+ isHealthy(): Promise<boolean>;
240
+ destroy?(): void | Promise<void>;
241
+ }
242
+
243
+ // =====================================================
244
+ // MODEL
245
+ // =====================================================
246
+
247
+ export interface ModelOptions {
248
+ user?: RapiddUser;
249
+ [key: string]: unknown;
250
+ }
251
+
252
+ export interface GetManyResult<T = Record<string, unknown>> {
253
+ data: T[];
254
+ meta: { take: number; skip: number; total: number };
255
+ }
256
+
257
+ export interface UpsertManyResult {
258
+ created: number;
259
+ updated: number;
260
+ failed: Array<{ record?: unknown; records?: unknown; error: Error }>;
261
+ totalSuccess: number;
262
+ totalFailed: number;
263
+ }
264
+
265
+ export interface UpsertManyOptions {
266
+ validateRelation?: boolean;
267
+ transaction?: boolean;
268
+ timeout?: number;
269
+ }
270
+
271
+ // =====================================================
272
+ // FASTIFY TYPE AUGMENTATION
273
+ // =====================================================
274
+
275
+ declare module 'fastify' {
276
+ interface FastifyRequest {
277
+ user: RapiddUser | null;
278
+ language: string;
279
+ remoteAddress: string;
280
+ getTranslation(key: string, data?: Record<string, unknown> | null, language?: string): string;
281
+ }
282
+
283
+ interface FastifyReply {
284
+ sendList(data: unknown[], meta: ListMeta): FastifyReply;
285
+ sendError(statusCode: number, message: string, data?: unknown): FastifyReply;
286
+ sendResponse(statusCode: number, message: string, params?: unknown): FastifyReply;
287
+ }
288
+ }
289
+
290
+ // =====================================================
291
+ // APP CONFIG
292
+ // =====================================================
293
+
294
+ export interface AppConfig {
295
+ languages: string[];
296
+ database?: Record<string, unknown>;
297
+ services?: Record<string, unknown>;
298
+ emails?: Record<string, unknown>;
299
+ }
300
+
301
+ export interface RapiddOptions {
302
+ routesPath?: string;
303
+ stringsPath?: string;
304
+ publicPath?: string;
305
+ config?: AppConfig;
306
+ cors?: Record<string, unknown>;
307
+ rateLimit?: boolean;
308
+ }