webspresso 0.0.101 → 0.0.103

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.
Files changed (62) hide show
  1. package/.agents/AGENTS.md +1 -1
  2. package/.agents/ARCHITECTURE.md +11 -0
  3. package/.agents/CONVENTIONS.md +4 -0
  4. package/.agents/ORM.md +13 -4
  5. package/.agents/PLUGINS.md +32 -6
  6. package/.agents/ROUTING.md +99 -2
  7. package/.agents/skills/webspresso-framework/SKILL.md +32 -0
  8. package/.agents/skills/webspresso-orm-and-database/SKILL.md +4 -5
  9. package/.agents/skills/webspresso-routing-and-api/SKILL.md +4 -4
  10. package/AGENTS.md +6 -0
  11. package/README.md +20 -7
  12. package/bin/commands/add-tailwind.js +10 -0
  13. package/bin/commands/build.js +34 -0
  14. package/bin/commands/routes.js +8 -0
  15. package/core/applySchema.js +18 -1
  16. package/core/auth/jwt.js +10 -17
  17. package/core/auth/user-agent.js +61 -42
  18. package/core/compression/compression-stream.js +2 -2
  19. package/core/compression/middleware.js +1 -1
  20. package/core/compression/negotiate.js +11 -4
  21. package/core/events/dispatcher.js +56 -33
  22. package/core/openapi/orm-components.js +2 -3
  23. package/core/orm/cache/listeners.js +12 -2
  24. package/core/orm/json-fields.js +7 -1
  25. package/core/orm/model.js +99 -8
  26. package/core/orm/query-builder.js +71 -25
  27. package/core/orm/repository.js +42 -17
  28. package/core/orm/schema-helpers.js +49 -0
  29. package/core/orm/scopes.js +40 -9
  30. package/core/orm/types.js +12 -1
  31. package/core/queue/manager.js +5 -0
  32. package/core/validation/index.js +9 -3
  33. package/index.d.ts +178 -6
  34. package/index.js +71 -16
  35. package/package.json +6 -5
  36. package/plugins/admin-panel/index.js +12 -0
  37. package/plugins/audit-log/middleware.js +39 -4
  38. package/plugins/csrf/index.js +1 -1
  39. package/plugins/email/service.js +32 -1
  40. package/plugins/file-manager/api-handlers.js +10 -17
  41. package/plugins/file-manager/index.js +24 -9
  42. package/plugins/file-manager/security.js +18 -6
  43. package/plugins/index.js +2 -0
  44. package/plugins/lazy.js +258 -0
  45. package/plugins/site-analytics/bot-patterns.js +3 -0
  46. package/plugins/site-analytics/client-error-handler.js +3 -1
  47. package/plugins/site-analytics/index.js +10 -1
  48. package/plugins/site-analytics/tracking.js +245 -46
  49. package/src/api/api-loader.js +18 -2
  50. package/src/api/validation-i18n.js +231 -0
  51. package/src/discovery/route-manifest.js +197 -0
  52. package/src/discovery/scan-routes.js +84 -8
  53. package/src/file-router.js +215 -97
  54. package/src/helpers.js +139 -26
  55. package/src/njk-frontmatter.js +17 -1
  56. package/src/plugin-manager.js +46 -13
  57. package/src/routing/route-table.js +15 -5
  58. package/src/server.js +86 -15
  59. package/src/services/builtins/system.js +4 -6
  60. package/src/services/registry.js +12 -2
  61. package/src/services/validator.js +57 -32
  62. package/src/template/precompile.js +247 -0
package/index.d.ts CHANGED
@@ -68,6 +68,14 @@ export interface CreateAppOptions {
68
68
  compression?: boolean | CompressionOptions;
69
69
  /** Shutdown and lifecycle configuration */
70
70
  shutdown?: ShutdownOptions;
71
+ /**
72
+ * Ahead-Of-Time (AOT) route manifest: boolean to enable/disable loading, or explicit path to manifest JSON file
73
+ */
74
+ routesManifest?: boolean | string;
75
+ /**
76
+ * Precompiled Nunjucks templates: boolean to enable/disable loading, or explicit path to compiled bundle file
77
+ */
78
+ precompiledTemplates?: boolean | string;
71
79
  /** Development tooling and DX options */
72
80
  dev?: {
73
81
  banner?: boolean;
@@ -657,6 +665,25 @@ export function detectLocale(
657
665
  defaultLocale: string
658
666
  ): string;
659
667
 
668
+ export interface TranslateValidationIssuesOptions {
669
+ req?: Request;
670
+ t?: Translator;
671
+ locale?: string;
672
+ pagesDir?: string;
673
+ routeDir?: string;
674
+ isDev?: boolean;
675
+ }
676
+
677
+ export function translateValidationIssues<T = unknown>(
678
+ issues: T[],
679
+ options?: TranslateValidationIssuesOptions
680
+ ): T[];
681
+
682
+ export function getTranslatorForRequest(
683
+ req?: Request,
684
+ options?: { pagesDir?: string; routeDir?: string; isDev?: boolean }
685
+ ): { t: Translator; locale: string };
686
+
660
687
  export function parseNjkFrontmatter(content: string): {
661
688
  body: string;
662
689
  fm: Record<string, unknown> | null;
@@ -1045,11 +1072,20 @@ export type InferModel<T> = T extends ModelDefinition<infer TModel>
1045
1072
  export type InferModelCreate<T> = Partial<InferModel<T>> & Record<string, any>;
1046
1073
  export type InferModelUpdate<T> = Partial<InferModel<T>>;
1047
1074
 
1048
- export interface ModelOptions<TSchema extends ZodObject<any> = ZodObject<Record<string, ZodTypeAny>>> {
1075
+ export interface ModelOptions<TSchema = ZodObject<Record<string, ZodTypeAny>>> {
1049
1076
  name: string;
1050
1077
  table: string;
1051
- schema: TSchema;
1078
+ schema: TSchema | Record<string, any>;
1052
1079
  primaryKey?: string;
1080
+ timestamps?: boolean | { createdAt?: string; updatedAt?: string; snakeCase?: boolean };
1081
+ softDelete?: boolean | { deletedAt?: string; snakeCase?: boolean };
1082
+ softDeletes?: boolean | { deletedAt?: string; snakeCase?: boolean };
1083
+ options?: {
1084
+ timestamps?: boolean | { createdAt?: string; updatedAt?: string; snakeCase?: boolean };
1085
+ softDelete?: boolean | { deletedAt?: string; snakeCase?: boolean };
1086
+ softDeletes?: boolean | { deletedAt?: string; snakeCase?: boolean };
1087
+ [key: string]: unknown;
1088
+ };
1053
1089
  relations?: Record<string, RelationDefinition>;
1054
1090
  scopes?: ScopeOptions;
1055
1091
  admin?: AdminMetadata;
@@ -1077,6 +1113,11 @@ export interface ModelDefinition<TModel = Record<string, any>> {
1077
1113
  timestamps: boolean;
1078
1114
  tenant: string | null;
1079
1115
  };
1116
+ options?: {
1117
+ timestamps?: boolean | { createdAt?: string; updatedAt?: string; snakeCase?: boolean };
1118
+ softDelete?: boolean | { deletedAt?: string; snakeCase?: boolean };
1119
+ [key: string]: unknown;
1120
+ };
1080
1121
  columns: Map<string, unknown>;
1081
1122
  admin: {
1082
1123
  enabled: boolean;
@@ -1102,9 +1143,9 @@ export interface ModelDefinition<TModel = Record<string, any>> {
1102
1143
  cache?: boolean | 'auto' | 'smart' | { strategy: 'auto' | 'smart' };
1103
1144
  }
1104
1145
 
1105
- export function defineModel<TSchema extends ZodObject<any>>(
1146
+ export function defineModel<TSchema = ZodObject<any>>(
1106
1147
  options: ModelOptions<TSchema>
1107
- ): ModelDefinition<import('zod').z.infer<TSchema>>;
1148
+ ): ModelDefinition<any>;
1108
1149
 
1109
1150
  export function getModel<T = Record<string, any>>(name: string): ModelDefinition<T> | undefined;
1110
1151
 
@@ -1120,7 +1161,10 @@ export function isCascadeEnabled(relation: RelationDefinition, operation: 'delet
1120
1161
 
1121
1162
  /** zdb column helpers (id, string, timestamp, …) + `schema()` */
1122
1163
  export type Zdb = Record<string, any> & {
1123
- schema(shape: Record<string, ZodTypeAny>): ZodObject<Record<string, ZodTypeAny>>;
1164
+ schema(shape: Record<string, any>): ZodObject<Record<string, ZodTypeAny>>;
1165
+ timestamps(options?: { snakeCase?: boolean; createdAt?: string; updatedAt?: string }): Record<string, any>;
1166
+ softDelete(options?: Record<string, any>): any;
1167
+ softDeletes(options?: { snakeCase?: boolean; deletedAt?: string }): Record<string, any>;
1124
1168
  };
1125
1169
 
1126
1170
  export const zdb: Zdb;
@@ -1561,9 +1605,38 @@ export interface AdminPanelPluginOptions {
1561
1605
  windowMs?: number;
1562
1606
  blockDurationMs?: number;
1563
1607
  };
1608
+ /** Lazy / On-Demand mount: defers plugin initialization until first request to adminPath or service execution */
1609
+ lazy?: boolean | LazyPluginOptions;
1564
1610
  [key: string]: unknown;
1565
1611
  }
1566
1612
 
1613
+ export interface LazyPluginOptions {
1614
+ name?: string;
1615
+ prefix?: string | string[];
1616
+ services?: string[];
1617
+ helpers?: string[];
1618
+ events?: string[];
1619
+ dependencies?: Record<string, string> | string[];
1620
+ csp?: Record<string, string[]>;
1621
+ onHydrate?: (realPlugin: WebspressoPlugin) => void;
1622
+ }
1623
+
1624
+ export interface LazyPlugin extends WebspressoPlugin {
1625
+ isLazy: true;
1626
+ readonly isHydrated: boolean;
1627
+ hydrate(): WebspressoPlugin;
1628
+ }
1629
+
1630
+ export function lazyPlugin(
1631
+ pluginFactory: (() => WebspressoPlugin) | WebspressoPlugin,
1632
+ lazyOptions?: LazyPluginOptions
1633
+ ): LazyPlugin;
1634
+
1635
+ export function defineLazyPlugin(
1636
+ pluginFactory: (() => WebspressoPlugin) | WebspressoPlugin,
1637
+ lazyOptions?: LazyPluginOptions
1638
+ ): LazyPlugin;
1639
+
1567
1640
  export function adminPanelPlugin(options?: AdminPanelPluginOptions): WebspressoPlugin;
1568
1641
 
1569
1642
  export function dataExchangePlugin(options?: Record<string, unknown>): WebspressoPlugin;
@@ -3142,11 +3215,14 @@ export interface ApiContext<TBody = any, TParams = Record<string, string>, TQuer
3142
3215
  query: TQuery;
3143
3216
  body: TBody;
3144
3217
  input: { body?: TBody; params?: TParams; query?: TQuery; headers?: Record<string, string> };
3218
+ locale: string;
3219
+ t: Translator;
3145
3220
  service: WebspressoServiceCaller;
3146
3221
  module: string | null;
3147
3222
  config: Record<string, unknown>;
3148
3223
  logger: Console;
3149
3224
  db: unknown;
3225
+ sse?: (sourceOrOptions?: any, maybeOptions?: any) => any;
3150
3226
  }
3151
3227
 
3152
3228
  export interface DefineApiOptions<TResult = any, TBody = any, TParams = Record<string, string>, TQuery = Record<string, string>> {
@@ -3213,7 +3289,21 @@ export function scanRoutes(options?: {
3213
3289
  pages?: boolean | { dir?: string; prefix?: string };
3214
3290
  api?: boolean | { dir?: string; prefix?: string };
3215
3291
  modules?: boolean | { dir?: string };
3216
- }): RouteDescriptor[];
3292
+ }): RouteDescriptor[] & { scannedPagesFiles?: string[]; scannedPagesDir?: string | null };
3293
+
3294
+ export function discoverAppRoutes(options?: {
3295
+ rootDir?: string;
3296
+ pages?: boolean | { dir?: string; prefix?: string };
3297
+ api?: boolean | { dir?: string; prefix?: string };
3298
+ modules?: boolean | { dir?: string };
3299
+ routes?: Array<{ method?: string; path: string }>;
3300
+ isDev?: boolean;
3301
+ }): {
3302
+ descriptors: RouteDescriptor[];
3303
+ routeTable: RouteTable;
3304
+ scannedPagesFiles: string[];
3305
+ scannedPagesDir: string | null;
3306
+ };
3217
3307
 
3218
3308
  export function parseFileRoute(
3219
3309
  relativePath: string,
@@ -3225,6 +3315,88 @@ export function compileRouteTable(
3225
3315
  options?: { explicitRoutes?: Array<{ method?: string; path: string }>; isDev?: boolean }
3226
3316
  ): RouteTable;
3227
3317
 
3318
+ export function generateRouteManifest(options?: {
3319
+ rootDir?: string;
3320
+ pages?: boolean | string | { dir?: string; prefix?: string };
3321
+ api?: boolean | string | { dir?: string; prefix?: string };
3322
+ modules?: boolean | { dir?: string };
3323
+ pagesDir?: string;
3324
+ outDir?: string;
3325
+ outFile?: string;
3326
+ silent?: boolean;
3327
+ }): {
3328
+ manifest: {
3329
+ version: number;
3330
+ generatedAt: string;
3331
+ pagesDirRel: string;
3332
+ scannedPagesFiles: string[];
3333
+ routes: Array<{
3334
+ type: 'page' | 'api';
3335
+ method: string;
3336
+ path: string;
3337
+ source: string;
3338
+ module?: string | null;
3339
+ moduleConfig?: { name?: string } | null;
3340
+ }>;
3341
+ };
3342
+ manifestPath: string;
3343
+ routeCount: number;
3344
+ scannedFilesCount: number;
3345
+ };
3346
+
3347
+ export function loadRouteManifest(options?: {
3348
+ rootDir?: string;
3349
+ manifestPath?: string;
3350
+ }): {
3351
+ manifest: {
3352
+ version: number;
3353
+ generatedAt: string;
3354
+ pagesDirRel: string;
3355
+ scannedPagesFiles: string[];
3356
+ routes: Array<{
3357
+ type: 'page' | 'api';
3358
+ method: string;
3359
+ path: string;
3360
+ source: string;
3361
+ module?: string | null;
3362
+ moduleConfig?: { name?: string } | null;
3363
+ }>;
3364
+ };
3365
+ descriptors: RouteDescriptor[] & { scannedPagesFiles?: string[]; scannedPagesDir?: string | null };
3366
+ } | null;
3367
+
3368
+ export function precompileTemplates(options?: {
3369
+ rootDir?: string;
3370
+ viewsDir?: string;
3371
+ pagesDir?: string;
3372
+ outDir?: string;
3373
+ outFile?: string;
3374
+ env?: any;
3375
+ }): {
3376
+ templateCount: number;
3377
+ manifestPath: string;
3378
+ bundle: {
3379
+ version: number;
3380
+ generatedAt: string;
3381
+ templates: Record<string, string>;
3382
+ frontmatter: Record<string, any>;
3383
+ };
3384
+ };
3385
+
3386
+ export function loadPrecompiledTemplates(options?: {
3387
+ rootDir?: string;
3388
+ bundlePath?: string;
3389
+ }): {
3390
+ loader: any;
3391
+ templateCount: number;
3392
+ bundle: {
3393
+ version: number;
3394
+ generatedAt: string;
3395
+ templates: Record<string, string>;
3396
+ frontmatter: Record<string, any>;
3397
+ };
3398
+ } | null;
3399
+
3228
3400
  export interface GenerateTypesOptions {
3229
3401
  /** Root directory of the project (default: process.cwd()) */
3230
3402
  rootDir?: string;
package/index.js CHANGED
@@ -58,13 +58,21 @@ const {
58
58
  const { definePage } = require('./src/pages/define-page');
59
59
  const { defineApi } = require('./src/api/define-api');
60
60
  const { defineModule } = require('./src/modules/define-module');
61
- const { scanRoutes } = require('./src/discovery/scan-routes');
61
+ const { scanRoutes, discoverAppRoutes } = require('./src/discovery/scan-routes');
62
+ const { generateRouteManifest, loadRouteManifest } = require('./src/discovery/route-manifest');
63
+ const { precompileTemplates, loadPrecompiledTemplates } = require('./src/template/precompile');
62
64
  const { parseFileRoute } = require('./src/discovery/file-route-parser');
63
65
  const { compileRouteTable, RouteTable } = require('./src/routing/route-table');
64
66
  const { mountDiscoveredRoutes } = require('./src/routing/mount-discovered-routes');
65
67
 
66
- // ORM exports (lazy loaded)
67
- const orm = require('./core/orm');
68
+ // Lazy-loaded ORM accessor
69
+ let _orm = null;
70
+ function getOrm() {
71
+ if (!_orm) {
72
+ _orm = require('./core/orm');
73
+ }
74
+ return _orm;
75
+ }
68
76
 
69
77
  // Lazy-loaded built-in plugins
70
78
  const plugins = require('./plugins');
@@ -99,12 +107,6 @@ module.exports = {
99
107
  discoverServices: require('./src/services').discoverServices,
100
108
  filePathToServiceName: require('./src/services').filePathToServiceName,
101
109
  toCamelCase: require('./src/services').toCamelCase,
102
- createAuthServices: require('./src/services').createAuthServices,
103
- createMailServices: require('./src/services').createMailServices,
104
- createMediaServices: require('./src/services').createMediaServices,
105
- createFileManagerServices: require('./src/services').createFileManagerServices,
106
- createSystemServices: require('./src/services').createSystemServices,
107
- createExchangeServices: require('./src/services').createExchangeServices,
108
110
  validateServiceInput: require('./src/services').validateServiceInput,
109
111
  executeService: require('./src/services').executeService,
110
112
 
@@ -171,12 +173,6 @@ module.exports = {
171
173
  createPluginManager,
172
174
  getPluginManager,
173
175
  resetPluginManager,
174
-
175
- // ORM
176
- ...orm,
177
-
178
- // Direct zdb export (for convenience)
179
- zdb: orm.zdb,
180
176
 
181
177
  // Discovery, Pages, API, Modules & Routing primitives
182
178
  definePage,
@@ -186,11 +182,18 @@ module.exports = {
186
182
  defineModule,
187
183
  module: defineModule,
188
184
  scanRoutes,
185
+ discoverAppRoutes,
186
+ generateRouteManifest,
187
+ loadRouteManifest,
188
+ precompileTemplates,
189
+ loadPrecompiledTemplates,
189
190
  parseFileRoute,
190
191
  compileRouteTable,
191
192
  RouteTable,
192
193
  mountDiscoveredRoutes,
193
194
  resolveMiddlewares: require('./src/routing/middleware-resolver').resolveMiddlewares,
195
+ translateValidationIssues: require('./src/api/validation-i18n').translateValidationIssues,
196
+ getTranslatorForRequest: require('./src/api/validation-i18n').getTranslatorForRequest,
194
197
 
195
198
  // Forms & Flash subsystem (AdonisJS & Django-inspired)
196
199
  createFlashMiddleware: require('./core/forms').createFlashMiddleware,
@@ -218,10 +221,62 @@ module.exports = {
218
221
  loadListeners: require('./core/events').loadListeners,
219
222
  };
220
223
 
221
- // Define lazy getters for plugins and heavy core subsystems
224
+ // Define lazy getters for plugins, ORM, and heavy core subsystems
222
225
  Object.defineProperties(module.exports, {
223
226
  ...Object.getOwnPropertyDescriptors(plugins),
224
227
 
228
+ // Built-in Services Creators (lazy loaded)
229
+ createAuthServices: { get: () => require('./src/services').createAuthServices, enumerable: true, configurable: true },
230
+ createMailServices: { get: () => require('./src/services').createMailServices, enumerable: true, configurable: true },
231
+ createMediaServices: { get: () => require('./src/services').createMediaServices, enumerable: true, configurable: true },
232
+ createFileManagerServices: { get: () => require('./src/services').createFileManagerServices, enumerable: true, configurable: true },
233
+ createSystemServices: { get: () => require('./src/services').createSystemServices, enumerable: true, configurable: true },
234
+ createExchangeServices: { get: () => require('./src/services').createExchangeServices, enumerable: true, configurable: true },
235
+
236
+ // ORM & Database Layer (lazy loaded)
237
+ orm: { get: () => getOrm(), enumerable: true, configurable: true },
238
+ createDatabase: { get: () => getOrm().createDatabase, enumerable: true, configurable: true },
239
+ createInMemoryDatabase: { get: () => getOrm().createInMemoryDatabase, enumerable: true, configurable: true },
240
+ defineModel: { get: () => getOrm().defineModel, enumerable: true, configurable: true },
241
+ zdb: { get: () => getOrm().zdb, enumerable: true, configurable: true },
242
+ BaseRepository: { get: () => getOrm().BaseRepository, enumerable: true, configurable: true },
243
+ Repository: { get: () => getOrm().Repository, enumerable: true, configurable: true },
244
+ createRepository: { get: () => getOrm().createRepository, enumerable: true, configurable: true },
245
+ getModel: { get: () => getOrm().getModel, enumerable: true, configurable: true },
246
+ getAllModels: { get: () => getOrm().getAllModels, enumerable: true, configurable: true },
247
+ hasModel: { get: () => getOrm().hasModel, enumerable: true, configurable: true },
248
+ clearRegistry: { get: () => getOrm().clearRegistry, enumerable: true, configurable: true },
249
+ isCascadeEnabled: { get: () => getOrm().isCascadeEnabled, enumerable: true, configurable: true },
250
+ extractColumnsFromSchema: { get: () => getOrm().extractColumnsFromSchema, enumerable: true, configurable: true },
251
+ getColumnMeta: { get: () => getOrm().getColumnMeta, enumerable: true, configurable: true },
252
+ generateNanoid: { get: () => getOrm().generateNanoid, enumerable: true, configurable: true },
253
+ zodNanoid: { get: () => getOrm().zodNanoid, enumerable: true, configurable: true },
254
+ extendZ: { get: () => getOrm().extendZ, enumerable: true, configurable: true },
255
+ omitHiddenColumns: { get: () => getOrm().omitHiddenColumns, enumerable: true, configurable: true },
256
+ sanitizeForOutput: { get: () => getOrm().sanitizeForOutput, enumerable: true, configurable: true },
257
+ ModelEvents: { get: () => getOrm().ModelEvents, enumerable: true, configurable: true },
258
+ Hooks: { get: () => getOrm().Hooks, enumerable: true, configurable: true },
259
+ HookCancellationError: { get: () => getOrm().HookCancellationError, enumerable: true, configurable: true },
260
+ createEventContext: { get: () => getOrm().createEventContext, enumerable: true, configurable: true },
261
+ getTransactionContext: { get: () => getOrm().getTransactionContext, enumerable: true, configurable: true },
262
+ createTransactionContext: { get: () => getOrm().createTransactionContext, enumerable: true, configurable: true },
263
+ runTransaction: { get: () => getOrm().runTransaction, enumerable: true, configurable: true },
264
+ createSchemaHelpers: { get: () => getOrm().createSchemaHelpers, enumerable: true, configurable: true },
265
+ loadRelations: { get: () => getOrm().loadRelations, enumerable: true, configurable: true },
266
+ loadRelation: { get: () => getOrm().loadRelation, enumerable: true, configurable: true },
267
+ parseRelationTree: { get: () => getOrm().parseRelationTree, enumerable: true, configurable: true },
268
+ QueryComplexityError: { get: () => getOrm().QueryComplexityError, enumerable: true, configurable: true },
269
+ validateQueryComplexity: { get: () => getOrm().validateQueryComplexity, enumerable: true, configurable: true },
270
+ resolveQueryLimits: { get: () => getOrm().resolveQueryLimits, enumerable: true, configurable: true },
271
+ DEFAULT_QUERY_LIMITS: { get: () => getOrm().DEFAULT_QUERY_LIMITS, enumerable: true, configurable: true },
272
+ buildSearchClause: { get: () => getOrm().buildSearchClause, enumerable: true, configurable: true },
273
+ tokenizeSearchTerm: { get: () => getOrm().tokenizeSearchTerm, enumerable: true, configurable: true },
274
+ detectDialect: { get: () => getOrm().detectDialect, enumerable: true, configurable: true },
275
+ defineFactory: { get: () => getOrm().defineFactory, enumerable: true, configurable: true },
276
+ FactoryBuilder: { get: () => getOrm().FactoryBuilder, enumerable: true, configurable: true },
277
+ diffSchema: { get: () => getOrm().diffSchema, enumerable: true, configurable: true },
278
+ makeMigrations: { get: () => getOrm().makeMigrations, enumerable: true, configurable: true },
279
+
225
280
  // Cascading Middleware & Layouts
226
281
  cascading: { get: () => require('./src/routing/cascading'), enumerable: true, configurable: true },
227
282
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webspresso",
3
- "version": "0.0.101",
3
+ "version": "0.0.103",
4
4
  "description": "Minimal, production-ready SSR framework for Node.js with file-based routing, Nunjucks templating, built-in i18n, and CLI tooling",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -47,21 +47,21 @@
47
47
  "test:services": "vitest run tests/**/service*",
48
48
  "test:plugins": "vitest run tests/**/plugin* tests/unit/*plugin*.test.js",
49
49
  "test:core": "vitest run tests/unit/core* tests/unit/server* tests/unit/routing* tests/unit/template*",
50
- "test:branches": "vitest run tests/unit/*branches.test.js",
51
- "test:fast": "vitest run tests/unit/*branches.test.js tests/unit/auth.test.js tests/unit/services.test.js tests/unit/server.test.js",
50
+ "test:branches": "vitest run tests/unit/*scenarios*.test.js tests/unit/*branches*.test.js",
51
+ "test:fast": "vitest run tests/unit/*scenarios*.test.js tests/unit/*branches*.test.js tests/unit/auth.test.js tests/unit/services.test.js tests/unit/server.test.js",
52
52
  "test:watch": "vitest",
53
53
  "test:coverage": "vitest run --coverage",
54
54
  "test:coverage:quick": "vitest run --coverage",
55
55
  "test:coverage:changed": "vitest run --coverage --changed",
56
56
  "test:coverage:full": "FULL_REPORT=true vitest run --coverage",
57
- "test:coverage:branches": "vitest run tests/unit/*branches.test.js --coverage",
57
+ "test:coverage:branches": "vitest run tests/unit/*scenarios*.test.js tests/unit/*branches*.test.js --coverage",
58
58
  "test:coverage:unit": "vitest run tests/unit/ --coverage",
59
59
  "test:coverage:core": "vitest run tests/unit/core* tests/unit/server* tests/unit/routing* tests/unit/template* --coverage",
60
60
  "test:coverage:auth": "vitest run tests/**/auth* tests/unit/server-auth-branches.test.js --coverage",
61
61
  "test:coverage:orm": "vitest run tests/**/orm* tests/**/database* --coverage",
62
62
  "test:coverage:services": "vitest run tests/**/service* --coverage",
63
63
  "test:coverage:plugins": "vitest run tests/**/plugin* tests/unit/*plugin*.test.js --coverage",
64
- "test:coverage:fast": "vitest run tests/unit/*branches.test.js tests/unit/auth.test.js tests/unit/services.test.js tests/unit/server.test.js --coverage",
64
+ "test:coverage:fast": "vitest run tests/unit/*scenarios*.test.js tests/unit/*branches*.test.js tests/unit/auth.test.js tests/unit/services.test.js tests/unit/server.test.js --coverage",
65
65
  "test:e2e": "playwright test",
66
66
  "test:e2e:ui": "playwright test --ui",
67
67
  "test:e2e:debug": "playwright test --debug",
@@ -176,6 +176,7 @@
176
176
  "dotenv": "^16.3.1",
177
177
  "express-rate-limit": "^8.4.1",
178
178
  "release-it": "^20.2.1",
179
+ "softscope": "^0.3.2",
179
180
  "supertest": "^6.3.4",
180
181
  "typescript": "~5.6.3",
181
182
  "vitest": "^4.1.11",
@@ -55,8 +55,20 @@ function adminPanelPlugin(options = {}) {
55
55
  richTextSanitize = true,
56
56
  dataExchange: dataExchangeOption = true,
57
57
  loginRateLimit,
58
+ lazy: lazyOption,
58
59
  } = options;
59
60
 
61
+ if (lazyOption) {
62
+ const { lazyPlugin } = require('../lazy');
63
+ const lazyOpts = typeof lazyOption === 'object' ? lazyOption : {};
64
+ return lazyPlugin(() => adminPanelPlugin({ ...options, lazy: false }), {
65
+ name: 'admin-panel',
66
+ prefix: adminPath,
67
+ services: ['exchange.export', 'exchange.import'],
68
+ ...lazyOpts,
69
+ });
70
+ }
71
+
60
72
  const adminUsersConfig = typeof adminUsersOption === 'object' ? adminUsersOption : { enabled: adminUsersOption !== false };
61
73
 
62
74
  // Validate required options
@@ -95,11 +95,9 @@ function createAuditMiddleware(options) {
95
95
  metadata: metadata || null,
96
96
  };
97
97
 
98
- knex(tableName).insert(row).catch((err) => {
99
- console.warn('[audit-log] Failed to insert audit row:', err.message);
100
- });
98
+ knex(tableName).insert(row).catch(logAuditError);
101
99
  } catch (e) {
102
- console.warn('[audit-log] finish handler error:', e.message);
100
+ logAuditError(e, 'finish handler error');
103
101
  }
104
102
  });
105
103
 
@@ -107,9 +105,46 @@ function createAuditMiddleware(options) {
107
105
  };
108
106
  }
109
107
 
108
+ let lastAuditWarnTime = 0;
109
+ let suppressedAuditErrors = 0;
110
+
111
+ /**
112
+ * Format and sanitize DB errors to prevent leaking raw SQL queries
113
+ * @param {Error|Object|string} e
114
+ * @returns {string}
115
+ */
116
+ function formatDbError(e) {
117
+ if (!e) return 'Unknown error';
118
+ let msg;
119
+ if (typeof e.message === 'string' && e.message.includes(' - ')) {
120
+ msg = e.message.split(' - ').pop().trim();
121
+ } else {
122
+ msg = String(e.code || e.message || 'Database error');
123
+ }
124
+ return msg.replace(/[\r\n]+/g, ' ').trim().slice(0, 150);
125
+ }
126
+
127
+ function logAuditError(err, prefix = 'Failed to insert audit row') {
128
+ const now = Date.now();
129
+ const formatted = formatDbError(err);
130
+ if (lastAuditWarnTime === 0 || now - lastAuditWarnTime >= 60000) {
131
+ const elapsedSec = lastAuditWarnTime > 0 ? Math.round((now - lastAuditWarnTime) / 1000) : 0;
132
+ if (suppressedAuditErrors > 0) {
133
+ console.warn(`[audit-log] ${prefix} (suppressed ${suppressedAuditErrors} occurrences in last ${elapsedSec}s): ${formatted}`);
134
+ } else {
135
+ console.warn(`[audit-log] ${prefix}: ${formatted}`);
136
+ }
137
+ lastAuditWarnTime = now;
138
+ suppressedAuditErrors = 0;
139
+ } else {
140
+ suppressedAuditErrors++;
141
+ }
142
+ }
143
+
110
144
  module.exports = {
111
145
  createAuditMiddleware,
112
146
  stringifyId,
113
147
  extractResourceIdFromJsonBody,
114
148
  buildMetadata,
149
+ formatDbError,
115
150
  };
@@ -199,7 +199,7 @@ function csrfPlugin(options = {}) {
199
199
 
200
200
  if (!submitted || !safeCompare(submitted, csrfTokenValue)) {
201
201
  res.status(errorStatus);
202
- if (req.accepts('html') && !req.path.startsWith('/api')) {
202
+ if (typeof req.accepts === 'function' && req.accepts('html') && !req.path.startsWith('/api')) {
203
203
  return res.send(`<h1>${errorStatus} Forbidden</h1><p>${errorMessage}</p>`);
204
204
  } else {
205
205
  return res.json({ error: errorMessage });
@@ -30,6 +30,37 @@ function createEmailService(options) {
30
30
  console.warn('[email]', err.message);
31
31
  }
32
32
 
33
+ let lastEmailLogWarnTime = 0;
34
+ let suppressedEmailLogErrors = 0;
35
+
36
+ function formatDbError(e) {
37
+ if (!e) return 'Unknown error';
38
+ let msg;
39
+ if (typeof e.message === 'string' && e.message.includes(' - ')) {
40
+ msg = e.message.split(' - ').pop().trim();
41
+ } else {
42
+ msg = String(e.code || e.message || 'Database error');
43
+ }
44
+ return msg.replace(/[\r\n]+/g, ' ').trim().slice(0, 150);
45
+ }
46
+
47
+ function logEmailError(err) {
48
+ const now = Date.now();
49
+ const formatted = formatDbError(err);
50
+ if (lastEmailLogWarnTime === 0 || now - lastEmailLogWarnTime >= 60000) {
51
+ const elapsedSec = lastEmailLogWarnTime > 0 ? Math.round((now - lastEmailLogWarnTime) / 1000) : 0;
52
+ if (suppressedEmailLogErrors > 0) {
53
+ console.warn(`[email] Failed to write email log (suppressed ${suppressedEmailLogErrors} occurrences in last ${elapsedSec}s): ${formatted}`);
54
+ } else {
55
+ console.warn('[email] Failed to write email log:', formatted);
56
+ }
57
+ lastEmailLogWarnTime = now;
58
+ suppressedEmailLogErrors = 0;
59
+ } else {
60
+ suppressedEmailLogErrors++;
61
+ }
62
+ }
63
+
33
64
  /**
34
65
  * @param {Object} row
35
66
  */
@@ -49,7 +80,7 @@ function createEmailService(options) {
49
80
  created_at: new Date(),
50
81
  });
51
82
  } catch (err) {
52
- console.warn('[email] Failed to write email log:', err.message);
83
+ logEmailError(err);
53
84
  }
54
85
  }
55
86
 
@@ -4,15 +4,7 @@
4
4
  */
5
5
 
6
6
  const multer = require('multer');
7
- const {
8
- listFiles,
9
- createDirectory,
10
- renameItem,
11
- deleteItem,
12
- moveItem,
13
- saveUploadedFile,
14
- calculateStorageUsage,
15
- } = require('./core');
7
+ const core = require('./core');
16
8
 
17
9
  /**
18
10
  * Create Multer middleware for in-memory upload parsing
@@ -71,7 +63,7 @@ function createFileManagerApiHandlers(options = {}) {
71
63
  const permissions = options.permissions || null;
72
64
  const securityOpts = options.security || {};
73
65
 
74
- const parseUpload = createUploadMiddleware(maxUploadBytes);
66
+ const parseUpload = options.uploadMiddleware || createUploadMiddleware(maxUploadBytes);
75
67
 
76
68
  return {
77
69
  async list(req, res) {
@@ -88,7 +80,7 @@ function createFileManagerApiHandlers(options = {}) {
88
80
  const page = req.query.page ? parseInt(req.query.page, 10) : undefined;
89
81
  const perPage = req.query.perPage ? parseInt(req.query.perPage, 10) : undefined;
90
82
 
91
- const result = await listFiles(baseDir, relPath, {
83
+ const result = await core.listFiles(baseDir, relPath, {
92
84
  search,
93
85
  type,
94
86
  sort,
@@ -115,7 +107,7 @@ function createFileManagerApiHandlers(options = {}) {
115
107
  if (!name) {
116
108
  return res.status(400).json({ success: false, error: 'Directory name is required' });
117
109
  }
118
- const result = await createDirectory(baseDir, relPath, name);
110
+ const result = await core.createDirectory(baseDir, relPath, name);
119
111
  return res.status(201).json({ success: true, data: result });
120
112
  } catch (err) {
121
113
  const status = err.statusCode || err.status || 400;
@@ -133,7 +125,7 @@ function createFileManagerApiHandlers(options = {}) {
133
125
  if (!itemPath || !newName) {
134
126
  return res.status(400).json({ success: false, error: 'Path and newName are required' });
135
127
  }
136
- const result = await renameItem(baseDir, itemPath, newName);
128
+ const result = await core.renameItem(baseDir, itemPath, newName);
137
129
  return res.json({ success: true, data: result });
138
130
  } catch (err) {
139
131
  const status = err.statusCode || err.status || 400;
@@ -151,7 +143,7 @@ function createFileManagerApiHandlers(options = {}) {
151
143
  if (!source) {
152
144
  return res.status(400).json({ success: false, error: 'Source path is required' });
153
145
  }
154
- const result = await moveItem(baseDir, source, destination);
146
+ const result = await core.moveItem(baseDir, source, destination);
155
147
  return res.json({ success: true, data: result });
156
148
  } catch (err) {
157
149
  const status = err.statusCode || err.status || 400;
@@ -169,7 +161,7 @@ function createFileManagerApiHandlers(options = {}) {
169
161
  if (!itemPath) {
170
162
  return res.status(400).json({ success: false, error: 'Path is required' });
171
163
  }
172
- const result = await deleteItem(baseDir, itemPath);
164
+ const result = await core.deleteItem(baseDir, itemPath);
173
165
  return res.json({ success: true, data: result });
174
166
  } catch (err) {
175
167
  const status = err.statusCode || err.status || 400;
@@ -199,7 +191,7 @@ function createFileManagerApiHandlers(options = {}) {
199
191
 
200
192
  // Storage Quota Check
201
193
  if (maxTotalStorageBytes && typeof maxTotalStorageBytes === 'number') {
202
- const currentUsage = await calculateStorageUsage(baseDir);
194
+ const currentUsage = await core.calculateStorageUsage(baseDir);
203
195
  const incomingBytes = files.reduce((acc, f) => acc + (f.size || f.buffer?.length || 0), 0);
204
196
  if (currentUsage + incomingBytes > maxTotalStorageBytes) {
205
197
  return res.status(507).json({
@@ -229,7 +221,7 @@ function createFileManagerApiHandlers(options = {}) {
229
221
  try {
230
222
  const uploaded = [];
231
223
  for (const file of files) {
232
- const saved = await saveUploadedFile(
224
+ const saved = await core.saveUploadedFile(
233
225
  baseDir,
234
226
  targetRelPath,
235
227
  file.buffer,
@@ -257,5 +249,6 @@ function createFileManagerApiHandlers(options = {}) {
257
249
 
258
250
  module.exports = {
259
251
  createFileManagerApiHandlers,
252
+ createUploadMiddleware,
260
253
  isActionAllowed,
261
254
  };