mega-framework 0.1.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/.env +127 -0
- package/.env.example +186 -0
- package/.prettierrc.json +8 -0
- package/CHANGELOG.md +259 -0
- package/LICENSE +21 -0
- package/README.md +153 -0
- package/bin/mega-ws-hub.js +15 -0
- package/bin/mega.js +38 -0
- package/docker-compose.yml +201 -0
- package/eslint.config.js +57 -0
- package/infra/otel-collector-config.yaml +43 -0
- package/jsconfig.json +18 -0
- package/package.json +121 -0
- package/sample/crud/.env +18 -0
- package/sample/crud/.env.example +50 -0
- package/sample/crud/README.md +85 -0
- package/sample/crud/apps/main/app.config.js +114 -0
- package/sample/crud/apps/main/channels/chat-bus.js +115 -0
- package/sample/crud/apps/main/channels/chat-channel.js +145 -0
- package/sample/crud/apps/main/controllers/auth-controller.js +144 -0
- package/sample/crud/apps/main/controllers/cron-controller.js +34 -0
- package/sample/crud/apps/main/controllers/guide-controller.js +37 -0
- package/sample/crud/apps/main/controllers/jobs-controller.js +43 -0
- package/sample/crud/apps/main/controllers/logs-controller.js +35 -0
- package/sample/crud/apps/main/controllers/metrics-controller.js +22 -0
- package/sample/crud/apps/main/controllers/note-controller.js +116 -0
- package/sample/crud/apps/main/controllers/perf-controller.js +38 -0
- package/sample/crud/apps/main/controllers/redis-controller.js +36 -0
- package/sample/crud/apps/main/controllers/tracing-controller.js +43 -0
- package/sample/crud/apps/main/controllers/upload-controller.js +98 -0
- package/sample/crud/apps/main/controllers/user-controller.js +34 -0
- package/sample/crud/apps/main/controllers/web-controller.js +137 -0
- package/sample/crud/apps/main/controllers/worker-controller.js +57 -0
- package/sample/crud/apps/main/controllers/ws-controller.js +29 -0
- package/sample/crud/apps/main/jobs/email-job.js +72 -0
- package/sample/crud/apps/main/locales/client/en.json +3 -0
- package/sample/crud/apps/main/locales/client/ko.json +3 -0
- package/sample/crud/apps/main/locales/server/en.json +316 -0
- package/sample/crud/apps/main/locales/server/ko.json +316 -0
- package/sample/crud/apps/main/middleware/web-auth.js +40 -0
- package/sample/crud/apps/main/middleware/ws-auth.js +48 -0
- package/sample/crud/apps/main/migrations/20260606000001-create-users.js +27 -0
- package/sample/crud/apps/main/migrations/20260606000002-add-auth-to-users.js +30 -0
- package/sample/crud/apps/main/models/note.js +71 -0
- package/sample/crud/apps/main/models/user.js +86 -0
- package/sample/crud/apps/main/public/css/app.css +101 -0
- package/sample/crud/apps/main/public/css/guide.css +137 -0
- package/sample/crud/apps/main/public/js/app.js +54 -0
- package/sample/crud/apps/main/public/js/perf.js +129 -0
- package/sample/crud/apps/main/public/js/theme-init.js +12 -0
- package/sample/crud/apps/main/public/js/upload-demo.js +63 -0
- package/sample/crud/apps/main/public/js/worker-demo.js +92 -0
- package/sample/crud/apps/main/public/js/ws-chat.js +161 -0
- package/sample/crud/apps/main/public/vendor/bootstrap/bootstrap.bundle.min.js +7 -0
- package/sample/crud/apps/main/public/vendor/bootstrap/bootstrap.min.css +6 -0
- package/sample/crud/apps/main/public/vendor/highlight/github-dark.css +109 -0
- package/sample/crud/apps/main/public/vendor/highlight/github.css +118 -0
- package/sample/crud/apps/main/public/vendor/mega-client-wasm/README.md +19 -0
- package/sample/crud/apps/main/public/vendor/mega-client-wasm/mega_client_wasm.d.ts +196 -0
- package/sample/crud/apps/main/public/vendor/mega-client-wasm/mega_client_wasm.js +1187 -0
- package/sample/crud/apps/main/public/vendor/mega-client-wasm/mega_client_wasm_bg.wasm +0 -0
- package/sample/crud/apps/main/routes/auth.js +15 -0
- package/sample/crud/apps/main/routes/cron.js +14 -0
- package/sample/crud/apps/main/routes/guide.js +25 -0
- package/sample/crud/apps/main/routes/jobs.js +14 -0
- package/sample/crud/apps/main/routes/logs.js +28 -0
- package/sample/crud/apps/main/routes/metrics.js +13 -0
- package/sample/crud/apps/main/routes/notes.js +19 -0
- package/sample/crud/apps/main/routes/perf.js +47 -0
- package/sample/crud/apps/main/routes/redis.js +14 -0
- package/sample/crud/apps/main/routes/tracing.js +14 -0
- package/sample/crud/apps/main/routes/upload.js +16 -0
- package/sample/crud/apps/main/routes/users.js +54 -0
- package/sample/crud/apps/main/routes/web.js +23 -0
- package/sample/crud/apps/main/routes/worker.js +15 -0
- package/sample/crud/apps/main/routes/ws.js +30 -0
- package/sample/crud/apps/main/schedules/cron-counter-schedule.js +30 -0
- package/sample/crud/apps/main/services/auth-service.js +74 -0
- package/sample/crud/apps/main/services/cron-demo-service.js +66 -0
- package/sample/crud/apps/main/services/guide-service.js +145 -0
- package/sample/crud/apps/main/services/jobs-demo-service.js +83 -0
- package/sample/crud/apps/main/services/logs-demo-service.js +59 -0
- package/sample/crud/apps/main/services/metrics-demo-service.js +144 -0
- package/sample/crud/apps/main/services/note-service.js +75 -0
- package/sample/crud/apps/main/services/perf-service.js +302 -0
- package/sample/crud/apps/main/services/redis-demo-service.js +75 -0
- package/sample/crud/apps/main/services/tracing-demo-service.js +69 -0
- package/sample/crud/apps/main/services/upload-demo-service.js +48 -0
- package/sample/crud/apps/main/services/user-service.js +65 -0
- package/sample/crud/apps/main/views/auth/login.ejs +57 -0
- package/sample/crud/apps/main/views/auth/register.ejs +71 -0
- package/sample/crud/apps/main/views/cron/index.ejs +92 -0
- package/sample/crud/apps/main/views/guide/index.ejs +24 -0
- package/sample/crud/apps/main/views/guide/page.ejs +64 -0
- package/sample/crud/apps/main/views/home.ejs +82 -0
- package/sample/crud/apps/main/views/jobs/index.ejs +113 -0
- package/sample/crud/apps/main/views/layouts/main.ejs +112 -0
- package/sample/crud/apps/main/views/logs/index.ejs +80 -0
- package/sample/crud/apps/main/views/metrics/index.ejs +123 -0
- package/sample/crud/apps/main/views/notes/edit.ejs +45 -0
- package/sample/crud/apps/main/views/notes/list.ejs +74 -0
- package/sample/crud/apps/main/views/notes/new.ejs +45 -0
- package/sample/crud/apps/main/views/perf/index.ejs +90 -0
- package/sample/crud/apps/main/views/redis/index.ejs +65 -0
- package/sample/crud/apps/main/views/tracing/index.ejs +106 -0
- package/sample/crud/apps/main/views/upload/index.ejs +79 -0
- package/sample/crud/apps/main/views/users/edit.ejs +48 -0
- package/sample/crud/apps/main/views/users/list.ejs +81 -0
- package/sample/crud/apps/main/views/users/new.ejs +48 -0
- package/sample/crud/apps/main/views/worker/index.ejs +70 -0
- package/sample/crud/apps/main/views/ws/index.ejs +62 -0
- package/sample/crud/apps/main/workers/hash-worker.js +17 -0
- package/sample/crud/apps/main/workers/hash.task.js +22 -0
- package/sample/crud/ecosystem.config.cjs +9 -0
- package/sample/crud/mega.config.js +105 -0
- package/sample/crud/package-lock.json +5665 -0
- package/sample/crud/package.json +28 -0
- package/sample/crud/test/apps/main/auth-flow.integration.test.js +177 -0
- package/sample/crud/test/apps/main/auth-service.test.js +93 -0
- package/sample/crud/test/apps/main/chat-bus.test.js +101 -0
- package/sample/crud/test/apps/main/chat-channel.test.js +144 -0
- package/sample/crud/test/apps/main/cron-demo-service.test.js +93 -0
- package/sample/crud/test/apps/main/demo-flow.integration.test.js +386 -0
- package/sample/crud/test/apps/main/email-job.test.js +76 -0
- package/sample/crud/test/apps/main/guide-service.test.js +68 -0
- package/sample/crud/test/apps/main/hash-task.test.js +30 -0
- package/sample/crud/test/apps/main/jobs-demo-service.test.js +88 -0
- package/sample/crud/test/apps/main/logs-demo-service.test.js +85 -0
- package/sample/crud/test/apps/main/metrics-demo-service.test.js +90 -0
- package/sample/crud/test/apps/main/note-service.test.js +68 -0
- package/sample/crud/test/apps/main/perf-service.test.js +121 -0
- package/sample/crud/test/apps/main/perf.integration.test.js +202 -0
- package/sample/crud/test/apps/main/redis-demo-service.test.js +98 -0
- package/sample/crud/test/apps/main/tracing-demo-service.test.js +90 -0
- package/sample/crud/test/apps/main/upload-demo-service.test.js +61 -0
- package/sample/crud/test/apps/main/user-service.test.js +65 -0
- package/sample/crud/test/apps/main/ws-chat.integration.test.js +232 -0
- package/sample/crud/vitest.config.js +8 -0
- package/sample/crud/yarn.lock +2142 -0
- package/sample/simple/.env.example +15 -0
- package/sample/simple/README.md +52 -0
- package/sample/simple/apps/main/app.config.js +35 -0
- package/sample/simple/apps/main/controllers/pages-controller.js +22 -0
- package/sample/simple/apps/main/locales/client/en.json +3 -0
- package/sample/simple/apps/main/locales/client/ko.json +3 -0
- package/sample/simple/apps/main/locales/server/en.json +23 -0
- package/sample/simple/apps/main/locales/server/ko.json +23 -0
- package/sample/simple/apps/main/public/css/app.css +101 -0
- package/sample/simple/apps/main/public/hello.txt +1 -0
- package/sample/simple/apps/main/public/js/app.js +54 -0
- package/sample/simple/apps/main/public/js/theme-init.js +12 -0
- package/sample/simple/apps/main/public/vendor/bootstrap/bootstrap.bundle.min.js +7 -0
- package/sample/simple/apps/main/public/vendor/bootstrap/bootstrap.min.css +6 -0
- package/sample/simple/apps/main/routes/index.js +9 -0
- package/sample/simple/apps/main/routes/pages.js +12 -0
- package/sample/simple/apps/main/views/index.ejs +56 -0
- package/sample/simple/apps/main/views/layouts/main.ejs +74 -0
- package/sample/simple/ecosystem.config.cjs +10 -0
- package/sample/simple/mega.config.js +27 -0
- package/sample/simple/package-lock.json +1851 -0
- package/sample/simple/package.json +25 -0
- package/sample/simple/test/apps/main/index.test.js +13 -0
- package/sample/simple/vitest.config.js +8 -0
- package/src/adapters/adapter-manager.js +305 -0
- package/src/adapters/adapter-options.js +208 -0
- package/src/adapters/file-adapter.js +350 -0
- package/src/adapters/file-session-adapter.js +363 -0
- package/src/adapters/index.js +38 -0
- package/src/adapters/maria-adapter.js +425 -0
- package/src/adapters/mega-adapter.js +511 -0
- package/src/adapters/mega-bus-adapter.js +81 -0
- package/src/adapters/mega-cache-adapter.js +94 -0
- package/src/adapters/mega-db-adapter.js +72 -0
- package/src/adapters/mega-lock-adapter.js +118 -0
- package/src/adapters/mega-log-sink-adapter.js +46 -0
- package/src/adapters/mega-session-adapter.js +72 -0
- package/src/adapters/mongo-adapter.js +396 -0
- package/src/adapters/nats-adapter.js +370 -0
- package/src/adapters/postgres-adapter.js +341 -0
- package/src/adapters/redis-adapter.js +331 -0
- package/src/adapters/redis-session-adapter.js +261 -0
- package/src/adapters/redlock-adapter.js +385 -0
- package/src/adapters/registry.js +157 -0
- package/src/adapters/sqlite-adapter.js +309 -0
- package/src/auth/index.js +103 -0
- package/src/cli/commands/console-cmd.js +56 -0
- package/src/cli/commands/new.js +101 -0
- package/src/cli/commands/routes.js +107 -0
- package/src/cli/commands/scaffold.js +120 -0
- package/src/cli/commands/test-cmd.js +45 -0
- package/src/cli/generators/index.js +368 -0
- package/src/cli/index.js +472 -0
- package/src/cli/template-engine.js +72 -0
- package/src/cli/ws-hub.js +582 -0
- package/src/core/ajv-mapper.js +80 -0
- package/src/core/boot.js +323 -0
- package/src/core/cluster-metrics.js +278 -0
- package/src/core/config-loader.js +115 -0
- package/src/core/config-validator.js +322 -0
- package/src/core/ctx-builder.js +253 -0
- package/src/core/envelope.js +88 -0
- package/src/core/error-mapper.js +116 -0
- package/src/core/formbody.js +69 -0
- package/src/core/hub-link.js +552 -0
- package/src/core/i18n.js +525 -0
- package/src/core/index.js +63 -0
- package/src/core/mega-app.js +1138 -0
- package/src/core/mega-cluster.js +232 -0
- package/src/core/mega-server.js +176 -0
- package/src/core/mega-service.js +41 -0
- package/src/core/migration-runner.js +196 -0
- package/src/core/multipart.js +282 -0
- package/src/core/openapi.js +114 -0
- package/src/core/router.js +388 -0
- package/src/core/routes-loader.js +57 -0
- package/src/core/scope-registry.js +53 -0
- package/src/core/security.js +275 -0
- package/src/core/services-loader.js +98 -0
- package/src/core/session-cleanup-schedule.js +57 -0
- package/src/core/session-store.js +55 -0
- package/src/core/session.js +414 -0
- package/src/core/static-assets.js +126 -0
- package/src/core/template.js +294 -0
- package/src/core/workers-manager.js +193 -0
- package/src/core/ws-compression.js +112 -0
- package/src/core/ws-controller.js +109 -0
- package/src/core/ws-message.js +176 -0
- package/src/core/ws-upgrade.js +445 -0
- package/src/errors/config-error.js +16 -0
- package/src/errors/http-errors.js +130 -0
- package/src/errors/index.js +19 -0
- package/src/errors/mega-error.js +34 -0
- package/src/eslint-plugin/index.js +15 -0
- package/src/eslint-plugin/no-direct-model-import.js +113 -0
- package/src/index.js +131 -0
- package/src/lib/asp/config.js +83 -0
- package/src/lib/asp/crypto.js +145 -0
- package/src/lib/asp/errors.js +49 -0
- package/src/lib/asp/nonce-cache.js +94 -0
- package/src/lib/asp/plugin.js +263 -0
- package/src/lib/asp/ws-terminator.js +101 -0
- package/src/lib/env-mapper.js +222 -0
- package/src/lib/hub-protocol.js +322 -0
- package/src/lib/index.js +42 -0
- package/src/lib/logger/telegram-core.js +150 -0
- package/src/lib/logger/telegram-transport.js +126 -0
- package/src/lib/mega-brute-force.js +225 -0
- package/src/lib/mega-circuit-breaker.js +412 -0
- package/src/lib/mega-cron.js +169 -0
- package/src/lib/mega-hash.js +179 -0
- package/src/lib/mega-health.js +91 -0
- package/src/lib/mega-job-queue.js +600 -0
- package/src/lib/mega-job-worker.js +295 -0
- package/src/lib/mega-job.js +140 -0
- package/src/lib/mega-logger.js +128 -0
- package/src/lib/mega-metrics.js +661 -0
- package/src/lib/mega-plugin.js +650 -0
- package/src/lib/mega-retry.js +95 -0
- package/src/lib/mega-schedule.js +507 -0
- package/src/lib/mega-shutdown.js +176 -0
- package/src/lib/mega-tracing.js +715 -0
- package/src/lib/mega-worker.js +653 -0
- package/src/lib/worker-runner/process-entry.js +30 -0
- package/src/lib/worker-runner/task-dispatch.js +72 -0
- package/src/lib/worker-runner/thread-entry.js +26 -0
- package/src/models/index.js +7 -0
- package/src/models/mega-model.js +151 -0
- package/src/test/index.js +288 -0
- package/templates/adapter/code.tpl +40 -0
- package/templates/adapter/test.tpl +13 -0
- package/templates/app/app.config.tpl +10 -0
- package/templates/app/route.tpl +10 -0
- package/templates/app/test.tpl +13 -0
- package/templates/channel/code.tpl +38 -0
- package/templates/channel/test.tpl +19 -0
- package/templates/controller/code.tpl +16 -0
- package/templates/controller/route.tpl +9 -0
- package/templates/controller/test.tpl +14 -0
- package/templates/job/code.tpl +23 -0
- package/templates/job/test.tpl +17 -0
- package/templates/locale/code.tpl +3 -0
- package/templates/locale/test.tpl +13 -0
- package/templates/middleware/code.tpl +13 -0
- package/templates/middleware/test.tpl +11 -0
- package/templates/migration/code.tpl +20 -0
- package/templates/migration/test.tpl +14 -0
- package/templates/model/code.tpl +21 -0
- package/templates/model/test.tpl +29 -0
- package/templates/project/app.config.tpl +8 -0
- package/templates/project/app.config.views.tpl +37 -0
- package/templates/project/ecosystem.config.tpl +10 -0
- package/templates/project/env.tpl +12 -0
- package/templates/project/gitignore.tpl +8 -0
- package/templates/project/locales/client/en.json.tpl +3 -0
- package/templates/project/locales/client/ko.json.tpl +3 -0
- package/templates/project/locales/server/en.json.tpl +17 -0
- package/templates/project/locales/server/ko.json.tpl +17 -0
- package/templates/project/mega.config.tpl +11 -0
- package/templates/project/package.tpl +25 -0
- package/templates/project/public/css/app.css +101 -0
- package/templates/project/public/js/app.js +54 -0
- package/templates/project/public/js/theme-init.js +12 -0
- package/templates/project/public/vendor/bootstrap/bootstrap.bundle.min.js +7 -0
- package/templates/project/public/vendor/bootstrap/bootstrap.min.css +6 -0
- package/templates/project/readme.tpl +48 -0
- package/templates/project/route.test.tpl +13 -0
- package/templates/project/route.test.views.tpl +15 -0
- package/templates/project/route.tpl +10 -0
- package/templates/project/route.views.tpl +10 -0
- package/templates/project/views/index.ejs.tpl +58 -0
- package/templates/project/views/layout.ejs.tpl +73 -0
- package/templates/project/vitest.config.tpl +8 -0
- package/templates/route/code.tpl +11 -0
- package/templates/route/test.tpl +26 -0
- package/templates/schedule/code.tpl +19 -0
- package/templates/schedule/test.tpl +17 -0
- package/templates/service/code.tpl +18 -0
- package/templates/service/test.tpl +17 -0
- package/templates/worker/code.tpl +14 -0
- package/templates/worker/task.tpl +13 -0
- package/templates/worker/test.tpl +18 -0
- package/vitest.config.js +33 -0
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* MegaApp 보안 플러그인 자동 등록 — helmet / cors / rate-limit / csrf + ASP HTTP (ADR-127).
|
|
4
|
+
*
|
|
5
|
+
* 매 프로젝트에서 보안 플러그인을 수동 등록하면 잊기 쉽다(ADR-047). 본 모듈이 앱 생성 시점에 검증된
|
|
6
|
+
* 공개 Fastify 플러그인 4종 + 기존 ASP HTTP terminator 를 **앱 config 기반으로 자동 등록**한다.
|
|
7
|
+
*
|
|
8
|
+
* # 등록 대상 (ADR-047 ~ ADR-051)
|
|
9
|
+
* - `@fastify/helmet` — 보안 헤더(+CSP). 디폴트 ON.
|
|
10
|
+
* - `@fastify/cors` — CORS. 디폴트 ON 이되 `origin: false`(교차출처 거부) — 안전 디폴트.
|
|
11
|
+
* - `@fastify/rate-limit` — 요청 제한. 디폴트 ON, `100 req / 1 minute`(ADR-048). 멀티 인스턴스는
|
|
12
|
+
* `caches.rate` 별명(Redis) 백엔드, 미선언 시 in-memory(단일 dev) 폴백.
|
|
13
|
+
* - `@fastify/csrf-protection` — CSRF. 디폴트 ON. `@fastify/cookie` 동반(쿠키 double-submit).
|
|
14
|
+
* ADR-051: `application/json` 요청은 토큰 면제 + **Origin 헤더 검증**,
|
|
15
|
+
* 폼 요청(`urlencoded`/`multipart`)만 토큰 검증.
|
|
16
|
+
* - ASP HTTP — `asp.masterSecret` + `asp.http.enabledPaths` 가 있을 때만 옵트인 등록
|
|
17
|
+
* (기존 {@link registerAspPlugin}).
|
|
18
|
+
*
|
|
19
|
+
* # 설정 스코프 (ADR-061 / ADR-047)
|
|
20
|
+
* 보안 옵션은 `apps/<name>/app.config.js` 의 **top-level 키** `cors / helmet / rateLimit / csrf` 로 받는다
|
|
21
|
+
* (`security: {}` 중첩 객체 아님 — scope-registry 의 App-only 키와 정합). 각 키는:
|
|
22
|
+
* - `undefined` → 안전 디폴트로 자동 등록.
|
|
23
|
+
* - `false` → 해당 플러그인 비활성(미등록).
|
|
24
|
+
* - `object` → 옵션으로 등록. **라우트 옵션 오버라이드는 완전 교체**(deep merge X, ADR-073).
|
|
25
|
+
*
|
|
26
|
+
* # `/health` 면제 실효 (ADR-072)
|
|
27
|
+
* 라우트 `config.skipAsp / skipCsrf` 플래그를 ASP·CSRF hook 이 검사해 우회한다. rate-limit 은 fastify
|
|
28
|
+
* 네이티브 라우트 옵션 `config.rateLimit: false` 로 면제한다(@fastify/rate-limit 의 onRoute 가 읽음).
|
|
29
|
+
* MegaApp 이 `/health`·`/health/ready` 를 이 세 플래그와 함께 등록한다.
|
|
30
|
+
*
|
|
31
|
+
* # 트레이싱 (ADR-126 인프라 재사용)
|
|
32
|
+
* 보안 거부 시 활성 HTTP span 에 `mega.security.reason` attribute 를 박는다(CSRF Origin 불일치/토큰
|
|
33
|
+
* 불일치, rate-limit 초과). 옵트인 OFF 면 `activeSpan()` 이 undefined 라 0 비용 no-op.
|
|
34
|
+
*
|
|
35
|
+
* @module core/security
|
|
36
|
+
*/
|
|
37
|
+
import fastifyHelmet from '@fastify/helmet'
|
|
38
|
+
import fastifyCors from '@fastify/cors'
|
|
39
|
+
import fastifyRateLimit from '@fastify/rate-limit'
|
|
40
|
+
import fastifyCsrf from '@fastify/csrf-protection'
|
|
41
|
+
import fastifyCookie from '@fastify/cookie'
|
|
42
|
+
import { registerAspPlugin } from '../lib/asp/plugin.js'
|
|
43
|
+
import { MegaForbiddenError } from '../errors/http-errors.js'
|
|
44
|
+
import * as MegaTracing from '../lib/mega-tracing.js'
|
|
45
|
+
|
|
46
|
+
/** rate-limit 기본값 (ADR-048: IP 당 100 req/min). 사용자 config 로 완전 교체(ADR-073). */
|
|
47
|
+
export const DEFAULT_RATE_LIMIT = Object.freeze({ max: 100, timeWindow: '1 minute' })
|
|
48
|
+
|
|
49
|
+
/** CSRF 토큰 검증을 건너뛰는 안전 메서드(상태 변경 없음). */
|
|
50
|
+
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS'])
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 보안 거부 사유를 현재 활성 HTTP span 에 기록한다(ADR-126). 활성 span 없거나 트레이싱 OFF 면 no-op.
|
|
54
|
+
* @param {string} reason - `mega.security.reason` 값(예: `'csrf.origin_mismatch'`).
|
|
55
|
+
* @param {Record<string, string|number|boolean>} [extra] - 부가 attribute.
|
|
56
|
+
* @returns {void}
|
|
57
|
+
*/
|
|
58
|
+
function annotateReject(reason, extra = {}) {
|
|
59
|
+
MegaTracing.tracer.activeSpan()?.setAttributes({ 'mega.security.reason': reason, ...extra })
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 보안 플러그인 자동 등록 결과 요약(디버그·테스트용).
|
|
64
|
+
* @typedef {Object} SecuritySummary
|
|
65
|
+
* @property {boolean} helmet
|
|
66
|
+
* @property {boolean} cors
|
|
67
|
+
* @property {boolean} rateLimit
|
|
68
|
+
* @property {boolean} csrf
|
|
69
|
+
* @property {boolean} asp - HTTP ASP 등록 여부.
|
|
70
|
+
* @property {'redis'|'memory'|null} rateLimitStore - rate-limit 백엔드(미등록이면 null).
|
|
71
|
+
*/
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Fastify 인스턴스에 보안 플러그인 4종 + ASP HTTP 를 자동 등록한다.
|
|
75
|
+
*
|
|
76
|
+
* ⚠️ **호출 순서** — 반드시 `/health` 라우트 등록 "이전"에 호출해야 한다. @fastify/rate-limit 은 onRoute
|
|
77
|
+
* hook 으로 라우트별 제한을 붙이는데, onRoute 는 플러그인 등록 "이후" 라우트만 잡는다. health 보다 먼저
|
|
78
|
+
* 등록해야 health 의 `config.rateLimit: false` 면제가 실효한다.
|
|
79
|
+
*
|
|
80
|
+
* @param {import('fastify').FastifyInstance} fastify - 대상 앱 Fastify 인스턴스.
|
|
81
|
+
* @param {Object} opts
|
|
82
|
+
* @param {string} opts.appName - 앱 이름(로그·에러 메시지용).
|
|
83
|
+
* @param {string[]} [opts.hosts] - 앱 도메인 목록(CSRF Origin 검증 allowlist, ADR-051).
|
|
84
|
+
* @param {Object|false} [opts.helmet] - fastify-helmet 옵션. false=미등록, undefined=디폴트 ON.
|
|
85
|
+
* @param {Object|false} [opts.cors] - fastify-cors 옵션. false=미등록, undefined=origin:false(교차출처 거부).
|
|
86
|
+
* @param {Object|false} [opts.rateLimit] - fastify-rate-limit 옵션. false=미등록, undefined=디폴트(100/min).
|
|
87
|
+
* @param {Object|false} [opts.csrf] - fastify-csrf-protection 옵션. false=미등록, undefined=디폴트 ON.
|
|
88
|
+
* @param {{ masterSecret?: string, http?: { enabledPaths?: string[], driftMs?: number, headerSignal?: string, timestampHeader?: string, nonceCache?: any } }} [opts.asp] -
|
|
89
|
+
* ASP 설정. `masterSecret` + `http.enabledPaths`(비어있지 않음)가 둘 다 있을 때만 HTTP ASP 등록.
|
|
90
|
+
* @param {(() => import('ioredis').Redis | null)} [opts.resolveRateStore] - rate-limit Redis 백엔드 해석기.
|
|
91
|
+
* `caches.rate` 별명을 ioredis 인스턴스로 돌려주면 분산 카운팅(ADR-048), null 이면 in-memory 폴백.
|
|
92
|
+
* @param {{ debug?: Function, warn?: Function }} [opts.logger] - 길목 debug 로그(선택).
|
|
93
|
+
* @returns {SecuritySummary}
|
|
94
|
+
*/
|
|
95
|
+
export function registerSecurityPlugins(fastify, opts) {
|
|
96
|
+
const {
|
|
97
|
+
appName,
|
|
98
|
+
hosts = [],
|
|
99
|
+
helmet,
|
|
100
|
+
cors,
|
|
101
|
+
rateLimit,
|
|
102
|
+
csrf,
|
|
103
|
+
asp,
|
|
104
|
+
resolveRateStore,
|
|
105
|
+
logger,
|
|
106
|
+
} = opts
|
|
107
|
+
|
|
108
|
+
/** @type {SecuritySummary} */
|
|
109
|
+
const summary = { helmet: false, cors: false, rateLimit: false, csrf: false, asp: false, rateLimitStore: null }
|
|
110
|
+
|
|
111
|
+
// ── helmet (보안 헤더 + CSP, ADR-047) ──
|
|
112
|
+
if (helmet !== false) {
|
|
113
|
+
fastify.register(fastifyHelmet, /** @type {any} */ (helmet ?? {}))
|
|
114
|
+
summary.helmet = true
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ── cors (ADR-047) — 디폴트 origin:false(교차출처 거부, 안전 디폴트). ──
|
|
118
|
+
if (cors !== false) {
|
|
119
|
+
fastify.register(fastifyCors, /** @type {any} */ (cors ?? { origin: false }))
|
|
120
|
+
summary.cors = true
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ── rate-limit (ADR-048) — 멀티 인스턴스는 Redis(caches.rate), 미선언 시 in-memory 폴백. ──
|
|
124
|
+
if (rateLimit !== false) {
|
|
125
|
+
const base = /** @type {Record<string, any>} */ (rateLimit ?? DEFAULT_RATE_LIMIT) // ADR-073: 완전 교체.
|
|
126
|
+
// Redis 백엔드 해석 — 해석기가 ioredis 인스턴스를 주면 분산 카운팅, 아니면 in-memory.
|
|
127
|
+
const redis = typeof resolveRateStore === 'function' ? resolveRateStore() : null
|
|
128
|
+
summary.rateLimitStore = redis ? 'redis' : 'memory'
|
|
129
|
+
|
|
130
|
+
// ⚠️ onRoute 순서 함정 회피 — @fastify/rate-limit 의 `global:true` 는 onRoute hook 으로 라우트별
|
|
131
|
+
// limiter 를 붙이는데, onRoute 는 "등록 이후" 라우트만 잡는다. 우리 라우트는 loadRoutes/Router 가
|
|
132
|
+
// 생성자 "이후" 직접 등록하므로 onRoute 가 놓친다(실측 확인). 대신 `global:false` 로 등록하고
|
|
133
|
+
// `register().after()`(플러그인 로드 직후, 데코레이터 `fastify.rateLimit` 가용)에서 limiter 를
|
|
134
|
+
// **글로벌 onRequest hook** 으로 부착한다 — 글로벌 hook 은 등록 순서와 무관하게 전 라우트 적용.
|
|
135
|
+
// `/health` 등 면제는 allowList 가 라우트 `config.skipRateLimit` 를 보고 우회한다(ADR-072/127).
|
|
136
|
+
const userAllow = base.allowList
|
|
137
|
+
/** @type {(req: any, key: string) => boolean} */
|
|
138
|
+
const allowList = (req, key) => {
|
|
139
|
+
if (req.routeOptions?.config?.skipRateLimit === true) return true // 면제 라우트(/health 등).
|
|
140
|
+
if (Array.isArray(userAllow)) return userAllow.includes(key)
|
|
141
|
+
if (typeof userAllow === 'function') return userAllow(req, key)
|
|
142
|
+
return false
|
|
143
|
+
}
|
|
144
|
+
fastify.register(fastifyRateLimit, /** @type {any} */ ({ global: false, ...(redis ? { redis } : {}) })).after(
|
|
145
|
+
(/** @type {Error|null} */ err) => {
|
|
146
|
+
if (err) throw err // 등록 실패는 fail-fast(묵시 무시 금지).
|
|
147
|
+
fastify.addHook(
|
|
148
|
+
'onRequest',
|
|
149
|
+
/** @type {any} */ (fastify).rateLimit({
|
|
150
|
+
...base,
|
|
151
|
+
allowList,
|
|
152
|
+
// 초과 시 활성 span 에 거부 사유 기록(ADR-126). 응답은 플러그인 기본(429 + Retry-After).
|
|
153
|
+
onExceeded: (/** @type {any} */ req, /** @type {string} */ key) => {
|
|
154
|
+
annotateReject('rate_limit.exceeded', { 'mega.security.rate_limit.key': String(key) })
|
|
155
|
+
logger?.debug?.({ app: appName, route: req.routeOptions?.url, key }, 'security.rate_limit exceeded')
|
|
156
|
+
},
|
|
157
|
+
}),
|
|
158
|
+
)
|
|
159
|
+
},
|
|
160
|
+
)
|
|
161
|
+
summary.rateLimit = true
|
|
162
|
+
logger?.debug?.({ app: appName, store: summary.rateLimitStore }, 'security.rate_limit registered')
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ── csrf (ADR-051) — cookie double-submit. JSON 면제+Origin 검증 / 폼 토큰 검증. ──
|
|
166
|
+
if (csrf !== false) {
|
|
167
|
+
fastify.register(fastifyCookie)
|
|
168
|
+
fastify.register(fastifyCsrf, /** @type {any} */ (csrf ?? {}))
|
|
169
|
+
registerCsrfGuard(fastify, { hosts, logger, appName })
|
|
170
|
+
summary.csrf = true
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ── ASP HTTP (옵트인) — masterSecret + http.enabledPaths 둘 다 있을 때만. ──
|
|
174
|
+
if (asp?.masterSecret && Array.isArray(asp.http?.enabledPaths) && asp.http.enabledPaths.length > 0) {
|
|
175
|
+
registerAspPlugin(
|
|
176
|
+
fastify,
|
|
177
|
+
{
|
|
178
|
+
masterSecret: asp.masterSecret,
|
|
179
|
+
enabledPaths: asp.http.enabledPaths,
|
|
180
|
+
driftMs: asp.http.driftMs,
|
|
181
|
+
headerSignal: asp.http.headerSignal,
|
|
182
|
+
timestampHeader: asp.http.timestampHeader,
|
|
183
|
+
nonceCache: asp.http.nonceCache,
|
|
184
|
+
},
|
|
185
|
+
{ logger: /** @type {any} */ (logger) },
|
|
186
|
+
)
|
|
187
|
+
summary.asp = true
|
|
188
|
+
logger?.debug?.({ app: appName, paths: asp.http.enabledPaths }, 'security.asp_http registered')
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
logger?.debug?.({ app: appName, ...summary }, 'security.plugins registered')
|
|
192
|
+
return summary
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* ADR-051 CSRF 조건부 가드 — 글로벌 `preHandler`(body 파싱 이후, 폼 `_csrf` 필드 접근 위해).
|
|
197
|
+
*
|
|
198
|
+
* 동작 (ADR-051 정본: "폼 기반 요청만 토큰 검증, 나머지는 Origin"):
|
|
199
|
+
* 1. `skipCsrf` 라우트 → 통과(/ health, /metrics 등 면제).
|
|
200
|
+
* 2. 안전 메서드(GET/HEAD/OPTIONS) → 통과(상태 변경 없음).
|
|
201
|
+
* 3. **폼**(`application/x-www-form-urlencoded` / `multipart/form-data`) → `fastify.csrfProtection`
|
|
202
|
+
* 으로 토큰 검증(실패 시 플러그인이 403 송신). HTML 폼 auto-submit 이 CSRF 의 주 공격 벡터.
|
|
203
|
+
* 4. 그 외(JSON·빈 본문·기타) → **토큰 면제 + Origin 검증**. Origin/Referer 가 앱 도메인과 불일치하면
|
|
204
|
+
* 403. Origin 헤더 없는 비브라우저 클라(API)는 통과(CSRF 는 브라우저 쿠키 공격이라 해당 없음).
|
|
205
|
+
*
|
|
206
|
+
* @param {import('fastify').FastifyInstance} fastify
|
|
207
|
+
* @param {{ hosts: string[], logger?: { debug?: Function, warn?: Function }, appName: string }} args
|
|
208
|
+
* @returns {void}
|
|
209
|
+
*/
|
|
210
|
+
function registerCsrfGuard(fastify, { hosts, logger, appName }) {
|
|
211
|
+
fastify.addHook('preHandler', async (req, reply) => {
|
|
212
|
+
// skipCsrf 는 우리 전용 라우트 플래그 — Fastify config 타입에 없어 cast.
|
|
213
|
+
const routeCfg = /** @type {{ skipCsrf?: boolean } | undefined} */ (req.routeOptions?.config)
|
|
214
|
+
if (routeCfg?.skipCsrf) return
|
|
215
|
+
if (SAFE_METHODS.has(req.method)) return
|
|
216
|
+
|
|
217
|
+
const contentType = String(req.headers['content-type'] ?? '')
|
|
218
|
+
const isForm =
|
|
219
|
+
contentType.includes('application/x-www-form-urlencoded') ||
|
|
220
|
+
contentType.includes('multipart/form-data')
|
|
221
|
+
|
|
222
|
+
if (isForm) {
|
|
223
|
+
// ADR-051: 폼 → 토큰 검증. csrfProtection 은 동기(@fastify/csrf-protection v7):
|
|
224
|
+
// 성공 시 next() 호출, 실패 시 reply.send(403) 후 next 미호출. next 호출 여부로 통과 판별한다.
|
|
225
|
+
let passed = false
|
|
226
|
+
fastify.csrfProtection(/** @type {any} */ (req), /** @type {any} */ (reply), () => {
|
|
227
|
+
passed = true
|
|
228
|
+
})
|
|
229
|
+
if (!passed) {
|
|
230
|
+
annotateReject('csrf.invalid_token')
|
|
231
|
+
logger?.debug?.({ app: appName, route: req.routeOptions?.url }, 'security.csrf invalid token')
|
|
232
|
+
// reply 는 csrfProtection 이 이미 403 송신. **return reply 로 라이프사이클을 명시 중단**한다
|
|
233
|
+
// (Fastify 정본 관용) — plain return 만 하면, 비동기 onSend 훅(예: 세션 store I/O await, ADR-129)이
|
|
234
|
+
// 응답 완료를 지연시키는 동안 Fastify 가 핸들러를 실행해 이중 send(ERR_HTTP_HEADERS_SENT)가 난다.
|
|
235
|
+
return reply
|
|
236
|
+
}
|
|
237
|
+
return
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ADR-051: 비폼(JSON·빈 본문·기타) → 토큰 면제 + Origin 검증.
|
|
241
|
+
if (!isOriginAllowed(req, hosts)) {
|
|
242
|
+
annotateReject('csrf.origin_mismatch')
|
|
243
|
+
logger?.debug?.({ app: appName, origin: req.headers.origin ?? req.headers.referer }, 'security.csrf origin mismatch')
|
|
244
|
+
throw new MegaForbiddenError(
|
|
245
|
+
'csrf.origin_mismatch',
|
|
246
|
+
'CSRF: Origin header does not match an allowed host (ADR-051).',
|
|
247
|
+
{ details: { rule: 'origin_mismatch' } },
|
|
248
|
+
)
|
|
249
|
+
}
|
|
250
|
+
})
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* CSRF Origin 검증(ADR-051) — Origin/Referer 의 hostname 이 요청 Host 또는 앱 도메인과 일치하는가.
|
|
255
|
+
* Origin·Referer 둘 다 없으면(비브라우저 API 클라) 통과 — CSRF 는 브라우저 쿠키 공격이라 해당 없음.
|
|
256
|
+
*
|
|
257
|
+
* @param {import('fastify').FastifyRequest} req
|
|
258
|
+
* @param {string[]} hosts - 앱 도메인 allowlist(`app.config.hosts`).
|
|
259
|
+
* @returns {boolean} 허용 여부.
|
|
260
|
+
*/
|
|
261
|
+
function isOriginAllowed(req, hosts) {
|
|
262
|
+
const raw = req.headers.origin ?? req.headers.referer
|
|
263
|
+
if (!raw) return true // 비브라우저 클라 — Origin 없음.
|
|
264
|
+
let originHost
|
|
265
|
+
try {
|
|
266
|
+
originHost = new URL(String(raw)).hostname
|
|
267
|
+
} catch {
|
|
268
|
+
// 파싱 불가능한 Origin = 위조 의심 → 거부(묵시 무시 아님, 명시 거부).
|
|
269
|
+
return false
|
|
270
|
+
}
|
|
271
|
+
const reqHostname = String(req.headers.host ?? '').split(':')[0]
|
|
272
|
+
if (originHost === reqHostname) return true // 동일 출처.
|
|
273
|
+
// 앱 도메인 allowlist 와도 비교(host:port 형태면 hostname 만).
|
|
274
|
+
return hosts.some((h) => originHost === String(h).split(':')[0])
|
|
275
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { readdirSync, statSync } from 'node:fs'
|
|
3
|
+
import { join, resolve as pathResolve } from 'node:path'
|
|
4
|
+
import { pathToFileURL } from 'node:url'
|
|
5
|
+
import { MegaService } from './mega-service.js'
|
|
6
|
+
import { MegaConfigError } from '../errors/config-error.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* 서비스 파일명 → DI 이름 도출. `-service` suffix 를 떼고 kebab→camelCase 한다
|
|
10
|
+
* (`user-service.js` → `user`, `audit-log-service.js` → `auditLog`, docs/03 §149 정본).
|
|
11
|
+
* suffix 가 없으면 파일명 stem 을 그대로 camelCase 한다(`user.js` → `user`).
|
|
12
|
+
*
|
|
13
|
+
* @param {string} fileName - `.js` 포함 파일명.
|
|
14
|
+
* @returns {string} ctx.services.<name> 키.
|
|
15
|
+
*/
|
|
16
|
+
export function serviceNameFromFile(fileName) {
|
|
17
|
+
const stem = fileName.replace(/\.js$/, '').replace(/-service$/, '')
|
|
18
|
+
return stem.replace(/-([a-z0-9])/g, (_, c) => c.toUpperCase())
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* 앱의 services/ 폴더 전체 스캔 → 각 파일의 default export(MegaService 서브클래스)를
|
|
23
|
+
* `name → Class` 레지스트리(Map)로 만든다. 부팅 시 1회 실행되며, 요청별 인스턴스화는
|
|
24
|
+
* `ctx.services` lazy proxy 가 이 레지스트리를 보고 수행한다(ADR-148).
|
|
25
|
+
*
|
|
26
|
+
* routes-loader 와 동일하게 flat readdir(하위 폴더 미스캔) + `.test.js` 제외 + 정렬한다.
|
|
27
|
+
* 폴더 부재(ENOENT)는 정상(서비스 없는 앱) — 빈 Map 반환.
|
|
28
|
+
*
|
|
29
|
+
* @param {Object} opts
|
|
30
|
+
* @param {string} opts.servicesDir - apps/<name>/services 절대 경로.
|
|
31
|
+
* @param {string} opts.appName - 앱 이름(에러 메시지용).
|
|
32
|
+
* @returns {Promise<Map<string, Function>>} name → 서비스 클래스.
|
|
33
|
+
* @throws {MegaConfigError} 로드 실패 / default export 없음 / MegaService 미상속 / 이름 중복.
|
|
34
|
+
*/
|
|
35
|
+
export async function loadServices({ servicesDir, appName }) {
|
|
36
|
+
/** @type {Map<string, Function>} */
|
|
37
|
+
const registry = new Map()
|
|
38
|
+
|
|
39
|
+
let serviceFiles
|
|
40
|
+
try {
|
|
41
|
+
serviceFiles = readdirSync(servicesDir)
|
|
42
|
+
.filter((f) => f.endsWith('.js') && !f.endsWith('.test.js'))
|
|
43
|
+
.sort()
|
|
44
|
+
} catch (err) {
|
|
45
|
+
if (/** @type {any} */ (err).code === 'ENOENT') return registry
|
|
46
|
+
throw err
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
for (const fileName of serviceFiles) {
|
|
50
|
+
const absPath = pathResolve(join(servicesDir, fileName))
|
|
51
|
+
if (!statSync(absPath).isFile()) continue
|
|
52
|
+
|
|
53
|
+
let mod
|
|
54
|
+
try {
|
|
55
|
+
mod = await import(pathToFileURL(absPath).href)
|
|
56
|
+
} catch (err) {
|
|
57
|
+
throw new MegaConfigError(
|
|
58
|
+
'service.file_load_failed',
|
|
59
|
+
`Failed to load service file '${appName}/services/${fileName}': ${/** @type {any} */ (err).message}`,
|
|
60
|
+
{ cause: err },
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// 서비스 클래스 = export(default 또는 named) 중 MegaService 를 상속한 함수. 스캐폴드 템플릿은
|
|
65
|
+
// `export class {{Name}}Service extends MegaService`(named) 를 쓰므로 default 만 보지 않고 전체 export 를
|
|
66
|
+
// 훑는다. DI 인스턴스화 계약은 `new Cls(ctx, { app })`(MegaService 생성자) — 미상속 클래스는 제외된다.
|
|
67
|
+
const candidates = Object.values(mod).filter(
|
|
68
|
+
(v) => typeof v === 'function' && (v === MegaService || v.prototype instanceof MegaService),
|
|
69
|
+
)
|
|
70
|
+
if (candidates.length === 0) {
|
|
71
|
+
throw new MegaConfigError(
|
|
72
|
+
'service.no_service_class',
|
|
73
|
+
`Service file '${appName}/services/${fileName}' must export a class extending MegaService.`,
|
|
74
|
+
{ details: { app: appName, file: fileName } },
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
if (candidates.length > 1) {
|
|
78
|
+
throw new MegaConfigError(
|
|
79
|
+
'service.ambiguous',
|
|
80
|
+
`Service file '${appName}/services/${fileName}' exports ${candidates.length} MegaService classes — keep one service per file.`,
|
|
81
|
+
{ details: { app: appName, file: fileName, count: candidates.length } },
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
const Cls = candidates[0]
|
|
85
|
+
|
|
86
|
+
const name = serviceNameFromFile(fileName)
|
|
87
|
+
if (registry.has(name)) {
|
|
88
|
+
throw new MegaConfigError(
|
|
89
|
+
'service.duplicate_name',
|
|
90
|
+
`Service name '${name}' (from '${appName}/services/${fileName}') collides with another service file in the same app.`,
|
|
91
|
+
{ details: { app: appName, name } },
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
registry.set(name, Cls)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return registry
|
|
98
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* createSessionCleanupSchedule — 만료 세션 정리 스케줄 팩토리 (ADR-129/046, roadmap §216).
|
|
4
|
+
*
|
|
5
|
+
* ADR-046: "file 모드 cleanup 은 mega scheduler 가 자동 등록". 세션 스토어는 per-app inline 자원이라
|
|
6
|
+
* 전역 어댑터 매니저(`ctx.lock` 등)로 해석되지 않으므로, **스토어 인스턴스를 클로저로 캡처**한 `MegaSchedule`
|
|
7
|
+
* 서브클래스를 만들어 `mega scheduler` 호스트(또는 `config.schedules`)에 등록한다.
|
|
8
|
+
*
|
|
9
|
+
* # 단일 인스턴스 vs 멀티 인스턴스
|
|
10
|
+
* - 단일 dev: `MegaFileSessionAdapter` 의 `cleanupIntervalMs`(내부 unref 타이머)로 충분(스케줄 불필요).
|
|
11
|
+
* - 멀티 인스턴스: 본 팩토리로 스케줄을 만들고 `lock`(분산 락, ctx.lock alias)을 주면 한 인스턴스만
|
|
12
|
+
* 정리하도록 중복방지된다(`MegaScheduler` 가 lock 을 처리).
|
|
13
|
+
* - Redis 스토어는 TTL 자동 만료라 cleanup 이 no-op(0) — 스케줄 등록은 무해하지만 보통 불필요.
|
|
14
|
+
*
|
|
15
|
+
* @module core/session-cleanup-schedule
|
|
16
|
+
*/
|
|
17
|
+
import { MegaSchedule } from '../lib/mega-schedule.js'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 만료 세션 정리 `MegaSchedule` 서브클래스를 만든다.
|
|
21
|
+
*
|
|
22
|
+
* @param {import('../adapters/mega-session-adapter.js').MegaSessionAdapter} store - 정리할 세션 스토어(연결됨).
|
|
23
|
+
* @param {object} [opts]
|
|
24
|
+
* @param {string} [opts.cron='0 * * * *'] - cron 표현식(기본 매시 정각).
|
|
25
|
+
* @param {string} [opts.timezone] - IANA 타임존.
|
|
26
|
+
* @param {import('../lib/mega-schedule.js').MegaScheduleLock} [opts.lock] - 분산 중복방지 락(멀티 인스턴스).
|
|
27
|
+
* @param {string} [opts.name='SessionCleanup'] - 스케줄 이름(MegaScheduler 등록 키).
|
|
28
|
+
* @returns {typeof MegaSchedule}
|
|
29
|
+
* @throws {Error} store 가 없거나 cleanup 메서드가 없을 때.
|
|
30
|
+
*/
|
|
31
|
+
export function createSessionCleanupSchedule(store, opts = {}) {
|
|
32
|
+
if (!store || typeof store.cleanup !== 'function') {
|
|
33
|
+
// 잘못된 store 는 fail-fast — 스케줄이 등록만 되고 조용히 아무것도 안 하는 일 방지.
|
|
34
|
+
throw new Error('createSessionCleanupSchedule: store must be a MegaSessionAdapter with cleanup().')
|
|
35
|
+
}
|
|
36
|
+
const cron = opts.cron ?? '0 * * * *'
|
|
37
|
+
const name = opts.name ?? 'SessionCleanup'
|
|
38
|
+
|
|
39
|
+
class SessionCleanup extends MegaSchedule {
|
|
40
|
+
static cron = cron
|
|
41
|
+
static timezone = opts.timezone
|
|
42
|
+
static lock = opts.lock
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 만료 세션 정리. store 는 클로저로 캡처(ctx 불필요).
|
|
46
|
+
* @param {Record<string, any>} _ctx
|
|
47
|
+
* @returns {Promise<{ removed: number }>}
|
|
48
|
+
*/
|
|
49
|
+
async run(_ctx) {
|
|
50
|
+
const removed = await store.cleanup()
|
|
51
|
+
return { removed: typeof removed === 'number' ? removed : 0 }
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
// MegaScheduler.register 는 ScheduleClass.name(비어있지 않음)을 등록 키로 쓴다 — 익명 클래스라 명시 부여.
|
|
55
|
+
Object.defineProperty(SessionCleanup, 'name', { value: name, configurable: true })
|
|
56
|
+
return SessionCleanup
|
|
57
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* createSessionStore — 세션 스토어 어댑터 팩토리 (ADR-129).
|
|
4
|
+
*
|
|
5
|
+
* `session.store.driver` ('file' | 'redis') 로 구체 `MegaSessionAdapter` 를 만든다.
|
|
6
|
+
*
|
|
7
|
+
* # 왜 전용 팩토리인가 (공유 드라이버 레지스트리 미사용)
|
|
8
|
+
* `adapters/registry.js` 의 공유 레지스트리는 driver 명 'redis'/'file' 을 이미 **cache 어댑터**
|
|
9
|
+
* (`MegaRedisAdapter`/`MegaFileAdapter`)에 매핑한다(ADR-044). 세션 스토어는 같은 driver 명을
|
|
10
|
+
* 쓰지만 **다른 인터페이스**(load/save/destroy/touch/cleanup)라 같은 레지스트리에 등록하면 충돌한다.
|
|
11
|
+
* 세션은 도메인이 분리(`ctx.session` = 요청별 로드 객체, db/cache 처럼 alias 접근자가 아님)되므로,
|
|
12
|
+
* 세션 전용 팩토리로 driver→어댑터를 직접 매핑한다(레지스트리/adapter-manager 무변경, ADR-129).
|
|
13
|
+
*
|
|
14
|
+
* @module core/session-store
|
|
15
|
+
*/
|
|
16
|
+
import { MegaConfigError } from '../errors/config-error.js'
|
|
17
|
+
import { MegaFileSessionAdapter } from '../adapters/file-session-adapter.js'
|
|
18
|
+
import { MegaRedisSessionAdapter } from '../adapters/redis-session-adapter.js'
|
|
19
|
+
|
|
20
|
+
/** 지원 driver → 어댑터 클래스. */
|
|
21
|
+
const SESSION_DRIVERS = Object.freeze({
|
|
22
|
+
file: MegaFileSessionAdapter,
|
|
23
|
+
redis: MegaRedisSessionAdapter,
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 세션 스토어 어댑터를 만든다(connect 는 호출자 책임 — MegaApp 가 부팅 시 connect).
|
|
28
|
+
*
|
|
29
|
+
* @param {{ driver?: string, [k: string]: any }} storeConfig - `session.store` 설정.
|
|
30
|
+
* `driver` ('file'|'redis') + driver 별 옵션(basePath / url / keyPrefix / ttlMs …).
|
|
31
|
+
* @param {{ ttlMs?: number }} [defaults] - 미들웨어가 주입하는 기본값(store 에 ttlMs 미지정 시 사용).
|
|
32
|
+
* @returns {import('../adapters/mega-session-adapter.js').MegaSessionAdapter}
|
|
33
|
+
* @throws {MegaConfigError} `session.invalid_store` - storeConfig 누락/형식 오류.
|
|
34
|
+
* @throws {MegaConfigError} `session.unknown_driver` - 미지원 driver.
|
|
35
|
+
*/
|
|
36
|
+
export function createSessionStore(storeConfig, defaults = {}) {
|
|
37
|
+
if (!storeConfig || typeof storeConfig !== 'object' || Array.isArray(storeConfig)) {
|
|
38
|
+
throw new MegaConfigError('session.invalid_store', 'session.store must be a plain object: { driver: "file"|"redis", ... }.', {
|
|
39
|
+
details: { type: Array.isArray(storeConfig) ? 'array' : storeConfig === null ? 'null' : typeof storeConfig },
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
const driver = storeConfig.driver
|
|
43
|
+
if (typeof driver !== 'string' || !(driver in SESSION_DRIVERS)) {
|
|
44
|
+
throw new MegaConfigError('session.unknown_driver', `session.store.driver must be one of [${Object.keys(SESSION_DRIVERS).join(', ')}] (got ${JSON.stringify(driver)}).`, {
|
|
45
|
+
details: { driver, supported: Object.keys(SESSION_DRIVERS) },
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
const Cls = SESSION_DRIVERS[/** @type {'file'|'redis'} */ (driver)]
|
|
49
|
+
// store 에 ttlMs 가 명시되지 않았으면 미들웨어 기본 ttlMs 를 주입(단일 출처 — session.ttlMs).
|
|
50
|
+
const cfg = storeConfig.ttlMs === undefined && defaults.ttlMs !== undefined ? { ...storeConfig, ttlMs: defaults.ttlMs } : storeConfig
|
|
51
|
+
return new Cls(/** @type {any} */ (cfg))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** 지원 세션 driver 목록(테스트·문서용). */
|
|
55
|
+
export const SESSION_STORE_DRIVERS = Object.freeze(Object.keys(SESSION_DRIVERS))
|