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/README.md +1559 -1462
- package/package.json +1 -1
- package/scripts/smoke-test.js +99 -2
- package/src/cli/commands/doctor.js +25 -0
- package/src/cli/commands/generateloader.js +37 -0
- package/src/cli/commands/startproject.js +1 -1
- package/src/cli/index.js +11 -0
- package/src/cli/utils/scaffolds.js +4 -3
- package/src/runtime/kernel.js +10 -0
- package/src/runtime/upload.js +48 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "aegisnode",
|
|
3
|
-
"version": "0.0.
|
|
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",
|
package/scripts/smoke-test.js
CHANGED
|
@@ -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.
|
|
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.
|
|
65
|
-
|
|
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',
|
package/src/runtime/kernel.js
CHANGED
|
@@ -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
|
}
|
package/src/runtime/upload.js
CHANGED
|
@@ -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
|
}
|