@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.
- package/.dockerignore +71 -0
- package/.env.example +70 -0
- package/.gitignore +11 -0
- package/LICENSE +15 -0
- package/README.md +231 -0
- package/bin/cli.js +145 -0
- package/config/app.json +166 -0
- package/config/rate-limit.json +12 -0
- package/dist/main.js +26 -0
- package/dockerfile +57 -0
- package/locales/ar_SA.json +179 -0
- package/locales/de_DE.json +179 -0
- package/locales/en_US.json +180 -0
- package/locales/es_ES.json +179 -0
- package/locales/fr_FR.json +179 -0
- package/locales/it_IT.json +179 -0
- package/locales/ja_JP.json +179 -0
- package/locales/pt_BR.json +179 -0
- package/locales/ru_RU.json +179 -0
- package/locales/tr_TR.json +179 -0
- package/main.ts +25 -0
- package/package.json +126 -0
- package/prisma/schema.prisma +9 -0
- package/prisma.config.ts +12 -0
- package/public/static/favicon.ico +0 -0
- package/public/static/image/logo.png +0 -0
- package/routes/api/v1/index.ts +113 -0
- package/src/app.ts +197 -0
- package/src/auth/Auth.ts +446 -0
- package/src/auth/stores/ISessionStore.ts +19 -0
- package/src/auth/stores/MemoryStore.ts +70 -0
- package/src/auth/stores/RedisStore.ts +92 -0
- package/src/auth/stores/index.ts +149 -0
- package/src/config/acl.ts +9 -0
- package/src/config/rls.ts +38 -0
- package/src/core/dmmf.ts +226 -0
- package/src/core/env.ts +183 -0
- package/src/core/errors.ts +87 -0
- package/src/core/i18n.ts +144 -0
- package/src/core/middleware.ts +123 -0
- package/src/core/prisma.ts +236 -0
- package/src/index.ts +112 -0
- package/src/middleware/model.ts +61 -0
- package/src/orm/Model.ts +881 -0
- package/src/orm/QueryBuilder.ts +2078 -0
- package/src/plugins/auth.ts +162 -0
- package/src/plugins/language.ts +79 -0
- package/src/plugins/rateLimit.ts +210 -0
- package/src/plugins/response.ts +80 -0
- package/src/plugins/rls.ts +51 -0
- package/src/plugins/security.ts +23 -0
- package/src/plugins/upload.ts +299 -0
- package/src/types.ts +308 -0
- package/src/utils/ApiClient.ts +526 -0
- package/src/utils/Mailer.ts +348 -0
- package/src/utils/index.ts +25 -0
- package/templates/email/example.ejs +17 -0
- package/templates/layouts/email.ejs +35 -0
- package/tsconfig.json +33 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { ISessionStore } from './ISessionStore';
|
|
2
|
+
import { MemoryStore } from './MemoryStore';
|
|
3
|
+
import { RedisStore } from './RedisStore';
|
|
4
|
+
|
|
5
|
+
const builtInStores: Record<string, new (options?: any) => ISessionStore> = {
|
|
6
|
+
memory: MemoryStore,
|
|
7
|
+
redis: RedisStore,
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Central session store manager with automatic fallback.
|
|
12
|
+
* Handles store failures transparently and switches to fallback.
|
|
13
|
+
*/
|
|
14
|
+
export class SessionStoreManager extends ISessionStore {
|
|
15
|
+
private ttl: number;
|
|
16
|
+
private storeName: string;
|
|
17
|
+
private healthCheckInterval: number;
|
|
18
|
+
private _primaryStore: ISessionStore | null = null;
|
|
19
|
+
private _fallbackStore: ISessionStore | null = null;
|
|
20
|
+
private _usingFallback = false;
|
|
21
|
+
private _initialized = false;
|
|
22
|
+
private _healthCheckTimer: ReturnType<typeof setInterval> | null = null;
|
|
23
|
+
|
|
24
|
+
constructor(options: { ttl?: number; store?: string; healthCheckInterval?: number } = {}) {
|
|
25
|
+
super();
|
|
26
|
+
this.ttl = options.ttl || parseInt(process.env.AUTH_SESSION_TTL || '86400', 10);
|
|
27
|
+
this.storeName = (options.store || process.env.AUTH_SESSION_STORAGE || 'redis').toLowerCase();
|
|
28
|
+
this.healthCheckInterval = options.healthCheckInterval || 30000;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
private async _ensureInitialized(): Promise<void> {
|
|
32
|
+
if (this._initialized) return;
|
|
33
|
+
|
|
34
|
+
this._fallbackStore = new MemoryStore({ ttl: this.ttl });
|
|
35
|
+
|
|
36
|
+
if (this.storeName === 'memory') {
|
|
37
|
+
this._primaryStore = this._fallbackStore;
|
|
38
|
+
this._initialized = true;
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const StoreClass = builtInStores[this.storeName];
|
|
43
|
+
if (!StoreClass) {
|
|
44
|
+
console.warn(`[SessionStore] Unknown store "${this.storeName}", using memory`);
|
|
45
|
+
this._primaryStore = this._fallbackStore;
|
|
46
|
+
this._initialized = true;
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
this._primaryStore = new StoreClass({ ttl: this.ttl });
|
|
52
|
+
} catch (err) {
|
|
53
|
+
console.warn(`[SessionStore] Failed to create ${this.storeName}: ${(err as Error).message}, using memory`);
|
|
54
|
+
this._primaryStore = this._fallbackStore;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
this._initialized = true;
|
|
58
|
+
|
|
59
|
+
if (this._primaryStore !== this._fallbackStore) {
|
|
60
|
+
this._startHealthCheck();
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
private _startHealthCheck(): void {
|
|
65
|
+
if (this._healthCheckTimer) return;
|
|
66
|
+
this._healthCheckTimer = setInterval(() => this._checkPrimaryHealth(), this.healthCheckInterval);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
private async _checkPrimaryHealth(): Promise<void> {
|
|
70
|
+
try {
|
|
71
|
+
const isHealthy = await this._primaryStore!.isHealthy();
|
|
72
|
+
if (isHealthy && this._usingFallback) {
|
|
73
|
+
console.log(`[SessionStore] ${this.storeName} recovered, switching back from memory`);
|
|
74
|
+
this._usingFallback = false;
|
|
75
|
+
} else if (!isHealthy && !this._usingFallback) {
|
|
76
|
+
console.warn(`[SessionStore] ${this.storeName} unavailable, switching to memory`);
|
|
77
|
+
this._usingFallback = true;
|
|
78
|
+
}
|
|
79
|
+
} catch {
|
|
80
|
+
if (!this._usingFallback) {
|
|
81
|
+
console.warn(`[SessionStore] ${this.storeName} health check failed, switching to memory`);
|
|
82
|
+
this._usingFallback = true;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private async _execute<T>(operation: string, ...args: any[]): Promise<T> {
|
|
88
|
+
await this._ensureInitialized();
|
|
89
|
+
|
|
90
|
+
if (this._primaryStore === this._fallbackStore) {
|
|
91
|
+
return (this._primaryStore as any)[operation](...args);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (this._usingFallback) {
|
|
95
|
+
return (this._fallbackStore as any)[operation](...args);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
return await (this._primaryStore as any)[operation](...args);
|
|
100
|
+
} catch (err) {
|
|
101
|
+
console.warn(`[SessionStore] ${this.storeName}.${operation} failed: ${(err as Error).message}, switching to memory`);
|
|
102
|
+
this._usingFallback = true;
|
|
103
|
+
return (this._fallbackStore as any)[operation](...args);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async create(sessionId: string, data: Record<string, unknown>): Promise<void> {
|
|
108
|
+
return this._execute('create', sessionId, data);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async get(sessionId: string): Promise<Record<string, unknown> | null> {
|
|
112
|
+
return this._execute('get', sessionId);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async delete(sessionId: string): Promise<void> {
|
|
116
|
+
return this._execute('delete', sessionId);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async refresh(sessionId: string): Promise<void> {
|
|
120
|
+
return this._execute('refresh', sessionId);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async isHealthy(): Promise<boolean> {
|
|
124
|
+
await this._ensureInitialized();
|
|
125
|
+
const store = this._usingFallback ? this._fallbackStore! : this._primaryStore!;
|
|
126
|
+
return store.isHealthy();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
destroy(): void {
|
|
130
|
+
if (this._healthCheckTimer) {
|
|
131
|
+
clearInterval(this._healthCheckTimer);
|
|
132
|
+
this._healthCheckTimer = null;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
getStatus(): { configured: string; active: string; usingFallback: boolean } {
|
|
137
|
+
return {
|
|
138
|
+
configured: this.storeName,
|
|
139
|
+
active: this._usingFallback ? 'memory' : this.storeName,
|
|
140
|
+
usingFallback: this._usingFallback,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function createStore(options: { ttl?: number; store?: string } = {}): SessionStoreManager {
|
|
146
|
+
return new SessionStoreManager(options);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export { ISessionStore, MemoryStore, RedisStore };
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { RlsContextFn } from '../types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* RLS (Row-Level Security) variable mapping.
|
|
5
|
+
*
|
|
6
|
+
* Return the SQL session variables to set before each database query.
|
|
7
|
+
* Keys become variable names (e.g. `app.current_user_id`), values are set per-request.
|
|
8
|
+
* Return null for a key to skip it. Return an empty object to disable RLS.
|
|
9
|
+
*
|
|
10
|
+
* The `request` parameter is the full Fastify request object — you can read
|
|
11
|
+
* from request.user, request.headers, request.body, or any custom property.
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* // User-based isolation
|
|
15
|
+
* const rlsContext: RlsContextFn = (request) => ({
|
|
16
|
+
* current_user_id: request.user?.id ?? null,
|
|
17
|
+
* current_user_role: request.user?.role ?? null,
|
|
18
|
+
* });
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* // Multi-tenant from header
|
|
22
|
+
* const rlsContext: RlsContextFn = (request) => ({
|
|
23
|
+
* current_tenant_id: request.headers['x-tenant-id'] ?? null,
|
|
24
|
+
* });
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* // Composite: tenant + user + department
|
|
28
|
+
* const rlsContext: RlsContextFn = (request) => ({
|
|
29
|
+
* current_user_id: request.user?.id ?? null,
|
|
30
|
+
* current_tenant_id: request.user?.tenantId ?? null,
|
|
31
|
+
* current_department_id: request.user?.departmentId ?? null,
|
|
32
|
+
* });
|
|
33
|
+
*/
|
|
34
|
+
const rlsContext: RlsContextFn = (request) => {
|
|
35
|
+
return {};
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export default rlsContext;
|
package/src/core/dmmf.ts
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import type { DMMF, DMMFModel, DMMFField, RelationConfig } from '../types';
|
|
4
|
+
|
|
5
|
+
let _dmmf: DMMF | null = null;
|
|
6
|
+
let _dmmfPromise: Promise<DMMF> | null = null;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Load and cache the full DMMF from the Prisma schema.
|
|
10
|
+
* Uses @prisma/internals for complete DMMF including:
|
|
11
|
+
* isId, isList, isRequired, relationFromFields, relationToFields, primaryKey
|
|
12
|
+
*/
|
|
13
|
+
export async function loadDMMF(): Promise<DMMF> {
|
|
14
|
+
if (_dmmf) return _dmmf;
|
|
15
|
+
if (_dmmfPromise) return _dmmfPromise;
|
|
16
|
+
|
|
17
|
+
_dmmfPromise = (async () => {
|
|
18
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
19
|
+
const { getDMMF } = require('@prisma/internals');
|
|
20
|
+
const schemaPath = path.join(process.cwd(), 'prisma', 'schema.prisma');
|
|
21
|
+
const schema = fs.readFileSync(schemaPath, 'utf8');
|
|
22
|
+
_dmmf = await getDMMF({ datamodel: schema });
|
|
23
|
+
return _dmmf!;
|
|
24
|
+
})();
|
|
25
|
+
|
|
26
|
+
return _dmmfPromise;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Get cached DMMF synchronously (must call loadDMMF first)
|
|
31
|
+
*/
|
|
32
|
+
export function getDMMFSync(): DMMF | null {
|
|
33
|
+
return _dmmf;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Get a model from the DMMF by name
|
|
38
|
+
*/
|
|
39
|
+
export function getModel(modelName: string): DMMFModel | undefined {
|
|
40
|
+
if (!_dmmf) {
|
|
41
|
+
throw new Error('DMMF not loaded. Call loadDMMF() first.');
|
|
42
|
+
}
|
|
43
|
+
return _dmmf.datamodel.models.find((m: DMMFModel) => m.name === modelName);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Get all fields for a model (including relation fields)
|
|
48
|
+
*/
|
|
49
|
+
export function getFields(modelName: string): Record<string, DMMFField> {
|
|
50
|
+
const model = getModel(modelName);
|
|
51
|
+
if (!model) return {};
|
|
52
|
+
return model.fields.reduce((acc: Record<string, DMMFField>, field: DMMFField) => {
|
|
53
|
+
acc[field.name] = field;
|
|
54
|
+
return acc;
|
|
55
|
+
}, {});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Get only scalar fields for a model (excludes relation fields)
|
|
60
|
+
*/
|
|
61
|
+
export function getScalarFields(modelName: string): Record<string, DMMFField> {
|
|
62
|
+
const model = getModel(modelName);
|
|
63
|
+
if (!model) return {};
|
|
64
|
+
return model.fields
|
|
65
|
+
.filter((field: DMMFField) => field.kind !== 'object')
|
|
66
|
+
.reduce((acc: Record<string, DMMFField>, field: DMMFField) => {
|
|
67
|
+
acc[field.name] = field;
|
|
68
|
+
return acc;
|
|
69
|
+
}, {});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Get the primary key field(s) for a model
|
|
74
|
+
*/
|
|
75
|
+
export function getPrimaryKey(modelName: string): string | string[] {
|
|
76
|
+
const model = getModel(modelName);
|
|
77
|
+
if (!model) return 'id';
|
|
78
|
+
|
|
79
|
+
if (model.primaryKey?.fields && model.primaryKey.fields.length > 0) {
|
|
80
|
+
return model.primaryKey.fields.length === 1
|
|
81
|
+
? model.primaryKey.fields[0]
|
|
82
|
+
: model.primaryKey.fields;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const idField = model.fields.find((f: DMMFField) => f.isId);
|
|
86
|
+
return idField ? idField.name : 'id';
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Get all relation fields for a model
|
|
91
|
+
*/
|
|
92
|
+
export function getRelations(modelName: string): DMMFField[] {
|
|
93
|
+
const model = getModel(modelName);
|
|
94
|
+
if (!model) return [];
|
|
95
|
+
return model.fields.filter((f: DMMFField) => f.kind === 'object');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Check if a field is a list relation
|
|
100
|
+
*/
|
|
101
|
+
export function isListRelation(modelName: string, fieldName: string): boolean {
|
|
102
|
+
const model = getModel(modelName);
|
|
103
|
+
if (!model) return false;
|
|
104
|
+
const field = model.fields.find((f: DMMFField) => f.name === fieldName);
|
|
105
|
+
return field?.isList === true;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Get relationship info for a relation field
|
|
110
|
+
*/
|
|
111
|
+
export function getRelationInfo(
|
|
112
|
+
modelName: string,
|
|
113
|
+
relationName: string
|
|
114
|
+
): { name: string; targetModel: string; isList: boolean; fromFields: string[]; toFields: string[]; onDelete?: string } | null {
|
|
115
|
+
const model = getModel(modelName);
|
|
116
|
+
if (!model) return null;
|
|
117
|
+
|
|
118
|
+
const field = model.fields.find((f: DMMFField) => f.name === relationName && f.kind === 'object');
|
|
119
|
+
if (!field) return null;
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
name: field.name,
|
|
123
|
+
targetModel: field.type,
|
|
124
|
+
isList: field.isList,
|
|
125
|
+
fromFields: field.relationFromFields || [],
|
|
126
|
+
toFields: field.relationToFields || [],
|
|
127
|
+
onDelete: field.relationOnDelete as string | undefined,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// =====================================================
|
|
132
|
+
// AUTO-DETECTION HELPERS
|
|
133
|
+
// =====================================================
|
|
134
|
+
|
|
135
|
+
const USER_MODEL_NAMES = ['user', 'users', 'account', 'accounts'];
|
|
136
|
+
const PASSWORD_FIELD_NAMES = ['password', 'hash', 'passwordhash', 'password_hash', 'passwd', 'pwd', 'hashed_password'];
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Find a user-like model by common naming conventions
|
|
140
|
+
*/
|
|
141
|
+
export function findUserModel(): DMMFModel | null {
|
|
142
|
+
if (!_dmmf) return null;
|
|
143
|
+
for (const name of USER_MODEL_NAMES) {
|
|
144
|
+
const model = _dmmf.datamodel.models.find(
|
|
145
|
+
(m: DMMFModel) => m.name.toLowerCase() === name
|
|
146
|
+
);
|
|
147
|
+
if (model) return model;
|
|
148
|
+
}
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Find unique scalar string fields suitable as login identifiers
|
|
154
|
+
*/
|
|
155
|
+
export function findIdentifierFields(modelName: string): string[] {
|
|
156
|
+
const fields = getScalarFields(modelName);
|
|
157
|
+
const result: string[] = [];
|
|
158
|
+
|
|
159
|
+
for (const [name, field] of Object.entries(fields)) {
|
|
160
|
+
if (field.isUnique && field.type === 'String' && !field.isId) {
|
|
161
|
+
const lower = name.toLowerCase();
|
|
162
|
+
if (!PASSWORD_FIELD_NAMES.includes(lower)) {
|
|
163
|
+
result.push(name);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return result.length > 0 ? result : ['email'];
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Find the password field by common naming conventions
|
|
173
|
+
*/
|
|
174
|
+
export function findPasswordField(modelName: string): string | null {
|
|
175
|
+
const fields = getScalarFields(modelName);
|
|
176
|
+
|
|
177
|
+
for (const [name] of Object.entries(fields)) {
|
|
178
|
+
if (PASSWORD_FIELD_NAMES.includes(name.toLowerCase())) {
|
|
179
|
+
return name;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// =====================================================
|
|
187
|
+
// RELATIONSHIP BUILDING
|
|
188
|
+
// =====================================================
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Build relationships configuration for a model (replaces relationships.json)
|
|
192
|
+
*/
|
|
193
|
+
export function buildRelationships(modelName: string): RelationConfig[] {
|
|
194
|
+
const relations = getRelations(modelName);
|
|
195
|
+
|
|
196
|
+
return relations.map((rel: DMMFField) => {
|
|
197
|
+
const config: RelationConfig = {
|
|
198
|
+
name: rel.name,
|
|
199
|
+
object: rel.type,
|
|
200
|
+
isList: rel.isList,
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
if (rel.relationFromFields && rel.relationFromFields.length > 0) {
|
|
204
|
+
config.field = rel.relationFromFields[0];
|
|
205
|
+
config.foreignKey = rel.relationToFields?.[0] || 'id';
|
|
206
|
+
|
|
207
|
+
if (rel.relationFromFields.length > 1) {
|
|
208
|
+
config.fields = rel.relationFromFields;
|
|
209
|
+
config.foreignKeys = rel.relationToFields;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const targetRelations = getRelations(rel.type);
|
|
214
|
+
if (targetRelations.length > 0) {
|
|
215
|
+
config.relation = targetRelations.map((nested: DMMFField) => ({
|
|
216
|
+
name: nested.name,
|
|
217
|
+
object: nested.type,
|
|
218
|
+
isList: nested.isList,
|
|
219
|
+
field: nested.relationFromFields?.[0],
|
|
220
|
+
foreignKey: nested.relationToFields?.[0] || 'id',
|
|
221
|
+
}));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return config;
|
|
225
|
+
});
|
|
226
|
+
}
|
package/src/core/env.ts
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Environment variable validation and access
|
|
3
|
+
* Validates required variables at startup and provides typed access
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export interface EnvConfig {
|
|
7
|
+
// Required
|
|
8
|
+
DATABASE_URL: string;
|
|
9
|
+
|
|
10
|
+
// Optional (auto-generated if auth is enabled)
|
|
11
|
+
JWT_SECRET?: string;
|
|
12
|
+
JWT_REFRESH_SECRET?: string;
|
|
13
|
+
|
|
14
|
+
// Optional with defaults
|
|
15
|
+
NODE_ENV: 'development' | 'production' | 'test';
|
|
16
|
+
PORT: number;
|
|
17
|
+
HOST: string;
|
|
18
|
+
DATABASE_URL_ADMIN?: string;
|
|
19
|
+
DATABASE_PROVIDER?: 'postgresql' | 'mysql';
|
|
20
|
+
COOKIE_SECRET?: string;
|
|
21
|
+
ALLOWED_ORIGINS?: string;
|
|
22
|
+
|
|
23
|
+
// Auth
|
|
24
|
+
AUTH_SESSION_STORAGE: 'redis' | 'memory';
|
|
25
|
+
AUTH_SESSION_TTL: number;
|
|
26
|
+
AUTH_SALT_ROUNDS: number;
|
|
27
|
+
AUTH_ACCESS_TOKEN_EXPIRY: string;
|
|
28
|
+
AUTH_REFRESH_TOKEN_EXPIRY: string;
|
|
29
|
+
DB_USER_TABLE: string;
|
|
30
|
+
|
|
31
|
+
// API
|
|
32
|
+
API_RESULT_LIMIT: number;
|
|
33
|
+
REQUEST_TIMEOUT: number;
|
|
34
|
+
API_MAX_RETRIES: number;
|
|
35
|
+
|
|
36
|
+
// Rate limiting
|
|
37
|
+
RATE_LIMIT_ENABLED: boolean;
|
|
38
|
+
RATE_LIMIT_WINDOW_MS: number;
|
|
39
|
+
RATE_LIMIT_MAX_REQUESTS: number;
|
|
40
|
+
|
|
41
|
+
// Redis
|
|
42
|
+
REDIS_HOST?: string;
|
|
43
|
+
REDIS_PORT: number;
|
|
44
|
+
REDIS_PASSWORD?: string;
|
|
45
|
+
REDIS_DB_RATE_LIMIT: number;
|
|
46
|
+
REDIS_DB_AUTH: number;
|
|
47
|
+
|
|
48
|
+
// RLS
|
|
49
|
+
RLS_ENABLED?: boolean;
|
|
50
|
+
RLS_NAMESPACE: string;
|
|
51
|
+
|
|
52
|
+
// Proxy
|
|
53
|
+
TRUST_PROXY?: boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const REQUIRED_VARS = [
|
|
57
|
+
'DATABASE_URL'
|
|
58
|
+
] as const;
|
|
59
|
+
|
|
60
|
+
const DEFAULTS: Partial<Record<keyof EnvConfig, string | number | boolean>> = {
|
|
61
|
+
NODE_ENV: 'development',
|
|
62
|
+
PORT: 3000,
|
|
63
|
+
HOST: '0.0.0.0',
|
|
64
|
+
AUTH_SESSION_STORAGE: 'redis',
|
|
65
|
+
AUTH_SESSION_TTL: 86400,
|
|
66
|
+
AUTH_SALT_ROUNDS: 10,
|
|
67
|
+
AUTH_ACCESS_TOKEN_EXPIRY: '1d',
|
|
68
|
+
AUTH_REFRESH_TOKEN_EXPIRY: '7d',
|
|
69
|
+
DB_USER_TABLE: 'users',
|
|
70
|
+
API_RESULT_LIMIT: 500,
|
|
71
|
+
REQUEST_TIMEOUT: 10000,
|
|
72
|
+
API_MAX_RETRIES: 2,
|
|
73
|
+
RATE_LIMIT_ENABLED: true,
|
|
74
|
+
RATE_LIMIT_WINDOW_MS: 900000,
|
|
75
|
+
RATE_LIMIT_MAX_REQUESTS: 100,
|
|
76
|
+
REDIS_PORT: 6379,
|
|
77
|
+
REDIS_DB_RATE_LIMIT: 0,
|
|
78
|
+
REDIS_DB_AUTH: 1,
|
|
79
|
+
RLS_NAMESPACE: 'app'
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Validate that all required environment variables are set
|
|
84
|
+
* @throws Error if required variables are missing
|
|
85
|
+
*/
|
|
86
|
+
export function validateEnv(): void {
|
|
87
|
+
const missing: string[] = [];
|
|
88
|
+
|
|
89
|
+
for (const key of REQUIRED_VARS) {
|
|
90
|
+
if (!process.env[key]) {
|
|
91
|
+
missing.push(key);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (missing.length > 0) {
|
|
96
|
+
throw new Error(
|
|
97
|
+
`Missing required environment variables: ${missing.join(', ')}\n` +
|
|
98
|
+
`Please check your .env file or environment configuration.`
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Validate DATABASE_URL format
|
|
103
|
+
const dbUrl = process.env.DATABASE_URL!;
|
|
104
|
+
if (!dbUrl.startsWith('postgresql://') && !dbUrl.startsWith('postgres://') &&
|
|
105
|
+
!dbUrl.startsWith('mysql://') && !dbUrl.startsWith('mariadb://')) {
|
|
106
|
+
throw new Error(
|
|
107
|
+
`Invalid DATABASE_URL format. Must start with postgresql://, postgres://, mysql://, or mariadb://`
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Get an environment variable with type coercion
|
|
114
|
+
*/
|
|
115
|
+
export function getEnv<K extends keyof EnvConfig>(key: K): EnvConfig[K] {
|
|
116
|
+
const value = process.env[key];
|
|
117
|
+
const defaultValue = DEFAULTS[key];
|
|
118
|
+
|
|
119
|
+
if (value === undefined || value === '') {
|
|
120
|
+
if (defaultValue !== undefined) {
|
|
121
|
+
return defaultValue as EnvConfig[K];
|
|
122
|
+
}
|
|
123
|
+
return undefined as EnvConfig[K];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Type coercion based on default value type
|
|
127
|
+
if (typeof defaultValue === 'number') {
|
|
128
|
+
return parseInt(value, 10) as EnvConfig[K];
|
|
129
|
+
}
|
|
130
|
+
if (typeof defaultValue === 'boolean') {
|
|
131
|
+
return (value.toLowerCase() === 'true') as EnvConfig[K];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return value as EnvConfig[K];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Get all environment configuration
|
|
139
|
+
*/
|
|
140
|
+
export function getAllEnv(): Partial<EnvConfig> {
|
|
141
|
+
const config: Partial<EnvConfig> = {};
|
|
142
|
+
|
|
143
|
+
for (const key of Object.keys(DEFAULTS) as (keyof EnvConfig)[]) {
|
|
144
|
+
(config as any)[key] = getEnv(key);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Add required vars
|
|
148
|
+
for (const key of REQUIRED_VARS) {
|
|
149
|
+
(config as any)[key] = process.env[key];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return config;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Check if running in production
|
|
157
|
+
*/
|
|
158
|
+
export function isProduction(): boolean {
|
|
159
|
+
return getEnv('NODE_ENV') === 'production';
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Check if running in development
|
|
164
|
+
*/
|
|
165
|
+
export function isDevelopment(): boolean {
|
|
166
|
+
return getEnv('NODE_ENV') === 'development';
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Check if running in test
|
|
171
|
+
*/
|
|
172
|
+
export function isTest(): boolean {
|
|
173
|
+
return getEnv('NODE_ENV') === 'test';
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export default {
|
|
177
|
+
validate: validateEnv,
|
|
178
|
+
get: getEnv,
|
|
179
|
+
getAll: getAllEnv,
|
|
180
|
+
isProduction,
|
|
181
|
+
isDevelopment,
|
|
182
|
+
isTest
|
|
183
|
+
};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { LanguageDict } from './i18n';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Basic error response with HTTP status code
|
|
5
|
+
*/
|
|
6
|
+
export class ErrorBasicResponse extends Error {
|
|
7
|
+
status_code: number;
|
|
8
|
+
|
|
9
|
+
constructor(message: string, status_code: number = 500) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.status_code = status_code;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
toJSON(): { status_code: number; message: string } {
|
|
15
|
+
return {
|
|
16
|
+
status_code: this.status_code,
|
|
17
|
+
message: this.message,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Localized error response with i18n support via LanguageDict.
|
|
24
|
+
* Constructor takes (status_code, message_key, data?) — NOT the standard Error(message) order.
|
|
25
|
+
*/
|
|
26
|
+
export class ErrorResponse extends ErrorBasicResponse {
|
|
27
|
+
data: Record<string, unknown> | null;
|
|
28
|
+
|
|
29
|
+
constructor(status_code: number, message: string, data: Record<string, unknown> | null = null) {
|
|
30
|
+
super(message, status_code);
|
|
31
|
+
this.data = data;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
toJSON(language: string = 'en_US'): { status_code: number; message: string } {
|
|
35
|
+
return {
|
|
36
|
+
status_code: this.status_code,
|
|
37
|
+
message: LanguageDict.get(this.message, this.data, language),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
static badRequest(key: string, data: Record<string, unknown> | null = null): ErrorResponse {
|
|
42
|
+
return new ErrorResponse(400, key, data);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
static unauthorized(key: string, data: Record<string, unknown> | null = null): ErrorResponse {
|
|
46
|
+
return new ErrorResponse(401, key, data);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
static forbidden(key: string, data: Record<string, unknown> | null = null): ErrorResponse {
|
|
50
|
+
return new ErrorResponse(403, key, data);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
static notFound(key: string, data: Record<string, unknown> | null = null): ErrorResponse {
|
|
54
|
+
return new ErrorResponse(404, key, data);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
static conflict(key: string, data: Record<string, unknown> | null = null): ErrorResponse {
|
|
58
|
+
return new ErrorResponse(409, key, data);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
static tooManyRequests(key: string, data: Record<string, unknown> | null = null): ErrorResponse {
|
|
62
|
+
return new ErrorResponse(429, key, data);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Success response with i18n support
|
|
68
|
+
*/
|
|
69
|
+
export class Response {
|
|
70
|
+
status_code: number;
|
|
71
|
+
message: string;
|
|
72
|
+
data: Record<string, unknown> | null;
|
|
73
|
+
|
|
74
|
+
constructor(status_code: number, message: string, data: Record<string, unknown> | null = null) {
|
|
75
|
+
this.status_code = status_code;
|
|
76
|
+
this.message = message;
|
|
77
|
+
this.data = data;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
toJSON(language: string = 'en_US'): { status_code: number; message: string; data: Record<string, unknown> | null } {
|
|
81
|
+
return {
|
|
82
|
+
status_code: this.status_code,
|
|
83
|
+
message: LanguageDict.get(this.message, this.data, language),
|
|
84
|
+
data: this.data,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
}
|