mcpmake 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/bundle.d.ts +1 -0
- package/dist/commands/bundle.d.ts.map +1 -0
- package/dist/commands/bundle.js +5 -4
- package/dist/commands/bundle.js.map +1 -0
- package/dist/commands/ci.d.ts +1 -0
- package/dist/commands/ci.d.ts.map +1 -0
- package/dist/commands/ci.js +3 -2
- package/dist/commands/ci.js.map +1 -0
- package/dist/commands/deploy.d.ts +1 -0
- package/dist/commands/deploy.d.ts.map +1 -0
- package/dist/commands/deploy.js +4 -3
- package/dist/commands/deploy.js.map +1 -0
- package/dist/commands/diff.d.ts +1 -0
- package/dist/commands/diff.d.ts.map +1 -0
- package/dist/commands/diff.js +5 -4
- package/dist/commands/diff.js.map +1 -0
- package/dist/commands/from/describe.d.ts +1 -0
- package/dist/commands/from/describe.d.ts.map +1 -0
- package/dist/commands/from/describe.js +11 -10
- package/dist/commands/from/describe.js.map +1 -0
- package/dist/commands/from/har.d.ts +1 -0
- package/dist/commands/from/har.d.ts.map +1 -0
- package/dist/commands/from/har.js +14 -13
- package/dist/commands/from/har.js.map +1 -0
- package/dist/commands/from/openapi.d.ts +1 -0
- package/dist/commands/from/openapi.d.ts.map +1 -0
- package/dist/commands/from/openapi.js +17 -16
- package/dist/commands/from/openapi.js.map +1 -0
- package/dist/commands/from/postman.d.ts +1 -0
- package/dist/commands/from/postman.d.ts.map +1 -0
- package/dist/commands/from/postman.js +13 -12
- package/dist/commands/from/postman.js.map +1 -0
- package/dist/commands/from/stainless.d.ts +110 -0
- package/dist/commands/from/stainless.d.ts.map +1 -0
- package/dist/commands/from/stainless.js +272 -0
- package/dist/commands/from/stainless.js.map +1 -0
- package/dist/commands/from/target-support.d.ts +1 -0
- package/dist/commands/from/target-support.d.ts.map +1 -0
- package/dist/commands/from/target-support.js +2 -1
- package/dist/commands/from/target-support.js.map +1 -0
- package/dist/commands/from/url.d.ts +1 -0
- package/dist/commands/from/url.d.ts.map +1 -0
- package/dist/commands/from/url.js +14 -13
- package/dist/commands/from/url.js.map +1 -0
- package/dist/commands/from/website.d.ts +1 -0
- package/dist/commands/from/website.d.ts.map +1 -0
- package/dist/commands/from/website.js +17 -16
- package/dist/commands/from/website.js.map +1 -0
- package/dist/commands/lint.d.ts +1 -0
- package/dist/commands/lint.d.ts.map +1 -0
- package/dist/commands/lint.js +6 -5
- package/dist/commands/lint.js.map +1 -0
- package/dist/commands/merge.d.ts +1 -0
- package/dist/commands/merge.d.ts.map +1 -0
- package/dist/commands/merge.js +3 -2
- package/dist/commands/merge.js.map +1 -0
- package/dist/commands/publish.d.ts +1 -0
- package/dist/commands/publish.d.ts.map +1 -0
- package/dist/commands/publish.js +4 -3
- package/dist/commands/publish.js.map +1 -0
- package/dist/commands/rescan.d.ts +1 -0
- package/dist/commands/rescan.d.ts.map +1 -0
- package/dist/commands/rescan.js +12 -11
- package/dist/commands/rescan.js.map +1 -0
- package/dist/commands/update.d.ts +1 -0
- package/dist/commands/update.d.ts.map +1 -0
- package/dist/commands/update.js +10 -9
- package/dist/commands/update.js.map +1 -0
- package/dist/commands/verify.d.ts +1 -0
- package/dist/commands/verify.d.ts.map +1 -0
- package/dist/commands/verify.js +7 -6
- package/dist/commands/verify.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +23 -2
- package/dist/index.js.map +1 -0
- package/dist/registry/official-registry.d.ts +1 -0
- package/dist/registry/official-registry.d.ts.map +1 -0
- package/dist/registry/official-registry.js +1 -0
- package/dist/registry/official-registry.js.map +1 -0
- package/package.json +20 -46
- package/README.md +0 -691
- package/dist/analyzer/auth-detector.d.ts +0 -12
- package/dist/analyzer/auth-detector.js +0 -142
- package/dist/analyzer/dom-parser.d.ts +0 -10
- package/dist/analyzer/dom-parser.js +0 -259
- package/dist/analyzer/goal-crawler.d.ts +0 -25
- package/dist/analyzer/goal-crawler.js +0 -177
- package/dist/analyzer/hybrid-detector.d.ts +0 -28
- package/dist/analyzer/hybrid-detector.js +0 -96
- package/dist/analyzer/index.d.ts +0 -12
- package/dist/analyzer/index.js +0 -8
- package/dist/analyzer/screenshot-capture.d.ts +0 -29
- package/dist/analyzer/screenshot-capture.js +0 -42
- package/dist/analyzer/selector-builder.d.ts +0 -19
- package/dist/analyzer/selector-builder.js +0 -199
- package/dist/analyzer/semantic-analyzer.d.ts +0 -13
- package/dist/analyzer/semantic-analyzer.js +0 -145
- package/dist/analyzer/site-crawler.d.ts +0 -38
- package/dist/analyzer/site-crawler.js +0 -235
- package/dist/cloud/billing/billing-engine.d.ts +0 -44
- package/dist/cloud/billing/billing-engine.js +0 -81
- package/dist/cloud/billing/credit-store.d.ts +0 -64
- package/dist/cloud/billing/credit-store.js +0 -168
- package/dist/cloud/billing/index.d.ts +0 -4
- package/dist/cloud/billing/index.js +0 -2
- package/dist/cloud/billing/usage-store.d.ts +0 -42
- package/dist/cloud/billing/usage-store.js +0 -85
- package/dist/cloud/billing/usage-tracker.d.ts +0 -38
- package/dist/cloud/billing/usage-tracker.js +0 -95
- package/dist/cloud/build-pipeline.d.ts +0 -39
- package/dist/cloud/build-pipeline.js +0 -310
- package/dist/cloud/build-queue.d.ts +0 -30
- package/dist/cloud/build-queue.js +0 -70
- package/dist/cloud/caddy-manager.d.ts +0 -18
- package/dist/cloud/caddy-manager.js +0 -97
- package/dist/cloud/container-backend.d.ts +0 -62
- package/dist/cloud/container-backend.js +0 -59
- package/dist/cloud/container-manager.d.ts +0 -64
- package/dist/cloud/container-manager.js +0 -301
- package/dist/cloud/crypto.d.ts +0 -27
- package/dist/cloud/crypto.js +0 -63
- package/dist/cloud/db/index.d.ts +0 -27
- package/dist/cloud/db/index.js +0 -53
- package/dist/cloud/db/migrations.d.ts +0 -12
- package/dist/cloud/db/migrations.js +0 -329
- package/dist/cloud/db/pg-store.d.ts +0 -45
- package/dist/cloud/db/pg-store.js +0 -336
- package/dist/cloud/failure-tracker.d.ts +0 -51
- package/dist/cloud/failure-tracker.js +0 -102
- package/dist/cloud/idle-monitor.d.ts +0 -30
- package/dist/cloud/idle-monitor.js +0 -70
- package/dist/cloud/mailer.d.ts +0 -21
- package/dist/cloud/mailer.js +0 -193
- package/dist/cloud/mcp-proxy.d.ts +0 -58
- package/dist/cloud/mcp-proxy.js +0 -203
- package/dist/cloud/metric-samples.d.ts +0 -43
- package/dist/cloud/metric-samples.js +0 -85
- package/dist/cloud/metrics.d.ts +0 -26
- package/dist/cloud/metrics.js +0 -59
- package/dist/cloud/multipart.d.ts +0 -26
- package/dist/cloud/multipart.js +0 -132
- package/dist/cloud/observability.d.ts +0 -27
- package/dist/cloud/observability.js +0 -98
- package/dist/cloud/rate-limiter.d.ts +0 -31
- package/dist/cloud/rate-limiter.js +0 -58
- package/dist/cloud/request-security.d.ts +0 -5
- package/dist/cloud/request-security.js +0 -74
- package/dist/cloud/resource-monitor.d.ts +0 -69
- package/dist/cloud/resource-monitor.js +0 -130
- package/dist/cloud/secret-store.d.ts +0 -38
- package/dist/cloud/secret-store.js +0 -103
- package/dist/cloud/security.d.ts +0 -26
- package/dist/cloud/security.js +0 -142
- package/dist/cloud/server.d.ts +0 -21
- package/dist/cloud/server.js +0 -1079
- package/dist/cloud/shared-state.d.ts +0 -72
- package/dist/cloud/shared-state.js +0 -159
- package/dist/cloud/ssrf.d.ts +0 -43
- package/dist/cloud/ssrf.js +0 -150
- package/dist/cloud/store.d.ts +0 -41
- package/dist/cloud/store.js +0 -75
- package/dist/cloud/stripe.d.ts +0 -78
- package/dist/cloud/stripe.js +0 -317
- package/dist/cloud/telemetry-store.d.ts +0 -53
- package/dist/cloud/telemetry-store.js +0 -108
- package/dist/cloud/web/auth.d.ts +0 -225
- package/dist/cloud/web/auth.js +0 -555
- package/dist/cloud/web/charts.d.ts +0 -70
- package/dist/cloud/web/charts.js +0 -178
- package/dist/cloud/web/csrf.d.ts +0 -14
- package/dist/cloud/web/csrf.js +0 -22
- package/dist/cloud/web/docs.d.ts +0 -40
- package/dist/cloud/web/docs.js +0 -174
- package/dist/cloud/web/router.d.ts +0 -25
- package/dist/cloud/web/router.js +0 -1921
- package/dist/cloud/web/static/alpine.min.js +0 -5
- package/dist/cloud/web/static/favicon.svg +0 -4
- package/dist/cloud/web/static/htmx-sse.js +0 -290
- package/dist/cloud/web/static/htmx.min.js +0 -1
- package/dist/cloud/web/static/style.css +0 -2683
- package/dist/cloud/web/static-server.d.ts +0 -13
- package/dist/cloud/web/static-server.js +0 -73
- package/dist/cloud/web/template-engine.d.ts +0 -27
- package/dist/cloud/web/template-engine.js +0 -146
- package/dist/cloud/web/templates/layouts/admin.hbs +0 -57
- package/dist/cloud/web/templates/layouts/auth.hbs +0 -138
- package/dist/cloud/web/templates/layouts/base.hbs +0 -16
- package/dist/cloud/web/templates/layouts/dashboard.hbs +0 -39
- package/dist/cloud/web/templates/layouts/landing.hbs +0 -82
- package/dist/cloud/web/templates/pages/admin/overview.hbs +0 -123
- package/dist/cloud/web/templates/pages/admin/servers.hbs +0 -129
- package/dist/cloud/web/templates/pages/admin/telemetry.hbs +0 -39
- package/dist/cloud/web/templates/pages/admin/user-edit.hbs +0 -91
- package/dist/cloud/web/templates/pages/admin/users.hbs +0 -179
- package/dist/cloud/web/templates/pages/auth/forgot-password.hbs +0 -25
- package/dist/cloud/web/templates/pages/auth/login.hbs +0 -33
- package/dist/cloud/web/templates/pages/auth/register.hbs +0 -32
- package/dist/cloud/web/templates/pages/auth/reset-password.hbs +0 -34
- package/dist/cloud/web/templates/pages/dashboard/billing.hbs +0 -140
- package/dist/cloud/web/templates/pages/dashboard/create.hbs +0 -173
- package/dist/cloud/web/templates/pages/dashboard/index.hbs +0 -8
- package/dist/cloud/web/templates/pages/dashboard/server-detail.hbs +0 -280
- package/dist/cloud/web/templates/pages/dashboard/server-logs.hbs +0 -35
- package/dist/cloud/web/templates/pages/dashboard/server-metrics.hbs +0 -63
- package/dist/cloud/web/templates/pages/dashboard/servers-partial.hbs +0 -21
- package/dist/cloud/web/templates/pages/dashboard/servers.hbs +0 -44
- package/dist/cloud/web/templates/pages/docs/show.hbs +0 -16
- package/dist/cloud/web/templates/pages/errors/404.hbs +0 -9
- package/dist/cloud/web/templates/pages/errors/500.hbs +0 -8
- package/dist/cloud/web/templates/pages/landing/index.hbs +0 -223
- package/dist/cloud/web/templates/pages/legal/privacy.hbs +0 -71
- package/dist/cloud/web/templates/pages/legal/terms.hbs +0 -73
- package/dist/cloud/web/templates/partials/admin-stats.hbs +0 -52
- package/dist/cloud/web/templates/partials/flash-message.hbs +0 -6
- package/dist/cloud/web/templates/partials/pricing-table.hbs +0 -103
- package/dist/cloud/web/templates/partials/server-card.hbs +0 -19
- package/dist/cloud/web/templates/partials/status-badge.hbs +0 -1
- package/dist/config/configurable-command.d.ts +0 -13
- package/dist/config/configurable-command.js +0 -70
- package/dist/config/mcpmake-config.d.ts +0 -68
- package/dist/config/mcpmake-config.js +0 -207
- package/dist/docs/cli.md +0 -400
- package/dist/docs/mcp-2026-07-28-migration.md +0 -78
- package/dist/docs/migrate-from-stainless.md +0 -94
- package/dist/docs/quickstart.md +0 -166
- package/dist/docs/show-hn.md +0 -26
- package/dist/docs/website-servers.md +0 -169
- package/dist/emitter/code-writer.d.ts +0 -8
- package/dist/emitter/code-writer.js +0 -25
- package/dist/emitter/index.d.ts +0 -32
- package/dist/emitter/index.js +0 -280
- package/dist/emitter/mcpb-bundler.d.ts +0 -31
- package/dist/emitter/mcpb-bundler.js +0 -172
- package/dist/emitter/project-scaffolder.d.ts +0 -4
- package/dist/emitter/project-scaffolder.js +0 -89
- package/dist/emitter/python-template-loader.d.ts +0 -4
- package/dist/emitter/python-template-loader.js +0 -30
- package/dist/emitter/python-templates/dockerfile.hbs +0 -14
- package/dist/emitter/python-templates/env.example.hbs +0 -6
- package/dist/emitter/python-templates/requirements.txt.hbs +0 -4
- package/dist/emitter/python-templates/server.py.hbs +0 -77
- package/dist/emitter/site-scaffolder.d.ts +0 -13
- package/dist/emitter/site-scaffolder.js +0 -70
- package/dist/emitter/site-template-loader.d.ts +0 -5
- package/dist/emitter/site-template-loader.js +0 -47
- package/dist/emitter/site-templates/browser-manager.ts.hbs +0 -233
- package/dist/emitter/site-templates/config.ts.hbs +0 -28
- package/dist/emitter/site-templates/dockerfile.hbs +0 -31
- package/dist/emitter/site-templates/env.example.hbs +0 -19
- package/dist/emitter/site-templates/package.json.hbs +0 -26
- package/dist/emitter/site-templates/server-main-http.ts.hbs +0 -108
- package/dist/emitter/site-templates/server-main.ts.hbs +0 -23
- package/dist/emitter/site-templates/tool-handler-action.ts.hbs +0 -86
- package/dist/emitter/site-templates/tool-handler-form.ts.hbs +0 -116
- package/dist/emitter/site-templates/tool-handler-lifecycle.ts.hbs +0 -146
- package/dist/emitter/site-templates/tool-index.ts.hbs +0 -11
- package/dist/emitter/template-loader.d.ts +0 -1
- package/dist/emitter/template-loader.js +0 -27
- package/dist/emitter/templates/auth-provider.ts.hbs +0 -57
- package/dist/emitter/templates/config.ts.hbs +0 -63
- package/dist/emitter/templates/discovery.ts.hbs +0 -301
- package/dist/emitter/templates/dockerfile.hbs +0 -34
- package/dist/emitter/templates/env.example.hbs +0 -28
- package/dist/emitter/templates/gitignore.hbs +0 -5
- package/dist/emitter/templates/http-executor.ts.hbs +0 -117
- package/dist/emitter/templates/oauth.ts.hbs +0 -188
- package/dist/emitter/templates/package.json.hbs +0 -25
- package/dist/emitter/templates/prompts.ts.hbs +0 -22
- package/dist/emitter/templates/readme.md.hbs +0 -123
- package/dist/emitter/templates/resources.ts.hbs +0 -63
- package/dist/emitter/templates/server-main-http.ts.hbs +0 -407
- package/dist/emitter/templates/server-main.ts.hbs +0 -40
- package/dist/emitter/templates/task-handlers.ts.hbs +0 -189
- package/dist/emitter/templates/task-manager.ts.hbs +0 -139
- package/dist/emitter/templates/task-sse.ts.hbs +0 -105
- package/dist/emitter/templates/tool-handler.ts.hbs +0 -124
- package/dist/emitter/templates/tool-index.ts.hbs +0 -11
- package/dist/emitter/templates/tool-test.ts.hbs +0 -57
- package/dist/emitter/templates/trace.ts.hbs +0 -79
- package/dist/emitter/templates/tsconfig.json.hbs +0 -16
- package/dist/emitter/templates/types.ts.hbs +0 -5
- package/dist/emitter/worker-template-loader.d.ts +0 -5
- package/dist/emitter/worker-template-loader.js +0 -33
- package/dist/emitter/worker-templates/config.ts.hbs +0 -54
- package/dist/emitter/worker-templates/dev-vars.example.hbs +0 -10
- package/dist/emitter/worker-templates/gitignore.hbs +0 -6
- package/dist/emitter/worker-templates/package.json.hbs +0 -24
- package/dist/emitter/worker-templates/readme.md.hbs +0 -53
- package/dist/emitter/worker-templates/server.test.ts.hbs +0 -20
- package/dist/emitter/worker-templates/tool-handler.ts.hbs +0 -85
- package/dist/emitter/worker-templates/tool-index.ts.hbs +0 -28
- package/dist/emitter/worker-templates/tsconfig.json.hbs +0 -17
- package/dist/emitter/worker-templates/worker.ts.hbs +0 -242
- package/dist/emitter/worker-templates/wrangler.toml.hbs +0 -19
- package/dist/generator/spec-generator.d.ts +0 -6
- package/dist/generator/spec-generator.js +0 -50
- package/dist/parser/har-filter.d.ts +0 -8
- package/dist/parser/har-filter.js +0 -71
- package/dist/parser/har-loader.d.ts +0 -2
- package/dist/parser/har-loader.js +0 -14
- package/dist/parser/har-normalizer.d.ts +0 -20
- package/dist/parser/har-normalizer.js +0 -78
- package/dist/parser/index.d.ts +0 -10
- package/dist/parser/index.js +0 -6
- package/dist/parser/openapi-loader.d.ts +0 -6
- package/dist/parser/openapi-loader.js +0 -308
- package/dist/parser/operation-extractor.d.ts +0 -13
- package/dist/parser/operation-extractor.js +0 -155
- package/dist/parser/overlay-loader.d.ts +0 -10
- package/dist/parser/overlay-loader.js +0 -184
- package/dist/parser/postman-loader.d.ts +0 -9
- package/dist/parser/postman-loader.js +0 -106
- package/dist/parser/schema-converter.d.ts +0 -12
- package/dist/parser/schema-converter.js +0 -117
- package/dist/plugins/adapter.d.ts +0 -40
- package/dist/plugins/adapter.js +0 -15
- package/dist/plugins/loader.d.ts +0 -25
- package/dist/plugins/loader.js +0 -58
- package/dist/pricing.d.ts +0 -55
- package/dist/pricing.js +0 -133
- package/dist/providers/index.d.ts +0 -15
- package/dist/providers/index.js +0 -56
- package/dist/recorder/browser-recorder.d.ts +0 -22
- package/dist/recorder/browser-recorder.js +0 -205
- package/dist/rescan/diff-engine.d.ts +0 -5
- package/dist/rescan/diff-engine.js +0 -312
- package/dist/rescan/index.d.ts +0 -3
- package/dist/rescan/index.js +0 -2
- package/dist/rescan/rescan-runner.d.ts +0 -42
- package/dist/rescan/rescan-runner.js +0 -69
- package/dist/rescan/rescan-scheduler.d.ts +0 -41
- package/dist/rescan/rescan-scheduler.js +0 -179
- package/dist/site-transformer/browser-tools.d.ts +0 -10
- package/dist/site-transformer/browser-tools.js +0 -59
- package/dist/site-transformer/index.d.ts +0 -2
- package/dist/site-transformer/index.js +0 -2
- package/dist/site-transformer/selector-healer.d.ts +0 -8
- package/dist/site-transformer/selector-healer.js +0 -106
- package/dist/site-transformer/tool-generator.d.ts +0 -13
- package/dist/site-transformer/tool-generator.js +0 -245
- package/dist/transformer/auth-detector.d.ts +0 -13
- package/dist/transformer/auth-detector.js +0 -90
- package/dist/transformer/catalog-builder.d.ts +0 -18
- package/dist/transformer/catalog-builder.js +0 -56
- package/dist/transformer/client-compat.d.ts +0 -6
- package/dist/transformer/client-compat.js +0 -44
- package/dist/transformer/har-clusterer.d.ts +0 -9
- package/dist/transformer/har-clusterer.js +0 -27
- package/dist/transformer/har-dedup.d.ts +0 -10
- package/dist/transformer/har-dedup.js +0 -81
- package/dist/transformer/har-schema-inferrer.d.ts +0 -15
- package/dist/transformer/har-schema-inferrer.js +0 -90
- package/dist/transformer/har-to-operations.d.ts +0 -13
- package/dist/transformer/har-to-operations.js +0 -192
- package/dist/transformer/index.d.ts +0 -8
- package/dist/transformer/index.js +0 -6
- package/dist/transformer/llm-namer.d.ts +0 -6
- package/dist/transformer/llm-namer.js +0 -59
- package/dist/transformer/naming.d.ts +0 -4
- package/dist/transformer/naming.js +0 -30
- package/dist/transformer/operation-filter.d.ts +0 -13
- package/dist/transformer/operation-filter.js +0 -52
- package/dist/transformer/resource-builder.d.ts +0 -12
- package/dist/transformer/resource-builder.js +0 -80
- package/dist/transformer/schema-merger.d.ts +0 -14
- package/dist/transformer/schema-merger.js +0 -65
- package/dist/transformer/tool-builder.d.ts +0 -3
- package/dist/transformer/tool-builder.js +0 -114
- package/dist/types/index.d.ts +0 -131
- package/dist/types/index.js +0 -1
- package/dist/types/site.d.ts +0 -284
- package/dist/types/site.js +0 -8
- package/dist/utils/fail.d.ts +0 -48
- package/dist/utils/fail.js +0 -204
- package/dist/utils/fs.d.ts +0 -5
- package/dist/utils/fs.js +0 -28
- package/dist/utils/interactive.d.ts +0 -6
- package/dist/utils/interactive.js +0 -30
- package/dist/utils/logger.d.ts +0 -1
- package/dist/utils/logger.js +0 -2
- package/dist/utils/sanitize.d.ts +0 -28
- package/dist/utils/sanitize.js +0 -44
- package/dist/utils/watcher.d.ts +0 -11
- package/dist/utils/watcher.js +0 -36
package/dist/cloud/server.js
DELETED
|
@@ -1,1079 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Hosting backend HTTP server.
|
|
3
|
-
*
|
|
4
|
-
* A minimal Node.js HTTP server (no Express) that accepts spec uploads,
|
|
5
|
-
* generates MCP servers, and manages Docker container lifecycles.
|
|
6
|
-
*
|
|
7
|
-
* Endpoints:
|
|
8
|
-
* POST /api/servers — Create a new hosted MCP server
|
|
9
|
-
* GET /api/servers — List servers
|
|
10
|
-
* GET /api/servers/:slug — Get server status
|
|
11
|
-
* DELETE /api/servers/:slug — Stop and remove server
|
|
12
|
-
* PUT /api/servers/:slug — Re-deploy with updated spec
|
|
13
|
-
* GET /api/servers/:slug/logs — Tail container logs (SSE)
|
|
14
|
-
* GET /health — Health check
|
|
15
|
-
*/
|
|
16
|
-
import http from 'node:http';
|
|
17
|
-
import { writeFile, mkdir, unlink, rm } from 'node:fs/promises';
|
|
18
|
-
import { join } from 'node:path';
|
|
19
|
-
import { randomBytes, timingSafeEqual, createHash } from 'node:crypto';
|
|
20
|
-
import { logger } from '../utils/logger.js';
|
|
21
|
-
import { buildFromSpec, detectFormat, detectFormatFromContent } from './build-pipeline.js';
|
|
22
|
-
import { getContainerBackend } from './container-backend.js';
|
|
23
|
-
import { generateBearerToken, hashToken, verifyToken, hashSpec } from './security.js';
|
|
24
|
-
import { IdleMonitor } from './idle-monitor.js';
|
|
25
|
-
import { parseMultipart, isAllowedSpecFile, getSafeExtension } from './multipart.js';
|
|
26
|
-
import { handleWebRequest, render500Page } from './web/router.js';
|
|
27
|
-
import { getSessionToken, getSession, getUserById, countUsers } from './web/auth.js';
|
|
28
|
-
import { getClientIp } from './request-security.js';
|
|
29
|
-
import { handleWebhookEvent, isStripeConfigured } from './stripe.js';
|
|
30
|
-
import { extractSlug, authorizeBearer, readBoundedBody, inspectMcpBody, proxyToUpstream, } from './mcp-proxy.js';
|
|
31
|
-
import { checkCallQuota } from './billing/billing-engine.js';
|
|
32
|
-
import { FAMILY_A_PRICING } from '../pricing.js';
|
|
33
|
-
import { getBillingPeriod } from './billing/usage-store.js';
|
|
34
|
-
import { buildQueue } from './build-queue.js';
|
|
35
|
-
import { reportError, installGlobalErrorHandlers, validateStartupConfig } from './observability.js';
|
|
36
|
-
import { ResourceMonitor, gatherResourceReading, } from './resource-monitor.js';
|
|
37
|
-
import { store, ports, uploadLimiter, loginLimiter, telemetryLimiter, metrics, usageTracker, failureTracker, getUsageStore, getCreditStore, getMetricSampleStore, getTelemetryStore, MAX_SPEC_SIZE, MAX_NAME_LENGTH, UPLOAD_DIR, getPgServerStore, getSecretStore, initStores, } from './shared-state.js';
|
|
38
|
-
const DEFAULT_PORT = 3001;
|
|
39
|
-
const DEFAULT_DOMAIN = 'mcpmake.dev';
|
|
40
|
-
/** Active host resource monitor, exposed so /health can report the gauges. */
|
|
41
|
-
let resourceMonitor = null;
|
|
42
|
-
/**
|
|
43
|
-
* Start the hosting backend server.
|
|
44
|
-
*/
|
|
45
|
-
export async function startServer(port = DEFAULT_PORT, domain = DEFAULT_DOMAIN) {
|
|
46
|
-
// Fail fast on missing production secrets; report crashes instead of dying.
|
|
47
|
-
validateStartupConfig();
|
|
48
|
-
installGlobalErrorHandlers();
|
|
49
|
-
// Initialise Pg-backed stores when DATABASE_URL is set
|
|
50
|
-
await initStores();
|
|
51
|
-
// Start idle monitor
|
|
52
|
-
const idleMonitor = new IdleMonitor(store, (p) => ports.release(p), {
|
|
53
|
-
idleThresholdMs: parseInt(process.env.IDLE_THRESHOLD_MS ?? '3600000', 10),
|
|
54
|
-
domain,
|
|
55
|
-
});
|
|
56
|
-
idleMonitor.start();
|
|
57
|
-
// Monitor host resource pressure (disk / RAM / active containers) and alert
|
|
58
|
-
// via the observability hook before the single host runs out of headroom.
|
|
59
|
-
// The same 5-min tick also persists a time-series sample for the admin charts.
|
|
60
|
-
const dataDir = process.env.DATA_DIR ?? '/';
|
|
61
|
-
resourceMonitor = new ResourceMonitor(async () => {
|
|
62
|
-
// Prefer the Pg store: the in-memory `store` is empty after a restart in
|
|
63
|
-
// Pg mode, which would under-report active containers.
|
|
64
|
-
const pg = getPgServerStore();
|
|
65
|
-
const all = pg ? await pg.list() : store.list();
|
|
66
|
-
const running = all.filter((s) => s.status === 'running').length;
|
|
67
|
-
return gatherResourceReading(running, dataDir);
|
|
68
|
-
}, {
|
|
69
|
-
onSample: (reading) => {
|
|
70
|
-
void persistMetricSample(reading);
|
|
71
|
-
},
|
|
72
|
-
});
|
|
73
|
-
resourceMonitor.start();
|
|
74
|
-
// Periodic cleanup of rate limiters and expired sessions
|
|
75
|
-
const cleanupTimer = setInterval(() => {
|
|
76
|
-
uploadLimiter.cleanup();
|
|
77
|
-
loginLimiter.cleanup();
|
|
78
|
-
telemetryLimiter.cleanup();
|
|
79
|
-
// cleanupSessions is async (handles Pg + in-memory) — fire and forget
|
|
80
|
-
import('./web/auth.js').then(({ cleanupSessions }) => cleanupSessions().catch(() => { }));
|
|
81
|
-
}, 3_600_000);
|
|
82
|
-
cleanupTimer.unref();
|
|
83
|
-
const server = http.createServer(async (req, res) => {
|
|
84
|
-
// Count every completed response by status class for the API failure rate.
|
|
85
|
-
// Registered here (the one entry point for all responses) rather than in
|
|
86
|
-
// sendJson/sendHtml, which miss the proxy, redirect, and SSE paths.
|
|
87
|
-
// 'finish' = response fully sent; 'close' without writableFinished = the
|
|
88
|
-
// socket was aborted before the response completed.
|
|
89
|
-
res.on('finish', () => failureTracker.record(res.statusCode));
|
|
90
|
-
res.on('close', () => {
|
|
91
|
-
if (!res.writableFinished)
|
|
92
|
-
failureTracker.recordAborted();
|
|
93
|
-
});
|
|
94
|
-
try {
|
|
95
|
-
await handleRequest(req, res, domain);
|
|
96
|
-
}
|
|
97
|
-
catch (err) {
|
|
98
|
-
reportError(err, { method: req.method, url: req.url });
|
|
99
|
-
if (res.headersSent) {
|
|
100
|
-
res.destroy();
|
|
101
|
-
return;
|
|
102
|
-
}
|
|
103
|
-
const accept = req.headers.accept ?? '';
|
|
104
|
-
if (accept.includes('text/html') && !accept.includes('application/json')) {
|
|
105
|
-
render500Page(res);
|
|
106
|
-
}
|
|
107
|
-
else {
|
|
108
|
-
sendJson(res, 500, { error: 'Internal server error' });
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
});
|
|
112
|
-
server.listen(port, () => {
|
|
113
|
-
logger.info(`Hosting backend listening on http://localhost:${port}`);
|
|
114
|
-
});
|
|
115
|
-
server.on('close', () => {
|
|
116
|
-
idleMonitor.stop();
|
|
117
|
-
resourceMonitor?.stop();
|
|
118
|
-
clearInterval(cleanupTimer);
|
|
119
|
-
});
|
|
120
|
-
return server;
|
|
121
|
-
}
|
|
122
|
-
// ---------------------------------------------------------------------------
|
|
123
|
-
// Router
|
|
124
|
-
// ---------------------------------------------------------------------------
|
|
125
|
-
export async function handleRequest(req, res, domain) {
|
|
126
|
-
const method = req.method ?? 'GET';
|
|
127
|
-
const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
|
|
128
|
-
const path = url.pathname;
|
|
129
|
-
logger.info(`${method} ${path}`);
|
|
130
|
-
// Served MCP traffic arrives on a per-server subdomain ({slug}.{domain}).
|
|
131
|
-
// The backend is the authoritative entry point: validate the token, enforce
|
|
132
|
-
// quota, meter usage, then stream-proxy to the container. This is wired
|
|
133
|
-
// before all apex routes because subdomains are a separate virtual host.
|
|
134
|
-
const proxySlug = extractSlug(req.headers.host, domain);
|
|
135
|
-
if (proxySlug) {
|
|
136
|
-
await handleMcpSubdomain(req, res, proxySlug);
|
|
137
|
-
return;
|
|
138
|
-
}
|
|
139
|
-
// HEAD probes from monitors / uptime tools. Mirror the GET 200 for the apex
|
|
140
|
-
// landing and health endpoints (headers only, no body per the HTTP spec) so
|
|
141
|
-
// a HEAD check doesn't read as an outage while GET returns 200.
|
|
142
|
-
if (method === 'HEAD' && (path === '/' || path === '/health')) {
|
|
143
|
-
res.writeHead(200, {
|
|
144
|
-
'Content-Type': path === '/health' ? 'application/json' : 'text/html; charset=utf-8',
|
|
145
|
-
});
|
|
146
|
-
res.end();
|
|
147
|
-
return;
|
|
148
|
-
}
|
|
149
|
-
// Stripe webhook — must be handled before anything that consumes the body,
|
|
150
|
-
// and must NOT go through CSRF validation (Stripe cannot send CSRF tokens).
|
|
151
|
-
// Requires the raw body as a Buffer for signature verification.
|
|
152
|
-
if (method === 'POST' && path === '/api/webhooks/stripe') {
|
|
153
|
-
await handleStripeWebhook(req, res);
|
|
154
|
-
return;
|
|
155
|
-
}
|
|
156
|
-
// Web routes (HTML pages, static files) — skip for API and health paths
|
|
157
|
-
if (!path.startsWith('/api/') && path !== '/health') {
|
|
158
|
-
const handled = await handleWebRequest(req, res, { domain, store, ports, metrics });
|
|
159
|
-
if (handled)
|
|
160
|
-
return;
|
|
161
|
-
}
|
|
162
|
-
// Health check
|
|
163
|
-
if (method === 'GET' && path === '/health') {
|
|
164
|
-
const pgStore = getPgServerStore();
|
|
165
|
-
const serverCount = pgStore ? (await pgStore.list()).length : store.list().length;
|
|
166
|
-
const reading = resourceMonitor?.lastReading();
|
|
167
|
-
const fw = failureTracker.window(60);
|
|
168
|
-
sendJson(res, 200, {
|
|
169
|
-
status: 'ok',
|
|
170
|
-
servers: serverCount,
|
|
171
|
-
ports: ports.allocatedCount,
|
|
172
|
-
requestsLastHour: fw.total,
|
|
173
|
-
serverErrorRatePct: Math.round(fw.serverErrorRatePct * 10) / 10,
|
|
174
|
-
...(reading
|
|
175
|
-
? {
|
|
176
|
-
activeContainers: reading.activeContainers,
|
|
177
|
-
memUsedPct: Math.round(reading.memUsedPct),
|
|
178
|
-
diskUsedPct: Math.round(reading.diskUsedPct),
|
|
179
|
-
}
|
|
180
|
-
: {}),
|
|
181
|
-
});
|
|
182
|
-
return;
|
|
183
|
-
}
|
|
184
|
-
// GET /api/pricing — Public pricing (the source of truth for the CLI).
|
|
185
|
-
// The installed CLI fetches this so an old binary never prints stale prices;
|
|
186
|
-
// it falls back to its bundled copy only when the backend is unreachable.
|
|
187
|
-
if (method === 'GET' && path === '/api/pricing') {
|
|
188
|
-
sendJson(res, 200, { familyA: FAMILY_A_PRICING });
|
|
189
|
-
return;
|
|
190
|
-
}
|
|
191
|
-
// POST /api/telemetry/report — Receive opt-in CLI crash reports.
|
|
192
|
-
// Unauthenticated (no secrets, no PII) but heavily rate-limited and size-
|
|
193
|
-
// capped; see handleTelemetryReport for the abuse hardening.
|
|
194
|
-
if (method === 'POST' && path === '/api/telemetry/report') {
|
|
195
|
-
await handleTelemetryReport(req, res);
|
|
196
|
-
return;
|
|
197
|
-
}
|
|
198
|
-
// POST /api/servers — Create
|
|
199
|
-
if (method === 'POST' && path === '/api/servers') {
|
|
200
|
-
await handleCreateServer(req, res, domain);
|
|
201
|
-
return;
|
|
202
|
-
}
|
|
203
|
-
// GET /api/servers — List (requires admin token)
|
|
204
|
-
if (method === 'GET' && path === '/api/servers') {
|
|
205
|
-
if (!authenticateAdmin(req, res))
|
|
206
|
-
return;
|
|
207
|
-
await handleListServers(res);
|
|
208
|
-
return;
|
|
209
|
-
}
|
|
210
|
-
// Match /api/servers/:slug routes (slugs: alphanumeric + hyphens only, no dots)
|
|
211
|
-
const slugMatch = path.match(/^\/api\/servers\/([a-z0-9][a-z0-9-]*)$/);
|
|
212
|
-
if (slugMatch) {
|
|
213
|
-
const slug = slugMatch[1];
|
|
214
|
-
if (method === 'GET') {
|
|
215
|
-
if (!(await authenticateRequest(req, res, slug)))
|
|
216
|
-
return;
|
|
217
|
-
await handleGetServer(res, slug);
|
|
218
|
-
return;
|
|
219
|
-
}
|
|
220
|
-
if (method === 'DELETE') {
|
|
221
|
-
if (!(await authenticateRequest(req, res, slug)))
|
|
222
|
-
return;
|
|
223
|
-
await handleDeleteServer(res, slug, domain);
|
|
224
|
-
return;
|
|
225
|
-
}
|
|
226
|
-
if (method === 'PUT') {
|
|
227
|
-
if (!(await authenticateRequest(req, res, slug)))
|
|
228
|
-
return;
|
|
229
|
-
await handleUpdateServer(req, res, slug, domain);
|
|
230
|
-
return;
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
// Match /api/servers/:slug/logs
|
|
234
|
-
const logsMatch = path.match(/^\/api\/servers\/([a-z0-9][a-z0-9-]*)\/logs$/);
|
|
235
|
-
if (logsMatch && method === 'GET') {
|
|
236
|
-
if (!(await authenticateRequest(req, res, logsMatch[1])))
|
|
237
|
-
return;
|
|
238
|
-
await handleGetLogs(res, logsMatch[1]);
|
|
239
|
-
return;
|
|
240
|
-
}
|
|
241
|
-
// Match /api/servers/:slug/metrics
|
|
242
|
-
const metricsMatch = path.match(/^\/api\/servers\/([a-z0-9][a-z0-9-]*)\/metrics$/);
|
|
243
|
-
if (metricsMatch && method === 'GET') {
|
|
244
|
-
if (!(await authenticateRequest(req, res, metricsMatch[1])))
|
|
245
|
-
return;
|
|
246
|
-
await handleGetMetrics(res, metricsMatch[1]);
|
|
247
|
-
return;
|
|
248
|
-
}
|
|
249
|
-
sendJson(res, 404, { error: 'Not found' });
|
|
250
|
-
}
|
|
251
|
-
/**
|
|
252
|
-
* Handle served MCP traffic for a single hosted server (subdomain request).
|
|
253
|
-
*
|
|
254
|
-
* Validates the bearer token against the server's stored hash, enforces the
|
|
255
|
-
* owner's monthly tool-call quota, meters usage, keeps the server marked
|
|
256
|
-
* active, and stream-proxies the request to the container.
|
|
257
|
-
*/
|
|
258
|
-
async function handleMcpSubdomain(req, res, slug) {
|
|
259
|
-
const pgStore = getPgServerStore();
|
|
260
|
-
const record = pgStore ? await pgStore.get(slug) : store.get(slug);
|
|
261
|
-
if (!record) {
|
|
262
|
-
sendJson(res, 404, { error: 'Server not found' });
|
|
263
|
-
return;
|
|
264
|
-
}
|
|
265
|
-
// 1. Authenticate against the stored token hash.
|
|
266
|
-
const auth = authorizeBearer(req, record.bearerToken);
|
|
267
|
-
if (!auth.ok) {
|
|
268
|
-
sendJson(res, auth.status, { error: auth.error });
|
|
269
|
-
return;
|
|
270
|
-
}
|
|
271
|
-
if (record.status !== 'running') {
|
|
272
|
-
sendJson(res, 503, { error: 'Server is not running. It may be sleeping or rebuilding.' });
|
|
273
|
-
return;
|
|
274
|
-
}
|
|
275
|
-
// 2. Buffer the body (bounded) so we can meter tool calls and enforce quota.
|
|
276
|
-
const body = await readBoundedBody(req);
|
|
277
|
-
if (body === null) {
|
|
278
|
-
sendJson(res, 413, { error: 'Request body too large' });
|
|
279
|
-
return;
|
|
280
|
-
}
|
|
281
|
-
const { toolCalls, primaryTool } = inspectMcpBody(body, req.headers['content-type']);
|
|
282
|
-
// 3. Enforce the owner's monthly tool-call quota before forwarding. When the
|
|
283
|
-
// plan quota is exhausted, fall back to any promotional credit balance
|
|
284
|
-
// (all-or-nothing for this request) before returning 429.
|
|
285
|
-
const billable = !!record.userId && toolCalls > 0;
|
|
286
|
-
let period;
|
|
287
|
-
let coveredByCredits = false;
|
|
288
|
-
if (billable) {
|
|
289
|
-
const user = await getUserById(record.userId);
|
|
290
|
-
const plan = (user?.plan ?? 'free');
|
|
291
|
-
period = getBillingPeriod(new Date(), user?.subscriptionPeriodEnd);
|
|
292
|
-
const used = await getUsageStore().getToolCalls(record.userId, period);
|
|
293
|
-
const quota = checkCallQuota(plan, used);
|
|
294
|
-
if (!quota.allowed) {
|
|
295
|
-
// Atomic consume — never read-modify-write (lost-update would leak credits).
|
|
296
|
-
const credit = await getCreditStore()
|
|
297
|
-
.consume(record.userId, toolCalls)
|
|
298
|
-
.catch(() => ({ consumed: false, balance: 0 }));
|
|
299
|
-
if (!credit.consumed) {
|
|
300
|
-
sendJson(res, 429, {
|
|
301
|
-
error: `Monthly tool-call quota exceeded (${quota.limit}). Upgrade your plan or ask for promotional credits.`,
|
|
302
|
-
});
|
|
303
|
-
return;
|
|
304
|
-
}
|
|
305
|
-
coveredByCredits = true;
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
|
-
// 4. Meter: per-server metrics, keep-alive timestamp, and durable usage.
|
|
309
|
-
// Persist the tool-call count BEFORE the (slow, streaming) proxy call and
|
|
310
|
-
// await it, so a concurrent request for the same user reads the incremented
|
|
311
|
-
// counter when checking quota. The DB upsert is atomic, so counts never get
|
|
312
|
-
// lost; this only narrows the read-before-write window to a single round trip
|
|
313
|
-
// (rather than the full proxy duration). A metering failure must not block
|
|
314
|
-
// serving, so we log and continue.
|
|
315
|
-
const now = new Date().toISOString();
|
|
316
|
-
metrics.recordRequest(slug, primaryTool);
|
|
317
|
-
store.update(slug, { lastActiveAt: now });
|
|
318
|
-
if (pgStore) {
|
|
319
|
-
pgStore.update(slug, { lastActiveAt: now }).catch(() => { });
|
|
320
|
-
}
|
|
321
|
-
// Credit-covered calls are NOT counted against the plan's monthly quota — the
|
|
322
|
-
// user paid for them with promotional credits, which were already decremented.
|
|
323
|
-
if (billable && period && !coveredByCredits) {
|
|
324
|
-
const meta = primaryTool ? { tool: primaryTool } : undefined;
|
|
325
|
-
for (let i = 0; i < toolCalls; i++) {
|
|
326
|
-
usageTracker.recordToolCall(record.userId, slug, meta);
|
|
327
|
-
}
|
|
328
|
-
try {
|
|
329
|
-
await getUsageStore().record(record.userId, period, toolCalls, 1);
|
|
330
|
-
}
|
|
331
|
-
catch (err) {
|
|
332
|
-
logger.warn(`Failed to persist usage for ${slug}: ${err}`);
|
|
333
|
-
}
|
|
334
|
-
}
|
|
335
|
-
// 5. Stream-proxy to the container.
|
|
336
|
-
await proxyToUpstream(req, res, record.port, body);
|
|
337
|
-
}
|
|
338
|
-
// ---------------------------------------------------------------------------
|
|
339
|
-
// Endpoint Handlers
|
|
340
|
-
// ---------------------------------------------------------------------------
|
|
341
|
-
/**
|
|
342
|
-
* POST /api/servers — Create a new hosted MCP server.
|
|
343
|
-
* Accepts multipart form data with a spec file and optional name field.
|
|
344
|
-
*/
|
|
345
|
-
async function handleCreateServer(req, res, domain) {
|
|
346
|
-
// Gate the unauthenticated JSON API. When ADMIN_TOKEN is configured (the
|
|
347
|
-
// production default), creating servers via the API requires it — otherwise
|
|
348
|
-
// anyone could spin up containers. In dev / self-host (no ADMIN_TOKEN set)
|
|
349
|
-
// the path stays open for the CLI.
|
|
350
|
-
if (process.env.ADMIN_TOKEN && !authenticateAdmin(req, res))
|
|
351
|
-
return;
|
|
352
|
-
// Rate limiting by IP. Trust X-Forwarded-For only when TRUST_PROXY=true.
|
|
353
|
-
const clientIp = getClientIp(req);
|
|
354
|
-
const rateCheck = uploadLimiter.check(clientIp);
|
|
355
|
-
if (!rateCheck.allowed) {
|
|
356
|
-
res.setHeader('Retry-After', String(Math.ceil((rateCheck.resetAt - Date.now()) / 1000)));
|
|
357
|
-
sendJson(res, 429, {
|
|
358
|
-
error: 'Rate limit exceeded. Try again later.',
|
|
359
|
-
remaining: 0,
|
|
360
|
-
resetAt: new Date(rateCheck.resetAt).toISOString(),
|
|
361
|
-
});
|
|
362
|
-
return;
|
|
363
|
-
}
|
|
364
|
-
// Parse multipart form data
|
|
365
|
-
const contentType = req.headers['content-type'] ?? '';
|
|
366
|
-
if (!contentType.includes('multipart/form-data')) {
|
|
367
|
-
sendJson(res, 400, { error: 'Content-Type must be multipart/form-data' });
|
|
368
|
-
return;
|
|
369
|
-
}
|
|
370
|
-
const parsed = await parseMultipart(req, contentType);
|
|
371
|
-
if (!parsed) {
|
|
372
|
-
sendJson(res, 400, { error: 'Failed to parse multipart body' });
|
|
373
|
-
return;
|
|
374
|
-
}
|
|
375
|
-
const specFile = parsed.files.get('spec');
|
|
376
|
-
if (!specFile) {
|
|
377
|
-
sendJson(res, 400, { error: 'Missing "spec" file field' });
|
|
378
|
-
return;
|
|
379
|
-
}
|
|
380
|
-
// Validate file size
|
|
381
|
-
if (specFile.data.length > MAX_SPEC_SIZE) {
|
|
382
|
-
sendJson(res, 400, {
|
|
383
|
-
error: `Spec file too large (${Math.round(specFile.data.length / 1024)}KB). Maximum is 5MB.`,
|
|
384
|
-
});
|
|
385
|
-
return;
|
|
386
|
-
}
|
|
387
|
-
// Validate file type by name extension
|
|
388
|
-
const fileName = specFile.filename ?? 'spec';
|
|
389
|
-
if (!isAllowedSpecFile(fileName)) {
|
|
390
|
-
sendJson(res, 400, {
|
|
391
|
-
error: 'Invalid file type. Accepted: .yaml, .yml, .json, .har',
|
|
392
|
-
});
|
|
393
|
-
return;
|
|
394
|
-
}
|
|
395
|
-
let name = parsed.fields.get('name') ?? undefined;
|
|
396
|
-
if (name && name.length > MAX_NAME_LENGTH) {
|
|
397
|
-
name = name.slice(0, MAX_NAME_LENGTH);
|
|
398
|
-
}
|
|
399
|
-
// Strip non-printable characters from name
|
|
400
|
-
if (name) {
|
|
401
|
-
name = name.replace(/[^\x20-\x7E]/g, '');
|
|
402
|
-
}
|
|
403
|
-
// Save uploaded file to temp dir
|
|
404
|
-
await mkdir(UPLOAD_DIR, { recursive: true });
|
|
405
|
-
const tempId = randomBytes(8).toString('hex');
|
|
406
|
-
const safeExtension = getSafeExtension(fileName);
|
|
407
|
-
const specPath = join(UPLOAD_DIR, `${tempId}${safeExtension}`);
|
|
408
|
-
await writeFile(specPath, specFile.data);
|
|
409
|
-
try {
|
|
410
|
-
// Detect format
|
|
411
|
-
let format = detectFormat(specPath);
|
|
412
|
-
if (safeExtension === '.json') {
|
|
413
|
-
// Ambiguous — inspect content
|
|
414
|
-
format = await detectFormatFromContent(specPath);
|
|
415
|
-
}
|
|
416
|
-
// Build pipeline: generate project from spec
|
|
417
|
-
logger.info(`Building from ${format} spec: ${fileName}`);
|
|
418
|
-
const buildResult = await buildFromSpec(specPath, { name, format });
|
|
419
|
-
// Allocate port and generate bearer token
|
|
420
|
-
const port = ports.allocate();
|
|
421
|
-
const bearerToken = generateBearerToken();
|
|
422
|
-
const tokenHash = hashToken(bearerToken);
|
|
423
|
-
const specHash = hashSpec(specFile.data);
|
|
424
|
-
// Build Docker image
|
|
425
|
-
await buildQueue.run(() => getContainerBackend().buildImage(buildResult.buildDir, buildResult.imageName, buildResult.imageTag));
|
|
426
|
-
// Clean up build directory — image is built, source no longer needed
|
|
427
|
-
try {
|
|
428
|
-
await rm(buildResult.buildDir, { recursive: true, force: true });
|
|
429
|
-
}
|
|
430
|
-
catch {
|
|
431
|
-
// Non-critical: log and continue
|
|
432
|
-
logger.warn(`Failed to clean up build directory: ${buildResult.buildDir}`);
|
|
433
|
-
}
|
|
434
|
-
// Retrieve any stored secrets for this slug to inject as env vars
|
|
435
|
-
const secrets = await getSecretStore().getSecretsAsEnv(buildResult.slug);
|
|
436
|
-
// Start container (Playwright servers get more resources).
|
|
437
|
-
// Inject the raw bearer token so the container authenticates /mcp traffic
|
|
438
|
-
// with the same token shown to the user in the dashboard config.
|
|
439
|
-
const containerId = await getContainerBackend().startContainer({
|
|
440
|
-
slug: buildResult.slug,
|
|
441
|
-
imageName: buildResult.imageName,
|
|
442
|
-
imageTag: buildResult.imageTag,
|
|
443
|
-
envVars: { MCP_AUTH_TOKEN: bearerToken },
|
|
444
|
-
port,
|
|
445
|
-
serverType: buildResult.serverType,
|
|
446
|
-
secrets,
|
|
447
|
-
});
|
|
448
|
-
// No per-container Caddy route: routing is backend-authoritative. The Caddy
|
|
449
|
-
// wildcard sends {slug}.{domain} to this backend, which authenticates,
|
|
450
|
-
// meters, and proxies to the container. Adding a direct route here would
|
|
451
|
-
// let traffic bypass auth/quota/metering.
|
|
452
|
-
const now = new Date().toISOString();
|
|
453
|
-
const endpoint = `https://${buildResult.slug}.${domain}`;
|
|
454
|
-
const serverRecord = {
|
|
455
|
-
slug: buildResult.slug,
|
|
456
|
-
specHash,
|
|
457
|
-
containerId,
|
|
458
|
-
port,
|
|
459
|
-
bearerToken: tokenHash,
|
|
460
|
-
status: 'running',
|
|
461
|
-
toolCount: buildResult.toolCount,
|
|
462
|
-
serverType: buildResult.serverType,
|
|
463
|
-
createdAt: now,
|
|
464
|
-
lastActiveAt: now,
|
|
465
|
-
};
|
|
466
|
-
// Persist to Pg when available, always keep in-memory copy
|
|
467
|
-
store.create(serverRecord);
|
|
468
|
-
const pgStore = getPgServerStore();
|
|
469
|
-
if (pgStore) {
|
|
470
|
-
await pgStore.create(serverRecord);
|
|
471
|
-
}
|
|
472
|
-
// Initialize metrics tracking
|
|
473
|
-
metrics.init(buildResult.slug);
|
|
474
|
-
sendJson(res, 201, {
|
|
475
|
-
slug: buildResult.slug,
|
|
476
|
-
endpoint,
|
|
477
|
-
bearerToken, // Return raw token once; it's stored hashed
|
|
478
|
-
status: 'running',
|
|
479
|
-
toolCount: buildResult.toolCount,
|
|
480
|
-
claudeDesktopConfig: {
|
|
481
|
-
mcpServers: {
|
|
482
|
-
[buildResult.slug]: {
|
|
483
|
-
url: `${endpoint}/mcp`,
|
|
484
|
-
headers: {
|
|
485
|
-
Authorization: `Bearer ${bearerToken}`,
|
|
486
|
-
},
|
|
487
|
-
},
|
|
488
|
-
},
|
|
489
|
-
},
|
|
490
|
-
});
|
|
491
|
-
}
|
|
492
|
-
catch (err) {
|
|
493
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
494
|
-
logger.error(`Build failed: ${message}`);
|
|
495
|
-
sendJson(res, 500, { error: 'Build failed. Check server logs for details.' });
|
|
496
|
-
}
|
|
497
|
-
finally {
|
|
498
|
-
// Clean up uploaded file
|
|
499
|
-
try {
|
|
500
|
-
await unlink(specPath);
|
|
501
|
-
}
|
|
502
|
-
catch {
|
|
503
|
-
// Ignore cleanup errors
|
|
504
|
-
}
|
|
505
|
-
}
|
|
506
|
-
}
|
|
507
|
-
/**
|
|
508
|
-
* GET /api/servers — List all servers.
|
|
509
|
-
*/
|
|
510
|
-
async function handleListServers(res) {
|
|
511
|
-
const pgStore = getPgServerStore();
|
|
512
|
-
const allServers = pgStore ? await pgStore.list() : store.list();
|
|
513
|
-
const servers = allServers.map((s) => ({
|
|
514
|
-
slug: s.slug,
|
|
515
|
-
status: s.status,
|
|
516
|
-
toolCount: s.toolCount,
|
|
517
|
-
createdAt: s.createdAt,
|
|
518
|
-
lastActiveAt: s.lastActiveAt,
|
|
519
|
-
}));
|
|
520
|
-
sendJson(res, 200, { servers });
|
|
521
|
-
}
|
|
522
|
-
/**
|
|
523
|
-
* GET /api/servers/:slug — Get server status.
|
|
524
|
-
*/
|
|
525
|
-
async function handleGetServer(res, slug) {
|
|
526
|
-
const pgStore = getPgServerStore();
|
|
527
|
-
const record = pgStore ? await pgStore.get(slug) : store.get(slug);
|
|
528
|
-
if (!record) {
|
|
529
|
-
sendJson(res, 404, { error: `Server "${slug}" not found` });
|
|
530
|
-
return;
|
|
531
|
-
}
|
|
532
|
-
// Check live container status
|
|
533
|
-
let liveStatus = record.status;
|
|
534
|
-
if (record.containerId && record.status === 'running') {
|
|
535
|
-
try {
|
|
536
|
-
liveStatus = await getContainerBackend().getContainerStatus(record.containerId);
|
|
537
|
-
if (liveStatus !== record.status) {
|
|
538
|
-
if (pgStore) {
|
|
539
|
-
await pgStore.update(slug, { status: liveStatus });
|
|
540
|
-
}
|
|
541
|
-
store.update(slug, { status: liveStatus });
|
|
542
|
-
}
|
|
543
|
-
}
|
|
544
|
-
catch {
|
|
545
|
-
// Docker might not be reachable; use stored status
|
|
546
|
-
}
|
|
547
|
-
}
|
|
548
|
-
sendJson(res, 200, {
|
|
549
|
-
slug: record.slug,
|
|
550
|
-
status: liveStatus,
|
|
551
|
-
toolCount: record.toolCount,
|
|
552
|
-
createdAt: record.createdAt,
|
|
553
|
-
lastActiveAt: record.lastActiveAt,
|
|
554
|
-
});
|
|
555
|
-
}
|
|
556
|
-
/**
|
|
557
|
-
* DELETE /api/servers/:slug — Stop and remove a server.
|
|
558
|
-
*/
|
|
559
|
-
async function handleDeleteServer(res, slug, domain) {
|
|
560
|
-
const pgStore = getPgServerStore();
|
|
561
|
-
const record = pgStore ? await pgStore.get(slug) : store.get(slug);
|
|
562
|
-
if (!record) {
|
|
563
|
-
sendJson(res, 404, { error: `Server "${slug}" not found` });
|
|
564
|
-
return;
|
|
565
|
-
}
|
|
566
|
-
// Stop container
|
|
567
|
-
if (record.containerId) {
|
|
568
|
-
try {
|
|
569
|
-
await getContainerBackend().stopContainer(record.containerId);
|
|
570
|
-
}
|
|
571
|
-
catch (err) {
|
|
572
|
-
logger.warn(`Failed to stop container for ${slug}: ${err}`);
|
|
573
|
-
}
|
|
574
|
-
}
|
|
575
|
-
// No Caddy route to remove — routing is backend-authoritative (wildcard).
|
|
576
|
-
// Release port
|
|
577
|
-
ports.release(record.port);
|
|
578
|
-
// Remove from store + metrics
|
|
579
|
-
if (pgStore) {
|
|
580
|
-
await pgStore.delete(slug);
|
|
581
|
-
}
|
|
582
|
-
store.delete(slug);
|
|
583
|
-
metrics.delete(slug);
|
|
584
|
-
sendJson(res, 200, { slug, status: 'deleted' });
|
|
585
|
-
}
|
|
586
|
-
/**
|
|
587
|
-
* PUT /api/servers/:slug — Re-deploy with an updated spec.
|
|
588
|
-
*/
|
|
589
|
-
async function handleUpdateServer(req, res, slug, domain) {
|
|
590
|
-
const pgStore = getPgServerStore();
|
|
591
|
-
const record = pgStore ? await pgStore.get(slug) : store.get(slug);
|
|
592
|
-
if (!record) {
|
|
593
|
-
sendJson(res, 404, { error: `Server "${slug}" not found` });
|
|
594
|
-
return;
|
|
595
|
-
}
|
|
596
|
-
// Parse multipart (same as create)
|
|
597
|
-
const contentType = req.headers['content-type'] ?? '';
|
|
598
|
-
if (!contentType.includes('multipart/form-data')) {
|
|
599
|
-
sendJson(res, 400, { error: 'Content-Type must be multipart/form-data' });
|
|
600
|
-
return;
|
|
601
|
-
}
|
|
602
|
-
const parsed = await parseMultipart(req, contentType);
|
|
603
|
-
if (!parsed) {
|
|
604
|
-
sendJson(res, 400, { error: 'Failed to parse multipart body' });
|
|
605
|
-
return;
|
|
606
|
-
}
|
|
607
|
-
const specFile = parsed.files.get('spec');
|
|
608
|
-
if (!specFile) {
|
|
609
|
-
sendJson(res, 400, { error: 'Missing "spec" file field' });
|
|
610
|
-
return;
|
|
611
|
-
}
|
|
612
|
-
if (specFile.data.length > MAX_SPEC_SIZE) {
|
|
613
|
-
sendJson(res, 400, { error: 'Spec file too large. Maximum is 5MB.' });
|
|
614
|
-
return;
|
|
615
|
-
}
|
|
616
|
-
const fileName = specFile.filename ?? 'spec';
|
|
617
|
-
if (!isAllowedSpecFile(fileName)) {
|
|
618
|
-
sendJson(res, 400, { error: 'Invalid file type. Accepted: .yaml, .yml, .json, .har' });
|
|
619
|
-
return;
|
|
620
|
-
}
|
|
621
|
-
// Check if spec actually changed
|
|
622
|
-
const newSpecHash = hashSpec(specFile.data);
|
|
623
|
-
if (newSpecHash === record.specHash) {
|
|
624
|
-
sendJson(res, 200, { slug, status: record.status, message: 'No changes detected' });
|
|
625
|
-
return;
|
|
626
|
-
}
|
|
627
|
-
if (pgStore) {
|
|
628
|
-
await pgStore.update(slug, { status: 'building' });
|
|
629
|
-
}
|
|
630
|
-
store.update(slug, { status: 'building' });
|
|
631
|
-
// Save uploaded file
|
|
632
|
-
await mkdir(UPLOAD_DIR, { recursive: true });
|
|
633
|
-
const tempId = randomBytes(8).toString('hex');
|
|
634
|
-
const safeExtension = getSafeExtension(fileName);
|
|
635
|
-
const specPath = join(UPLOAD_DIR, `${tempId}${safeExtension}`);
|
|
636
|
-
await writeFile(specPath, specFile.data);
|
|
637
|
-
try {
|
|
638
|
-
let format = detectFormat(specPath);
|
|
639
|
-
if (safeExtension === '.json') {
|
|
640
|
-
format = await detectFormatFromContent(specPath);
|
|
641
|
-
}
|
|
642
|
-
// Rebuild
|
|
643
|
-
const buildResult = await buildFromSpec(specPath, {
|
|
644
|
-
name: slug.replace(/-[a-f0-9]{4}$/, ''),
|
|
645
|
-
format,
|
|
646
|
-
});
|
|
647
|
-
// Build new image
|
|
648
|
-
await buildQueue.run(() => getContainerBackend().buildImage(buildResult.buildDir, buildResult.imageName, buildResult.imageTag));
|
|
649
|
-
// Clean up build directory
|
|
650
|
-
try {
|
|
651
|
-
await rm(buildResult.buildDir, { recursive: true, force: true });
|
|
652
|
-
}
|
|
653
|
-
catch {
|
|
654
|
-
logger.warn(`Failed to clean up build directory: ${buildResult.buildDir}`);
|
|
655
|
-
}
|
|
656
|
-
// Stop old container
|
|
657
|
-
if (record.containerId) {
|
|
658
|
-
try {
|
|
659
|
-
await getContainerBackend().stopContainer(record.containerId);
|
|
660
|
-
}
|
|
661
|
-
catch (err) {
|
|
662
|
-
logger.warn(`Failed to stop old container: ${err}`);
|
|
663
|
-
}
|
|
664
|
-
}
|
|
665
|
-
// Retrieve stored secrets for the slug
|
|
666
|
-
const secrets = await getSecretStore().getSecretsAsEnv(slug);
|
|
667
|
-
// Rotate the bearer token on re-deploy. We only store the token's hash, so
|
|
668
|
-
// the old raw token cannot be recovered to re-inject into the new container.
|
|
669
|
-
// A fresh token keeps the container authenticating and is returned below.
|
|
670
|
-
const newBearerToken = generateBearerToken();
|
|
671
|
-
const newTokenHash = hashToken(newBearerToken);
|
|
672
|
-
// Start new container on the same port (Playwright servers get more resources)
|
|
673
|
-
const containerId = await getContainerBackend().startContainer({
|
|
674
|
-
slug: buildResult.slug,
|
|
675
|
-
imageName: buildResult.imageName,
|
|
676
|
-
imageTag: buildResult.imageTag,
|
|
677
|
-
envVars: { MCP_AUTH_TOKEN: newBearerToken },
|
|
678
|
-
port: record.port,
|
|
679
|
-
serverType: buildResult.serverType,
|
|
680
|
-
secrets,
|
|
681
|
-
});
|
|
682
|
-
const updateFields = {
|
|
683
|
-
containerId,
|
|
684
|
-
specHash: newSpecHash,
|
|
685
|
-
bearerToken: newTokenHash,
|
|
686
|
-
status: 'running',
|
|
687
|
-
toolCount: buildResult.toolCount,
|
|
688
|
-
serverType: buildResult.serverType,
|
|
689
|
-
lastActiveAt: new Date().toISOString(),
|
|
690
|
-
};
|
|
691
|
-
if (pgStore) {
|
|
692
|
-
await pgStore.update(slug, updateFields);
|
|
693
|
-
}
|
|
694
|
-
store.update(slug, updateFields);
|
|
695
|
-
// The re-deploy reused the image tag, so the previous build is now dangling.
|
|
696
|
-
// Reclaim it (best-effort, non-blocking) to keep the host disk from filling.
|
|
697
|
-
void getContainerBackend().pruneDanglingImages();
|
|
698
|
-
sendJson(res, 200, {
|
|
699
|
-
slug,
|
|
700
|
-
status: 'running',
|
|
701
|
-
toolCount: buildResult.toolCount,
|
|
702
|
-
bearerToken: newBearerToken, // Rotated — update your client config
|
|
703
|
-
message: 'Re-deployed successfully. Bearer token was rotated.',
|
|
704
|
-
});
|
|
705
|
-
}
|
|
706
|
-
catch (err) {
|
|
707
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
708
|
-
if (pgStore) {
|
|
709
|
-
await pgStore.update(slug, { status: 'error' }).catch(() => { });
|
|
710
|
-
}
|
|
711
|
-
store.update(slug, { status: 'error' });
|
|
712
|
-
logger.error(`Re-deploy failed for ${slug}: ${message}`);
|
|
713
|
-
sendJson(res, 500, { error: 'Re-deploy failed. Check server logs for details.' });
|
|
714
|
-
}
|
|
715
|
-
finally {
|
|
716
|
-
try {
|
|
717
|
-
await unlink(specPath);
|
|
718
|
-
}
|
|
719
|
-
catch {
|
|
720
|
-
// Ignore
|
|
721
|
-
}
|
|
722
|
-
}
|
|
723
|
-
}
|
|
724
|
-
/**
|
|
725
|
-
* GET /api/servers/:slug/logs — Stream container logs via SSE.
|
|
726
|
-
*/
|
|
727
|
-
async function handleGetLogs(res, slug) {
|
|
728
|
-
const pgStore = getPgServerStore();
|
|
729
|
-
const record = pgStore ? await pgStore.get(slug) : store.get(slug);
|
|
730
|
-
if (!record) {
|
|
731
|
-
sendJson(res, 404, { error: `Server "${slug}" not found` });
|
|
732
|
-
return;
|
|
733
|
-
}
|
|
734
|
-
if (!record.containerId) {
|
|
735
|
-
sendJson(res, 400, { error: 'No container associated with this server' });
|
|
736
|
-
return;
|
|
737
|
-
}
|
|
738
|
-
// Set up SSE headers
|
|
739
|
-
res.writeHead(200, {
|
|
740
|
-
'Content-Type': 'text/event-stream',
|
|
741
|
-
'Cache-Control': 'no-cache',
|
|
742
|
-
Connection: 'keep-alive',
|
|
743
|
-
'X-Content-Type-Options': 'nosniff',
|
|
744
|
-
'X-Frame-Options': 'DENY',
|
|
745
|
-
});
|
|
746
|
-
try {
|
|
747
|
-
const logStream = getContainerBackend().getContainerLogs(record.containerId);
|
|
748
|
-
for await (const line of logStream) {
|
|
749
|
-
if (res.destroyed)
|
|
750
|
-
break;
|
|
751
|
-
res.write(`data: ${JSON.stringify(line)}\n\n`);
|
|
752
|
-
}
|
|
753
|
-
}
|
|
754
|
-
catch (err) {
|
|
755
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
756
|
-
res.write(`event: error\ndata: ${JSON.stringify({ error: message })}\n\n`);
|
|
757
|
-
}
|
|
758
|
-
res.end();
|
|
759
|
-
}
|
|
760
|
-
/**
|
|
761
|
-
* GET /api/servers/:slug/metrics — Get usage metrics.
|
|
762
|
-
*/
|
|
763
|
-
async function handleGetMetrics(res, slug) {
|
|
764
|
-
const pgStore = getPgServerStore();
|
|
765
|
-
const record = pgStore ? await pgStore.get(slug) : store.get(slug);
|
|
766
|
-
if (!record) {
|
|
767
|
-
sendJson(res, 404, { error: `Server "${slug}" not found` });
|
|
768
|
-
return;
|
|
769
|
-
}
|
|
770
|
-
const serverMetrics = metrics.get(slug);
|
|
771
|
-
const topTools = metrics.getTopTools(slug);
|
|
772
|
-
sendJson(res, 200, {
|
|
773
|
-
slug,
|
|
774
|
-
totalRequests: serverMetrics?.totalRequests ?? 0,
|
|
775
|
-
topTools,
|
|
776
|
-
lastActiveAt: serverMetrics?.lastActiveAt ?? record.lastActiveAt,
|
|
777
|
-
createdAt: record.createdAt,
|
|
778
|
-
});
|
|
779
|
-
}
|
|
780
|
-
/**
|
|
781
|
-
* POST /api/webhooks/stripe — Receive Stripe webhook events.
|
|
782
|
-
*
|
|
783
|
-
* Reads the raw request body as a Buffer (required for signature verification),
|
|
784
|
-
* then delegates to the stripe module's handleWebhookEvent.
|
|
785
|
-
*/
|
|
786
|
-
async function handleStripeWebhook(req, res) {
|
|
787
|
-
if (!isStripeConfigured()) {
|
|
788
|
-
sendJson(res, 404, { error: 'Not found' });
|
|
789
|
-
return;
|
|
790
|
-
}
|
|
791
|
-
const signature = req.headers['stripe-signature'];
|
|
792
|
-
if (!signature || typeof signature !== 'string') {
|
|
793
|
-
sendJson(res, 400, { error: 'Missing stripe-signature header' });
|
|
794
|
-
return;
|
|
795
|
-
}
|
|
796
|
-
// Read raw body as Buffer for signature verification
|
|
797
|
-
const MAX_WEBHOOK_BODY = 1024 * 1024; // 1 MB
|
|
798
|
-
const chunks = [];
|
|
799
|
-
let totalSize = 0;
|
|
800
|
-
try {
|
|
801
|
-
for await (const chunk of req) {
|
|
802
|
-
totalSize += chunk.length;
|
|
803
|
-
if (totalSize > MAX_WEBHOOK_BODY) {
|
|
804
|
-
sendJson(res, 413, { error: 'Payload too large' });
|
|
805
|
-
return;
|
|
806
|
-
}
|
|
807
|
-
chunks.push(chunk);
|
|
808
|
-
}
|
|
809
|
-
}
|
|
810
|
-
catch {
|
|
811
|
-
sendJson(res, 400, { error: 'Failed to read request body' });
|
|
812
|
-
return;
|
|
813
|
-
}
|
|
814
|
-
const payload = Buffer.concat(chunks);
|
|
815
|
-
try {
|
|
816
|
-
await handleWebhookEvent(payload, signature);
|
|
817
|
-
sendJson(res, 200, { received: true });
|
|
818
|
-
}
|
|
819
|
-
catch (err) {
|
|
820
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
821
|
-
logger.error(`[stripe] Webhook error: ${message}`);
|
|
822
|
-
sendJson(res, 400, { error: 'Webhook processing failed' });
|
|
823
|
-
}
|
|
824
|
-
}
|
|
825
|
-
// Multipart parser and file validation re-exported from shared module
|
|
826
|
-
// (used by both server.ts and web/router.ts)
|
|
827
|
-
// ---------------------------------------------------------------------------
|
|
828
|
-
// Time-series sampling
|
|
829
|
-
// ---------------------------------------------------------------------------
|
|
830
|
-
/**
|
|
831
|
-
* Persist one time-series sample for the admin charts. The request/error counts
|
|
832
|
-
* are PER-INTERVAL deltas read from the rolling failure tracker (last ~5 min),
|
|
833
|
-
* not cumulative totals. Best-effort: a metrics write must never disrupt the
|
|
834
|
-
* sampler or serving.
|
|
835
|
-
*/
|
|
836
|
-
async function persistMetricSample(reading) {
|
|
837
|
-
try {
|
|
838
|
-
const fw = failureTracker.window(5); // ~one sampler interval
|
|
839
|
-
const pg = getPgServerStore();
|
|
840
|
-
const totalServers = pg ? (await pg.list()).length : store.list().length;
|
|
841
|
-
let totalUsers = 0;
|
|
842
|
-
try {
|
|
843
|
-
totalUsers = await countUsers();
|
|
844
|
-
}
|
|
845
|
-
catch {
|
|
846
|
-
// best-effort; leave at 0 if the count query fails
|
|
847
|
-
}
|
|
848
|
-
const samples = getMetricSampleStore();
|
|
849
|
-
await samples.record({
|
|
850
|
-
sampledAt: new Date().toISOString(),
|
|
851
|
-
requests: fw.total,
|
|
852
|
-
errors4xx: fw.c4xx,
|
|
853
|
-
errors5xx: fw.c5xx + fw.aborted,
|
|
854
|
-
activeContainers: reading.activeContainers,
|
|
855
|
-
memUsedPct: Math.round(reading.memUsedPct * 10) / 10,
|
|
856
|
-
diskUsedPct: Math.round(reading.diskUsedPct * 10) / 10,
|
|
857
|
-
totalUsers,
|
|
858
|
-
totalServers,
|
|
859
|
-
});
|
|
860
|
-
// Retain 30 days of history.
|
|
861
|
-
await samples.prune(30 * 24 * 3_600_000);
|
|
862
|
-
}
|
|
863
|
-
catch (err) {
|
|
864
|
-
logger.warn(`Failed to persist metric sample: ${err}`);
|
|
865
|
-
}
|
|
866
|
-
}
|
|
867
|
-
// ---------------------------------------------------------------------------
|
|
868
|
-
// CLI telemetry endpoint
|
|
869
|
-
// ---------------------------------------------------------------------------
|
|
870
|
-
/**
|
|
871
|
-
* A crash report is tiny — cap it far below the 5MB spec ceiling so a hostile
|
|
872
|
-
* client can't stream junk at us.
|
|
873
|
-
*/
|
|
874
|
-
const MAX_TELEMETRY_BODY = 64 * 1024; // 64 KB
|
|
875
|
-
const TELEMETRY_FIELD_CAPS = {
|
|
876
|
-
command: 200,
|
|
877
|
-
errorMessage: 2_000,
|
|
878
|
-
stack: 16_000,
|
|
879
|
-
cliVersion: 40,
|
|
880
|
-
platform: 60,
|
|
881
|
-
nodeVersion: 40,
|
|
882
|
-
};
|
|
883
|
-
/**
|
|
884
|
-
* POST /api/telemetry/report — Receive an opt-in CLI crash report.
|
|
885
|
-
*
|
|
886
|
-
* Unauthenticated by design (no secrets, no PII — the CLI redacts home-dir
|
|
887
|
-
* paths and tokens before sending), but hardened against abuse:
|
|
888
|
-
* - rejects any Content-Encoding (we never decompress → no decompression bomb)
|
|
889
|
-
* - rejects oversized bodies by declared length, then again while streaming
|
|
890
|
-
* - per-IP rate limit
|
|
891
|
-
* - strict JSON whitelist with per-field truncation; unknown fields dropped
|
|
892
|
-
* - groups duplicates by a fingerprint; never logs the raw body
|
|
893
|
-
*/
|
|
894
|
-
async function handleTelemetryReport(req, res) {
|
|
895
|
-
// These early rejections return WITHOUT draining the request body. On a
|
|
896
|
-
// keep-alive connection the unread bytes would desync the next request on the
|
|
897
|
-
// socket, so close the connection instead of trying to drain (draining the
|
|
898
|
-
// oversized case would mean reading the very body we're rejecting).
|
|
899
|
-
const rejectClosing = (status, body) => {
|
|
900
|
-
res.setHeader('Connection', 'close');
|
|
901
|
-
sendJson(res, status, body);
|
|
902
|
-
};
|
|
903
|
-
// 1. Never decompress untrusted input. Reject any encoded body outright.
|
|
904
|
-
if (req.headers['content-encoding']) {
|
|
905
|
-
rejectClosing(415, { error: 'Content-Encoding is not supported' });
|
|
906
|
-
return;
|
|
907
|
-
}
|
|
908
|
-
// 2. Reject oversized bodies up front using the (untrusted) Content-Length.
|
|
909
|
-
const declared = Number(req.headers['content-length']);
|
|
910
|
-
if (Number.isFinite(declared) && declared > MAX_TELEMETRY_BODY) {
|
|
911
|
-
rejectClosing(413, { error: 'Payload too large' });
|
|
912
|
-
return;
|
|
913
|
-
}
|
|
914
|
-
// 3. Per-IP flood cap.
|
|
915
|
-
const rate = telemetryLimiter.check(getClientIp(req));
|
|
916
|
-
if (!rate.allowed) {
|
|
917
|
-
res.setHeader('Retry-After', String(Math.ceil((rate.resetAt - Date.now()) / 1000)));
|
|
918
|
-
rejectClosing(429, { error: 'Rate limit exceeded. Try again later.' });
|
|
919
|
-
return;
|
|
920
|
-
}
|
|
921
|
-
// 4. Read the body, hard-capped regardless of the declared length.
|
|
922
|
-
let raw = '';
|
|
923
|
-
let total = 0;
|
|
924
|
-
try {
|
|
925
|
-
for await (const chunk of req) {
|
|
926
|
-
total += chunk.length;
|
|
927
|
-
if (total > MAX_TELEMETRY_BODY) {
|
|
928
|
-
rejectClosing(413, { error: 'Payload too large' });
|
|
929
|
-
return;
|
|
930
|
-
}
|
|
931
|
-
raw += chunk.toString('utf-8');
|
|
932
|
-
}
|
|
933
|
-
}
|
|
934
|
-
catch {
|
|
935
|
-
sendJson(res, 400, { error: 'Failed to read request body' });
|
|
936
|
-
return;
|
|
937
|
-
}
|
|
938
|
-
// 5. Strict parse + whitelist + per-field truncation. Unknown fields dropped.
|
|
939
|
-
let parsed;
|
|
940
|
-
try {
|
|
941
|
-
parsed = JSON.parse(raw);
|
|
942
|
-
}
|
|
943
|
-
catch {
|
|
944
|
-
sendJson(res, 400, { error: 'Invalid JSON' });
|
|
945
|
-
return;
|
|
946
|
-
}
|
|
947
|
-
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
948
|
-
sendJson(res, 400, { error: 'Invalid report' });
|
|
949
|
-
return;
|
|
950
|
-
}
|
|
951
|
-
const src = parsed;
|
|
952
|
-
const str = (v, cap) => (typeof v === 'string' ? v.slice(0, cap) : '');
|
|
953
|
-
const command = str(src.command, TELEMETRY_FIELD_CAPS.command);
|
|
954
|
-
const errorMessage = str(src.errorMessage, TELEMETRY_FIELD_CAPS.errorMessage);
|
|
955
|
-
const stack = str(src.stack, TELEMETRY_FIELD_CAPS.stack);
|
|
956
|
-
const cliVersion = str(src.cliVersion, TELEMETRY_FIELD_CAPS.cliVersion);
|
|
957
|
-
const platform = str(src.platform, TELEMETRY_FIELD_CAPS.platform);
|
|
958
|
-
const nodeVersion = str(src.nodeVersion, TELEMETRY_FIELD_CAPS.nodeVersion);
|
|
959
|
-
if (!command && !errorMessage) {
|
|
960
|
-
sendJson(res, 400, { error: 'Report must include a command or error message' });
|
|
961
|
-
return;
|
|
962
|
-
}
|
|
963
|
-
// 6. Fingerprint = command + normalized message (digits/hex collapsed) so
|
|
964
|
-
// identical failures cluster together in the admin view.
|
|
965
|
-
const normalized = errorMessage
|
|
966
|
-
.toLowerCase()
|
|
967
|
-
.replace(/0x[0-9a-f]+/g, '')
|
|
968
|
-
.replace(/[0-9a-f]{8,}/g, '')
|
|
969
|
-
.replace(/\d+/g, 'n');
|
|
970
|
-
const fingerprint = createHash('sha256')
|
|
971
|
-
.update(`${command}|${normalized}`)
|
|
972
|
-
.digest('hex')
|
|
973
|
-
.slice(0, 16);
|
|
974
|
-
// Hash the client IP for abuse correlation without storing the raw address.
|
|
975
|
-
const clientIp = createHash('sha256').update(getClientIp(req)).digest('hex').slice(0, 16);
|
|
976
|
-
try {
|
|
977
|
-
await getTelemetryStore().save({
|
|
978
|
-
receivedAt: new Date().toISOString(),
|
|
979
|
-
clientIp,
|
|
980
|
-
cliVersion,
|
|
981
|
-
platform,
|
|
982
|
-
nodeVersion,
|
|
983
|
-
command,
|
|
984
|
-
errorMessage,
|
|
985
|
-
stack,
|
|
986
|
-
fingerprint,
|
|
987
|
-
});
|
|
988
|
-
}
|
|
989
|
-
catch (err) {
|
|
990
|
-
// Never echo the body back; just log that a save failed.
|
|
991
|
-
logger.warn(`Failed to store telemetry report: ${err}`);
|
|
992
|
-
}
|
|
993
|
-
sendJson(res, 202, { received: true });
|
|
994
|
-
}
|
|
995
|
-
// ---------------------------------------------------------------------------
|
|
996
|
-
// Utilities
|
|
997
|
-
// ---------------------------------------------------------------------------
|
|
998
|
-
function sendJson(res, status, body) {
|
|
999
|
-
const payload = JSON.stringify(body);
|
|
1000
|
-
res.writeHead(status, {
|
|
1001
|
-
'Content-Type': 'application/json',
|
|
1002
|
-
'Content-Length': Buffer.byteLength(payload),
|
|
1003
|
-
'X-Content-Type-Options': 'nosniff',
|
|
1004
|
-
'X-Frame-Options': 'DENY',
|
|
1005
|
-
});
|
|
1006
|
-
res.end(payload);
|
|
1007
|
-
}
|
|
1008
|
-
/**
|
|
1009
|
-
* Authenticate a request using bearer token for a specific server slug.
|
|
1010
|
-
* Falls back to cookie-based session auth if no Authorization header is present.
|
|
1011
|
-
* This fallback is needed because EventSource (used by htmx SSE) cannot set custom headers.
|
|
1012
|
-
*/
|
|
1013
|
-
async function authenticateRequest(req, res, slug) {
|
|
1014
|
-
const pgStore = getPgServerStore();
|
|
1015
|
-
const record = pgStore ? await pgStore.get(slug) : store.get(slug);
|
|
1016
|
-
if (!record) {
|
|
1017
|
-
sendJson(res, 404, { error: 'Server not found' });
|
|
1018
|
-
return false;
|
|
1019
|
-
}
|
|
1020
|
-
// Try bearer token auth first
|
|
1021
|
-
const authHeader = req.headers.authorization;
|
|
1022
|
-
if (authHeader && authHeader.startsWith('Bearer ')) {
|
|
1023
|
-
const token = authHeader.slice(7);
|
|
1024
|
-
if (verifyToken(token, record.bearerToken)) {
|
|
1025
|
-
return true;
|
|
1026
|
-
}
|
|
1027
|
-
sendJson(res, 403, { error: 'Invalid bearer token' });
|
|
1028
|
-
return false;
|
|
1029
|
-
}
|
|
1030
|
-
// Fallback: cookie-based session auth (uses Pg-aware helpers)
|
|
1031
|
-
const sessionToken = getSessionToken(req);
|
|
1032
|
-
if (sessionToken) {
|
|
1033
|
-
const session = await getSession(sessionToken);
|
|
1034
|
-
if (session && session.userId !== '__anon__') {
|
|
1035
|
-
const user = await getUserById(session.userId);
|
|
1036
|
-
if (user && record.userId && record.userId === user.id) {
|
|
1037
|
-
return true;
|
|
1038
|
-
}
|
|
1039
|
-
}
|
|
1040
|
-
}
|
|
1041
|
-
sendJson(res, 401, { error: 'Missing or invalid Authorization header' });
|
|
1042
|
-
return false;
|
|
1043
|
-
}
|
|
1044
|
-
/**
|
|
1045
|
-
* Authenticate admin requests using ADMIN_TOKEN environment variable.
|
|
1046
|
-
*/
|
|
1047
|
-
function authenticateAdmin(req, res) {
|
|
1048
|
-
const adminToken = process.env.ADMIN_TOKEN;
|
|
1049
|
-
if (!adminToken) {
|
|
1050
|
-
sendJson(res, 403, { error: 'Admin access not configured (set ADMIN_TOKEN)' });
|
|
1051
|
-
return false;
|
|
1052
|
-
}
|
|
1053
|
-
const authHeader = req.headers.authorization;
|
|
1054
|
-
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
|
1055
|
-
sendJson(res, 401, { error: 'Missing or invalid Authorization header' });
|
|
1056
|
-
return false;
|
|
1057
|
-
}
|
|
1058
|
-
const token = authHeader.slice(7);
|
|
1059
|
-
const tokenBuf = Buffer.from(token);
|
|
1060
|
-
const adminBuf = Buffer.from(adminToken);
|
|
1061
|
-
if (tokenBuf.length !== adminBuf.length || !timingSafeEqual(tokenBuf, adminBuf)) {
|
|
1062
|
-
sendJson(res, 403, { error: 'Invalid admin token' });
|
|
1063
|
-
return false;
|
|
1064
|
-
}
|
|
1065
|
-
return true;
|
|
1066
|
-
}
|
|
1067
|
-
// File validation functions from shared module
|
|
1068
|
-
// ---------------------------------------------------------------------------
|
|
1069
|
-
// CLI entry point (when run directly)
|
|
1070
|
-
// ---------------------------------------------------------------------------
|
|
1071
|
-
const isDirectRun = process.argv[1]?.endsWith('cloud/server.js') || process.argv[1]?.endsWith('cloud/server.ts');
|
|
1072
|
-
if (isDirectRun) {
|
|
1073
|
-
const port = parseInt(process.env.PORT ?? String(DEFAULT_PORT), 10);
|
|
1074
|
-
const domain = process.env.DOMAIN ?? DEFAULT_DOMAIN;
|
|
1075
|
-
startServer(port, domain).catch((err) => {
|
|
1076
|
-
logger.error(`Failed to start server: ${err}`);
|
|
1077
|
-
process.exit(1);
|
|
1078
|
-
});
|
|
1079
|
-
}
|