@martel/calyx 1.4.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/package.json +1 -1
- package/src/config/config.module.ts +61 -0
- package/src/config/config.service.ts +24 -0
- package/src/config/index.ts +2 -0
- package/src/event-emitter/decorators.ts +10 -0
- package/src/event-emitter/event-emitter.module.ts +17 -0
- package/src/event-emitter/event-emitter.ts +61 -0
- package/src/event-emitter/index.ts +3 -0
- package/src/http/application.ts +115 -0
- package/src/index.ts +4 -0
- package/src/schedule/cron.matcher.ts +45 -0
- package/src/schedule/decorators.ts +28 -0
- package/src/schedule/index.ts +3 -0
- package/src/schedule/schedule.module.ts +13 -0
- package/src/security/cors.middleware.ts +50 -0
- package/src/security/hashing.service.ts +12 -0
- package/src/security/helmet.middleware.ts +45 -0
- package/src/security/index.ts +3 -0
- package/tests/config-event.test.ts +145 -0
- package/tests/schedule.test.ts +64 -0
- package/tests/security.test.ts +89 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
# [1.5.0](https://github.com/bmartel/calyx/compare/v1.4.0...v1.5.0) (2026-07-01)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* **security,schedule:** implement ConfigModule, EventEmitterModule, HashingService, CORS, Helmet, and Task Scheduler ([6cfa8ca](https://github.com/bmartel/calyx/commit/6cfa8ca9459d73c7a7b1087953991d675d3a55db))
|
|
7
|
+
|
|
1
8
|
# [1.4.0](https://github.com/bmartel/calyx/compare/v1.3.0...v1.4.0) (2026-07-01)
|
|
2
9
|
|
|
3
10
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'fs';
|
|
2
|
+
import { Module, DynamicModule } from '../core/decorators.ts';
|
|
3
|
+
import { ConfigService } from './config.service.ts';
|
|
4
|
+
|
|
5
|
+
export interface ConfigModuleOptions {
|
|
6
|
+
isGlobal?: boolean;
|
|
7
|
+
load?: (() => Record<string, any>)[];
|
|
8
|
+
envFilePath?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
@Module({
|
|
12
|
+
providers: [ConfigService],
|
|
13
|
+
exports: [ConfigService],
|
|
14
|
+
})
|
|
15
|
+
export class ConfigModule {
|
|
16
|
+
static forRoot(options: ConfigModuleOptions = {}): DynamicModule {
|
|
17
|
+
const configData: Record<string, string> = { ...(process.env as Record<string, string>) };
|
|
18
|
+
|
|
19
|
+
if (options.envFilePath && existsSync(options.envFilePath)) {
|
|
20
|
+
try {
|
|
21
|
+
const text = readFileSync(options.envFilePath, 'utf-8');
|
|
22
|
+
if (text) {
|
|
23
|
+
const lines = text.split('\n');
|
|
24
|
+
for (const line of lines) {
|
|
25
|
+
const trimmed = line.trim();
|
|
26
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
27
|
+
const eqIdx = trimmed.indexOf('=');
|
|
28
|
+
if (eqIdx !== -1) {
|
|
29
|
+
const key = trimmed.substring(0, eqIdx).trim();
|
|
30
|
+
const val = trimmed.substring(eqIdx + 1).trim();
|
|
31
|
+
configData[key] = val.replace(/^['"]|['"]$/g, '');
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
} catch (err) {
|
|
36
|
+
// ignore read error
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (options.load) {
|
|
41
|
+
for (const factory of options.load) {
|
|
42
|
+
const data = factory();
|
|
43
|
+
for (const [key, val] of Object.entries(data)) {
|
|
44
|
+
configData[key] = String(val);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const configServiceProvider = {
|
|
50
|
+
provide: ConfigService,
|
|
51
|
+
useValue: new ConfigService(configData),
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
module: ConfigModule,
|
|
56
|
+
providers: [configServiceProvider],
|
|
57
|
+
exports: [ConfigService],
|
|
58
|
+
global: options.isGlobal ?? false,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Injectable } from '../core/decorators.ts';
|
|
2
|
+
|
|
3
|
+
@Injectable()
|
|
4
|
+
export class ConfigService {
|
|
5
|
+
private readonly env: Record<string, string> = {};
|
|
6
|
+
|
|
7
|
+
constructor(internalConfig?: Record<string, string>) {
|
|
8
|
+
this.env = internalConfig ?? (process.env as Record<string, string>);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
get<T = string>(path: string, defaultValue?: T): T {
|
|
12
|
+
const val = this.env[path];
|
|
13
|
+
if (val === undefined) {
|
|
14
|
+
return defaultValue as T;
|
|
15
|
+
}
|
|
16
|
+
if (val === 'true') return true as unknown as T;
|
|
17
|
+
if (val === 'false') return false as unknown as T;
|
|
18
|
+
const num = Number(val);
|
|
19
|
+
if (!isNaN(num) && val.trim() !== '') {
|
|
20
|
+
return num as unknown as T;
|
|
21
|
+
}
|
|
22
|
+
return val as unknown as T;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import 'reflect-metadata';
|
|
2
|
+
|
|
3
|
+
export function OnEvent(event: string): MethodDecorator {
|
|
4
|
+
return (target, propertyKey) => {
|
|
5
|
+
const constructor = target.constructor;
|
|
6
|
+
const existing = Reflect.getOwnMetadata('calyx:on_event', constructor) || [];
|
|
7
|
+
existing.push({ event, propertyKey });
|
|
8
|
+
Reflect.defineMetadata('calyx:on_event', existing, constructor);
|
|
9
|
+
};
|
|
10
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Module, DynamicModule } from '../core/decorators.ts';
|
|
2
|
+
import { EventEmitter } from './event-emitter.ts';
|
|
3
|
+
|
|
4
|
+
@Module({
|
|
5
|
+
providers: [EventEmitter],
|
|
6
|
+
exports: [EventEmitter],
|
|
7
|
+
})
|
|
8
|
+
export class EventEmitterModule {
|
|
9
|
+
static forRoot(): DynamicModule {
|
|
10
|
+
return {
|
|
11
|
+
module: EventEmitterModule,
|
|
12
|
+
providers: [EventEmitter],
|
|
13
|
+
exports: [EventEmitter],
|
|
14
|
+
global: true,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { Injectable } from '../core/decorators.ts';
|
|
2
|
+
|
|
3
|
+
@Injectable()
|
|
4
|
+
export class EventEmitter {
|
|
5
|
+
private readonly listeners = new Map<string, Function[]>();
|
|
6
|
+
private readonly wildcardListeners: { pattern: RegExp; fn: Function }[] = [];
|
|
7
|
+
|
|
8
|
+
on(event: string, fn: Function) {
|
|
9
|
+
if (event.includes('*')) {
|
|
10
|
+
const regexStr = '^' + event.replace(/\./g, '\\.').replace(/\*/g, '.*') + '$';
|
|
11
|
+
this.wildcardListeners.push({ pattern: new RegExp(regexStr), fn });
|
|
12
|
+
} else {
|
|
13
|
+
let list = this.listeners.get(event);
|
|
14
|
+
if (!list) {
|
|
15
|
+
list = [];
|
|
16
|
+
this.listeners.set(event, list);
|
|
17
|
+
}
|
|
18
|
+
list.push(fn);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
emit(event: string, ...args: any[]) {
|
|
23
|
+
const list = this.listeners.get(event);
|
|
24
|
+
if (list) {
|
|
25
|
+
for (const fn of list) {
|
|
26
|
+
fn(...args);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
for (const item of this.wildcardListeners) {
|
|
31
|
+
if (item.pattern.test(event)) {
|
|
32
|
+
item.fn(...args);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async emitAsync(event: string, ...args: any[]): Promise<any[]> {
|
|
38
|
+
const promises: Promise<any>[] = [];
|
|
39
|
+
|
|
40
|
+
const list = this.listeners.get(event);
|
|
41
|
+
if (list) {
|
|
42
|
+
for (const fn of list) {
|
|
43
|
+
const res = fn(...args);
|
|
44
|
+
if (res instanceof Promise) {
|
|
45
|
+
promises.push(res);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
for (const item of this.wildcardListeners) {
|
|
51
|
+
if (item.pattern.test(event)) {
|
|
52
|
+
const res = item.fn(...args);
|
|
53
|
+
if (res instanceof Promise) {
|
|
54
|
+
promises.push(res);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return Promise.all(promises);
|
|
60
|
+
}
|
|
61
|
+
}
|
package/src/http/application.ts
CHANGED
|
@@ -6,6 +6,10 @@ import { HttpException, NotFoundException } from './exceptions.ts';
|
|
|
6
6
|
import { ArgumentsHost, ExecutionContext } from '../lifecycle/interfaces.ts';
|
|
7
7
|
import { CalyxArgumentsHost, CalyxExecutionContext } from '../lifecycle/context.ts';
|
|
8
8
|
import { CalyxMiddlewareConsumer, MiddlewareConfiguration, RequestMethodMap, CompiledLifecycleItem as CompiledMiddlewareItem } from './middleware.ts';
|
|
9
|
+
import { EventEmitter } from '../event-emitter/event-emitter.ts';
|
|
10
|
+
import { cors, CorsOptions } from '../security/cors.middleware.ts';
|
|
11
|
+
import { helmet, HelmetOptions } from '../security/helmet.middleware.ts';
|
|
12
|
+
import { CronMatcher } from '../schedule/cron.matcher.ts';
|
|
9
13
|
|
|
10
14
|
class ObjectPool<T> {
|
|
11
15
|
private pool: T[] = [];
|
|
@@ -109,6 +113,16 @@ export class CalyxApplication {
|
|
|
109
113
|
this.globalInterceptors.push(...interceptors);
|
|
110
114
|
}
|
|
111
115
|
|
|
116
|
+
enableCors(options?: CorsOptions) {
|
|
117
|
+
this.use(cors(options));
|
|
118
|
+
return this;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
useHelmet(options?: HelmetOptions) {
|
|
122
|
+
this.use(helmet(options));
|
|
123
|
+
return this;
|
|
124
|
+
}
|
|
125
|
+
|
|
112
126
|
useGlobalPipes(...pipes: any[]) {
|
|
113
127
|
this.globalPipes.push(...pipes);
|
|
114
128
|
}
|
|
@@ -129,6 +143,12 @@ export class CalyxApplication {
|
|
|
129
143
|
// Build the routing table from registered controllers
|
|
130
144
|
this.buildRoutes();
|
|
131
145
|
|
|
146
|
+
// Register Event listeners
|
|
147
|
+
this.registerEventListeners();
|
|
148
|
+
|
|
149
|
+
// Register Scheduled tasks
|
|
150
|
+
this.registerScheduledTasks();
|
|
151
|
+
|
|
132
152
|
// Call OnModuleInit hooks
|
|
133
153
|
await this.runOnModuleInit();
|
|
134
154
|
|
|
@@ -989,4 +1009,99 @@ export class CalyxApplication {
|
|
|
989
1009
|
}
|
|
990
1010
|
}
|
|
991
1011
|
}
|
|
1012
|
+
|
|
1013
|
+
private registerEventListeners() {
|
|
1014
|
+
let eventEmitter: EventEmitter;
|
|
1015
|
+
try {
|
|
1016
|
+
eventEmitter = this.container.getGlobalOrAnyInstance(EventEmitter);
|
|
1017
|
+
} catch {
|
|
1018
|
+
return;
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
const instances = this.container.getProviderAndControllerInstances();
|
|
1022
|
+
for (const instance of instances) {
|
|
1023
|
+
if (!instance || !instance.constructor) continue;
|
|
1024
|
+
const listeners: { event: string; propertyKey: string | symbol }[] =
|
|
1025
|
+
Reflect.getMetadata('calyx:on_event', instance.constructor) || [];
|
|
1026
|
+
|
|
1027
|
+
for (const listener of listeners) {
|
|
1028
|
+
eventEmitter.on(listener.event, (...args: any[]) => {
|
|
1029
|
+
return instance[listener.propertyKey](...args);
|
|
1030
|
+
});
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
private registerScheduledTasks() {
|
|
1036
|
+
let hasScheduleModule = false;
|
|
1037
|
+
for (const moduleClass of this.container.getModules().keys()) {
|
|
1038
|
+
if (moduleClass.name === 'ScheduleModule') {
|
|
1039
|
+
hasScheduleModule = true;
|
|
1040
|
+
break;
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
if (!hasScheduleModule) {
|
|
1044
|
+
return;
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
const instances = this.container.getProviderAndControllerInstances();
|
|
1048
|
+
for (const instance of instances) {
|
|
1049
|
+
if (!instance || !instance.constructor) continue;
|
|
1050
|
+
|
|
1051
|
+
const crons: { expression: string; propertyKey: string | symbol }[] =
|
|
1052
|
+
Reflect.getMetadata('calyx:cron', instance.constructor) || [];
|
|
1053
|
+
for (const cron of crons) {
|
|
1054
|
+
const parts = cron.expression.split(' ');
|
|
1055
|
+
const isSecondLevel = parts.length === 6;
|
|
1056
|
+
|
|
1057
|
+
let lastRan = 0;
|
|
1058
|
+
const tick = () => {
|
|
1059
|
+
const now = new Date();
|
|
1060
|
+
const nowMs = now.getTime();
|
|
1061
|
+
const timeUnit = isSecondLevel ? Math.floor(nowMs / 1000) : Math.floor(nowMs / 60000);
|
|
1062
|
+
if (timeUnit === lastRan) return;
|
|
1063
|
+
|
|
1064
|
+
if (CronMatcher.match(cron.expression, now)) {
|
|
1065
|
+
lastRan = timeUnit;
|
|
1066
|
+
try {
|
|
1067
|
+
instance[cron.propertyKey]();
|
|
1068
|
+
} catch (err) {
|
|
1069
|
+
console.error(`Error executing cron task ${String(cron.propertyKey)}:`, err);
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
};
|
|
1073
|
+
|
|
1074
|
+
const intervalMs = isSecondLevel ? 1000 : 20000;
|
|
1075
|
+
const timer = setInterval(tick, intervalMs);
|
|
1076
|
+
this.cleanupListeners.push(() => clearInterval(timer));
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
const intervals: { ms: number; propertyKey: string | symbol }[] =
|
|
1080
|
+
Reflect.getMetadata('calyx:interval', instance.constructor) || [];
|
|
1081
|
+
for (const interval of intervals) {
|
|
1082
|
+
const timer = setInterval(() => {
|
|
1083
|
+
try {
|
|
1084
|
+
instance[interval.propertyKey]();
|
|
1085
|
+
} catch (err) {
|
|
1086
|
+
console.error(`Error executing interval task ${String(interval.propertyKey)}:`, err);
|
|
1087
|
+
}
|
|
1088
|
+
}, interval.ms);
|
|
1089
|
+
this.cleanupListeners.push(() => clearInterval(timer));
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
const timeouts: { ms: number; propertyKey: string | symbol }[] =
|
|
1093
|
+
Reflect.getMetadata('calyx:timeout', instance.constructor) || [];
|
|
1094
|
+
for (const timeout of timeouts) {
|
|
1095
|
+
const timer = setTimeout(() => {
|
|
1096
|
+
try {
|
|
1097
|
+
instance[timeout.propertyKey]();
|
|
1098
|
+
} catch (err) {
|
|
1099
|
+
console.error(`Error executing timeout task ${String(timeout.propertyKey)}:`, err);
|
|
1100
|
+
}
|
|
1101
|
+
}, timeout.ms);
|
|
1102
|
+
this.cleanupListeners.push(() => clearTimeout(timer));
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
992
1106
|
}
|
|
1107
|
+
|
package/src/index.ts
CHANGED
|
@@ -2,3 +2,7 @@ import 'reflect-metadata';
|
|
|
2
2
|
export * from './core/index.ts';
|
|
3
3
|
export * from './http/index.ts';
|
|
4
4
|
export * from './lifecycle/index.ts';
|
|
5
|
+
export * from './config/index.ts';
|
|
6
|
+
export * from './event-emitter/index.ts';
|
|
7
|
+
export * from './security/index.ts';
|
|
8
|
+
export * from './schedule/index.ts';
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export class CronMatcher {
|
|
2
|
+
static match(pattern: string, date: Date): boolean {
|
|
3
|
+
const parts = pattern.split(' ');
|
|
4
|
+
if (parts.length < 5) return false;
|
|
5
|
+
|
|
6
|
+
const hasSeconds = parts.length === 6;
|
|
7
|
+
const sec = hasSeconds ? date.getSeconds() : 0;
|
|
8
|
+
const min = date.getMinutes();
|
|
9
|
+
const hour = date.getHours();
|
|
10
|
+
const dom = date.getDate();
|
|
11
|
+
const month = date.getMonth() + 1; // 1-12
|
|
12
|
+
const dow = date.getDay(); // 0-6 (Sunday-Saturday)
|
|
13
|
+
|
|
14
|
+
const matchPart = (part: string, val: number): boolean => {
|
|
15
|
+
if (part === '*') return true;
|
|
16
|
+
if (part.includes('/')) {
|
|
17
|
+
const [left, right] = part.split('/');
|
|
18
|
+
const step = Number(right);
|
|
19
|
+
if (left === '*') {
|
|
20
|
+
return val % step === 0;
|
|
21
|
+
}
|
|
22
|
+
const start = Number(left);
|
|
23
|
+
return val >= start && (val - start) % step === 0;
|
|
24
|
+
}
|
|
25
|
+
if (part.includes(',')) {
|
|
26
|
+
return part.split(',').map(Number).includes(val);
|
|
27
|
+
}
|
|
28
|
+
if (part.includes('-')) {
|
|
29
|
+
const [start, end] = part.split('-').map(Number);
|
|
30
|
+
return val >= start && val <= end;
|
|
31
|
+
}
|
|
32
|
+
return Number(part) === val;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
if (hasSeconds && !matchPart(parts[0], sec)) return false;
|
|
36
|
+
const offset = hasSeconds ? 1 : 0;
|
|
37
|
+
if (!matchPart(parts[offset + 0], min)) return false;
|
|
38
|
+
if (!matchPart(parts[offset + 1], hour)) return false;
|
|
39
|
+
if (!matchPart(parts[offset + 2], dom)) return false;
|
|
40
|
+
if (!matchPart(parts[offset + 3], month)) return false;
|
|
41
|
+
if (!matchPart(parts[offset + 4], dow)) return false;
|
|
42
|
+
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import 'reflect-metadata';
|
|
2
|
+
|
|
3
|
+
export function Cron(expression: string): MethodDecorator {
|
|
4
|
+
return (target, propertyKey) => {
|
|
5
|
+
const constructor = target.constructor;
|
|
6
|
+
const existing = Reflect.getOwnMetadata('calyx:cron', constructor) || [];
|
|
7
|
+
existing.push({ expression, propertyKey });
|
|
8
|
+
Reflect.defineMetadata('calyx:cron', existing, constructor);
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function Interval(ms: number): MethodDecorator {
|
|
13
|
+
return (target, propertyKey) => {
|
|
14
|
+
const constructor = target.constructor;
|
|
15
|
+
const existing = Reflect.getOwnMetadata('calyx:interval', constructor) || [];
|
|
16
|
+
existing.push({ ms, propertyKey });
|
|
17
|
+
Reflect.defineMetadata('calyx:interval', existing, constructor);
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function Timeout(ms: number): MethodDecorator {
|
|
22
|
+
return (target, propertyKey) => {
|
|
23
|
+
const constructor = target.constructor;
|
|
24
|
+
const existing = Reflect.getOwnMetadata('calyx:timeout', constructor) || [];
|
|
25
|
+
existing.push({ ms, propertyKey });
|
|
26
|
+
Reflect.defineMetadata('calyx:timeout', existing, constructor);
|
|
27
|
+
};
|
|
28
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { CalyxResponse } from '../http/application.ts';
|
|
2
|
+
|
|
3
|
+
export interface CorsOptions {
|
|
4
|
+
origin?: string | string[] | boolean;
|
|
5
|
+
methods?: string | string[];
|
|
6
|
+
allowedHeaders?: string | string[];
|
|
7
|
+
exposedHeaders?: string | string[];
|
|
8
|
+
credentials?: boolean;
|
|
9
|
+
maxAge?: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function cors(options: CorsOptions = {}) {
|
|
13
|
+
const origin = options.origin ?? '*';
|
|
14
|
+
const methods = options.methods ?? 'GET,HEAD,PUT,PATCH,POST,DELETE';
|
|
15
|
+
const allowedHeaders = options.allowedHeaders ?? '*';
|
|
16
|
+
const credentials = options.credentials ?? false;
|
|
17
|
+
const maxAge = options.maxAge;
|
|
18
|
+
|
|
19
|
+
return (req: Request, res: CalyxResponse, next: () => void) => {
|
|
20
|
+
const reqOrigin = req.headers.get('origin');
|
|
21
|
+
|
|
22
|
+
if (origin === '*') {
|
|
23
|
+
res.headers['access-control-allow-origin'] = '*';
|
|
24
|
+
} else if (typeof origin === 'string') {
|
|
25
|
+
res.headers['access-control-allow-origin'] = origin;
|
|
26
|
+
} else if (Array.isArray(origin) && reqOrigin && origin.includes(reqOrigin)) {
|
|
27
|
+
res.headers['access-control-allow-origin'] = reqOrigin;
|
|
28
|
+
} else if (origin === true && reqOrigin) {
|
|
29
|
+
res.headers['access-control-allow-origin'] = reqOrigin;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
res.headers['access-control-allow-methods'] = Array.isArray(methods) ? methods.join(',') : methods;
|
|
33
|
+
res.headers['access-control-allow-headers'] = Array.isArray(allowedHeaders) ? allowedHeaders.join(',') : allowedHeaders;
|
|
34
|
+
|
|
35
|
+
if (credentials) {
|
|
36
|
+
res.headers['access-control-allow-credentials'] = 'true';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (maxAge !== undefined) {
|
|
40
|
+
res.headers['access-control-max-age'] = String(maxAge);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (req.method === 'OPTIONS') {
|
|
44
|
+
res.status(204).send('');
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
next();
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Injectable } from '../core/decorators.ts';
|
|
2
|
+
|
|
3
|
+
@Injectable()
|
|
4
|
+
export class HashingService {
|
|
5
|
+
async hash(data: string, algorithm: 'bcrypt' | 'argon2id' | 'argon2i' | 'argon2d' = 'bcrypt'): Promise<string> {
|
|
6
|
+
return Bun.password.hash(data, algorithm);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
async verify(data: string, hash: string): Promise<boolean> {
|
|
10
|
+
return Bun.password.verify(data, hash);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { CalyxResponse } from '../http/application.ts';
|
|
2
|
+
|
|
3
|
+
export interface HelmetOptions {
|
|
4
|
+
contentSecurityPolicy?: boolean | string;
|
|
5
|
+
crossOriginOpenerPolicy?: string;
|
|
6
|
+
crossOriginResourcePolicy?: string;
|
|
7
|
+
referrerPolicy?: string;
|
|
8
|
+
xContentTypeOptions?: string;
|
|
9
|
+
xDnsPrefetchControl?: string;
|
|
10
|
+
xFrameOptions?: string;
|
|
11
|
+
xXssProtection?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function helmet(options: HelmetOptions = {}) {
|
|
15
|
+
const headers: Record<string, string> = {
|
|
16
|
+
'cross-origin-opener-policy': options.crossOriginOpenerPolicy ?? 'same-origin',
|
|
17
|
+
'cross-origin-resource-policy': options.crossOriginResourcePolicy ?? 'same-origin',
|
|
18
|
+
'origin-agent-cluster': '?1',
|
|
19
|
+
'referrer-policy': options.referrerPolicy ?? 'no-referrer',
|
|
20
|
+
'strict-transport-security': 'max-age=15552000; includeSubDomains',
|
|
21
|
+
'x-content-type-options': options.xContentTypeOptions ?? 'nosniff',
|
|
22
|
+
'x-dns-prefetch-control': options.xDnsPrefetchControl ?? 'off',
|
|
23
|
+
'x-download-options': 'noopen',
|
|
24
|
+
'x-frame-options': options.xFrameOptions ?? 'SAMEORIGIN',
|
|
25
|
+
'x-permitted-cross-domain-policies': 'none',
|
|
26
|
+
'x-xss-protection': options.xXssProtection ?? '0',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const csp = options.contentSecurityPolicy;
|
|
30
|
+
if (csp !== false) {
|
|
31
|
+
headers['content-security-policy'] = typeof csp === 'string'
|
|
32
|
+
? csp
|
|
33
|
+
: "default-src 'self';base-uri 'self';font-src 'self' https: data:;form-action 'self';frame-ancestors 'self';img-src 'self' data:;object-src 'none';script-src 'self';script-src-attr 'none';style-src 'self' https: 'unsafe-inline';upgrade-insecure-requests";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const headerPairs = Object.entries(headers);
|
|
37
|
+
|
|
38
|
+
return (req: Request, res: CalyxResponse, next: () => void) => {
|
|
39
|
+
for (let i = 0; i < headerPairs.length; i++) {
|
|
40
|
+
const pair = headerPairs[i];
|
|
41
|
+
res.headers[pair[0]] = pair[1];
|
|
42
|
+
}
|
|
43
|
+
next();
|
|
44
|
+
};
|
|
45
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
|
2
|
+
import {
|
|
3
|
+
Module,
|
|
4
|
+
Controller,
|
|
5
|
+
Get,
|
|
6
|
+
Injectable,
|
|
7
|
+
CalyxFactory,
|
|
8
|
+
ConfigModule,
|
|
9
|
+
ConfigService,
|
|
10
|
+
EventEmitterModule,
|
|
11
|
+
EventEmitter,
|
|
12
|
+
OnEvent,
|
|
13
|
+
} from '../src/index.ts';
|
|
14
|
+
|
|
15
|
+
// 1. Config Test classes
|
|
16
|
+
@Injectable()
|
|
17
|
+
class ConfigTestService {
|
|
18
|
+
constructor(private readonly config: ConfigService) {}
|
|
19
|
+
|
|
20
|
+
getDbUser(): string {
|
|
21
|
+
return this.config.get('DB_USER', 'default');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
getDbPort(): number {
|
|
25
|
+
return this.config.get('DB_PORT', 5432);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
getDebugMode(): boolean {
|
|
29
|
+
return this.config.get('DEBUG_MODE', false);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// 2. Event emitter test classes
|
|
34
|
+
let orderCreatedCount = 0;
|
|
35
|
+
let anyOrderCount = 0;
|
|
36
|
+
|
|
37
|
+
@Injectable()
|
|
38
|
+
class OrderService {
|
|
39
|
+
constructor(private readonly emitter: EventEmitter) {}
|
|
40
|
+
|
|
41
|
+
createOrder() {
|
|
42
|
+
this.emitter.emit('order.created', { id: 101, total: 50.0 });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
cancelOrder() {
|
|
46
|
+
this.emitter.emit('order.cancelled', { id: 101 });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
@Injectable()
|
|
51
|
+
class NotificationService {
|
|
52
|
+
@OnEvent('order.created')
|
|
53
|
+
handleOrderCreated(payload: any) {
|
|
54
|
+
orderCreatedCount++;
|
|
55
|
+
expect(payload.id).toBe(101);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
@OnEvent('order.*')
|
|
59
|
+
handleAnyOrder(payload: any) {
|
|
60
|
+
anyOrderCount++;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
@Controller('test')
|
|
65
|
+
class TestController {
|
|
66
|
+
constructor(
|
|
67
|
+
private readonly configTest: ConfigTestService,
|
|
68
|
+
private readonly orders: OrderService
|
|
69
|
+
) {}
|
|
70
|
+
|
|
71
|
+
@Get('config')
|
|
72
|
+
getConfig() {
|
|
73
|
+
return {
|
|
74
|
+
user: this.configTest.getDbUser(),
|
|
75
|
+
port: this.configTest.getDbPort(),
|
|
76
|
+
debug: this.configTest.getDebugMode(),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
@Get('trigger-event')
|
|
81
|
+
triggerEvent() {
|
|
82
|
+
this.orders.createOrder();
|
|
83
|
+
this.orders.cancelOrder();
|
|
84
|
+
return { triggered: true };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
@Module({
|
|
89
|
+
imports: [
|
|
90
|
+
ConfigModule.forRoot({
|
|
91
|
+
load: [
|
|
92
|
+
() => ({
|
|
93
|
+
DB_USER: 'calyx_admin',
|
|
94
|
+
DB_PORT: '8080',
|
|
95
|
+
DEBUG_MODE: 'true',
|
|
96
|
+
}),
|
|
97
|
+
],
|
|
98
|
+
}),
|
|
99
|
+
EventEmitterModule.forRoot(),
|
|
100
|
+
],
|
|
101
|
+
controllers: [TestController],
|
|
102
|
+
providers: [ConfigTestService, OrderService, NotificationService],
|
|
103
|
+
})
|
|
104
|
+
class AppModule {}
|
|
105
|
+
|
|
106
|
+
describe('Config and Event Modules Parity', () => {
|
|
107
|
+
let app: any;
|
|
108
|
+
let baseUrl: string;
|
|
109
|
+
const PORT = 3866;
|
|
110
|
+
|
|
111
|
+
beforeAll(async () => {
|
|
112
|
+
app = await CalyxFactory.create(AppModule);
|
|
113
|
+
await app.listen(PORT);
|
|
114
|
+
baseUrl = `http://localhost:${PORT}`;
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
afterAll(async () => {
|
|
118
|
+
await app.close();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('should resolve custom environment variables via ConfigService', async () => {
|
|
122
|
+
const res = await fetch(`${baseUrl}/test/config`);
|
|
123
|
+
expect(res.status).toBe(200);
|
|
124
|
+
const body = await res.json();
|
|
125
|
+
expect(body).toEqual({
|
|
126
|
+
user: 'calyx_admin',
|
|
127
|
+
port: 8080,
|
|
128
|
+
debug: true,
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('should trigger event and notify OnEvent listeners, including wildcards', async () => {
|
|
133
|
+
orderCreatedCount = 0;
|
|
134
|
+
anyOrderCount = 0;
|
|
135
|
+
|
|
136
|
+
const res = await fetch(`${baseUrl}/test/trigger-event`);
|
|
137
|
+
expect(res.status).toBe(200);
|
|
138
|
+
|
|
139
|
+
// Wait for event loop ticks
|
|
140
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
141
|
+
|
|
142
|
+
expect(orderCreatedCount).toBe(1);
|
|
143
|
+
expect(anyOrderCount).toBe(2); // Matches order.created and order.cancelled
|
|
144
|
+
});
|
|
145
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
|
2
|
+
import {
|
|
3
|
+
Module,
|
|
4
|
+
CalyxFactory,
|
|
5
|
+
ScheduleModule,
|
|
6
|
+
Cron,
|
|
7
|
+
Interval,
|
|
8
|
+
Timeout,
|
|
9
|
+
Injectable,
|
|
10
|
+
} from '../src/index.ts';
|
|
11
|
+
|
|
12
|
+
let cronCalls = 0;
|
|
13
|
+
let intervalCalls = 0;
|
|
14
|
+
let timeoutCalls = 0;
|
|
15
|
+
|
|
16
|
+
@Injectable()
|
|
17
|
+
class TaskService {
|
|
18
|
+
@Cron('*/1 * * * * *') // Every second
|
|
19
|
+
handleCron() {
|
|
20
|
+
cronCalls++;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
@Interval(100) // Every 100ms
|
|
24
|
+
handleInterval() {
|
|
25
|
+
intervalCalls++;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
@Timeout(250) // After 250ms
|
|
29
|
+
handleTimeout() {
|
|
30
|
+
timeoutCalls++;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
@Module({
|
|
35
|
+
imports: [ScheduleModule.forRoot()],
|
|
36
|
+
providers: [TaskService],
|
|
37
|
+
})
|
|
38
|
+
class TestApp {}
|
|
39
|
+
|
|
40
|
+
describe('Task Scheduler (ScheduleModule, Cron, Interval, Timeout)', () => {
|
|
41
|
+
let app: any;
|
|
42
|
+
|
|
43
|
+
beforeAll(async () => {
|
|
44
|
+
cronCalls = 0;
|
|
45
|
+
intervalCalls = 0;
|
|
46
|
+
timeoutCalls = 0;
|
|
47
|
+
|
|
48
|
+
app = await CalyxFactory.create(TestApp);
|
|
49
|
+
await app.init();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
afterAll(async () => {
|
|
53
|
+
await app.close();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('should fire intervals, timeouts, and cron jobs on schedule', async () => {
|
|
57
|
+
// Wait 500ms
|
|
58
|
+
await new Promise((resolve) => setTimeout(resolve, 550));
|
|
59
|
+
|
|
60
|
+
expect(cronCalls).toBeGreaterThanOrEqual(0);
|
|
61
|
+
expect(intervalCalls).toBeGreaterThan(3);
|
|
62
|
+
expect(timeoutCalls).toBe(1);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
|
2
|
+
import {
|
|
3
|
+
Module,
|
|
4
|
+
Controller,
|
|
5
|
+
Get,
|
|
6
|
+
CalyxFactory,
|
|
7
|
+
HashingService,
|
|
8
|
+
} from '../src/index.ts';
|
|
9
|
+
|
|
10
|
+
@Controller('test-security')
|
|
11
|
+
class SecurityController {
|
|
12
|
+
constructor(private readonly hashing: HashingService) {}
|
|
13
|
+
|
|
14
|
+
@Get('hash')
|
|
15
|
+
async testHash() {
|
|
16
|
+
const password = 'my_secure_password';
|
|
17
|
+
const hash = await this.hashing.hash(password);
|
|
18
|
+
const valid = await this.hashing.verify(password, hash);
|
|
19
|
+
const invalid = await this.hashing.verify('wrong_password', hash);
|
|
20
|
+
return { valid, invalid };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
@Get('hello')
|
|
24
|
+
sayHello() {
|
|
25
|
+
return { hello: 'world' };
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
@Module({
|
|
30
|
+
controllers: [SecurityController],
|
|
31
|
+
providers: [HashingService],
|
|
32
|
+
})
|
|
33
|
+
class SecurityTestApp {}
|
|
34
|
+
|
|
35
|
+
describe('Security Features (Hashing, CORS, Helmet)', () => {
|
|
36
|
+
let app: any;
|
|
37
|
+
let baseUrl: string;
|
|
38
|
+
const PORT = 3878;
|
|
39
|
+
|
|
40
|
+
beforeAll(async () => {
|
|
41
|
+
app = await CalyxFactory.create(SecurityTestApp);
|
|
42
|
+
app.enableCors({ origin: 'http://example.com', credentials: true });
|
|
43
|
+
app.useHelmet({ contentSecurityPolicy: false }); // Disable CSP to make tests simpler
|
|
44
|
+
await app.listen(PORT);
|
|
45
|
+
baseUrl = `http://localhost:${PORT}`;
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
afterAll(async () => {
|
|
49
|
+
await app.close();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('should hash and verify passwords using native Bun.password Zig bindings', async () => {
|
|
53
|
+
const res = await fetch(`${baseUrl}/test-security/hash`);
|
|
54
|
+
expect(res.status).toBe(200);
|
|
55
|
+
const body = await res.json();
|
|
56
|
+
expect(body.valid).toBe(true);
|
|
57
|
+
expect(body.invalid).toBe(false);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('should apply CORS headers and respond to preflight OPTIONS request', async () => {
|
|
61
|
+
// 1. Regular GET request with Origin
|
|
62
|
+
const res = await fetch(`${baseUrl}/test-security/hello`, {
|
|
63
|
+
headers: { Origin: 'http://example.com' },
|
|
64
|
+
});
|
|
65
|
+
expect(res.status).toBe(200);
|
|
66
|
+
expect(res.headers.get('access-control-allow-origin')).toBe('http://example.com');
|
|
67
|
+
expect(res.headers.get('access-control-allow-credentials')).toBe('true');
|
|
68
|
+
|
|
69
|
+
// 2. Preflight OPTIONS request
|
|
70
|
+
const preflight = await fetch(`${baseUrl}/test-security/hello`, {
|
|
71
|
+
method: 'OPTIONS',
|
|
72
|
+
headers: {
|
|
73
|
+
Origin: 'http://example.com',
|
|
74
|
+
'Access-Control-Request-Method': 'GET',
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
expect(preflight.status).toBe(204);
|
|
78
|
+
expect(preflight.headers.get('access-control-allow-origin')).toBe('http://example.com');
|
|
79
|
+
expect(preflight.headers.get('access-control-allow-methods')).toContain('GET');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('should set Helmet secure headers', async () => {
|
|
83
|
+
const res = await fetch(`${baseUrl}/test-security/hello`);
|
|
84
|
+
expect(res.status).toBe(200);
|
|
85
|
+
expect(res.headers.get('x-content-type-options')).toBe('nosniff');
|
|
86
|
+
expect(res.headers.get('x-frame-options')).toBe('SAMEORIGIN');
|
|
87
|
+
expect(res.headers.get('referrer-policy')).toBe('no-referrer');
|
|
88
|
+
});
|
|
89
|
+
});
|