aegisnode 0.0.3 → 0.0.5

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,65 @@
1
+ import path from 'path';
2
+ import { ensureValidName, normalizeMountPath } from '../utils/fs.js';
3
+ import { resolveProjectRoot } from '../utils/project.js';
4
+ import {
5
+ detectSettingsMode,
6
+ ensureAppScaffold,
7
+ readAppsConfig,
8
+ updateAppRegistry,
9
+ updateProjectRoutesFile,
10
+ } from '../utils/apps.js';
11
+
12
+ export async function runFixApp({ appName, projectRoot, mount } = {}) {
13
+ if (!appName) {
14
+ throw new Error('Missing app name. Usage: aegisnode fix --app <app-name>');
15
+ }
16
+
17
+ ensureValidName(appName, 'app');
18
+
19
+ const resolvedRoot = await resolveProjectRoot(projectRoot || process.cwd());
20
+ const settingsMode = await detectSettingsMode(resolvedRoot);
21
+ const existingApps = await readAppsConfig(settingsMode);
22
+ const existingApp = existingApps.find((entry) => entry.name === appName) || null;
23
+ const appMount = existingApp?.mount || normalizeMountPath(mount || `/${appName}`);
24
+
25
+ const scaffoldResult = await ensureAppScaffold(resolvedRoot, appName, { overwrite: false });
26
+
27
+ let registryUpdated = false;
28
+ if (!existingApp) {
29
+ await updateAppRegistry(
30
+ resolvedRoot,
31
+ [...existingApps, { name: appName, mount: appMount }],
32
+ settingsMode,
33
+ );
34
+ registryUpdated = true;
35
+ }
36
+
37
+ const routesResult = await updateProjectRoutesFile(resolvedRoot, appName, appMount);
38
+ const relativeWritten = scaffoldResult.written.map((target) => path.relative(resolvedRoot, target));
39
+
40
+ if (relativeWritten.length === 0 && !registryUpdated && !routesResult.updatedImport && !routesResult.updatedRoute) {
41
+ console.log(`App "${appName}" is already complete.`);
42
+ } else {
43
+ console.log(`App "${appName}" repaired at ${resolvedRoot}/apps/${appName}`);
44
+ if (relativeWritten.length > 0) {
45
+ console.log(`Created missing files: ${relativeWritten.join(', ')}`);
46
+ }
47
+ if (registryUpdated) {
48
+ console.log(`Added app "${appName}" to settings.apps with mount ${appMount}`);
49
+ }
50
+ if (routesResult.updatedImport || routesResult.updatedRoute) {
51
+ console.log(`Updated routes.js registration for app "${appName}"`);
52
+ }
53
+ }
54
+
55
+ return {
56
+ rootDir: resolvedRoot,
57
+ appName,
58
+ mount: appMount,
59
+ createdFiles: scaffoldResult.written,
60
+ skippedFiles: scaffoldResult.skipped,
61
+ registryUpdated,
62
+ routesUpdated: routesResult.updatedImport || routesResult.updatedRoute,
63
+ routesFile: routesResult.routesFile,
64
+ };
65
+ }
@@ -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,8 @@ 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';
8
+ import { runFixApp } from './commands/fixapp.js';
7
9
 
8
10
  function printHelp() {
9
11
  console.log(`AegisNode CLI
@@ -11,9 +13,11 @@ function printHelp() {
11
13
  Usage:
12
14
  aegisnode startproject <project-name>
13
15
  aegisnode createapp <app-name> [--project <path>] [--mount </path>]
16
+ aegisnode fix [--app <app-name>] [--project <path>]
14
17
  aegisnode generate <type> <name> --app <app-name> [--project <path>]
15
18
  aegisnode runserver [--project <path>] [--port <number>]
16
- aegisnode doctor [--project <path>]
19
+ aegisnode generateloader [--project <path>]
20
+ aegisnode doctor [--app <app-name>] [--project <path>]
17
21
  aegisnode updatedeps [--project <path>]
18
22
 
19
23
  Examples:
@@ -22,8 +26,11 @@ Examples:
22
26
  npm install
23
27
  aegisnode runserver
24
28
  aegisnode createapp users
29
+ aegisnode fix --app users
25
30
  aegisnode generate view user --app users
26
31
  aegisnode generate validator user --app users
32
+ aegisnode generateloader --project blog
33
+ aegisnode doctor --app users --project blog
27
34
  aegisnode updatedeps --project blog
28
35
  `);
29
36
  }
@@ -120,8 +127,32 @@ export async function runCli(argv) {
120
127
  }
121
128
 
122
129
  case 'doctor': {
130
+ if (positional.length > 0) {
131
+ throw new Error('Doctor app target must use --app <app-name>. Usage: aegisnode doctor [--app <app-name>] [--project <path>]');
132
+ }
123
133
  await runDoctor({
124
134
  projectRoot: flags.project ? String(flags.project) : process.cwd(),
135
+ appName: flags.app ? String(flags.app) : null,
136
+ });
137
+ return;
138
+ }
139
+
140
+ case 'fix':
141
+ case 'fixapp': {
142
+ if (positional.length > 0) {
143
+ throw new Error('Fix app target must use --app <app-name>. Usage: aegisnode fix [--app <app-name>] [--project <path>]');
144
+ }
145
+ await runFixApp({
146
+ appName: flags.app ? String(flags.app) : undefined,
147
+ projectRoot: flags.project ? String(flags.project) : process.cwd(),
148
+ });
149
+ return;
150
+ }
151
+
152
+ case 'generateloader':
153
+ case 'loader': {
154
+ await runGenerateLoader({
155
+ projectRoot: flags.project ? String(flags.project) : process.cwd(),
125
156
  });
126
157
  return;
127
158
  }
@@ -0,0 +1,253 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ import { pathToFileURL } from 'url';
4
+ import { ensureDir, ensureValidName, exists, normalizeMountPath, writeFile } from './fs.js';
5
+ import {
6
+ renderAppModelTest,
7
+ renderAppModelsFile,
8
+ renderAppRoutes,
9
+ renderAppRoutesTest,
10
+ renderAppServiceTest,
11
+ renderAppServicesFile,
12
+ renderAppSubscribersFile,
13
+ renderAppUtilsFile,
14
+ renderAppValidatorTest,
15
+ renderAppValidatorsFile,
16
+ renderAppViewsFile,
17
+ renderSettingsApps,
18
+ } from './scaffolds.js';
19
+
20
+ export const APPS_START = '// AEGIS_APPS_START';
21
+ export const APPS_END = '// AEGIS_APPS_END';
22
+
23
+ function renderAppEntries(apps) {
24
+ return apps
25
+ .map((app) => ` { name: ${JSON.stringify(app.name)}, mount: ${JSON.stringify(app.mount)} },`)
26
+ .join('\n');
27
+ }
28
+
29
+ function escapeRegExp(value) {
30
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
31
+ }
32
+
33
+ export function toImportName(appName) {
34
+ const safe = appName
35
+ .split(/[-_\s]+/)
36
+ .filter(Boolean)
37
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
38
+ .join('');
39
+
40
+ if (!safe) {
41
+ return 'appRoutes';
42
+ }
43
+
44
+ return `${safe.charAt(0).toLowerCase()}${safe.slice(1)}`;
45
+ }
46
+
47
+ async function readDefaultExport(filePath) {
48
+ const moduleUrl = `${pathToFileURL(filePath).href}?t=${Date.now()}`;
49
+ const loaded = await import(moduleUrl);
50
+ return loaded?.default;
51
+ }
52
+
53
+ export async function detectSettingsMode(projectRoot) {
54
+ const single = path.join(projectRoot, 'settings.js');
55
+ const split = path.join(projectRoot, 'settings', 'apps.js');
56
+
57
+ if (await exists(single)) {
58
+ return { mode: 'single', file: single };
59
+ }
60
+
61
+ if (await exists(split)) {
62
+ return { mode: 'split', file: split };
63
+ }
64
+
65
+ throw new Error(`Not an AegisNode project root: missing ${single} (or legacy ${split})`);
66
+ }
67
+
68
+ export async function readAppsConfig(settingsMode) {
69
+ const normalizeApp = (entry) => {
70
+ if (!entry || typeof entry !== 'object' || typeof entry.name !== 'string') {
71
+ return null;
72
+ }
73
+
74
+ ensureValidName(entry.name, 'app');
75
+ return {
76
+ name: entry.name,
77
+ mount: normalizeMountPath(entry.mount || `/${entry.name}`),
78
+ };
79
+ };
80
+
81
+ if (settingsMode.mode === 'single') {
82
+ const settings = await readDefaultExport(settingsMode.file);
83
+ const apps = settings?.apps;
84
+
85
+ if (!Array.isArray(apps)) {
86
+ throw new Error(`settings.js must export { apps: [] }. File: ${settingsMode.file}`);
87
+ }
88
+
89
+ return apps.map(normalizeApp).filter(Boolean);
90
+ }
91
+
92
+ const apps = await readDefaultExport(settingsMode.file);
93
+ if (!Array.isArray(apps)) {
94
+ throw new Error(`settings/apps.js must export an array. File: ${settingsMode.file}`);
95
+ }
96
+
97
+ return apps.map(normalizeApp).filter(Boolean);
98
+ }
99
+
100
+ async function updateSingleSettingsApps(settingsFile, apps) {
101
+ const current = await fs.readFile(settingsFile, 'utf8');
102
+
103
+ if (!current.includes(APPS_START) || !current.includes(APPS_END)) {
104
+ throw new Error(`settings.js is missing ${APPS_START}/${APPS_END} markers: ${settingsFile}`);
105
+ }
106
+
107
+ const replacement = `${APPS_START}\n${renderAppEntries(apps)}\n ${APPS_END}`;
108
+ const updated = current.replace(/\/\/ AEGIS_APPS_START[\s\S]*?\/\/ AEGIS_APPS_END/m, replacement);
109
+
110
+ await writeFile(settingsFile, updated);
111
+ }
112
+
113
+ export async function updateAppRegistry(projectRoot, apps, settingsMode) {
114
+ if (settingsMode.mode === 'single') {
115
+ await updateSingleSettingsApps(settingsMode.file, apps);
116
+ return;
117
+ }
118
+
119
+ await writeFile(path.join(projectRoot, 'settings', 'apps.js'), renderSettingsApps(apps));
120
+ }
121
+
122
+ export function getAppScaffoldEntries(appName) {
123
+ const appRoot = path.join('apps', appName);
124
+ return [
125
+ {
126
+ target: path.join(appRoot, 'views.js'),
127
+ content: renderAppViewsFile(appName),
128
+ },
129
+ {
130
+ target: path.join(appRoot, 'models.js'),
131
+ content: renderAppModelsFile(appName),
132
+ },
133
+ {
134
+ target: path.join(appRoot, 'validators.js'),
135
+ content: renderAppValidatorsFile(appName),
136
+ },
137
+ {
138
+ target: path.join(appRoot, 'services.js'),
139
+ content: renderAppServicesFile(appName),
140
+ },
141
+ {
142
+ target: path.join(appRoot, 'utils.js'),
143
+ content: renderAppUtilsFile(),
144
+ },
145
+ {
146
+ target: path.join(appRoot, 'subscribers.js'),
147
+ content: renderAppSubscribersFile(appName),
148
+ },
149
+ {
150
+ target: path.join(appRoot, 'routes.js'),
151
+ content: renderAppRoutes(appName),
152
+ },
153
+ {
154
+ target: path.join(appRoot, 'tests', 'models.test.js'),
155
+ content: renderAppModelTest(appName),
156
+ },
157
+ {
158
+ target: path.join(appRoot, 'tests', 'validators.test.js'),
159
+ content: renderAppValidatorTest(appName),
160
+ },
161
+ {
162
+ target: path.join(appRoot, 'tests', 'services.test.js'),
163
+ content: renderAppServiceTest(appName),
164
+ },
165
+ {
166
+ target: path.join(appRoot, 'tests', 'routes.test.js'),
167
+ content: renderAppRoutesTest(appName),
168
+ },
169
+ ];
170
+ }
171
+
172
+ export async function ensureAppScaffold(projectRoot, appName, { overwrite = false } = {}) {
173
+ ensureValidName(appName, 'app');
174
+
175
+ const appRoot = path.join(projectRoot, 'apps', appName);
176
+ await ensureDir(appRoot);
177
+ await ensureDir(path.join(appRoot, 'tests'));
178
+
179
+ const written = [];
180
+ const skipped = [];
181
+
182
+ for (const entry of getAppScaffoldEntries(appName)) {
183
+ const target = path.join(projectRoot, entry.target);
184
+ if (!overwrite && await exists(target)) {
185
+ skipped.push(target);
186
+ continue;
187
+ }
188
+
189
+ await writeFile(target, entry.content);
190
+ written.push(target);
191
+ }
192
+
193
+ return {
194
+ appRoot,
195
+ written,
196
+ skipped,
197
+ };
198
+ }
199
+
200
+ export async function updateProjectRoutesFile(projectRoot, appName, mountPath) {
201
+ const routesFile = path.join(projectRoot, 'routes.js');
202
+ if (!(await exists(routesFile))) {
203
+ // Keep backward compatibility for legacy projects that still use routes/index.js.
204
+ return {
205
+ routesFile,
206
+ updatedImport: false,
207
+ updatedRoute: false,
208
+ skipped: true,
209
+ };
210
+ }
211
+
212
+ const importName = toImportName(appName);
213
+ const importLine = `import ${importName} from './apps/${appName}/routes.js';`;
214
+ const routeLine = ` route.use(${JSON.stringify(mountPath)}, ${importName});`;
215
+ const routePattern = new RegExp(`route\\.use\\([^\\n]*,\\s*${escapeRegExp(importName)}\\s*\\);`);
216
+
217
+ let content = await fs.readFile(routesFile, 'utf8');
218
+ let updatedImport = false;
219
+ let updatedRoute = false;
220
+
221
+ if (!content.includes(importLine)) {
222
+ if (content.includes('// AEGIS_APP_IMPORTS_START') && content.includes('// AEGIS_APP_IMPORTS_END')) {
223
+ content = content.replace('// AEGIS_APP_IMPORTS_END', `${importLine}\n// AEGIS_APP_IMPORTS_END`);
224
+ } else {
225
+ content = `${importLine}\n${content}`;
226
+ }
227
+ updatedImport = true;
228
+ }
229
+
230
+ if (!routePattern.test(content)) {
231
+ if (content.includes('// AEGIS_PROJECT_APP_ROUTES_START') && content.includes('// AEGIS_PROJECT_APP_ROUTES_END')) {
232
+ content = content.replace(' // AEGIS_PROJECT_APP_ROUTES_END', `${routeLine}\n // AEGIS_PROJECT_APP_ROUTES_END`);
233
+ updatedRoute = true;
234
+ } else {
235
+ const match = content.match(/register\s*\([^)]*\)\s*{[\s\S]*?\n\s*}/m);
236
+ if (match) {
237
+ content = content.replace(match[0], `${match[0]}\n${routeLine}`);
238
+ updatedRoute = true;
239
+ }
240
+ }
241
+ }
242
+
243
+ if (updatedImport || updatedRoute) {
244
+ await writeFile(routesFile, content);
245
+ }
246
+
247
+ return {
248
+ routesFile,
249
+ updatedImport,
250
+ updatedRoute,
251
+ skipped: false,
252
+ };
253
+ }
@@ -53,7 +53,7 @@ export function renderProjectLoaderCjs() {
53
53
  `;
54
54
  }
55
55
 
56
- export function renderProjectSettings(projectName, apps) {
56
+ export function renderProjectSettings(projectName, apps, appSecret = '') {
57
57
  return `export default {
58
58
  appName: '${projectName}',
59
59
  env: process.env.NODE_ENV || 'development',
@@ -61,8 +61,9 @@ export function renderProjectSettings(projectName, apps) {
61
61
  port: process.env.PORT ? Number(process.env.PORT) : 3000,
62
62
  trustProxy: false,
63
63
  security: {
64
- // Loaded from .env by default. Replace or rotate APP_SECRET in production.
65
- appSecret: process.env.APP_SECRET || '',
64
+ // Loaded from .env by default. Scaffold also embeds the generated secret as a fallback.
65
+ // Replace or rotate APP_SECRET in production.
66
+ appSecret: process.env.APP_SECRET || ${JSON.stringify(appSecret)},
66
67
  },
67
68
  logging: {
68
69
  level: process.env.LOG_LEVEL || 'info',
@@ -402,6 +403,19 @@ export default {
402
403
  `;
403
404
  }
404
405
 
406
+ export function renderAppUtilsFile() {
407
+ return `/**
408
+ * App-local pure utilities.
409
+ *
410
+ * Use this file for small reusable functions that belong only to this app.
411
+ * Keep database access in models, business workflows in services, and request
412
+ * validation/sanitization in validators.
413
+ */
414
+
415
+ export {};
416
+ `;
417
+ }
418
+
405
419
  export function renderAppSubscribersFile(appName) {
406
420
  return `export default function register${toPascalCase(appName)}Subscribers({ events, logger }) {
407
421
  events.subscribe('app.booted', ({ appName }) => {
@@ -3148,6 +3148,16 @@ function attachCsrfProtection(expressApp, config, logger, auth = null) {
3148
3148
  }
3149
3149
 
3150
3150
  const provided = extractCsrfToken(req, csrfConfig);
3151
+ if (!provided && isMultipartRequestContentType(req.headers?.['content-type'])) {
3152
+ req.aegis = req.aegis || {};
3153
+ req.aegis.csrf = {
3154
+ deferredMultipart: true,
3155
+ fieldName: csrfConfig.fieldName,
3156
+ token,
3157
+ };
3158
+ return next();
3159
+ }
3160
+
3151
3161
  if (!provided || !constantTimeEqual(provided, token)) {
3152
3162
  return res.status(403).json({ error: 'CSRF token missing or invalid' });
3153
3163
  }
@@ -120,6 +120,47 @@ function createUploadError(code, message, statusCode) {
120
120
  return error;
121
121
  }
122
122
 
123
+ function constantTimeEqual(left, right) {
124
+ if (typeof left !== 'string' || typeof right !== 'string') {
125
+ return false;
126
+ }
127
+
128
+ const a = Buffer.from(left);
129
+ const b = Buffer.from(right);
130
+ if (a.length !== b.length) {
131
+ return false;
132
+ }
133
+
134
+ try {
135
+ return crypto.timingSafeEqual(a, b);
136
+ } catch {
137
+ return false;
138
+ }
139
+ }
140
+
141
+ function validateDeferredMultipartCsrf(req) {
142
+ const csrfState = req?.aegis?.csrf;
143
+ if (!csrfState || csrfState.deferredMultipart !== true) {
144
+ return null;
145
+ }
146
+
147
+ const expected = typeof csrfState.token === 'string' ? csrfState.token : '';
148
+ const fieldName = typeof csrfState.fieldName === 'string' && csrfState.fieldName.length > 0
149
+ ? csrfState.fieldName
150
+ : '_csrf';
151
+ const provided = req?.body && typeof req.body === 'object'
152
+ ? req.body[fieldName]
153
+ : '';
154
+
155
+ delete req.aegis.csrf;
156
+
157
+ if (!expected || typeof provided !== 'string' || !constantTimeEqual(provided, expected)) {
158
+ return createUploadError('AEGIS_CSRF_INVALID', 'CSRF token missing or invalid', 403);
159
+ }
160
+
161
+ return null;
162
+ }
163
+
123
164
  function resolveUploadError(error) {
124
165
  if (!error) {
125
166
  return null;
@@ -207,6 +248,13 @@ function wrapUploadMiddleware(middleware) {
207
248
  return (req, res, next) => {
208
249
  middleware(req, res, (error) => {
209
250
  if (!error) {
251
+ const csrfError = validateDeferredMultipartCsrf(req);
252
+ if (csrfError) {
253
+ res.status(csrfError.statusCode || 403).json({
254
+ error: csrfError.message || 'CSRF token missing or invalid',
255
+ });
256
+ return;
257
+ }
210
258
  next();
211
259
  return;
212
260
  }