webspresso 0.0.106 → 0.0.107

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 (49) hide show
  1. package/bin/webspresso.js +17 -7
  2. package/core/app.js +38 -6
  3. package/core/auth/index.js +4 -1
  4. package/core/auth/jwt.js +85 -13
  5. package/core/auth/magic-link.js +23 -5
  6. package/core/auth/manager.js +7 -1
  7. package/core/auth/redis-session.js +62 -25
  8. package/core/auth/session-manager.js +28 -2
  9. package/core/auth/sessions.js +182 -52
  10. package/core/console/eval.js +4 -1
  11. package/core/content/service.js +1 -0
  12. package/core/errors/domain.js +20 -2
  13. package/core/errors/http.js +1 -0
  14. package/core/http/hono-adapter.js +1 -1
  15. package/core/http/pipeline.js +36 -2
  16. package/core/index.js +12 -1
  17. package/core/mail/mailer.js +1 -1
  18. package/core/orm/eager-loader.js +81 -1
  19. package/core/orm/index.js +27 -13
  20. package/core/orm/json-fields.js +44 -13
  21. package/core/orm/model.js +12 -0
  22. package/core/orm/query-builder.js +33 -15
  23. package/core/orm/repository.js +59 -12
  24. package/core/orm/scopes.js +54 -4
  25. package/core/services/index.js +9 -0
  26. package/core/shutdown/shutdown-manager.js +57 -38
  27. package/core/studio/inspector.js +9 -1
  28. package/core/studio/server.js +24 -8
  29. package/core/utils/string.js +77 -16
  30. package/core/validation/index.js +18 -0
  31. package/index.d.ts +172 -8
  32. package/index.js +4 -0
  33. package/package.json +18 -8
  34. package/plugins/admin-panel/client/parts/07-model-list.js +5 -2
  35. package/plugins/admin-panel/index.js +24 -11
  36. package/plugins/admin-panel/modules/user-management.js +3 -2
  37. package/plugins/csrf/index.js +2 -1
  38. package/plugins/dashboard/index.js +27 -7
  39. package/src/app-context.js +9 -1
  40. package/src/file-router.js +12 -3
  41. package/src/plugin-manager.js +273 -48
  42. package/src/server.js +26 -33
  43. package/src/services/discovery.js +9 -1
  44. package/src/services/executor.js +271 -58
  45. package/src/services/index.js +52 -3
  46. package/src/services/loader.js +120 -0
  47. package/src/services/memoize.js +223 -49
  48. package/src/services/registry.js +492 -94
  49. package/src/services/validator.js +164 -13
@@ -18,10 +18,35 @@
18
18
  */
19
19
  function toCamelCase(str) {
20
20
  if (!str || typeof str !== 'string') return '';
21
- const trimmed = str.replace(/^[-_\s]+|[-_\s]+$/g, '');
22
- if (!trimmed) return '';
23
- const res = trimmed.replace(/[-_\s]+([a-zA-Z0-9])/g, (_, letter) => letter.toUpperCase());
24
- return res.charAt(0).toLowerCase() + res.slice(1);
21
+ const s = str.trim();
22
+ if (!s) return '';
23
+
24
+ let start = 0;
25
+ let end = s.length;
26
+ while (start < end && (s[start] === '-' || s[start] === '_')) {
27
+ start++;
28
+ }
29
+ while (end > start && (s[end - 1] === '-' || s[end - 1] === '_')) {
30
+ end--;
31
+ }
32
+ if (start >= end) return '';
33
+
34
+ let result = '';
35
+ let capitalizeNext = false;
36
+ for (let i = start; i < end; i++) {
37
+ const ch = s[i];
38
+ if (ch === '-' || ch === '_' || ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r') {
39
+ capitalizeNext = true;
40
+ } else {
41
+ if (capitalizeNext) {
42
+ result += result.length === 0 ? ch.toLowerCase() : ch.toUpperCase();
43
+ capitalizeNext = false;
44
+ } else {
45
+ result += result.length === 0 ? ch.toLowerCase() : ch;
46
+ }
47
+ }
48
+ }
49
+ return result;
25
50
  }
26
51
 
27
52
  /**
@@ -35,12 +60,30 @@ function toCamelCase(str) {
35
60
  */
36
61
  function toSnakeCase(str) {
37
62
  if (!str || typeof str !== 'string') return '';
38
- return str
39
- .trim()
40
- .replace(/([a-z0-9])([A-Z])/g, '$1_$2')
41
- .replace(/[-_\s]+/g, '_')
42
- .replace(/^_+|_+$/g, '')
43
- .toLowerCase();
63
+ const s = str.trim();
64
+ if (!s) return '';
65
+
66
+ const expanded = s.replace(/([a-z0-9])([A-Z])/g, '$1_$2');
67
+ let result = '';
68
+ let prevWasUnderscore = false;
69
+
70
+ for (let i = 0; i < expanded.length; i++) {
71
+ const ch = expanded[i];
72
+ if (ch === '-' || ch === '_' || ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r') {
73
+ if (!prevWasUnderscore && result.length > 0) {
74
+ result += '_';
75
+ prevWasUnderscore = true;
76
+ }
77
+ } else {
78
+ result += ch.toLowerCase();
79
+ prevWasUnderscore = false;
80
+ }
81
+ }
82
+
83
+ while (result.endsWith('_')) {
84
+ result = result.slice(0, -1);
85
+ }
86
+ return result;
44
87
  }
45
88
 
46
89
  /**
@@ -54,12 +97,30 @@ function toSnakeCase(str) {
54
97
  */
55
98
  function toKebabCase(str) {
56
99
  if (!str || typeof str !== 'string') return '';
57
- return str
58
- .trim()
59
- .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
60
- .replace(/[-_\s]+/g, '-')
61
- .replace(/^-+|-+$/g, '')
62
- .toLowerCase();
100
+ const s = str.trim();
101
+ if (!s) return '';
102
+
103
+ const expanded = s.replace(/([a-z0-9])([A-Z])/g, '$1-$2');
104
+ let result = '';
105
+ let prevWasDash = false;
106
+
107
+ for (let i = 0; i < expanded.length; i++) {
108
+ const ch = expanded[i];
109
+ if (ch === '-' || ch === '_' || ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r') {
110
+ if (!prevWasDash && result.length > 0) {
111
+ result += '-';
112
+ prevWasDash = true;
113
+ }
114
+ } else {
115
+ result += ch.toLowerCase();
116
+ prevWasDash = false;
117
+ }
118
+ }
119
+
120
+ while (result.endsWith('-')) {
121
+ result = result.slice(0, -1);
122
+ }
123
+ return result;
63
124
  }
64
125
 
65
126
  /**
@@ -12,6 +12,24 @@ const { extendZ, generateNanoid, zodNanoid } = require('../orm/utils/nanoid');
12
12
  */
13
13
  const zExtended = extendZ(z);
14
14
 
15
+ // Ensure asynchronous refinements and superRefinements never leak dangling unhandled rejections
16
+ // if evaluated during a synchronous parse attempt before falling back to async parsing.
17
+ if (z.ZodType && z.ZodType.prototype && typeof z.ZodType.prototype._refinement === 'function' && !z.ZodType.prototype._refinement.__webspresso_wrapped) {
18
+ const origRefinement = z.ZodType.prototype._refinement;
19
+ const wrappedRefinement = function (refinement) {
20
+ const wrapped = (val, ctx) => {
21
+ const res = refinement(val, ctx);
22
+ if (res && typeof res.catch === 'function') {
23
+ res.catch(() => {});
24
+ }
25
+ return res;
26
+ };
27
+ return origRefinement.call(this, wrapped);
28
+ };
29
+ wrappedRefinement.__webspresso_wrapped = true;
30
+ z.ZodType.prototype._refinement = wrappedRefinement;
31
+ }
32
+
15
33
  /**
16
34
  * Check if an object contains dangerous prototype pollution keys
17
35
  * @param {*} obj
package/index.d.ts CHANGED
@@ -218,8 +218,14 @@ export interface ServiceContext {
218
218
  t?: unknown;
219
219
  service?: {
220
220
  <T = unknown>(name: string, input?: unknown, options?: unknown): Promise<T>;
221
+ call?: <T = unknown>(name: string, input?: unknown, options?: unknown) => Promise<T>;
222
+ execute?: <T = unknown>(name: string, input?: unknown, options?: unknown) => Promise<T>;
221
223
  invalidate?: (name: string, input?: unknown, ctx?: unknown) => boolean;
222
224
  clearCache?: (name?: string) => boolean;
225
+ invalidateByTag?: (tag: string, serviceName?: string) => number | Promise<number>;
226
+ invalidateByTags?: (tags: string | string[], serviceName?: string) => number | Promise<number>;
227
+ invalidateTag?: (tag: string, serviceName?: string) => number | Promise<number>;
228
+ invalidateTags?: (tags: string | string[], serviceName?: string) => number | Promise<number>;
223
229
  };
224
230
  breaker?: (name: string, options?: CircuitBreakerOptions) => CircuitBreaker;
225
231
  retry?: typeof retry;
@@ -231,6 +237,43 @@ export type ServiceHandler<TInput = unknown, TOutput = unknown> = (
231
237
  ctx: ServiceContext
232
238
  ) => Promise<TOutput> | TOutput;
233
239
 
240
+ export interface ServiceMiddlewareContext<TInput = unknown> {
241
+ name: string;
242
+ input: TInput;
243
+ ctx: ServiceContext;
244
+ serviceDef: ServiceDefinition<TInput, any>;
245
+ options: unknown;
246
+ registry: ServiceRegistry;
247
+ }
248
+
249
+ export interface ServiceBeforeContext<TInput = unknown> {
250
+ name: string;
251
+ input: TInput;
252
+ ctx: ServiceContext;
253
+ serviceDef: ServiceDefinition<TInput, any>;
254
+ options: unknown;
255
+ }
256
+
257
+ export interface ServiceAfterContext<TInput = unknown, TOutput = unknown> {
258
+ name: string;
259
+ result: TOutput;
260
+ input: TInput;
261
+ ctx: ServiceContext;
262
+ serviceDef: ServiceDefinition<TInput, TOutput>;
263
+ duration: number;
264
+ options: unknown;
265
+ }
266
+
267
+ export interface ServiceErrorContext<TInput = unknown> {
268
+ name: string;
269
+ error: Error;
270
+ input: TInput;
271
+ ctx: ServiceContext;
272
+ serviceDef: ServiceDefinition<TInput, any>;
273
+ duration: number;
274
+ options: unknown;
275
+ }
276
+
234
277
  export interface ServiceDefinition<TInput = unknown, TOutput = unknown> {
235
278
  schema?: import('zod').ZodType<any> | Record<string, unknown> | ((input: unknown) => unknown) | null;
236
279
  handler: ServiceHandler<TInput, TOutput>;
@@ -248,21 +291,66 @@ export interface ServiceDefinition<TInput = unknown, TOutput = unknown> {
248
291
  transaction?: boolean;
249
292
  circuitBreaker?: boolean | string | CircuitBreakerOptions | CircuitBreaker;
250
293
  retry?: boolean | number | RetryOptions;
294
+ before?: ((input: TInput, ctx: ServiceContext) => unknown) | Array<(input: TInput, ctx: ServiceContext) => unknown>;
295
+ after?: ((result: TOutput, input: TInput, ctx: ServiceContext) => unknown) | Array<(result: TOutput, input: TInput, ctx: ServiceContext) => unknown>;
296
+ onError?: ((error: Error, input: TInput, ctx: ServiceContext) => unknown) | Array<(error: Error, input: TInput, ctx: ServiceContext) => unknown>;
297
+ middleware?: ((context: ServiceMiddlewareContext<TInput>, next: () => Promise<unknown>) => Promise<unknown>) | Array<(context: ServiceMiddlewareContext<TInput>, next: () => Promise<unknown>) => Promise<unknown>>;
251
298
  [key: string]: unknown;
252
299
  }
253
300
 
254
301
  export class ServiceRegistry {
255
- constructor(options?: { servicesDir?: string; isDev?: boolean; logger?: unknown });
302
+ constructor(options?: {
303
+ servicesDir?: string;
304
+ isDev?: boolean;
305
+ logger?: unknown;
306
+ timeouts?: unknown;
307
+ timeout?: number | string;
308
+ lazy?: boolean;
309
+ middlewares?: Array<(context: ServiceMiddlewareContext, next: () => Promise<unknown>) => Promise<unknown>>;
310
+ middleware?: (context: ServiceMiddlewareContext, next: () => Promise<unknown>) => Promise<unknown>;
311
+ before?: Array<(context: ServiceBeforeContext) => unknown> | ((context: ServiceBeforeContext) => unknown);
312
+ after?: Array<(context: ServiceAfterContext) => unknown> | ((context: ServiceAfterContext) => unknown);
313
+ onError?: Array<(context: ServiceErrorContext) => unknown> | ((context: ServiceErrorContext) => unknown);
314
+ events?: unknown;
315
+ cacheProvider?: unknown;
316
+ cache?: unknown;
317
+ });
256
318
  readonly services: Map<string, ServiceDefinition>;
319
+ readonly lazyServices: Map<string, unknown>;
320
+ readonly aliases: Map<string, string>;
321
+ readonly middlewares: Array<(context: ServiceMiddlewareContext, next: () => Promise<unknown>) => Promise<unknown>>;
322
+ readonly beforeHooks: Array<(context: ServiceBeforeContext) => unknown>;
323
+ readonly afterHooks: Array<(context: ServiceAfterContext) => unknown>;
324
+ readonly onErrorHooks: Array<(context: ServiceErrorContext) => unknown>;
257
325
  load(): this;
258
- register(name: string, definition: ServiceDefinition | ServiceHandler): this;
326
+ loadAsync(): Promise<this>;
327
+ register(name: string, definition: ServiceDefinition | ServiceHandler, options?: { overwrite?: boolean; aliases?: string[] }): this;
328
+ registerLazy(
329
+ name: string,
330
+ loader: (() => Promise<unknown> | unknown) | string,
331
+ options?: { overwrite?: boolean; aliases?: string[]; filePath?: string }
332
+ ): this;
333
+ use(middleware: (context: ServiceMiddlewareContext, next: () => Promise<unknown>) => Promise<unknown>): this;
334
+ before(hook: (context: ServiceBeforeContext) => unknown): this;
335
+ after(hook: (context: ServiceAfterContext) => unknown): this;
336
+ onError(hook: (context: ServiceErrorContext) => unknown): this;
337
+ setEventDispatcher(events: unknown): this;
259
338
  get(name: string): ServiceDefinition | undefined;
339
+ getAsync(name: string): Promise<ServiceDefinition | undefined>;
260
340
  has(name: string): boolean;
261
341
  list(): string[];
342
+ unregister(name: string): boolean;
262
343
  invalidate(name: string, input?: unknown, ctx?: unknown): boolean;
263
344
  clearCache(name?: string): boolean;
345
+ clearAllCaches(): boolean;
346
+ invalidateByTag(tag: string, serviceName?: string): number | Promise<number>;
347
+ invalidateByTags(tags: string | string[], serviceName?: string): number | Promise<number>;
348
+ invalidateTag?(tag: string, serviceName?: string): number | Promise<number>;
349
+ invalidateTags?(tags: string | string[], serviceName?: string): number | Promise<number>;
264
350
  call<T = unknown>(name: string, input?: unknown, ctx?: unknown, options?: unknown): Promise<T>;
351
+ execute<T = unknown>(name: string, input?: unknown, ctx?: unknown, options?: unknown): Promise<T>;
265
352
  reload(): this;
353
+ reloadAsync(): Promise<this>;
266
354
  }
267
355
 
268
356
  export function createServiceRegistry(options?: {
@@ -270,8 +358,42 @@ export function createServiceRegistry(options?: {
270
358
  isDev?: boolean;
271
359
  logger?: unknown;
272
360
  autoLoad?: boolean;
361
+ lazy?: boolean;
362
+ timeouts?: unknown;
363
+ timeout?: number | string;
364
+ middlewares?: Array<(context: ServiceMiddlewareContext, next: () => Promise<unknown>) => Promise<unknown>>;
365
+ middleware?: (context: ServiceMiddlewareContext, next: () => Promise<unknown>) => Promise<unknown>;
366
+ before?: Array<(context: ServiceBeforeContext) => unknown> | ((context: ServiceBeforeContext) => unknown);
367
+ after?: Array<(context: ServiceAfterContext) => unknown> | ((context: ServiceAfterContext) => unknown);
368
+ onError?: Array<(context: ServiceErrorContext) => unknown> | ((context: ServiceErrorContext) => unknown);
369
+ events?: unknown;
370
+ cacheProvider?: unknown;
371
+ cache?: unknown;
273
372
  }): ServiceRegistry;
274
373
 
374
+ export function createServiceRegistryAsync(options?: {
375
+ servicesDir?: string;
376
+ isDev?: boolean;
377
+ logger?: unknown;
378
+ autoLoad?: boolean;
379
+ lazy?: boolean;
380
+ timeouts?: unknown;
381
+ timeout?: number | string;
382
+ middlewares?: Array<(context: ServiceMiddlewareContext, next: () => Promise<unknown>) => Promise<unknown>>;
383
+ middleware?: (context: ServiceMiddlewareContext, next: () => Promise<unknown>) => Promise<unknown>;
384
+ before?: Array<(context: ServiceBeforeContext) => unknown> | ((context: ServiceBeforeContext) => unknown);
385
+ after?: Array<(context: ServiceAfterContext) => unknown> | ((context: ServiceAfterContext) => unknown);
386
+ onError?: Array<(context: ServiceErrorContext) => unknown> | ((context: ServiceErrorContext) => unknown);
387
+ events?: unknown;
388
+ cacheProvider?: unknown;
389
+ cache?: unknown;
390
+ }): Promise<ServiceRegistry>;
391
+
392
+ export function composeMiddleware(middlewares: Function[]): (context: unknown, next?: Function) => Promise<unknown>;
393
+ export function loadModuleSync(filePath: string, options?: { isDev?: boolean }): unknown;
394
+ export function loadModuleAsync(filePath: string, options?: { isDev?: boolean }): Promise<unknown>;
395
+ export function extractModuleExport(mod: unknown): unknown;
396
+
275
397
  export type InferServiceInput<T> = T extends ServiceDefinition<infer TInput, any>
276
398
  ? TInput
277
399
  : T extends { schema: import('zod').ZodType<infer TInput> }
@@ -297,15 +419,22 @@ export const service: typeof defineService;
297
419
  export interface MemoizeOptions {
298
420
  ttl?: string | number | boolean;
299
421
  maxSize?: number;
422
+ clone?: boolean;
300
423
  key?: (input: unknown, ctx?: unknown) => string;
424
+ tags?: string | string[] | ((input: unknown, ctx?: unknown) => string | string[]);
425
+ provider?: unknown;
301
426
  }
302
427
 
303
428
  export interface MemoizedFunction<TArgs extends unknown[], TReturn> {
304
429
  (...args: TArgs): Promise<TReturn>;
305
- invalidate(...args: TArgs): boolean;
306
- delete(...args: TArgs): boolean;
307
- clear(): void;
308
- has(...args: TArgs): boolean;
430
+ invalidate(...args: TArgs): boolean | Promise<boolean>;
431
+ delete(...args: TArgs): boolean | Promise<boolean>;
432
+ invalidateByTag(tag: string): number | Promise<number>;
433
+ invalidateByTags(tags: string | string[]): number | Promise<number>;
434
+ invalidateTag?(tag: string): number | Promise<number>;
435
+ invalidateTags?(tags: string | string[]): number | Promise<number>;
436
+ clear(): void | Promise<void>;
437
+ has(...args: TArgs): boolean | Promise<boolean>;
309
438
  readonly size: number;
310
439
  }
311
440
 
@@ -363,7 +492,28 @@ export function createExchangeServices(options?: {
363
492
  db?: unknown;
364
493
  }): Record<string, ServiceDefinition>;
365
494
 
366
- export function validateServiceInput(schema: unknown, input: unknown, serviceName: string): unknown;
495
+ export function validateServiceInput(
496
+ schema: unknown,
497
+ input: unknown,
498
+ serviceName: string,
499
+ options?: { rejectDangerousKeys?: boolean }
500
+ ): unknown;
501
+
502
+ export function createServiceValidationError(
503
+ message: string,
504
+ options?: {
505
+ serviceName: string;
506
+ fields?: Record<string, string>;
507
+ fieldErrors?: Record<string, string[]>;
508
+ details?: unknown[];
509
+ cause?: unknown;
510
+ status?: number;
511
+ }
512
+ ): ValidationError;
513
+
514
+ export function normalizeValidationIssues(
515
+ issues: unknown[]
516
+ ): Array<{ path: string; message: string; code?: string }>;
367
517
 
368
518
  export function executeService(
369
519
  registry: ServiceRegistry,
@@ -650,7 +800,19 @@ export class CircuitBreakerTimeoutError extends GatewayTimeoutError {
650
800
 
651
801
  export class ValidationError extends HttpError {
652
802
  fields: Record<string, string[] | string>;
653
- constructor(message?: string, options?: ErrorOptions & { fields?: Record<string, string[] | string> });
803
+ fieldErrors?: Record<string, string[]>;
804
+ service?: string;
805
+ issues?: unknown[];
806
+ details?: unknown[];
807
+ normalizedIssues?: Array<{ path: string; message: string; code?: string }>;
808
+ constructor(message?: string, options?: ErrorOptions & {
809
+ fields?: Record<string, string[] | string>;
810
+ fieldErrors?: Record<string, string[]>;
811
+ service?: string;
812
+ statusCode?: number;
813
+ issues?: unknown[];
814
+ details?: unknown[];
815
+ });
654
816
  }
655
817
 
656
818
  export class ConfigurationError extends WebspressoError { constructor(message: string, options?: ErrorOptions); }
@@ -5076,6 +5238,7 @@ export function createUserSessionsTable(knex: any): Promise<void>;
5076
5238
  export function dropUserSessionsTable(knex: any): Promise<void>;
5077
5239
  export function createKnexSessionTracker(db: any, options?: { tableName?: string }): SessionTracker;
5078
5240
  export function createMemorySessionTracker(): SessionTracker;
5241
+ export function createKvSessionTracker(kv: any, options?: { prefix?: string; ttlSeconds?: number }): SessionTracker;
5079
5242
  export function createListSessionsHandler(authManager: any): (req: any, res: any) => Promise<any>;
5080
5243
  export function createRevokeSessionHandler(authManager: any): (req: any, res: any) => Promise<any>;
5081
5244
 
@@ -5121,6 +5284,7 @@ export namespace sessions {
5121
5284
  dropUserSessionsTable,
5122
5285
  createKnexSessionTracker,
5123
5286
  createMemorySessionTracker,
5287
+ createKvSessionTracker,
5124
5288
  createRedisSessionStore,
5125
5289
  createRedisSessionTracker,
5126
5290
  RedisSessionStore,
package/index.js CHANGED
@@ -117,6 +117,7 @@ module.exports = {
117
117
  setAppContext,
118
118
 
119
119
  // Services Layer
120
+ services: require('./core/services'),
120
121
  createServiceRegistry,
121
122
  defineService,
122
123
  service: defineService,
@@ -541,6 +542,9 @@ Object.defineProperties(module.exports, {
541
542
  parseUserAgent: { get: () => require('./core/auth').parseUserAgent, enumerable: true, configurable: true },
542
543
  sessions: { get: () => require('./core/auth').sessions, enumerable: true, configurable: true },
543
544
  SessionManager: { get: () => require('./core/auth').SessionManager, enumerable: true, configurable: true },
545
+ createKnexSessionTracker: { get: () => require('./core/auth').createKnexSessionTracker, enumerable: true, configurable: true },
546
+ createMemorySessionTracker: { get: () => require('./core/auth').createMemorySessionTracker, enumerable: true, configurable: true },
547
+ createKvSessionTracker: { get: () => require('./core/auth').createKvSessionTracker, enumerable: true, configurable: true },
544
548
  createRedisSessionStore: { get: () => require('./core/auth').createRedisSessionStore, enumerable: true, configurable: true },
545
549
  createRedisSessionTracker: { get: () => require('./core/auth').createRedisSessionTracker, enumerable: true, configurable: true },
546
550
  RedisSessionStore: { get: () => require('./core/auth').RedisSessionStore, enumerable: true, configurable: true },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webspresso",
3
- "version": "0.0.106",
3
+ "version": "0.0.107",
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",
@@ -18,7 +18,15 @@
18
18
  "./plugins/polar/src/*": "./plugins/polar/src/*.js",
19
19
  "./plugins/*": "./plugins/*/index.js",
20
20
  "./realtime": "./core/realtime/index.js",
21
+ "./services": {
22
+ "types": "./index.d.ts",
23
+ "default": "./core/services/index.js"
24
+ },
21
25
  "./core": "./core/index.js",
26
+ "./core/services": {
27
+ "types": "./index.d.ts",
28
+ "default": "./core/services/index.js"
29
+ },
22
30
  "./core/auth": "./core/auth/index.js",
23
31
  "./core/auth/*": "./core/auth/*.js",
24
32
  "./core/errors": "./core/errors/index.js",
@@ -39,17 +47,17 @@
39
47
  },
40
48
  "scripts": {
41
49
  "test": "vitest run",
42
- "test:unit": "vitest run tests/unit/",
43
- "test:integration": "vitest run tests/integration/",
50
+ "test:unit": "vitest run tests/unit/ tests/plugins/unit/",
51
+ "test:integration": "vitest run tests/integration/ tests/plugins/integration/",
44
52
  "test:security": "vitest run tests/security/",
45
53
  "test:orm": "vitest run tests/**/orm* tests/**/database*",
46
54
  "test:auth": "vitest run tests/**/auth* tests/unit/server-auth-branches.test.js",
47
55
  "test:admin": "vitest run tests/**/admin*",
48
- "test:services": "vitest run tests/**/service*",
49
- "test:plugins": "vitest run tests/**/plugin* tests/unit/*plugin*.test.js",
56
+ "test:services": "vitest run tests/unit/services/ tests/**/service*",
57
+ "test:plugins": "vitest run tests/plugins/",
50
58
  "test:core": "vitest run tests/unit/core* tests/unit/server* tests/unit/routing* tests/unit/template*",
51
59
  "test:branches": "vitest run tests/unit/*scenarios*.test.js tests/unit/*branches*.test.js",
52
- "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",
60
+ "test:fast": "vitest run tests/unit/*scenarios*.test.js tests/unit/*branches*.test.js tests/unit/auth.test.js tests/unit/services/services.test.js tests/unit/server.test.js",
53
61
  "test:watch": "vitest",
54
62
  "test:coverage": "vitest run --coverage",
55
63
  "test:coverage:quick": "vitest run --coverage",
@@ -129,7 +137,7 @@
129
137
  "multer": "^2.4.0",
130
138
  "nodemailer": "^10.0.10",
131
139
  "nunjucks": "^3.2.4",
132
- "sanitize-html": "^2.17.7",
140
+ "sanitize-html": "^2.18.1",
133
141
  "sharp": "^0.35.5",
134
142
  "swup": "^4.8.3",
135
143
  "yaml": "^2.8.3",
@@ -194,7 +202,9 @@
194
202
  "undici": "^6.28.1",
195
203
  "uuid": "^11.1.1",
196
204
  "js-yaml": "^5.4.2",
197
- "qs": "^6.16.0"
205
+ "qs": "^6.16.0",
206
+ "sanitize-html": "^2.18.1",
207
+ "vite": "^8.3.5"
198
208
  },
199
209
  "allowScripts": {
200
210
  "better-sqlite3@11.10.0": true
@@ -518,7 +518,7 @@ function parseFilterQuery(queryString) {
518
518
  if (!queryString) return filters;
519
519
 
520
520
  const params = new URLSearchParams(queryString);
521
- const filterParams = {};
521
+ const filterParams = Object.create(null);
522
522
 
523
523
  // Group filter parameters using simple string parsing (no regex needed)
524
524
  for (const [key, value] of params.entries()) {
@@ -532,7 +532,10 @@ function parseFilterQuery(queryString) {
532
532
  const col = key.substring(7, firstClose);
533
533
  const prop = key.substring(secondOpen + 1, secondClose);
534
534
 
535
- if (!filterParams[col]) filterParams[col] = {};
535
+ if (col === '__proto__' || col === 'constructor' || col === 'prototype') continue;
536
+ if (prop === '__proto__' || prop === 'constructor' || prop === 'prototype') continue;
537
+
538
+ if (!filterParams[col]) filterParams[col] = Object.create(null);
536
539
  if (prop === 'value' && filterParams[col].value) {
537
540
  // Multiple values for 'in' operator
538
541
  if (!Array.isArray(filterParams[col].value)) {
@@ -324,6 +324,7 @@ function adminPanelPlugin(options = {}) {
324
324
  db,
325
325
  auth,
326
326
  config: userMgmtConfig,
327
+ hashPassword: (password, rounds) => bcrypt.hash(password, process.env.NODE_ENV === 'test' ? 4 : (rounds || 10)),
327
328
  });
328
329
  }
329
330
 
@@ -570,24 +571,36 @@ function adminPanelPlugin(options = {}) {
570
571
  // Static Vendor Assets (Offline Zero-CDN)
571
572
  // ==========================================
572
573
  const vendorDir = path.join(__dirname, 'vendor');
574
+ const vendorAssetCache = new Map();
573
575
  ctx.addRoute('get', `${adminPath}/vendor/:file`, (req, res) => {
574
576
  const fileName = path.basename(req.params.file);
575
- const filePath = path.join(vendorDir, fileName);
576
-
577
- if (!fs.existsSync(filePath)) {
578
- return res.status(404).send('Vendor asset not found');
577
+ let cached = vendorAssetCache.get(fileName);
578
+ if (cached === undefined) {
579
+ const filePath = path.join(vendorDir, fileName);
580
+ if (!fs.existsSync(filePath)) {
581
+ vendorAssetCache.set(fileName, null);
582
+ return res.status(404).send('Vendor asset not found');
583
+ }
584
+ let contentType = 'application/octet-stream';
585
+ if (fileName.endsWith('.js')) {
586
+ contentType = 'application/javascript';
587
+ } else if (fileName.endsWith('.css')) {
588
+ contentType = 'text/css';
589
+ }
590
+ cached = {
591
+ buffer: fs.readFileSync(filePath),
592
+ contentType,
593
+ };
594
+ vendorAssetCache.set(fileName, cached);
579
595
  }
580
596
 
581
- if (fileName.endsWith('.js')) {
582
- res.type('application/javascript');
583
- } else if (fileName.endsWith('.css')) {
584
- res.type('text/css');
585
- } else {
586
- res.type('application/octet-stream');
597
+ if (!cached) {
598
+ return res.status(404).send('Vendor asset not found');
587
599
  }
588
600
 
601
+ res.type(cached.contentType);
589
602
  res.setHeader('Cache-Control', 'public, max-age=86400');
590
- res.sendFile(filePath);
603
+ res.send(cached.buffer);
591
604
  });
592
605
 
593
606
  // ==========================================
@@ -50,7 +50,7 @@ function resolveUserManagementModelName(config = {}) {
50
50
  * @param {Object} [options.config] - User management config
51
51
  */
52
52
  function registerUserManagement(options) {
53
- const { registry, db, auth, config = {} } = options;
53
+ const { registry, db, auth, config = {}, hashPassword: customHashPassword } = options;
54
54
 
55
55
  const {
56
56
  fields = {},
@@ -230,7 +230,8 @@ function registerUserManagement(options) {
230
230
  const repo = db.getRepository(targetModel);
231
231
  // Generate random password
232
232
  const newPassword = generateRandomPassword(12);
233
- const hashedPassword = await hash(newPassword);
233
+ const hashFn = customHashPassword || ((p) => hash(p, process.env.NODE_ENV === 'test' ? 4 : 10));
234
+ const hashedPassword = await hashFn(newPassword);
234
235
  await repo.update(record.id, { [fieldMap.password]: hashedPassword });
235
236
  return { message: 'Password reset', newPassword }; // Return new password to show in UI
236
237
  },
@@ -171,13 +171,14 @@ function csrfPlugin(options = {}) {
171
171
  if (activeMethods.includes(method) && !isIgnored(req, activeIgnorePaths)) {
172
172
  const body = req.body && typeof req.body === 'object' ? req.body : EMPTY_OBJECT;
173
173
  const query = req.query && typeof req.query === 'object' ? req.query : EMPTY_OBJECT;
174
- const submitted = (
174
+ const rawSubmitted = (
175
175
  body._csrf ||
176
176
  query._csrf ||
177
177
  req.headers['x-csrf-token'] ||
178
178
  req.headers['xsrf-token'] ||
179
179
  req.headers['x-xsrf-token']
180
180
  );
181
+ const submitted = typeof rawSubmitted === 'string' ? rawSubmitted : null;
181
182
 
182
183
  if (!submitted || !safeCompare(submitted, csrfTokenValue)) {
183
184
  res.status(errorStatus);