aegisnode 0.0.3 → 0.0.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aegisnode",
3
- "version": "0.0.3",
3
+ "version": "0.0.4",
4
4
  "description": "A view-first Node.js framework for modular web apps and JSON APIs with CLI scaffolding, runtime injection, auth, uploads, i18n, mail, and WebSocket support.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -10,6 +10,7 @@ import { createApp } from '../src/cli/commands/createapp.js';
10
10
  import { generateArtifact } from '../src/cli/commands/generate.js';
11
11
  import { createKernel } from '../src/runtime/kernel.js';
12
12
  import { runServer } from '../src/cli/commands/runserver.js';
13
+ import { runGenerateLoader } from '../src/cli/commands/generateloader.js';
13
14
  import { runProject } from '../src/index.js';
14
15
  import { createAuthManager, normalizeAuthConfig } from '../src/runtime/auth.js';
15
16
  import { loadProjectConfig } from '../src/runtime/config.js';
@@ -137,8 +138,10 @@ async function main() {
137
138
  await startProject({ projectName, cwd: sandboxRoot });
138
139
  const generatedProjectEnv = await fs.readFile(path.join(projectRoot, '.env'), 'utf8');
139
140
  assert.match(generatedProjectEnv, /^APP_SECRET=.{16,}$/m);
141
+ const generatedAppSecret = generatedProjectEnv.match(/^APP_SECRET=(.+)$/m)?.[1]?.trim();
142
+ assert.ok(generatedAppSecret);
140
143
  const generatedSettings = await fs.readFile(path.join(projectRoot, 'settings.js'), 'utf8');
141
- assert.match(generatedSettings, /appSecret:\s*process\.env\.APP_SECRET\s*\|\|\s*''/);
144
+ assert.ok(generatedSettings.includes(`appSecret: process.env.APP_SECRET || ${JSON.stringify(generatedAppSecret)}`));
142
145
  await assert.rejects(
143
146
  () => runProject({
144
147
  rootDir: projectRoot,
@@ -404,6 +407,74 @@ dkcqnJD4SGWVeG+KhA==
404
407
  await fs.access(filePath);
405
408
  }
406
409
 
410
+ await fs.unlink(path.join(projectRoot, 'app.js'));
411
+ await fs.unlink(path.join(projectRoot, 'loader.cjs'));
412
+ const restoredStartupEntries = await runGenerateLoader({
413
+ projectRoot,
414
+ output: {
415
+ log() {},
416
+ },
417
+ });
418
+ assert.equal(restoredStartupEntries.createdApp, true);
419
+ assert.equal(restoredStartupEntries.createdLoader, true);
420
+ await fs.access(path.join(projectRoot, 'app.js'));
421
+ await fs.access(path.join(projectRoot, 'loader.cjs'));
422
+
423
+ await fs.writeFile(
424
+ path.join(envProjectRoot, 'settings.js'),
425
+ `export default {
426
+ appName: 'envdemo',
427
+ env: 'production',
428
+ logging: {
429
+ level: 'info',
430
+ },
431
+ security: {
432
+ appSecret: process.env.APP_SECRET || '',
433
+ ddos: {
434
+ maxRequests: 120,
435
+ },
436
+ },
437
+ environments: {
438
+ default: {
439
+ security: {
440
+ ddos: {
441
+ windowMs: 45000,
442
+ },
443
+ },
444
+ },
445
+ production: {
446
+ logging: { level: 'warn' },
447
+ security: { ddos: { maxRequests: 80 } },
448
+ },
449
+ },
450
+ };
451
+ `,
452
+ 'utf8',
453
+ );
454
+ await fs.unlink(path.join(envProjectRoot, 'loader.cjs'));
455
+ const missingLoaderDoctorReport = await runDoctor({
456
+ projectRoot: envProjectRoot,
457
+ failOnError: false,
458
+ output: {
459
+ log() {},
460
+ },
461
+ });
462
+ assert.equal(missingLoaderDoctorReport.summary.errors, 1);
463
+ assert.ok(
464
+ missingLoaderDoctorReport.entries.some((entry) => (
465
+ entry.level === 'ERROR'
466
+ && /loader\.cjs is missing for production startup/.test(entry.message)
467
+ )),
468
+ );
469
+ const restoredProductionLoader = await runGenerateLoader({
470
+ projectRoot: envProjectRoot,
471
+ output: {
472
+ log() {},
473
+ },
474
+ });
475
+ assert.equal(restoredProductionLoader.createdLoader, true);
476
+ await fs.access(path.join(envProjectRoot, 'loader.cjs'));
477
+
407
478
  const registryPackages = new Map([
408
479
  ['alpha', '2.0.0'],
409
480
  ['@scope/bravo', '3.4.0'],
@@ -1288,7 +1359,7 @@ export default {
1288
1359
  );
1289
1360
  await fs.writeFile(
1290
1361
  path.join(projectRoot, 'routes.js'),
1291
- `export default {\n register(route) {\n route.get('/csrf-token', (req, res) => {\n res.json({ token: req.csrfToken() });\n });\n\n route.get('/csrf-form', (req, res) => {\n return res.render('csrf-form', { layout: false });\n });\n\n route.post('/submit', (req, res) => {\n res.json({ ok: true, body: req.body || {} });\n });\n },\n};\n`,
1362
+ `export default {\n register(route) {\n route.get('/csrf-token', (req, res) => {\n res.json({ token: req.csrfToken() });\n });\n\n route.get('/csrf-form', (req, res) => {\n return res.render('csrf-form', { layout: false });\n });\n\n route.post('/submit', (req, res) => {\n res.json({ ok: true, body: req.body || {} });\n });\n\n route.post('/submit-upload', route.upload.none(), (req, res) => {\n res.json({ ok: true, body: req.body || {} });\n });\n },\n};\n`,
1292
1363
  'utf8',
1293
1364
  );
1294
1365
 
@@ -1361,6 +1432,32 @@ export default {
1361
1432
  assert.equal(validJsonTokenJson.ok, true);
1362
1433
  assert.equal(validJsonTokenJson.body.name, 'json-with-token');
1363
1434
 
1435
+ const missingMultipartTokenBody = new FormData();
1436
+ missingMultipartTokenBody.set('name', 'multipart-without-token');
1437
+ const missingMultipartTokenResponse = await fetch(`http://127.0.0.1:${csrfPort}/submit-upload`, {
1438
+ method: 'POST',
1439
+ headers: {
1440
+ cookie: csrfCookie,
1441
+ },
1442
+ body: missingMultipartTokenBody,
1443
+ });
1444
+ assert.equal(missingMultipartTokenResponse.status, 403);
1445
+
1446
+ const validMultipartTokenBody = new FormData();
1447
+ validMultipartTokenBody.set('_csrf', csrfTokenJson.token);
1448
+ validMultipartTokenBody.set('name', 'multipart-with-token');
1449
+ const validMultipartTokenResponse = await fetch(`http://127.0.0.1:${csrfPort}/submit-upload`, {
1450
+ method: 'POST',
1451
+ headers: {
1452
+ cookie: csrfCookie,
1453
+ },
1454
+ body: validMultipartTokenBody,
1455
+ });
1456
+ assert.equal(validMultipartTokenResponse.status, 200);
1457
+ const validMultipartTokenJson = await validMultipartTokenResponse.json();
1458
+ assert.equal(validMultipartTokenJson.ok, true);
1459
+ assert.equal(validMultipartTokenJson.body.name, 'multipart-with-token');
1460
+
1364
1461
  const csrfFormResponse = await fetch(`http://127.0.0.1:${csrfPort}/csrf-form`, {
1365
1462
  headers: {
1366
1463
  cookie: csrfCookie,
@@ -70,6 +70,30 @@ async function runAppChecks(rootDir, config, collector) {
70
70
  }
71
71
  }
72
72
 
73
+ async function runStartupEntryChecks(rootDir, config, collector) {
74
+ const env = String(config.env || process.env.NODE_ENV || 'development').trim().toLowerCase();
75
+ const appEntryPath = path.join(rootDir, 'app.js');
76
+ const loaderEntryPath = path.join(rootDir, 'loader.cjs');
77
+ const appEntryExists = await fileExists(appEntryPath);
78
+ const loaderEntryExists = await fileExists(loaderEntryPath);
79
+
80
+ if (appEntryExists) {
81
+ collector.ok('app.js exists.');
82
+ } else if (env === 'production') {
83
+ collector.error('app.js is missing for production startup. Run "aegisnode generateloader" to restore startup entry files.');
84
+ } else {
85
+ collector.warn('app.js is missing. Run "aegisnode generateloader" to restore startup entry files.');
86
+ }
87
+
88
+ if (loaderEntryExists) {
89
+ collector.ok('loader.cjs exists.');
90
+ } else if (env === 'production') {
91
+ collector.error('loader.cjs is missing for production startup. Run "aegisnode generateloader" to restore it.');
92
+ } else {
93
+ collector.warn('loader.cjs is missing. Generate it with "aegisnode generateloader" before non-development startup.');
94
+ }
95
+ }
96
+
73
97
  function runSecurityChecks(config, collector) {
74
98
  const env = String(config.env || process.env.NODE_ENV || 'development');
75
99
  const security = config.security || {};
@@ -179,6 +203,7 @@ export async function runDoctor({
179
203
  collector.ok(`Environment: ${config.env || 'development'}`);
180
204
 
181
205
  await runAppChecks(resolvedRoot, config, collector);
206
+ await runStartupEntryChecks(resolvedRoot, config, collector);
182
207
  runSecurityChecks(config, collector);
183
208
  runAuthChecks(config, collector);
184
209
  runApiChecks(config, collector);
@@ -0,0 +1,37 @@
1
+ import path from 'path';
2
+ import { exists, writeFile } from '../utils/fs.js';
3
+ import { resolveProjectRoot } from '../utils/project.js';
4
+ import { renderProjectAppJs, renderProjectLoaderCjs } from '../utils/scaffolds.js';
5
+
6
+ async function ensureStartupFile(rootDir, fileName, content, output) {
7
+ const target = path.join(rootDir, fileName);
8
+ if (await exists(target)) {
9
+ output.log(`${fileName} already exists.`);
10
+ return false;
11
+ }
12
+
13
+ await writeFile(target, content);
14
+ output.log(`Generated ${fileName}.`);
15
+ return true;
16
+ }
17
+
18
+ export async function runGenerateLoader({
19
+ projectRoot,
20
+ output = console,
21
+ } = {}) {
22
+ const resolvedRoot = await resolveProjectRoot(projectRoot || process.cwd());
23
+ const createdApp = await ensureStartupFile(resolvedRoot, 'app.js', renderProjectAppJs(), output);
24
+ const createdLoader = await ensureStartupFile(resolvedRoot, 'loader.cjs', renderProjectLoaderCjs(), output);
25
+
26
+ if (!createdApp && !createdLoader) {
27
+ output.log(`Startup entry files already exist in ${resolvedRoot}`);
28
+ } else {
29
+ output.log(`Startup entry files are ready in ${resolvedRoot}`);
30
+ }
31
+
32
+ return {
33
+ rootDir: resolvedRoot,
34
+ createdApp,
35
+ createdLoader,
36
+ };
37
+ }
@@ -53,7 +53,7 @@ async function createBaseProjectFiles(projectRoot, projectName) {
53
53
  await writeFile(path.join(projectRoot, '.env'), renderProjectEnv(appSecret));
54
54
  await writeFile(path.join(projectRoot, '.env.example'), renderEnvExample());
55
55
 
56
- await writeFile(path.join(projectRoot, 'settings.js'), renderProjectSettings(projectName, apps));
56
+ await writeFile(path.join(projectRoot, 'settings.js'), renderProjectSettings(projectName, apps, appSecret));
57
57
  await writeFile(path.join(projectRoot, 'routes.js'), renderProjectRoutes());
58
58
  }
59
59
 
package/src/cli/index.js CHANGED
@@ -4,6 +4,7 @@ import { runServer } from './commands/runserver.js';
4
4
  import { generateArtifact } from './commands/generate.js';
5
5
  import { runDoctor } from './commands/doctor.js';
6
6
  import { runUpdateDependencies } from './commands/updatedeps.js';
7
+ import { runGenerateLoader } from './commands/generateloader.js';
7
8
 
8
9
  function printHelp() {
9
10
  console.log(`AegisNode CLI
@@ -13,6 +14,7 @@ Usage:
13
14
  aegisnode createapp <app-name> [--project <path>] [--mount </path>]
14
15
  aegisnode generate <type> <name> --app <app-name> [--project <path>]
15
16
  aegisnode runserver [--project <path>] [--port <number>]
17
+ aegisnode generateloader [--project <path>]
16
18
  aegisnode doctor [--project <path>]
17
19
  aegisnode updatedeps [--project <path>]
18
20
 
@@ -24,6 +26,7 @@ Examples:
24
26
  aegisnode createapp users
25
27
  aegisnode generate view user --app users
26
28
  aegisnode generate validator user --app users
29
+ aegisnode generateloader --project blog
27
30
  aegisnode updatedeps --project blog
28
31
  `);
29
32
  }
@@ -126,6 +129,14 @@ export async function runCli(argv) {
126
129
  return;
127
130
  }
128
131
 
132
+ case 'generateloader':
133
+ case 'loader': {
134
+ await runGenerateLoader({
135
+ projectRoot: flags.project ? String(flags.project) : process.cwd(),
136
+ });
137
+ return;
138
+ }
139
+
129
140
  case 'updatedeps': {
130
141
  await runUpdateDependencies({
131
142
  projectRoot: flags.project ? String(flags.project) : process.cwd(),
@@ -53,7 +53,7 @@ export function renderProjectLoaderCjs() {
53
53
  `;
54
54
  }
55
55
 
56
- export function renderProjectSettings(projectName, apps) {
56
+ export function renderProjectSettings(projectName, apps, appSecret = '') {
57
57
  return `export default {
58
58
  appName: '${projectName}',
59
59
  env: process.env.NODE_ENV || 'development',
@@ -61,8 +61,9 @@ export function renderProjectSettings(projectName, apps) {
61
61
  port: process.env.PORT ? Number(process.env.PORT) : 3000,
62
62
  trustProxy: false,
63
63
  security: {
64
- // Loaded from .env by default. Replace or rotate APP_SECRET in production.
65
- appSecret: process.env.APP_SECRET || '',
64
+ // Loaded from .env by default. Scaffold also embeds the generated secret as a fallback.
65
+ // Replace or rotate APP_SECRET in production.
66
+ appSecret: process.env.APP_SECRET || ${JSON.stringify(appSecret)},
66
67
  },
67
68
  logging: {
68
69
  level: process.env.LOG_LEVEL || 'info',
@@ -3148,6 +3148,16 @@ function attachCsrfProtection(expressApp, config, logger, auth = null) {
3148
3148
  }
3149
3149
 
3150
3150
  const provided = extractCsrfToken(req, csrfConfig);
3151
+ if (!provided && isMultipartRequestContentType(req.headers?.['content-type'])) {
3152
+ req.aegis = req.aegis || {};
3153
+ req.aegis.csrf = {
3154
+ deferredMultipart: true,
3155
+ fieldName: csrfConfig.fieldName,
3156
+ token,
3157
+ };
3158
+ return next();
3159
+ }
3160
+
3151
3161
  if (!provided || !constantTimeEqual(provided, token)) {
3152
3162
  return res.status(403).json({ error: 'CSRF token missing or invalid' });
3153
3163
  }
@@ -120,6 +120,47 @@ function createUploadError(code, message, statusCode) {
120
120
  return error;
121
121
  }
122
122
 
123
+ function constantTimeEqual(left, right) {
124
+ if (typeof left !== 'string' || typeof right !== 'string') {
125
+ return false;
126
+ }
127
+
128
+ const a = Buffer.from(left);
129
+ const b = Buffer.from(right);
130
+ if (a.length !== b.length) {
131
+ return false;
132
+ }
133
+
134
+ try {
135
+ return crypto.timingSafeEqual(a, b);
136
+ } catch {
137
+ return false;
138
+ }
139
+ }
140
+
141
+ function validateDeferredMultipartCsrf(req) {
142
+ const csrfState = req?.aegis?.csrf;
143
+ if (!csrfState || csrfState.deferredMultipart !== true) {
144
+ return null;
145
+ }
146
+
147
+ const expected = typeof csrfState.token === 'string' ? csrfState.token : '';
148
+ const fieldName = typeof csrfState.fieldName === 'string' && csrfState.fieldName.length > 0
149
+ ? csrfState.fieldName
150
+ : '_csrf';
151
+ const provided = req?.body && typeof req.body === 'object'
152
+ ? req.body[fieldName]
153
+ : '';
154
+
155
+ delete req.aegis.csrf;
156
+
157
+ if (!expected || typeof provided !== 'string' || !constantTimeEqual(provided, expected)) {
158
+ return createUploadError('AEGIS_CSRF_INVALID', 'CSRF token missing or invalid', 403);
159
+ }
160
+
161
+ return null;
162
+ }
163
+
123
164
  function resolveUploadError(error) {
124
165
  if (!error) {
125
166
  return null;
@@ -207,6 +248,13 @@ function wrapUploadMiddleware(middleware) {
207
248
  return (req, res, next) => {
208
249
  middleware(req, res, (error) => {
209
250
  if (!error) {
251
+ const csrfError = validateDeferredMultipartCsrf(req);
252
+ if (csrfError) {
253
+ res.status(csrfError.statusCode || 403).json({
254
+ error: csrfError.message || 'CSRF token missing or invalid',
255
+ });
256
+ return;
257
+ }
210
258
  next();
211
259
  return;
212
260
  }