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,1831 @@
1
+ import assert from 'assert';
2
+ import crypto from 'crypto';
3
+ import http from 'http';
4
+ import https from 'https';
5
+ import os from 'os';
6
+ import path from 'path';
7
+ import fs from 'fs/promises';
8
+ import { startProject } from '../src/cli/commands/startproject.js';
9
+ import { createApp } from '../src/cli/commands/createapp.js';
10
+ import { generateArtifact } from '../src/cli/commands/generate.js';
11
+ import { createKernel } from '../src/runtime/kernel.js';
12
+ import { runServer } from '../src/cli/commands/runserver.js';
13
+ import { runProject } from '../src/index.js';
14
+ import { createAuthManager, normalizeAuthConfig } from '../src/runtime/auth.js';
15
+ import { loadProjectConfig } from '../src/runtime/config.js';
16
+ import { initializeDatabase, closeDatabase } from '../src/runtime/database.js';
17
+ import { runDoctor } from '../src/cli/commands/doctor.js';
18
+ import { runUpdateDependencies } from '../src/cli/commands/updatedeps.js';
19
+ import { createHelpers } from '../src/runtime/helpers.js';
20
+
21
+ function createSilentLogger() {
22
+ return {
23
+ debug() {},
24
+ info() {},
25
+ warn() {},
26
+ error() {},
27
+ };
28
+ }
29
+
30
+ function createFakeQueryMeshSqlClient() {
31
+ const tables = new Map();
32
+ const ensureTable = (name) => {
33
+ if (!tables.has(name)) {
34
+ tables.set(name, []);
35
+ }
36
+ return tables.get(name);
37
+ };
38
+
39
+ return {
40
+ schema() {
41
+ return {
42
+ createTable(name) {
43
+ ensureTable(name);
44
+ return {
45
+ exec: async () => {},
46
+ };
47
+ },
48
+ };
49
+ },
50
+ table(name) {
51
+ return {
52
+ select() {
53
+ return {
54
+ get: async () => [...ensureTable(name)],
55
+ };
56
+ },
57
+ delete() {
58
+ return {
59
+ run: async () => {
60
+ tables.set(name, []);
61
+ return true;
62
+ },
63
+ };
64
+ },
65
+ insert(rows) {
66
+ return {
67
+ run: async () => {
68
+ const payload = Array.isArray(rows)
69
+ ? rows.map((entry) => ({ ...entry }))
70
+ : [];
71
+ tables.set(name, payload);
72
+ return true;
73
+ },
74
+ };
75
+ },
76
+ };
77
+ },
78
+ dump(name) {
79
+ return [...ensureTable(name)];
80
+ },
81
+ };
82
+ }
83
+
84
+ function toBase64Url(buffer) {
85
+ return Buffer.from(buffer)
86
+ .toString('base64')
87
+ .replace(/\+/g, '-')
88
+ .replace(/\//g, '_')
89
+ .replace(/=+$/g, '');
90
+ }
91
+
92
+ function createPkcePair() {
93
+ const verifier = toBase64Url(crypto.randomBytes(48));
94
+ const challenge = toBase64Url(crypto.createHash('sha256').update(verifier).digest());
95
+ return {
96
+ verifier,
97
+ challenge,
98
+ };
99
+ }
100
+
101
+ function requestHttps(url, { method = 'GET', headers = {} } = {}) {
102
+ return new Promise((resolve, reject) => {
103
+ const request = https.request(url, {
104
+ method,
105
+ headers,
106
+ rejectUnauthorized: false,
107
+ }, (response) => {
108
+ let body = '';
109
+ response.setEncoding('utf8');
110
+ response.on('data', (chunk) => {
111
+ body += chunk;
112
+ });
113
+ response.on('end', () => {
114
+ resolve({
115
+ status: response.statusCode || 0,
116
+ headers: response.headers,
117
+ body,
118
+ });
119
+ });
120
+ });
121
+
122
+ request.on('error', reject);
123
+ request.end();
124
+ });
125
+ }
126
+
127
+ async function main() {
128
+ const sandboxRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'aegisnode-'));
129
+ const projectName = 'blog';
130
+ const projectRoot = path.join(sandboxRoot, projectName);
131
+ const frameworkRoot = path.resolve(process.cwd());
132
+ const envSandboxRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'aegisnode-env-'));
133
+ const dotenvSandboxRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'aegisnode-dotenv-'));
134
+ const httpsSandboxRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'aegisnode-https-'));
135
+ const proxySandboxRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'aegisnode-proxy-'));
136
+
137
+ await startProject({ projectName, cwd: sandboxRoot });
138
+ const generatedProjectEnv = await fs.readFile(path.join(projectRoot, '.env'), 'utf8');
139
+ assert.match(generatedProjectEnv, /^APP_SECRET=.{16,}$/m);
140
+ const generatedSettings = await fs.readFile(path.join(projectRoot, 'settings.js'), 'utf8');
141
+ assert.match(generatedSettings, /appSecret:\s*process\.env\.APP_SECRET\s*\|\|\s*''/);
142
+ await assert.rejects(
143
+ () => runProject({
144
+ rootDir: projectRoot,
145
+ overrides: {
146
+ host: '127.0.0.1',
147
+ port: 0,
148
+ },
149
+ }),
150
+ /started with "aegisnode runserver"/,
151
+ );
152
+
153
+ const envProjectName = 'envdemo';
154
+ const envProjectRoot = path.join(envSandboxRoot, envProjectName);
155
+ await startProject({ projectName: envProjectName, cwd: envSandboxRoot });
156
+ await fs.writeFile(
157
+ path.join(envProjectRoot, 'settings.js'),
158
+ `export default {
159
+ env: 'production',
160
+ logging: {
161
+ level: 'info',
162
+ },
163
+ security: {
164
+ ddos: {
165
+ maxRequests: 120,
166
+ },
167
+ },
168
+ environments: {
169
+ default: {
170
+ security: {
171
+ ddos: {
172
+ windowMs: 45000,
173
+ },
174
+ },
175
+ },
176
+ production: {
177
+ logging: { level: 'warn' },
178
+ security: { ddos: { maxRequests: 80 } },
179
+ },
180
+ },
181
+ };
182
+ `,
183
+ 'utf8',
184
+ );
185
+ const envConfig = await loadProjectConfig(envProjectRoot);
186
+ assert.equal(envConfig.env, 'production');
187
+ assert.equal(envConfig.logging.level, 'warn');
188
+ assert.equal(envConfig.security.ddos.windowMs, 45000);
189
+ assert.equal(envConfig.security.ddos.maxRequests, 80);
190
+ await assert.rejects(
191
+ () => runServer({
192
+ projectRoot: envProjectRoot,
193
+ port: 0,
194
+ }),
195
+ /development mode/,
196
+ );
197
+ const productionProject = await runProject({
198
+ rootDir: envProjectRoot,
199
+ overrides: {
200
+ host: '127.0.0.1',
201
+ port: 0,
202
+ },
203
+ });
204
+ await productionProject.stop();
205
+
206
+ const dotenvProjectName = 'dotenvdemo';
207
+ const dotenvProjectRoot = path.join(dotenvSandboxRoot, dotenvProjectName);
208
+ await startProject({ projectName: dotenvProjectName, cwd: dotenvSandboxRoot });
209
+ await fs.writeFile(
210
+ path.join(dotenvProjectRoot, '.env'),
211
+ `AEGIS_TEST_HOST=127.0.0.1
212
+ AEGIS_TEST_PORT=4321
213
+ AEGIS_TEST_LOG_LEVEL=warn
214
+ AEGIS_TEST_APP_SECRET=test-dotenv-secret
215
+ `,
216
+ 'utf8',
217
+ );
218
+ await fs.writeFile(
219
+ path.join(dotenvProjectRoot, 'settings.js'),
220
+ `export default {
221
+ appName: 'dotenvdemo',
222
+ host: process.env.AEGIS_TEST_HOST || '0.0.0.0',
223
+ port: process.env.AEGIS_TEST_PORT ? Number(process.env.AEGIS_TEST_PORT) : 3000,
224
+ security: {
225
+ appSecret: process.env.AEGIS_TEST_APP_SECRET || '',
226
+ },
227
+ logging: {
228
+ level: process.env.AEGIS_TEST_LOG_LEVEL || 'info',
229
+ },
230
+ apps: [],
231
+ };
232
+ `,
233
+ 'utf8',
234
+ );
235
+ const dotenvConfig = await loadProjectConfig(dotenvProjectRoot);
236
+ assert.equal(dotenvConfig.host, '127.0.0.1');
237
+ assert.equal(dotenvConfig.port, 4321);
238
+ assert.equal(dotenvConfig.logging.level, 'warn');
239
+ assert.equal(dotenvConfig.security.appSecret, 'test-dotenv-secret');
240
+
241
+ const httpsProjectName = 'httpsdemo';
242
+ const httpsProjectRoot = path.join(httpsSandboxRoot, httpsProjectName);
243
+ await startProject({ projectName: httpsProjectName, cwd: httpsSandboxRoot });
244
+ await fs.mkdir(path.join(httpsProjectRoot, 'certs'), { recursive: true });
245
+ await fs.writeFile(
246
+ path.join(httpsProjectRoot, 'certs', 'localhost-key.pem'),
247
+ `-----BEGIN PRIVATE KEY-----
248
+ MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDC+P51c3EEyTG3
249
+ 0oBSv5RuF/EakUD8lYgftczXyRZS2tidrLpUoEw3HRMV2yPsgMByFc8ctQPgTic5
250
+ dsUiiHid9ozl/S282i+AlrWE2xf4me9DoyC7ITQe15UBfxzqpB7ZXVrL/lQ6INgX
251
+ KkmSEeWY+/EyxyU/cDYwzFzru5TcPwOXL8ui09siYYOD75DEMufEh6v7k4zF+9nh
252
+ tixH8a3IRSr0MIW9WzBov1DNMRrdnG5P173Klc/4bh7eHrLk6vXC5Y6L3/B3PaVB
253
+ 6idaeY6BwAGvL1Ik1yzIGQiPyZnpLZVir+wgiiRr4M56e1kjqsigWQR9S5VKGQUH
254
+ Sk2wccUhAgMBAAECggEAPKwTMyVrZBvf1t4whI+Ndv0IUEYnPPKjW4rNZdDzm3Dy
255
+ u45GpZMEZJotmD2LXktql5Xlz38c564qUp19FxP0xOM2UVOJ6hzTb2Z2shMj0H7G
256
+ j/uxccoRWA+qFL8jlnjgCLAeUyCfwT77P6ovHr9m/UZZdn22P5mBo4nU2J6U4jxG
257
+ ll2PZe8byL0bvZAmzVZ6a38Y7n4EjJIgfGGDCBojJedbrvEMO4U83OtOXUsWCepF
258
+ OZae3pNdHGuG8AJeUIcGYaUqAhe4/JEbJhkM9moQVLyLNSSAMknS50MeMO4pRWwJ
259
+ rA+iqCXyWNDXgaTNs02my6q1bxVfHH4aZeOMrMmjKQKBgQDnUvQs+KskRB9LLM2T
260
+ WReIQUP+m+s2y3+76CfEUjx7N4YG1jFRRId+grAyMHUcjbMopGi2aBxpEpveqzaH
261
+ /5ym7Ir4vZgOQ3MxRhKLnHGZprkKfi5Z/V4L18Mc9z1UHJql+wyXlazz9h1twwIO
262
+ R5AcI7nCrbaqwsACoGRKEN6V7wKBgQDXxVklj/4qB1hU+isy8xZBL4s46i1oGOEH
263
+ EoLXAYSNeL1QRhsuyoBj61Q+ac1mXI97GITKHMdVRsU6qAhMZnTbxVAe3Zk2LSMA
264
+ 4qiyuHHd7J15Koe+WgKXTBh+gyDrWXlqEZXW8VY3YSltY4rf2+/EcijLvh+SKMkR
265
+ na3fEMvl7wKBgD9UUJD3SzNUixSzoVxTqcOdypWr7gtETyYMesaelPxOyRyaC0pq
266
+ boXOFZrH9Wfpy0C3MguuGQkTFSUyzm0RJ7vzSmCq1zQgdyroOi+Klvcv07zxqpLs
267
+ cJDhcwM9FMcwRY5nWp0tVvo7SPdByhBKu0NY7IRFtpqtUo/lhU9ZqvZ1AoGARNVN
268
+ MiF0eJ3tPPatz0wjHlp3dImoQJwnNWVfXg265pLM+g3TYCLzwGxzbJG+F9iRYTia
269
+ LAvwPzEbfDHcq9rHjtCsVZxl4xWVJBQqsxEKKjzwo5XAxiXay79X1Qwp9UqO5BqG
270
+ DZLh6TrSx3XI+M8l9ypf/1dApRTjx/3gWNf34/sCgYABb8Q1N2596ljMDezoiXBa
271
+ jnH0e+HEbacoxhF+zPkPhuCaHwQkkhFqDA4mdTwiTDbht68G8MLh5RBMDb6AGZ3h
272
+ L6OWwXt24ZYAs3GCFJWxiUdurw95Ce/UGBt8ShCHQqf5+8kGBZdQmX5yuyGPKEfK
273
+ 8zvKq/Ke3FmfryEqANcGNg==
274
+ -----END PRIVATE KEY-----
275
+ `,
276
+ 'utf8',
277
+ );
278
+ await fs.writeFile(
279
+ path.join(httpsProjectRoot, 'certs', 'localhost-cert.pem'),
280
+ `-----BEGIN CERTIFICATE-----
281
+ MIIDCTCCAfGgAwIBAgIUKfS3Uua04vAtQ1Cqq5MhhIm3KnswDQYJKoZIhvcNAQEL
282
+ BQAwFDESMBAGA1UEAwwJMTI3LjAuMC4xMB4XDTI2MDMxNTE1NDQzOFoXDTI2MDMx
283
+ NjE1NDQzOFowFDESMBAGA1UEAwwJMTI3LjAuMC4xMIIBIjANBgkqhkiG9w0BAQEF
284
+ AAOCAQ8AMIIBCgKCAQEAwvj+dXNxBMkxt9KAUr+UbhfxGpFA/JWIH7XM18kWUtrY
285
+ nay6VKBMNx0TFdsj7IDAchXPHLUD4E4nOXbFIoh4nfaM5f0tvNovgJa1hNsX+Jnv
286
+ Q6MguyE0HteVAX8c6qQe2V1ay/5UOiDYFypJkhHlmPvxMsclP3A2MMxc67uU3D8D
287
+ ly/LotPbImGDg++QxDLnxIer+5OMxfvZ4bYsR/GtyEUq9DCFvVswaL9QzTEa3Zxu
288
+ T9e9ypXP+G4e3h6y5Or1wuWOi9/wdz2lQeonWnmOgcABry9SJNcsyBkIj8mZ6S2V
289
+ Yq/sIIoka+DOentZI6rIoFkEfUuVShkFB0pNsHHFIQIDAQABo1MwUTAdBgNVHQ4E
290
+ FgQU6TCiIGnLvsRYjRhBnjdSNu2oBKUwHwYDVR0jBBgwFoAU6TCiIGnLvsRYjRhB
291
+ njdSNu2oBKUwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEALiwu
292
+ RULxP/LTqypp1sQmM1WsrUv/mfMoCCVmHOeEqljLOvtYKnVjPsS8ER90dfcTJQ6B
293
+ qJRNjWWDqnIFR90mkgBzVIp1JnV3kabShbhc+Gtd40qVEAzzXXV+PJgAIu/HjNp0
294
+ S5XWmSz2NNVwEEMqpIm2Aej/dDmSoD1Wvx5Z8PndHTAb8yP2gJM8oK/gE0g7o3gm
295
+ D2qu8eIsBibj/h99WhNApm4c39Sat1g9xl3dIe8xE0+hI12WtnyfuPj9TxDCai+r
296
+ 0AIsvw3CSCUSwU4Cb/1zsHQ28IfSjbU3k7mbC79ja4MqZfEy/n6G1ZNV5FjapwVy
297
+ dkcqnJD4SGWVeG+KhA==
298
+ -----END CERTIFICATE-----
299
+ `,
300
+ 'utf8',
301
+ );
302
+
303
+ const httpsKernel = await createKernel({
304
+ rootDir: httpsProjectRoot,
305
+ overrides: {
306
+ host: '127.0.0.1',
307
+ port: 0,
308
+ https: {
309
+ enabled: true,
310
+ keyPath: 'certs/localhost-key.pem',
311
+ certPath: 'certs/localhost-cert.pem',
312
+ },
313
+ },
314
+ });
315
+
316
+ await httpsKernel.start();
317
+ const httpsAddress = httpsKernel.context.server.address();
318
+ const httpsPort = typeof httpsAddress === 'object' && httpsAddress ? httpsAddress.port : 0;
319
+ const httpsResponse = await requestHttps(`https://127.0.0.1:${httpsPort}/`);
320
+ assert.equal(httpsResponse.status, 200);
321
+ assert.match(httpsResponse.body, /Install Confirmed/);
322
+ await httpsKernel.stop();
323
+
324
+ const proxyProjectName = 'proxydemo';
325
+ const proxyProjectRoot = path.join(proxySandboxRoot, proxyProjectName);
326
+ await startProject({ projectName: proxyProjectName, cwd: proxySandboxRoot });
327
+ await fs.writeFile(
328
+ path.join(proxyProjectRoot, 'routes.js'),
329
+ `export default {\n register(route) {\n route.get('/secure-check', (req, res) => {\n res.json({ secure: req.secure, protocol: req.protocol });\n });\n },\n};\n`,
330
+ 'utf8',
331
+ );
332
+
333
+ const proxyKernel = await createKernel({
334
+ rootDir: proxyProjectRoot,
335
+ overrides: {
336
+ host: '127.0.0.1',
337
+ port: 0,
338
+ trustProxy: 1,
339
+ },
340
+ });
341
+
342
+ await proxyKernel.start();
343
+ const proxyAddress = proxyKernel.context.server.address();
344
+ const proxyPort = typeof proxyAddress === 'object' && proxyAddress ? proxyAddress.port : 0;
345
+ const proxyResponse = await fetch(`http://127.0.0.1:${proxyPort}/secure-check`, {
346
+ headers: {
347
+ 'x-forwarded-proto': 'https',
348
+ },
349
+ });
350
+ const proxyJson = await proxyResponse.json();
351
+ assert.equal(proxyJson.secure, true);
352
+ assert.equal(proxyJson.protocol, 'https');
353
+ await proxyKernel.stop();
354
+
355
+ const helpers = createHelpers();
356
+ const validObjectId = '507f1f77bcf86cd799439011';
357
+ const invalidObjectId = 'not-an-object-id';
358
+
359
+ assert.equal(helpers.isObjectId(validObjectId), true);
360
+ assert.equal(helpers.isObjectId(invalidObjectId), false);
361
+ const convertedObjectId = helpers.toObjectId(validObjectId);
362
+ assert.ok(convertedObjectId);
363
+ assert.equal(convertedObjectId.toString(), validObjectId);
364
+ assert.equal(helpers.toObjectId(invalidObjectId), null);
365
+
366
+ const fakeMongoConnection = {
367
+ db: {
368
+ collection() {
369
+ return {};
370
+ },
371
+ },
372
+ };
373
+
374
+ const noSqlDb = await initializeDatabase({
375
+ enabled: true,
376
+ dialect: 'mongoose',
377
+ config: {
378
+ connection: fakeMongoConnection,
379
+ },
380
+ }, createSilentLogger());
381
+
382
+ assert.equal(noSqlDb.type, 'nosql');
383
+ assert.equal(noSqlDb.dialect, 'mongodb');
384
+ assert.equal(typeof noSqlDb.client.table, 'function');
385
+
386
+ const compiledMongoQuery = noSqlDb.client.table('users').select(['id', 'name']).compile();
387
+ assert.equal(typeof compiledMongoQuery, 'object');
388
+
389
+ await closeDatabase(noSqlDb);
390
+
391
+ await fs.mkdir(path.join(projectRoot, 'node_modules'), { recursive: true });
392
+ await fs.symlink(frameworkRoot, path.join(projectRoot, 'node_modules', 'aegisnode'), 'dir');
393
+
394
+ const filesToCheck = [
395
+ 'app.js',
396
+ 'loader.cjs',
397
+ 'package.json',
398
+ 'settings.js',
399
+ 'routes.js',
400
+ ];
401
+
402
+ for (const relativeFile of filesToCheck) {
403
+ const filePath = path.join(projectRoot, relativeFile);
404
+ await fs.access(filePath);
405
+ }
406
+
407
+ const registryPackages = new Map([
408
+ ['alpha', '2.0.0'],
409
+ ['@scope/bravo', '3.4.0'],
410
+ ['charlie', '5.0.0'],
411
+ ['delta', '1.0.0'],
412
+ ['echo', '1.1.0'],
413
+ ['foxtrot', '8.0.0'],
414
+ ]);
415
+ const registryServer = http.createServer((request, response) => {
416
+ const packageName = decodeURIComponent((request.url || '/').replace(/^\/+/, ''));
417
+ const latestVersion = registryPackages.get(packageName);
418
+
419
+ response.setHeader('content-type', 'application/json');
420
+
421
+ if (!latestVersion) {
422
+ response.statusCode = 404;
423
+ response.end(JSON.stringify({ error: 'not_found' }));
424
+ return;
425
+ }
426
+
427
+ response.end(JSON.stringify({
428
+ name: packageName,
429
+ 'dist-tags': {
430
+ latest: latestVersion,
431
+ },
432
+ }));
433
+ });
434
+ await new Promise((resolve) => {
435
+ registryServer.listen(0, '127.0.0.1', resolve);
436
+ });
437
+ const registryAddress = registryServer.address();
438
+ assert.ok(registryAddress && typeof registryAddress === 'object');
439
+ const registryBaseUrl = `http://127.0.0.1:${registryAddress.port}/`;
440
+
441
+ try {
442
+ await fs.writeFile(
443
+ path.join(projectRoot, 'package.json'),
444
+ `${JSON.stringify(
445
+ {
446
+ name: projectName,
447
+ version: '1.0.0',
448
+ private: true,
449
+ type: 'module',
450
+ dependencies: {
451
+ alpha: '^1.0.0',
452
+ '@scope/bravo': '~3.2.1',
453
+ localpkg: 'file:../localpkg',
454
+ foxtrot: '^8.0.0',
455
+ },
456
+ devDependencies: {
457
+ charlie: '4.0.0',
458
+ },
459
+ optionalDependencies: {
460
+ echo: 'latest',
461
+ aliasecho: 'npm:echo@^0.1.0',
462
+ },
463
+ peerDependencies: {
464
+ delta: '^0.5.0',
465
+ },
466
+ },
467
+ null,
468
+ 2,
469
+ )}\n`,
470
+ 'utf8',
471
+ );
472
+
473
+ const dependencyUpdateReport = await runUpdateDependencies({
474
+ projectRoot,
475
+ registryBaseUrl,
476
+ installDependencies: false,
477
+ output: {
478
+ log() {},
479
+ },
480
+ });
481
+ const updatedPackageJson = JSON.parse(await fs.readFile(path.join(projectRoot, 'package.json'), 'utf8'));
482
+
483
+ assert.equal(dependencyUpdateReport.rootDir, projectRoot);
484
+ assert.equal(dependencyUpdateReport.packageManager, null);
485
+ assert.equal(dependencyUpdateReport.updatedEntries.length, 6);
486
+ assert.equal(dependencyUpdateReport.unchangedEntries.length, 1);
487
+ assert.equal(dependencyUpdateReport.skippedEntries.length, 1);
488
+ assert.equal(updatedPackageJson.dependencies.alpha, '^2.0.0');
489
+ assert.equal(updatedPackageJson.dependencies['@scope/bravo'], '~3.4.0');
490
+ assert.equal(updatedPackageJson.dependencies.localpkg, 'file:../localpkg');
491
+ assert.equal(updatedPackageJson.dependencies.foxtrot, '^8.0.0');
492
+ assert.equal(updatedPackageJson.devDependencies.charlie, '5.0.0');
493
+ assert.equal(updatedPackageJson.optionalDependencies.echo, '1.1.0');
494
+ assert.equal(updatedPackageJson.optionalDependencies.aliasecho, 'npm:echo@^1.1.0');
495
+ assert.equal(updatedPackageJson.peerDependencies.delta, '^1.0.0');
496
+ } finally {
497
+ await new Promise((resolve) => {
498
+ registryServer.close(resolve);
499
+ });
500
+ }
501
+
502
+ await createApp({
503
+ appName: 'users',
504
+ projectRoot: sandboxRoot,
505
+ mount: '/users',
506
+ });
507
+
508
+ await assert.rejects(
509
+ () => createApp({
510
+ appName: 'unsafe',
511
+ projectRoot: sandboxRoot,
512
+ mount: "/users');drop_table",
513
+ }),
514
+ /Invalid mount path segment/,
515
+ );
516
+
517
+ await fs.access(path.join(projectRoot, 'apps', 'users', 'routes.js'));
518
+ await fs.access(path.join(projectRoot, 'apps', 'users', 'views.js'));
519
+ await fs.access(path.join(projectRoot, 'apps', 'users', 'models.js'));
520
+ await fs.access(path.join(projectRoot, 'apps', 'users', 'validators.js'));
521
+ await fs.access(path.join(projectRoot, 'apps', 'users', 'services.js'));
522
+ await fs.access(path.join(projectRoot, 'apps', 'users', 'subscribers.js'));
523
+ await fs.access(path.join(projectRoot, 'apps', 'users', 'tests', 'models.test.js'));
524
+ await fs.access(path.join(projectRoot, 'apps', 'users', 'tests', 'validators.test.js'));
525
+ await fs.access(path.join(projectRoot, 'apps', 'users', 'tests', 'services.test.js'));
526
+ await fs.access(path.join(projectRoot, 'apps', 'users', 'tests', 'routes.test.js'));
527
+ const doctorReport = await runDoctor({
528
+ projectRoot,
529
+ failOnError: true,
530
+ output: {
531
+ log() {},
532
+ },
533
+ });
534
+ assert.equal(doctorReport.summary.errors, 0);
535
+ const projectRoutesFile = await fs.readFile(path.join(projectRoot, 'routes.js'), 'utf8');
536
+ assert.match(projectRoutesFile, /route\.use\((['"])\/users\1, users\);/);
537
+ await generateArtifact({
538
+ type: 'view',
539
+ name: 'profile',
540
+ appName: 'users',
541
+ projectRoot,
542
+ });
543
+ await fs.access(path.join(projectRoot, 'apps', 'users', 'profile.view.js'));
544
+ await generateArtifact({
545
+ type: 'validator',
546
+ name: 'profile',
547
+ appName: 'users',
548
+ projectRoot,
549
+ });
550
+ await fs.access(path.join(projectRoot, 'apps', 'users', 'profile.validator.js'));
551
+ await generateArtifact({
552
+ type: 'dto',
553
+ name: 'account',
554
+ appName: 'users',
555
+ projectRoot,
556
+ });
557
+ await fs.access(path.join(projectRoot, 'apps', 'users', 'account.validator.js'));
558
+ await generateArtifact({
559
+ type: 'route',
560
+ name: 'profile',
561
+ appName: 'users',
562
+ projectRoot,
563
+ });
564
+ const usersRoutesFile = await fs.readFile(path.join(projectRoot, 'apps', 'users', 'routes.js'), 'utf8');
565
+ assert.match(usersRoutesFile, /import ProfileView from '\.\/profile\.view\.js';/);
566
+ assert.match(usersRoutesFile, /route\.get\('\/profile', ProfileView\.index\);/);
567
+ await fs.writeFile(
568
+ path.join(projectRoot, 'apps', 'users', 'services.js'),
569
+ `class UsersService {
570
+ constructor({ models, mail }) {
571
+ this.model = models.get('users');
572
+ this.mail = mail;
573
+ }
574
+
575
+ async list() {
576
+ return this.model.list();
577
+ }
578
+
579
+ async getById(id) {
580
+ return this.model.getById(id);
581
+ }
582
+
583
+ async create(payload) {
584
+ return this.model.create(payload);
585
+ }
586
+
587
+ async update(id, payload) {
588
+ return this.model.update(id, payload);
589
+ }
590
+
591
+ async remove(id) {
592
+ return this.model.remove(id);
593
+ }
594
+
595
+ async sendSmokeMail() {
596
+ return this.mail.send({
597
+ to: 'user@example.com',
598
+ subject: 'Smoke mail',
599
+ html: '<p>mail transport ok</p>',
600
+ });
601
+ }
602
+ }
603
+
604
+ export default {
605
+ users: UsersService,
606
+ };
607
+ `,
608
+ 'utf8',
609
+ );
610
+ const sentMail = [];
611
+ const fakeTransporter = {
612
+ async sendMail(message) {
613
+ sentMail.push({ ...message });
614
+ const accepted = Array.isArray(message.to) ? message.to : [message.to];
615
+ return {
616
+ messageId: `mail-${sentMail.length}`,
617
+ accepted,
618
+ rejected: [],
619
+ envelope: {
620
+ from: message.from,
621
+ to: accepted,
622
+ },
623
+ response: '250 queued',
624
+ };
625
+ },
626
+ async verify() {
627
+ return true;
628
+ },
629
+ async close() {},
630
+ };
631
+ await fs.writeFile(
632
+ path.join(projectRoot, 'routes.js'),
633
+ `// AEGIS_APP_IMPORTS_START
634
+ import users from './apps/users/routes.js';
635
+ // AEGIS_APP_IMPORTS_END
636
+
637
+ export default {
638
+ register(route) {
639
+ route.get('/health', (req, res) => {
640
+ res.json({ status: 'ok' });
641
+ });
642
+
643
+ route.get('/maintenance-page', (req, res) => {
644
+ res.type('html').send('<!doctype html><html><body><h1>Custom maintenance route</h1><p>Temporarily offline.</p></body></html>');
645
+ });
646
+
647
+ route.get('/mail/send', async ({ mail, services }, req, res, next) => {
648
+ try {
649
+ const info = await services.forApp('users').get('users').sendSmokeMail();
650
+
651
+ res.json({
652
+ enabled: mail.enabled,
653
+ viaReq: req.aegis.mail.enabled,
654
+ sameBridge: req.aegis.mail === mail,
655
+ from: info.envelope.from,
656
+ accepted: info.accepted,
657
+ });
658
+ } catch (error) {
659
+ next(error);
660
+ }
661
+ });
662
+
663
+ // AEGIS_PROJECT_APP_ROUTES_START
664
+ route.use("/users", users);
665
+ // AEGIS_PROJECT_APP_ROUTES_END
666
+ },
667
+ };
668
+ `,
669
+ 'utf8',
670
+ );
671
+
672
+ const maintenanceKernel = await createKernel({
673
+ rootDir: projectRoot,
674
+ overrides: {
675
+ host: '127.0.0.1',
676
+ port: 0,
677
+ maintenance: {
678
+ enabled: true,
679
+ route: '/maintenance-page',
680
+ excludePaths: ['/health'],
681
+ retryAfter: 120,
682
+ },
683
+ },
684
+ });
685
+ await maintenanceKernel.start();
686
+ const maintenanceAddress = maintenanceKernel.context.server.address();
687
+ const maintenancePort = typeof maintenanceAddress === 'object' && maintenanceAddress ? maintenanceAddress.port : 0;
688
+ const maintenanceUsersResponse = await fetch(`http://127.0.0.1:${maintenancePort}/users`);
689
+ assert.equal(maintenanceUsersResponse.status, 503);
690
+ assert.equal(maintenanceUsersResponse.headers.get('retry-after'), '120');
691
+ assert.match(maintenanceUsersResponse.headers.get('content-type') || '', /text\/html/);
692
+ const maintenanceHtml = await maintenanceUsersResponse.text();
693
+ assert.match(maintenanceHtml, /Custom maintenance route/);
694
+ const maintenanceHealthResponse = await fetch(`http://127.0.0.1:${maintenancePort}/health`);
695
+ assert.equal(maintenanceHealthResponse.status, 200);
696
+ const maintenanceHealthJson = await maintenanceHealthResponse.json();
697
+ assert.equal(maintenanceHealthJson.status, 'ok');
698
+ await maintenanceKernel.stop();
699
+
700
+ const maintenanceFallbackKernel = await createKernel({
701
+ rootDir: projectRoot,
702
+ overrides: {
703
+ host: '127.0.0.1',
704
+ port: 0,
705
+ maintenance: {
706
+ enabled: true,
707
+ route: '/missing-maintenance-route',
708
+ excludePaths: ['/health'],
709
+ },
710
+ },
711
+ });
712
+ await maintenanceFallbackKernel.start();
713
+ const maintenanceFallbackAddress = maintenanceFallbackKernel.context.server.address();
714
+ const maintenanceFallbackPort = typeof maintenanceFallbackAddress === 'object' && maintenanceFallbackAddress
715
+ ? maintenanceFallbackAddress.port
716
+ : 0;
717
+ const maintenanceFallbackResponse = await fetch(`http://127.0.0.1:${maintenanceFallbackPort}/users`);
718
+ assert.equal(maintenanceFallbackResponse.status, 503);
719
+ assert.match(maintenanceFallbackResponse.headers.get('content-type') || '', /text\/html/);
720
+ const maintenanceFallbackHtml = await maintenanceFallbackResponse.text();
721
+ assert.match(maintenanceFallbackHtml, /We&apos;ll be back soon\.|We'll be back soon\./);
722
+ assert.match(maintenanceFallbackHtml, /Requested Path/);
723
+ await maintenanceFallbackKernel.stop();
724
+
725
+ const settingsFilePath = path.join(projectRoot, 'settings.js');
726
+ const settingsBeforeUndeclaredCheck = await fs.readFile(settingsFilePath, 'utf8');
727
+ const settingsWithoutUsers = settingsBeforeUndeclaredCheck.replace(
728
+ /\s*\{\s*name:\s*['"]users['"]\s*,\s*mount:\s*['"]\/users['"]\s*\},?\n?/,
729
+ '',
730
+ );
731
+ await fs.writeFile(settingsFilePath, settingsWithoutUsers, 'utf8');
732
+
733
+ await assert.rejects(
734
+ () => createKernel({
735
+ rootDir: projectRoot,
736
+ overrides: {
737
+ host: '127.0.0.1',
738
+ port: 0,
739
+ },
740
+ }),
741
+ /settings\.apps/,
742
+ );
743
+
744
+ await fs.writeFile(settingsFilePath, settingsBeforeUndeclaredCheck, 'utf8');
745
+
746
+ const kernel = await createKernel({
747
+ rootDir: projectRoot,
748
+ overrides: {
749
+ host: '127.0.0.1',
750
+ port: 0,
751
+ mail: {
752
+ enabled: true,
753
+ defaults: {
754
+ from: 'noreply@example.com',
755
+ },
756
+ transporter: fakeTransporter,
757
+ },
758
+ },
759
+ });
760
+
761
+ await kernel.start();
762
+ const address = kernel.context.server.address();
763
+ const port = typeof address === 'object' && address ? address.port : 0;
764
+ const homeResponse = await fetch(`http://127.0.0.1:${port}/`);
765
+ const homeText = await homeResponse.text();
766
+ assert.match(homeText, /Install Confirmed/);
767
+ assert.equal(homeResponse.headers.get('x-content-type-options'), 'nosniff');
768
+ assert.match(homeResponse.headers.get('content-security-policy') || '', /default-src/);
769
+ const usersResponse = await fetch(`http://127.0.0.1:${port}/users`);
770
+ const usersJson = await usersResponse.json();
771
+ assert.equal(Array.isArray(usersJson.data), true);
772
+ const usersCreateBlocked = await fetch(`http://127.0.0.1:${port}/users`, {
773
+ method: 'POST',
774
+ headers: {
775
+ 'content-type': 'application/json',
776
+ },
777
+ body: JSON.stringify({ name: 'blocked-without-csrf' }),
778
+ });
779
+ assert.equal(usersCreateBlocked.status, 403);
780
+ const profileResponse = await fetch(`http://127.0.0.1:${port}/users/profile`);
781
+ const profileJson = await profileResponse.json();
782
+ assert.equal(profileJson.view, 'profile');
783
+ const mailResponse = await fetch(`http://127.0.0.1:${port}/mail/send`);
784
+ assert.equal(mailResponse.status, 200);
785
+ const mailJson = await mailResponse.json();
786
+ assert.equal(mailJson.enabled, true);
787
+ assert.equal(mailJson.viaReq, true);
788
+ assert.equal(mailJson.sameBridge, true);
789
+ assert.equal(mailJson.from, 'noreply@example.com');
790
+ assert.equal(mailJson.accepted[0], 'user@example.com');
791
+ assert.equal(sentMail.length, 1);
792
+ assert.equal(sentMail[0].from, 'noreply@example.com');
793
+ assert.equal(sentMail[0].subject, 'Smoke mail');
794
+ assert.equal(sentMail[0].html, '<p>mail transport ok</p>');
795
+ await kernel.stop();
796
+
797
+ const kernelWithApiApps = await createKernel({
798
+ rootDir: projectRoot,
799
+ overrides: {
800
+ host: '127.0.0.1',
801
+ port: 0,
802
+ api: {
803
+ apps: ['users'],
804
+ },
805
+ },
806
+ });
807
+
808
+ await kernelWithApiApps.start();
809
+ const apiAddress = kernelWithApiApps.context.server.address();
810
+ const apiPort = typeof apiAddress === 'object' && apiAddress ? apiAddress.port : 0;
811
+ const usersCreateAllowed = await fetch(`http://127.0.0.1:${apiPort}/users`, {
812
+ method: 'POST',
813
+ headers: {
814
+ 'content-type': 'application/json',
815
+ },
816
+ body: JSON.stringify({ name: 'allowed-api-json' }),
817
+ });
818
+ assert.equal(usersCreateAllowed.status, 201);
819
+ assert.equal(usersCreateAllowed.headers.get('cache-control'), 'no-store');
820
+ const usersCreateAllowedJson = await usersCreateAllowed.json();
821
+ assert.equal(usersCreateAllowedJson.data.name, 'allowed-api-json');
822
+
823
+ const usersCreateInvalidContentType = await fetch(`http://127.0.0.1:${apiPort}/users`, {
824
+ method: 'POST',
825
+ headers: {
826
+ 'content-type': 'application/x-www-form-urlencoded',
827
+ },
828
+ body: new URLSearchParams({ name: 'invalid-content-type' }),
829
+ });
830
+ assert.equal(usersCreateInvalidContentType.status, 415);
831
+ await kernelWithApiApps.stop();
832
+
833
+ const kernelWithSwagger = await createKernel({
834
+ rootDir: projectRoot,
835
+ overrides: {
836
+ host: '127.0.0.1',
837
+ port: 0,
838
+ swagger: {
839
+ enabled: true,
840
+ document: {
841
+ openapi: '3.0.3',
842
+ info: {
843
+ title: 'Smoke API',
844
+ version: '1.0.0',
845
+ },
846
+ paths: {},
847
+ },
848
+ },
849
+ },
850
+ });
851
+
852
+ await kernelWithSwagger.start();
853
+ const swaggerAddress = kernelWithSwagger.context.server.address();
854
+ const swaggerPort = typeof swaggerAddress === 'object' && swaggerAddress ? swaggerAddress.port : 0;
855
+
856
+ const openApiResponse = await fetch(`http://127.0.0.1:${swaggerPort}/openapi.json`);
857
+ assert.equal(openApiResponse.status, 200);
858
+ const openApiJson = await openApiResponse.json();
859
+ assert.equal(openApiJson.openapi, '3.0.3');
860
+ assert.equal(openApiJson.info.title, 'Smoke API');
861
+
862
+ const swaggerUiResponse = await fetch(`http://127.0.0.1:${swaggerPort}/docs`);
863
+ assert.equal(swaggerUiResponse.status, 200);
864
+ const swaggerUiHtml = await swaggerUiResponse.text();
865
+ assert.match(swaggerUiHtml, /Swagger UI/i);
866
+ await kernelWithSwagger.stop();
867
+
868
+ await fs.writeFile(
869
+ path.join(projectRoot, 'routes.js'),
870
+ `export default {\n register(route) {\n const authGuard = (req, res, next) => req.aegis.auth.middleware()(req, res, next);\n\n route.get('/auth/disabled-safe', authGuard, (req, res) => {\n res.json({ ok: true });\n });\n },\n};\n`,
871
+ 'utf8',
872
+ );
873
+
874
+ const kernelWithAuthDisabled = await createKernel({
875
+ rootDir: projectRoot,
876
+ overrides: {
877
+ host: '127.0.0.1',
878
+ port: 0,
879
+ auth: {
880
+ enabled: false,
881
+ },
882
+ },
883
+ });
884
+
885
+ await kernelWithAuthDisabled.start();
886
+ const authDisabledAddress = kernelWithAuthDisabled.context.server.address();
887
+ const authDisabledPort = typeof authDisabledAddress === 'object' && authDisabledAddress ? authDisabledAddress.port : 0;
888
+ const authDisabledResponse = await fetch(`http://127.0.0.1:${authDisabledPort}/auth/disabled-safe`);
889
+ assert.equal(authDisabledResponse.status, 503);
890
+ await kernelWithAuthDisabled.stop();
891
+
892
+ await fs.writeFile(
893
+ path.join(projectRoot, 'routes.js'),
894
+ `export default {\n register(route) {\n const authGuard = (req, res, next) => req.aegis.auth.middleware()(req, res, next);\n\n route.get('/jwt/token', (req, res) => {\n const token = req.aegis.auth.issue({\n subject: 'u1',\n scope: ['read:users'],\n });\n const refreshToken = req.aegis.auth.issueRefreshToken({\n subject: 'u1',\n });\n res.json({ token, refreshToken, tables: req.aegis.auth.tables });\n });\n\n route.get('/jwt/me', authGuard, (req, res) => {\n res.json({ sub: req.auth.sub, scope: req.auth.scope || '' });\n });\n\n route.get('/jwt/revoke', (req, res) => {\n const revoked = req.aegis.auth.revoke(req.query.token || '');\n res.json({ revoked });\n });\n },\n};\n`,
895
+ 'utf8',
896
+ );
897
+
898
+ const kernelWithJwtAuth = await createKernel({
899
+ rootDir: projectRoot,
900
+ overrides: {
901
+ host: '127.0.0.1',
902
+ port: 0,
903
+ auth: {
904
+ enabled: true,
905
+ provider: 'jwt',
906
+ tablePrefix: 'aegisnode',
907
+ jwt: {
908
+ secret: '0123456789abcdef0123456789abcdef',
909
+ },
910
+ },
911
+ },
912
+ });
913
+
914
+ await kernelWithJwtAuth.start();
915
+ const jwtAddress = kernelWithJwtAuth.context.server.address();
916
+ const jwtPort = typeof jwtAddress === 'object' && jwtAddress ? jwtAddress.port : 0;
917
+ const jwtTokenResponse = await fetch(`http://127.0.0.1:${jwtPort}/jwt/token`);
918
+ assert.equal(jwtTokenResponse.status, 200);
919
+ const jwtTokenJson = await jwtTokenResponse.json();
920
+ assert.ok(typeof jwtTokenJson.token === 'string' && jwtTokenJson.token.length > 20);
921
+ assert.match(jwtTokenJson.tables.oauthClients, /^aegisnode_/);
922
+
923
+ const jwtMeResponse = await fetch(`http://127.0.0.1:${jwtPort}/jwt/me`, {
924
+ headers: {
925
+ authorization: `Bearer ${jwtTokenJson.token}`,
926
+ },
927
+ });
928
+ assert.equal(jwtMeResponse.status, 200);
929
+ const jwtMeJson = await jwtMeResponse.json();
930
+ assert.equal(jwtMeJson.sub, 'u1');
931
+
932
+ const jwtRevokeResponse = await fetch(`http://127.0.0.1:${jwtPort}/jwt/revoke?token=${encodeURIComponent(jwtTokenJson.token)}`);
933
+ const jwtRevokeJson = await jwtRevokeResponse.json();
934
+ assert.equal(jwtRevokeJson.revoked, true);
935
+
936
+ const jwtAfterRevoke = await fetch(`http://127.0.0.1:${jwtPort}/jwt/me`, {
937
+ headers: {
938
+ authorization: `Bearer ${jwtTokenJson.token}`,
939
+ },
940
+ });
941
+ assert.equal(jwtAfterRevoke.status, 401);
942
+ await kernelWithJwtAuth.stop();
943
+
944
+ await fs.writeFile(
945
+ path.join(projectRoot, 'routes.js'),
946
+ `export default {\n register(route) {\n const authGuard = (req, res, next) => req.aegis.auth.middleware()(req, res, next);\n\n route.get('/oauth/register', (req, res) => {\n req.aegis.auth.registerClient({ clientId: 'web', clientSecret: 'secret' });\n res.json({ ok: true, tables: req.aegis.auth.tables });\n });\n\n route.get('/oauth/token', (req, res) => {\n const tokens = req.aegis.auth.issue({\n clientId: 'web',\n clientSecret: 'secret',\n subject: 'u1',\n scope: ['read:users'],\n });\n res.json(tokens);\n });\n\n route.get('/oauth/introspect', (req, res) => {\n res.json(req.aegis.auth.introspect(req.query.token || ''));\n });\n\n route.get('/oauth/protected', authGuard, (req, res) => {\n res.json({ sub: req.auth.sub || null, active: req.auth.active });\n });\n\n route.get('/oauth/revoke', (req, res) => {\n const revoked = req.aegis.auth.revoke(req.query.token || '');\n res.json({ revoked });\n });\n },\n};\n`,
947
+ 'utf8',
948
+ );
949
+
950
+ const kernelWithOAuth2 = await createKernel({
951
+ rootDir: projectRoot,
952
+ overrides: {
953
+ host: '127.0.0.1',
954
+ port: 0,
955
+ auth: {
956
+ enabled: true,
957
+ provider: 'oauth2',
958
+ tablePrefix: 'aegisnode',
959
+ storage: {
960
+ driver: 'file',
961
+ filePath: 'storage/aegisnode-auth-store.json',
962
+ },
963
+ },
964
+ },
965
+ });
966
+
967
+ await kernelWithOAuth2.start();
968
+ const oauthAddress = kernelWithOAuth2.context.server.address();
969
+ const oauthPort = typeof oauthAddress === 'object' && oauthAddress ? oauthAddress.port : 0;
970
+ const oauthRegisterResponse = await fetch(`http://127.0.0.1:${oauthPort}/oauth/register`);
971
+ const oauthRegisterJson = await oauthRegisterResponse.json();
972
+ assert.equal(oauthRegisterJson.ok, true);
973
+ assert.match(oauthRegisterJson.tables.oauthAccessTokens, /^aegisnode_/);
974
+
975
+ const oauthTokenResponse = await fetch(`http://127.0.0.1:${oauthPort}/oauth/token`);
976
+ const oauthTokenJson = await oauthTokenResponse.json();
977
+ assert.ok(typeof oauthTokenJson.accessToken === 'string' && oauthTokenJson.accessToken.length > 20);
978
+
979
+ const oauthProtectedResponse = await fetch(`http://127.0.0.1:${oauthPort}/oauth/protected`, {
980
+ headers: {
981
+ authorization: `Bearer ${oauthTokenJson.accessToken}`,
982
+ },
983
+ });
984
+ assert.equal(oauthProtectedResponse.status, 200);
985
+ const oauthProtectedJson = await oauthProtectedResponse.json();
986
+ assert.equal(oauthProtectedJson.sub, 'u1');
987
+
988
+ const oauthIntrospectResponse = await fetch(`http://127.0.0.1:${oauthPort}/oauth/introspect?token=${encodeURIComponent(oauthTokenJson.accessToken)}`);
989
+ const oauthIntrospectJson = await oauthIntrospectResponse.json();
990
+ assert.equal(oauthIntrospectJson.active, true);
991
+ await kernelWithOAuth2.stop();
992
+
993
+ const kernelWithOAuth2Restarted = await createKernel({
994
+ rootDir: projectRoot,
995
+ overrides: {
996
+ host: '127.0.0.1',
997
+ port: 0,
998
+ auth: {
999
+ enabled: true,
1000
+ provider: 'oauth2',
1001
+ tablePrefix: 'aegisnode',
1002
+ storage: {
1003
+ driver: 'file',
1004
+ filePath: 'storage/aegisnode-auth-store.json',
1005
+ },
1006
+ },
1007
+ },
1008
+ });
1009
+
1010
+ await kernelWithOAuth2Restarted.start();
1011
+ const oauthRestartedAddress = kernelWithOAuth2Restarted.context.server.address();
1012
+ const oauthRestartedPort = typeof oauthRestartedAddress === 'object' && oauthRestartedAddress ? oauthRestartedAddress.port : 0;
1013
+ const oauthIntrospectAfterRestartResponse = await fetch(`http://127.0.0.1:${oauthRestartedPort}/oauth/introspect?token=${encodeURIComponent(oauthTokenJson.accessToken)}`);
1014
+ const oauthIntrospectAfterRestartJson = await oauthIntrospectAfterRestartResponse.json();
1015
+ assert.equal(oauthIntrospectAfterRestartJson.active, true);
1016
+
1017
+ const oauthRevokeResponse = await fetch(`http://127.0.0.1:${oauthRestartedPort}/oauth/revoke?token=${encodeURIComponent(oauthTokenJson.accessToken)}`);
1018
+ const oauthRevokeJson = await oauthRevokeResponse.json();
1019
+ assert.equal(oauthRevokeJson.revoked, true);
1020
+
1021
+ const oauthProtectedAfterRevoke = await fetch(`http://127.0.0.1:${oauthRestartedPort}/oauth/protected`, {
1022
+ headers: {
1023
+ authorization: `Bearer ${oauthTokenJson.accessToken}`,
1024
+ },
1025
+ });
1026
+ assert.equal(oauthProtectedAfterRevoke.status, 401);
1027
+ await kernelWithOAuth2Restarted.stop();
1028
+
1029
+ await fs.writeFile(
1030
+ path.join(projectRoot, 'routes.js'),
1031
+ `export default {\n register(route) {\n const authGuard = (req, res, next) => req.aegis.auth.middleware()(req, res, next);\n\n route.get('/oauth/server/register', (req, res) => {\n const web = req.aegis.auth.registerClient({\n clientId: 'web',\n clientSecret: 'secret',\n redirectUris: ['http://127.0.0.1/callback'],\n grants: ['authorization_code', 'refresh_token'],\n scopes: ['read:users'],\n });\n\n const machine = req.aegis.auth.registerClient({\n clientId: 'machine',\n clientSecret: 'machine-secret',\n grants: ['client_credentials'],\n scopes: ['read:users'],\n });\n\n res.json({ ok: true, web, machine });\n });\n\n route.get('/oauth/server/protected', authGuard, (req, res) => {\n res.json({\n active: req.auth.active,\n sub: req.auth.sub || null,\n clientId: req.auth.clientId || null,\n tokenType: req.auth.tokenType,\n });\n });\n },\n};\n`,
1032
+ 'utf8',
1033
+ );
1034
+
1035
+ const kernelWithOAuthServer = await createKernel({
1036
+ rootDir: projectRoot,
1037
+ overrides: {
1038
+ host: '127.0.0.1',
1039
+ port: 0,
1040
+ auth: {
1041
+ enabled: true,
1042
+ provider: 'oauth2',
1043
+ oauth2: {
1044
+ server: {
1045
+ allowHttp: true,
1046
+ resolveSubject: () => 'u1',
1047
+ },
1048
+ },
1049
+ },
1050
+ },
1051
+ });
1052
+
1053
+ await kernelWithOAuthServer.start();
1054
+ const oauthServerAddress = kernelWithOAuthServer.context.server.address();
1055
+ const oauthServerPort = typeof oauthServerAddress === 'object' && oauthServerAddress ? oauthServerAddress.port : 0;
1056
+
1057
+ const oauthServerRegister = await fetch(`http://127.0.0.1:${oauthServerPort}/oauth/server/register`);
1058
+ const oauthServerRegisterJson = await oauthServerRegister.json();
1059
+ assert.equal(oauthServerRegisterJson.ok, true);
1060
+ assert.equal(oauthServerRegisterJson.web.clientId, 'web');
1061
+
1062
+ const metadataResponse = await fetch(`http://127.0.0.1:${oauthServerPort}/.well-known/oauth-authorization-server`);
1063
+ assert.equal(metadataResponse.status, 200);
1064
+ const metadataJson = await metadataResponse.json();
1065
+ assert.ok(String(metadataJson.authorization_endpoint || '').includes('/oauth/authorize'));
1066
+ assert.ok(String(metadataJson.token_endpoint || '').includes('/oauth/token'));
1067
+
1068
+ const pkce = createPkcePair();
1069
+ const authorizeUrl = new URL(`http://127.0.0.1:${oauthServerPort}/oauth/authorize`);
1070
+ authorizeUrl.searchParams.set('response_type', 'code');
1071
+ authorizeUrl.searchParams.set('client_id', 'web');
1072
+ authorizeUrl.searchParams.set('redirect_uri', 'http://127.0.0.1/callback');
1073
+ authorizeUrl.searchParams.set('scope', 'read:users');
1074
+ authorizeUrl.searchParams.set('state', 's123');
1075
+ authorizeUrl.searchParams.set('code_challenge', pkce.challenge);
1076
+ authorizeUrl.searchParams.set('code_challenge_method', 'S256');
1077
+
1078
+ const authorizeResponse = await fetch(authorizeUrl, { redirect: 'manual' });
1079
+ assert.equal(authorizeResponse.status, 302);
1080
+ const authorizeLocation = authorizeResponse.headers.get('location') || '';
1081
+ const authorizeRedirect = new URL(authorizeLocation);
1082
+ const authCode = authorizeRedirect.searchParams.get('code') || '';
1083
+ assert.ok(authCode.length > 20);
1084
+ assert.equal(authorizeRedirect.searchParams.get('state'), 's123');
1085
+
1086
+ const tokenCodeResponse = await fetch(`http://127.0.0.1:${oauthServerPort}/oauth/token`, {
1087
+ method: 'POST',
1088
+ headers: {
1089
+ 'content-type': 'application/x-www-form-urlencoded',
1090
+ },
1091
+ body: new URLSearchParams({
1092
+ grant_type: 'authorization_code',
1093
+ code: authCode,
1094
+ redirect_uri: 'http://127.0.0.1/callback',
1095
+ client_id: 'web',
1096
+ client_secret: 'secret',
1097
+ code_verifier: pkce.verifier,
1098
+ }),
1099
+ });
1100
+ assert.equal(tokenCodeResponse.status, 200);
1101
+ const tokenCodeJson = await tokenCodeResponse.json();
1102
+ assert.ok(typeof tokenCodeJson.access_token === 'string' && tokenCodeJson.access_token.length > 20);
1103
+ assert.ok(typeof tokenCodeJson.refresh_token === 'string' && tokenCodeJson.refresh_token.length > 20);
1104
+
1105
+ const oauthProtectedWithCodeToken = await fetch(`http://127.0.0.1:${oauthServerPort}/oauth/server/protected`, {
1106
+ headers: {
1107
+ authorization: `Bearer ${tokenCodeJson.access_token}`,
1108
+ },
1109
+ });
1110
+ assert.equal(oauthProtectedWithCodeToken.status, 200);
1111
+ const oauthProtectedWithCodeTokenJson = await oauthProtectedWithCodeToken.json();
1112
+ assert.equal(oauthProtectedWithCodeTokenJson.sub, 'u1');
1113
+ assert.equal(oauthProtectedWithCodeTokenJson.clientId, 'web');
1114
+
1115
+ const webBasic = `Basic ${Buffer.from('web:secret').toString('base64')}`;
1116
+ const introspectCodeTokenResponse = await fetch(`http://127.0.0.1:${oauthServerPort}/oauth/introspect`, {
1117
+ method: 'POST',
1118
+ headers: {
1119
+ authorization: webBasic,
1120
+ 'content-type': 'application/x-www-form-urlencoded',
1121
+ },
1122
+ body: new URLSearchParams({
1123
+ token: tokenCodeJson.access_token,
1124
+ }),
1125
+ });
1126
+ assert.equal(introspectCodeTokenResponse.status, 200);
1127
+ const introspectCodeTokenJson = await introspectCodeTokenResponse.json();
1128
+ assert.equal(introspectCodeTokenJson.active, true);
1129
+ assert.equal(introspectCodeTokenJson.client_id, 'web');
1130
+
1131
+ const refreshExchangeResponse = await fetch(`http://127.0.0.1:${oauthServerPort}/oauth/token`, {
1132
+ method: 'POST',
1133
+ headers: {
1134
+ authorization: webBasic,
1135
+ 'content-type': 'application/x-www-form-urlencoded',
1136
+ },
1137
+ body: new URLSearchParams({
1138
+ grant_type: 'refresh_token',
1139
+ refresh_token: tokenCodeJson.refresh_token,
1140
+ }),
1141
+ });
1142
+ assert.equal(refreshExchangeResponse.status, 200);
1143
+ const refreshExchangeJson = await refreshExchangeResponse.json();
1144
+ assert.ok(typeof refreshExchangeJson.access_token === 'string' && refreshExchangeJson.access_token.length > 20);
1145
+
1146
+ const revokeResponse = await fetch(`http://127.0.0.1:${oauthServerPort}/oauth/revoke`, {
1147
+ method: 'POST',
1148
+ headers: {
1149
+ authorization: webBasic,
1150
+ 'content-type': 'application/x-www-form-urlencoded',
1151
+ },
1152
+ body: new URLSearchParams({
1153
+ token: refreshExchangeJson.access_token,
1154
+ }),
1155
+ });
1156
+ assert.equal(revokeResponse.status, 200);
1157
+
1158
+ const introspectRevokedResponse = await fetch(`http://127.0.0.1:${oauthServerPort}/oauth/introspect`, {
1159
+ method: 'POST',
1160
+ headers: {
1161
+ authorization: webBasic,
1162
+ 'content-type': 'application/x-www-form-urlencoded',
1163
+ },
1164
+ body: new URLSearchParams({
1165
+ token: refreshExchangeJson.access_token,
1166
+ }),
1167
+ });
1168
+ const introspectRevokedJson = await introspectRevokedResponse.json();
1169
+ assert.equal(introspectRevokedJson.active, false);
1170
+
1171
+ const machineBasic = `Basic ${Buffer.from('machine:machine-secret').toString('base64')}`;
1172
+ const clientCredentialsTokenResponse = await fetch(`http://127.0.0.1:${oauthServerPort}/oauth/token`, {
1173
+ method: 'POST',
1174
+ headers: {
1175
+ authorization: machineBasic,
1176
+ 'content-type': 'application/x-www-form-urlencoded',
1177
+ },
1178
+ body: new URLSearchParams({
1179
+ grant_type: 'client_credentials',
1180
+ scope: 'read:users',
1181
+ }),
1182
+ });
1183
+ assert.equal(clientCredentialsTokenResponse.status, 200);
1184
+ const clientCredentialsTokenJson = await clientCredentialsTokenResponse.json();
1185
+ assert.ok(typeof clientCredentialsTokenJson.access_token === 'string' && clientCredentialsTokenJson.access_token.length > 20);
1186
+ assert.equal(Object.prototype.hasOwnProperty.call(clientCredentialsTokenJson, 'refresh_token'), false);
1187
+
1188
+ const oauthProtectedWithMachineToken = await fetch(`http://127.0.0.1:${oauthServerPort}/oauth/server/protected`, {
1189
+ headers: {
1190
+ authorization: `Bearer ${clientCredentialsTokenJson.access_token}`,
1191
+ },
1192
+ });
1193
+ assert.equal(oauthProtectedWithMachineToken.status, 200);
1194
+ const oauthProtectedWithMachineTokenJson = await oauthProtectedWithMachineToken.json();
1195
+ assert.equal(oauthProtectedWithMachineTokenJson.sub, null);
1196
+ assert.equal(oauthProtectedWithMachineTokenJson.clientId, 'machine');
1197
+ await kernelWithOAuthServer.stop();
1198
+
1199
+ const authLogger = createSilentLogger();
1200
+ const fakeSqlClient = createFakeQueryMeshSqlClient();
1201
+ const authDatabaseConfig = normalizeAuthConfig({
1202
+ enabled: true,
1203
+ provider: 'oauth2',
1204
+ tablePrefix: 'aegisnode',
1205
+ storage: {
1206
+ driver: 'database',
1207
+ },
1208
+ }, {
1209
+ appName: 'blog',
1210
+ appSecret: '0123456789abcdef0123456789abcdef',
1211
+ });
1212
+
1213
+ const authWithDbStorage = createAuthManager({
1214
+ config: authDatabaseConfig,
1215
+ cache: null,
1216
+ logger: authLogger,
1217
+ rootDir: projectRoot,
1218
+ database: {
1219
+ type: 'sql',
1220
+ client: fakeSqlClient,
1221
+ },
1222
+ });
1223
+ await authWithDbStorage.ready;
1224
+ authWithDbStorage.registerClient({
1225
+ clientId: 'api',
1226
+ clientSecret: 'secret',
1227
+ });
1228
+ const dbStorageTokens = authWithDbStorage.issue({
1229
+ clientId: 'api',
1230
+ clientSecret: 'secret',
1231
+ subject: 'u-db',
1232
+ scope: ['read:db'],
1233
+ });
1234
+ assert.equal(authWithDbStorage.introspect(dbStorageTokens.accessToken).active, true);
1235
+ await authWithDbStorage.close();
1236
+
1237
+ const authWithDbStorageRestarted = createAuthManager({
1238
+ config: authDatabaseConfig,
1239
+ cache: null,
1240
+ logger: authLogger,
1241
+ rootDir: projectRoot,
1242
+ database: {
1243
+ type: 'sql',
1244
+ client: fakeSqlClient,
1245
+ },
1246
+ });
1247
+ await authWithDbStorageRestarted.ready;
1248
+ assert.equal(authWithDbStorageRestarted.introspect(dbStorageTokens.accessToken).active, true);
1249
+ assert.ok(fakeSqlClient.dump('aegisnode_auth_store').length > 0);
1250
+ await authWithDbStorageRestarted.close();
1251
+
1252
+ await fs.writeFile(
1253
+ path.join(projectRoot, 'routes.js'),
1254
+ `export default {\n register(route) {\n route.get('/', (req, res) => {\n res.send('custom-home');\n });\n },\n};\n`,
1255
+ 'utf8',
1256
+ );
1257
+
1258
+ const kernelWithCustomHome = await createKernel({
1259
+ rootDir: projectRoot,
1260
+ overrides: {
1261
+ host: '127.0.0.1',
1262
+ port: 0,
1263
+ security: {
1264
+ headers: {
1265
+ csp: {
1266
+ enabled: false,
1267
+ },
1268
+ },
1269
+ },
1270
+ },
1271
+ });
1272
+
1273
+ await kernelWithCustomHome.start();
1274
+ const customAddress = kernelWithCustomHome.context.server.address();
1275
+ const customPort = typeof customAddress === 'object' && customAddress ? customAddress.port : 0;
1276
+ const customHomeResponse = await fetch(`http://127.0.0.1:${customPort}/`);
1277
+ const customHomeText = await customHomeResponse.text();
1278
+ assert.equal(customHomeText, 'custom-home');
1279
+ assert.equal(customHomeResponse.headers.get('x-content-type-options'), 'nosniff');
1280
+ assert.equal(customHomeResponse.headers.get('content-security-policy'), null);
1281
+ await kernelWithCustomHome.stop();
1282
+
1283
+ await fs.mkdir(path.join(projectRoot, 'templates'), { recursive: true });
1284
+ await fs.writeFile(
1285
+ path.join(projectRoot, 'templates', 'csrf-form.ejs'),
1286
+ '<form method="post" action="/submit"><%= csrfToken %></form>\n',
1287
+ 'utf8',
1288
+ );
1289
+ await fs.writeFile(
1290
+ 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`,
1292
+ 'utf8',
1293
+ );
1294
+
1295
+ const kernelWithCsrf = await createKernel({
1296
+ rootDir: projectRoot,
1297
+ overrides: {
1298
+ host: '127.0.0.1',
1299
+ port: 0,
1300
+ },
1301
+ });
1302
+
1303
+ await kernelWithCsrf.start();
1304
+ const csrfAddress = kernelWithCsrf.context.server.address();
1305
+ const csrfPort = typeof csrfAddress === 'object' && csrfAddress ? csrfAddress.port : 0;
1306
+
1307
+ const csrfTokenResponse = await fetch(`http://127.0.0.1:${csrfPort}/csrf-token`);
1308
+ const csrfTokenJson = await csrfTokenResponse.json();
1309
+ const csrfCookieHeader = csrfTokenResponse.headers.get('set-cookie') || '';
1310
+ const csrfCookie = csrfCookieHeader.split(';')[0];
1311
+ assert.match(csrfCookie, /^_aegis_csrf=[a-f0-9]{64}\.[a-f0-9]{64}$/);
1312
+ assert.ok(typeof csrfTokenJson.token === 'string' && csrfTokenJson.token.length >= 32);
1313
+
1314
+ const missingTokenResponse = await fetch(`http://127.0.0.1:${csrfPort}/submit`, {
1315
+ method: 'POST',
1316
+ headers: {
1317
+ 'content-type': 'application/x-www-form-urlencoded',
1318
+ cookie: csrfCookie,
1319
+ },
1320
+ body: new URLSearchParams({ name: 'without-token' }),
1321
+ });
1322
+ assert.equal(missingTokenResponse.status, 403);
1323
+
1324
+ const missingJsonTokenResponse = await fetch(`http://127.0.0.1:${csrfPort}/submit`, {
1325
+ method: 'POST',
1326
+ headers: {
1327
+ 'content-type': 'application/json',
1328
+ cookie: csrfCookie,
1329
+ },
1330
+ body: JSON.stringify({ name: 'json-without-token' }),
1331
+ });
1332
+ assert.equal(missingJsonTokenResponse.status, 403);
1333
+
1334
+ const validTokenResponse = await fetch(`http://127.0.0.1:${csrfPort}/submit`, {
1335
+ method: 'POST',
1336
+ headers: {
1337
+ 'content-type': 'application/x-www-form-urlencoded',
1338
+ cookie: csrfCookie,
1339
+ },
1340
+ body: new URLSearchParams({
1341
+ _csrf: csrfTokenJson.token,
1342
+ name: 'with-token',
1343
+ }),
1344
+ });
1345
+ assert.equal(validTokenResponse.status, 200);
1346
+ const validTokenJson = await validTokenResponse.json();
1347
+ assert.equal(validTokenJson.ok, true);
1348
+ assert.equal(validTokenJson.body.name, 'with-token');
1349
+
1350
+ const validJsonTokenResponse = await fetch(`http://127.0.0.1:${csrfPort}/submit`, {
1351
+ method: 'POST',
1352
+ headers: {
1353
+ 'content-type': 'application/json',
1354
+ cookie: csrfCookie,
1355
+ 'x-csrf-token': csrfTokenJson.token,
1356
+ },
1357
+ body: JSON.stringify({ name: 'json-with-token' }),
1358
+ });
1359
+ assert.equal(validJsonTokenResponse.status, 200);
1360
+ const validJsonTokenJson = await validJsonTokenResponse.json();
1361
+ assert.equal(validJsonTokenJson.ok, true);
1362
+ assert.equal(validJsonTokenJson.body.name, 'json-with-token');
1363
+
1364
+ const csrfFormResponse = await fetch(`http://127.0.0.1:${csrfPort}/csrf-form`, {
1365
+ headers: {
1366
+ cookie: csrfCookie,
1367
+ },
1368
+ });
1369
+ const csrfFormHtml = await csrfFormResponse.text();
1370
+ assert.match(csrfFormHtml, /<input type="hidden" name="_csrf" value="[a-f0-9]{64}" \/>/);
1371
+ await kernelWithCsrf.stop();
1372
+
1373
+ await fs.writeFile(
1374
+ path.join(projectRoot, 'templates', 'helpers.ejs'),
1375
+ '<div id="money"><%= money(amount, { currency: "USD" }) %></div>\n<div id="money2"><%= helpers.money(amount, { currency: "USD" }) %></div>\n<div id="elapsed"><%= timeElapsed(past, { now }) %></div>\n<div id="jlive"><%= typeof jlive.generate === "function" %></div>\n<div id="custom"><%= formatCurrency(amount) %></div>\n<div id="class"><%= new ViewBag("Dashboard").title %></div>\n',
1376
+ 'utf8',
1377
+ );
1378
+
1379
+ await fs.writeFile(
1380
+ path.join(projectRoot, 'apps', 'users', 'views.js'),
1381
+ `class UsersView {\n constructor({ helpers, jlive }) {\n this.helpers = helpers;\n this.jlive = jlive;\n }\n\n index(req, res) {\n res.json({\n injectedMoney: this.helpers.money(55, { currency: 'USD' }),\n jliveGenerateLength: this.jlive.generate(6).length,\n });\n }\n}\n\nexport default UsersView;\n`,
1382
+ 'utf8',
1383
+ );
1384
+
1385
+ await fs.writeFile(
1386
+ path.join(projectRoot, 'routes.js'),
1387
+ `export default {\n register(route) {\n route.get('/helpers/json', (req, res) => {\n res.json({\n money: req.aegis.helpers.money(1234.5, { currency: 'USD' }),\n elapsed: req.aegis.helpers.timeElapsed(Date.now() - 90_000, { now: Date.now() }),\n elapsedShort: req.aegis.helpers.timeElapsed(Math.floor(Date.now() / 1000) - 90, true),\n progress: req.aegis.helpers.timeDifference(50, 0, 100),\n excerpt: req.aegis.helpers.breakStr('Aegis Node Framework', 10, '...', true),\n generatedSecret: req.aegis.jlive.generate(24),\n jliveAvailable: req.aegis.jlive.available,\n });\n });\n\n route.get('/helpers/request', (req, res) => {\n res.json({\n fromReq: req.aegis.helpers.money(10, { currency: 'USD' }),\n reqJliveSecretLength: req.aegis.jlive.generate(8).length,\n });\n });\n\n route.get('/helpers/controller', 'users.views.index');\n\n route.get('/helpers/view', (req, res) => {\n return res.render('helpers', {\n layout: false,\n amount: 1234.5,\n past: Date.now() - 90_000,\n now: Date.now(),\n });\n });\n },\n};\n`,
1388
+ 'utf8',
1389
+ );
1390
+
1391
+ const kernelWithHelpers = await createKernel({
1392
+ rootDir: projectRoot,
1393
+ overrides: {
1394
+ host: '127.0.0.1',
1395
+ port: 0,
1396
+ templates: {
1397
+ locals: {
1398
+ formatCurrency: (value) => `$${Number(value || 0).toFixed(2)}`,
1399
+ ViewBag: class ViewBag {
1400
+ constructor(title) {
1401
+ this.title = title;
1402
+ }
1403
+ },
1404
+ },
1405
+ },
1406
+ },
1407
+ });
1408
+
1409
+ assert.equal(typeof kernelWithHelpers.context.helpers?.money, 'function');
1410
+ assert.equal(typeof kernelWithHelpers.context.jlive?.generate, 'function');
1411
+
1412
+ await kernelWithHelpers.start();
1413
+ const helpersAddress = kernelWithHelpers.context.server.address();
1414
+ const helpersPort = typeof helpersAddress === 'object' && helpersAddress ? helpersAddress.port : 0;
1415
+
1416
+ const helpersJsonResponse = await fetch(`http://127.0.0.1:${helpersPort}/helpers/json`);
1417
+ assert.equal(helpersJsonResponse.status, 200);
1418
+ const helpersJson = await helpersJsonResponse.json();
1419
+ assert.equal(helpersJson.money, '$1,234.50');
1420
+ assert.match(helpersJson.elapsed, /(just now|minute|second|hour|day|week|month|year|ago|in )/i);
1421
+ assert.equal(typeof helpersJson.elapsedShort, 'string');
1422
+ assert.ok(helpersJson.elapsedShort.length > 0);
1423
+ assert.equal(helpersJson.progress, 50);
1424
+ assert.equal(helpersJson.excerpt, 'Aegis...');
1425
+ assert.equal(typeof helpersJson.generatedSecret, 'string');
1426
+ assert.equal(helpersJson.generatedSecret.length, 24);
1427
+ assert.equal(typeof helpersJson.jliveAvailable, 'boolean');
1428
+
1429
+ const helpersReqResponse = await fetch(`http://127.0.0.1:${helpersPort}/helpers/request`);
1430
+ assert.equal(helpersReqResponse.status, 200);
1431
+ const helpersReqJson = await helpersReqResponse.json();
1432
+ assert.equal(helpersReqJson.fromReq, '$10.00');
1433
+ assert.equal(helpersReqJson.reqJliveSecretLength, 8);
1434
+
1435
+ const helpersControllerResponse = await fetch(`http://127.0.0.1:${helpersPort}/helpers/controller`);
1436
+ assert.equal(helpersControllerResponse.status, 200);
1437
+ const helpersControllerJson = await helpersControllerResponse.json();
1438
+ assert.equal(helpersControllerJson.injectedMoney, '$55.00');
1439
+ assert.equal(helpersControllerJson.jliveGenerateLength, 6);
1440
+
1441
+ const helpersViewResponse = await fetch(`http://127.0.0.1:${helpersPort}/helpers/view`);
1442
+ assert.equal(helpersViewResponse.status, 200);
1443
+ const helpersViewHtml = await helpersViewResponse.text();
1444
+ assert.match(helpersViewHtml, /<div id="money">\$1,234\.50<\/div>/);
1445
+ assert.match(helpersViewHtml, /<div id="money2">\$1,234\.50<\/div>/);
1446
+ assert.match(helpersViewHtml, /<div id="jlive">true<\/div>/);
1447
+ assert.match(helpersViewHtml, /<div id="custom">\$1234\.50<\/div>/);
1448
+ assert.match(helpersViewHtml, /<div id="class">Dashboard<\/div>/);
1449
+ await kernelWithHelpers.stop();
1450
+
1451
+ await fs.writeFile(
1452
+ path.join(projectRoot, 'routes.js'),
1453
+ `export default {\n register(route) {\n route.get('/limited', (req, res) => {\n res.json({ ok: true });\n });\n },\n};\n`,
1454
+ 'utf8',
1455
+ );
1456
+
1457
+ const kernelWithRateLimit = await createKernel({
1458
+ rootDir: projectRoot,
1459
+ overrides: {
1460
+ host: '127.0.0.1',
1461
+ port: 0,
1462
+ security: {
1463
+ ddos: {
1464
+ windowMs: 5000,
1465
+ maxRequests: 2,
1466
+ skipPaths: [],
1467
+ },
1468
+ },
1469
+ },
1470
+ });
1471
+
1472
+ await kernelWithRateLimit.start();
1473
+ const rateLimitAddress = kernelWithRateLimit.context.server.address();
1474
+ const rateLimitPort = typeof rateLimitAddress === 'object' && rateLimitAddress ? rateLimitAddress.port : 0;
1475
+
1476
+ const limitedOne = await fetch(`http://127.0.0.1:${rateLimitPort}/limited`);
1477
+ assert.equal(limitedOne.status, 200);
1478
+ const limitedTwo = await fetch(`http://127.0.0.1:${rateLimitPort}/limited`);
1479
+ assert.equal(limitedTwo.status, 200);
1480
+ const limitedThree = await fetch(`http://127.0.0.1:${rateLimitPort}/limited`);
1481
+ assert.equal(limitedThree.status, 429);
1482
+ const limitedThreeJson = await limitedThree.json();
1483
+ assert.equal(limitedThreeJson.error, 'Too many requests, please try again later.');
1484
+ await kernelWithRateLimit.stop();
1485
+
1486
+ await fs.writeFile(
1487
+ path.join(projectRoot, 'routes.js'),
1488
+ `export default {\n register(route) {\n const authGuard = (req, res, next) => {\n if (req.headers['x-auth'] === 'ok') {\n next();\n return;\n }\n res.status(401).json({ error: 'Unauthorized' });\n };\n\n route.get('/secured', authGuard, (req, res) => {\n res.json({ ok: true });\n });\n },\n};\n`,
1489
+ 'utf8',
1490
+ );
1491
+
1492
+ const kernelWithMiddleware = await createKernel({
1493
+ rootDir: projectRoot,
1494
+ overrides: {
1495
+ host: '127.0.0.1',
1496
+ port: 0,
1497
+ },
1498
+ });
1499
+
1500
+ await kernelWithMiddleware.start();
1501
+ const middlewareAddress = kernelWithMiddleware.context.server.address();
1502
+ const middlewarePort = typeof middlewareAddress === 'object' && middlewareAddress ? middlewareAddress.port : 0;
1503
+
1504
+ const securedDenied = await fetch(`http://127.0.0.1:${middlewarePort}/secured`);
1505
+ assert.equal(securedDenied.status, 401);
1506
+
1507
+ const securedAllowed = await fetch(`http://127.0.0.1:${middlewarePort}/secured`, {
1508
+ headers: {
1509
+ 'x-auth': 'ok',
1510
+ },
1511
+ });
1512
+ assert.equal(securedAllowed.status, 200);
1513
+ const securedAllowedJson = await securedAllowed.json();
1514
+ assert.equal(securedAllowedJson.ok, true);
1515
+ await kernelWithMiddleware.stop();
1516
+
1517
+ await fs.writeFile(
1518
+ path.join(projectRoot, 'routes.js'),
1519
+ `export default {\n register(route) {\n route.post('/upload', route.upload.single('avatar'), (req, res) => {\n if (!req.file) {\n res.status(400).json({ error: 'No file uploaded' });\n return;\n }\n\n res.json({\n file: {\n name: req.file.filename,\n mimeType: req.file.mimetype,\n size: req.file.size,\n },\n });\n });\n },\n};\n`,
1520
+ 'utf8',
1521
+ );
1522
+
1523
+ const kernelWithUploads = await createKernel({
1524
+ rootDir: projectRoot,
1525
+ overrides: {
1526
+ host: '127.0.0.1',
1527
+ port: 0,
1528
+ security: {
1529
+ csrf: {
1530
+ enabled: false,
1531
+ },
1532
+ },
1533
+ uploads: {
1534
+ enabled: true,
1535
+ dir: 'storage/uploads-test',
1536
+ maxFileSize: 64,
1537
+ allowedMimeTypes: ['text/plain'],
1538
+ allowedExtensions: ['.txt'],
1539
+ },
1540
+ },
1541
+ });
1542
+
1543
+ await kernelWithUploads.start();
1544
+ const uploadAddress = kernelWithUploads.context.server.address();
1545
+ const uploadPort = typeof uploadAddress === 'object' && uploadAddress ? uploadAddress.port : 0;
1546
+
1547
+ const uploadForm = new FormData();
1548
+ uploadForm.append('avatar', new Blob(['hello-upload'], { type: 'text/plain' }), 'avatar.txt');
1549
+ const uploadOk = await fetch(`http://127.0.0.1:${uploadPort}/upload`, {
1550
+ method: 'POST',
1551
+ body: uploadForm,
1552
+ });
1553
+ assert.equal(uploadOk.status, 200);
1554
+ const uploadOkJson = await uploadOk.json();
1555
+ assert.equal(uploadOkJson.file.mimeType, 'text/plain');
1556
+ assert.equal(typeof uploadOkJson.file.name, 'string');
1557
+
1558
+ const uploadMimeForm = new FormData();
1559
+ uploadMimeForm.append('avatar', new Blob(['png-content'], { type: 'image/png' }), 'avatar.png');
1560
+ const uploadMimeRejected = await fetch(`http://127.0.0.1:${uploadPort}/upload`, {
1561
+ method: 'POST',
1562
+ body: uploadMimeForm,
1563
+ });
1564
+ assert.equal(uploadMimeRejected.status, 415);
1565
+
1566
+ const uploadSizeForm = new FormData();
1567
+ uploadSizeForm.append('avatar', new Blob(['x'.repeat(256)], { type: 'text/plain' }), 'big.txt');
1568
+ const uploadTooLarge = await fetch(`http://127.0.0.1:${uploadPort}/upload`, {
1569
+ method: 'POST',
1570
+ body: uploadSizeForm,
1571
+ });
1572
+ assert.equal(uploadTooLarge.status, 413);
1573
+ await kernelWithUploads.stop();
1574
+
1575
+ await assert.rejects(
1576
+ () => createKernel({
1577
+ rootDir: projectRoot,
1578
+ overrides: {
1579
+ host: '127.0.0.1',
1580
+ port: 0,
1581
+ security: {
1582
+ csrf: {
1583
+ enabled: false,
1584
+ },
1585
+ },
1586
+ uploads: {
1587
+ enabled: false,
1588
+ },
1589
+ },
1590
+ }),
1591
+ /Uploads are disabled/,
1592
+ );
1593
+
1594
+ const settingsFile = path.join(projectRoot, 'settings.js');
1595
+ const settingsBeforeStrict = await fs.readFile(settingsFile, 'utf8');
1596
+ const settingsWithStrictLayers = settingsBeforeStrict.replace(
1597
+ /\n\s*apps:\s*\[/,
1598
+ "\n architecture: {\n strictLayers: true,\n },\n autoMountApps: true,\n apps: [",
1599
+ );
1600
+ await fs.writeFile(settingsFile, settingsWithStrictLayers, 'utf8');
1601
+
1602
+ await fs.writeFile(
1603
+ path.join(projectRoot, 'apps', 'users', 'models.js'),
1604
+ `class UsersModel {\n constructor({ dbClient }) {\n this.dbClient = dbClient;\n }\n\n async list() {\n return [{ id: 1, name: 'alice' }];\n }\n}\n\nexport default {\n users: UsersModel,\n};\n`,
1605
+ 'utf8',
1606
+ );
1607
+ await fs.writeFile(
1608
+ path.join(projectRoot, 'apps', 'users', 'services.js'),
1609
+ `class UsersService {\n constructor({ models }) {\n this.usersModel = models.get('users');\n }\n\n async list() {\n return this.usersModel.list();\n }\n}\n\nexport default {\n users: UsersService,\n};\n`,
1610
+ 'utf8',
1611
+ );
1612
+ const strictUsersRoutes = `import UsersView from './views.js';\n\nexport default {\n appName: 'users',\n register(route) {\n route.get('/', UsersView.index);\n\n // AEGIS_APP_EXTRA_ROUTES_START\n // AEGIS_APP_EXTRA_ROUTES_END\n },\n};\n`;
1613
+ await fs.writeFile(
1614
+ path.join(projectRoot, 'apps', 'users', 'routes.js'),
1615
+ strictUsersRoutes,
1616
+ 'utf8',
1617
+ );
1618
+ const kernelWithStrictLayers = await createKernel({
1619
+ rootDir: projectRoot,
1620
+ overrides: {
1621
+ host: '127.0.0.1',
1622
+ port: 0,
1623
+ },
1624
+ });
1625
+
1626
+ await kernelWithStrictLayers.start();
1627
+ const strictAddress = kernelWithStrictLayers.context.server.address();
1628
+ const strictPort = typeof strictAddress === 'object' && strictAddress ? strictAddress.port : 0;
1629
+ const strictUsersResponse = await fetch(`http://127.0.0.1:${strictPort}/users`);
1630
+ assert.equal(strictUsersResponse.status, 200);
1631
+ const strictUsersJson = await strictUsersResponse.json();
1632
+ assert.equal(Array.isArray(strictUsersJson.data), true);
1633
+ assert.equal(strictUsersJson.data[0].name, 'alice');
1634
+ await kernelWithStrictLayers.stop();
1635
+
1636
+ await fs.appendFile(path.join(projectRoot, '.env'), '\nAEGIS_LAYER_ENV_TEST=from-dotenv\n', 'utf8');
1637
+ await fs.writeFile(
1638
+ path.join(projectRoot, 'apps', 'users', 'models.js'),
1639
+ `class UsersModel {\n constructor({ env }) {\n this.env = env;\n }\n\n async list() {\n return [{ id: 1, name: 'alice', modelEnv: this.env.AEGIS_LAYER_ENV_TEST || null }];\n }\n}\n\nexport default {\n users: UsersModel,\n};\n`,
1640
+ 'utf8',
1641
+ );
1642
+ await fs.writeFile(
1643
+ path.join(projectRoot, 'apps', 'users', 'services.js'),
1644
+ `class UsersService {\n constructor({ models, env }) {\n this.usersModel = models.get('users');\n this.env = env;\n }\n\n async list() {\n const users = await this.usersModel.list();\n return users.map((user) => ({ ...user, serviceEnv: this.env.AEGIS_LAYER_ENV_TEST || null }));\n }\n}\n\nexport default {\n users: UsersService,\n};\n`,
1645
+ 'utf8',
1646
+ );
1647
+ await fs.writeFile(
1648
+ path.join(projectRoot, 'apps', 'users', 'views.js'),
1649
+ `class UsersView {\n async index({ service, env }, req, res, next) {\n try {\n const data = await service.list();\n res.json({\n viewEnv: env.AEGIS_LAYER_ENV_TEST || null,\n data,\n });\n } catch (error) {\n next(error);\n }\n }\n}\n\nexport default UsersView;\n`,
1650
+ 'utf8',
1651
+ );
1652
+ await fs.writeFile(
1653
+ path.join(projectRoot, 'apps', 'users', 'routes.js'),
1654
+ `export default {\n appName: 'users',\n register(route) {\n route.get('/', 'users.views.index');\n },\n};\n`,
1655
+ 'utf8',
1656
+ );
1657
+
1658
+ const kernelWithInjectedEnv = await createKernel({
1659
+ rootDir: projectRoot,
1660
+ overrides: {
1661
+ host: '127.0.0.1',
1662
+ port: 0,
1663
+ },
1664
+ });
1665
+
1666
+ await kernelWithInjectedEnv.start();
1667
+ const injectedEnvAddress = kernelWithInjectedEnv.context.server.address();
1668
+ const injectedEnvPort = typeof injectedEnvAddress === 'object' && injectedEnvAddress ? injectedEnvAddress.port : 0;
1669
+ const injectedEnvResponse = await fetch(`http://127.0.0.1:${injectedEnvPort}/users`);
1670
+ assert.equal(injectedEnvResponse.status, 200);
1671
+ const injectedEnvJson = await injectedEnvResponse.json();
1672
+ assert.equal(injectedEnvJson.viewEnv, 'from-dotenv');
1673
+ assert.equal(injectedEnvJson.data[0].serviceEnv, 'from-dotenv');
1674
+ assert.equal(injectedEnvJson.data[0].modelEnv, 'from-dotenv');
1675
+ await kernelWithInjectedEnv.stop();
1676
+
1677
+ await fs.writeFile(
1678
+ path.join(projectRoot, 'apps', 'users', 'routes.js'),
1679
+ `import Models from './models.js';\n\nexport default {\n appName: 'users',\n register(route) {\n route.get('/', async (req, res, next) => {\n try {\n const model = new Models.users({ dbClient: null });\n const users = await model.list();\n res.json({ users });\n } catch (error) {\n next(error);\n }\n });\n },\n};\n`,
1680
+ 'utf8',
1681
+ );
1682
+ await assert.rejects(
1683
+ () => createKernel({
1684
+ rootDir: projectRoot,
1685
+ overrides: {
1686
+ host: '127.0.0.1',
1687
+ port: 0,
1688
+ },
1689
+ }),
1690
+ /strictLayers.*Routes in app "users" must call services only/,
1691
+ );
1692
+ await fs.writeFile(path.join(projectRoot, 'apps', 'users', 'routes.js'), strictUsersRoutes, 'utf8');
1693
+
1694
+ const currentSettings = await fs.readFile(settingsFile, 'utf8');
1695
+ const updatedSettings = currentSettings.replace(
1696
+ /\n\s*apps:\s*\[/,
1697
+ "\n templates: {\n dir: 'ui',\n },\n i18n: {\n enabled: true,\n defaultLocale: 'en',\n fallbackLocale: 'en',\n supported: ['en', 'fr'],\n translations: {\n en: 'locales/en.json',\n fr: 'locales/fr.json',\n },\n },\n apps: [",
1698
+ );
1699
+ await fs.writeFile(settingsFile, updatedSettings, 'utf8');
1700
+
1701
+ await fs.mkdir(path.join(projectRoot, 'locales'), { recursive: true });
1702
+ await fs.writeFile(
1703
+ path.join(projectRoot, 'locales', 'en.json'),
1704
+ JSON.stringify({
1705
+ home: {
1706
+ pageTitle: 'Template Test',
1707
+ title: 'Welcome {name}',
1708
+ },
1709
+ }, null, 2),
1710
+ 'utf8',
1711
+ );
1712
+ await fs.writeFile(
1713
+ path.join(projectRoot, 'locales', 'fr.json'),
1714
+ JSON.stringify({
1715
+ home: {
1716
+ pageTitle: 'Test de template',
1717
+ title: 'Bienvenue {name}',
1718
+ },
1719
+ }, null, 2),
1720
+ 'utf8',
1721
+ );
1722
+
1723
+ await fs.mkdir(path.join(projectRoot, 'ui'), { recursive: true });
1724
+ await fs.writeFile(
1725
+ path.join(projectRoot, 'ui', 'base.ejs'),
1726
+ '<!doctype html><html><head><title><%= title %></title></head><body><main><%- content %></main></body></html>\n',
1727
+ 'utf8',
1728
+ );
1729
+ await fs.writeFile(
1730
+ path.join(projectRoot, 'ui', 'home.ejs'),
1731
+ '<h1><%= t("home.title", { name: "Aegis" }) %></h1>\n<p><%= locale %></p>\n',
1732
+ 'utf8',
1733
+ );
1734
+ await fs.writeFile(
1735
+ path.join(projectRoot, 'apps', 'users', 'models.js'),
1736
+ `class UsersModel {\n constructor({ i18n }) {\n this.i18n = i18n;\n }\n\n greeting(name) {\n return this.i18n.t('home.title', { name });\n }\n}\n\nexport default {\n users: UsersModel,\n};\n`,
1737
+ 'utf8',
1738
+ );
1739
+ await fs.writeFile(
1740
+ path.join(projectRoot, 'apps', 'users', 'services.js'),
1741
+ `class UsersService {\n constructor({ models, i18n }) {\n this.usersModel = models.get('users');\n this.i18n = i18n;\n }\n\n greetings() {\n return {\n service: this.i18n.t('home.title', { name: 'Service' }),\n model: this.usersModel.greeting('Model'),\n };\n }\n}\n\nexport default {\n users: UsersService,\n};\n`,
1742
+ 'utf8',
1743
+ );
1744
+ await fs.writeFile(
1745
+ path.join(projectRoot, 'apps', 'users', 'validators.js'),
1746
+ `class UsersValidator {\n constructor({ i18n }) {\n this.i18n = i18n;\n }\n\n greeting(name) {\n return this.i18n.t('home.title', { name });\n }\n}\n\nexport default {\n users: UsersValidator,\n};\n`,
1747
+ 'utf8',
1748
+ );
1749
+ await fs.writeFile(
1750
+ path.join(projectRoot, 'apps', 'users', 'subscribers.js'),
1751
+ `export default function registerUsersSubscribers({ appName, cache, events, i18n }) {\n events.subscribe('app.booted', ({ appName: bootedAppName }) => {\n if (bootedAppName !== appName) {\n return;\n }\n\n cache.set('users.subscriber.greeting', i18n.t('home.title', { name: 'Subscriber' }));\n });\n}\n`,
1752
+ 'utf8',
1753
+ );
1754
+ await fs.writeFile(
1755
+ path.join(projectRoot, 'routes.js'),
1756
+ `export default {\n register(route) {\n route.get('/i18n/json', (req, res) => {\n res.json({\n locale: req.aegis.locale,\n hello: req.aegis.t('home.title', { name: 'Aegis' }),\n });\n });\n\n route.get('/i18n/layers', (req, res) => {\n const usersService = req.aegis.services.forApp('users').get('users');\n const layers = usersService.greetings();\n res.json({\n locale: req.aegis.locale,\n requestHello: req.aegis.i18n.t('home.title', { name: 'Request' }),\n serviceHello: layers.service,\n modelHello: layers.model,\n });\n });\n\n route.get('/i18n/injected', ({ cache, i18n, validators }, req, res, next) => {\n try {\n const usersValidator = validators.forApp('users').get('users');\n res.json({\n locale: req.aegis.locale,\n viewHello: i18n.t('home.title', { name: 'View' }),\n validatorHello: usersValidator.greeting('Validator'),\n subscriberHello: cache.get('users.subscriber.greeting'),\n });\n } catch (error) {\n next(error);\n }\n });\n\n route.get('/', (req, res) => {\n return res.render('home', {\n title: req.aegis.t('home.pageTitle'),\n });\n });\n },\n};\n`,
1757
+ 'utf8',
1758
+ );
1759
+
1760
+ const kernelWithTemplates = await createKernel({
1761
+ rootDir: projectRoot,
1762
+ overrides: {
1763
+ host: '127.0.0.1',
1764
+ port: 0,
1765
+ },
1766
+ });
1767
+
1768
+ await kernelWithTemplates.start();
1769
+ const templateAddress = kernelWithTemplates.context.server.address();
1770
+ const templatePort = typeof templateAddress === 'object' && templateAddress ? templateAddress.port : 0;
1771
+ const directLayerGreetings = kernelWithTemplates.context.services.forApp('users').get('users').greetings();
1772
+ assert.equal(directLayerGreetings.service, 'Welcome Service');
1773
+ assert.equal(directLayerGreetings.model, 'Welcome Model');
1774
+ const templateResponse = await fetch(`http://127.0.0.1:${templatePort}/`, {
1775
+ headers: {
1776
+ 'accept-language': 'fr-FR,fr;q=0.9,en;q=0.8',
1777
+ },
1778
+ });
1779
+ const templateHtml = await templateResponse.text();
1780
+ assert.match(templateHtml, /<title>Test de template<\/title>/);
1781
+ assert.match(templateHtml, /Bienvenue Aegis/);
1782
+ assert.match(templateHtml, /<p>fr<\/p>/);
1783
+
1784
+ const i18nJsonResponse = await fetch(`http://127.0.0.1:${templatePort}/i18n/json?lang=fr`);
1785
+ assert.equal(i18nJsonResponse.status, 200);
1786
+ const i18nJson = await i18nJsonResponse.json();
1787
+ assert.equal(i18nJson.locale, 'fr');
1788
+ assert.equal(i18nJson.hello, 'Bienvenue Aegis');
1789
+
1790
+ const i18nLayerResponse = await fetch(`http://127.0.0.1:${templatePort}/i18n/layers?lang=fr`);
1791
+ assert.equal(i18nLayerResponse.status, 200);
1792
+ const i18nLayers = await i18nLayerResponse.json();
1793
+ assert.equal(i18nLayers.locale, 'fr');
1794
+ assert.equal(i18nLayers.requestHello, 'Bienvenue Request');
1795
+ assert.equal(i18nLayers.serviceHello, 'Bienvenue Service');
1796
+ assert.equal(i18nLayers.modelHello, 'Bienvenue Model');
1797
+
1798
+ const i18nInjectedResponse = await fetch(`http://127.0.0.1:${templatePort}/i18n/injected?lang=fr`);
1799
+ assert.equal(i18nInjectedResponse.status, 200);
1800
+ const i18nInjected = await i18nInjectedResponse.json();
1801
+ assert.equal(i18nInjected.locale, 'fr');
1802
+ assert.equal(i18nInjected.viewHello, 'Bienvenue View');
1803
+ assert.equal(i18nInjected.validatorHello, 'Bienvenue Validator');
1804
+ assert.equal(i18nInjected.subscriberHello, 'Welcome Subscriber');
1805
+ await kernelWithTemplates.stop();
1806
+
1807
+ const kernelFromParent = await runServer({
1808
+ projectRoot: sandboxRoot,
1809
+ port: 0,
1810
+ });
1811
+
1812
+ const parentAddress = kernelFromParent.context.server.address();
1813
+ const parentPort = typeof parentAddress === 'object' && parentAddress ? parentAddress.port : 0;
1814
+ const parentRootResponse = await fetch(`http://127.0.0.1:${parentPort}/`);
1815
+ const parentRootText = await parentRootResponse.text();
1816
+ assert.match(parentRootText, /Welcome Aegis/);
1817
+ await kernelFromParent.stop();
1818
+
1819
+ assert.ok(true, 'Smoke test completed');
1820
+
1821
+ await fs.rm(sandboxRoot, { recursive: true, force: true });
1822
+ await fs.rm(envSandboxRoot, { recursive: true, force: true });
1823
+ await fs.rm(dotenvSandboxRoot, { recursive: true, force: true });
1824
+ await fs.rm(httpsSandboxRoot, { recursive: true, force: true });
1825
+ await fs.rm(proxySandboxRoot, { recursive: true, force: true });
1826
+ }
1827
+
1828
+ main().catch((error) => {
1829
+ console.error(error);
1830
+ process.exit(1);
1831
+ });