@fuzionx/framework 0.1.30 → 0.1.31

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/cli/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * CLI — fx 명령어 핸들러
3
3
  *
4
- * fx make:app <name> 앱 디렉토리 생성
4
+ * fx make:app --type=ssr|spa 앱 디렉토리 생성 (고정 이름)
5
5
  * fx make:controller <Name> 컨트롤러 생성
6
6
  * fx make:service <Name> 서비스 생성
7
7
  * fx make:model <Name> 모델 생성
@@ -12,6 +12,8 @@
12
12
  * fx make:event <Name> 이벤트 핸들러 생성
13
13
  * fx make:worker <Name> Worker 생성
14
14
  * fx make:test <Name> 테스트 생성
15
+ * fx dev:spa FuzionX + Vite 동시 실행
16
+ * fx build:spa Vite 프로덕션 빌드
15
17
  *
16
18
  * @see docs/framework/16-cli.md
17
19
  */
@@ -157,21 +159,41 @@ export async function run(args) {
157
159
  }
158
160
 
159
161
  if (command === 'make:app') {
160
- const name = rest[0];
161
- if (!name) { console.error('Usage: fx make:app <appName>'); process.exit(1); }
162
- const appDir = path.join('.', 'app', name);
162
+ const typeFlag = rest.find(a => a.startsWith('--type='));
163
+ const type = typeFlag?.split('=')[1] || rest[0];
164
+ const validTypes = ['ssr', 'spa'];
165
+
166
+ if (!type || !validTypes.includes(type)) {
167
+ console.error(`Usage: fx make:app --type=ssr|spa`);
168
+ console.error(` Available types: ${validTypes.join(', ')}`);
169
+ process.exit(1);
170
+ }
171
+
172
+ // 앱 이름은 타입에 따라 고정
173
+ const appName = type;
174
+ const appDir = path.join('.', 'app', appName);
175
+
176
+ // 기존 앱 확인
177
+ try {
178
+ await fs.access(appDir);
179
+ console.error(`❌ app/${appName}/ already exists.`);
180
+ process.exit(1);
181
+ } catch { /* 없으면 정상 */ }
182
+
163
183
  // 빈 디렉토리 생성
164
184
  for (const d of ['services', 'middleware', 'ws']) {
165
185
  const fullDir = path.join(appDir, d);
166
186
  await fs.mkdir(fullDir, { recursive: true });
167
187
  await fs.writeFile(path.join(fullDir, '.gitkeep'), '');
168
188
  }
189
+
169
190
  // 기본 파일 복사 (HomeController, routes, views)
170
191
  const appTplDir = path.join(TPL_DIR, 'make/app');
171
192
  await copyDirRecursive(appTplDir, appDir);
172
- console.log(`✅ Created app/${name}/`);
193
+
194
+ console.log(`✅ Created app/${appName}/ (type: ${type})`);
173
195
  console.log(`\n fuzionx.yaml의 apps에 추가하세요:`);
174
- console.log(` "127.0.0.1:49080": ${name}\n`);
196
+ console.log(` "127.0.0.1:49080": ${appName}\n`);
175
197
  return;
176
198
  }
177
199
 
@@ -199,6 +221,45 @@ export async function run(args) {
199
221
  return;
200
222
  }
201
223
 
224
+ // ── fx dev:spa — FuzionX + Vite HMR 동시 실행 ──
225
+ if (command === 'dev:spa') {
226
+ const { execSync } = await import('node:child_process');
227
+ const spaDir = path.resolve('app/spa/views/default/spa');
228
+ try {
229
+ await fs.access(spaDir);
230
+ } catch {
231
+ console.error('❌ app/spa/views/default/spa/ 디렉토리가 없습니다.');
232
+ console.error(' fx make:app --type=spa 로 SPA 앱을 먼저 생성하세요.');
233
+ process.exit(1);
234
+ }
235
+ console.log(`🚀 Starting dev server + Vite HMR...`);
236
+ try {
237
+ execSync(
238
+ 'npx concurrently "node --watch app.js" "cd app/spa/views/default/spa && npx vite"',
239
+ { stdio: 'inherit', cwd: process.cwd() },
240
+ );
241
+ } catch {}
242
+ return;
243
+ }
244
+
245
+ // ── fx build:spa — Vite 프로덕션 빌드 ──
246
+ if (command === 'build:spa') {
247
+ const { execSync } = await import('node:child_process');
248
+ const spaDir = path.resolve('app/spa/views/default/spa');
249
+ try {
250
+ await fs.access(spaDir);
251
+ } catch {
252
+ console.error('❌ app/spa/views/default/spa/ 디렉토리가 없습니다.');
253
+ process.exit(1);
254
+ }
255
+ console.log(`📦 Building SPA for production...`);
256
+ try {
257
+ execSync('npx vite build', { stdio: 'inherit', cwd: spaDir });
258
+ console.log('\n✅ Build complete → public/dist/');
259
+ } catch { process.exit(1); }
260
+ return;
261
+ }
262
+
202
263
  // ── fx stop — 서버 종료 ──
203
264
  if (command === 'stop') {
204
265
  const pidFile = path.resolve('fuzionx.pid');
@@ -315,18 +376,90 @@ export async function run(args) {
315
376
  const modelsDir = path.resolve('database/models');
316
377
  const files = await fs.readdir(modelsDir);
317
378
  const jsFiles = files.filter(f => f.endsWith('.js'));
379
+ const apply = rest.includes('--apply');
380
+
381
+ // fuzionx.yaml에서 DB 경로 읽기
382
+ let dbPath = './storage/database.sqlite';
383
+ try {
384
+ const yaml = await fs.readFile(path.resolve('fuzionx.yaml'), 'utf8');
385
+ const dbMatch = yaml.match(/database:\s*(.+\.sqlite)/);
386
+ if (dbMatch) dbPath = dbMatch[1].trim();
387
+ } catch {}
388
+
318
389
  console.log('\n📊 Model Schema Status:\n');
390
+ const models = [];
391
+
319
392
  for (const file of jsFiles) {
320
393
  const mod = await import(path.join(modelsDir, file));
321
394
  const Model = mod.default;
322
395
  if (!Model) continue;
323
396
  const name = path.basename(file, '.js');
324
397
  const table = Model.table || name.toLowerCase() + 's';
325
- const cols = Object.keys(Model.columns || {});
326
- const status = rest.includes('--apply') ? '✅ synced' : '⏳ pending';
327
- console.log(` ${name.padEnd(20)} table: ${table.padEnd(20)} columns: ${cols.length} ${status}`);
398
+ const cols = Model.columns || {};
399
+ models.push({ name, table, cols });
328
400
  }
329
- if (!rest.includes('--apply')) {
401
+
402
+ if (apply) {
403
+ // SQLite DB 열기
404
+ const resolvedDbPath = path.resolve(dbPath);
405
+ await fs.mkdir(path.dirname(resolvedDbPath), { recursive: true });
406
+ const { default: Database } = await import('better-sqlite3');
407
+ const db = new Database(resolvedDbPath);
408
+
409
+ for (const { name, table, cols } of models) {
410
+ const colDefs = [];
411
+ for (const [col, def] of Object.entries(cols)) {
412
+ if (def.type === 'increments') {
413
+ colDefs.push(`\`${col}\` INTEGER PRIMARY KEY AUTOINCREMENT`);
414
+ } else if (def.type === 'integer') {
415
+ colDefs.push(`\`${col}\` INTEGER NOT NULL DEFAULT 0`);
416
+ } else if (def.type === 'text') {
417
+ colDefs.push(`\`${col}\` TEXT NOT NULL DEFAULT ''`);
418
+ } else if (def.type === 'datetime') {
419
+ colDefs.push(`\`${col}\` DATETIME DEFAULT CURRENT_TIMESTAMP`);
420
+ } else if (def.type === 'boolean') {
421
+ colDefs.push(`\`${col}\` INTEGER NOT NULL DEFAULT 0`);
422
+ } else {
423
+ // string, etc.
424
+ const unique = def.unique ? ' UNIQUE' : '';
425
+ const dflt = def.default != null ? ` DEFAULT '${def.default}'` : " DEFAULT ''";
426
+ colDefs.push(`\`${col}\` TEXT NOT NULL${dflt}${unique}`);
427
+ }
428
+ }
429
+ const sql = `CREATE TABLE IF NOT EXISTS \`${table}\` (\n ${colDefs.join(',\n ')}\n)`;
430
+ db.exec(sql);
431
+
432
+ // ── 기존 테이블에 누락된 컬럼 추가 (ALTER TABLE) ──
433
+ const existingCols = db.pragma(`table_info(${table})`).map(c => c.name);
434
+ let addedCols = 0;
435
+ for (const [col, def] of Object.entries(cols)) {
436
+ if (existingCols.includes(col)) continue;
437
+
438
+ let colSql;
439
+ if (def.type === 'integer') {
440
+ colSql = `INTEGER NOT NULL DEFAULT 0`;
441
+ } else if (def.type === 'text') {
442
+ colSql = `TEXT NOT NULL DEFAULT ''`;
443
+ } else if (def.type === 'datetime') {
444
+ colSql = `DATETIME DEFAULT NULL`;
445
+ } else if (def.type === 'boolean') {
446
+ colSql = `INTEGER NOT NULL DEFAULT 0`;
447
+ } else {
448
+ const dflt = def.default != null ? `'${def.default}'` : "''";
449
+ colSql = `TEXT DEFAULT ${dflt}`;
450
+ }
451
+ db.exec(`ALTER TABLE \`${table}\` ADD COLUMN \`${col}\` ${colSql}`);
452
+ addedCols++;
453
+ }
454
+ const status = addedCols > 0 ? `✅ synced (+${addedCols} columns)` : '✅ synced';
455
+ console.log(` ${name.padEnd(20)} table: ${table.padEnd(20)} columns: ${Object.keys(cols).length} ${status}`);
456
+ }
457
+ db.close();
458
+ console.log(`\n Database: ${path.resolve(dbPath)}`);
459
+ } else {
460
+ for (const { name, table, cols } of models) {
461
+ console.log(` ${name.padEnd(20)} table: ${table.padEnd(20)} columns: ${Object.keys(cols).length} ⏳ pending`);
462
+ }
330
463
  console.log('\n Run with --apply to sync changes to database.');
331
464
  }
332
465
  console.log('');
@@ -336,7 +469,7 @@ export async function run(args) {
336
469
 
337
470
  console.log(`
338
471
  npx create-fuzionx <name> Create new project
339
- fx make:app <name> Create new app directory structure
472
+ fx make:app --type=ssr|spa Create app (fixed name: app/ssr or app/spa)
340
473
  fx make:controller <Name> --app= Create controller (app-specific)
341
474
  fx make:service <Name> --app= Create service (app-specific)
342
475
  fx make:model <Name> Create model (database/models)
@@ -348,6 +481,8 @@ export async function run(args) {
348
481
  fx make:worker <Name> Create worker (shared/workers)
349
482
  fx make:test <Name> Create test
350
483
  fx dev Start dev server (--watch)
484
+ fx dev:spa Start dev server + Vite HMR
485
+ fx build:spa Build SPA for production
351
486
  fx stop Stop server (graceful)
352
487
  fx restart Restart server (graceful)
353
488
  fx test Run tests
package/index.js CHANGED
@@ -60,5 +60,8 @@ export { default as FileHelper } from './lib/helpers/FileHelper.js';
60
60
  export { default as View } from './lib/view/View.js';
61
61
  export { default as OpenAPI } from './lib/view/OpenAPI.js';
62
62
 
63
+ // ── Utilities (Stateless) ──
64
+ export { PaginationUtil, StrUtil, NumUtil, DateUtil, ArrUtil, FunctionUtil, ObjectUtil } from './lib/utilities/index.js';
65
+
63
66
  // ── Built-in Middleware ──
64
67
  export { bodyParser, cors, auth, apiAuth, csrf, session, theme } from './lib/middleware/index.js';
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import path from 'node:path';
11
11
  import { promises as fs } from 'node:fs';
12
+ import cluster from 'node:cluster';
12
13
  import Config from './Config.js';
13
14
  import Context from './Context.js';
14
15
  import Router from '../http/Router.js';
@@ -429,13 +430,20 @@ export default class Application {
429
430
  if (!this._booted) await this.boot();
430
431
 
431
432
  // PID 파일 생성 (primary만)
432
- const cluster = await import('node:cluster');
433
- if (!cluster.default?.isWorker) {
433
+ if (!cluster.isWorker) {
434
434
  await this._checkPort(port);
435
435
  const pidPath = path.join(this.baseDir, 'fuzionx.pid');
436
436
  await fs.writeFile(pidPath, String(process.pid));
437
437
  }
438
438
 
439
+ // process.title 설정 (ps에서 보이는 프로세스 이름)
440
+ const appName = this.config.get('app.name', 'fuzionx');
441
+ if (cluster.isWorker) {
442
+ process.title = `${appName}-worker-${cluster.worker.id}`;
443
+ } else {
444
+ process.title = `${appName}-primary`;
445
+ }
446
+
439
447
  await this.emit('ready');
440
448
 
441
449
  // Bridge 연동 — _coreApp은 boot()에서 이미 생성됨
@@ -461,6 +469,14 @@ export default class Application {
461
469
  }
462
470
 
463
471
  await this.emit('listening');
472
+
473
+ // Scheduler 시작 — primary 프로세스에서만 (워커 중복 실행 방지)
474
+ // fuzionx 상위 레이어가 cluster.fork()로 워커를 생성하므로
475
+ // cluster.isPrimary로 단일 프로세스 보장
476
+ if (this._scheduler && this._scheduler._jobs.length > 0 && !cluster.isWorker) {
477
+ this._scheduler.start();
478
+ }
479
+
464
480
  return this;
465
481
  }
466
482
 
@@ -622,9 +638,15 @@ export default class Application {
622
638
  // 미들웨어 체인 사전 구성
623
639
  const middlewareFns = [...this._globalMiddleware];
624
640
  if (route.middleware?.length) {
625
- for (const mwName of route.middleware) {
626
- const fn = this.resolveMiddleware(mwName, appName);
627
- if (fn) middlewareFns.push(fn);
641
+ for (const mw of route.middleware) {
642
+ // 함수면 직접 사용 (built-in: auth(), cors() 등)
643
+ // 문자열이면 resolveMiddleware로 클래스 기반 조회
644
+ if (typeof mw === 'function') {
645
+ middlewareFns.push(mw);
646
+ } else if (typeof mw === 'string') {
647
+ const fn = this.resolveMiddleware(mw, appName);
648
+ if (fn) middlewareFns.push(fn);
649
+ }
628
650
  }
629
651
  }
630
652
 
@@ -28,6 +28,7 @@ export default class Context {
28
28
  this.body = rawReq.body || null;
29
29
  this.ip = rawReq.remoteIp || '';
30
30
  this.files = rawReq.files || null;
31
+ this.uploadError = rawReq.uploadError || null;
31
32
  this.formFields = rawReq.formFields || null;
32
33
  this.handlerId = rawReq.handlerId;
33
34
 
@@ -306,6 +307,22 @@ export default class Context {
306
307
 
307
308
  render(view, data) {
308
309
  // 글로벌 변수 주입 (03-views-templates.md)
310
+ const bridge = this.app?._bridge;
311
+ let aspVars = {};
312
+ if (bridge?.getAspConfig && bridge?.cryptoEncryptCustom) {
313
+ try {
314
+ const aspCfg = JSON.parse(bridge.getAspConfig());
315
+ const clientSecret = this.app?.config?.get('app.client_secret') || '';
316
+ if (clientSecret && aspCfg.masterSecret) {
317
+ aspVars = {
318
+ _fx_client_secret: clientSecret,
319
+ _fx_asp_secret: bridge.cryptoEncryptCustom(clientSecret, aspCfg.masterSecret),
320
+ _fx_asp_header: aspCfg.headerSignal || 'Ruxy-Enc-Mode',
321
+ };
322
+ }
323
+ } catch {}
324
+ }
325
+
309
326
  const globals = {
310
327
  session: this._rawSession,
311
328
  auth: { user: this.user },
@@ -315,6 +332,7 @@ export default class Context {
315
332
  flash: this.session?.getFlash() || {},
316
333
  theme: this.theme || this.app?.config?.get('themes.default', 'default') || 'default',
317
334
  locale: this.locale,
335
+ ...aspVars,
318
336
  ...data,
319
337
  };
320
338
 
@@ -336,11 +354,18 @@ export default class Context {
336
354
  /** @private */
337
355
  _createSession(rawReq, app) {
338
356
  const data = rawReq.session || {};
357
+ // Flash 추출 + 세션에서 삭제 (one-time read)
339
358
  const flash = { ...(data._flash || {}) };
359
+ const hadFlash = !!data._flash;
340
360
  delete data._flash;
341
361
  const sessionId = rawReq.sessionId || null;
342
362
  const bridge = app?._bridge || null;
343
363
 
364
+ // Flash 소비 후 세션 저장소에 즉시 반영
365
+ if (hadFlash && sessionId && bridge?.sessionSet) {
366
+ try { bridge.sessionSet(sessionId, { ...data }); } catch {}
367
+ }
368
+
344
369
  return {
345
370
  /** 세션 값 조회 */
346
371
  get: (key) => key ? (data[key] ?? null) : { ...data },
@@ -350,7 +375,11 @@ export default class Context {
350
375
  data[key] = value;
351
376
  // Core SessionProto 참조: bridge.sessionSet(id, { ...data })
352
377
  if (sessionId && bridge?.sessionSet) {
353
- try { bridge.sessionSet(sessionId, { ...data }); } catch {}
378
+ try {
379
+ bridge.sessionSet(sessionId, { ...data });
380
+ } catch (e) {
381
+ console.error('[Session] sessionSet failed:', e?.message || e);
382
+ }
354
383
  }
355
384
  },
356
385
 
@@ -60,7 +60,6 @@ export default class I18nHelper {
60
60
  */
61
61
  translate(locale, key, vars = {}) {
62
62
  // ── Bridge N-API 위임 ──
63
- // Core i18n.js 참조: bridge.i18NTranslate(locale, key)
64
63
  if (this._bridge && typeof this._bridge.i18NTranslate === 'function') {
65
64
  try {
66
65
  const result = this._bridge.i18NTranslate(locale, key);
@@ -72,12 +71,18 @@ export default class I18nHelper {
72
71
  const messages = this._messages.get(locale)
73
72
  || this._messages.get(this.fallback)
74
73
  || this._messages.get(this.defaultLocale);
75
- if (!messages) return vars.default || key;
76
74
 
77
- const value = messages[key];
78
- if (value == null) return vars.default || key;
75
+ const value = messages?.[key];
76
+ if (value != null) return this._substitute(value, vars);
79
77
 
80
- return this._substitute(value, vars);
78
+ // ── auto_complete: 누락 키 자동 등록 ──
79
+ // default 값이 제공된 경우, Bridge에 누락 키를 보고하여
80
+ // 모든 locale 파일에 자동으로 추가한다.
81
+ if (vars.default) {
82
+ this.updateMissing(key, vars.default);
83
+ }
84
+
85
+ return vars.default || key;
81
86
  }
82
87
 
83
88
  /**
@@ -86,7 +91,6 @@ export default class I18nHelper {
86
91
  * @returns {object}
87
92
  */
88
93
  all(locale) {
89
- // Bridge: i18NGetLocales() 는 locale 목록만 반환, 전체 데이터 API 없음
90
94
  const messages = this._messages.get(locale) || this._messages.get(this.defaultLocale);
91
95
  if (!messages) return {};
92
96
  return { ...messages };
@@ -0,0 +1,79 @@
1
+ /**
2
+ * apiAuth — JWT Bearer 토큰 인증 미들웨어
3
+ *
4
+ * Authorization: Bearer <token> → 검증 → ctx.user
5
+ *
6
+ * @see docs/framework/14-authentication.md
7
+ *
8
+ * @param {object} [opts]
9
+ * @param {string} [opts.secret] - JWT 시크릿
10
+ * @param {string} [opts.model='User']
11
+ */
12
+ import { createHmac, timingSafeEqual } from 'node:crypto';
13
+
14
+ export function apiAuth(opts = {}) {
15
+ const modelName = opts.model || 'User';
16
+
17
+ return async (ctx, next) => {
18
+ const authHeader = ctx.get('authorization') || '';
19
+
20
+ if (!authHeader.startsWith('Bearer ')) {
21
+ ctx.status(401).json({ error: { message: 'Token required', status: 401 } });
22
+ return;
23
+ }
24
+
25
+ const token = authHeader.slice(7);
26
+
27
+ try {
28
+ const secret = opts.secret || ctx.app?.config?.get('app.auth.secret', 'fuzionx');
29
+ const payload = decodeJwtPayload(token, secret);
30
+
31
+ if (!payload || !payload.sub) {
32
+ ctx.status(401).json({ error: { message: 'Invalid token', status: 401 } });
33
+ return;
34
+ }
35
+
36
+ if (ctx.app?.db?.[modelName]) {
37
+ ctx.user = await ctx.app.db[modelName].find(payload.sub);
38
+ } else {
39
+ ctx.user = { id: payload.sub, ...payload };
40
+ }
41
+ } catch {
42
+ ctx.status(401).json({ error: { message: 'Invalid token', status: 401 } });
43
+ return;
44
+ }
45
+
46
+ await next();
47
+ };
48
+ }
49
+
50
+ /**
51
+ * JWT 디코드 + HMAC-SHA256 서명 검증
52
+ * @private
53
+ */
54
+ function decodeJwtPayload(token, secret) {
55
+ try {
56
+ const parts = token.split('.');
57
+ if (parts.length !== 3) return null;
58
+
59
+ const [headerB64, payloadB64, signatureB64] = parts;
60
+
61
+ const signingInput = `${headerB64}.${payloadB64}`;
62
+ const expectedSig = createHmac('sha256', secret)
63
+ .update(signingInput)
64
+ .digest();
65
+
66
+ const actualSig = Buffer.from(signatureB64, 'base64url');
67
+ if (expectedSig.length !== actualSig.length) return null;
68
+ if (!timingSafeEqual(expectedSig, actualSig)) return null;
69
+
70
+ const payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString());
71
+
72
+ if (payload.exp !== undefined && Date.now() / 1000 > payload.exp) return null;
73
+ if (payload.nbf !== undefined && Date.now() / 1000 < payload.nbf) return null;
74
+
75
+ return payload;
76
+ } catch {
77
+ return null;
78
+ }
79
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * auth — 세션 인증 미들웨어
3
+ *
4
+ * ctx.session.userId → db.User.find() → ctx.user
5
+ *
6
+ * @see docs/framework/14-authentication.md
7
+ *
8
+ * @param {object} [opts]
9
+ * @param {string} [opts.sessionKey='userId']
10
+ * @param {string} [opts.model='User']
11
+ * @param {string} [opts.redirectTo='/login']
12
+ */
13
+ export function auth(opts = {}) {
14
+ const sessionKey = opts.sessionKey || 'userId';
15
+ const modelName = opts.model || 'User';
16
+ const redirectTo = opts.redirectTo || null;
17
+
18
+ return async (ctx, next) => {
19
+ const userId = ctx._rawSession?.[sessionKey] || ctx.get('x-test-user-id');
20
+
21
+ if (!userId) {
22
+ if (redirectTo) {
23
+ ctx.redirect(redirectTo);
24
+ } else {
25
+ ctx.status(401).json({ error: { message: 'Unauthorized', status: 401 } });
26
+ }
27
+ return;
28
+ }
29
+
30
+ if (ctx.app?.db?.[modelName]) {
31
+ try {
32
+ ctx.user = await ctx.app.db[modelName].find(userId);
33
+ } catch {
34
+ ctx.user = { id: userId };
35
+ }
36
+ } else {
37
+ ctx.user = { id: userId };
38
+ }
39
+
40
+ await next();
41
+ };
42
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * bodyParser — JSON/Form body 파싱 미들웨어
3
+ *
4
+ * Bridge가 이미 파싱한 body를 보장.
5
+ * 추가 파싱이 필요한 경우 처리.
6
+ *
7
+ * @see docs/framework/12-middleware.md
8
+ */
9
+ export function bodyParser() {
10
+ return async (ctx, next) => {
11
+ if (typeof ctx.body === 'string' && ctx.body) {
12
+ const ct = ctx.get('content-type') || '';
13
+ if (ct.includes('application/json')) {
14
+ try { ctx.body = JSON.parse(ctx.body); } catch {}
15
+ }
16
+ }
17
+ await next();
18
+ };
19
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * cors — CORS 미들웨어
3
+ *
4
+ * @see docs/framework/12-middleware.md
5
+ *
6
+ * @param {object} [opts]
7
+ * @param {string|string[]} [opts.origin='*']
8
+ * @param {string} [opts.methods='GET,POST,PUT,PATCH,DELETE,OPTIONS']
9
+ * @param {string} [opts.headers='Content-Type,Authorization']
10
+ * @param {boolean} [opts.credentials=false]
11
+ * @param {number} [opts.maxAge=86400]
12
+ */
13
+ export function cors(opts = {}) {
14
+ const origin = opts.origin || '*';
15
+ const methods = opts.methods || 'GET,POST,PUT,PATCH,DELETE,OPTIONS';
16
+ const headers = opts.headers || 'Content-Type,Authorization';
17
+ const credentials = opts.credentials || false;
18
+ const maxAge = opts.maxAge || 86400;
19
+
20
+ return async (ctx, next) => {
21
+ const reqOrigin = ctx.get('origin') || '*';
22
+ let allowOrigin;
23
+
24
+ if (credentials && origin === '*') {
25
+ allowOrigin = reqOrigin !== '*' ? reqOrigin : '';
26
+ } else {
27
+ allowOrigin = origin === '*' ? '*' : (
28
+ Array.isArray(origin)
29
+ ? (origin.includes(reqOrigin) ? reqOrigin : origin[0])
30
+ : origin
31
+ );
32
+ }
33
+
34
+ ctx.header('Access-Control-Allow-Origin', allowOrigin);
35
+ ctx.header('Access-Control-Allow-Methods', methods);
36
+ ctx.header('Access-Control-Allow-Headers', headers);
37
+ if (credentials) ctx.header('Access-Control-Allow-Credentials', 'true');
38
+
39
+ if (ctx.method === 'OPTIONS') {
40
+ ctx.header('Access-Control-Max-Age', String(maxAge));
41
+ ctx.status(204).end();
42
+ return;
43
+ }
44
+
45
+ await next();
46
+ };
47
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * csrf — CSRF 보호 미들웨어
3
+ *
4
+ * GET/HEAD/OPTIONS 는 통과, 나머지는 토큰 검증.
5
+ *
6
+ * @see docs/framework/12-middleware.md
7
+ *
8
+ * @param {object} [opts]
9
+ * @param {string} [opts.headerName='x-csrf-token']
10
+ * @param {string} [opts.sessionKey='_csrfToken']
11
+ */
12
+ export function csrf(opts = {}) {
13
+ const headerName = opts.headerName || 'x-csrf-token';
14
+ const sessionKey = opts.sessionKey || '_csrfToken';
15
+
16
+ return async (ctx, next) => {
17
+ if (['GET', 'HEAD', 'OPTIONS'].includes(ctx.method)) {
18
+ await next();
19
+ return;
20
+ }
21
+
22
+ const token = ctx.get(headerName);
23
+ const expected = ctx._rawSession?.[sessionKey];
24
+
25
+ if (!token || !expected || token !== expected) {
26
+ ctx.status(403).json({ error: { message: 'CSRF token mismatch', status: 403 } });
27
+ return;
28
+ }
29
+
30
+ await next();
31
+ };
32
+ }