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,414 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* 세션 미들웨어 자동 등록 — 쿠키 ↔ sid ↔ 세션 레코드 배선 (ADR-129/046).
|
|
4
|
+
*
|
|
5
|
+
* 보안 플러그인(`registerSecurityPlugins`)과 같은 패턴으로, 앱 생성 시점에 세션 미들웨어를
|
|
6
|
+
* Fastify 인스턴스에 등록한다. 스토어는 `MegaSessionAdapter`(file/redis, `createSessionStore`)이고,
|
|
7
|
+
* 본 모듈은 그 위에 **쿠키 서명 + sid 발급 + 요청별 세션 객체(`req.session`/`ctx.session`)** 를 얹는다.
|
|
8
|
+
*
|
|
9
|
+
* # 정책 (ADR-046 정본)
|
|
10
|
+
* - **sid = ULID** (시간순 정렬 + 추측 어려움). `crypto.randomBytes` 기반 zero-dep 생성.
|
|
11
|
+
* - **쿠키 HMAC sign 기본 ON** — `value = sid.base64url(hmac_sha256(secret, sid))`. 변조 시 무효 처리.
|
|
12
|
+
* 서명 비교는 `crypto.timingSafeEqual`(타이밍 공격 회피).
|
|
13
|
+
* - **rolling TTL** — 로드된(기존) 세션은 매 요청 응답 시 `store.touch(sid, ttlMs)` 로 만료 연장.
|
|
14
|
+
* - cleanup 은 스토어 책임(file=스캔, redis=TTL 자동, ADR-046).
|
|
15
|
+
*
|
|
16
|
+
* # 세션 객체 계약 (03-api-spec §7/§9)
|
|
17
|
+
* `req.session`/`ctx.session` 은 데이터 필드를 직접 읽고 쓰는 객체다. **변경된 세션은 응답 시
|
|
18
|
+
* 자동 영속화**된다(express-session 관례 — 로드 시점 스냅샷과 응답 시점을 비교해 변경분만 저장).
|
|
19
|
+
* 명시 `await ctx.session.save()` 도 지원(즉시 영속 + await 가능). 로그아웃은
|
|
20
|
+
* `await ctx.session.destroy()`. 예약 메서드/게터: `id`·`isNew`·`save`·`destroy`·`regenerate`·`secret`
|
|
21
|
+
* (이 이름은 세션 데이터 키로 쓰지 말 것). `secret` 은 CSRF 세션 모드용 세션 바인딩 시크릿
|
|
22
|
+
* (ADR-051)이며, `@fastify/csrf-protection`(sessionPlugin 모드)이 쓰는 `_csrf` 데이터 키와 동일하다.
|
|
23
|
+
*
|
|
24
|
+
* # 신규 visitor lazy 생성
|
|
25
|
+
* 쿠키 없는 첫 방문은 새 sid 로 세션 객체만 만들고 **`save()` 호출 전까지 영속화·쿠키 발급을 하지
|
|
26
|
+
* 않는다** — 봇/헬스체크가 빈 세션을 양산하지 않게.
|
|
27
|
+
*
|
|
28
|
+
* # 트레이싱 (ADR-126 인프라 재사용)
|
|
29
|
+
* 응답 커밋 시 활성 HTTP span 에 `mega.session.event`(save/touch/destroy/none)·`mega.session.driver`·
|
|
30
|
+
* `mega.session.id`(sid 해시 — 원본 sid 비노출) attribute. 옵트인 OFF 면 0 비용 no-op.
|
|
31
|
+
*
|
|
32
|
+
* @module core/session
|
|
33
|
+
*/
|
|
34
|
+
import { createHmac, timingSafeEqual, randomBytes, createHash } from 'node:crypto'
|
|
35
|
+
import * as MegaTracing from '../lib/mega-tracing.js'
|
|
36
|
+
import { recordSession } from './../lib/mega-metrics.js'
|
|
37
|
+
|
|
38
|
+
/** Crockford base32 알파벳 (ULID 표준). 혼동 문자(I,L,O,U) 제외. */
|
|
39
|
+
const CROCKFORD = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'
|
|
40
|
+
|
|
41
|
+
/** 쿠키 기본값 (httpOnly ON / secure auto / sameSite lax / 24h). */
|
|
42
|
+
const DEFAULT_COOKIE = Object.freeze({ name: 'mega.sid', path: '/', httpOnly: true, secure: 'auto', sameSite: 'lax' })
|
|
43
|
+
|
|
44
|
+
/** 세션 데이터로 쓰면 안 되는 예약 키(메서드/게터와 충돌). */
|
|
45
|
+
const RESERVED_KEYS = Object.freeze(['id', 'isNew', 'save', 'destroy', 'regenerate', 'secret'])
|
|
46
|
+
|
|
47
|
+
/** CSRF 세션 시크릿을 보관하는 데이터 키(세션 모드, ADR-051). `@fastify/csrf-protection` 의 sessionKey 기본값과 동일. */
|
|
48
|
+
const CSRF_SECRET_KEY = '_csrf'
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* ULID sid 생성 — 48-bit timestamp + 80-bit randomness = 26자 Crockford base32 (ADR-046).
|
|
52
|
+
* (ws-message 의 generateMessageId 는 "envelope 전용"이라 세션용 독립 helper 를 둔다. 정식 MegaIdGen 은 OQ-014.)
|
|
53
|
+
*
|
|
54
|
+
* @param {number} [timestamp] - epoch ms(테스트 주입용).
|
|
55
|
+
* @returns {string} 26자 ULID.
|
|
56
|
+
*/
|
|
57
|
+
export function generateSid(timestamp = Date.now()) {
|
|
58
|
+
let time = timestamp
|
|
59
|
+
let timeChars = ''
|
|
60
|
+
for (let i = 0; i < 10; i++) {
|
|
61
|
+
timeChars = CROCKFORD[time % 32] + timeChars
|
|
62
|
+
time = Math.floor(time / 32)
|
|
63
|
+
}
|
|
64
|
+
const rand = randomBytes(16)
|
|
65
|
+
let randChars = ''
|
|
66
|
+
for (let i = 0; i < 16; i++) randChars += CROCKFORD[rand[i] % 32]
|
|
67
|
+
return timeChars + randChars
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* sid 를 HMAC 서명한 쿠키 값으로 만든다 — `sid.base64url(hmac)`.
|
|
72
|
+
* @param {string} sid @param {string} secret @returns {string}
|
|
73
|
+
*/
|
|
74
|
+
function signSid(sid, secret) {
|
|
75
|
+
const mac = createHmac('sha256', secret).update(sid).digest('base64url')
|
|
76
|
+
return `${sid}.${mac}`
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 서명된 쿠키 값을 검증해 sid 를 복원한다. 변조/형식오류면 `null`(timingSafeEqual 로 비교).
|
|
81
|
+
* @param {string} signed @param {string} secret @returns {string | null}
|
|
82
|
+
*/
|
|
83
|
+
function unsignSid(signed, secret) {
|
|
84
|
+
const dot = signed.lastIndexOf('.')
|
|
85
|
+
if (dot <= 0) return null
|
|
86
|
+
const sid = signed.slice(0, dot)
|
|
87
|
+
const mac = signed.slice(dot + 1)
|
|
88
|
+
const expected = createHmac('sha256', secret).update(sid).digest('base64url')
|
|
89
|
+
const a = Buffer.from(mac)
|
|
90
|
+
const b = Buffer.from(expected)
|
|
91
|
+
// 길이 다르면 timingSafeEqual 이 throw 하므로 먼저 길이 비교(불일치 = 위조).
|
|
92
|
+
if (a.length !== b.length) return null
|
|
93
|
+
return timingSafeEqual(a, b) ? sid : null
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 요청 Cookie 헤더에서 특정 이름의 쿠키 값을 꺼낸다(@fastify/cookie 의존 회피 — 우리 쿠키 1종만 파싱).
|
|
98
|
+
* @param {string | undefined} header - `req.headers.cookie`.
|
|
99
|
+
* @param {string} name
|
|
100
|
+
* @returns {string | undefined}
|
|
101
|
+
*/
|
|
102
|
+
function readCookie(header, name) {
|
|
103
|
+
if (!header) return undefined
|
|
104
|
+
for (const part of header.split(';')) {
|
|
105
|
+
const eq = part.indexOf('=')
|
|
106
|
+
if (eq === -1) continue
|
|
107
|
+
if (part.slice(0, eq).trim() === name) return decodeURIComponent(part.slice(eq + 1).trim())
|
|
108
|
+
}
|
|
109
|
+
return undefined
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Set-Cookie 헤더 문자열을 만든다.
|
|
114
|
+
* @param {string} name @param {string} value
|
|
115
|
+
* @param {{ path?: string, httpOnly?: boolean, secure?: boolean, sameSite?: string, maxAgeSec?: number }} opts
|
|
116
|
+
* @returns {string}
|
|
117
|
+
*/
|
|
118
|
+
function serializeCookie(name, value, opts) {
|
|
119
|
+
let str = `${name}=${encodeURIComponent(value)}`
|
|
120
|
+
if (opts.path) str += `; Path=${opts.path}`
|
|
121
|
+
if (typeof opts.maxAgeSec === 'number') str += `; Max-Age=${opts.maxAgeSec}`
|
|
122
|
+
if (opts.httpOnly) str += '; HttpOnly'
|
|
123
|
+
if (opts.secure) str += '; Secure'
|
|
124
|
+
if (opts.sameSite) str += `; SameSite=${opts.sameSite[0].toUpperCase()}${opts.sameSite.slice(1)}`
|
|
125
|
+
return str
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** sid 를 로그/span 에 노출할 때 쓰는 해시(원본 비노출). @param {string} sid @returns {string} */
|
|
129
|
+
function hashSid(sid) {
|
|
130
|
+
return createHash('sha256').update(sid).digest('hex').slice(0, 16)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** 미들웨어 내부 상태를 세션 객체에 숨겨 붙이는 심볼 키(데이터와 분리). */
|
|
134
|
+
const STATE = Symbol('mega.session.state')
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* 요청별 세션 객체를 만든다. 데이터 필드는 객체에 직접(enumerable), 메서드/게터는 non-enumerable
|
|
138
|
+
* (`JSON.stringify` 가 데이터만 직렬화하도록).
|
|
139
|
+
*
|
|
140
|
+
* @param {object} args
|
|
141
|
+
* @param {string} args.sid
|
|
142
|
+
* @param {object} args.data - 로드된 데이터(신규면 `{}`).
|
|
143
|
+
* @param {boolean} args.isNew - 스토어에서 로드 안 됐으면 true.
|
|
144
|
+
* @param {import('../adapters/mega-session-adapter.js').MegaSessionAdapter} args.store
|
|
145
|
+
* @param {number} args.ttlMs
|
|
146
|
+
* @param {{ debug?: Function, warn?: Function }} [args.logger] - regenerate 의 best-effort 정리 실패 로깅용(onSend touch 와 동일 패턴).
|
|
147
|
+
* @returns {Record<string, any>}
|
|
148
|
+
*/
|
|
149
|
+
function makeSession({ sid, data, isNew, store, ttlMs, logger }) {
|
|
150
|
+
/** @type {Record<string, any>} */
|
|
151
|
+
const session = {}
|
|
152
|
+
// 데이터 필드를 객체에 직접 복사(예약 키는 건너뜀 — 메서드와 충돌 방지).
|
|
153
|
+
for (const [k, v] of Object.entries(data ?? {})) {
|
|
154
|
+
if (!RESERVED_KEYS.includes(k)) session[k] = v
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const state = {
|
|
158
|
+
sid,
|
|
159
|
+
isNew,
|
|
160
|
+
store,
|
|
161
|
+
ttlMs,
|
|
162
|
+
/** @type {'set'|'clear'|null} 응답 시 쿠키 동작(null=무동작). */
|
|
163
|
+
cookieAction: /** @type {'set'|'clear'|null} */ (null),
|
|
164
|
+
/** save()/regenerate() 가 명시 호출돼 이미 영속화됐는지. */
|
|
165
|
+
saved: false,
|
|
166
|
+
/** destroy() 됐는지. */
|
|
167
|
+
destroyed: false,
|
|
168
|
+
/** 로드된(기존) 세션인지 — rolling touch 대상. */
|
|
169
|
+
loaded: !isNew,
|
|
170
|
+
/** 로드 시점 데이터 스냅샷(JSON) — 응답 시 변경 감지 자동 저장 기준. */
|
|
171
|
+
snapshot: '',
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
Object.defineProperties(session, {
|
|
175
|
+
[STATE]: { value: state, enumerable: false },
|
|
176
|
+
id: { get: () => state.sid, enumerable: false, configurable: true },
|
|
177
|
+
isNew: { get: () => state.isNew, enumerable: false, configurable: true },
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* 세션 바인딩 시크릿(ADR-051) — 데이터 키 `_csrf` 의 게터/lazy 생성기. 없으면 `randomBytes(18)` 로
|
|
181
|
+
* 생성하고 `_csrf` 에 저장(다음 응답에서 변경 감지 자동 저장).
|
|
182
|
+
*
|
|
183
|
+
* ⚠️ CSRF 세션 모드의 실제 동작은 이 게터를 거치지 않는다 — `@fastify/csrf-protection`(sessionPlugin
|
|
184
|
+
* 모드)은 `req.session._csrf` 를 **직접** 읽고 쓴다(sessionKey 기본값 = `_csrf`). 따라서 이 게터는
|
|
185
|
+
* **사용자 코드 전용**(예: 직접 토큰 생성 등 `_csrf` 를 편하게 다룰 때)이며 향후 활용 위해 보존한다.
|
|
186
|
+
*/
|
|
187
|
+
secret: {
|
|
188
|
+
get() {
|
|
189
|
+
let s = /** @type {any} */ (session)[CSRF_SECRET_KEY]
|
|
190
|
+
if (typeof s !== 'string') {
|
|
191
|
+
s = randomBytes(18).toString('base64url')
|
|
192
|
+
;/** @type {any} */ (session)[CSRF_SECRET_KEY] = s
|
|
193
|
+
}
|
|
194
|
+
return s
|
|
195
|
+
},
|
|
196
|
+
enumerable: false,
|
|
197
|
+
configurable: true,
|
|
198
|
+
},
|
|
199
|
+
|
|
200
|
+
/** 데이터 변경을 스토어에 영속화. 신규 세션이면 응답에서 쿠키 발급. */
|
|
201
|
+
save: {
|
|
202
|
+
value: async () => {
|
|
203
|
+
if (state.destroyed) throw new Error('MegaSession: cannot save() a destroyed session.')
|
|
204
|
+
await state.store.save(state.sid, serializeData(session))
|
|
205
|
+
state.saved = true
|
|
206
|
+
state.loaded = true
|
|
207
|
+
state.cookieAction = 'set'
|
|
208
|
+
},
|
|
209
|
+
enumerable: false,
|
|
210
|
+
configurable: true,
|
|
211
|
+
},
|
|
212
|
+
|
|
213
|
+
/** 세션 폐기(로그아웃). 스토어에서 삭제하고 응답에서 쿠키 제거. */
|
|
214
|
+
destroy: {
|
|
215
|
+
value: async () => {
|
|
216
|
+
await state.store.destroy(state.sid)
|
|
217
|
+
state.destroyed = true
|
|
218
|
+
state.cookieAction = 'clear'
|
|
219
|
+
},
|
|
220
|
+
enumerable: false,
|
|
221
|
+
configurable: true,
|
|
222
|
+
},
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* 세션 고정(fixation) 방지 — 기존 sid 를 폐기하고 새 sid 로 재발급한다(로그인 직후 권장).
|
|
226
|
+
* 데이터는 유지된다.
|
|
227
|
+
*/
|
|
228
|
+
regenerate: {
|
|
229
|
+
value: async () => {
|
|
230
|
+
if (state.destroyed) throw new Error('MegaSession: cannot regenerate() a destroyed session.')
|
|
231
|
+
const oldSid = state.sid
|
|
232
|
+
const newSid = generateSid()
|
|
233
|
+
state.sid = newSid
|
|
234
|
+
await state.store.save(newSid, serializeData(session))
|
|
235
|
+
// 기존 sid 정리는 best-effort — 새 sid 저장은 이미 성공했고 옛 키는 TTL 만료/cleanup 이 회수한다.
|
|
236
|
+
// 실패해도 새 세션은 유효하므로 응답을 막지 않되, silent 묵살은 금지(P4) — warn 으로 가시화한다
|
|
237
|
+
// (onSend 의 rolling touch 실패와 동일 패턴).
|
|
238
|
+
try {
|
|
239
|
+
await state.store.destroy(oldSid)
|
|
240
|
+
} catch (err) {
|
|
241
|
+
logger?.warn?.({ sid: hashSid(oldSid), err }, 'session.regenerate: old sid destroy failed (non-fatal, TTL/cleanup will reclaim)')
|
|
242
|
+
}
|
|
243
|
+
state.saved = true
|
|
244
|
+
state.loaded = true
|
|
245
|
+
state.cookieAction = 'set'
|
|
246
|
+
},
|
|
247
|
+
enumerable: false,
|
|
248
|
+
configurable: true,
|
|
249
|
+
},
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
// 로드 시점 데이터 스냅샷 — 응답 시 이 값과 다르면 자동 저장(변경 감지).
|
|
253
|
+
state.snapshot = JSON.stringify(serializeData(session))
|
|
254
|
+
return session
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* 세션 객체의 데이터 부분만 추출(enumerable own 키 — 메서드/게터 제외).
|
|
259
|
+
* @param {Record<string, any>} session @returns {object}
|
|
260
|
+
*/
|
|
261
|
+
function serializeData(session) {
|
|
262
|
+
/** @type {Record<string, any>} */
|
|
263
|
+
const out = {}
|
|
264
|
+
for (const k of Object.keys(session)) out[k] = session[k]
|
|
265
|
+
return out
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Fastify 인스턴스에 세션 미들웨어를 등록한다.
|
|
270
|
+
*
|
|
271
|
+
* @param {import('fastify').FastifyInstance} fastify
|
|
272
|
+
* @param {object} opts
|
|
273
|
+
* @param {import('../adapters/mega-session-adapter.js').MegaSessionAdapter} opts.store - 연결된 세션 스토어.
|
|
274
|
+
* @param {string} opts.secret - 쿠키 HMAC 서명 시크릿(global `server.sessionSecret`).
|
|
275
|
+
* @param {number} [opts.ttlMs] - 세션 TTL(ms). 기본 24시간.
|
|
276
|
+
* @param {boolean} [opts.rolling] - rolling TTL(기본 true, ADR-046).
|
|
277
|
+
* @param {{ name?: string, path?: string, httpOnly?: boolean, secure?: boolean|'auto', sameSite?: string }} [opts.cookie]
|
|
278
|
+
* @param {string} [opts.driver] - 스토어 driver 명(트레이싱용; 미지정 시 store.getStats().driver).
|
|
279
|
+
* @param {{ debug?: Function, warn?: Function }} [opts.logger]
|
|
280
|
+
* @returns {void}
|
|
281
|
+
* @throws {Error} secret 누락 — 서명 불가(부팅 abort).
|
|
282
|
+
*/
|
|
283
|
+
export function registerSession(fastify, opts) {
|
|
284
|
+
const { store, secret, logger } = opts
|
|
285
|
+
if (typeof secret !== 'string' || secret.length === 0) {
|
|
286
|
+
// 시크릿 없는 세션은 변조 방지 불가 — fail-fast(silent 진행 금지).
|
|
287
|
+
throw new Error('registerSession: opts.secret is required (set global server.sessionSecret) — cookie signing is mandatory (ADR-046).')
|
|
288
|
+
}
|
|
289
|
+
const ttlMs = opts.ttlMs ?? 86_400_000
|
|
290
|
+
const rolling = opts.rolling !== false
|
|
291
|
+
const cookieCfg = { ...DEFAULT_COOKIE, ...(opts.cookie ?? {}) }
|
|
292
|
+
const driver = opts.driver ?? /** @type {any} */ (store.getStats?.())?.driver ?? 'unknown'
|
|
293
|
+
|
|
294
|
+
// 요청 진입 — 쿠키 검증 → sid → 세션 로드 → req.session 부착(길목 로그).
|
|
295
|
+
fastify.addHook('onRequest', async (req) => {
|
|
296
|
+
const signed = readCookie(req.headers.cookie, cookieCfg.name)
|
|
297
|
+
let sid = signed ? unsignSid(signed, secret) : null
|
|
298
|
+
/** @type {object} */
|
|
299
|
+
let data = {}
|
|
300
|
+
let isNew = true
|
|
301
|
+
if (sid) {
|
|
302
|
+
const loaded = await store.load(sid)
|
|
303
|
+
if (loaded !== null && typeof loaded === 'object') {
|
|
304
|
+
data = loaded
|
|
305
|
+
isNew = false
|
|
306
|
+
} else {
|
|
307
|
+
// 쿠키 sid 는 유효 서명이나 스토어에 없음(만료/삭제) → 새 sid 로 신규 취급.
|
|
308
|
+
sid = null
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
if (!sid) sid = generateSid()
|
|
312
|
+
const session = makeSession({ sid, data, isNew, store, ttlMs, logger })
|
|
313
|
+
;/** @type {any} */ (req).session = session
|
|
314
|
+
logger?.debug?.({ route: req.routeOptions?.url, sid: hashSid(sid), isNew }, 'session.load')
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
// 응답 직전 — 쿠키 발급/제거 + rolling touch + 트레이싱(onSend: 헤더 수정 가능 시점).
|
|
318
|
+
fastify.addHook('onSend', async (req, reply, payload) => {
|
|
319
|
+
const session = /** @type {any} */ (req).session
|
|
320
|
+
if (!session) return payload
|
|
321
|
+
const state = session[STATE]
|
|
322
|
+
const secureFlag = cookieCfg.secure === 'auto' ? req.protocol === 'https' : cookieCfg.secure === true
|
|
323
|
+
|
|
324
|
+
/** 쿠키 발급 헬퍼(set/clear). @param {string} value @param {number} maxAgeSec */
|
|
325
|
+
const emitCookie = (value, maxAgeSec) =>
|
|
326
|
+
reply.header('set-cookie', serializeCookie(cookieCfg.name, value, { path: cookieCfg.path, httpOnly: cookieCfg.httpOnly, secure: secureFlag, sameSite: cookieCfg.sameSite, maxAgeSec }))
|
|
327
|
+
const maxAgeSec = Math.floor(ttlMs / 1000)
|
|
328
|
+
|
|
329
|
+
/** @type {'save'|'touch'|'destroy'|'none'} */
|
|
330
|
+
let event = 'none'
|
|
331
|
+
if (state.destroyed) {
|
|
332
|
+
event = 'destroy'
|
|
333
|
+
emitCookie('', 0)
|
|
334
|
+
} else if (state.cookieAction === 'set') {
|
|
335
|
+
// save()/regenerate() 명시 호출 — 이미 store 에 영속됨. 쿠키만 발급(maxAge=ttl).
|
|
336
|
+
event = 'save'
|
|
337
|
+
emitCookie(signSid(state.sid, secret), maxAgeSec)
|
|
338
|
+
} else if (JSON.stringify(serializeData(session)) !== state.snapshot) {
|
|
339
|
+
// 변경 감지 자동 저장(express-session 관례) — 데이터가 로드 시점과 다르면 영속화.
|
|
340
|
+
// CSRF 세션 모드(_csrf 시크릿 생성)·핸들러 mutation 둘 다 여기서 잡힌다.
|
|
341
|
+
await store.save(state.sid, serializeData(session))
|
|
342
|
+
event = 'save'
|
|
343
|
+
emitCookie(signSid(state.sid, secret), maxAgeSec)
|
|
344
|
+
} else if (rolling && state.loaded) {
|
|
345
|
+
// 로드된 기존 세션 — 변경 없어도 rolling TTL 연장 + 쿠키 maxAge 갱신.
|
|
346
|
+
// touch 실패는 비치명적(다음 요청 재시도) — 응답을 막지 않는다.
|
|
347
|
+
try {
|
|
348
|
+
await store.touch(state.sid, ttlMs)
|
|
349
|
+
event = 'touch'
|
|
350
|
+
emitCookie(signSid(state.sid, secret), maxAgeSec)
|
|
351
|
+
} catch (err) {
|
|
352
|
+
logger?.warn?.({ sid: hashSid(state.sid), err }, 'session.touch failed (continuing)')
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (event !== 'none') {
|
|
357
|
+
// 활성 HTTP span 에 세션 이벤트 기록(ADR-126). 옵트인 OFF 면 activeSpan undefined → no-op.
|
|
358
|
+
MegaTracing.tracer.activeSpan()?.setAttributes({
|
|
359
|
+
'mega.session.event': event,
|
|
360
|
+
'mega.session.driver': driver,
|
|
361
|
+
'mega.session.id': hashSid(state.sid),
|
|
362
|
+
})
|
|
363
|
+
logger?.debug?.({ route: req.routeOptions?.url, event, sid: hashSid(state.sid) }, 'session.commit')
|
|
364
|
+
// 메트릭 집계(ADR-131) — 새 세션 영속=created, 파기=destroyed 만 카운트(touch/기존-save 제외).
|
|
365
|
+
// sid 는 PII 라 라벨 미노출. 옵트인 OFF 면 0 비용.
|
|
366
|
+
if (event === 'destroy') recordSession({ driver, event: 'destroyed' })
|
|
367
|
+
else if (event === 'save' && state.isNew) recordSession({ driver, event: 'created' })
|
|
368
|
+
}
|
|
369
|
+
return payload
|
|
370
|
+
})
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Fastify 요청 파이프라인 밖(raw `IncomingMessage`)에서 세션을 **읽기 전용**으로 복원한다.
|
|
375
|
+
*
|
|
376
|
+
* HTTP `'upgrade'` 핸드셰이크는 Fastify 의 onRequest 훅을 타지 않아 `req.session` 이 없다. 그래서
|
|
377
|
+
* WS 라우트의 `before(req)` 인증은 이 helper 로 세션 신원을 확인한다 — {@link registerSession} 의
|
|
378
|
+
* onRequest 가 하는 경로(쿠키 → unsign → `store.load`)를 그대로 재사용하되, rolling touch·쿠키 발급·
|
|
379
|
+
* 변경 저장은 하지 않는다(upgrade 응답엔 set-cookie 를 실을 수 없고, 읽기만 필요하므로).
|
|
380
|
+
*
|
|
381
|
+
* 보안상 동일 규칙을 따른다: 쿠키가 없거나 서명 위조면 `null`(익명), 스토어에 없으면(만료/삭제) `null`.
|
|
382
|
+
* 절대 silent 하게 통과시키지 않는다(fail-closed) — 신원이 없으면 `before` 가 401 로 거부한다.
|
|
383
|
+
*
|
|
384
|
+
* @param {{ headers?: Record<string, any> }} req - raw `IncomingMessage`(또는 `headers.cookie` 를 가진 객체).
|
|
385
|
+
* @param {object} opts
|
|
386
|
+
* @param {import('../adapters/mega-session-adapter.js').MegaSessionAdapter} opts.store - 연결된 세션 스토어(`app.sessionStore`).
|
|
387
|
+
* @param {string} opts.secret - 쿠키 HMAC 서명 시크릿(global `server.sessionSecret`).
|
|
388
|
+
* @param {string} [opts.cookieName] - 세션 쿠키 이름(기본 `'mega.sid'` — {@link registerSession} 디폴트와 일치).
|
|
389
|
+
* @returns {Promise<{ sid: string, data: Record<string, any> } | null>} 유효 세션이면 `{ sid, data }`, 아니면 `null`.
|
|
390
|
+
* @throws {Error} `secret`/`store` 누락 — 서명 검증·로드가 불가하므로 fail-fast(silent 진행 금지).
|
|
391
|
+
* @example
|
|
392
|
+
* // WS before 인증 — 로그인한 세션만 upgrade 허용
|
|
393
|
+
* async function wsRequireAuth(req) {
|
|
394
|
+
* const sess = await readSession(req, { store: app.sessionStore, secret })
|
|
395
|
+
* if (sess?.data.userId == null) return false // 401
|
|
396
|
+
* return { userId: String(sess.data.userId), sessionId: sess.sid } // ctx.auth
|
|
397
|
+
* }
|
|
398
|
+
*/
|
|
399
|
+
export async function readSession(req, { store, secret, cookieName } = /** @type {any} */ ({})) {
|
|
400
|
+
if (typeof secret !== 'string' || secret.length === 0) {
|
|
401
|
+
throw new Error('readSession: opts.secret is required (cookie signature cannot be verified).')
|
|
402
|
+
}
|
|
403
|
+
if (!store || typeof store.load !== 'function') {
|
|
404
|
+
throw new Error('readSession: opts.store with load(sid) is required.')
|
|
405
|
+
}
|
|
406
|
+
const name = typeof cookieName === 'string' && cookieName.length > 0 ? cookieName : DEFAULT_COOKIE.name
|
|
407
|
+
const signed = readCookie(req?.headers?.cookie, name)
|
|
408
|
+
if (!signed) return null // 쿠키 없음 = 비로그인.
|
|
409
|
+
const sid = unsignSid(signed, secret)
|
|
410
|
+
if (!sid) return null // 서명 위조/형식오류 = 익명 취급(fail-closed).
|
|
411
|
+
const loaded = await store.load(sid)
|
|
412
|
+
if (loaded === null || typeof loaded !== 'object') return null // 만료/삭제된 세션.
|
|
413
|
+
return { sid, data: /** @type {Record<string, any>} */ (loaded) }
|
|
414
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* MegaApp 정적 자산 자동 등록 — `@fastify/static` 옵트인 통합.
|
|
4
|
+
*
|
|
5
|
+
* # 무엇인가 (중학생용 설명)
|
|
6
|
+
* 웹페이지에 쓰는 이미지·CSS·JS 같은 "그냥 파일"을 노드가 직접 내려주게 하는 기능이다.
|
|
7
|
+
* `apps/<name>/app.config.js` 의 `staticAssets` 를 켰을 때만(`enabled:true`) 등록한다(디폴트 OFF).
|
|
8
|
+
* 프로덕션에서는 보통 nginx/CDN 이 정적 트래픽을 받으므로, 코어가 멋대로 켜지 않고 옵트인으로 둔다.
|
|
9
|
+
*
|
|
10
|
+
* # 동작 한눈에 (multipart.js / template.js 형제 패턴)
|
|
11
|
+
* 1. `registerStaticAssets(fastify, { staticAssets })` — `enabled:true` + `dir`(실존 디렉터리)일 때만 옵트인 등록.
|
|
12
|
+
* 2. 검증된 공개 플러그인 `@fastify/static` 을 `{ root, prefix, cacheControl/setHeaders, dotfiles }` 로 등록 →
|
|
13
|
+
* `${prefix}/<파일>`(prefix 디폴트 `/static`)로 디스크 파일을 스트리밍 서빙.
|
|
14
|
+
* 3. `enabled:true` 인데 `dir` 누락/미존재 — 프로덕션 **부팅 throw**(fail-fast), dev **warn + skip**(ADR-071).
|
|
15
|
+
*
|
|
16
|
+
* # 보안 (ADR-071 / 12-performance-security / 04-data-models §2)
|
|
17
|
+
* - **path traversal**: `@fastify/static@9.1.3+` 가 인코딩된 경로 구분자·디렉터리 listing 우회를 내부 차단
|
|
18
|
+
* (GHSA-pr96-94w5-mx2h / GHSA-x428-ghpx-8j92 해소 버전). dir 루트 밖 접근 불가.
|
|
19
|
+
* - **dotfiles 차단(디폴트)**: `.git`·`.env` 같은 점파일은 디폴트로 404 차단(`dotfiles:'ignore'`, 존재까지 은닉).
|
|
20
|
+
* `dotfiles:true` 옵트인 시만 서빙. ('deny'(403)는 @fastify/send throw → 인코딩 경로 hang 유발이라 미채택, ADR-139.)
|
|
21
|
+
* - **디폴트 OFF**: 옵트인이 아니면 정적 라우트가 아예 없어 의도치 않은 노출이 없다.
|
|
22
|
+
* - **App-only 스코프**: 앱마다 자기 `dir`·`prefix` 라 멀티앱이 같은 prefix 로 충돌하지 않는다(다른 Fastify 인스턴스).
|
|
23
|
+
* - **메트릭 경로 충돌**: `prefix` 가 `health.metricsPath` 와 정확히 같으면 부팅 throw(config-validator, ADR-072).
|
|
24
|
+
*
|
|
25
|
+
* @module core/static-assets
|
|
26
|
+
* @see ADR-071
|
|
27
|
+
* @see ADR-139
|
|
28
|
+
* @see https://github.com/fastify/fastify-static (@fastify/static v9)
|
|
29
|
+
*/
|
|
30
|
+
import { existsSync } from 'node:fs'
|
|
31
|
+
import { resolve } from 'node:path'
|
|
32
|
+
import fastifyStatic from '@fastify/static'
|
|
33
|
+
import { MegaConfigError } from '../errors/config-error.js'
|
|
34
|
+
|
|
35
|
+
/** 정적 자산 prefix 디폴트 (04-data-models §2 정본). */
|
|
36
|
+
export const DEFAULT_STATIC_PREFIX = '/static'
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 정적 자산 자동 등록 결과 요약(디버그·테스트용).
|
|
40
|
+
* @typedef {Object} StaticAssetsSummary
|
|
41
|
+
* @property {boolean} enabled - 등록 여부.
|
|
42
|
+
* @property {string|null} root - 서빙 루트 절대경로(미등록 시 null).
|
|
43
|
+
* @property {string} prefix - URL prefix.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* `staticAssets` config 를 **순수 정규화**한다(파일시스템 접근 없음 — dir 실존 검사는 register 가 담당).
|
|
48
|
+
*
|
|
49
|
+
* - falsy / `enabled !== true` → `null`(미옵트인).
|
|
50
|
+
* - 그 외 → `{ dir, prefix, cacheControlHeader, dotfiles }`(prefix 디폴트 `/static`).
|
|
51
|
+
*
|
|
52
|
+
* `cacheControl`(정본: 문자열 = `Cache-Control` 헤더값, 예 `'public, max-age=3600'`)은 `cacheControlHeader`
|
|
53
|
+
* 로 전달. `dotfiles`(디폴트 false=차단)는 `'allow'`/`'deny'` 로 변환.
|
|
54
|
+
*
|
|
55
|
+
* @param {unknown} staticAssets - `MegaStaticAssetsAppConfig`.
|
|
56
|
+
* @returns {{ dir: string, prefix: string, cacheControlHeader: string|null, dotfiles: 'allow'|'ignore' } | null}
|
|
57
|
+
*/
|
|
58
|
+
export function normalizeStaticAssets(staticAssets) {
|
|
59
|
+
if (!staticAssets || typeof staticAssets !== 'object') return null
|
|
60
|
+
const c = /** @type {Record<string, any>} */ (staticAssets)
|
|
61
|
+
if (c.enabled !== true) return null // 옵트인 — 명시적으로 켜야 등록.
|
|
62
|
+
|
|
63
|
+
const dir = typeof c.dir === 'string' ? c.dir : ''
|
|
64
|
+
const prefix = typeof c.prefix === 'string' && c.prefix.length > 0 ? c.prefix : DEFAULT_STATIC_PREFIX
|
|
65
|
+
const cacheControlHeader = typeof c.cacheControl === 'string' && c.cacheControl.length > 0 ? c.cacheControl : null
|
|
66
|
+
// 디폴트 false = 점파일 차단(.git/.env 노출 방지). 'ignore'(404, 존재까지 은닉)를 쓴다 — 'deny'(403)는
|
|
67
|
+
// @fastify/send 가 **에러를 throw** 해 인코딩 경로 구분자 요청에서 응답 미완결(hang/DoS, ADR-139 검증) 유발.
|
|
68
|
+
// 'ignore' 는 404 로 조용히 차단(throw 없음). true 옵트인 시만 'allow'(서빙).
|
|
69
|
+
const dotfiles = c.dotfiles === true ? 'allow' : 'ignore'
|
|
70
|
+
return { dir, prefix, cacheControlHeader, dotfiles }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Fastify 인스턴스에 `@fastify/static` 을 옵트인 등록한다.
|
|
75
|
+
*
|
|
76
|
+
* `staticAssets.enabled !== true` 면 **미등록**(정적 라우트 없음). 보안 플러그인·템플릿 등록 이후·`/health`
|
|
77
|
+
* 등록 이전에 호출한다(라우트 등록이므로 보안 onRoute hook 뒤여야 면제/제한이 일관). 멀티앱은 각자 Fastify
|
|
78
|
+
* 인스턴스라 prefix 충돌 없음.
|
|
79
|
+
*
|
|
80
|
+
* `enabled:true` 인데 `dir` 누락/미존재면 — 프로덕션 부팅 throw(`config.static_dir_not_found`), dev warn+skip
|
|
81
|
+
* (ADR-071, 04-data-models §3 검증표). `@fastify/static` 자체도 root 미존재 시 throw 하지만, dev 에선 warn 후
|
|
82
|
+
* 건너뛰어 잘못된 dir 하나로 앱 전체가 죽지 않게 한다.
|
|
83
|
+
*
|
|
84
|
+
* @param {import('fastify').FastifyInstance} fastify - 대상 앱 Fastify 인스턴스.
|
|
85
|
+
* @param {Object} opts
|
|
86
|
+
* @param {unknown} opts.staticAssets - `MegaStaticAssetsAppConfig`. `enabled:true` + 실존 `dir` 일 때만 등록.
|
|
87
|
+
* @param {string} [opts.appName] - 앱 이름(로그용).
|
|
88
|
+
* @param {boolean} [opts.isProduction] - 프로덕션 여부. 미지정 시 `NODE_ENV==='production'`.
|
|
89
|
+
* @param {{ debug?: Function, warn?: Function }} [opts.logger] - 흐름 길목 debug/warn 로그(선택).
|
|
90
|
+
* @returns {StaticAssetsSummary}
|
|
91
|
+
* @throws {MegaConfigError} 프로덕션에서 `enabled:true` + `dir` 누락/미존재.
|
|
92
|
+
*/
|
|
93
|
+
export function registerStaticAssets(
|
|
94
|
+
fastify,
|
|
95
|
+
{ staticAssets, appName = '(unknown)', isProduction = process.env.NODE_ENV === 'production', logger } = /** @type {any} */ ({}),
|
|
96
|
+
) {
|
|
97
|
+
const cfg = normalizeStaticAssets(staticAssets)
|
|
98
|
+
if (cfg === null) return { enabled: false, root: null, prefix: DEFAULT_STATIC_PREFIX }
|
|
99
|
+
|
|
100
|
+
const root = cfg.dir.length > 0 ? resolve(cfg.dir) : ''
|
|
101
|
+
if (root.length === 0 || !existsSync(root)) {
|
|
102
|
+
// ADR-071 — 켰는데 디렉터리가 없다: 운영은 잘못된 배포라 즉시 멈추고, dev 는 경고만 하고 건너뛴다.
|
|
103
|
+
if (isProduction) {
|
|
104
|
+
throw new MegaConfigError(
|
|
105
|
+
'config.static_dir_not_found',
|
|
106
|
+
`App '${appName}' staticAssets.dir='${cfg.dir}' not found (enabled but directory missing).`,
|
|
107
|
+
{ details: { app: appName, dir: cfg.dir, scope: 'staticAssets.dir' } },
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
logger?.warn?.({ app: appName, dir: cfg.dir }, 'static.skipped (enabled but dir missing — dev)')
|
|
111
|
+
return { enabled: false, root: null, prefix: cfg.prefix }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
fastify.register(fastifyStatic, /** @type {any} */ ({
|
|
115
|
+
root,
|
|
116
|
+
prefix: cfg.prefix,
|
|
117
|
+
dotfiles: cfg.dotfiles,
|
|
118
|
+
// 정본 cacheControl 은 raw 헤더 문자열 — @fastify/static 자체 cacheControl 은 끄고 setHeaders 로 직접 박는다.
|
|
119
|
+
...(cfg.cacheControlHeader
|
|
120
|
+
? { cacheControl: false, setHeaders: (/** @type {any} */ res) => res.setHeader('cache-control', cfg.cacheControlHeader) }
|
|
121
|
+
: {}),
|
|
122
|
+
}))
|
|
123
|
+
|
|
124
|
+
logger?.debug?.({ app: appName, root, prefix: cfg.prefix, dotfiles: cfg.dotfiles }, 'static.registered')
|
|
125
|
+
return { enabled: true, root, prefix: cfg.prefix }
|
|
126
|
+
}
|