mcpmake 0.1.0 → 0.2.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 (385) hide show
  1. package/dist/commands/bundle.d.ts +1 -0
  2. package/dist/commands/bundle.d.ts.map +1 -0
  3. package/dist/commands/bundle.js +5 -4
  4. package/dist/commands/bundle.js.map +1 -0
  5. package/dist/commands/ci.d.ts +1 -0
  6. package/dist/commands/ci.d.ts.map +1 -0
  7. package/dist/commands/ci.js +3 -2
  8. package/dist/commands/ci.js.map +1 -0
  9. package/dist/commands/deploy.d.ts +1 -0
  10. package/dist/commands/deploy.d.ts.map +1 -0
  11. package/dist/commands/deploy.js +4 -3
  12. package/dist/commands/deploy.js.map +1 -0
  13. package/dist/commands/diff.d.ts +1 -0
  14. package/dist/commands/diff.d.ts.map +1 -0
  15. package/dist/commands/diff.js +5 -4
  16. package/dist/commands/diff.js.map +1 -0
  17. package/dist/commands/from/describe.d.ts +1 -0
  18. package/dist/commands/from/describe.d.ts.map +1 -0
  19. package/dist/commands/from/describe.js +11 -10
  20. package/dist/commands/from/describe.js.map +1 -0
  21. package/dist/commands/from/har.d.ts +1 -0
  22. package/dist/commands/from/har.d.ts.map +1 -0
  23. package/dist/commands/from/har.js +14 -13
  24. package/dist/commands/from/har.js.map +1 -0
  25. package/dist/commands/from/openapi.d.ts +1 -0
  26. package/dist/commands/from/openapi.d.ts.map +1 -0
  27. package/dist/commands/from/openapi.js +17 -16
  28. package/dist/commands/from/openapi.js.map +1 -0
  29. package/dist/commands/from/postman.d.ts +1 -0
  30. package/dist/commands/from/postman.d.ts.map +1 -0
  31. package/dist/commands/from/postman.js +13 -12
  32. package/dist/commands/from/postman.js.map +1 -0
  33. package/dist/commands/from/stainless.d.ts +110 -0
  34. package/dist/commands/from/stainless.d.ts.map +1 -0
  35. package/dist/commands/from/stainless.js +272 -0
  36. package/dist/commands/from/stainless.js.map +1 -0
  37. package/dist/commands/from/target-support.d.ts +1 -0
  38. package/dist/commands/from/target-support.d.ts.map +1 -0
  39. package/dist/commands/from/target-support.js +2 -1
  40. package/dist/commands/from/target-support.js.map +1 -0
  41. package/dist/commands/from/url.d.ts +1 -0
  42. package/dist/commands/from/url.d.ts.map +1 -0
  43. package/dist/commands/from/url.js +14 -13
  44. package/dist/commands/from/url.js.map +1 -0
  45. package/dist/commands/from/website.d.ts +1 -0
  46. package/dist/commands/from/website.d.ts.map +1 -0
  47. package/dist/commands/from/website.js +17 -16
  48. package/dist/commands/from/website.js.map +1 -0
  49. package/dist/commands/lint.d.ts +1 -0
  50. package/dist/commands/lint.d.ts.map +1 -0
  51. package/dist/commands/lint.js +6 -5
  52. package/dist/commands/lint.js.map +1 -0
  53. package/dist/commands/merge.d.ts +1 -0
  54. package/dist/commands/merge.d.ts.map +1 -0
  55. package/dist/commands/merge.js +3 -2
  56. package/dist/commands/merge.js.map +1 -0
  57. package/dist/commands/publish.d.ts +1 -0
  58. package/dist/commands/publish.d.ts.map +1 -0
  59. package/dist/commands/publish.js +4 -3
  60. package/dist/commands/publish.js.map +1 -0
  61. package/dist/commands/rescan.d.ts +1 -0
  62. package/dist/commands/rescan.d.ts.map +1 -0
  63. package/dist/commands/rescan.js +12 -11
  64. package/dist/commands/rescan.js.map +1 -0
  65. package/dist/commands/update.d.ts +1 -0
  66. package/dist/commands/update.d.ts.map +1 -0
  67. package/dist/commands/update.js +10 -9
  68. package/dist/commands/update.js.map +1 -0
  69. package/dist/commands/verify.d.ts +1 -0
  70. package/dist/commands/verify.d.ts.map +1 -0
  71. package/dist/commands/verify.js +7 -6
  72. package/dist/commands/verify.js.map +1 -0
  73. package/dist/index.d.ts +1 -0
  74. package/dist/index.d.ts.map +1 -0
  75. package/dist/index.js +23 -2
  76. package/dist/index.js.map +1 -0
  77. package/dist/registry/official-registry.d.ts +1 -0
  78. package/dist/registry/official-registry.d.ts.map +1 -0
  79. package/dist/registry/official-registry.js +1 -0
  80. package/dist/registry/official-registry.js.map +1 -0
  81. package/package.json +24 -42
  82. package/README.md +0 -691
  83. package/dist/analyzer/auth-detector.d.ts +0 -12
  84. package/dist/analyzer/auth-detector.js +0 -142
  85. package/dist/analyzer/dom-parser.d.ts +0 -10
  86. package/dist/analyzer/dom-parser.js +0 -259
  87. package/dist/analyzer/goal-crawler.d.ts +0 -25
  88. package/dist/analyzer/goal-crawler.js +0 -177
  89. package/dist/analyzer/hybrid-detector.d.ts +0 -28
  90. package/dist/analyzer/hybrid-detector.js +0 -96
  91. package/dist/analyzer/index.d.ts +0 -12
  92. package/dist/analyzer/index.js +0 -8
  93. package/dist/analyzer/screenshot-capture.d.ts +0 -29
  94. package/dist/analyzer/screenshot-capture.js +0 -42
  95. package/dist/analyzer/selector-builder.d.ts +0 -19
  96. package/dist/analyzer/selector-builder.js +0 -199
  97. package/dist/analyzer/semantic-analyzer.d.ts +0 -13
  98. package/dist/analyzer/semantic-analyzer.js +0 -145
  99. package/dist/analyzer/site-crawler.d.ts +0 -38
  100. package/dist/analyzer/site-crawler.js +0 -235
  101. package/dist/cloud/billing/billing-engine.d.ts +0 -44
  102. package/dist/cloud/billing/billing-engine.js +0 -81
  103. package/dist/cloud/billing/credit-store.d.ts +0 -64
  104. package/dist/cloud/billing/credit-store.js +0 -168
  105. package/dist/cloud/billing/index.d.ts +0 -4
  106. package/dist/cloud/billing/index.js +0 -2
  107. package/dist/cloud/billing/usage-store.d.ts +0 -42
  108. package/dist/cloud/billing/usage-store.js +0 -85
  109. package/dist/cloud/billing/usage-tracker.d.ts +0 -38
  110. package/dist/cloud/billing/usage-tracker.js +0 -95
  111. package/dist/cloud/build-pipeline.d.ts +0 -39
  112. package/dist/cloud/build-pipeline.js +0 -310
  113. package/dist/cloud/build-queue.d.ts +0 -30
  114. package/dist/cloud/build-queue.js +0 -70
  115. package/dist/cloud/caddy-manager.d.ts +0 -18
  116. package/dist/cloud/caddy-manager.js +0 -97
  117. package/dist/cloud/container-backend.d.ts +0 -62
  118. package/dist/cloud/container-backend.js +0 -59
  119. package/dist/cloud/container-manager.d.ts +0 -64
  120. package/dist/cloud/container-manager.js +0 -301
  121. package/dist/cloud/crypto.d.ts +0 -27
  122. package/dist/cloud/crypto.js +0 -63
  123. package/dist/cloud/db/index.d.ts +0 -27
  124. package/dist/cloud/db/index.js +0 -53
  125. package/dist/cloud/db/migrations.d.ts +0 -12
  126. package/dist/cloud/db/migrations.js +0 -329
  127. package/dist/cloud/db/pg-store.d.ts +0 -45
  128. package/dist/cloud/db/pg-store.js +0 -336
  129. package/dist/cloud/failure-tracker.d.ts +0 -51
  130. package/dist/cloud/failure-tracker.js +0 -102
  131. package/dist/cloud/idle-monitor.d.ts +0 -30
  132. package/dist/cloud/idle-monitor.js +0 -70
  133. package/dist/cloud/mailer.d.ts +0 -21
  134. package/dist/cloud/mailer.js +0 -193
  135. package/dist/cloud/mcp-proxy.d.ts +0 -58
  136. package/dist/cloud/mcp-proxy.js +0 -203
  137. package/dist/cloud/metric-samples.d.ts +0 -43
  138. package/dist/cloud/metric-samples.js +0 -85
  139. package/dist/cloud/metrics.d.ts +0 -26
  140. package/dist/cloud/metrics.js +0 -59
  141. package/dist/cloud/multipart.d.ts +0 -26
  142. package/dist/cloud/multipart.js +0 -132
  143. package/dist/cloud/observability.d.ts +0 -27
  144. package/dist/cloud/observability.js +0 -98
  145. package/dist/cloud/rate-limiter.d.ts +0 -31
  146. package/dist/cloud/rate-limiter.js +0 -58
  147. package/dist/cloud/request-security.d.ts +0 -5
  148. package/dist/cloud/request-security.js +0 -74
  149. package/dist/cloud/resource-monitor.d.ts +0 -69
  150. package/dist/cloud/resource-monitor.js +0 -130
  151. package/dist/cloud/secret-store.d.ts +0 -38
  152. package/dist/cloud/secret-store.js +0 -103
  153. package/dist/cloud/security.d.ts +0 -26
  154. package/dist/cloud/security.js +0 -142
  155. package/dist/cloud/server.d.ts +0 -21
  156. package/dist/cloud/server.js +0 -1079
  157. package/dist/cloud/shared-state.d.ts +0 -72
  158. package/dist/cloud/shared-state.js +0 -159
  159. package/dist/cloud/ssrf.d.ts +0 -43
  160. package/dist/cloud/ssrf.js +0 -150
  161. package/dist/cloud/store.d.ts +0 -41
  162. package/dist/cloud/store.js +0 -75
  163. package/dist/cloud/stripe.d.ts +0 -78
  164. package/dist/cloud/stripe.js +0 -317
  165. package/dist/cloud/telemetry-store.d.ts +0 -53
  166. package/dist/cloud/telemetry-store.js +0 -108
  167. package/dist/cloud/web/auth.d.ts +0 -225
  168. package/dist/cloud/web/auth.js +0 -555
  169. package/dist/cloud/web/charts.d.ts +0 -70
  170. package/dist/cloud/web/charts.js +0 -178
  171. package/dist/cloud/web/csrf.d.ts +0 -14
  172. package/dist/cloud/web/csrf.js +0 -22
  173. package/dist/cloud/web/docs.d.ts +0 -40
  174. package/dist/cloud/web/docs.js +0 -174
  175. package/dist/cloud/web/router.d.ts +0 -25
  176. package/dist/cloud/web/router.js +0 -1921
  177. package/dist/cloud/web/static/alpine.min.js +0 -5
  178. package/dist/cloud/web/static/favicon.svg +0 -4
  179. package/dist/cloud/web/static/htmx-sse.js +0 -290
  180. package/dist/cloud/web/static/htmx.min.js +0 -1
  181. package/dist/cloud/web/static/style.css +0 -2683
  182. package/dist/cloud/web/static-server.d.ts +0 -13
  183. package/dist/cloud/web/static-server.js +0 -73
  184. package/dist/cloud/web/template-engine.d.ts +0 -27
  185. package/dist/cloud/web/template-engine.js +0 -146
  186. package/dist/cloud/web/templates/layouts/admin.hbs +0 -57
  187. package/dist/cloud/web/templates/layouts/auth.hbs +0 -138
  188. package/dist/cloud/web/templates/layouts/base.hbs +0 -16
  189. package/dist/cloud/web/templates/layouts/dashboard.hbs +0 -39
  190. package/dist/cloud/web/templates/layouts/landing.hbs +0 -82
  191. package/dist/cloud/web/templates/pages/admin/overview.hbs +0 -123
  192. package/dist/cloud/web/templates/pages/admin/servers.hbs +0 -129
  193. package/dist/cloud/web/templates/pages/admin/telemetry.hbs +0 -39
  194. package/dist/cloud/web/templates/pages/admin/user-edit.hbs +0 -91
  195. package/dist/cloud/web/templates/pages/admin/users.hbs +0 -179
  196. package/dist/cloud/web/templates/pages/auth/forgot-password.hbs +0 -25
  197. package/dist/cloud/web/templates/pages/auth/login.hbs +0 -33
  198. package/dist/cloud/web/templates/pages/auth/register.hbs +0 -32
  199. package/dist/cloud/web/templates/pages/auth/reset-password.hbs +0 -34
  200. package/dist/cloud/web/templates/pages/dashboard/billing.hbs +0 -140
  201. package/dist/cloud/web/templates/pages/dashboard/create.hbs +0 -173
  202. package/dist/cloud/web/templates/pages/dashboard/index.hbs +0 -8
  203. package/dist/cloud/web/templates/pages/dashboard/server-detail.hbs +0 -280
  204. package/dist/cloud/web/templates/pages/dashboard/server-logs.hbs +0 -35
  205. package/dist/cloud/web/templates/pages/dashboard/server-metrics.hbs +0 -63
  206. package/dist/cloud/web/templates/pages/dashboard/servers-partial.hbs +0 -21
  207. package/dist/cloud/web/templates/pages/dashboard/servers.hbs +0 -44
  208. package/dist/cloud/web/templates/pages/docs/show.hbs +0 -16
  209. package/dist/cloud/web/templates/pages/errors/404.hbs +0 -9
  210. package/dist/cloud/web/templates/pages/errors/500.hbs +0 -8
  211. package/dist/cloud/web/templates/pages/landing/index.hbs +0 -223
  212. package/dist/cloud/web/templates/pages/legal/privacy.hbs +0 -71
  213. package/dist/cloud/web/templates/pages/legal/terms.hbs +0 -73
  214. package/dist/cloud/web/templates/partials/admin-stats.hbs +0 -52
  215. package/dist/cloud/web/templates/partials/flash-message.hbs +0 -6
  216. package/dist/cloud/web/templates/partials/pricing-table.hbs +0 -103
  217. package/dist/cloud/web/templates/partials/server-card.hbs +0 -19
  218. package/dist/cloud/web/templates/partials/status-badge.hbs +0 -1
  219. package/dist/config/configurable-command.d.ts +0 -13
  220. package/dist/config/configurable-command.js +0 -70
  221. package/dist/config/mcpmake-config.d.ts +0 -68
  222. package/dist/config/mcpmake-config.js +0 -207
  223. package/dist/docs/cli.md +0 -400
  224. package/dist/docs/mcp-2026-07-28-migration.md +0 -78
  225. package/dist/docs/migrate-from-stainless.md +0 -94
  226. package/dist/docs/quickstart.md +0 -166
  227. package/dist/docs/show-hn.md +0 -26
  228. package/dist/docs/website-servers.md +0 -169
  229. package/dist/emitter/code-writer.d.ts +0 -8
  230. package/dist/emitter/code-writer.js +0 -25
  231. package/dist/emitter/index.d.ts +0 -32
  232. package/dist/emitter/index.js +0 -280
  233. package/dist/emitter/mcpb-bundler.d.ts +0 -31
  234. package/dist/emitter/mcpb-bundler.js +0 -172
  235. package/dist/emitter/project-scaffolder.d.ts +0 -4
  236. package/dist/emitter/project-scaffolder.js +0 -89
  237. package/dist/emitter/python-template-loader.d.ts +0 -4
  238. package/dist/emitter/python-template-loader.js +0 -30
  239. package/dist/emitter/python-templates/dockerfile.hbs +0 -14
  240. package/dist/emitter/python-templates/env.example.hbs +0 -6
  241. package/dist/emitter/python-templates/requirements.txt.hbs +0 -4
  242. package/dist/emitter/python-templates/server.py.hbs +0 -77
  243. package/dist/emitter/site-scaffolder.d.ts +0 -13
  244. package/dist/emitter/site-scaffolder.js +0 -70
  245. package/dist/emitter/site-template-loader.d.ts +0 -5
  246. package/dist/emitter/site-template-loader.js +0 -47
  247. package/dist/emitter/site-templates/browser-manager.ts.hbs +0 -233
  248. package/dist/emitter/site-templates/config.ts.hbs +0 -28
  249. package/dist/emitter/site-templates/dockerfile.hbs +0 -31
  250. package/dist/emitter/site-templates/env.example.hbs +0 -19
  251. package/dist/emitter/site-templates/package.json.hbs +0 -26
  252. package/dist/emitter/site-templates/server-main-http.ts.hbs +0 -108
  253. package/dist/emitter/site-templates/server-main.ts.hbs +0 -23
  254. package/dist/emitter/site-templates/tool-handler-action.ts.hbs +0 -86
  255. package/dist/emitter/site-templates/tool-handler-form.ts.hbs +0 -116
  256. package/dist/emitter/site-templates/tool-handler-lifecycle.ts.hbs +0 -146
  257. package/dist/emitter/site-templates/tool-index.ts.hbs +0 -11
  258. package/dist/emitter/template-loader.d.ts +0 -1
  259. package/dist/emitter/template-loader.js +0 -27
  260. package/dist/emitter/templates/auth-provider.ts.hbs +0 -57
  261. package/dist/emitter/templates/config.ts.hbs +0 -63
  262. package/dist/emitter/templates/discovery.ts.hbs +0 -301
  263. package/dist/emitter/templates/dockerfile.hbs +0 -34
  264. package/dist/emitter/templates/env.example.hbs +0 -28
  265. package/dist/emitter/templates/gitignore.hbs +0 -5
  266. package/dist/emitter/templates/http-executor.ts.hbs +0 -117
  267. package/dist/emitter/templates/oauth.ts.hbs +0 -188
  268. package/dist/emitter/templates/package.json.hbs +0 -25
  269. package/dist/emitter/templates/prompts.ts.hbs +0 -22
  270. package/dist/emitter/templates/readme.md.hbs +0 -123
  271. package/dist/emitter/templates/resources.ts.hbs +0 -63
  272. package/dist/emitter/templates/server-main-http.ts.hbs +0 -407
  273. package/dist/emitter/templates/server-main.ts.hbs +0 -40
  274. package/dist/emitter/templates/task-handlers.ts.hbs +0 -189
  275. package/dist/emitter/templates/task-manager.ts.hbs +0 -139
  276. package/dist/emitter/templates/task-sse.ts.hbs +0 -105
  277. package/dist/emitter/templates/tool-handler.ts.hbs +0 -124
  278. package/dist/emitter/templates/tool-index.ts.hbs +0 -11
  279. package/dist/emitter/templates/tool-test.ts.hbs +0 -57
  280. package/dist/emitter/templates/trace.ts.hbs +0 -79
  281. package/dist/emitter/templates/tsconfig.json.hbs +0 -16
  282. package/dist/emitter/templates/types.ts.hbs +0 -5
  283. package/dist/emitter/worker-template-loader.d.ts +0 -5
  284. package/dist/emitter/worker-template-loader.js +0 -33
  285. package/dist/emitter/worker-templates/config.ts.hbs +0 -54
  286. package/dist/emitter/worker-templates/dev-vars.example.hbs +0 -10
  287. package/dist/emitter/worker-templates/gitignore.hbs +0 -6
  288. package/dist/emitter/worker-templates/package.json.hbs +0 -24
  289. package/dist/emitter/worker-templates/readme.md.hbs +0 -53
  290. package/dist/emitter/worker-templates/server.test.ts.hbs +0 -20
  291. package/dist/emitter/worker-templates/tool-handler.ts.hbs +0 -85
  292. package/dist/emitter/worker-templates/tool-index.ts.hbs +0 -28
  293. package/dist/emitter/worker-templates/tsconfig.json.hbs +0 -17
  294. package/dist/emitter/worker-templates/worker.ts.hbs +0 -242
  295. package/dist/emitter/worker-templates/wrangler.toml.hbs +0 -19
  296. package/dist/generator/spec-generator.d.ts +0 -6
  297. package/dist/generator/spec-generator.js +0 -50
  298. package/dist/parser/har-filter.d.ts +0 -8
  299. package/dist/parser/har-filter.js +0 -71
  300. package/dist/parser/har-loader.d.ts +0 -2
  301. package/dist/parser/har-loader.js +0 -14
  302. package/dist/parser/har-normalizer.d.ts +0 -20
  303. package/dist/parser/har-normalizer.js +0 -78
  304. package/dist/parser/index.d.ts +0 -10
  305. package/dist/parser/index.js +0 -6
  306. package/dist/parser/openapi-loader.d.ts +0 -6
  307. package/dist/parser/openapi-loader.js +0 -308
  308. package/dist/parser/operation-extractor.d.ts +0 -13
  309. package/dist/parser/operation-extractor.js +0 -155
  310. package/dist/parser/overlay-loader.d.ts +0 -10
  311. package/dist/parser/overlay-loader.js +0 -184
  312. package/dist/parser/postman-loader.d.ts +0 -9
  313. package/dist/parser/postman-loader.js +0 -106
  314. package/dist/parser/schema-converter.d.ts +0 -12
  315. package/dist/parser/schema-converter.js +0 -117
  316. package/dist/plugins/adapter.d.ts +0 -40
  317. package/dist/plugins/adapter.js +0 -15
  318. package/dist/plugins/loader.d.ts +0 -25
  319. package/dist/plugins/loader.js +0 -58
  320. package/dist/pricing.d.ts +0 -55
  321. package/dist/pricing.js +0 -133
  322. package/dist/providers/index.d.ts +0 -15
  323. package/dist/providers/index.js +0 -56
  324. package/dist/recorder/browser-recorder.d.ts +0 -22
  325. package/dist/recorder/browser-recorder.js +0 -205
  326. package/dist/rescan/diff-engine.d.ts +0 -5
  327. package/dist/rescan/diff-engine.js +0 -312
  328. package/dist/rescan/index.d.ts +0 -3
  329. package/dist/rescan/index.js +0 -2
  330. package/dist/rescan/rescan-runner.d.ts +0 -42
  331. package/dist/rescan/rescan-runner.js +0 -69
  332. package/dist/rescan/rescan-scheduler.d.ts +0 -41
  333. package/dist/rescan/rescan-scheduler.js +0 -179
  334. package/dist/site-transformer/browser-tools.d.ts +0 -10
  335. package/dist/site-transformer/browser-tools.js +0 -59
  336. package/dist/site-transformer/index.d.ts +0 -2
  337. package/dist/site-transformer/index.js +0 -2
  338. package/dist/site-transformer/selector-healer.d.ts +0 -8
  339. package/dist/site-transformer/selector-healer.js +0 -106
  340. package/dist/site-transformer/tool-generator.d.ts +0 -13
  341. package/dist/site-transformer/tool-generator.js +0 -245
  342. package/dist/transformer/auth-detector.d.ts +0 -13
  343. package/dist/transformer/auth-detector.js +0 -90
  344. package/dist/transformer/catalog-builder.d.ts +0 -18
  345. package/dist/transformer/catalog-builder.js +0 -56
  346. package/dist/transformer/client-compat.d.ts +0 -6
  347. package/dist/transformer/client-compat.js +0 -44
  348. package/dist/transformer/har-clusterer.d.ts +0 -9
  349. package/dist/transformer/har-clusterer.js +0 -27
  350. package/dist/transformer/har-dedup.d.ts +0 -10
  351. package/dist/transformer/har-dedup.js +0 -81
  352. package/dist/transformer/har-schema-inferrer.d.ts +0 -15
  353. package/dist/transformer/har-schema-inferrer.js +0 -90
  354. package/dist/transformer/har-to-operations.d.ts +0 -13
  355. package/dist/transformer/har-to-operations.js +0 -192
  356. package/dist/transformer/index.d.ts +0 -8
  357. package/dist/transformer/index.js +0 -6
  358. package/dist/transformer/llm-namer.d.ts +0 -6
  359. package/dist/transformer/llm-namer.js +0 -59
  360. package/dist/transformer/naming.d.ts +0 -4
  361. package/dist/transformer/naming.js +0 -30
  362. package/dist/transformer/operation-filter.d.ts +0 -13
  363. package/dist/transformer/operation-filter.js +0 -52
  364. package/dist/transformer/resource-builder.d.ts +0 -12
  365. package/dist/transformer/resource-builder.js +0 -80
  366. package/dist/transformer/schema-merger.d.ts +0 -14
  367. package/dist/transformer/schema-merger.js +0 -65
  368. package/dist/transformer/tool-builder.d.ts +0 -3
  369. package/dist/transformer/tool-builder.js +0 -114
  370. package/dist/types/index.d.ts +0 -131
  371. package/dist/types/index.js +0 -1
  372. package/dist/types/site.d.ts +0 -284
  373. package/dist/types/site.js +0 -8
  374. package/dist/utils/fail.d.ts +0 -48
  375. package/dist/utils/fail.js +0 -204
  376. package/dist/utils/fs.d.ts +0 -5
  377. package/dist/utils/fs.js +0 -28
  378. package/dist/utils/interactive.d.ts +0 -6
  379. package/dist/utils/interactive.js +0 -30
  380. package/dist/utils/logger.d.ts +0 -1
  381. package/dist/utils/logger.js +0 -2
  382. package/dist/utils/sanitize.d.ts +0 -28
  383. package/dist/utils/sanitize.js +0 -44
  384. package/dist/utils/watcher.d.ts +0 -11
  385. package/dist/utils/watcher.js +0 -36
@@ -1,1921 +0,0 @@
1
- /**
2
- * Web route handler — serves HTML pages and static assets.
3
- *
4
- * Called before API routes in the main server. Returns `true` if the
5
- * request was handled, `false` to fall through to API routes.
6
- */
7
- import os from 'node:os';
8
- import { randomUUID, randomBytes } from 'node:crypto';
9
- import { writeFile, mkdir, unlink, rm } from 'node:fs/promises';
10
- import { join } from 'node:path';
11
- import { serveStatic } from './static-server.js';
12
- import { renderPage, renderTemplate } from './template-engine.js';
13
- import { getAuthenticatedUser, getSessionToken, requireAuth, requireAdmin, hashPassword, verifyPassword, setSessionCookie, clearSessionCookie, getUserByEmail, getUserById, createUser, updateUser, deleteUser, countUsers, listUsers, getSession, createSession, destroySession, setPendingToken, consumePendingToken, generateResetToken, hashResetToken, setPasswordResetToken, getUserByResetToken, clearPasswordResetToken, isEmailVerificationRequired, generateVerificationToken, hashVerificationToken, setEmailVerificationToken, getUserByVerificationToken, markEmailVerified, } from './auth.js';
14
- import { validateCsrf } from './csrf.js';
15
- import { getDoc, getSidebar, renderDoc, renderDocsIndexHtml } from './docs.js';
16
- import { parseMultipart, isAllowedSpecFile, getSafeExtension } from '../multipart.js';
17
- import { buildFromSpec, detectFormat, detectFormatFromContent } from '../build-pipeline.js';
18
- import { getContainerBackend } from '../container-backend.js';
19
- import { buildQueue } from '../build-queue.js';
20
- // Routing is backend-authoritative: the Caddy wildcard forwards every server
21
- // subdomain to this backend, so we no longer add per-container Caddy routes.
22
- import { generateBearerToken, hashToken, hashSpec } from '../security.js';
23
- import { getClientIp } from '../request-security.js';
24
- import { safeFetch, assertPublicUrl, SsrfError } from '../ssrf.js';
25
- import { loginLimiter, uploadLimiter, resetLimiter, usageTracker, getSecretStore, getPgServerStore, getCreditStore, getMetricSampleStore, getTelemetryStore, failureTracker, getDb, } from '../shared-state.js';
26
- import { gatherResourceReading, evaluatePressure, DEFAULT_THRESHOLDS, INITIAL_PRESSURE_STATE, } from '../resource-monitor.js';
27
- import { sendMail } from '../mailer.js';
28
- import { checkQuota, getPlanLimits, checkServerLimit } from '../billing/billing-engine.js';
29
- import { isStripeConfigured, createCheckoutSession, createPortalSession } from '../stripe.js';
30
- import { renderSparkline, renderBars, renderDualLine } from './charts.js';
31
- import { logger } from '../../utils/logger.js';
32
- const MAX_SPEC_SIZE = 5 * 1024 * 1024; // 5 MB
33
- const MAX_NAME_LEN = 100;
34
- const UPLOAD_DIR = '/tmp/mcpmake-uploads';
35
- const MAX_FORM_BODY = 1024 * 1024; // 1 MB
36
- /**
37
- * Handle web requests (HTML pages and static files).
38
- *
39
- * @returns `true` if handled, `false` to fall through to API routes.
40
- */
41
- export async function handleWebRequest(req, res, context) {
42
- const method = req.method ?? 'GET';
43
- const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
44
- const pathname = url.pathname;
45
- // Static files
46
- if (pathname.startsWith('/static/')) {
47
- const served = await serveStatic(req, res);
48
- if (served)
49
- return true;
50
- // Static file not found — send 404
51
- sendHtml(res, 404, '<h1>404 — Not Found</h1>');
52
- return true;
53
- }
54
- // --- Auth routes (GET + POST) ---
55
- if (pathname === '/login' && method === 'GET') {
56
- return handleLoginPage(req, res);
57
- }
58
- if (pathname === '/login' && method === 'POST') {
59
- return handleLoginSubmit(req, res);
60
- }
61
- if (pathname === '/register' && method === 'GET') {
62
- return handleRegisterPage(req, res);
63
- }
64
- if (pathname === '/register' && method === 'POST') {
65
- return handleRegisterSubmit(req, res);
66
- }
67
- if (pathname === '/forgot-password' && method === 'GET') {
68
- return handleForgotPasswordPage(req, res);
69
- }
70
- if (pathname === '/forgot-password' && method === 'POST') {
71
- return handleForgotPasswordSubmit(req, res);
72
- }
73
- if (pathname === '/reset-password' && method === 'GET') {
74
- return handleResetPasswordPage(req, res);
75
- }
76
- if (pathname === '/reset-password' && method === 'POST') {
77
- return handleResetPasswordSubmit(req, res);
78
- }
79
- if (pathname === '/verify-email' && method === 'GET') {
80
- return handleVerifyEmail(req, res);
81
- }
82
- if (pathname === '/dashboard/resend-verification' && method === 'POST') {
83
- return handleResendVerification(req, res);
84
- }
85
- if (pathname === '/logout' && method === 'POST') {
86
- return handleLogout(req, res);
87
- }
88
- // --- Dashboard routes (all require auth) ---
89
- if (pathname === '/dashboard' && method === 'GET') {
90
- return handleDashboardServers(req, res, context);
91
- }
92
- if (pathname === '/dashboard/_servers' && method === 'GET') {
93
- return handleDashboardServersPartial(req, res, context);
94
- }
95
- if (pathname === '/dashboard/create' && method === 'GET') {
96
- return handleDashboardCreatePage(req, res);
97
- }
98
- if (pathname === '/dashboard/create' && method === 'POST') {
99
- return handleDashboardCreateSubmit(req, res, context);
100
- }
101
- // Match /dashboard/servers/:slug
102
- const dashSlugMatch = pathname.match(/^\/dashboard\/servers\/([a-z0-9][a-z0-9-]*)$/);
103
- if (dashSlugMatch && method === 'GET') {
104
- return handleDashboardServerDetail(req, res, context, dashSlugMatch[1]);
105
- }
106
- // Match /dashboard/servers/:slug/_status (htmx partial)
107
- const dashStatusMatch = pathname.match(/^\/dashboard\/servers\/([a-z0-9][a-z0-9-]*)\/_status$/);
108
- if (dashStatusMatch && method === 'GET') {
109
- return handleDashboardServerStatus(req, res, context, dashStatusMatch[1]);
110
- }
111
- // Match /dashboard/servers/:slug/logs
112
- const dashLogsMatch = pathname.match(/^\/dashboard\/servers\/([a-z0-9][a-z0-9-]*)\/logs$/);
113
- if (dashLogsMatch && method === 'GET') {
114
- return handleDashboardServerLogs(req, res, context, dashLogsMatch[1]);
115
- }
116
- // Match /dashboard/servers/:slug/metrics
117
- const dashMetricsMatch = pathname.match(/^\/dashboard\/servers\/([a-z0-9][a-z0-9-]*)\/metrics$/);
118
- if (dashMetricsMatch && method === 'GET') {
119
- return handleDashboardServerMetrics(req, res, context, dashMetricsMatch[1]);
120
- }
121
- // Match /dashboard/servers/:slug/secrets (POST — set a secret)
122
- const dashSecretsMatch = pathname.match(/^\/dashboard\/servers\/([a-z0-9][a-z0-9-]*)\/secrets$/);
123
- if (dashSecretsMatch && method === 'POST') {
124
- return handleDashboardServerSecrets(req, res, context, dashSecretsMatch[1]);
125
- }
126
- // Match /dashboard/servers/:slug/delete
127
- const dashDeleteMatch = pathname.match(/^\/dashboard\/servers\/([a-z0-9][a-z0-9-]*)\/delete$/);
128
- if (dashDeleteMatch && method === 'POST') {
129
- return handleDashboardServerDelete(req, res, context, dashDeleteMatch[1]);
130
- }
131
- // --- Billing routes ---
132
- if (pathname === '/dashboard/billing' && method === 'GET') {
133
- return handleBillingPage(req, res);
134
- }
135
- if (pathname === '/dashboard/billing/checkout' && method === 'POST') {
136
- return handleBillingCheckout(req, res);
137
- }
138
- if (pathname === '/dashboard/billing/portal' && method === 'POST') {
139
- return handleBillingPortal(req, res);
140
- }
141
- if (pathname === '/dashboard/billing/success' && method === 'GET') {
142
- return handleBillingSuccess(req, res);
143
- }
144
- // --- Admin routes ---
145
- if (pathname === '/admin' && method === 'GET') {
146
- return handleAdminOverview(req, res, context);
147
- }
148
- if (pathname === '/admin/_stats' && method === 'GET') {
149
- return handleAdminStatsPartial(req, res, context);
150
- }
151
- if (pathname === '/admin/servers' && method === 'GET') {
152
- return handleAdminServers(req, res, context);
153
- }
154
- const adminServerDeleteMatch = pathname.match(/^\/admin\/servers\/([a-z0-9][a-z0-9-]*)\/delete$/);
155
- if (adminServerDeleteMatch && method === 'POST') {
156
- return handleAdminServerDelete(req, res, context, adminServerDeleteMatch[1]);
157
- }
158
- if (pathname === '/admin/users' && method === 'GET') {
159
- return handleAdminUsers(req, res, context);
160
- }
161
- const adminUserPlanMatch = pathname.match(/^\/admin\/users\/([a-f0-9-]+)\/plan$/);
162
- if (adminUserPlanMatch && method === 'POST') {
163
- return handleAdminUserPlan(req, res, adminUserPlanMatch[1]);
164
- }
165
- const adminUserAdminMatch = pathname.match(/^\/admin\/users\/([a-f0-9-]+)\/admin$/);
166
- if (adminUserAdminMatch && method === 'POST') {
167
- return handleAdminUserAdmin(req, res, adminUserAdminMatch[1]);
168
- }
169
- const adminUserDeleteMatch = pathname.match(/^\/admin\/users\/([a-f0-9-]+)\/delete$/);
170
- if (adminUserDeleteMatch && method === 'POST') {
171
- return handleAdminUserDelete(req, res, adminUserDeleteMatch[1]);
172
- }
173
- const adminUserCreditsMatch = pathname.match(/^\/admin\/users\/([a-f0-9-]+)\/credits$/);
174
- if (adminUserCreditsMatch && method === 'POST') {
175
- return handleAdminUserCredits(req, res, context, adminUserCreditsMatch[1]);
176
- }
177
- // Only handle GET for remaining page routes
178
- if (method !== 'GET') {
179
- return false;
180
- }
181
- if (pathname === '/admin/telemetry') {
182
- return handleAdminTelemetry(req, res);
183
- }
184
- // Bare user-id edit page. Registered AFTER /admin/users (exact) and the
185
- // /plan, /admin, /delete, /credits POST matches above so it never shadows
186
- // them. The `method !== 'GET'` guard above means only GET reaches here.
187
- const adminUserEditMatch = pathname.match(/^\/admin\/users\/([a-f0-9-]+)$/);
188
- if (adminUserEditMatch) {
189
- return handleAdminUserEdit(req, res, context, adminUserEditMatch[1]);
190
- }
191
- // --- Page routes ---
192
- if (pathname === '/') {
193
- const html = renderPage('pages/landing/index', 'layouts/landing', {
194
- metaTitle: 'mcpmake Cloud — Your API, as an MCP server in 30 seconds',
195
- metaDescription: 'Upload an OpenAPI spec or HAR file. We generate and host the MCP server. Connect it to Claude, Cursor, or any MCP client.',
196
- domain: context.domain,
197
- year: new Date().getFullYear(),
198
- });
199
- sendHtml(res, 200, html);
200
- return true;
201
- }
202
- // Legal pages (footer links).
203
- if (pathname === '/terms') {
204
- const html = renderPage('pages/legal/terms', 'layouts/landing', {
205
- metaTitle: 'Terms of Service — mcpmake Cloud',
206
- domain: context.domain,
207
- year: new Date().getFullYear(),
208
- });
209
- sendHtml(res, 200, html);
210
- return true;
211
- }
212
- if (pathname === '/privacy') {
213
- const html = renderPage('pages/legal/privacy', 'layouts/landing', {
214
- metaTitle: 'Privacy Policy — mcpmake Cloud',
215
- domain: context.domain,
216
- year: new Date().getFullYear(),
217
- });
218
- sendHtml(res, 200, html);
219
- return true;
220
- }
221
- // Documentation (public — rendered from the project's Markdown docs).
222
- if (pathname === '/docs') {
223
- return handleDocsIndex(res, context);
224
- }
225
- const docMatch = pathname.match(/^\/docs\/([a-z0-9][a-z0-9-]*)$/);
226
- if (docMatch) {
227
- return handleDocPage(res, context, docMatch[1]);
228
- }
229
- // For non-API GET requests that didn't match any route, render a 404 page
230
- if (!pathname.startsWith('/api/')) {
231
- const html = renderPage('pages/errors/404', 'layouts/landing', {
232
- metaTitle: '404 — Page not found — mcpmake Cloud',
233
- });
234
- sendHtml(res, 404, html);
235
- return true;
236
- }
237
- // API routes — fall through to API handler
238
- return false;
239
- }
240
- // ---------------------------------------------------------------------------
241
- // Documentation route handlers
242
- // ---------------------------------------------------------------------------
243
- function handleDocsIndex(res, context) {
244
- const html = renderPage('pages/docs/show', 'layouts/landing', {
245
- metaTitle: 'Documentation — mcpmake',
246
- metaDescription: 'mcpmake documentation: cloud quickstart, CLI reference, website MCP servers, and migration guides.',
247
- domain: context.domain,
248
- year: new Date().getFullYear(),
249
- isIndex: true,
250
- docTitle: 'Documentation',
251
- docGroups: getSidebar(),
252
- docHtml: renderDocsIndexHtml(),
253
- });
254
- sendHtml(res, 200, html);
255
- return true;
256
- }
257
- function handleDocPage(res, context, slug) {
258
- const meta = getDoc(slug);
259
- const docHtml = meta ? renderDoc(slug) : null;
260
- if (!meta || docHtml === null) {
261
- const notFound = renderPage('pages/errors/404', 'layouts/landing', {
262
- metaTitle: '404 — Page not found — mcpmake Cloud',
263
- domain: context.domain,
264
- year: new Date().getFullYear(),
265
- });
266
- sendHtml(res, 404, notFound);
267
- return true;
268
- }
269
- const html = renderPage('pages/docs/show', 'layouts/landing', {
270
- metaTitle: `${meta.title} — mcpmake docs`,
271
- metaDescription: meta.description,
272
- domain: context.domain,
273
- year: new Date().getFullYear(),
274
- isIndex: false,
275
- docTitle: meta.title,
276
- docGroups: getSidebar(slug),
277
- docHtml,
278
- });
279
- sendHtml(res, 200, html);
280
- return true;
281
- }
282
- // ---------------------------------------------------------------------------
283
- // Auth route handlers
284
- // ---------------------------------------------------------------------------
285
- async function handleLoginPage(req, res) {
286
- // If already logged in, redirect to dashboard
287
- const auth = await getAuthenticatedUser(req);
288
- if (auth) {
289
- redirect(res, '/dashboard');
290
- return true;
291
- }
292
- // Create a temporary session for the CSRF token (anon — always in-memory)
293
- const tempSession = await createSession('__anon__');
294
- setSessionCookie(res, req, tempSession.token);
295
- const html = renderPage('pages/auth/login', 'layouts/auth', {
296
- metaTitle: 'Sign in — mcpmake Cloud',
297
- csrfToken: tempSession.csrfToken,
298
- email: '',
299
- });
300
- sendHtml(res, 200, html);
301
- return true;
302
- }
303
- async function handleLoginSubmit(req, res) {
304
- const body = await parseFormBody(req);
305
- // Validate CSRF — need to get the session from the cookie
306
- const auth = await getAuthenticatedUser(req);
307
- if (auth) {
308
- redirect(res, '/dashboard');
309
- return true;
310
- }
311
- // Get the anon session for CSRF validation
312
- const sessionToken = getSessionToken(req);
313
- const session = sessionToken ? await getSession(sessionToken) : undefined;
314
- if (!session || !validateCsrf(req, body, session)) {
315
- return renderLoginError(req, res, 'Invalid form submission. Please try again.', body.email ?? '');
316
- }
317
- // Destroy the anon session
318
- await destroySession(session.token);
319
- const email = (body.email ?? '').trim().toLowerCase();
320
- const password = body.password ?? '';
321
- if (!email || !password) {
322
- return renderLoginError(req, res, 'Email and password are required.', email);
323
- }
324
- // Rate limit login attempts by IP and by email
325
- const clientIp = getClientIp(req);
326
- const ipCheck = loginLimiter.check(`login-ip:${clientIp}`);
327
- const emailCheck = loginLimiter.check(`login-email:${email}`);
328
- if (!ipCheck.allowed || !emailCheck.allowed) {
329
- return renderLoginError(req, res, 'Too many login attempts. Please try again later.', email);
330
- }
331
- const user = await getUserByEmail(email);
332
- if (!user) {
333
- return renderLoginError(req, res, 'Invalid email or password.', email);
334
- }
335
- const valid = await verifyPassword(password, user.passwordHash);
336
- if (!valid) {
337
- return renderLoginError(req, res, 'Invalid email or password.', email);
338
- }
339
- // Update last login
340
- await updateUser(user.id, { lastLoginAt: new Date().toISOString() });
341
- // Create session
342
- const newSession = await createSession(user.id);
343
- setSessionCookie(res, req, newSession.token);
344
- redirect(res, '/dashboard');
345
- return true;
346
- }
347
- async function renderLoginError(req, res, error, email) {
348
- const tempSession = await createSession('__anon__');
349
- setSessionCookie(res, req, tempSession.token);
350
- const html = renderPage('pages/auth/login', 'layouts/auth', {
351
- metaTitle: 'Sign in — mcpmake Cloud',
352
- csrfToken: tempSession.csrfToken,
353
- error,
354
- email,
355
- });
356
- sendHtml(res, 200, html);
357
- return true;
358
- }
359
- async function handleRegisterPage(req, res) {
360
- // If already logged in, redirect to dashboard
361
- const auth = await getAuthenticatedUser(req);
362
- if (auth) {
363
- redirect(res, '/dashboard');
364
- return true;
365
- }
366
- const tempSession = await createSession('__anon__');
367
- setSessionCookie(res, req, tempSession.token);
368
- const html = renderPage('pages/auth/register', 'layouts/auth', {
369
- metaTitle: 'Register — mcpmake Cloud',
370
- csrfToken: tempSession.csrfToken,
371
- email: '',
372
- });
373
- sendHtml(res, 200, html);
374
- return true;
375
- }
376
- async function handleRegisterSubmit(req, res) {
377
- const body = await parseFormBody(req);
378
- // If already logged in, redirect
379
- const auth = await getAuthenticatedUser(req);
380
- if (auth) {
381
- redirect(res, '/dashboard');
382
- return true;
383
- }
384
- // CSRF check
385
- const sessionToken = getSessionToken(req);
386
- const session = sessionToken ? await getSession(sessionToken) : undefined;
387
- if (!session || !validateCsrf(req, body, session)) {
388
- return renderRegisterError(req, res, 'Invalid form submission. Please try again.', body.email ?? '');
389
- }
390
- // Destroy the anon session
391
- await destroySession(session.token);
392
- const email = (body.email ?? '').trim().toLowerCase();
393
- const password = body.password ?? '';
394
- const confirmPassword = body.confirmPassword ?? '';
395
- // Validate email
396
- if (!email) {
397
- return renderRegisterError(req, res, 'Email is required.', email);
398
- }
399
- if (!isValidEmail(email)) {
400
- return renderRegisterError(req, res, 'Please enter a valid email address.', email);
401
- }
402
- // Email allowlist — restrict registration to approved domains/addresses
403
- const allowedEmails = (process.env.ALLOWED_EMAILS ?? '')
404
- .split(',')
405
- .map((s) => s.trim())
406
- .filter(Boolean);
407
- if (allowedEmails.length > 0) {
408
- const isAllowed = allowedEmails.some((pattern) => {
409
- if (pattern.startsWith('*@')) {
410
- const allowedDomain = pattern.slice(2).toLowerCase();
411
- const atIdx = email.lastIndexOf('@');
412
- return atIdx >= 0 && email.slice(atIdx + 1) === allowedDomain;
413
- }
414
- return email === pattern;
415
- });
416
- if (!isAllowed) {
417
- return renderRegisterError(req, res, 'Registration is currently limited to invited users.', email);
418
- }
419
- }
420
- // Validate password
421
- if (password.length < 8) {
422
- return renderRegisterError(req, res, 'Password must be at least 8 characters.', email);
423
- }
424
- if (password !== confirmPassword) {
425
- return renderRegisterError(req, res, 'Passwords do not match.', email);
426
- }
427
- // Check for existing user
428
- if (await getUserByEmail(email)) {
429
- return renderRegisterError(req, res, 'An account with this email already exists.', email);
430
- }
431
- // Hash password (async — yields the event loop)
432
- const passwordHashValue = await hashPassword(password);
433
- // Re-check after await to prevent race conditions (e.g. two concurrent
434
- // registrations both seeing count === 0 and both becoming admin)
435
- if (await getUserByEmail(email)) {
436
- return renderRegisterError(req, res, 'An account with this email already exists.', email);
437
- }
438
- const now = new Date().toISOString();
439
- const isFirstUser = (await countUsers()) === 0;
440
- const adminEmail = process.env.ADMIN_EMAIL?.toLowerCase().trim();
441
- const isAdmin = isFirstUser || (!!adminEmail && email === adminEmail);
442
- const user = {
443
- id: randomUUID(),
444
- email,
445
- passwordHash: passwordHashValue,
446
- plan: 'free',
447
- isAdmin,
448
- // Admins (and the first user / operator) are auto-verified so they are
449
- // never locked out of their own instance.
450
- emailVerified: isAdmin,
451
- createdAt: now,
452
- lastLoginAt: now,
453
- };
454
- await createUser(user);
455
- // Send an email verification link to non-admin users.
456
- if (!user.emailVerified) {
457
- await sendVerificationEmail(req, user).catch((err) => logger.error(`[mailer] Failed to send verification email: ${err}`));
458
- }
459
- // Create session and log in
460
- const newSession = await createSession(user.id);
461
- setSessionCookie(res, req, newSession.token);
462
- redirect(res, '/dashboard');
463
- return true;
464
- }
465
- /**
466
- * Generate a fresh email verification token, persist its hash, and email the
467
- * verification link to the user. Shared by registration and resend.
468
- */
469
- async function sendVerificationEmail(req, user) {
470
- const { token, hash } = generateVerificationToken();
471
- await setEmailVerificationToken(user.id, hash);
472
- const host = req.headers.host ?? 'localhost';
473
- const proto = process.env.TRUST_PROXY === 'true' && req.headers['x-forwarded-proto'] === 'https'
474
- ? 'https'
475
- : 'http';
476
- const verifyUrl = `${proto}://${host}/verify-email?token=${token}`;
477
- await sendMail({
478
- to: user.email,
479
- subject: 'Verify your mcpmake email',
480
- text: [
481
- `Welcome to mcpmake Cloud!`,
482
- ``,
483
- `Confirm your email address to start deploying servers:`,
484
- `${verifyUrl}`,
485
- ``,
486
- `If you did not create this account, you can ignore this email.`,
487
- ].join('\n'),
488
- html: [
489
- `<p>Welcome to mcpmake Cloud!</p>`,
490
- `<p>Confirm your email address to start deploying servers:</p>`,
491
- `<p><a href="${verifyUrl}">${verifyUrl}</a></p>`,
492
- `<p>If you did not create this account, you can ignore this email.</p>`,
493
- ].join('\n'),
494
- });
495
- }
496
- async function renderRegisterError(req, res, error, email) {
497
- const tempSession = await createSession('__anon__');
498
- setSessionCookie(res, req, tempSession.token);
499
- const html = renderPage('pages/auth/register', 'layouts/auth', {
500
- metaTitle: 'Register — mcpmake Cloud',
501
- csrfToken: tempSession.csrfToken,
502
- error,
503
- email,
504
- });
505
- sendHtml(res, 200, html);
506
- return true;
507
- }
508
- async function handleLogout(req, res) {
509
- const token = getSessionToken(req);
510
- const session = token ? await getSession(token) : undefined;
511
- if (session) {
512
- const body = await parseFormBody(req);
513
- if (!validateCsrf(req, body, session)) {
514
- redirect(res, '/dashboard');
515
- return true;
516
- }
517
- await destroySession(session.token);
518
- }
519
- clearSessionCookie(res, req);
520
- redirect(res, '/');
521
- return true;
522
- }
523
- // ---------------------------------------------------------------------------
524
- // Password reset route handlers
525
- // ---------------------------------------------------------------------------
526
- async function handleForgotPasswordPage(req, res) {
527
- const auth = await getAuthenticatedUser(req);
528
- if (auth) {
529
- redirect(res, '/dashboard');
530
- return true;
531
- }
532
- const tempSession = await createSession('__anon__');
533
- setSessionCookie(res, req, tempSession.token);
534
- const html = renderPage('pages/auth/forgot-password', 'layouts/auth', {
535
- metaTitle: 'Reset password — mcpmake Cloud',
536
- csrfToken: tempSession.csrfToken,
537
- email: '',
538
- });
539
- sendHtml(res, 200, html);
540
- return true;
541
- }
542
- async function handleForgotPasswordSubmit(req, res) {
543
- const body = await parseFormBody(req);
544
- const auth = await getAuthenticatedUser(req);
545
- if (auth) {
546
- redirect(res, '/dashboard');
547
- return true;
548
- }
549
- // CSRF check
550
- const sessionToken = getSessionToken(req);
551
- const session = sessionToken ? await getSession(sessionToken) : undefined;
552
- if (!session || !validateCsrf(req, body, session)) {
553
- return renderForgotPasswordError(req, res, 'Invalid form submission. Please try again.', '');
554
- }
555
- await destroySession(session.token);
556
- const email = (body.email ?? '').trim().toLowerCase();
557
- if (!email) {
558
- return renderForgotPasswordError(req, res, 'Email is required.', email);
559
- }
560
- // Rate limit by IP
561
- const clientIp = getClientIp(req);
562
- const ipCheck = resetLimiter.check(`reset-ip:${clientIp}`);
563
- if (!ipCheck.allowed) {
564
- return renderForgotPasswordError(req, res, 'Too many reset requests. Please try again later.', email);
565
- }
566
- // Always show success message to prevent email enumeration
567
- const successMessage = 'If an account with that email exists, a password reset link has been sent.';
568
- const user = await getUserByEmail(email);
569
- if (user) {
570
- const { token, hash } = generateResetToken();
571
- await setPasswordResetToken(user.id, hash);
572
- const host = req.headers.host ?? 'localhost';
573
- const proto = process.env.TRUST_PROXY === 'true' && req.headers['x-forwarded-proto'] === 'https'
574
- ? 'https'
575
- : 'http';
576
- const resetUrl = `${proto}://${host}/reset-password?token=${token}`;
577
- try {
578
- await sendMail({
579
- to: user.email,
580
- subject: 'Reset your mcpmake password',
581
- text: [
582
- `You requested a password reset for your mcpmake Cloud account.`,
583
- ``,
584
- `Click the link below to set a new password (expires in 1 hour):`,
585
- `${resetUrl}`,
586
- ``,
587
- `If you did not request this, you can safely ignore this email.`,
588
- ].join('\n'),
589
- html: [
590
- `<p>You requested a password reset for your mcpmake Cloud account.</p>`,
591
- `<p>Click the link below to set a new password (expires in 1 hour):</p>`,
592
- `<p><a href="${resetUrl}">${resetUrl}</a></p>`,
593
- `<p>If you did not request this, you can safely ignore this email.</p>`,
594
- ].join('\n'),
595
- });
596
- }
597
- catch (err) {
598
- logger.error(`[mailer] Failed to send reset email: ${err instanceof Error ? err.message : err}`);
599
- }
600
- }
601
- // Always show success — never reveal whether the email exists
602
- const tempSession = await createSession('__anon__');
603
- setSessionCookie(res, req, tempSession.token);
604
- const html = renderPage('pages/auth/forgot-password', 'layouts/auth', {
605
- metaTitle: 'Reset password — mcpmake Cloud',
606
- csrfToken: tempSession.csrfToken,
607
- success: successMessage,
608
- });
609
- sendHtml(res, 200, html);
610
- return true;
611
- }
612
- async function renderForgotPasswordError(req, res, error, email) {
613
- const tempSession = await createSession('__anon__');
614
- setSessionCookie(res, req, tempSession.token);
615
- const html = renderPage('pages/auth/forgot-password', 'layouts/auth', {
616
- metaTitle: 'Reset password — mcpmake Cloud',
617
- csrfToken: tempSession.csrfToken,
618
- error,
619
- email,
620
- });
621
- sendHtml(res, 200, html);
622
- return true;
623
- }
624
- async function handleResetPasswordPage(req, res) {
625
- const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
626
- const token = url.searchParams.get('token') ?? '';
627
- const tempSession = await createSession('__anon__');
628
- setSessionCookie(res, req, tempSession.token);
629
- if (!token || !/^[a-f0-9]{64}$/.test(token)) {
630
- const html = renderPage('pages/auth/reset-password', 'layouts/auth', {
631
- metaTitle: 'Reset password — mcpmake Cloud',
632
- csrfToken: tempSession.csrfToken,
633
- expired: true,
634
- });
635
- sendHtml(res, 200, html);
636
- return true;
637
- }
638
- const tokenHash = hashResetToken(token);
639
- const user = await getUserByResetToken(tokenHash);
640
- if (!user) {
641
- const html = renderPage('pages/auth/reset-password', 'layouts/auth', {
642
- metaTitle: 'Reset password — mcpmake Cloud',
643
- csrfToken: tempSession.csrfToken,
644
- expired: true,
645
- });
646
- sendHtml(res, 200, html);
647
- return true;
648
- }
649
- const html = renderPage('pages/auth/reset-password', 'layouts/auth', {
650
- metaTitle: 'Reset password — mcpmake Cloud',
651
- csrfToken: tempSession.csrfToken,
652
- token,
653
- });
654
- sendHtml(res, 200, html);
655
- return true;
656
- }
657
- async function handleResetPasswordSubmit(req, res) {
658
- const body = await parseFormBody(req);
659
- // CSRF check
660
- const sessionToken = getSessionToken(req);
661
- const session = sessionToken ? await getSession(sessionToken) : undefined;
662
- if (!session || !validateCsrf(req, body, session)) {
663
- return renderResetPasswordError(req, res, 'Invalid form submission. Please try again.', '');
664
- }
665
- await destroySession(session.token);
666
- const token = body.token ?? '';
667
- const password = body.password ?? '';
668
- const confirmPassword = body.confirmPassword ?? '';
669
- if (!token || !/^[a-f0-9]{64}$/.test(token)) {
670
- return renderResetPasswordError(req, res, '', '', true);
671
- }
672
- const tokenHash = hashResetToken(token);
673
- const user = await getUserByResetToken(tokenHash);
674
- if (!user) {
675
- return renderResetPasswordError(req, res, '', '', true);
676
- }
677
- if (password.length < 8) {
678
- return renderResetPasswordError(req, res, 'Password must be at least 8 characters.', token);
679
- }
680
- if (password !== confirmPassword) {
681
- return renderResetPasswordError(req, res, 'Passwords do not match.', token);
682
- }
683
- const newHash = await hashPassword(password);
684
- await updateUser(user.id, { passwordHash: newHash });
685
- await clearPasswordResetToken(user.id);
686
- // Redirect to login with success message
687
- const tempSession = await createSession('__anon__');
688
- setSessionCookie(res, req, tempSession.token);
689
- const html = renderPage('pages/auth/login', 'layouts/auth', {
690
- metaTitle: 'Sign in — mcpmake Cloud',
691
- csrfToken: tempSession.csrfToken,
692
- email: user.email,
693
- success: 'Your password has been reset. Please sign in with your new password.',
694
- });
695
- sendHtml(res, 200, html);
696
- return true;
697
- }
698
- async function renderResetPasswordError(req, res, error, token, expired = false) {
699
- const tempSession = await createSession('__anon__');
700
- setSessionCookie(res, req, tempSession.token);
701
- const html = renderPage('pages/auth/reset-password', 'layouts/auth', {
702
- metaTitle: 'Reset password — mcpmake Cloud',
703
- csrfToken: tempSession.csrfToken,
704
- error: error || undefined,
705
- token: token || undefined,
706
- expired,
707
- });
708
- sendHtml(res, 200, html);
709
- return true;
710
- }
711
- // ---------------------------------------------------------------------------
712
- // Email verification route handlers
713
- // ---------------------------------------------------------------------------
714
- async function handleVerifyEmail(req, res) {
715
- const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
716
- const token = url.searchParams.get('token') ?? '';
717
- if (!token || !/^[a-f0-9]{64}$/.test(token)) {
718
- redirect(res, '/dashboard?error=' + encodeURIComponent('Invalid or expired verification link.'));
719
- return true;
720
- }
721
- const user = await getUserByVerificationToken(hashVerificationToken(token));
722
- if (!user) {
723
- redirect(res, '/dashboard?error=' + encodeURIComponent('Invalid or expired verification link.'));
724
- return true;
725
- }
726
- await markEmailVerified(user.id);
727
- redirect(res, '/dashboard?success=' + encodeURIComponent('Email verified — you can now deploy servers.'));
728
- return true;
729
- }
730
- async function handleResendVerification(req, res) {
731
- const auth = await requireAuth(req, res);
732
- if (!auth)
733
- return true;
734
- const body = await parseFormBody(req);
735
- if (!validateCsrf(req, body, auth.session)) {
736
- redirect(res, '/dashboard?error=' + encodeURIComponent('Invalid form submission.'));
737
- return true;
738
- }
739
- if (auth.user.emailVerified) {
740
- redirect(res, '/dashboard?success=' + encodeURIComponent('Your email is already verified.'));
741
- return true;
742
- }
743
- // Rate limit resends by user to prevent mail flooding.
744
- const check = resetLimiter.check(`verify-resend:${auth.user.id}`);
745
- if (!check.allowed) {
746
- redirect(res, '/dashboard?error=' + encodeURIComponent('Too many requests. Please try again later.'));
747
- return true;
748
- }
749
- await sendVerificationEmail(req, auth.user).catch((err) => logger.error(`[mailer] Failed to resend verification email: ${err}`));
750
- redirect(res, '/dashboard?success=' + encodeURIComponent('Verification email sent. Check your inbox.'));
751
- return true;
752
- }
753
- // ---------------------------------------------------------------------------
754
- // Dashboard route handlers
755
- // ---------------------------------------------------------------------------
756
- async function handleDashboardServers(req, res, context) {
757
- const auth = await requireAuth(req, res);
758
- if (!auth)
759
- return true;
760
- const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
761
- const flashSuccess = url.searchParams.get('success') ?? undefined;
762
- const flashError = url.searchParams.get('error') ?? undefined;
763
- const allServers = context.store.list();
764
- const userServers = allServers
765
- .filter((s) => s.userId === auth.user.id)
766
- .map((s) => ({
767
- ...s,
768
- csrfToken: auth.session.csrfToken,
769
- endpoint: `https://${s.slug}.${context.domain}`,
770
- }));
771
- const html = renderPage('pages/dashboard/servers', 'layouts/dashboard', {
772
- metaTitle: 'Dashboard — mcpmake Cloud',
773
- activePage: 'servers',
774
- user: auth.user,
775
- csrfToken: auth.session.csrfToken,
776
- servers: userServers,
777
- showVerifyBanner: isEmailVerificationRequired() && !auth.user.emailVerified,
778
- flashSuccess,
779
- flashError,
780
- });
781
- sendHtml(res, 200, html);
782
- return true;
783
- }
784
- async function handleDashboardServersPartial(req, res, context) {
785
- const auth = await requireAuth(req, res);
786
- if (!auth)
787
- return true;
788
- const allServers = context.store.list();
789
- const userServers = allServers
790
- .filter((s) => s.userId === auth.user.id)
791
- .map((s) => ({
792
- ...s,
793
- csrfToken: auth.session.csrfToken,
794
- endpoint: `https://${s.slug}.${context.domain}`,
795
- }));
796
- const html = renderTemplate('pages/dashboard/servers-partial', {
797
- servers: userServers,
798
- csrfToken: auth.session.csrfToken,
799
- });
800
- sendHtml(res, 200, html);
801
- return true;
802
- }
803
- async function handleDashboardCreatePage(req, res) {
804
- const auth = await requireAuth(req, res);
805
- if (!auth)
806
- return true;
807
- const html = renderPage('pages/dashboard/create', 'layouts/dashboard', {
808
- metaTitle: 'New Server — mcpmake Cloud',
809
- activePage: 'create',
810
- user: auth.user,
811
- csrfToken: auth.session.csrfToken,
812
- });
813
- sendHtml(res, 200, html);
814
- return true;
815
- }
816
- async function handleDashboardCreateSubmit(req, res, context) {
817
- const auth = await requireAuth(req, res);
818
- if (!auth)
819
- return true;
820
- const contentType = req.headers['content-type'] ?? '';
821
- if (!contentType.includes('multipart/form-data')) {
822
- return renderCreateError(res, auth, 'Invalid form submission.');
823
- }
824
- const parsed = await parseMultipart(req, contentType);
825
- if (!parsed) {
826
- return renderCreateError(res, auth, 'Failed to parse form data.');
827
- }
828
- // Validate CSRF token from form field
829
- const csrfBody = { _csrf: parsed.fields.get('_csrf') ?? '' };
830
- if (!validateCsrf(req, csrfBody, auth.session)) {
831
- return renderCreateError(res, auth, 'Invalid form submission. Please try again.');
832
- }
833
- // Require a verified email before allowing any server creation.
834
- if (isEmailVerificationRequired() && !auth.user.emailVerified) {
835
- return renderCreateError(res, auth, 'Please verify your email address before deploying a server. Check your inbox, or resend the link from your dashboard.');
836
- }
837
- // Rate limit server creation (same limiter as the API path)
838
- const clientIp = getClientIp(req);
839
- const rateCheck = uploadLimiter.check(clientIp);
840
- if (!rateCheck.allowed) {
841
- return renderCreateError(res, auth, 'Rate limit exceeded. Try again later.');
842
- }
843
- // Check billing quota for Playwright servers
844
- const userPlan = (auth.user.plan ?? 'free');
845
- const usage = usageTracker.getUsageSummary(auth.user.id);
846
- const quota = checkQuota(auth.user.id, userPlan, usage.totalSessionsMs);
847
- if (!quota.allowed) {
848
- return renderCreateError(res, auth, `Browser usage quota exceeded (${Math.round(quota.limit)} min). Upgrade your plan for more.`);
849
- }
850
- // Enforce the plan's hosted-server limit before allocating a port / building.
851
- // Count from Postgres when available so the limit survives process restarts.
852
- const pgServerStore = getPgServerStore();
853
- const ownedServers = pgServerStore
854
- ? (await pgServerStore.list()).filter((s) => s.userId === auth.user.id)
855
- : context.store.list().filter((s) => s.userId === auth.user.id);
856
- const serverLimit = checkServerLimit(userPlan, ownedServers.length);
857
- if (!serverLimit.allowed) {
858
- return renderCreateError(res, auth, `You've reached your plan's limit of ${serverLimit.limit} hosted server${serverLimit.limit === 1 ? '' : 's'}. Upgrade your plan or delete an existing server.`);
859
- }
860
- // Determine source: file upload or URL
861
- const sourceUrl = parsed.fields.get('source_url')?.trim();
862
- let specFile = parsed.files.get('spec');
863
- let fileName;
864
- if (sourceUrl && sourceUrl.startsWith('http')) {
865
- // Fetch spec from URL
866
- try {
867
- const urlObj = new URL(sourceUrl);
868
- if (!['http:', 'https:'].includes(urlObj.protocol)) {
869
- return renderCreateError(res, auth, 'Only http/https URLs are supported.');
870
- }
871
- // SSRF guard: refuse private / link-local / metadata targets and
872
- // re-validate every redirect hop.
873
- let fetchRes;
874
- try {
875
- fetchRes = await safeFetch(sourceUrl, {
876
- headers: { Accept: 'application/json, application/yaml, text/yaml, */*' },
877
- timeoutMs: 15_000,
878
- });
879
- }
880
- catch (err) {
881
- if (err instanceof SsrfError) {
882
- return renderCreateError(res, auth, 'That URL is not allowed (must be a public address).');
883
- }
884
- throw err;
885
- }
886
- if (!fetchRes.ok) {
887
- return renderCreateError(res, auth, `Failed to fetch URL: ${fetchRes.status} ${fetchRes.statusText}`);
888
- }
889
- const contentType = fetchRes.headers.get('content-type') ?? '';
890
- const body = Buffer.from(await fetchRes.arrayBuffer());
891
- if (body.length > MAX_SPEC_SIZE) {
892
- return renderCreateError(res, auth, 'Fetched content too large. Maximum is 5MB.');
893
- }
894
- // Determine extension from content type or URL
895
- let ext = '.yaml';
896
- if (contentType.includes('json') || sourceUrl.endsWith('.json'))
897
- ext = '.json';
898
- else if (sourceUrl.endsWith('.har'))
899
- ext = '.har';
900
- else if (sourceUrl.endsWith('.yml'))
901
- ext = '.yml';
902
- // If it looks like HTML (a website), run Playwright analysis
903
- if (contentType.includes('text/html') && !sourceUrl.match(/\.(yaml|yml|json|har)(\?|$)/i)) {
904
- // Website-to-MCP: analyze with Playwright headless
905
- const depthStr = parsed.fields.get('depth') ?? '2';
906
- const crawlDepth = Math.min(Math.max(parseInt(depthStr, 10) || 2, 1), 3);
907
- const maxPages = crawlDepth === 1 ? 5 : crawlDepth === 2 ? 15 : 30;
908
- try {
909
- const { crawlSite } = await import('../../analyzer/site-crawler.js');
910
- const { generateSiteTools } = await import('../../site-transformer/tool-generator.js');
911
- const { emitSiteProject } = await import('../../emitter/index.js');
912
- const { mkdtemp } = await import('node:fs/promises');
913
- const { tmpdir } = await import('node:os');
914
- // SSRF guard before driving a headless browser to the target. The
915
- // seed URL is validated here; deep-crawled links rely on the
916
- // network-level egress allowlist (deploy/harden-egress.sh).
917
- try {
918
- await assertPublicUrl(sourceUrl);
919
- }
920
- catch (err) {
921
- if (err instanceof SsrfError) {
922
- return renderCreateError(res, auth, 'That URL is not allowed (must be a public address).');
923
- }
924
- throw err;
925
- }
926
- logger.info(`[dashboard] Crawling website: ${sourceUrl} (depth: ${crawlDepth}, max: ${maxPages})`);
927
- const { siteDescriptor } = await crawlSite({
928
- url: sourceUrl,
929
- depth: crawlDepth,
930
- maxPages,
931
- headless: true,
932
- captureScreenshots: false,
933
- timeout: 60_000,
934
- });
935
- if (siteDescriptor.pages.length === 0) {
936
- return renderCreateError(res, auth, 'No pages could be analyzed from that URL.');
937
- }
938
- const tools = generateSiteTools(siteDescriptor);
939
- if (tools.length === 0) {
940
- return renderCreateError(res, auth, 'No interactive elements found on the site.');
941
- }
942
- const rawName = (parsed.fields.get('name') ?? '').trim();
943
- const serverName = rawName
944
- ? rawName
945
- .toLowerCase()
946
- .replace(/[^a-z0-9]+/g, '-')
947
- .replace(/^-|-$/g, '')
948
- : new URL(sourceUrl).hostname.replace(/[^a-z0-9]+/g, '-');
949
- const buildDir = await mkdtemp(join(tmpdir(), 'mcpmake-site-'));
950
- await emitSiteProject({
951
- serverName,
952
- serverVersion: '1.0.0',
953
- baseUrl: siteDescriptor.baseUrl,
954
- transport: 'http',
955
- siteDescriptor,
956
- tools,
957
- envVars: [
958
- {
959
- name: 'BASE_URL',
960
- description: 'Target website URL',
961
- required: true,
962
- example: siteDescriptor.baseUrl,
963
- },
964
- ],
965
- browserConfig: {
966
- headless: true,
967
- idleTimeoutMs: 5 * 60 * 1000,
968
- viewport: { width: 1280, height: 720 },
969
- maxSessions: 3,
970
- },
971
- }, { outputDir: buildDir, force: true, dryRun: false });
972
- // Build and deploy the Playwright server
973
- const slug = serverName.slice(0, 40) + '-' + randomBytes(4).toString('hex');
974
- const imageName = `mcpmake-${slug}`;
975
- const imageTag = 'latest';
976
- // Install deps and build in the emitted project
977
- const { execFile: execFileCb } = await import('node:child_process');
978
- const { promisify } = await import('node:util');
979
- const execFileAsync = promisify(execFileCb);
980
- // Gate the heavy install/build/image steps through the build queue
981
- // so concurrent crawls can't exhaust the host.
982
- await buildQueue.run(async () => {
983
- await execFileAsync('npm', ['install', '--omit=dev'], {
984
- cwd: buildDir,
985
- timeout: 60_000,
986
- });
987
- await execFileAsync('npm', ['run', 'build'], { cwd: buildDir, timeout: 30_000 });
988
- await getContainerBackend().buildImage(buildDir, imageName, imageTag);
989
- });
990
- try {
991
- await rm(buildDir, { recursive: true, force: true });
992
- }
993
- catch {
994
- logger.warn(`Failed to clean up build directory: ${buildDir}`);
995
- }
996
- const port = context.ports.allocate();
997
- const bearerToken = generateBearerToken();
998
- const tokenHash = hashToken(bearerToken);
999
- const specHash = hashSpec(Buffer.from(sourceUrl));
1000
- const secrets = await getSecretStore().getSecretsAsEnv(slug);
1001
- const containerId = await getContainerBackend().startContainer({
1002
- slug,
1003
- imageName,
1004
- imageTag,
1005
- envVars: { BASE_URL: sourceUrl, MCP_AUTH_TOKEN: bearerToken },
1006
- port,
1007
- serverType: 'playwright',
1008
- secrets,
1009
- });
1010
- // Routing is backend-authoritative (Caddy wildcard → this backend),
1011
- // so no per-container Caddy route is created.
1012
- const now = new Date().toISOString();
1013
- context.store.create({
1014
- slug,
1015
- userId: auth.user.id,
1016
- specHash,
1017
- containerId,
1018
- port,
1019
- bearerToken: tokenHash,
1020
- status: 'running',
1021
- toolCount: tools.length,
1022
- serverType: 'playwright',
1023
- createdAt: now,
1024
- lastActiveAt: now,
1025
- });
1026
- const pgStore = getPgServerStore();
1027
- if (pgStore) {
1028
- await pgStore.create({
1029
- slug,
1030
- userId: auth.user.id,
1031
- specHash,
1032
- containerId,
1033
- port,
1034
- bearerToken: tokenHash,
1035
- status: 'running',
1036
- toolCount: tools.length,
1037
- serverType: 'playwright',
1038
- createdAt: now,
1039
- lastActiveAt: now,
1040
- });
1041
- }
1042
- context.metrics.init(slug);
1043
- await setPendingToken(auth.session.token, slug, bearerToken);
1044
- redirect(res, `/dashboard/servers/${encodeURIComponent(slug)}`);
1045
- return true;
1046
- }
1047
- catch (err) {
1048
- const msg = err instanceof Error ? err.message : String(err);
1049
- logger.error(`[dashboard] Website analysis failed: ${msg}`);
1050
- return renderCreateError(res, auth, `Website analysis failed: ${msg.slice(0, 200)}`);
1051
- }
1052
- }
1053
- fileName = `url-spec${ext}`;
1054
- specFile = { data: body, filename: fileName };
1055
- }
1056
- catch (err) {
1057
- const msg = err instanceof Error ? err.message : String(err);
1058
- return renderCreateError(res, auth, `Failed to fetch URL: ${msg}`);
1059
- }
1060
- }
1061
- else if (!specFile) {
1062
- return renderCreateError(res, auth, 'Please upload a spec file or enter a URL.');
1063
- }
1064
- else {
1065
- fileName = specFile.filename ?? 'spec';
1066
- }
1067
- if (specFile.data.length > MAX_SPEC_SIZE) {
1068
- return renderCreateError(res, auth, `File too large (${Math.round(specFile.data.length / 1024)}KB). Maximum is 5MB.`);
1069
- }
1070
- if (!isAllowedSpecFile(fileName)) {
1071
- return renderCreateError(res, auth, 'Invalid file type. Accepted: .yaml, .yml, .json, .har');
1072
- }
1073
- let name = parsed.fields.get('name') ?? undefined;
1074
- if (name && name.length > MAX_NAME_LEN) {
1075
- name = name.slice(0, MAX_NAME_LEN);
1076
- }
1077
- if (name) {
1078
- name = name.replace(/[^\x20-\x7E]/g, '');
1079
- }
1080
- // Save uploaded file
1081
- await mkdir(UPLOAD_DIR, { recursive: true });
1082
- const tempId = randomBytes(8).toString('hex');
1083
- const safeExtension = getSafeExtension(fileName);
1084
- const specPath = join(UPLOAD_DIR, `${tempId}${safeExtension}`);
1085
- await writeFile(specPath, specFile.data);
1086
- try {
1087
- let format = detectFormat(specPath);
1088
- if (safeExtension === '.json') {
1089
- format = await detectFormatFromContent(specPath);
1090
- }
1091
- logger.info(`[dashboard] Building from ${format} spec: ${fileName}`);
1092
- const buildResult = await buildFromSpec(specPath, { name, format });
1093
- const port = context.ports.allocate();
1094
- const bearerToken = generateBearerToken();
1095
- const tokenHash = hashToken(bearerToken);
1096
- const specHash = hashSpec(specFile.data);
1097
- await buildQueue.run(() => getContainerBackend().buildImage(buildResult.buildDir, buildResult.imageName, buildResult.imageTag));
1098
- try {
1099
- await rm(buildResult.buildDir, { recursive: true, force: true });
1100
- }
1101
- catch {
1102
- logger.warn(`Failed to clean up build directory: ${buildResult.buildDir}`);
1103
- }
1104
- // Retrieve any stored secrets for the slug to inject as env vars
1105
- const secrets = await getSecretStore().getSecretsAsEnv(buildResult.slug);
1106
- // Inject the raw bearer token so the container authenticates /mcp traffic.
1107
- const containerId = await getContainerBackend().startContainer({
1108
- slug: buildResult.slug,
1109
- imageName: buildResult.imageName,
1110
- imageTag: buildResult.imageTag,
1111
- envVars: { MCP_AUTH_TOKEN: bearerToken },
1112
- port,
1113
- serverType: buildResult.serverType,
1114
- secrets,
1115
- });
1116
- // Routing is backend-authoritative (Caddy wildcard → this backend), so no
1117
- // per-container Caddy route is created.
1118
- const now = new Date().toISOString();
1119
- context.store.create({
1120
- slug: buildResult.slug,
1121
- userId: auth.user.id,
1122
- specHash,
1123
- containerId,
1124
- port,
1125
- bearerToken: tokenHash,
1126
- status: 'running',
1127
- toolCount: buildResult.toolCount,
1128
- serverType: buildResult.serverType,
1129
- createdAt: now,
1130
- lastActiveAt: now,
1131
- });
1132
- context.metrics.init(buildResult.slug);
1133
- await setPendingToken(auth.session.token, buildResult.slug, bearerToken);
1134
- redirect(res, `/dashboard/servers/${encodeURIComponent(buildResult.slug)}`);
1135
- return true;
1136
- }
1137
- catch (err) {
1138
- const message = err instanceof Error ? err.message : String(err);
1139
- logger.error(`[dashboard] Build failed: ${message}`);
1140
- return renderCreateError(res, auth, 'Build failed. Please check your spec file and try again.');
1141
- }
1142
- finally {
1143
- try {
1144
- await unlink(specPath);
1145
- }
1146
- catch {
1147
- // Ignore cleanup errors
1148
- }
1149
- }
1150
- }
1151
- function renderCreateError(res, auth, error) {
1152
- const html = renderPage('pages/dashboard/create', 'layouts/dashboard', {
1153
- metaTitle: 'New Server — mcpmake Cloud',
1154
- activePage: 'create',
1155
- user: auth.user,
1156
- csrfToken: auth.session.csrfToken,
1157
- flashError: error,
1158
- });
1159
- sendHtml(res, 200, html);
1160
- return true;
1161
- }
1162
- async function handleDashboardServerDetail(req, res, context, slug) {
1163
- const auth = await requireAuth(req, res);
1164
- if (!auth)
1165
- return true;
1166
- const server = context.store.get(slug);
1167
- if (!server || server.userId !== auth.user.id) {
1168
- redirect(res, '/dashboard?error=' + encodeURIComponent('Server not found.'));
1169
- return true;
1170
- }
1171
- const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
1172
- const flashSuccess = url.searchParams.get('success') ?? undefined;
1173
- const flashError = url.searchParams.get('error') ?? undefined;
1174
- const token = await consumePendingToken(auth.session.token, slug);
1175
- // Retrieve secret names (never values) for display
1176
- const secretNames = await getSecretStore().listSecrets(slug);
1177
- // Build Playwright-specific data if this is a site server
1178
- const isPlaywright = server.serverType === 'playwright';
1179
- let sessions = [];
1180
- let usage = {};
1181
- let screenshots = [];
1182
- let changes = [];
1183
- if (isPlaywright) {
1184
- const summary = usageTracker.getUsageSummary(auth.user.id);
1185
- const plan = (auth.user.plan ?? 'free');
1186
- const quota = checkQuota(auth.user.id, plan, summary.totalSessionsMs);
1187
- usage = {
1188
- minutesUsed: Math.round(summary.totalSessionsMs / 60_000),
1189
- minutesRemaining: Math.round(quota.remaining),
1190
- toolCalls: summary.totalToolCalls,
1191
- screenshots: summary.totalScreenshots,
1192
- plan,
1193
- limit: quota.limit === Infinity ? 'unlimited' : quota.limit,
1194
- };
1195
- // Sessions, screenshots, and changes will be populated from DB when available
1196
- }
1197
- const html = renderPage('pages/dashboard/server-detail', 'layouts/dashboard', {
1198
- metaTitle: `${slug} — mcpmake Cloud`,
1199
- activePage: 'servers',
1200
- user: auth.user,
1201
- csrfToken: auth.session.csrfToken,
1202
- server,
1203
- endpoint: `https://${slug}.${context.domain}`,
1204
- token,
1205
- isPlaywright,
1206
- sessions,
1207
- usage,
1208
- screenshots,
1209
- changes,
1210
- secretNames,
1211
- flashSuccess,
1212
- flashError,
1213
- });
1214
- sendHtml(res, 200, html, token ? { 'Cache-Control': 'no-store' } : undefined);
1215
- return true;
1216
- }
1217
- async function handleDashboardServerStatus(req, res, context, slug) {
1218
- const auth = await requireAuth(req, res);
1219
- if (!auth)
1220
- return true;
1221
- const server = context.store.get(slug);
1222
- if (!server || server.userId !== auth.user.id) {
1223
- sendHtml(res, 404, '<span class="status-badge status-stopped">unknown</span>');
1224
- return true;
1225
- }
1226
- const html = renderTemplate('partials/status-badge', { status: server.status });
1227
- sendHtml(res, 200, html);
1228
- return true;
1229
- }
1230
- async function handleDashboardServerLogs(req, res, context, slug) {
1231
- const auth = await requireAuth(req, res);
1232
- if (!auth)
1233
- return true;
1234
- const server = context.store.get(slug);
1235
- if (!server || server.userId !== auth.user.id) {
1236
- redirect(res, '/dashboard?error=' + encodeURIComponent('Server not found.'));
1237
- return true;
1238
- }
1239
- const html = renderPage('pages/dashboard/server-logs', 'layouts/dashboard', {
1240
- metaTitle: `Logs — ${slug} — mcpmake Cloud`,
1241
- activePage: 'servers',
1242
- user: auth.user,
1243
- csrfToken: auth.session.csrfToken,
1244
- server,
1245
- });
1246
- sendHtml(res, 200, html);
1247
- return true;
1248
- }
1249
- async function handleDashboardServerMetrics(req, res, context, slug) {
1250
- const auth = await requireAuth(req, res);
1251
- if (!auth)
1252
- return true;
1253
- const server = context.store.get(slug);
1254
- if (!server || server.userId !== auth.user.id) {
1255
- redirect(res, '/dashboard?error=' + encodeURIComponent('Server not found.'));
1256
- return true;
1257
- }
1258
- const serverMetrics = context.metrics.get(slug);
1259
- const topTools = context.metrics.getTopTools(slug);
1260
- const maxCalls = topTools.length > 0 ? topTools[0].calls : 1;
1261
- const topToolsWithPercentage = topTools.map((t) => ({
1262
- ...t,
1263
- percentage: Math.max(5, Math.round((t.calls / maxCalls) * 100)),
1264
- }));
1265
- const html = renderPage('pages/dashboard/server-metrics', 'layouts/dashboard', {
1266
- metaTitle: `Metrics — ${slug} — mcpmake Cloud`,
1267
- activePage: 'servers',
1268
- user: auth.user,
1269
- csrfToken: auth.session.csrfToken,
1270
- server,
1271
- totalRequests: serverMetrics?.totalRequests ?? 0,
1272
- topTools: topToolsWithPercentage,
1273
- lastActiveAt: serverMetrics?.lastActiveAt ?? server.lastActiveAt,
1274
- });
1275
- sendHtml(res, 200, html);
1276
- return true;
1277
- }
1278
- async function handleDashboardServerDelete(req, res, context, slug) {
1279
- const auth = await requireAuth(req, res);
1280
- if (!auth)
1281
- return true;
1282
- const body = await parseFormBody(req);
1283
- if (!validateCsrf(req, body, auth.session)) {
1284
- redirect(res, '/dashboard?error=' + encodeURIComponent('Invalid form submission.'));
1285
- return true;
1286
- }
1287
- const server = context.store.get(slug);
1288
- if (!server || server.userId !== auth.user.id) {
1289
- redirect(res, '/dashboard?error=' + encodeURIComponent('Server not found.'));
1290
- return true;
1291
- }
1292
- if (server.containerId) {
1293
- try {
1294
- await getContainerBackend().stopContainer(server.containerId);
1295
- }
1296
- catch (err) {
1297
- logger.warn(`Failed to stop container for ${slug}: ${err}`);
1298
- }
1299
- }
1300
- // No Caddy route to remove — routing is backend-authoritative (wildcard).
1301
- context.ports.release(server.port);
1302
- context.store.delete(slug);
1303
- const pgStoreForDelete = getPgServerStore();
1304
- if (pgStoreForDelete)
1305
- await pgStoreForDelete.delete(slug);
1306
- context.metrics.delete(slug);
1307
- redirect(res, '/dashboard?success=' + encodeURIComponent(`Server "${slug}" has been deleted.`));
1308
- return true;
1309
- }
1310
- async function handleDashboardServerSecrets(req, res, context, slug) {
1311
- const auth = await requireAuth(req, res);
1312
- if (!auth)
1313
- return true;
1314
- const body = await parseFormBody(req);
1315
- if (!validateCsrf(req, body, auth.session)) {
1316
- redirect(res, `/dashboard/servers/${encodeURIComponent(slug)}?error=` +
1317
- encodeURIComponent('Invalid form submission.'));
1318
- return true;
1319
- }
1320
- const pgStore = getPgServerStore();
1321
- const server = pgStore ? await pgStore.get(slug) : context.store.get(slug);
1322
- if (!server || server.userId !== auth.user.id) {
1323
- redirect(res, '/dashboard?error=' + encodeURIComponent('Server not found.'));
1324
- return true;
1325
- }
1326
- const secretName = (body.secret_name ?? '').trim();
1327
- const secretValue = body.secret_value ?? '';
1328
- if (!secretName || !secretValue) {
1329
- redirect(res, `/dashboard/servers/${encodeURIComponent(slug)}?error=` +
1330
- encodeURIComponent('Secret name and value are required.'));
1331
- return true;
1332
- }
1333
- // Validate secret name: must look like an env var (letters, digits, underscores)
1334
- if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(secretName)) {
1335
- redirect(res, `/dashboard/servers/${encodeURIComponent(slug)}?error=` +
1336
- encodeURIComponent('Secret name must be a valid environment variable name (letters, digits, underscores).'));
1337
- return true;
1338
- }
1339
- await getSecretStore().storeSecret(slug, secretName, secretValue);
1340
- redirect(res, `/dashboard/servers/${encodeURIComponent(slug)}?success=` +
1341
- encodeURIComponent(`Secret "${secretName}" saved.`));
1342
- return true;
1343
- }
1344
- // ---------------------------------------------------------------------------
1345
- // Billing route handlers
1346
- // ---------------------------------------------------------------------------
1347
- async function handleBillingPage(req, res) {
1348
- const auth = await requireAuth(req, res);
1349
- if (!auth)
1350
- return true;
1351
- const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
1352
- const flashSuccess = url.searchParams.get('success') ?? undefined;
1353
- const flashError = url.searchParams.get('error') ?? undefined;
1354
- const plan = (auth.user.plan ?? 'free');
1355
- const limits = getPlanLimits(plan);
1356
- const usage = usageTracker.getUsageSummary(auth.user.id);
1357
- const quota = checkQuota(auth.user.id, plan, usage.totalSessionsMs);
1358
- const html = renderPage('pages/dashboard/billing', 'layouts/dashboard', {
1359
- metaTitle: 'Billing — mcpmake Cloud',
1360
- activePage: 'billing',
1361
- user: auth.user,
1362
- csrfToken: auth.session.csrfToken,
1363
- stripeConfigured: isStripeConfigured(),
1364
- currentPlan: plan,
1365
- minutesUsed: Math.round(usage.totalSessionsMs / 60_000),
1366
- minutesLimit: limits.maxMinutes === Infinity ? 'unlimited' : limits.maxMinutes,
1367
- minutesRemaining: quota.remaining === Infinity ? 'unlimited' : Math.round(quota.remaining),
1368
- hasSubscription: !!auth.user.stripeCustomerId,
1369
- flashSuccess,
1370
- flashError,
1371
- });
1372
- sendHtml(res, 200, html);
1373
- return true;
1374
- }
1375
- async function handleBillingCheckout(req, res) {
1376
- const auth = await requireAuth(req, res);
1377
- if (!auth)
1378
- return true;
1379
- const body = await parseFormBody(req);
1380
- if (!validateCsrf(req, body, auth.session)) {
1381
- redirect(res, '/dashboard/billing?error=' + encodeURIComponent('Invalid form submission.'));
1382
- return true;
1383
- }
1384
- const plan = body.plan;
1385
- const validPlans = new Set(['hobbyist', 'pro', 'team']);
1386
- if (!plan || !validPlans.has(plan)) {
1387
- redirect(res, '/dashboard/billing?error=' + encodeURIComponent('Invalid plan selected.'));
1388
- return true;
1389
- }
1390
- if (!isStripeConfigured()) {
1391
- redirect(res, '/dashboard/billing?error=' + encodeURIComponent('Payments are not configured.'));
1392
- return true;
1393
- }
1394
- try {
1395
- const host = req.headers.host ?? 'localhost';
1396
- const proto = process.env.TRUST_PROXY === 'true' && req.headers['x-forwarded-proto'] === 'https'
1397
- ? 'https'
1398
- : 'http';
1399
- const baseUrl = `${proto}://${host}`;
1400
- const checkoutUrl = await createCheckoutSession({
1401
- userId: auth.user.id,
1402
- email: auth.user.email,
1403
- plan,
1404
- successUrl: `${baseUrl}/dashboard/billing/success?plan=${encodeURIComponent(plan)}`,
1405
- cancelUrl: `${baseUrl}/dashboard/billing`,
1406
- });
1407
- redirect(res, checkoutUrl);
1408
- }
1409
- catch (err) {
1410
- const message = err instanceof Error ? err.message : String(err);
1411
- logger.error(`[billing] Checkout failed: ${message}`);
1412
- redirect(res, '/dashboard/billing?error=' +
1413
- encodeURIComponent('Failed to start checkout. Please try again.'));
1414
- }
1415
- return true;
1416
- }
1417
- async function handleBillingPortal(req, res) {
1418
- const auth = await requireAuth(req, res);
1419
- if (!auth)
1420
- return true;
1421
- const body = await parseFormBody(req);
1422
- if (!validateCsrf(req, body, auth.session)) {
1423
- redirect(res, '/dashboard/billing?error=' + encodeURIComponent('Invalid form submission.'));
1424
- return true;
1425
- }
1426
- if (!auth.user.stripeCustomerId) {
1427
- redirect(res, '/dashboard/billing?error=' + encodeURIComponent('No active subscription found.'));
1428
- return true;
1429
- }
1430
- try {
1431
- const host = req.headers.host ?? 'localhost';
1432
- const proto = process.env.TRUST_PROXY === 'true' && req.headers['x-forwarded-proto'] === 'https'
1433
- ? 'https'
1434
- : 'http';
1435
- const returnUrl = `${proto}://${host}/dashboard/billing`;
1436
- const portalUrl = await createPortalSession(auth.user.stripeCustomerId, returnUrl);
1437
- redirect(res, portalUrl);
1438
- }
1439
- catch (err) {
1440
- const message = err instanceof Error ? err.message : String(err);
1441
- logger.error(`[billing] Portal session failed: ${message}`);
1442
- redirect(res, '/dashboard/billing?error=' +
1443
- encodeURIComponent('Failed to open billing portal. Please try again.'));
1444
- }
1445
- return true;
1446
- }
1447
- async function handleBillingSuccess(req, res) {
1448
- const auth = await requireAuth(req, res);
1449
- if (!auth)
1450
- return true;
1451
- const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
1452
- const plan = url.searchParams.get('plan') ?? '';
1453
- redirect(res, '/dashboard/billing?success=' +
1454
- encodeURIComponent(plan
1455
- ? `Welcome to the ${plan.charAt(0).toUpperCase() + plan.slice(1)} plan! Your subscription is now active.`
1456
- : 'Your subscription is now active!'));
1457
- return true;
1458
- }
1459
- // ---------------------------------------------------------------------------
1460
- // Admin route handlers
1461
- // ---------------------------------------------------------------------------
1462
- async function gatherAdminStats(context) {
1463
- // Prefer the Postgres store when configured — `context.store` is the in-memory
1464
- // store, which is empty after a restart in Pg mode (the Pg-blindness bug).
1465
- const pg = getPgServerStore();
1466
- const allServers = pg ? await pg.list() : context.store.list();
1467
- const runningCount = allServers.filter((s) => s.status === 'running').length;
1468
- const mem = process.memoryUsage();
1469
- const uptimeSec = process.uptime();
1470
- const hours = Math.floor(uptimeSec / 3600);
1471
- const mins = Math.floor((uptimeSec % 3600) / 60);
1472
- const secs = Math.floor(uptimeSec % 60);
1473
- const uptimeStr = hours > 0 ? `${hours}h ${mins}m ${secs}s` : mins > 0 ? `${mins}m ${secs}s` : `${secs}s`;
1474
- // --- Charts: 24h history from durable metric samples ---
1475
- let samples = [];
1476
- try {
1477
- samples = await getMetricSampleStore().recent(24 * 3600 * 1000);
1478
- }
1479
- catch {
1480
- samples = [];
1481
- }
1482
- const dbAvailable = getDb() != null;
1483
- // History needs a database; with no DB and no samples, the in-memory ring is
1484
- // empty (or lost on restart) so render an explicit empty-state instead of a
1485
- // blank chart.
1486
- const hasHistory = samples.length > 0;
1487
- const historyEmpty = !dbAvailable && !hasHistory;
1488
- let charts = {};
1489
- if (hasHistory) {
1490
- charts = {
1491
- requestsErrors: renderDualLine(samples.map((s) => s.requests), samples.map((s) => s.errors5xx), { labelA: 'Requests / interval', labelB: '5xx errors', unit: '' }),
1492
- memDisk: renderDualLine(samples.map((s) => s.memUsedPct), samples.map((s) => s.diskUsedPct), { labelA: 'Memory used', labelB: 'Disk used', unit: '%' }),
1493
- users: renderSparkline(samples.map((s) => s.totalUsers), { label: 'Total users', color: 'var(--color-success, #10b981)' }),
1494
- servers: renderBars(samples.map((s) => s.totalServers), { label: 'Total servers' }),
1495
- };
1496
- }
1497
- // --- Live "last hour" sparkline from the in-memory failure tracker ---
1498
- const lastHourSeries = failureTracker.series(60);
1499
- const lastHourChart = renderSparkline(lastHourSeries.map((p) => p.total), { label: 'Requests / min (last hour)' });
1500
- // --- Failure rate (last hour) ---
1501
- const failWindow = failureTracker.window(60);
1502
- // --- Health panel ---
1503
- let reading;
1504
- try {
1505
- reading = await gatherResourceReading(runningCount, process.env.DATA_DIR ?? '/');
1506
- }
1507
- catch {
1508
- reading = { memUsedPct: 0, diskUsedPct: 0, activeContainers: runningCount };
1509
- }
1510
- const pressure = evaluatePressure(reading, DEFAULT_THRESHOLDS, INITIAL_PRESSURE_STATE);
1511
- const ramOk = reading.memUsedPct < DEFAULT_THRESHOLDS.memPct;
1512
- const diskOk = reading.diskUsedPct < DEFAULT_THRESHOLDS.diskPct;
1513
- const health = {
1514
- db: dbAvailable,
1515
- stripe: isStripeConfigured(),
1516
- backend: getContainerBackend().name,
1517
- ramOk,
1518
- diskOk,
1519
- memUsedPct: reading.memUsedPct.toFixed(0),
1520
- diskUsedPct: reading.diskUsedPct.toFixed(0),
1521
- alerts: pressure.alerts,
1522
- };
1523
- return {
1524
- stats: {
1525
- totalServers: allServers.length,
1526
- runningServers: runningCount,
1527
- totalUsers: await countUsers(),
1528
- portsAllocated: context.ports.allocatedCount,
1529
- },
1530
- failure: {
1531
- requests: failWindow.total,
1532
- serverErrorRatePct: failWindow.serverErrorRatePct.toFixed(1),
1533
- clientErrorRatePct: failWindow.clientErrorRatePct.toFixed(1),
1534
- },
1535
- health,
1536
- historyEmpty,
1537
- hasHistory,
1538
- charts,
1539
- lastHourChart,
1540
- systemInfo: {
1541
- memoryRss: (mem.rss / 1024 / 1024).toFixed(1),
1542
- memoryHeapUsed: (mem.heapUsed / 1024 / 1024).toFixed(1),
1543
- uptime: uptimeStr,
1544
- nodeVersion: process.version,
1545
- platform: `${os.platform()} ${os.arch()}`,
1546
- totalMem: (os.totalmem() / 1024 / 1024).toFixed(0),
1547
- freeMem: (os.freemem() / 1024 / 1024).toFixed(0),
1548
- },
1549
- };
1550
- }
1551
- async function handleAdminOverview(req, res, context) {
1552
- const auth = await requireAdmin(req, res);
1553
- if (!auth)
1554
- return true;
1555
- const data = await gatherAdminStats(context);
1556
- const html = renderPage('pages/admin/overview', 'layouts/admin', {
1557
- metaTitle: 'Admin Overview — mcpmake Cloud',
1558
- activePage: 'overview',
1559
- user: auth.user,
1560
- csrfToken: auth.session.csrfToken,
1561
- ...data,
1562
- });
1563
- sendHtml(res, 200, html);
1564
- return true;
1565
- }
1566
- async function handleAdminStatsPartial(req, res, context) {
1567
- const auth = await requireAdmin(req, res);
1568
- if (!auth)
1569
- return true;
1570
- const data = await gatherAdminStats(context);
1571
- const html = renderTemplate('partials/admin-stats', data);
1572
- sendHtml(res, 200, html);
1573
- return true;
1574
- }
1575
- async function handleAdminServers(req, res, context) {
1576
- const auth = await requireAdmin(req, res);
1577
- if (!auth)
1578
- return true;
1579
- const pg = getPgServerStore();
1580
- const allServers = pg ? await pg.list() : context.store.list();
1581
- const allUsers = await listUsers();
1582
- const userMap = new Map(allUsers.map((u) => [u.id, u.email]));
1583
- // Enrich server records with user email for display
1584
- const servers = allServers.map((s) => ({
1585
- ...s,
1586
- userEmail: s.userId ? (userMap.get(s.userId) ?? null) : null,
1587
- }));
1588
- const html = renderPage('pages/admin/servers', 'layouts/admin', {
1589
- metaTitle: 'Servers — Admin — mcpmake Cloud',
1590
- activePage: 'servers',
1591
- user: auth.user,
1592
- csrfToken: auth.session.csrfToken,
1593
- servers,
1594
- });
1595
- sendHtml(res, 200, html);
1596
- return true;
1597
- }
1598
- async function handleAdminServerDelete(req, res, context, slug) {
1599
- const auth = await requireAdmin(req, res);
1600
- if (!auth)
1601
- return true;
1602
- const body = await parseFormBody(req);
1603
- if (!validateCsrf(req, body, auth.session)) {
1604
- sendHtml(res, 403, '<h1>403 — Invalid CSRF token</h1>');
1605
- return true;
1606
- }
1607
- // Prefer the Pg store: the in-memory `store` is empty after a restart in Pg
1608
- // mode, so reading/deleting only in-memory would leave a ghost row that
1609
- // reappears on the next list (and leaks its port forever).
1610
- const pg = getPgServerStore();
1611
- const record = pg ? await pg.get(slug) : context.store.get(slug);
1612
- if (!record) {
1613
- redirect(res, '/admin/servers');
1614
- return true;
1615
- }
1616
- // Stop the container
1617
- try {
1618
- if (record.containerId) {
1619
- await getContainerBackend().stopContainer(record.containerId);
1620
- }
1621
- }
1622
- catch {
1623
- // Container may already be stopped or Docker unavailable
1624
- }
1625
- // No Caddy route to remove — routing is backend-authoritative (wildcard).
1626
- // Release port and delete record from both stores
1627
- context.ports.release(record.port);
1628
- if (pg)
1629
- await pg.delete(slug);
1630
- context.store.delete(slug);
1631
- redirect(res, '/admin/servers');
1632
- return true;
1633
- }
1634
- async function handleAdminUsers(req, res, context) {
1635
- const auth = await requireAdmin(req, res);
1636
- if (!auth)
1637
- return true;
1638
- const allUsers = await listUsers();
1639
- const pg = getPgServerStore();
1640
- const allServers = pg ? await pg.list() : context.store.list();
1641
- // Count servers per user
1642
- const serverCounts = new Map();
1643
- for (const s of allServers) {
1644
- if (s.userId) {
1645
- serverCounts.set(s.userId, (serverCounts.get(s.userId) ?? 0) + 1);
1646
- }
1647
- }
1648
- const users = allUsers.map(({ passwordHash: _, ...u }) => ({
1649
- ...u,
1650
- serverCount: serverCounts.get(u.id) ?? 0,
1651
- }));
1652
- const html = renderPage('pages/admin/users', 'layouts/admin', {
1653
- metaTitle: 'Users — Admin — mcpmake Cloud',
1654
- activePage: 'users',
1655
- user: auth.user,
1656
- csrfToken: auth.session.csrfToken,
1657
- users,
1658
- });
1659
- sendHtml(res, 200, html);
1660
- return true;
1661
- }
1662
- async function handleAdminUserPlan(req, res, userId) {
1663
- const auth = await requireAdmin(req, res);
1664
- if (!auth)
1665
- return true;
1666
- const body = await parseFormBody(req);
1667
- if (!validateCsrf(req, body, auth.session)) {
1668
- sendHtml(res, 403, '<h1>403 — Invalid CSRF token</h1>');
1669
- return true;
1670
- }
1671
- const validPlans = new Set(['free', 'hobbyist', 'pro', 'team', 'enterprise']);
1672
- const plan = body.plan;
1673
- if (!plan || !validPlans.has(plan)) {
1674
- sendHtml(res, 400, '<h1>400 — Invalid plan</h1>');
1675
- return true;
1676
- }
1677
- const target = await getUserById(userId);
1678
- if (!target) {
1679
- redirect(res, '/admin/users');
1680
- return true;
1681
- }
1682
- await updateUser(userId, { plan: plan });
1683
- // If htmx request, return 204 (no content, hx-swap="none")
1684
- if (req.headers['hx-request']) {
1685
- res.writeHead(204);
1686
- res.end();
1687
- return true;
1688
- }
1689
- redirect(res, '/admin/users');
1690
- return true;
1691
- }
1692
- async function handleAdminUserAdmin(req, res, userId) {
1693
- const auth = await requireAdmin(req, res);
1694
- if (!auth)
1695
- return true;
1696
- const body = await parseFormBody(req);
1697
- if (!validateCsrf(req, body, auth.session)) {
1698
- sendHtml(res, 403, '<h1>403 — Invalid CSRF token</h1>');
1699
- return true;
1700
- }
1701
- const target = await getUserById(userId);
1702
- if (!target) {
1703
- redirect(res, '/admin/users');
1704
- return true;
1705
- }
1706
- // Prevent admin from removing their own admin status
1707
- if (target.id === auth.user.id) {
1708
- sendHtml(res, 400, '<h1>400 — Cannot modify your own admin status</h1>');
1709
- return true;
1710
- }
1711
- await updateUser(userId, { isAdmin: !target.isAdmin });
1712
- if (req.headers['hx-request']) {
1713
- res.writeHead(204);
1714
- res.end();
1715
- return true;
1716
- }
1717
- redirect(res, '/admin/users');
1718
- return true;
1719
- }
1720
- async function handleAdminUserDelete(req, res, userId) {
1721
- const auth = await requireAdmin(req, res);
1722
- if (!auth)
1723
- return true;
1724
- const body = await parseFormBody(req);
1725
- if (!validateCsrf(req, body, auth.session)) {
1726
- sendHtml(res, 403, '<h1>403 — Invalid CSRF token</h1>');
1727
- return true;
1728
- }
1729
- // Prevent admin from deleting themselves
1730
- if (userId === auth.user.id) {
1731
- sendHtml(res, 400, '<h1>400 — Cannot delete your own account</h1>');
1732
- return true;
1733
- }
1734
- const target = await getUserById(userId);
1735
- if (!target) {
1736
- redirect(res, '/admin/users');
1737
- return true;
1738
- }
1739
- await deleteUser(userId);
1740
- redirect(res, '/admin/users');
1741
- return true;
1742
- }
1743
- /** Cap on a single grant/revoke amount — guards against typos / overflow. */
1744
- const MAX_CREDIT_AMOUNT = 100_000_000;
1745
- async function handleAdminUserEdit(req, res, context, userId) {
1746
- const auth = await requireAdmin(req, res);
1747
- if (!auth)
1748
- return true;
1749
- const target = await getUserById(userId);
1750
- if (!target) {
1751
- redirect(res, '/admin/users');
1752
- return true;
1753
- }
1754
- const credits = getCreditStore();
1755
- const balance = await credits.balance(userId);
1756
- const ledgerRaw = await credits.recentLedger(userId, 20);
1757
- const ledger = ledgerRaw.map((e) => ({
1758
- delta: e.delta,
1759
- deltaLabel: e.delta >= 0 ? `+${e.delta}` : String(e.delta),
1760
- isCredit: e.delta >= 0,
1761
- reason: e.reason,
1762
- balanceAfter: e.balanceAfter == null ? '—' : String(e.balanceAfter),
1763
- createdAt: e.createdAt,
1764
- }));
1765
- const pg = getPgServerStore();
1766
- const allServers = pg ? await pg.list() : context.store.list();
1767
- const serverCount = allServers.filter((s) => s.userId === userId).length;
1768
- const { passwordHash: _omit, ...safeUser } = target;
1769
- const html = renderPage('pages/admin/user-edit', 'layouts/admin', {
1770
- metaTitle: `${target.email} — Admin — mcpmake Cloud`,
1771
- activePage: 'users',
1772
- user: auth.user,
1773
- csrfToken: auth.session.csrfToken,
1774
- targetUser: safeUser,
1775
- balance,
1776
- ledger,
1777
- serverCount,
1778
- });
1779
- sendHtml(res, 200, html);
1780
- return true;
1781
- }
1782
- async function handleAdminUserCredits(req, res, _context, userId) {
1783
- const auth = await requireAdmin(req, res);
1784
- if (!auth)
1785
- return true;
1786
- const body = await parseFormBody(req);
1787
- if (!validateCsrf(req, body, auth.session)) {
1788
- sendHtml(res, 403, '<h1>403 — Invalid CSRF token</h1>');
1789
- return true;
1790
- }
1791
- const target = await getUserById(userId);
1792
- if (!target) {
1793
- redirect(res, '/admin/users');
1794
- return true;
1795
- }
1796
- const action = body.action;
1797
- if (action !== 'grant' && action !== 'revoke') {
1798
- sendHtml(res, 400, '<h1>400 — Invalid action</h1>');
1799
- return true;
1800
- }
1801
- // Validate amount: must be a positive integer within a sane bound.
1802
- const raw = (body.amount ?? '').trim();
1803
- if (!/^\d+$/.test(raw)) {
1804
- sendHtml(res, 400, '<h1>400 — Invalid amount</h1>');
1805
- return true;
1806
- }
1807
- const amount = Number(raw);
1808
- if (!Number.isInteger(amount) || amount <= 0 || amount > MAX_CREDIT_AMOUNT) {
1809
- sendHtml(res, 400, '<h1>400 — Invalid amount</h1>');
1810
- return true;
1811
- }
1812
- const credits = getCreditStore();
1813
- if (action === 'grant') {
1814
- await credits.grant(userId, amount, auth.user.id);
1815
- }
1816
- else {
1817
- await credits.revoke(userId, amount, auth.user.id);
1818
- }
1819
- redirect(res, `/admin/users/${encodeURIComponent(userId)}`);
1820
- return true;
1821
- }
1822
- async function handleAdminTelemetry(req, res) {
1823
- const auth = await requireAdmin(req, res);
1824
- if (!auth)
1825
- return true;
1826
- let groups = [];
1827
- try {
1828
- groups = await getTelemetryStore().recentGrouped(100);
1829
- }
1830
- catch {
1831
- groups = [];
1832
- }
1833
- const html = renderPage('pages/admin/telemetry', 'layouts/admin', {
1834
- metaTitle: 'Telemetry — Admin — mcpmake Cloud',
1835
- activePage: 'telemetry',
1836
- user: auth.user,
1837
- csrfToken: auth.session.csrfToken,
1838
- groups,
1839
- });
1840
- sendHtml(res, 200, html);
1841
- return true;
1842
- }
1843
- // ---------------------------------------------------------------------------
1844
- // Helpers
1845
- // ---------------------------------------------------------------------------
1846
- function sendHtml(res, status, html, extraHeaders) {
1847
- const body = Buffer.from(html, 'utf-8');
1848
- res.writeHead(status, {
1849
- 'Content-Type': 'text/html; charset=utf-8',
1850
- 'Content-Length': body.length,
1851
- 'Cache-Control': 'no-cache',
1852
- 'Content-Security-Policy': "default-src 'self'; script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'",
1853
- 'Referrer-Policy': 'same-origin',
1854
- 'X-Content-Type-Options': 'nosniff',
1855
- 'X-Frame-Options': 'DENY',
1856
- ...extraHeaders,
1857
- });
1858
- res.end(body);
1859
- }
1860
- /**
1861
- * Render a 500 error page. Exported for use in the main server error handler.
1862
- */
1863
- export function render500Page(res) {
1864
- try {
1865
- const html = renderPage('pages/errors/500', 'layouts/landing', {
1866
- metaTitle: '500 — Something went wrong — mcpmake Cloud',
1867
- });
1868
- sendHtml(res, 500, html);
1869
- }
1870
- catch {
1871
- // Fallback if template rendering itself fails
1872
- sendHtml(res, 500, '<h1>500 — Internal Server Error</h1>');
1873
- }
1874
- }
1875
- function redirect(res, location) {
1876
- res.writeHead(302, { Location: location });
1877
- res.end();
1878
- }
1879
- /**
1880
- * Validate email: must contain @ with at least one char before it,
1881
- * and a dot after the @.
1882
- */
1883
- function isValidEmail(email) {
1884
- const atIndex = email.indexOf('@');
1885
- if (atIndex < 1)
1886
- return false;
1887
- const domain = email.slice(atIndex + 1);
1888
- return domain.includes('.') && !domain.startsWith('.') && !domain.endsWith('.');
1889
- }
1890
- /**
1891
- * Parse URL-encoded form body from a POST request.
1892
- * Returns a plain object of field name -> value.
1893
- * Max body size: 1MB.
1894
- */
1895
- async function parseFormBody(req) {
1896
- return new Promise((resolve) => {
1897
- const chunks = [];
1898
- let totalSize = 0;
1899
- req.on('data', (chunk) => {
1900
- totalSize += chunk.length;
1901
- if (totalSize > MAX_FORM_BODY) {
1902
- req.destroy();
1903
- resolve({});
1904
- return;
1905
- }
1906
- chunks.push(chunk);
1907
- });
1908
- req.on('end', () => {
1909
- const raw = Buffer.concat(chunks).toString('utf-8');
1910
- const params = new URLSearchParams(raw);
1911
- const result = {};
1912
- for (const [key, value] of params) {
1913
- result[key] = value;
1914
- }
1915
- resolve(result);
1916
- });
1917
- req.on('error', () => {
1918
- resolve({});
1919
- });
1920
- });
1921
- }