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.
Files changed (322) hide show
  1. package/.env +127 -0
  2. package/.env.example +186 -0
  3. package/.prettierrc.json +8 -0
  4. package/CHANGELOG.md +259 -0
  5. package/LICENSE +21 -0
  6. package/README.md +153 -0
  7. package/bin/mega-ws-hub.js +15 -0
  8. package/bin/mega.js +38 -0
  9. package/docker-compose.yml +201 -0
  10. package/eslint.config.js +57 -0
  11. package/infra/otel-collector-config.yaml +43 -0
  12. package/jsconfig.json +18 -0
  13. package/package.json +121 -0
  14. package/sample/crud/.env +18 -0
  15. package/sample/crud/.env.example +50 -0
  16. package/sample/crud/README.md +85 -0
  17. package/sample/crud/apps/main/app.config.js +114 -0
  18. package/sample/crud/apps/main/channels/chat-bus.js +115 -0
  19. package/sample/crud/apps/main/channels/chat-channel.js +145 -0
  20. package/sample/crud/apps/main/controllers/auth-controller.js +144 -0
  21. package/sample/crud/apps/main/controllers/cron-controller.js +34 -0
  22. package/sample/crud/apps/main/controllers/guide-controller.js +37 -0
  23. package/sample/crud/apps/main/controllers/jobs-controller.js +43 -0
  24. package/sample/crud/apps/main/controllers/logs-controller.js +35 -0
  25. package/sample/crud/apps/main/controllers/metrics-controller.js +22 -0
  26. package/sample/crud/apps/main/controllers/note-controller.js +116 -0
  27. package/sample/crud/apps/main/controllers/perf-controller.js +38 -0
  28. package/sample/crud/apps/main/controllers/redis-controller.js +36 -0
  29. package/sample/crud/apps/main/controllers/tracing-controller.js +43 -0
  30. package/sample/crud/apps/main/controllers/upload-controller.js +98 -0
  31. package/sample/crud/apps/main/controllers/user-controller.js +34 -0
  32. package/sample/crud/apps/main/controllers/web-controller.js +137 -0
  33. package/sample/crud/apps/main/controllers/worker-controller.js +57 -0
  34. package/sample/crud/apps/main/controllers/ws-controller.js +29 -0
  35. package/sample/crud/apps/main/jobs/email-job.js +72 -0
  36. package/sample/crud/apps/main/locales/client/en.json +3 -0
  37. package/sample/crud/apps/main/locales/client/ko.json +3 -0
  38. package/sample/crud/apps/main/locales/server/en.json +316 -0
  39. package/sample/crud/apps/main/locales/server/ko.json +316 -0
  40. package/sample/crud/apps/main/middleware/web-auth.js +40 -0
  41. package/sample/crud/apps/main/middleware/ws-auth.js +48 -0
  42. package/sample/crud/apps/main/migrations/20260606000001-create-users.js +27 -0
  43. package/sample/crud/apps/main/migrations/20260606000002-add-auth-to-users.js +30 -0
  44. package/sample/crud/apps/main/models/note.js +71 -0
  45. package/sample/crud/apps/main/models/user.js +86 -0
  46. package/sample/crud/apps/main/public/css/app.css +101 -0
  47. package/sample/crud/apps/main/public/css/guide.css +137 -0
  48. package/sample/crud/apps/main/public/js/app.js +54 -0
  49. package/sample/crud/apps/main/public/js/perf.js +129 -0
  50. package/sample/crud/apps/main/public/js/theme-init.js +12 -0
  51. package/sample/crud/apps/main/public/js/upload-demo.js +63 -0
  52. package/sample/crud/apps/main/public/js/worker-demo.js +92 -0
  53. package/sample/crud/apps/main/public/js/ws-chat.js +161 -0
  54. package/sample/crud/apps/main/public/vendor/bootstrap/bootstrap.bundle.min.js +7 -0
  55. package/sample/crud/apps/main/public/vendor/bootstrap/bootstrap.min.css +6 -0
  56. package/sample/crud/apps/main/public/vendor/highlight/github-dark.css +109 -0
  57. package/sample/crud/apps/main/public/vendor/highlight/github.css +118 -0
  58. package/sample/crud/apps/main/public/vendor/mega-client-wasm/README.md +19 -0
  59. package/sample/crud/apps/main/public/vendor/mega-client-wasm/mega_client_wasm.d.ts +196 -0
  60. package/sample/crud/apps/main/public/vendor/mega-client-wasm/mega_client_wasm.js +1187 -0
  61. package/sample/crud/apps/main/public/vendor/mega-client-wasm/mega_client_wasm_bg.wasm +0 -0
  62. package/sample/crud/apps/main/routes/auth.js +15 -0
  63. package/sample/crud/apps/main/routes/cron.js +14 -0
  64. package/sample/crud/apps/main/routes/guide.js +25 -0
  65. package/sample/crud/apps/main/routes/jobs.js +14 -0
  66. package/sample/crud/apps/main/routes/logs.js +28 -0
  67. package/sample/crud/apps/main/routes/metrics.js +13 -0
  68. package/sample/crud/apps/main/routes/notes.js +19 -0
  69. package/sample/crud/apps/main/routes/perf.js +47 -0
  70. package/sample/crud/apps/main/routes/redis.js +14 -0
  71. package/sample/crud/apps/main/routes/tracing.js +14 -0
  72. package/sample/crud/apps/main/routes/upload.js +16 -0
  73. package/sample/crud/apps/main/routes/users.js +54 -0
  74. package/sample/crud/apps/main/routes/web.js +23 -0
  75. package/sample/crud/apps/main/routes/worker.js +15 -0
  76. package/sample/crud/apps/main/routes/ws.js +30 -0
  77. package/sample/crud/apps/main/schedules/cron-counter-schedule.js +30 -0
  78. package/sample/crud/apps/main/services/auth-service.js +74 -0
  79. package/sample/crud/apps/main/services/cron-demo-service.js +66 -0
  80. package/sample/crud/apps/main/services/guide-service.js +145 -0
  81. package/sample/crud/apps/main/services/jobs-demo-service.js +83 -0
  82. package/sample/crud/apps/main/services/logs-demo-service.js +59 -0
  83. package/sample/crud/apps/main/services/metrics-demo-service.js +144 -0
  84. package/sample/crud/apps/main/services/note-service.js +75 -0
  85. package/sample/crud/apps/main/services/perf-service.js +302 -0
  86. package/sample/crud/apps/main/services/redis-demo-service.js +75 -0
  87. package/sample/crud/apps/main/services/tracing-demo-service.js +69 -0
  88. package/sample/crud/apps/main/services/upload-demo-service.js +48 -0
  89. package/sample/crud/apps/main/services/user-service.js +65 -0
  90. package/sample/crud/apps/main/views/auth/login.ejs +57 -0
  91. package/sample/crud/apps/main/views/auth/register.ejs +71 -0
  92. package/sample/crud/apps/main/views/cron/index.ejs +92 -0
  93. package/sample/crud/apps/main/views/guide/index.ejs +24 -0
  94. package/sample/crud/apps/main/views/guide/page.ejs +64 -0
  95. package/sample/crud/apps/main/views/home.ejs +82 -0
  96. package/sample/crud/apps/main/views/jobs/index.ejs +113 -0
  97. package/sample/crud/apps/main/views/layouts/main.ejs +112 -0
  98. package/sample/crud/apps/main/views/logs/index.ejs +80 -0
  99. package/sample/crud/apps/main/views/metrics/index.ejs +123 -0
  100. package/sample/crud/apps/main/views/notes/edit.ejs +45 -0
  101. package/sample/crud/apps/main/views/notes/list.ejs +74 -0
  102. package/sample/crud/apps/main/views/notes/new.ejs +45 -0
  103. package/sample/crud/apps/main/views/perf/index.ejs +90 -0
  104. package/sample/crud/apps/main/views/redis/index.ejs +65 -0
  105. package/sample/crud/apps/main/views/tracing/index.ejs +106 -0
  106. package/sample/crud/apps/main/views/upload/index.ejs +79 -0
  107. package/sample/crud/apps/main/views/users/edit.ejs +48 -0
  108. package/sample/crud/apps/main/views/users/list.ejs +81 -0
  109. package/sample/crud/apps/main/views/users/new.ejs +48 -0
  110. package/sample/crud/apps/main/views/worker/index.ejs +70 -0
  111. package/sample/crud/apps/main/views/ws/index.ejs +62 -0
  112. package/sample/crud/apps/main/workers/hash-worker.js +17 -0
  113. package/sample/crud/apps/main/workers/hash.task.js +22 -0
  114. package/sample/crud/ecosystem.config.cjs +9 -0
  115. package/sample/crud/mega.config.js +105 -0
  116. package/sample/crud/package-lock.json +5665 -0
  117. package/sample/crud/package.json +28 -0
  118. package/sample/crud/test/apps/main/auth-flow.integration.test.js +177 -0
  119. package/sample/crud/test/apps/main/auth-service.test.js +93 -0
  120. package/sample/crud/test/apps/main/chat-bus.test.js +101 -0
  121. package/sample/crud/test/apps/main/chat-channel.test.js +144 -0
  122. package/sample/crud/test/apps/main/cron-demo-service.test.js +93 -0
  123. package/sample/crud/test/apps/main/demo-flow.integration.test.js +386 -0
  124. package/sample/crud/test/apps/main/email-job.test.js +76 -0
  125. package/sample/crud/test/apps/main/guide-service.test.js +68 -0
  126. package/sample/crud/test/apps/main/hash-task.test.js +30 -0
  127. package/sample/crud/test/apps/main/jobs-demo-service.test.js +88 -0
  128. package/sample/crud/test/apps/main/logs-demo-service.test.js +85 -0
  129. package/sample/crud/test/apps/main/metrics-demo-service.test.js +90 -0
  130. package/sample/crud/test/apps/main/note-service.test.js +68 -0
  131. package/sample/crud/test/apps/main/perf-service.test.js +121 -0
  132. package/sample/crud/test/apps/main/perf.integration.test.js +202 -0
  133. package/sample/crud/test/apps/main/redis-demo-service.test.js +98 -0
  134. package/sample/crud/test/apps/main/tracing-demo-service.test.js +90 -0
  135. package/sample/crud/test/apps/main/upload-demo-service.test.js +61 -0
  136. package/sample/crud/test/apps/main/user-service.test.js +65 -0
  137. package/sample/crud/test/apps/main/ws-chat.integration.test.js +232 -0
  138. package/sample/crud/vitest.config.js +8 -0
  139. package/sample/crud/yarn.lock +2142 -0
  140. package/sample/simple/.env.example +15 -0
  141. package/sample/simple/README.md +52 -0
  142. package/sample/simple/apps/main/app.config.js +35 -0
  143. package/sample/simple/apps/main/controllers/pages-controller.js +22 -0
  144. package/sample/simple/apps/main/locales/client/en.json +3 -0
  145. package/sample/simple/apps/main/locales/client/ko.json +3 -0
  146. package/sample/simple/apps/main/locales/server/en.json +23 -0
  147. package/sample/simple/apps/main/locales/server/ko.json +23 -0
  148. package/sample/simple/apps/main/public/css/app.css +101 -0
  149. package/sample/simple/apps/main/public/hello.txt +1 -0
  150. package/sample/simple/apps/main/public/js/app.js +54 -0
  151. package/sample/simple/apps/main/public/js/theme-init.js +12 -0
  152. package/sample/simple/apps/main/public/vendor/bootstrap/bootstrap.bundle.min.js +7 -0
  153. package/sample/simple/apps/main/public/vendor/bootstrap/bootstrap.min.css +6 -0
  154. package/sample/simple/apps/main/routes/index.js +9 -0
  155. package/sample/simple/apps/main/routes/pages.js +12 -0
  156. package/sample/simple/apps/main/views/index.ejs +56 -0
  157. package/sample/simple/apps/main/views/layouts/main.ejs +74 -0
  158. package/sample/simple/ecosystem.config.cjs +10 -0
  159. package/sample/simple/mega.config.js +27 -0
  160. package/sample/simple/package-lock.json +1851 -0
  161. package/sample/simple/package.json +25 -0
  162. package/sample/simple/test/apps/main/index.test.js +13 -0
  163. package/sample/simple/vitest.config.js +8 -0
  164. package/src/adapters/adapter-manager.js +305 -0
  165. package/src/adapters/adapter-options.js +208 -0
  166. package/src/adapters/file-adapter.js +350 -0
  167. package/src/adapters/file-session-adapter.js +363 -0
  168. package/src/adapters/index.js +38 -0
  169. package/src/adapters/maria-adapter.js +425 -0
  170. package/src/adapters/mega-adapter.js +511 -0
  171. package/src/adapters/mega-bus-adapter.js +81 -0
  172. package/src/adapters/mega-cache-adapter.js +94 -0
  173. package/src/adapters/mega-db-adapter.js +72 -0
  174. package/src/adapters/mega-lock-adapter.js +118 -0
  175. package/src/adapters/mega-log-sink-adapter.js +46 -0
  176. package/src/adapters/mega-session-adapter.js +72 -0
  177. package/src/adapters/mongo-adapter.js +396 -0
  178. package/src/adapters/nats-adapter.js +370 -0
  179. package/src/adapters/postgres-adapter.js +341 -0
  180. package/src/adapters/redis-adapter.js +331 -0
  181. package/src/adapters/redis-session-adapter.js +261 -0
  182. package/src/adapters/redlock-adapter.js +385 -0
  183. package/src/adapters/registry.js +157 -0
  184. package/src/adapters/sqlite-adapter.js +309 -0
  185. package/src/auth/index.js +103 -0
  186. package/src/cli/commands/console-cmd.js +56 -0
  187. package/src/cli/commands/new.js +101 -0
  188. package/src/cli/commands/routes.js +107 -0
  189. package/src/cli/commands/scaffold.js +120 -0
  190. package/src/cli/commands/test-cmd.js +45 -0
  191. package/src/cli/generators/index.js +368 -0
  192. package/src/cli/index.js +472 -0
  193. package/src/cli/template-engine.js +72 -0
  194. package/src/cli/ws-hub.js +582 -0
  195. package/src/core/ajv-mapper.js +80 -0
  196. package/src/core/boot.js +323 -0
  197. package/src/core/cluster-metrics.js +278 -0
  198. package/src/core/config-loader.js +115 -0
  199. package/src/core/config-validator.js +322 -0
  200. package/src/core/ctx-builder.js +253 -0
  201. package/src/core/envelope.js +88 -0
  202. package/src/core/error-mapper.js +116 -0
  203. package/src/core/formbody.js +69 -0
  204. package/src/core/hub-link.js +552 -0
  205. package/src/core/i18n.js +525 -0
  206. package/src/core/index.js +63 -0
  207. package/src/core/mega-app.js +1138 -0
  208. package/src/core/mega-cluster.js +232 -0
  209. package/src/core/mega-server.js +176 -0
  210. package/src/core/mega-service.js +41 -0
  211. package/src/core/migration-runner.js +196 -0
  212. package/src/core/multipart.js +282 -0
  213. package/src/core/openapi.js +114 -0
  214. package/src/core/router.js +388 -0
  215. package/src/core/routes-loader.js +57 -0
  216. package/src/core/scope-registry.js +53 -0
  217. package/src/core/security.js +275 -0
  218. package/src/core/services-loader.js +98 -0
  219. package/src/core/session-cleanup-schedule.js +57 -0
  220. package/src/core/session-store.js +55 -0
  221. package/src/core/session.js +414 -0
  222. package/src/core/static-assets.js +126 -0
  223. package/src/core/template.js +294 -0
  224. package/src/core/workers-manager.js +193 -0
  225. package/src/core/ws-compression.js +112 -0
  226. package/src/core/ws-controller.js +109 -0
  227. package/src/core/ws-message.js +176 -0
  228. package/src/core/ws-upgrade.js +445 -0
  229. package/src/errors/config-error.js +16 -0
  230. package/src/errors/http-errors.js +130 -0
  231. package/src/errors/index.js +19 -0
  232. package/src/errors/mega-error.js +34 -0
  233. package/src/eslint-plugin/index.js +15 -0
  234. package/src/eslint-plugin/no-direct-model-import.js +113 -0
  235. package/src/index.js +131 -0
  236. package/src/lib/asp/config.js +83 -0
  237. package/src/lib/asp/crypto.js +145 -0
  238. package/src/lib/asp/errors.js +49 -0
  239. package/src/lib/asp/nonce-cache.js +94 -0
  240. package/src/lib/asp/plugin.js +263 -0
  241. package/src/lib/asp/ws-terminator.js +101 -0
  242. package/src/lib/env-mapper.js +222 -0
  243. package/src/lib/hub-protocol.js +322 -0
  244. package/src/lib/index.js +42 -0
  245. package/src/lib/logger/telegram-core.js +150 -0
  246. package/src/lib/logger/telegram-transport.js +126 -0
  247. package/src/lib/mega-brute-force.js +225 -0
  248. package/src/lib/mega-circuit-breaker.js +412 -0
  249. package/src/lib/mega-cron.js +169 -0
  250. package/src/lib/mega-hash.js +179 -0
  251. package/src/lib/mega-health.js +91 -0
  252. package/src/lib/mega-job-queue.js +600 -0
  253. package/src/lib/mega-job-worker.js +295 -0
  254. package/src/lib/mega-job.js +140 -0
  255. package/src/lib/mega-logger.js +128 -0
  256. package/src/lib/mega-metrics.js +661 -0
  257. package/src/lib/mega-plugin.js +650 -0
  258. package/src/lib/mega-retry.js +95 -0
  259. package/src/lib/mega-schedule.js +507 -0
  260. package/src/lib/mega-shutdown.js +176 -0
  261. package/src/lib/mega-tracing.js +715 -0
  262. package/src/lib/mega-worker.js +653 -0
  263. package/src/lib/worker-runner/process-entry.js +30 -0
  264. package/src/lib/worker-runner/task-dispatch.js +72 -0
  265. package/src/lib/worker-runner/thread-entry.js +26 -0
  266. package/src/models/index.js +7 -0
  267. package/src/models/mega-model.js +151 -0
  268. package/src/test/index.js +288 -0
  269. package/templates/adapter/code.tpl +40 -0
  270. package/templates/adapter/test.tpl +13 -0
  271. package/templates/app/app.config.tpl +10 -0
  272. package/templates/app/route.tpl +10 -0
  273. package/templates/app/test.tpl +13 -0
  274. package/templates/channel/code.tpl +38 -0
  275. package/templates/channel/test.tpl +19 -0
  276. package/templates/controller/code.tpl +16 -0
  277. package/templates/controller/route.tpl +9 -0
  278. package/templates/controller/test.tpl +14 -0
  279. package/templates/job/code.tpl +23 -0
  280. package/templates/job/test.tpl +17 -0
  281. package/templates/locale/code.tpl +3 -0
  282. package/templates/locale/test.tpl +13 -0
  283. package/templates/middleware/code.tpl +13 -0
  284. package/templates/middleware/test.tpl +11 -0
  285. package/templates/migration/code.tpl +20 -0
  286. package/templates/migration/test.tpl +14 -0
  287. package/templates/model/code.tpl +21 -0
  288. package/templates/model/test.tpl +29 -0
  289. package/templates/project/app.config.tpl +8 -0
  290. package/templates/project/app.config.views.tpl +37 -0
  291. package/templates/project/ecosystem.config.tpl +10 -0
  292. package/templates/project/env.tpl +12 -0
  293. package/templates/project/gitignore.tpl +8 -0
  294. package/templates/project/locales/client/en.json.tpl +3 -0
  295. package/templates/project/locales/client/ko.json.tpl +3 -0
  296. package/templates/project/locales/server/en.json.tpl +17 -0
  297. package/templates/project/locales/server/ko.json.tpl +17 -0
  298. package/templates/project/mega.config.tpl +11 -0
  299. package/templates/project/package.tpl +25 -0
  300. package/templates/project/public/css/app.css +101 -0
  301. package/templates/project/public/js/app.js +54 -0
  302. package/templates/project/public/js/theme-init.js +12 -0
  303. package/templates/project/public/vendor/bootstrap/bootstrap.bundle.min.js +7 -0
  304. package/templates/project/public/vendor/bootstrap/bootstrap.min.css +6 -0
  305. package/templates/project/readme.tpl +48 -0
  306. package/templates/project/route.test.tpl +13 -0
  307. package/templates/project/route.test.views.tpl +15 -0
  308. package/templates/project/route.tpl +10 -0
  309. package/templates/project/route.views.tpl +10 -0
  310. package/templates/project/views/index.ejs.tpl +58 -0
  311. package/templates/project/views/layout.ejs.tpl +73 -0
  312. package/templates/project/vitest.config.tpl +8 -0
  313. package/templates/route/code.tpl +11 -0
  314. package/templates/route/test.tpl +26 -0
  315. package/templates/schedule/code.tpl +19 -0
  316. package/templates/schedule/test.tpl +17 -0
  317. package/templates/service/code.tpl +18 -0
  318. package/templates/service/test.tpl +17 -0
  319. package/templates/worker/code.tpl +14 -0
  320. package/templates/worker/task.tpl +13 -0
  321. package/templates/worker/test.tpl +18 -0
  322. package/vitest.config.js +33 -0
@@ -0,0 +1,263 @@
1
+ // @ts-check
2
+ /**
3
+ * MegaAspPlugin — ASP HTTP terminator (서버측, Fastify hook 3종).
4
+ *
5
+ * ADR-053/054/057/058/076/090/092. 라우트·경로 옵트인(`enabledPaths` glob)으로만 활성.
6
+ * 요청은 `X-Mega-Encrypted: true` 시그널 + `X-Timestamp` 로 식별 (WASM 클라이언트
7
+ * `transport_http.rs` 와 byte 호환).
8
+ *
9
+ * # 훅 (ADR-076 stage 분리)
10
+ * - onRequest: ASP 스코프 판별 + ts/drift 검증 + per-request 키 유도 (signal_required fail-closed)
11
+ * - preParsing: body ciphertext 복호화 → 평문 stream 반환 (JSON 파서가 평문을 보게)
12
+ * - preValidation: query `?q=` (URL-safe base64, ADR-092) 복호화 → req.query 치환
13
+ * - onSend: 응답 envelope JSON 암호화 + 시그널/timestamp 헤더 (자동 envelope 이후)
14
+ *
15
+ * # fail-closed (ADR-057)
16
+ * 복호화·drift·replay·signal 실패는 {@link MegaAspDecryptError}(403) 로 throw —
17
+ * 평문 통과 절대 없음. 에러 응답은 ok 마커 미설정이라 onSend 가 암호화하지 않음(평문).
18
+ *
19
+ * @module lib/asp/plugin
20
+ */
21
+ import { Readable } from 'node:stream'
22
+ import {
23
+ deriveKey,
24
+ getUaSlice,
25
+ encryptTransport,
26
+ decryptTransport,
27
+ extractNonce,
28
+ } from './crypto.js'
29
+ import { normalizeAspConfig } from './config.js'
30
+ import { MegaAspDecryptError, ASP_RULES } from './errors.js'
31
+
32
+ /** per-request ASP 상태 보관용 symbol (키/ts/마커). */
33
+ const ASP_STATE = Symbol('mega.asp.state')
34
+
35
+ /**
36
+ * per-request ASP 상태. `req[ASP_STATE]` 에 보관 (Fastify 타입에 없는 symbol 키).
37
+ * `multipart` 는 body 평문 통과 마커(02-architecture §1625).
38
+ * @typedef {{ intent: boolean, ok: boolean, ts: number, key: Buffer, multipart?: boolean }} AspState
39
+ */
40
+
41
+ /**
42
+ * Fastify 인스턴스에 ASP HTTP terminator 등록.
43
+ *
44
+ * @param {import('fastify').FastifyInstance} fastify
45
+ * @param {Object} aspConfigRaw - { masterSecret, enabledPaths, headerSignal?, timestampHeader?, driftMs?, failOpen?, nonceCache? }
46
+ * @param {{ logger?: { warn: Function, debug?: Function } }} [opts]
47
+ * @returns {import('./config.js').NormalizedAspConfig} 정규화된 config (테스트·디버그용)
48
+ */
49
+ export function registerAspPlugin(fastify, aspConfigRaw, opts = {}) {
50
+ const cfg = normalizeAspConfig(aspConfigRaw, opts)
51
+ const matchers = cfg.enabledPaths.map(globToRegex)
52
+ const signalKey = cfg.headerSignal.toLowerCase()
53
+ const tsKey = cfg.timestampHeader.toLowerCase()
54
+
55
+ /**
56
+ * ASP 활성 경로인가 (skipAsp 라우트 제외 + glob 매칭).
57
+ * @param {import('fastify').FastifyRequest} req
58
+ * @returns {boolean}
59
+ */
60
+ function isAspPath(req) {
61
+ // skipAsp 는 라우트 config 의 우리 전용 플래그 — Fastify config 타입에 없어 cast.
62
+ const routeCfg = /** @type {{ skipAsp?: boolean } | undefined} */ (req.routeOptions?.config)
63
+ if (routeCfg?.skipAsp) return false
64
+ const pathOnly = String(req.url).split('?')[0]
65
+ return matchers.some((re) => re.test(pathOnly))
66
+ }
67
+
68
+ // ── onRequest: 스코프 판별 + ts/drift + 키 유도 ──
69
+ fastify.addHook('onRequest', async (req) => {
70
+ if (!isAspPath(req)) return
71
+
72
+ const signaled = String(req.headers[signalKey] ?? '') === 'true'
73
+ if (!signaled) {
74
+ // ASP 활성 경로인데 암호화 시그널 없음 → fail-closed (ADR-057). 평문 거부.
75
+ throw new MegaAspDecryptError(
76
+ ASP_RULES.SIGNAL_REQUIRED,
77
+ `ASP-enabled path requires '${cfg.headerSignal}: true' (fail-closed, ADR-057).`,
78
+ { field: 'headers' },
79
+ )
80
+ }
81
+
82
+ const tsRaw = req.headers[tsKey]
83
+ const ts = Number(tsRaw)
84
+ if (tsRaw === undefined || !Number.isFinite(ts) || ts <= 0) {
85
+ throw new MegaAspDecryptError(
86
+ ASP_RULES.MISSING_TIMESTAMP,
87
+ `ASP request missing valid '${cfg.timestampHeader}' header.`,
88
+ { field: 'headers' },
89
+ )
90
+ }
91
+ if (Math.abs(Date.now() - ts) > cfg.driftMs) {
92
+ throw new MegaAspDecryptError(
93
+ ASP_RULES.DRIFT,
94
+ `ASP timestamp drift exceeds ${cfg.driftMs}ms.`,
95
+ { field: cfg.timestampHeader },
96
+ )
97
+ }
98
+
99
+ // 키 유도 — body/query/응답 모두 동일 키 (같은 domain/path/ua/ts). 클라와 byte 호환.
100
+ const domain = String(req.headers.host ?? '').split(':')[0]
101
+ const ua = String(req.headers['user-agent'] ?? '')
102
+ const pathOnly = String(req.url).split('?')[0]
103
+ const uaSlice = getUaSlice(ua, ts)
104
+ const key = deriveKey(cfg.masterSecret, domain, pathOnly, uaSlice, ts)
105
+
106
+ // ASP_STATE 는 Fastify 타입에 없는 우리 전용 symbol 키 — 부착 시 cast.
107
+ ;(/** @type {Record<symbol, AspState>} */ (/** @type {unknown} */ (req)))[ASP_STATE] = {
108
+ intent: true,
109
+ ok: false,
110
+ ts,
111
+ key,
112
+ }
113
+ })
114
+
115
+ // ── preParsing: body ciphertext → 평문 stream ──
116
+ fastify.addHook('preParsing', async (req, _reply, payload) => {
117
+ const state = /** @type {AspState | undefined} */ (
118
+ (/** @type {Record<symbol, AspState>} */ (/** @type {unknown} */ (req)))[ASP_STATE]
119
+ )
120
+ if (!state?.intent) return payload
121
+
122
+ // multipart body 평문 통과 (02-architecture §1625). 파일 업로드는 body 를 ciphertext 로 받지 않으므로
123
+ // 복호화를 건너뛰고 multipart 파서에 평문 그대로 넘긴다. **ASP 인증(onRequest 의 signal/ts/drift)·응답
124
+ // 암호화(onSend)는 그대로 유지**되므로 Content-Type 위조로 ASP 자체를 우회할 수는 없다 — 위조 multipart 도
125
+ // 유효 ts·signal 이 필요하고 응답은 여전히 암호화돼 키 없이는 못 읽는다.
126
+ const contentType = String(req.headers['content-type'] ?? '')
127
+ if (contentType.includes('multipart/form-data')) {
128
+ state.multipart = true // 디버그/테스트 마커. 응답 암호화는 preValidation 의 state.ok=true 가 활성화.
129
+ return payload
130
+ }
131
+
132
+ const raw = await readStream(payload)
133
+ if (raw.length === 0) {
134
+ // body 없음 (GET 등). query/응답 ASP 는 계속. 빈 stream 그대로 반환.
135
+ return makeStream(raw)
136
+ }
137
+
138
+ const encB64 = raw.toString('utf8')
139
+ const plain = decryptOrThrow(state.key, encB64, 'body')
140
+ await checkReplay(cfg, encB64, 'body')
141
+
142
+ return makeStream(Buffer.from(plain, 'utf8'))
143
+ })
144
+
145
+ // ── preValidation: query ?q= 복호화 + 최종 ok 마커 ──
146
+ fastify.addHook('preValidation', async (req) => {
147
+ const state = /** @type {AspState | undefined} */ (
148
+ (/** @type {Record<symbol, AspState>} */ (/** @type {unknown} */ (req)))[ASP_STATE]
149
+ )
150
+ if (!state?.intent) return
151
+
152
+ const q = (/** @type {Record<string, any> | undefined} */ (req.query))?.q
153
+ if (typeof q === 'string' && q.length > 0) {
154
+ // URL-safe base64 → STANDARD (ADR-092). `-`→`+`, `_`→`/`. 패딩은 decrypt 가 자동 보정.
155
+ const stdB64 = q.replace(/-/g, '+').replace(/_/g, '/')
156
+ const plainQuery = decryptOrThrow(state.key, stdB64, 'query')
157
+ await checkReplay(cfg, stdB64, 'query')
158
+ // 복호화된 원본 query string → req.query 치환 (q 제거).
159
+ req.query = Object.fromEntries(new URLSearchParams(plainQuery))
160
+ }
161
+
162
+ // 여기 도달 = body/query 복호화 모두 통과 → 응답 암호화 허용.
163
+ state.ok = true
164
+ })
165
+
166
+ // ── onSend: 응답 envelope JSON 암호화 (ADR-076) ──
167
+ fastify.addHook('onSend', async (req, reply, payload) => {
168
+ const state = /** @type {AspState | undefined} */ (
169
+ (/** @type {Record<symbol, AspState>} */ (/** @type {unknown} */ (req)))[ASP_STATE]
170
+ )
171
+ if (!state?.ok) return payload // ASP 미통과(에러 등) → 평문 (클라가 키 못 만드는 상황 대비).
172
+
173
+ const plain = Buffer.isBuffer(payload) ? payload : String(payload ?? '')
174
+ const enc = encryptTransport(state.key, plain)
175
+ reply.header(cfg.headerSignal, 'true')
176
+ reply.header(cfg.timestampHeader, String(state.ts)) // 요청 ts 재사용 (클라가 동일 키로 복호)
177
+ reply.header('content-type', 'text/plain; charset=utf-8') // body 는 base64 ciphertext
178
+ return enc
179
+ })
180
+
181
+ return cfg
182
+ }
183
+
184
+ /**
185
+ * 복호화 시도 → 실패 시 MegaAspDecryptError. rule 분류:
186
+ * blob 길이/포맷 깨짐 → invalid_payload, auth tag 불일치(잘못된 키) → key_mismatch.
187
+ * @param {Buffer} key
188
+ * @param {string} encB64
189
+ * @param {string} field
190
+ * @returns {string} 평문
191
+ */
192
+ function decryptOrThrow(key, encB64, field) {
193
+ try {
194
+ return decryptTransport(key, encB64).toString('utf8')
195
+ } catch (err) {
196
+ const msg = err instanceof Error ? err.message : String(err)
197
+ const rule = /too short|base64|invalid/i.test(msg)
198
+ ? ASP_RULES.INVALID_PAYLOAD
199
+ : ASP_RULES.KEY_MISMATCH
200
+ throw new MegaAspDecryptError(rule, `ASP ${field} decrypt failed.`, { field, cause: err })
201
+ }
202
+ }
203
+
204
+ /**
205
+ * nonce 기반 replay 검사 (ADR-058). nonceCache 미설정이면 no-op.
206
+ * **복호화 성공 후** 호출 — 위조 ciphertext 가 nonce 를 소모하지 않게.
207
+ * @param {import('./config.js').NormalizedAspConfig} cfg
208
+ * @param {string} encB64
209
+ * @param {string} field
210
+ */
211
+ async function checkReplay(cfg, encB64, field) {
212
+ if (!cfg.nonceCache) return
213
+ let nonceHex
214
+ try {
215
+ nonceHex = extractNonce(encB64).toString('hex')
216
+ } catch (err) {
217
+ throw new MegaAspDecryptError(ASP_RULES.INVALID_PAYLOAD, `ASP ${field} malformed (nonce).`, {
218
+ field,
219
+ cause: err,
220
+ })
221
+ }
222
+ const fresh = await cfg.nonceCache.isFresh(nonceHex)
223
+ if (!fresh) {
224
+ throw new MegaAspDecryptError(ASP_RULES.REPLAY, `ASP ${field} replay detected.`, { field })
225
+ }
226
+ }
227
+
228
+ /**
229
+ * Readable stream 전체를 Buffer 로 수집.
230
+ * @param {import('node:stream').Readable} stream
231
+ * @returns {Promise<Buffer>}
232
+ */
233
+ async function readStream(stream) {
234
+ const chunks = []
235
+ for await (const chunk of stream) {
236
+ chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk)
237
+ }
238
+ return Buffer.concat(chunks)
239
+ }
240
+
241
+ /**
242
+ * Buffer → Readable. Fastify preParsing 계약: content-length 매칭용
243
+ * `receivedEncodedLength` 부착 (docs/Hooks preParsing 노트).
244
+ * @param {Buffer} buf
245
+ * @returns {import('node:stream').Readable & { receivedEncodedLength: number }}
246
+ */
247
+ function makeStream(buf) {
248
+ const stream = Readable.from(buf)
249
+ // @ts-expect-error — Fastify 가 읽는 비표준 프로퍼티.
250
+ stream.receivedEncodedLength = buf.length
251
+ // @ts-expect-error
252
+ return stream
253
+ }
254
+
255
+ /**
256
+ * glob('/api/*') → RegExp. `*` 는 임의 문자열. 그 외 정규식 특수문자는 이스케이프.
257
+ * @param {string} glob
258
+ * @returns {RegExp}
259
+ */
260
+ function globToRegex(glob) {
261
+ const escaped = glob.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*')
262
+ return new RegExp(`^${escaped}$`)
263
+ }
@@ -0,0 +1,101 @@
1
+ // @ts-check
2
+ /**
3
+ * MegaAspTerminator — WS bridge-side ASP terminator (ADR-053/083/084).
4
+ *
5
+ * WS 프레임의 평문/암호 명시 신호(`P:` / `E:` prefix, ADR-083) 를 인코드·디코드한다.
6
+ * WASM 클라이언트(`MegaSocket`, `transport_ws.rs`) 와 **byte 호환** — 같은 알고리즘
7
+ * (`crypto.js` 의 `wsEncrypt`/`wsDecrypt`) 을 재사용하므로 wire 가 byte-for-byte 일치.
8
+ *
9
+ * # 프레임 규약 (ADR-083)
10
+ * - 송신: `encrypt && !plain` → `E:<ts>:<base64>`. 그 외 → `P:<json>`.
11
+ * - 수신: prefix 가 권위. `E:` 는 항상 복호화 시도, `P:` 는 평문, prefix 없으면
12
+ * `invalid_payload`. decrypt 실패는 silent fallback 없이 throw (ADR-084).
13
+ *
14
+ * @module lib/asp/ws-terminator
15
+ */
16
+ import { wsEncrypt, wsDecrypt } from './crypto.js'
17
+ import { MegaAspDecryptError, ASP_RULES } from './errors.js'
18
+
19
+ const PREFIX_ENCRYPTED = 'E:'
20
+ const PREFIX_PLAIN = 'P:'
21
+
22
+ export class MegaAspTerminator {
23
+ /**
24
+ * @param {Object} opts
25
+ * @param {string} opts.masterSecret
26
+ * @param {string} opts.domain - Host (키 유도, 클라 location.hostname 과 일치)
27
+ * @param {string} opts.wsPath - WS 경로 (키 유도, 클라 URL path 와 일치)
28
+ * @param {string} opts.ua - User-Agent (키 유도)
29
+ * @param {boolean} [opts.encrypt=true] - 송신 기본 암호화 (ADR-086)
30
+ */
31
+ constructor({ masterSecret, domain, wsPath, ua, encrypt = true }) {
32
+ if (typeof masterSecret !== 'string' || masterSecret.length === 0) {
33
+ throw new Error('MegaAspTerminator: masterSecret is required (ADR-056).')
34
+ }
35
+ this._secret = masterSecret
36
+ this._domain = domain ?? ''
37
+ this._wsPath = wsPath ?? '/'
38
+ this._ua = ua ?? ''
39
+ this._encrypt = encrypt !== false
40
+ }
41
+
42
+ /** 이 terminator 가 기본 암호화 송신인지. */
43
+ get isEncrypted() {
44
+ return this._encrypt
45
+ }
46
+
47
+ /**
48
+ * 송신 프레임 생성. `plain=true` 또는 `encrypt=false` 면 `P:` 평문, 아니면 `E:` 암호화.
49
+ *
50
+ * @param {Object|string} message - 객체면 JSON.stringify, 문자열이면 그대로 (이미 JSON 가정).
51
+ * @param {{ plain?: boolean }} [opts]
52
+ * @returns {string} wire 프레임 (`E:` 또는 `P:` prefix 포함)
53
+ */
54
+ encodeFrame(message, { plain = false } = {}) {
55
+ const json = typeof message === 'string' ? message : JSON.stringify(message)
56
+ if (!this._encrypt || plain) return `${PREFIX_PLAIN}${json}`
57
+ const body = wsEncrypt(this._secret, this._domain, this._wsPath, this._ua, json)
58
+ return `${PREFIX_ENCRYPTED}${body}`
59
+ }
60
+
61
+ /**
62
+ * 수신 프레임 디코드. prefix 권위 (ADR-083). `E:` 복호화 실패 → throw (ADR-084).
63
+ *
64
+ * @param {string} frame - wire 프레임.
65
+ * @returns {{ encrypted: boolean, plain: string }} plain = 평문 JSON 문자열.
66
+ * @throws {MegaAspDecryptError} prefix 누락 / 복호화 실패.
67
+ */
68
+ decodeFrame(frame) {
69
+ if (typeof frame !== 'string') {
70
+ throw new MegaAspDecryptError(ASP_RULES.INVALID_PAYLOAD, 'WS frame must be a string.', {
71
+ field: 'frame',
72
+ })
73
+ }
74
+ if (frame.startsWith(PREFIX_PLAIN)) {
75
+ return { encrypted: false, plain: frame.slice(PREFIX_PLAIN.length) }
76
+ }
77
+ if (frame.startsWith(PREFIX_ENCRYPTED)) {
78
+ const body = frame.slice(PREFIX_ENCRYPTED.length)
79
+ try {
80
+ const plain = wsDecrypt(this._secret, this._domain, this._wsPath, this._ua, body)
81
+ return { encrypted: true, plain }
82
+ } catch (err) {
83
+ // ADR-084: 원문 fallback 금지 — 명시 에러. ts 파싱 실패 vs 복호화 실패 분류.
84
+ const msg = err instanceof Error ? err.message : String(err)
85
+ const rule = /missing ':'|invalid timestamp/.test(msg)
86
+ ? ASP_RULES.INVALID_PAYLOAD
87
+ : ASP_RULES.KEY_MISMATCH
88
+ throw new MegaAspDecryptError(rule, `WS frame decrypt failed: ${msg}`, {
89
+ field: 'frame',
90
+ cause: err,
91
+ })
92
+ }
93
+ }
94
+ // prefix 없음 → fuzionx 원본 포맷 wire 호환 X (ADR-083).
95
+ throw new MegaAspDecryptError(
96
+ ASP_RULES.INVALID_PAYLOAD,
97
+ 'WS frame missing E:/P: prefix (ADR-083).',
98
+ { field: 'frame' },
99
+ )
100
+ }
101
+ }
@@ -0,0 +1,222 @@
1
+ // @ts-check
2
+ /**
3
+ * `.env` → 어댑터 옵션 자동 매핑 (ADR-109 / 12-factor).
4
+ *
5
+ * 모든 어댑터 옵션이 `.env` 로 외부 주입 가능해야 한다는 결정(12-factor)에 따라, `MEGA_<SERVICE>_<KEY>`
6
+ * 환경변수를 표준 어댑터 옵션 객체(`adapter-options.js` 의 통합 시그니처)로 변환한다.
7
+ *
8
+ * # 매핑 규칙 (prefix `MEGA_<SERVICE>_` 제거 후 남은 suffix 기준)
9
+ * | suffix | 결과 | 예 |
10
+ * |---------------------|----------------------------|-----------------------------------------------|
11
+ * | URL | `{ url }` | MEGA_PG_URL=postgres://… → `{ url: '…' }` |
12
+ * | HOST/PORT/USER/PASSWORD | `{ host/port/user/password }` | MEGA_PG_HOST=localhost → `{ host:'localhost' }` |
13
+ * | DATABASE \| DB | `{ database }` | MEGA_PG_DB=app → `{ database: 'app' }` |
14
+ * | DBNAME | `{ dbName }` (Mongo) | MEGA_MONGO_DBNAME=app → `{ dbName: 'app' }` |
15
+ * | POOL_<X> | `{ pool: { camelCase(X) } }` | MEGA_PG_POOL_MAX=10 → `{ pool:{ max:10 } }` |
16
+ * | OPTIONS_<X> | `{ options: { driverKey(X) } }` | **driver 별 변환** — 아래 "driver-aware OPTIONS_*" 참조 |
17
+ *
18
+ * # driver-aware OPTIONS_* 변환 (ADR-109 보강)
19
+ * `.env` 는 관례상 `UPPER_SNAKE_CASE` 로 쓰지만, 드라이버가 인식하는 옵션 키 표기는 제각각이다:
20
+ * ioredis/nats/mongodb/mariadb 는 **camelCase**(`keyPrefix`, `maxReconnectAttempts`,
21
+ * `serverSelectionTimeoutMS`), pg/sqlite 는 **snake_case**(`statement_timeout`). 그래서 OPTIONS_*
22
+ * 의 suffix 를 **driver 별 표기로 변환**한다(`{@link DRIVER_KEY_STYLE}`). driver 를 모르면(미지정/미상)
23
+ * snake 로 두고, 미상 driver 면 1회 warn 한다(fail-safe — 추측 변환 X).
24
+ * - camel: `KEY_PREFIX`→`keyPrefix`, `MAX_RECONNECT_ATTEMPTS`→`maxReconnectAttempts`,
25
+ * `SERVER_SELECTION_TIMEOUT_MS`→`serverSelectionTimeoutMS`(`MS` 접미사 대문자 보존).
26
+ * - snake: `STATEMENT_TIMEOUT`→`statement_timeout`(그대로 lowercase).
27
+ * POOL_* 는 driver 무관 **공통 풀 인터페이스**(이미 camelCase 표준)라 변환 정책에서 제외 — 항상 camelCase.
28
+ *
29
+ * # Redis `db` 특례
30
+ * Redis 의 `DB`/`DATABASE` 는 connection(host/url)이 아니라 **논리 DB 번호(정수 별개 축)**다.
31
+ * driver=='redis' 면 `MEGA_REDIS_DB=1` → `{ db: 1 }`(정수, `database` 가 아님 — redis-adapter 가
32
+ * `database` 는 무시하므로 db 축 설정이 불가능했던 버그를 교정). 범위(0~15) 검증은 어댑터 생성자 책임.
33
+ *
34
+ * # 값 타입 자동 변환 (POOL_*·OPTIONS_* 만)
35
+ * `'true'/'false'/'null'` → boolean/null, 정수/소수 → Number, `{`·`[` 로 시작하면 JSON.parse.
36
+ * 단 **2^53(MAX_SAFE_INTEGER) 초과 정수는 문자열로 유지**(IEEE-754 정밀도 손실 방지, L-3 — warn 1회).
37
+ * **연결 필드(url/host/user/password/database/dbName)는 항상 문자열** — 비밀번호가 `12345` 같이 숫자
38
+ * 처럼 보여도 문자열로 보존(숫자 강제 시 자격증명 깨짐). port·redis db 만 정수.
39
+ *
40
+ * # 알 수 없는 키 처리
41
+ * `MEGA_<SERVICE>_` namespace 는 docker-compose 인프라 제어 변수(예: `MEGA_MARIA_ROOT_PASSWORD`,
42
+ * ADR-103)와 **공유**되므로, 위 grammar 에 안 맞는 suffix 는 **어댑터 옵션이 아닌 것으로 간주해 무시**한다
43
+ * (throw 하면 인프라 변수에서 깨짐). 단, POOL_*·OPTIONS_* 하위 키의 오타는 무시되지 않고 어댑터의
44
+ * `normalizePool`/드라이버가 fail-fast 로 잡는다(예: MEGA_PG_POOL_MAXX → pool.maxx → `adapter.invalid_option`).
45
+ * 즉 "조용한 실패" 가 위험한 풀/옵션 키는 downstream 에서 검출되고, 무시되는 건 인프라 전용 변수뿐이다.
46
+ *
47
+ * @module lib/env-mapper
48
+ */
49
+ import { MegaValidationError } from '../errors/http-errors.js'
50
+
51
+ /** 연결 필드 suffix → 표준 키 (값은 항상 문자열, port 제외). */
52
+ const CONNECTION_KEYS = /** @type {const} */ ({
53
+ URL: 'url',
54
+ HOST: 'host',
55
+ PORT: 'port',
56
+ USER: 'user',
57
+ PASSWORD: 'password',
58
+ DATABASE: 'database',
59
+ DB: 'database',
60
+ DBNAME: 'dbName',
61
+ })
62
+
63
+ /**
64
+ * driver → OPTIONS_* 키 표기 스타일. 드라이버가 실제로 인식하는 옵션 키 표기에
65
+ * 맞춘다 — camelCase 드라이버에 snake 키를 넘기면 **조용히 무시**되던 버그를 교정한다.
66
+ * - 'camel': ioredis/nats 공식 옵션은 camelCase(`keyPrefix`, `maxReconnectAttempts`). mongodb 드라이버
67
+ * 옵션도 camelCase(+`MS` 접미사), mariadb `createPool` 옵션도 camelCase(`multipleStatements` 등).
68
+ * - 'snake': pg(`statement_timeout`)·better-sqlite3 는 snake_case 가 자연스럽다.
69
+ * @type {Record<string, 'camel' | 'snake'>}
70
+ */
71
+ const DRIVER_KEY_STYLE = {
72
+ postgres: 'snake',
73
+ mariadb: 'camel',
74
+ mongodb: 'camel',
75
+ sqlite: 'snake',
76
+ redis: 'camel',
77
+ nats: 'camel',
78
+ redlock: 'camel', // redlock Settings(driftFactor/retryCount/retryDelay/retryJitter/automaticExtensionThreshold)는 camelCase.
79
+ file: 'snake', // File 자체 옵션(serializer/extension)은 단일 단어라 snake/camel 무관.
80
+ }
81
+
82
+ /**
83
+ * camelCase 변환 시 **그대로 대문자로 보존**할 토큰(드라이버 관례). mongodb 의 `*MS` 접미사
84
+ * (`serverSelectionTimeoutMS`, `connectTimeoutMS`, `maxIdleTimeMS` …)가 대표 — 일반 camelCase 로는
85
+ * `Ms` 가 되어 드라이버가 인식 못 한다. 추측이 아니라 mongodb 드라이버 옵션 표기를 직접 따른다.
86
+ */
87
+ const PRESERVED_UPPER = new Set(['MS'])
88
+
89
+ /**
90
+ * `MEGA_<SERVICE>_*` 환경변수를 표준 어댑터 옵션 객체로 변환한다. OPTIONS_* 의 키 표기는 `driver`
91
+ * 별로 변환된다(camel/snake, {@link DRIVER_KEY_STYLE}).
92
+ *
93
+ * @param {string} service - 서비스 prefix(대소문자 무관, 예: 'pg' / 'PG' / 'maria' / 'mongo').
94
+ * @param {Record<string, string | undefined>} [env] - 환경변수 맵(기본 `process.env`).
95
+ * @param {{ driver?: string }} [opts] - `driver`(예 'redis'/'postgres') — OPTIONS_* 키 표기 변환 + redis db 특례에 사용.
96
+ * 미지정이면 snake(레거시 디폴트), 미상 driver 면 snake + warn 1회(fail-safe).
97
+ * @returns {Record<string, any>} 어댑터 옵션 partial (`{ url?, host?, …, db?, pool?, options? }`). 매칭 없으면 빈 객체.
98
+ * @throws {MegaValidationError} `adapter.env_invalid_value` - PORT/redis DB 가 정수가 아님.
99
+ */
100
+ export function buildAdapterEnvConfig(service, env = process.env, { driver } = {}) {
101
+ if (typeof service !== 'string' || service.length === 0) {
102
+ throw new MegaValidationError('adapter.env_invalid_prefix', 'buildAdapterEnvConfig(service): service prefix must be a non-empty string.', {
103
+ details: { service },
104
+ })
105
+ }
106
+ const prefix = `MEGA_${service.toUpperCase()}_`
107
+ // OPTIONS_* 키 표기 스타일 결정: driver 미지정 → snake(레거시). driver 지정+미상 → snake + lazy warn.
108
+ const normalizedDriver = typeof driver === 'string' ? driver.toLowerCase() : undefined
109
+ const mappedStyle = normalizedDriver !== undefined ? DRIVER_KEY_STYLE[normalizedDriver] : undefined
110
+ const optionStyle = mappedStyle ?? 'snake'
111
+ let didWarnUnknownDriver = false
112
+ /** @type {Record<string, any>} */
113
+ const config = {}
114
+
115
+ for (const [envKey, rawValue] of Object.entries(env)) {
116
+ if (rawValue === undefined) continue
117
+ if (!envKey.startsWith(prefix)) continue
118
+ const suffix = envKey.slice(prefix.length)
119
+
120
+ if (suffix.startsWith('POOL_')) {
121
+ const rest = suffix.slice('POOL_'.length)
122
+ if (rest.length === 0) continue
123
+ // POOL_* 은 driver 무관 공통 풀 인터페이스(camelCase 표준) — driver-aware 변환에서 제외.
124
+ ;(config.pool ??= {})[toCamelCase(rest)] = coerceValue(rawValue)
125
+ } else if (suffix.startsWith('OPTIONS_')) {
126
+ const rest = suffix.slice('OPTIONS_'.length)
127
+ if (rest.length === 0) continue
128
+ // driver 가 지정됐는데 표를 모르면(미상) 1회 warn 후 snake 폴백 — 추측 변환 X.
129
+ if (normalizedDriver !== undefined && mappedStyle === undefined && !didWarnUnknownDriver) {
130
+ console.warn(
131
+ `[env-mapper] unknown driver "${driver}" — OPTIONS_* keys kept as snake_case (no driver-aware conversion). Known: ${Object.keys(DRIVER_KEY_STYLE).join(', ')}.`,
132
+ )
133
+ didWarnUnknownDriver = true
134
+ }
135
+ ;(config.options ??= {})[toOptionKey(rest, optionStyle)] = coerceValue(rawValue)
136
+ } else if (normalizedDriver === 'redis' && (suffix === 'DB' || suffix === 'DATABASE')) {
137
+ // Redis 특례 — DB/DATABASE 는 connection 이 아니라 논리 DB 번호(정수 별개 축, M-1).
138
+ config.db = coerceInt(rawValue, envKey)
139
+ } else if (Object.prototype.hasOwnProperty.call(CONNECTION_KEYS, suffix)) {
140
+ const key = CONNECTION_KEYS[/** @type {keyof typeof CONNECTION_KEYS} */ (suffix)]
141
+ config[key] = key === 'port' ? coerceInt(rawValue, envKey) : rawValue
142
+ }
143
+ // else: grammar 불일치 — docker-infra 전용 변수로 보고 무시(위 docstring 참조).
144
+ }
145
+ return config
146
+ }
147
+
148
+ /**
149
+ * `IDLE_TIMEOUT_MS` → `idleTimeoutMs` (UPPER_SNAKE → camelCase). POOL_* 공통 인터페이스 전용.
150
+ * @param {string} upperSnake @returns {string}
151
+ */
152
+ function toCamelCase(upperSnake) {
153
+ return upperSnake
154
+ .toLowerCase()
155
+ .split('_')
156
+ .map((w, i) => (i === 0 ? w : w.charAt(0).toUpperCase() + w.slice(1)))
157
+ .join('')
158
+ }
159
+
160
+ /**
161
+ * OPTIONS_* suffix 를 driver 표기로 변환.
162
+ * - 'snake': 그대로 lowercase (`STATEMENT_TIMEOUT` → `statement_timeout`).
163
+ * - 'camel': UPPER_SNAKE → camelCase, 단 `MS` 등 {@link PRESERVED_UPPER} 토큰은 대문자 보존
164
+ * (`SERVER_SELECTION_TIMEOUT_MS` → `serverSelectionTimeoutMS`).
165
+ * @param {string} upperSnake @param {'camel' | 'snake'} style @returns {string}
166
+ */
167
+ function toOptionKey(upperSnake, style) {
168
+ if (style === 'snake') return upperSnake.toLowerCase()
169
+ return upperSnake
170
+ .split('_')
171
+ .map((w, i) => {
172
+ if (PRESERVED_UPPER.has(w)) return w // 예: MS → MS (대문자 보존).
173
+ const lower = w.toLowerCase()
174
+ return i === 0 ? lower : lower.charAt(0).toUpperCase() + lower.slice(1)
175
+ })
176
+ .join('')
177
+ }
178
+
179
+ /**
180
+ * POOL_*·OPTIONS_* 값 타입 자동 변환. 변환 불가하면 원본 문자열.
181
+ * @param {string} v @returns {string | number | boolean | null | object}
182
+ */
183
+ function coerceValue(v) {
184
+ if (v === 'true') return true
185
+ if (v === 'false') return false
186
+ if (v === 'null') return null
187
+ if (/^-?\d+$/.test(v)) {
188
+ const n = Number(v)
189
+ // 2^53(MAX_SAFE_INTEGER) 초과 정수는 IEEE-754 double 로 변환 시 정밀도 손실(예 9007199254740993→…992).
190
+ // BigInt 변환은 JSON 직렬화 비호환이라 채택하지 않고, 원본 문자열을 그대로 보존한다(L-3).
191
+ // 큰 ID/카운터를 옵션으로 넘기는 경우 문자열이 안전 — downstream 드라이버가 필요 시 해석한다.
192
+ if (!Number.isSafeInteger(n)) {
193
+ console.warn(`[env-mapper] integer value exceeds Number.MAX_SAFE_INTEGER, keeping as string to avoid precision loss: "${v}"`)
194
+ return v
195
+ }
196
+ return n
197
+ }
198
+ if (/^-?\d*\.\d+$/.test(v)) return Number(v)
199
+ const t = v.trim()
200
+ if (t.startsWith('{') || t.startsWith('[')) {
201
+ try {
202
+ return JSON.parse(t)
203
+ } catch {
204
+ // JSON 처럼 보였으나 파싱 실패 — 원본 문자열 보존(추측 변환 X). 의도적 폴백.
205
+ return v
206
+ }
207
+ }
208
+ return v
209
+ }
210
+
211
+ /**
212
+ * 정수 강제 변환(PORT 전용). 정수가 아니면 throw(silent 0 변환 금지).
213
+ * @param {string} v @param {string} envKey @returns {number}
214
+ */
215
+ function coerceInt(v, envKey) {
216
+ if (!/^-?\d+$/.test(v)) {
217
+ throw new MegaValidationError('adapter.env_invalid_value', `${envKey} must be an integer, got "${v}".`, {
218
+ details: { envKey, value: v },
219
+ })
220
+ }
221
+ return Number(v)
222
+ }