aegisnode 0.0.1

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.
@@ -0,0 +1,46 @@
1
+ import path from 'path';
2
+ import { pathToFileURL } from 'url';
3
+
4
+ async function resolveLoader(loaderEntry, rootDir) {
5
+ if (typeof loaderEntry === 'function') {
6
+ return { loader: loaderEntry, options: {} };
7
+ }
8
+
9
+ if (typeof loaderEntry === 'string') {
10
+ const filePath = path.isAbsolute(loaderEntry)
11
+ ? loaderEntry
12
+ : path.join(rootDir, loaderEntry);
13
+ const loaded = await import(pathToFileURL(filePath).href);
14
+ return { loader: loaded.default ?? loaded, options: {} };
15
+ }
16
+
17
+ if (loaderEntry && typeof loaderEntry === 'object' && typeof loaderEntry.path === 'string') {
18
+ const filePath = path.isAbsolute(loaderEntry.path)
19
+ ? loaderEntry.path
20
+ : path.join(rootDir, loaderEntry.path);
21
+ const loaded = await import(pathToFileURL(filePath).href);
22
+ return {
23
+ loader: loaded.default ?? loaded,
24
+ options: loaderEntry.options || {},
25
+ };
26
+ }
27
+
28
+ throw new Error(`Invalid loader entry: ${JSON.stringify(loaderEntry)}`);
29
+ }
30
+
31
+ export async function runLoaders(loaderEntries, context, rootDir, logger) {
32
+ if (!Array.isArray(loaderEntries) || loaderEntries.length === 0) {
33
+ return;
34
+ }
35
+
36
+ for (const entry of loaderEntries) {
37
+ const { loader, options } = await resolveLoader(entry, rootDir);
38
+
39
+ if (typeof loader !== 'function') {
40
+ throw new Error('Loader module must export a function.');
41
+ }
42
+
43
+ logger.info('Running loader: %s', typeof entry === 'string' ? entry : 'inline-loader');
44
+ await loader({ ...context, options });
45
+ }
46
+ }
@@ -0,0 +1,56 @@
1
+ import util from 'util';
2
+
3
+ const LEVEL_ORDER = {
4
+ error: 0,
5
+ warn: 1,
6
+ info: 2,
7
+ debug: 3,
8
+ trace: 4,
9
+ };
10
+
11
+ function toLevel(level) {
12
+ if (!level) {
13
+ return 'info';
14
+ }
15
+
16
+ const normalized = String(level).toLowerCase();
17
+ return Object.prototype.hasOwnProperty.call(LEVEL_ORDER, normalized) ? normalized : 'info';
18
+ }
19
+
20
+ export function createLogger({ level = 'info', name = 'aegisnode' } = {}) {
21
+ const currentLevel = toLevel(level);
22
+
23
+ function shouldLog(candidateLevel) {
24
+ return LEVEL_ORDER[candidateLevel] <= LEVEL_ORDER[currentLevel];
25
+ }
26
+
27
+ function formatLine(candidateLevel, message, args) {
28
+ const timestamp = new Date().toISOString();
29
+ const rendered = util.format(message, ...args);
30
+ return [`[${timestamp}]`, `[${name}]`, `[${candidateLevel.toUpperCase()}]`, rendered];
31
+ }
32
+
33
+ return {
34
+ level: currentLevel,
35
+ error(message, ...args) {
36
+ if (!shouldLog('error')) return;
37
+ console.error(...formatLine('error', message, args));
38
+ },
39
+ warn(message, ...args) {
40
+ if (!shouldLog('warn')) return;
41
+ console.warn(...formatLine('warn', message, args));
42
+ },
43
+ info(message, ...args) {
44
+ if (!shouldLog('info')) return;
45
+ console.info(...formatLine('info', message, args));
46
+ },
47
+ debug(message, ...args) {
48
+ if (!shouldLog('debug')) return;
49
+ console.debug(...formatLine('debug', message, args));
50
+ },
51
+ trace(message, ...args) {
52
+ if (!shouldLog('trace')) return;
53
+ console.debug(...formatLine('trace', message, args));
54
+ },
55
+ };
56
+ }
@@ -0,0 +1,225 @@
1
+ import nodemailer from 'nodemailer';
2
+
3
+ function isPlainObject(value) {
4
+ return Boolean(value) && Object.prototype.toString.call(value) === '[object Object]';
5
+ }
6
+
7
+ function asNonEmptyString(value, fallback = '') {
8
+ if (typeof value !== 'string') {
9
+ return fallback;
10
+ }
11
+
12
+ const trimmed = value.trim();
13
+ return trimmed.length > 0 ? trimmed : fallback;
14
+ }
15
+
16
+ function asPositiveInteger(value, fallback) {
17
+ const parsed = Number(value);
18
+ if (Number.isFinite(parsed) && parsed > 0) {
19
+ return Math.floor(parsed);
20
+ }
21
+ return fallback;
22
+ }
23
+
24
+ function isConfiguredTransport(value) {
25
+ if (typeof value === 'string') {
26
+ return value.trim().length > 0;
27
+ }
28
+
29
+ return isPlainObject(value) && Object.keys(value).length > 0;
30
+ }
31
+
32
+ function normalizeMailDefaults(rawDefaults, rawMail) {
33
+ const defaults = isPlainObject(rawDefaults) ? rawDefaults : {};
34
+
35
+ return {
36
+ ...defaults,
37
+ from: asNonEmptyString(defaults.from, asNonEmptyString(rawMail?.from)),
38
+ replyTo: asNonEmptyString(defaults.replyTo, asNonEmptyString(rawMail?.replyTo)),
39
+ };
40
+ }
41
+
42
+ export function normalizeMailConfig(rawMail) {
43
+ if (rawMail === false || rawMail === null || rawMail === undefined) {
44
+ return {
45
+ enabled: false,
46
+ defaults: {},
47
+ transport: {},
48
+ transportFactory: null,
49
+ transporter: null,
50
+ verifyOnStartup: false,
51
+ };
52
+ }
53
+
54
+ const mail = isPlainObject(rawMail) ? rawMail : {};
55
+ const transport = typeof mail.transport === 'string' && mail.transport.trim().length > 0
56
+ ? mail.transport.trim()
57
+ : isPlainObject(mail.transport)
58
+ ? { ...mail.transport }
59
+ : {};
60
+ const transporter = mail.transporter && typeof mail.transporter.sendMail === 'function'
61
+ ? mail.transporter
62
+ : null;
63
+ const transportFactory = typeof mail.transportFactory === 'function'
64
+ ? mail.transportFactory
65
+ : null;
66
+
67
+ const enabled = mail.enabled === true
68
+ || transporter !== null
69
+ || transportFactory !== null
70
+ || isConfiguredTransport(transport);
71
+
72
+ return {
73
+ enabled,
74
+ defaults: normalizeMailDefaults(mail.defaults, mail),
75
+ transport,
76
+ transportFactory,
77
+ transporter,
78
+ verifyOnStartup: mail.verifyOnStartup === true,
79
+ };
80
+ }
81
+
82
+ function createMailDisabledError() {
83
+ const error = new Error('Mail is disabled. Enable settings.mail and configure a transport to send email.');
84
+ error.code = 'AEGIS_MAIL_DISABLED';
85
+ error.statusCode = 503;
86
+ return error;
87
+ }
88
+
89
+ function normalizeEnvelopeField(value) {
90
+ if (Array.isArray(value)) {
91
+ const normalized = value
92
+ .map((entry) => (typeof entry === 'string' ? entry.trim() : entry))
93
+ .filter(Boolean);
94
+ return normalized.length > 0 ? normalized : null;
95
+ }
96
+
97
+ if (typeof value === 'string' && value.trim().length > 0) {
98
+ return value.trim();
99
+ }
100
+
101
+ return null;
102
+ }
103
+
104
+ function listRecipients(payload) {
105
+ return ['to', 'cc', 'bcc']
106
+ .map((field) => normalizeEnvelopeField(payload[field]))
107
+ .flatMap((value) => (Array.isArray(value) ? value : value ? [value] : []));
108
+ }
109
+
110
+ function buildMailPayload(message, mailConfig) {
111
+ if (!isPlainObject(message)) {
112
+ throw new Error('Mail payload must be an object.');
113
+ }
114
+
115
+ const payload = {
116
+ ...mailConfig.defaults,
117
+ ...message,
118
+ };
119
+
120
+ if (!payload.from && mailConfig.defaults.from) {
121
+ payload.from = mailConfig.defaults.from;
122
+ }
123
+
124
+ if (!payload.replyTo && mailConfig.defaults.replyTo) {
125
+ payload.replyTo = mailConfig.defaults.replyTo;
126
+ }
127
+
128
+ const hasRecipient = Boolean(
129
+ normalizeEnvelopeField(payload.to)
130
+ || normalizeEnvelopeField(payload.cc)
131
+ || normalizeEnvelopeField(payload.bcc),
132
+ );
133
+
134
+ if (!hasRecipient) {
135
+ throw new Error('Mail payload must include at least one recipient in "to", "cc", or "bcc".');
136
+ }
137
+
138
+ if (!payload.from) {
139
+ throw new Error('Mail payload must include "from" or configure settings.mail.defaults.from.');
140
+ }
141
+
142
+ return payload;
143
+ }
144
+
145
+ async function resolveTransporter(mailConfig) {
146
+ if (mailConfig.transporter) {
147
+ return mailConfig.transporter;
148
+ }
149
+
150
+ if (mailConfig.transportFactory) {
151
+ const created = await mailConfig.transportFactory(mailConfig);
152
+ if (!created || typeof created.sendMail !== 'function') {
153
+ throw new Error('settings.mail.transportFactory must return a transporter with sendMail().');
154
+ }
155
+ return created;
156
+ }
157
+
158
+ return nodemailer.createTransport(mailConfig.transport);
159
+ }
160
+
161
+ export async function createMailManager(rawMailConfig, logger) {
162
+ const mailConfig = normalizeMailConfig(rawMailConfig);
163
+ const runtimeLogger = logger && typeof logger.info === 'function'
164
+ ? logger
165
+ : { info() {} };
166
+
167
+ if (!mailConfig.enabled) {
168
+ const disabled = async () => {
169
+ throw createMailDisabledError();
170
+ };
171
+
172
+ return {
173
+ enabled: false,
174
+ config: mailConfig,
175
+ transporter: null,
176
+ send: disabled,
177
+ sendMail: disabled,
178
+ verify: disabled,
179
+ close: async () => {},
180
+ };
181
+ }
182
+
183
+ const transporter = await resolveTransporter(mailConfig);
184
+
185
+ if (mailConfig.verifyOnStartup && typeof transporter.verify === 'function') {
186
+ await transporter.verify();
187
+ runtimeLogger.info('Mail transport verified successfully.');
188
+ }
189
+
190
+ const send = async (message) => {
191
+ const payload = buildMailPayload(message, mailConfig);
192
+ const info = await transporter.sendMail(payload);
193
+ const recipients = listRecipients(payload).join(', ') || '(none)';
194
+ runtimeLogger.info(
195
+ 'Mail sent: messageId=%s to=%s',
196
+ info?.messageId || '(none)',
197
+ recipients,
198
+ );
199
+ return info;
200
+ };
201
+
202
+ const verify = async () => {
203
+ if (typeof transporter.verify !== 'function') {
204
+ return true;
205
+ }
206
+
207
+ return transporter.verify();
208
+ };
209
+
210
+ const close = async () => {
211
+ if (typeof transporter.close === 'function') {
212
+ await transporter.close();
213
+ }
214
+ };
215
+
216
+ return {
217
+ enabled: true,
218
+ config: mailConfig,
219
+ transporter,
220
+ send,
221
+ sendMail: send,
222
+ verify,
223
+ close,
224
+ };
225
+ }
@@ -0,0 +1,272 @@
1
+ import crypto from 'crypto';
2
+ import fs from 'fs/promises';
3
+ import path from 'path';
4
+ import multer from 'multer';
5
+
6
+ function asNonEmptyString(value, fallback = '') {
7
+ if (typeof value !== 'string') {
8
+ return fallback;
9
+ }
10
+ const trimmed = value.trim();
11
+ return trimmed.length > 0 ? trimmed : fallback;
12
+ }
13
+
14
+ function asPositiveInteger(value, fallback) {
15
+ const parsed = Number(value);
16
+ if (Number.isFinite(parsed) && parsed > 0) {
17
+ return Math.floor(parsed);
18
+ }
19
+ return fallback;
20
+ }
21
+
22
+ function parseBytes(value, fallback) {
23
+ if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
24
+ return Math.floor(value);
25
+ }
26
+
27
+ if (typeof value !== 'string') {
28
+ return fallback;
29
+ }
30
+
31
+ const normalized = value.trim().toLowerCase();
32
+ if (!normalized) {
33
+ return fallback;
34
+ }
35
+
36
+ const match = normalized.match(/^(\d+(?:\.\d+)?)\s*(b|kb|mb|gb)?$/i);
37
+ if (!match) {
38
+ return fallback;
39
+ }
40
+
41
+ const amount = Number(match[1]);
42
+ if (!Number.isFinite(amount) || amount <= 0) {
43
+ return fallback;
44
+ }
45
+
46
+ const unit = String(match[2] || 'b').toLowerCase();
47
+ const multiplier = unit === 'gb'
48
+ ? 1024 * 1024 * 1024
49
+ : unit === 'mb'
50
+ ? 1024 * 1024
51
+ : unit === 'kb'
52
+ ? 1024
53
+ : 1;
54
+
55
+ return Math.max(1, Math.floor(amount * multiplier));
56
+ }
57
+
58
+ function normalizeStringList(value, { lowerCase = false } = {}) {
59
+ const source = Array.isArray(value)
60
+ ? value
61
+ : (typeof value === 'string' ? value.split(',') : []);
62
+
63
+ const normalized = source
64
+ .map((entry) => asNonEmptyString(entry))
65
+ .filter(Boolean)
66
+ .map((entry) => (lowerCase ? entry.toLowerCase() : entry));
67
+
68
+ return [...new Set(normalized)];
69
+ }
70
+
71
+ function normalizeExtensions(value) {
72
+ return normalizeStringList(value, { lowerCase: true })
73
+ .map((entry) => (entry.startsWith('.') ? entry : `.${entry}`));
74
+ }
75
+
76
+ function normalizeUploadDirectory(rawDir, rootDir) {
77
+ const dir = asNonEmptyString(rawDir, 'uploads');
78
+ return path.isAbsolute(dir) ? dir : path.join(rootDir, dir);
79
+ }
80
+
81
+ export function normalizeUploadsConfig(rawUploads, rootDir) {
82
+ const uploads = rawUploads && typeof rawUploads === 'object' ? rawUploads : {};
83
+ const directory = normalizeUploadDirectory(uploads.dir, rootDir);
84
+
85
+ return {
86
+ enabled: uploads.enabled !== false,
87
+ dir: asNonEmptyString(uploads.dir, 'uploads'),
88
+ directory,
89
+ createDir: uploads.createDir !== false,
90
+ preserveExtension: uploads.preserveExtension !== false,
91
+ maxFileSize: parseBytes(uploads.maxFileSize, 5 * 1024 * 1024),
92
+ maxFiles: asPositiveInteger(uploads.maxFiles, 5),
93
+ maxFields: asPositiveInteger(uploads.maxFields, 50),
94
+ maxFieldSize: parseBytes(uploads.maxFieldSize, 1024 * 1024),
95
+ allowedMimeTypes: normalizeStringList(uploads.allowedMimeTypes, { lowerCase: true }),
96
+ allowedExtensions: normalizeExtensions(uploads.allowedExtensions),
97
+ allowApiMultipart: uploads.allowApiMultipart !== false,
98
+ };
99
+ }
100
+
101
+ function randomName(bytes = 16) {
102
+ if (typeof crypto.randomUUID === 'function') {
103
+ return crypto.randomUUID().replace(/-/g, '');
104
+ }
105
+ return crypto.randomBytes(bytes).toString('hex');
106
+ }
107
+
108
+ function getExtension(fileName, preserveExtension) {
109
+ if (!preserveExtension) {
110
+ return '';
111
+ }
112
+ const ext = path.extname(String(fileName || '')).toLowerCase();
113
+ return ext && ext.length <= 10 ? ext : '';
114
+ }
115
+
116
+ function createUploadError(code, message, statusCode) {
117
+ const error = new Error(message);
118
+ error.code = code;
119
+ error.statusCode = statusCode;
120
+ return error;
121
+ }
122
+
123
+ function resolveUploadError(error) {
124
+ if (!error) {
125
+ return null;
126
+ }
127
+
128
+ if (typeof error.statusCode === 'number' && error.statusCode >= 400) {
129
+ return error;
130
+ }
131
+
132
+ const multerCode = String(error.code || '');
133
+ switch (multerCode) {
134
+ case 'LIMIT_FILE_SIZE':
135
+ return createUploadError(multerCode, 'Uploaded file exceeds the configured size limit.', 413);
136
+ case 'LIMIT_FILE_COUNT':
137
+ return createUploadError(multerCode, 'Too many files uploaded.', 413);
138
+ case 'LIMIT_FIELD_COUNT':
139
+ return createUploadError(multerCode, 'Too many form fields submitted.', 413);
140
+ case 'LIMIT_FIELD_VALUE':
141
+ return createUploadError(multerCode, 'A form field exceeds the configured size limit.', 413);
142
+ case 'LIMIT_UNEXPECTED_FILE':
143
+ return createUploadError(multerCode, 'Unexpected file field.', 400);
144
+ default:
145
+ return createUploadError('UPLOAD_ERROR', error.message || 'Upload failed.', 400);
146
+ }
147
+ }
148
+
149
+ function createDiskStorage(uploadConfig) {
150
+ return multer.diskStorage({
151
+ destination(_req, _file, callback) {
152
+ callback(null, uploadConfig.directory);
153
+ },
154
+ filename(_req, file, callback) {
155
+ callback(null, `${randomName()}${getExtension(file?.originalname, uploadConfig.preserveExtension)}`);
156
+ },
157
+ });
158
+ }
159
+
160
+ function createFileFilter(uploadConfig) {
161
+ return (_req, file, callback) => {
162
+ const mimeType = String(file?.mimetype || '').toLowerCase();
163
+ const extension = path.extname(String(file?.originalname || '')).toLowerCase();
164
+
165
+ if (
166
+ uploadConfig.allowedMimeTypes.length > 0
167
+ && !uploadConfig.allowedMimeTypes.includes(mimeType)
168
+ ) {
169
+ callback(createUploadError(
170
+ 'AEGIS_UPLOAD_MIME_NOT_ALLOWED',
171
+ `File type "${mimeType || 'unknown'}" is not allowed.`,
172
+ 415,
173
+ ));
174
+ return;
175
+ }
176
+
177
+ if (
178
+ uploadConfig.allowedExtensions.length > 0
179
+ && !uploadConfig.allowedExtensions.includes(extension)
180
+ ) {
181
+ callback(createUploadError(
182
+ 'AEGIS_UPLOAD_EXTENSION_NOT_ALLOWED',
183
+ `File extension "${extension || '(none)'}" is not allowed.`,
184
+ 415,
185
+ ));
186
+ return;
187
+ }
188
+
189
+ callback(null, true);
190
+ };
191
+ }
192
+
193
+ function createMulterOptions(uploadConfig) {
194
+ return {
195
+ storage: createDiskStorage(uploadConfig),
196
+ limits: {
197
+ fileSize: uploadConfig.maxFileSize,
198
+ files: uploadConfig.maxFiles,
199
+ fields: uploadConfig.maxFields,
200
+ fieldSize: uploadConfig.maxFieldSize,
201
+ },
202
+ fileFilter: createFileFilter(uploadConfig),
203
+ };
204
+ }
205
+
206
+ function wrapUploadMiddleware(middleware) {
207
+ return (req, res, next) => {
208
+ middleware(req, res, (error) => {
209
+ if (!error) {
210
+ next();
211
+ return;
212
+ }
213
+
214
+ const resolved = resolveUploadError(error);
215
+ if (res.headersSent) {
216
+ next(resolved);
217
+ return;
218
+ }
219
+
220
+ res.status(resolved.statusCode || 400).json({
221
+ error: resolved.message || 'Upload failed.',
222
+ });
223
+ });
224
+ };
225
+ }
226
+
227
+ export async function createUploadManager(uploadConfig, logger) {
228
+ if (!uploadConfig?.enabled) {
229
+ return null;
230
+ }
231
+
232
+ if (uploadConfig.createDir) {
233
+ await fs.mkdir(uploadConfig.directory, { recursive: true });
234
+ }
235
+
236
+ const uploader = multer(createMulterOptions(uploadConfig));
237
+ logger.debug(
238
+ 'Uploads enabled: dir=%s maxFileSize=%s maxFiles=%s',
239
+ uploadConfig.directory,
240
+ uploadConfig.maxFileSize,
241
+ uploadConfig.maxFiles,
242
+ );
243
+
244
+ return {
245
+ config: {
246
+ ...uploadConfig,
247
+ directory: uploadConfig.directory,
248
+ },
249
+ single(fieldName) {
250
+ return wrapUploadMiddleware(uploader.single(String(fieldName || 'file')));
251
+ },
252
+ array(fieldName, maxCount = undefined) {
253
+ const count = typeof maxCount === 'number' && Number.isFinite(maxCount) && maxCount > 0
254
+ ? Math.floor(maxCount)
255
+ : undefined;
256
+ return wrapUploadMiddleware(uploader.array(String(fieldName || 'files'), count));
257
+ },
258
+ fields(definitions) {
259
+ return wrapUploadMiddleware(uploader.fields(Array.isArray(definitions) ? definitions : []));
260
+ },
261
+ any() {
262
+ return wrapUploadMiddleware(uploader.any());
263
+ },
264
+ none() {
265
+ return wrapUploadMiddleware(uploader.none());
266
+ },
267
+ };
268
+ }
269
+
270
+ export function isMultipartRequestContentType(contentType) {
271
+ return String(contentType || '').toLowerCase().includes('multipart/form-data');
272
+ }