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,282 @@
1
+ // @ts-check
2
+ /**
3
+ * MegaApp 파일 업로드 자동 등록 — `@fastify/multipart` 통합.
4
+ *
5
+ * # 무엇인가 (중학생용 설명)
6
+ * 웹에서 사진·문서를 올리면 브라우저는 `multipart/form-data` 라는 형식으로 파일을 쪼개 보낸다.
7
+ * 본 모듈은 검증된 공개 플러그인 `@fastify/multipart` 를 앱 config 의 `upload` 키 기반으로 자동 등록하고,
8
+ * 그 위에 **보안(허용 MIME·크기·개수·경로 탐색)** 과 **관측성(트레이싱 span·메트릭)** 을 얹는다.
9
+ *
10
+ * # 동작 한눈에 (security.js / session.js 형제 패턴)
11
+ * 1. `registerMultipart(fastify, { upload })` — `upload` 가 있을 때만 옵트인 등록(없으면 multipart 미지원=415).
12
+ * 2. `@fastify/multipart` 를 `limits`(fileSize/files/parts) + `throwFileSizeLimit:true` 로 등록 →
13
+ * `req.file()/req.files()/parts()/saveRequestFiles()` 데코레이터 제공.
14
+ * 3. multipart 요청에 한해 `req.file()/req.files()` 를 **MIME 화이트리스트 게이트**로 감싼다(비허용 → 415).
15
+ * 4. `req.saveUploads(destDir)` — 파일명 살균(path traversal 차단) + 스트리밍 저장 + `mega.upload` span +
16
+ * `mega_upload_*` 메트릭.
17
+ *
18
+ * # 보안 (12-performance-security / 02-architecture §1502·1625)
19
+ * - **크기 제한**: `upload.maxFileSize`(디폴트 10 MB) → `limits.fileSize` + `throwFileSizeLimit` → 초과 시
20
+ * 플러그인이 413 throw(절단 후 silent 통과 없이 명시 실패).
21
+ * - **개수 제한**: `upload.maxFiles` → `limits.files` → 초과 시 413.
22
+ * - **MIME 화이트리스트**: `upload.allowedMimeTypes`(빈 배열=전부 허용). 정확 매치 + `type/*` 와일드카드.
23
+ * 플러그인 내장 기능이 아니라 본 모듈이 게이트로 강제 → 비허용 415(`MegaUnsupportedMediaTypeError`).
24
+ * - **경로 탐색(path traversal) 차단**: 저장 시 `sanitizeFilename`(basename + 선행 점 제거)으로 파일명에서
25
+ * 디렉터리 성분을 제거하고, 최종 경로가 대상 디렉터리 내부인지 한 번 더 검증(이중 방어).
26
+ * - **MIME 스푸핑 한계**: 게이트는 클라가 선언한 `Content-Type`(part header) 기준이다. 매직바이트 스니핑은
27
+ * 하지 않는다(스트림 1패스 비용 회피). 선언 MIME 위조 가능성은 문서화된 한계이며, 진짜 콘텐츠 검증이
28
+ * 필요하면 핸들러에서 `toBuffer()` 후 별도 검사한다.
29
+ *
30
+ * # 트레이싱·메트릭
31
+ * - 트레이싱: `req.saveUploads` 가 `MegaTracing.span('mega.upload', …)` 로 감싸 `mega.upload.{files,bytes,dir}`
32
+ * 속성을 박는다. MIME 거부는 활성 span 에 `mega.upload.reason='rejected_mime'` 기록.
33
+ * - 메트릭: `recordUpload({ result })` — accepted(MIME 통과)/rejected_mime/saved + 크기 히스토그램.
34
+ *
35
+ * # CSRF·ASP 통합
36
+ * - **CSRF**: `security.js` 가 `multipart/form-data` 를 **폼**으로 분류해 CSRF 토큰을 검증한다(per ADR-051) —
37
+ * 별도 배선 없이 자동 적용. 업로드 폼은 `_csrf` 토큰을 함께 보내야 한다.
38
+ * - **ASP**: ASP 활성 경로의 multipart body 는 복호화하지 않고 평문으로 파서에 넘긴다(02-architecture §1625,
39
+ * `asp/plugin.js` preParsing). 인증(ts/drift/signal)·응답 암호화는 유지 → Content-Type 위조로 ASP 자체를
40
+ * 우회할 수 없다.
41
+ *
42
+ * @module core/multipart
43
+ * @see ADR-133
44
+ * @see https://github.com/fastify/fastify-multipart (@fastify/multipart v10)
45
+ */
46
+ import { mkdir, unlink } from 'node:fs/promises'
47
+ import { createWriteStream } from 'node:fs'
48
+ import { stat } from 'node:fs/promises'
49
+ import { pipeline } from 'node:stream/promises'
50
+ import { basename, resolve, sep } from 'node:path'
51
+ import fastifyMultipart from '@fastify/multipart'
52
+ import { MegaUnsupportedMediaTypeError, MegaPayloadTooLargeError } from '../errors/http-errors.js'
53
+ import * as MegaTracing from '../lib/mega-tracing.js'
54
+ import * as MegaMetrics from '../lib/mega-metrics.js'
55
+
56
+ /** 업로드 파일 크기 디폴트 상한 (10 MB — 02-architecture §1502). */
57
+ export const DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024
58
+
59
+ /**
60
+ * 업로드 자동 등록 결과 요약(디버그·테스트용).
61
+ * @typedef {Object} MultipartSummary
62
+ * @property {boolean} enabled - 등록 여부.
63
+ * @property {number} maxFileSize - 적용된 파일 크기 상한(bytes).
64
+ * @property {number|null} maxFiles - 파일 개수 상한(null=무제한).
65
+ * @property {string[]} allowedMimeTypes - 허용 MIME 목록(빈 배열=전부 허용).
66
+ */
67
+
68
+ /**
69
+ * 파일명을 안전한 단일 파일명으로 살균한다 (path traversal 차단).
70
+ *
71
+ * `basename` 으로 디렉터리 성분(`../`, `/`, `\`)을 모두 제거하고, 남은 선행 점(`.`/`..`/`.bashrc` 류)과
72
+ * NUL·제어문자를 제거한다. 빈 문자열이 되면 `'upload'` 로 폴백한다 — 빈 파일명이 디렉터리로 해석되는 걸 막는다.
73
+ *
74
+ * @param {unknown} name - 클라가 보낸 원본 파일명(신뢰 불가).
75
+ * @returns {string} 디렉터리 성분 없는 안전한 파일명.
76
+ * @example sanitizeFilename('../../etc/passwd') // 'passwd'
77
+ * @example sanitizeFilename('..') // 'upload'
78
+ */
79
+ export function sanitizeFilename(name) {
80
+ // 백슬래시(Windows 구분자)를 '/' 로 정규화 — POSIX 의 path.basename 은 '\' 를 구분자로 안 보므로
81
+ // 'C:\\..\\evil.exe' 같은 윈도우식 경로가 통째로 남는 걸 막는다(클라가 보낸 파일명은 OS 무관 신뢰 불가).
82
+ const normalized = String(name ?? '').replace(/\\/g, '/')
83
+ // basename 이 '../../etc/passwd'→'passwd', 'a/b.png'→'b.png', '..'→'..' 로 디렉터리 성분을 떼낸다.
84
+ const base = basename(normalized)
85
+ // NUL·제어문자 제거(파일시스템 인젝션 방지) + 선행 점 제거('..'·'.'·숨김파일 → 일반 파일명화).
86
+ // eslint-disable-next-line no-control-regex -- NUL·제어문자(0x00~0x1f)를 의도적으로 제거한다.
87
+ const cleaned = base.replace(/[\x00-\x1f]/g, '').replace(/^\.+/, '')
88
+ return cleaned.length > 0 ? cleaned : 'upload'
89
+ }
90
+
91
+ /**
92
+ * MIME 타입이 화이트리스트에 허용되는지 (Boolean — `is*`). 빈 목록/미지정이면 **전부 허용**(게이트 비활성).
93
+ * 정확 매치 + `type/*` 와일드카드(예: `'image/*'` 는 `'image/png'` 허용)를 지원한다.
94
+ *
95
+ * @param {string} mimetype - 검사할 MIME(part header 선언값).
96
+ * @param {string[]} [allowedMimeTypes] - 허용 목록. 빈 배열/미지정 → 전부 허용.
97
+ * @returns {boolean}
98
+ */
99
+ export function isMimeAllowed(mimetype, allowedMimeTypes) {
100
+ if (!Array.isArray(allowedMimeTypes) || allowedMimeTypes.length === 0) return true // 게이트 비활성.
101
+ const mime = String(mimetype ?? '').toLowerCase()
102
+ for (const entry of allowedMimeTypes) {
103
+ if (typeof entry !== 'string' || entry.length === 0) continue
104
+ const allow = entry.toLowerCase()
105
+ if (allow === mime) return true // 정확 매치.
106
+ if (allow.endsWith('/*') && mime.startsWith(allow.slice(0, -1))) return true // 'image/*' → 'image/'.
107
+ }
108
+ return false
109
+ }
110
+
111
+ /**
112
+ * `upload` config 를 정규화한다. `null` 이면 미옵트인(등록 안 함).
113
+ * @param {unknown} upload
114
+ * @returns {{ maxFileSize: number, maxFiles: number|null, allowedMimeTypes: string[] } | null}
115
+ */
116
+ function normalizeUpload(upload) {
117
+ if (!upload || typeof upload !== 'object') return null
118
+ const u = /** @type {Record<string, any>} */ (upload)
119
+ const maxFileSize =
120
+ Number.isInteger(u.maxFileSize) && u.maxFileSize > 0 ? u.maxFileSize : DEFAULT_MAX_FILE_SIZE
121
+ const maxFiles = Number.isInteger(u.maxFiles) && u.maxFiles > 0 ? u.maxFiles : null
122
+ const allowedMimeTypes = Array.isArray(u.allowedMimeTypes)
123
+ ? u.allowedMimeTypes.filter((/** @type {unknown} */ t) => typeof t === 'string' && t.length > 0)
124
+ : []
125
+ return { maxFileSize, maxFiles, allowedMimeTypes }
126
+ }
127
+
128
+ /**
129
+ * Fastify 인스턴스에 `@fastify/multipart` 를 자동 등록하고 MIME 게이트·안전 저장·관측성을 배선한다.
130
+ *
131
+ * `upload` 가 없으면 **미등록**(옵트인) — multipart 요청은 Fastify 가 415(파서 없음)로 거부한다. session.js
132
+ * 패턴 정합. 호출 순서는 보안 플러그인 등록 이후·라우트 등록 이전 어디든 무방하다(글로벌 onRequest hook +
133
+ * request 데코레이터라 라우트 등록 순서와 무관).
134
+ *
135
+ * @param {import('fastify').FastifyInstance} fastify - 대상 앱 Fastify 인스턴스.
136
+ * @param {Object} opts
137
+ * @param {unknown} opts.upload - `MegaUploadConfig`(maxFileSize/maxFiles/allowedMimeTypes). falsy 면 미등록.
138
+ * @param {string} [opts.appName] - 앱 이름(메트릭 라벨·로그용).
139
+ * @param {{ debug?: Function, warn?: Function }} [opts.logger] - 흐름 길목 debug 로그(선택).
140
+ * @returns {MultipartSummary}
141
+ */
142
+ export function registerMultipart(fastify, { upload, appName = '(unknown)', logger } = /** @type {any} */ ({})) {
143
+ const cfg = normalizeUpload(upload)
144
+ if (cfg === null) {
145
+ return { enabled: false, maxFileSize: DEFAULT_MAX_FILE_SIZE, maxFiles: null, allowedMimeTypes: [] }
146
+ }
147
+
148
+ // @fastify/multipart 등록 — limits 로 크기·개수 강제, throwFileSizeLimit 로 초과 시 throw(절단 silent 금지).
149
+ fastify.register(fastifyMultipart, /** @type {any} */ ({
150
+ throwFileSizeLimit: true,
151
+ limits: {
152
+ fileSize: cfg.maxFileSize,
153
+ ...(cfg.maxFiles !== null ? { files: cfg.maxFiles } : {}),
154
+ },
155
+ }))
156
+
157
+ /**
158
+ * MIME 화이트리스트 게이트 — 허용이면 'accepted' 메트릭, 비허용이면 활성 span 기록 + 415 throw.
159
+ * @param {{ mimetype: string, filename: string, fieldname: string }} file
160
+ * @returns {void}
161
+ */
162
+ const gateMime = (file) => {
163
+ if (isMimeAllowed(file.mimetype, cfg.allowedMimeTypes)) {
164
+ MegaMetrics.recordUpload({ app: appName, result: 'accepted' })
165
+ return
166
+ }
167
+ // 비허용 MIME — 활성 HTTP span 에 사유 기록(security.js annotateReject 정합) + 메트릭 + 415.
168
+ MegaTracing.tracer.activeSpan()?.setAttributes({ 'mega.upload.reason': 'rejected_mime' })
169
+ MegaMetrics.recordUpload({ app: appName, result: 'rejected_mime' })
170
+ logger?.debug?.({ app: appName, mimetype: file.mimetype, field: file.fieldname }, 'upload.rejected (mime)')
171
+ throw new MegaUnsupportedMediaTypeError('upload.unsupported_media_type', `Upload rejected: MIME '${file.mimetype}' is not allowed.`, {
172
+ details: { mimetype: file.mimetype, allowed: cfg.allowedMimeTypes },
173
+ })
174
+ }
175
+
176
+ // multipart 요청에 한해 req.file()/req.files() 를 MIME 게이트로 감싼다. onRequest 시점에 본 hook 이 돌고,
177
+ // 핸들러가 그 뒤에 req.file()/req.files() 를 부르므로 래퍼가 항상 먼저 설치된다. 비-multipart 요청은
178
+ // 래핑하지 않는다(불필요한 오버헤드 회피 — content-type 헤더로 판별).
179
+ fastify.addHook('onRequest', async (req) => {
180
+ const ct = String(req.headers['content-type'] ?? '')
181
+ if (!ct.includes('multipart/form-data')) return
182
+ const r = /** @type {any} */ (req)
183
+ const origFile = r.file // 플러그인 프로토타입 메서드(아직 미래핑).
184
+ const origFiles = r.files
185
+ if (typeof origFile !== 'function' || typeof origFiles !== 'function') return // 플러그인 미등록 방어.
186
+
187
+ // 단일 파일 — 게이트 후 반환.
188
+ r.file = async (/** @type {any} */ fileOpts) => {
189
+ const f = await origFile.call(req, fileOpts)
190
+ if (f) gateMime(f)
191
+ return f
192
+ }
193
+ // 다중 파일 — 각 파일 게이트 후 yield(비허용 만나면 throw 전파, 이미 통과한 파일은 핸들러가 소비).
194
+ r.files = async function* (/** @type {any} */ filesOpts) {
195
+ for await (const f of origFiles.call(req, filesOpts)) {
196
+ gateMime(f)
197
+ yield f
198
+ }
199
+ }
200
+ // 안전 저장 — 파일명 살균 + 디렉터리 내부 검증 + 스트리밍 저장 + span + 메트릭. 게이트는 위 r.files() 재사용.
201
+ r.saveUploads = (/** @type {string} */ destDir, /** @type {{ filesOptions?: object }} */ saveOpts = {}) =>
202
+ saveUploads({ req, destDir, appName, saveOpts })
203
+ })
204
+
205
+ logger?.debug?.(
206
+ { app: appName, maxFileSize: cfg.maxFileSize, maxFiles: cfg.maxFiles, allowedMimeTypes: cfg.allowedMimeTypes.length },
207
+ 'multipart.registered',
208
+ )
209
+ return { enabled: true, maxFileSize: cfg.maxFileSize, maxFiles: cfg.maxFiles, allowedMimeTypes: cfg.allowedMimeTypes }
210
+ }
211
+
212
+ /**
213
+ * 업로드된 모든 파일을 `destDir` 에 안전하게 저장한다(`req.saveUploads` 구현부). 파일명을 살균하고 최종
214
+ * 경로가 대상 디렉터리 내부인지 검증한 뒤 스트리밍으로 디스크에 쓴다. `mega.upload` span + `mega_upload_*`
215
+ * 메트릭을 기록한다. MIME 게이트는 래핑된 `req.files()` 가 적용한다(비허용 415 전파).
216
+ *
217
+ * @param {object} args
218
+ * @param {any} args.req - Fastify 요청(래핑된 `files()` 보유).
219
+ * @param {string} args.destDir - 저장 디렉터리(없으면 생성).
220
+ * @param {string} args.appName
221
+ * @param {{ filesOptions?: object }} [args.saveOpts]
222
+ * @returns {Promise<Array<{ fieldname: string, filename: string, savedAs: string, mimetype: string, encoding: string, bytes: number }>>}
223
+ * @throws {MegaUnsupportedMediaTypeError} 비허용 MIME(게이트). @throws {Error} 저장 I/O·크기 초과(413).
224
+ */
225
+ async function saveUploads({ req, destDir, appName, saveOpts = {} }) {
226
+ return MegaTracing.span(
227
+ 'mega.upload',
228
+ async (/** @type {import('@opentelemetry/api').Span} */ span) => {
229
+ const root = resolve(destDir)
230
+ await mkdir(root, { recursive: true })
231
+ /** @type {Array<{ fieldname: string, filename: string, savedAs: string, mimetype: string, encoding: string, bytes: number }>} */
232
+ const saved = []
233
+ /** @type {string[]} 이번 호출에서 디스크에 쓴 절대경로 — 실패 시 일괄 롤백(원자성). */
234
+ const written = []
235
+ let totalBytes = 0
236
+ try {
237
+ for await (const part of req.files(saveOpts.filesOptions)) {
238
+ const safeName = sanitizeFilename(part.filename)
239
+ const abs = resolve(root, safeName)
240
+ // 이중 방어 — 살균했어도 최종 경로가 root 내부(root/<파일>)가 아니면 거부.
241
+ if (!abs.startsWith(root + sep)) {
242
+ throw new Error(`upload.unsafe_path: resolved path escapes destDir (name='${part.filename}')`)
243
+ }
244
+ await pipeline(part.file, createWriteStream(abs))
245
+ written.push(abs)
246
+ // 크기 초과 검증 — throwFileSizeLimit 의 busboy 'limit' 은 **다음 part 이터레이션**에서야 throw 하므로,
247
+ // 그 사이 잘린 파일이 디스크에 남는다. 여기서 즉시 truncated 를 잡아 잘린 파일을 저장 성공으로 오인하지
248
+ // 않게 한다(부분저장 후 성공 위장 방지). 잔존 파일은 아래 catch 의 일괄 롤백이 정리한다.
249
+ if (part.file?.truncated) {
250
+ throw new MegaPayloadTooLargeError('upload.too_large', `Upload rejected: file '${safeName}' exceeds the size limit.`, {
251
+ details: { filename: safeName },
252
+ })
253
+ }
254
+ const { size } = await stat(abs)
255
+ totalBytes += size
256
+ MegaMetrics.recordUpload({ app: appName, result: 'saved', bytes: size })
257
+ saved.push({
258
+ fieldname: part.fieldname,
259
+ filename: safeName,
260
+ savedAs: abs,
261
+ mimetype: part.mimetype,
262
+ encoding: part.encoding,
263
+ bytes: size,
264
+ })
265
+ }
266
+ } catch (err) {
267
+ // 실패 시 이번 호출에서 쓴 파일 전부 롤백(원자성 — 부분 업로드 잔존 방지). 크기·개수 초과·MIME
268
+ // 거부·I/O 실패 모두 여기로 온다. 정리 실패는 비치명적이라 원인 에러를 가리지 않게 warn 만 남기고 재전파.
269
+ await Promise.allSettled(
270
+ written.map((p) =>
271
+ unlink(p).catch((cleanupErr) => req.log?.warn?.({ err: cleanupErr, path: p }, 'upload.cleanup failed (partial file)')),
272
+ ),
273
+ )
274
+ throw err
275
+ }
276
+ span?.setAttributes({ 'mega.upload.files': saved.length, 'mega.upload.bytes': totalBytes, 'mega.app': appName })
277
+ req.log?.debug?.({ app: appName, files: saved.length, bytes: totalBytes, dir: destDir }, 'upload.saved')
278
+ return saved
279
+ },
280
+ { attributes: { 'mega.app': appName } },
281
+ )
282
+ }
@@ -0,0 +1,114 @@
1
+ // @ts-check
2
+ /**
3
+ * MegaApp OpenAPI/Swagger 자동 등록 — `@fastify/swagger` + `@fastify/swagger-ui` 옵트인 통합.
4
+ *
5
+ * # 무엇인가 (중학생용 설명)
6
+ * "이 API 가 어떤 주소로 뭘 받고 뭘 주는지" 설명서를 자동으로 만들어 웹페이지(`/docs`)로 보여준다.
7
+ * 라우트에 적어둔 JSON Schema 를 코어가 긁어모아 OpenAPI 3.x 명세로 만든다 — 손으로 문서 쓰다 어긋나는 일이 없다.
8
+ * `apps/<name>/app.config.js` 의 `openapi` 를 켰을 때만(`enabled:true`) 등록한다(디폴트 OFF — 프로덕션 노출 방지).
9
+ *
10
+ * # 동작 한눈에 (multipart.js / static-assets.js 형제 패턴)
11
+ * 1. `registerOpenapi(fastify, { openapi })` — `enabled:true` 일 때만 옵트인 등록.
12
+ * 2. `@fastify/swagger`(OpenAPI 3.x 모드, `info` 주입)를 등록 → 라우트 schema 자동 수집(onRoute).
13
+ * 3. `@fastify/swagger-ui` 를 `routePrefix=path`(디폴트 `/docs`)로 등록 → 브라우저 명세 뷰어.
14
+ * 4. `auth` 미들웨어 배열이 있으면 docs 경로에 preHandler 가드(빈 배열=공개).
15
+ *
16
+ * # 보안 (ADR-070)
17
+ * - **디폴트 OFF**: 옵트인이 아니면 명세·UI 라우트가 없어 프로덕션 API surface 가 외부에 안 드러난다.
18
+ * - **env 토글**: `enabled: process.env.NODE_ENV !== 'production'` 패턴으로 dev 만 노출 가능.
19
+ * - **auth 가드**: `auth` 미들웨어 배열로 docs 접근 보호(예 `requireRole('admin')`). 핸들러와 동일 `(req, reply, ctx)` 시그니처.
20
+ * - **App-only 스코프**: 앱마다 자기 명세(다른 Fastify 인스턴스). 라우트는 코어 envelope 와 무관하게 swagger 가 자체 서빙.
21
+ *
22
+ * # 라우트 메타
23
+ * 라우트 옵션 `openapi:{ tags, summary, description, deprecated }`(router.js)가 라우트 schema 의 메타 필드로
24
+ * 병합돼 명세에 반영된다. 신규 클래스 없음 — Fastify 플러그인 위임만(ADR-070).
25
+ *
26
+ * @module core/openapi
27
+ * @see ADR-070
28
+ * @see ADR-140
29
+ * @see https://github.com/fastify/fastify-swagger (@fastify/swagger v9) / fastify-swagger-ui (v5)
30
+ */
31
+ import fastifySwagger from '@fastify/swagger'
32
+ import fastifySwaggerUi from '@fastify/swagger-ui'
33
+
34
+ /** swagger-ui 마운트 경로 디폴트 (04-data-models §2 정본). */
35
+ export const DEFAULT_OPENAPI_PATH = '/docs'
36
+
37
+ /**
38
+ * OpenAPI 자동 등록 결과 요약(디버그·테스트용).
39
+ * @typedef {Object} OpenapiSummary
40
+ * @property {boolean} enabled - 등록 여부.
41
+ * @property {string|null} path - swagger-ui 경로(미등록 시 null).
42
+ */
43
+
44
+ /**
45
+ * `openapi` config 를 정규화한다. `enabled !== true` 면 `null`(미옵트인).
46
+ *
47
+ * @param {unknown} openapi - `MegaOpenApiAppConfig`.
48
+ * @param {string} [appName] - info.title 디폴트용.
49
+ * @returns {{ path: string, info: { title: string, version: string, description?: string }, auth: Function[], theme: Object } | null}
50
+ */
51
+ export function normalizeOpenapi(openapi, appName = 'Mega') {
52
+ if (!openapi || typeof openapi !== 'object') return null
53
+ const c = /** @type {Record<string, any>} */ (openapi)
54
+ if (c.enabled !== true) return null // 옵트인 — 명시적으로 켜야 등록.
55
+
56
+ const path = typeof c.path === 'string' && c.path.length > 0 ? c.path : DEFAULT_OPENAPI_PATH
57
+ // info — 정본 { title, version, description? }. 누락 필드는 안전 디폴트로 채운다(명세 생성 자체는 실패 안 하게).
58
+ const userInfo = c.info && typeof c.info === 'object' ? c.info : {}
59
+ const info = {
60
+ title: typeof userInfo.title === 'string' && userInfo.title.length > 0 ? userInfo.title : `${appName} API`,
61
+ version: typeof userInfo.version === 'string' && userInfo.version.length > 0 ? userInfo.version : '0.0.0',
62
+ ...(typeof userInfo.description === 'string' ? { description: userInfo.description } : {}),
63
+ }
64
+ const auth = Array.isArray(c.auth) ? c.auth.filter((/** @type {unknown} */ f) => typeof f === 'function') : []
65
+ const theme = c.theme && typeof c.theme === 'object' ? c.theme : {}
66
+ return { path, info, auth, theme }
67
+ }
68
+
69
+ /**
70
+ * Fastify 인스턴스에 OpenAPI 명세 수집기 + swagger-ui 를 옵트인 등록한다.
71
+ *
72
+ * `openapi.enabled !== true` 면 **미등록**. `@fastify/swagger` 는 **라우트 등록보다 먼저** 등록돼야 onRoute 로
73
+ * 스키마를 수집하므로 `MegaApp` 생성자(3g, 보안·정적 자산 뒤·`/health` 앞)에서 호출한다. 멀티앱은 각자 Fastify
74
+ * 인스턴스라 docs 경로 충돌 없음(같은 앱 안에서 `path` 가 사용자 라우트와 겹치면 Fastify 가 중복 라우트로 부팅 throw).
75
+ *
76
+ * @param {import('fastify').FastifyInstance} fastify - 대상 앱 Fastify 인스턴스.
77
+ * @param {Object} opts
78
+ * @param {unknown} opts.openapi - `MegaOpenApiAppConfig`. `enabled:true` 일 때만 등록.
79
+ * @param {string} [opts.appName] - 앱 이름(info.title 디폴트·로그).
80
+ * @param {(req: any, reply: any) => any} [opts.buildCtx] - docs auth 미들웨어에 넘길 ctx 팩토리(요청당). 미지정 시 auth 는 `(req, reply)` 로만 호출.
81
+ * @param {{ debug?: Function, warn?: Function }} [opts.logger] - 흐름 길목 debug 로그(선택).
82
+ * @returns {OpenapiSummary}
83
+ */
84
+ export function registerOpenapi(fastify, { openapi, appName = '(unknown)', buildCtx, logger } = /** @type {any} */ ({})) {
85
+ const cfg = normalizeOpenapi(openapi, appName)
86
+ if (cfg === null) return { enabled: false, path: null }
87
+
88
+ // 1) 명세 수집기 — OpenAPI 3.x 모드(`openapi` 키). 라우트 schema(summary/tags/description/deprecated + body/response)를 수집.
89
+ fastify.register(fastifySwagger, /** @type {any} */ ({
90
+ openapi: { info: cfg.info },
91
+ }))
92
+
93
+ // 2) docs auth 가드 — auth 미들웨어 배열을 swagger-ui preHandler 로. 핸들러와 동일 (req, reply, ctx) 시그니처
94
+ // (ADR-074). buildCtx 가 있으면 요청당 ctx 를 만들어 넘긴다(globalMiddleware 패턴 정합). 빈 배열=공개.
95
+ const uiHooks =
96
+ cfg.auth.length > 0
97
+ ? {
98
+ preHandler: cfg.auth.map((/** @type {Function} */ mw) => async (/** @type {any} */ req, /** @type {any} */ reply) => {
99
+ const ctx = buildCtx ? buildCtx(req, reply) : undefined
100
+ return /** @type {any} */ (mw)(req, reply, ctx)
101
+ }),
102
+ }
103
+ : undefined
104
+
105
+ // 3) swagger-ui 뷰어 — routePrefix=path. theme 는 uiConfig 로 그대로 전달.
106
+ fastify.register(fastifySwaggerUi, /** @type {any} */ ({
107
+ routePrefix: cfg.path,
108
+ ...(Object.keys(cfg.theme).length > 0 ? { uiConfig: cfg.theme } : {}),
109
+ ...(uiHooks ? { uiHooks } : {}),
110
+ }))
111
+
112
+ logger?.debug?.({ app: appName, path: cfg.path, auth: cfg.auth.length, title: cfg.info.title }, 'openapi.registered')
113
+ return { enabled: true, path: cfg.path }
114
+ }