mcpmake 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +691 -0
- package/bin/mcpmake.mjs +2 -0
- package/dist/analyzer/auth-detector.d.ts +12 -0
- package/dist/analyzer/auth-detector.js +142 -0
- package/dist/analyzer/dom-parser.d.ts +10 -0
- package/dist/analyzer/dom-parser.js +259 -0
- package/dist/analyzer/goal-crawler.d.ts +25 -0
- package/dist/analyzer/goal-crawler.js +177 -0
- package/dist/analyzer/hybrid-detector.d.ts +28 -0
- package/dist/analyzer/hybrid-detector.js +96 -0
- package/dist/analyzer/index.d.ts +12 -0
- package/dist/analyzer/index.js +8 -0
- package/dist/analyzer/screenshot-capture.d.ts +29 -0
- package/dist/analyzer/screenshot-capture.js +42 -0
- package/dist/analyzer/selector-builder.d.ts +19 -0
- package/dist/analyzer/selector-builder.js +199 -0
- package/dist/analyzer/semantic-analyzer.d.ts +13 -0
- package/dist/analyzer/semantic-analyzer.js +145 -0
- package/dist/analyzer/site-crawler.d.ts +38 -0
- package/dist/analyzer/site-crawler.js +235 -0
- package/dist/cloud/billing/billing-engine.d.ts +44 -0
- package/dist/cloud/billing/billing-engine.js +81 -0
- package/dist/cloud/billing/credit-store.d.ts +64 -0
- package/dist/cloud/billing/credit-store.js +168 -0
- package/dist/cloud/billing/index.d.ts +4 -0
- package/dist/cloud/billing/index.js +2 -0
- package/dist/cloud/billing/usage-store.d.ts +42 -0
- package/dist/cloud/billing/usage-store.js +85 -0
- package/dist/cloud/billing/usage-tracker.d.ts +38 -0
- package/dist/cloud/billing/usage-tracker.js +95 -0
- package/dist/cloud/build-pipeline.d.ts +39 -0
- package/dist/cloud/build-pipeline.js +310 -0
- package/dist/cloud/build-queue.d.ts +30 -0
- package/dist/cloud/build-queue.js +70 -0
- package/dist/cloud/caddy-manager.d.ts +18 -0
- package/dist/cloud/caddy-manager.js +97 -0
- package/dist/cloud/container-backend.d.ts +62 -0
- package/dist/cloud/container-backend.js +59 -0
- package/dist/cloud/container-manager.d.ts +64 -0
- package/dist/cloud/container-manager.js +301 -0
- package/dist/cloud/crypto.d.ts +27 -0
- package/dist/cloud/crypto.js +63 -0
- package/dist/cloud/db/index.d.ts +27 -0
- package/dist/cloud/db/index.js +53 -0
- package/dist/cloud/db/migrations.d.ts +12 -0
- package/dist/cloud/db/migrations.js +329 -0
- package/dist/cloud/db/pg-store.d.ts +45 -0
- package/dist/cloud/db/pg-store.js +336 -0
- package/dist/cloud/failure-tracker.d.ts +51 -0
- package/dist/cloud/failure-tracker.js +102 -0
- package/dist/cloud/idle-monitor.d.ts +30 -0
- package/dist/cloud/idle-monitor.js +70 -0
- package/dist/cloud/mailer.d.ts +21 -0
- package/dist/cloud/mailer.js +193 -0
- package/dist/cloud/mcp-proxy.d.ts +58 -0
- package/dist/cloud/mcp-proxy.js +203 -0
- package/dist/cloud/metric-samples.d.ts +43 -0
- package/dist/cloud/metric-samples.js +85 -0
- package/dist/cloud/metrics.d.ts +26 -0
- package/dist/cloud/metrics.js +59 -0
- package/dist/cloud/multipart.d.ts +26 -0
- package/dist/cloud/multipart.js +132 -0
- package/dist/cloud/observability.d.ts +27 -0
- package/dist/cloud/observability.js +98 -0
- package/dist/cloud/rate-limiter.d.ts +31 -0
- package/dist/cloud/rate-limiter.js +58 -0
- package/dist/cloud/request-security.d.ts +5 -0
- package/dist/cloud/request-security.js +74 -0
- package/dist/cloud/resource-monitor.d.ts +69 -0
- package/dist/cloud/resource-monitor.js +130 -0
- package/dist/cloud/secret-store.d.ts +38 -0
- package/dist/cloud/secret-store.js +103 -0
- package/dist/cloud/security.d.ts +26 -0
- package/dist/cloud/security.js +142 -0
- package/dist/cloud/server.d.ts +21 -0
- package/dist/cloud/server.js +1079 -0
- package/dist/cloud/shared-state.d.ts +72 -0
- package/dist/cloud/shared-state.js +159 -0
- package/dist/cloud/ssrf.d.ts +43 -0
- package/dist/cloud/ssrf.js +150 -0
- package/dist/cloud/store.d.ts +41 -0
- package/dist/cloud/store.js +75 -0
- package/dist/cloud/stripe.d.ts +78 -0
- package/dist/cloud/stripe.js +317 -0
- package/dist/cloud/telemetry-store.d.ts +53 -0
- package/dist/cloud/telemetry-store.js +108 -0
- package/dist/cloud/web/auth.d.ts +225 -0
- package/dist/cloud/web/auth.js +555 -0
- package/dist/cloud/web/charts.d.ts +70 -0
- package/dist/cloud/web/charts.js +178 -0
- package/dist/cloud/web/csrf.d.ts +14 -0
- package/dist/cloud/web/csrf.js +22 -0
- package/dist/cloud/web/docs.d.ts +40 -0
- package/dist/cloud/web/docs.js +174 -0
- package/dist/cloud/web/router.d.ts +25 -0
- package/dist/cloud/web/router.js +1921 -0
- package/dist/cloud/web/static/alpine.min.js +5 -0
- package/dist/cloud/web/static/favicon.svg +4 -0
- package/dist/cloud/web/static/htmx-sse.js +290 -0
- package/dist/cloud/web/static/htmx.min.js +1 -0
- package/dist/cloud/web/static/style.css +2683 -0
- package/dist/cloud/web/static-server.d.ts +13 -0
- package/dist/cloud/web/static-server.js +73 -0
- package/dist/cloud/web/template-engine.d.ts +27 -0
- package/dist/cloud/web/template-engine.js +146 -0
- package/dist/cloud/web/templates/layouts/admin.hbs +57 -0
- package/dist/cloud/web/templates/layouts/auth.hbs +138 -0
- package/dist/cloud/web/templates/layouts/base.hbs +16 -0
- package/dist/cloud/web/templates/layouts/dashboard.hbs +39 -0
- package/dist/cloud/web/templates/layouts/landing.hbs +82 -0
- package/dist/cloud/web/templates/pages/admin/overview.hbs +123 -0
- package/dist/cloud/web/templates/pages/admin/servers.hbs +129 -0
- package/dist/cloud/web/templates/pages/admin/telemetry.hbs +39 -0
- package/dist/cloud/web/templates/pages/admin/user-edit.hbs +91 -0
- package/dist/cloud/web/templates/pages/admin/users.hbs +179 -0
- package/dist/cloud/web/templates/pages/auth/forgot-password.hbs +25 -0
- package/dist/cloud/web/templates/pages/auth/login.hbs +33 -0
- package/dist/cloud/web/templates/pages/auth/register.hbs +32 -0
- package/dist/cloud/web/templates/pages/auth/reset-password.hbs +34 -0
- package/dist/cloud/web/templates/pages/dashboard/billing.hbs +140 -0
- package/dist/cloud/web/templates/pages/dashboard/create.hbs +173 -0
- package/dist/cloud/web/templates/pages/dashboard/index.hbs +8 -0
- package/dist/cloud/web/templates/pages/dashboard/server-detail.hbs +280 -0
- package/dist/cloud/web/templates/pages/dashboard/server-logs.hbs +35 -0
- package/dist/cloud/web/templates/pages/dashboard/server-metrics.hbs +63 -0
- package/dist/cloud/web/templates/pages/dashboard/servers-partial.hbs +21 -0
- package/dist/cloud/web/templates/pages/dashboard/servers.hbs +44 -0
- package/dist/cloud/web/templates/pages/docs/show.hbs +16 -0
- package/dist/cloud/web/templates/pages/errors/404.hbs +9 -0
- package/dist/cloud/web/templates/pages/errors/500.hbs +8 -0
- package/dist/cloud/web/templates/pages/landing/index.hbs +223 -0
- package/dist/cloud/web/templates/pages/legal/privacy.hbs +71 -0
- package/dist/cloud/web/templates/pages/legal/terms.hbs +73 -0
- package/dist/cloud/web/templates/partials/admin-stats.hbs +52 -0
- package/dist/cloud/web/templates/partials/flash-message.hbs +6 -0
- package/dist/cloud/web/templates/partials/pricing-table.hbs +103 -0
- package/dist/cloud/web/templates/partials/server-card.hbs +19 -0
- package/dist/cloud/web/templates/partials/status-badge.hbs +1 -0
- package/dist/commands/bundle.d.ts +18 -0
- package/dist/commands/bundle.js +82 -0
- package/dist/commands/ci.d.ts +25 -0
- package/dist/commands/ci.js +149 -0
- package/dist/commands/deploy.d.ts +24 -0
- package/dist/commands/deploy.js +145 -0
- package/dist/commands/diff.d.ts +18 -0
- package/dist/commands/diff.js +185 -0
- package/dist/commands/from/describe.d.ts +65 -0
- package/dist/commands/from/describe.js +173 -0
- package/dist/commands/from/har.d.ts +81 -0
- package/dist/commands/from/har.js +255 -0
- package/dist/commands/from/openapi.d.ts +105 -0
- package/dist/commands/from/openapi.js +302 -0
- package/dist/commands/from/postman.d.ts +51 -0
- package/dist/commands/from/postman.js +146 -0
- package/dist/commands/from/target-support.d.ts +11 -0
- package/dist/commands/from/target-support.js +33 -0
- package/dist/commands/from/url.d.ts +75 -0
- package/dist/commands/from/url.js +244 -0
- package/dist/commands/from/website.d.ts +75 -0
- package/dist/commands/from/website.js +284 -0
- package/dist/commands/lint.d.ts +24 -0
- package/dist/commands/lint.js +184 -0
- package/dist/commands/merge.d.ts +18 -0
- package/dist/commands/merge.js +161 -0
- package/dist/commands/publish.d.ts +27 -0
- package/dist/commands/publish.js +334 -0
- package/dist/commands/rescan.d.ts +40 -0
- package/dist/commands/rescan.js +255 -0
- package/dist/commands/update.d.ts +14 -0
- package/dist/commands/update.js +87 -0
- package/dist/commands/verify.d.ts +14 -0
- package/dist/commands/verify.js +71 -0
- package/dist/config/configurable-command.d.ts +13 -0
- package/dist/config/configurable-command.js +70 -0
- package/dist/config/mcpmake-config.d.ts +68 -0
- package/dist/config/mcpmake-config.js +207 -0
- package/dist/docs/cli.md +400 -0
- package/dist/docs/mcp-2026-07-28-migration.md +78 -0
- package/dist/docs/migrate-from-stainless.md +94 -0
- package/dist/docs/quickstart.md +166 -0
- package/dist/docs/show-hn.md +26 -0
- package/dist/docs/website-servers.md +169 -0
- package/dist/emitter/code-writer.d.ts +8 -0
- package/dist/emitter/code-writer.js +25 -0
- package/dist/emitter/index.d.ts +32 -0
- package/dist/emitter/index.js +280 -0
- package/dist/emitter/mcpb-bundler.d.ts +31 -0
- package/dist/emitter/mcpb-bundler.js +172 -0
- package/dist/emitter/project-scaffolder.d.ts +4 -0
- package/dist/emitter/project-scaffolder.js +89 -0
- package/dist/emitter/python-template-loader.d.ts +4 -0
- package/dist/emitter/python-template-loader.js +30 -0
- package/dist/emitter/python-templates/dockerfile.hbs +14 -0
- package/dist/emitter/python-templates/env.example.hbs +6 -0
- package/dist/emitter/python-templates/requirements.txt.hbs +4 -0
- package/dist/emitter/python-templates/server.py.hbs +77 -0
- package/dist/emitter/site-scaffolder.d.ts +13 -0
- package/dist/emitter/site-scaffolder.js +70 -0
- package/dist/emitter/site-template-loader.d.ts +5 -0
- package/dist/emitter/site-template-loader.js +47 -0
- package/dist/emitter/site-templates/browser-manager.ts.hbs +233 -0
- package/dist/emitter/site-templates/config.ts.hbs +28 -0
- package/dist/emitter/site-templates/dockerfile.hbs +31 -0
- package/dist/emitter/site-templates/env.example.hbs +19 -0
- package/dist/emitter/site-templates/package.json.hbs +26 -0
- package/dist/emitter/site-templates/server-main-http.ts.hbs +108 -0
- package/dist/emitter/site-templates/server-main.ts.hbs +23 -0
- package/dist/emitter/site-templates/tool-handler-action.ts.hbs +86 -0
- package/dist/emitter/site-templates/tool-handler-form.ts.hbs +116 -0
- package/dist/emitter/site-templates/tool-handler-lifecycle.ts.hbs +146 -0
- package/dist/emitter/site-templates/tool-index.ts.hbs +11 -0
- package/dist/emitter/template-loader.d.ts +1 -0
- package/dist/emitter/template-loader.js +27 -0
- package/dist/emitter/templates/auth-provider.ts.hbs +57 -0
- package/dist/emitter/templates/config.ts.hbs +63 -0
- package/dist/emitter/templates/discovery.ts.hbs +301 -0
- package/dist/emitter/templates/dockerfile.hbs +34 -0
- package/dist/emitter/templates/env.example.hbs +28 -0
- package/dist/emitter/templates/gitignore.hbs +5 -0
- package/dist/emitter/templates/http-executor.ts.hbs +117 -0
- package/dist/emitter/templates/oauth.ts.hbs +188 -0
- package/dist/emitter/templates/package.json.hbs +25 -0
- package/dist/emitter/templates/prompts.ts.hbs +22 -0
- package/dist/emitter/templates/readme.md.hbs +123 -0
- package/dist/emitter/templates/resources.ts.hbs +63 -0
- package/dist/emitter/templates/server-main-http.ts.hbs +407 -0
- package/dist/emitter/templates/server-main.ts.hbs +40 -0
- package/dist/emitter/templates/task-handlers.ts.hbs +189 -0
- package/dist/emitter/templates/task-manager.ts.hbs +139 -0
- package/dist/emitter/templates/task-sse.ts.hbs +105 -0
- package/dist/emitter/templates/tool-handler.ts.hbs +124 -0
- package/dist/emitter/templates/tool-index.ts.hbs +11 -0
- package/dist/emitter/templates/tool-test.ts.hbs +57 -0
- package/dist/emitter/templates/trace.ts.hbs +79 -0
- package/dist/emitter/templates/tsconfig.json.hbs +16 -0
- package/dist/emitter/templates/types.ts.hbs +5 -0
- package/dist/emitter/worker-template-loader.d.ts +5 -0
- package/dist/emitter/worker-template-loader.js +33 -0
- package/dist/emitter/worker-templates/config.ts.hbs +54 -0
- package/dist/emitter/worker-templates/dev-vars.example.hbs +10 -0
- package/dist/emitter/worker-templates/gitignore.hbs +6 -0
- package/dist/emitter/worker-templates/package.json.hbs +24 -0
- package/dist/emitter/worker-templates/readme.md.hbs +53 -0
- package/dist/emitter/worker-templates/server.test.ts.hbs +20 -0
- package/dist/emitter/worker-templates/tool-handler.ts.hbs +85 -0
- package/dist/emitter/worker-templates/tool-index.ts.hbs +28 -0
- package/dist/emitter/worker-templates/tsconfig.json.hbs +17 -0
- package/dist/emitter/worker-templates/worker.ts.hbs +242 -0
- package/dist/emitter/worker-templates/wrangler.toml.hbs +19 -0
- package/dist/generator/spec-generator.d.ts +6 -0
- package/dist/generator/spec-generator.js +50 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +64 -0
- package/dist/parser/har-filter.d.ts +8 -0
- package/dist/parser/har-filter.js +71 -0
- package/dist/parser/har-loader.d.ts +2 -0
- package/dist/parser/har-loader.js +14 -0
- package/dist/parser/har-normalizer.d.ts +20 -0
- package/dist/parser/har-normalizer.js +78 -0
- package/dist/parser/index.d.ts +10 -0
- package/dist/parser/index.js +6 -0
- package/dist/parser/openapi-loader.d.ts +6 -0
- package/dist/parser/openapi-loader.js +308 -0
- package/dist/parser/operation-extractor.d.ts +13 -0
- package/dist/parser/operation-extractor.js +155 -0
- package/dist/parser/overlay-loader.d.ts +10 -0
- package/dist/parser/overlay-loader.js +184 -0
- package/dist/parser/postman-loader.d.ts +9 -0
- package/dist/parser/postman-loader.js +106 -0
- package/dist/parser/schema-converter.d.ts +12 -0
- package/dist/parser/schema-converter.js +117 -0
- package/dist/plugins/adapter.d.ts +40 -0
- package/dist/plugins/adapter.js +15 -0
- package/dist/plugins/loader.d.ts +25 -0
- package/dist/plugins/loader.js +58 -0
- package/dist/pricing.d.ts +55 -0
- package/dist/pricing.js +133 -0
- package/dist/providers/index.d.ts +15 -0
- package/dist/providers/index.js +56 -0
- package/dist/recorder/browser-recorder.d.ts +22 -0
- package/dist/recorder/browser-recorder.js +205 -0
- package/dist/registry/official-registry.d.ts +90 -0
- package/dist/registry/official-registry.js +129 -0
- package/dist/rescan/diff-engine.d.ts +5 -0
- package/dist/rescan/diff-engine.js +312 -0
- package/dist/rescan/index.d.ts +3 -0
- package/dist/rescan/index.js +2 -0
- package/dist/rescan/rescan-runner.d.ts +42 -0
- package/dist/rescan/rescan-runner.js +69 -0
- package/dist/rescan/rescan-scheduler.d.ts +41 -0
- package/dist/rescan/rescan-scheduler.js +179 -0
- package/dist/site-transformer/browser-tools.d.ts +10 -0
- package/dist/site-transformer/browser-tools.js +59 -0
- package/dist/site-transformer/index.d.ts +2 -0
- package/dist/site-transformer/index.js +2 -0
- package/dist/site-transformer/selector-healer.d.ts +8 -0
- package/dist/site-transformer/selector-healer.js +106 -0
- package/dist/site-transformer/tool-generator.d.ts +13 -0
- package/dist/site-transformer/tool-generator.js +245 -0
- package/dist/transformer/auth-detector.d.ts +13 -0
- package/dist/transformer/auth-detector.js +90 -0
- package/dist/transformer/catalog-builder.d.ts +18 -0
- package/dist/transformer/catalog-builder.js +56 -0
- package/dist/transformer/client-compat.d.ts +6 -0
- package/dist/transformer/client-compat.js +44 -0
- package/dist/transformer/har-clusterer.d.ts +9 -0
- package/dist/transformer/har-clusterer.js +27 -0
- package/dist/transformer/har-dedup.d.ts +10 -0
- package/dist/transformer/har-dedup.js +81 -0
- package/dist/transformer/har-schema-inferrer.d.ts +15 -0
- package/dist/transformer/har-schema-inferrer.js +90 -0
- package/dist/transformer/har-to-operations.d.ts +13 -0
- package/dist/transformer/har-to-operations.js +192 -0
- package/dist/transformer/index.d.ts +8 -0
- package/dist/transformer/index.js +6 -0
- package/dist/transformer/llm-namer.d.ts +6 -0
- package/dist/transformer/llm-namer.js +59 -0
- package/dist/transformer/naming.d.ts +4 -0
- package/dist/transformer/naming.js +30 -0
- package/dist/transformer/operation-filter.d.ts +13 -0
- package/dist/transformer/operation-filter.js +52 -0
- package/dist/transformer/resource-builder.d.ts +12 -0
- package/dist/transformer/resource-builder.js +80 -0
- package/dist/transformer/schema-merger.d.ts +14 -0
- package/dist/transformer/schema-merger.js +65 -0
- package/dist/transformer/tool-builder.d.ts +3 -0
- package/dist/transformer/tool-builder.js +114 -0
- package/dist/types/index.d.ts +131 -0
- package/dist/types/index.js +1 -0
- package/dist/types/site.d.ts +284 -0
- package/dist/types/site.js +8 -0
- package/dist/utils/fail.d.ts +48 -0
- package/dist/utils/fail.js +204 -0
- package/dist/utils/fs.d.ts +5 -0
- package/dist/utils/fs.js +28 -0
- package/dist/utils/interactive.d.ts +6 -0
- package/dist/utils/interactive.js +30 -0
- package/dist/utils/logger.d.ts +1 -0
- package/dist/utils/logger.js +2 -0
- package/dist/utils/sanitize.d.ts +28 -0
- package/dist/utils/sanitize.js +44 -0
- package/dist/utils/watcher.d.ts +11 -0
- package/dist/utils/watcher.js +36 -0
- package/package.json +65 -0
|
@@ -0,0 +1,1921 @@
|
|
|
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
|
+
}
|