Commit Graph

112 Commits

Author SHA1 Message Date
Marco Sadjadi
2a12ea18cd fix(wizard): editable slug on confirm step so slug_taken (409) is fixable in place
All checks were successful
Deploy to Production / deploy (push) Successful in 1m24s
POST /v1/servers returns 409 slug_taken when the org already has that slug. The error told users to change the slug field above, but the normal (non-fork) flow had no slug field on the confirm step - only Step 1 did - leaving them stuck. Now Name+Slug are editable on the confirm step for the normal flow too, mirroring the fork flow, so a slug conflict is resolved without going back.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 19:56:45 +02:00
Marco Sadjadi
be02600759 feat(security): block credentials from reaching the LLM via prompt secret scan
All checks were successful
Deploy to Production / deploy (push) Successful in 1m20s
Prompts were sent to the model with no secret scan, so a pasted API key would leak to the LLM. Added findSecretInPrompt in @bmm/types (tight provider-key patterns: Anthropic/OpenAI/GitHub/AWS/Google/Slack/Stripe/JWT/private-key) shared by both sides. The web wizard blocks before sending with a clear message; the API preview and preview-stream endpoints reject with secret_in_prompt as the hard guarantee. Credential VALUES already never touched the model - they are entered in the separate encrypted step 2; this closes the remaining leak path where a user pastes a key into the prompt itself.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 19:52:16 +02:00
Marco Sadjadi
ee4713f82c feat(account): self-service GDPR Art.17 erasure; Enterprise price -> Custom
All checks were successful
Deploy to Production / deploy (push) Successful in 1m19s
Account deletion (DELETE /v1/account): re-type email/phone to confirm, stops live containers and hard-deletes every org where the caller is sole member (FK cascade clears servers, builds, logs, encrypted secrets), deletes the user (cascade drops sessions), audits the action, clears the session cookie. Frontend danger-zone replaces the old open-a-ticket placeholder. Closes audit ACC-001. Enterprise price unified to Custom on landing + pricing, removing the 499/999 inconsistency.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 19:23:41 +02:00
Marco Sadjadi
bd82a67fba fix(claims): purge false tier claims from landing, billing cards and legal docs
All checks were successful
Deploy to Production / deploy (push) Successful in 1m32s
The false RBAC / 99.9 SLA / BYOC / custom-domain claims were not only on /pricing but also on the landing-page tier cards, the in-app billing upgrade cards, and — most seriously — the AGB and Terms as a binding 99.9 monthly uptime SLA the single-host infra cannot meet. Aligned all of them: SLA removed from AGB/Terms (best-effort, no guaranteed SLA for self-serve; Enterprise by contract); landing+billing cards now show Audit log, RBAC coming-soon, custom-domain coming-soon, honest Enterprise infra; landing Team price corrected 149->199; billing cards model name Haiku/Sonnet -> Claude AI. Privacy page intentionally keeps exact model names for data-residency disclosure.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 13:43:46 +02:00
Marco Sadjadi
7eb323e8f8 fix(pricing): every tier claim now true or honest; build real priority queue
All checks were successful
Deploy to Production / deploy (push) Successful in 1m21s
Audited all tiers vs code. BUILT priority build queue (both enqueue sites set BullMQ priority by plan, enterprise>team>pro>hobby). Made honest what is not built and cannot be built remotely: Custom domain -> coming soon; Team RBAC -> Audit log + RBAC coming soon; dropped Team 99.9 SLA; reworded FAQ rate-limit, cold-start sub-50ms, 30-day-retention and auto-TLS claims to reality; quota FAQ no longer promises unbuilt overage billing; JSON-LD offers aligned, Team price 149->199. Verified-true kept: server limits 1/5/25/inf and daily caps 5/40/50 enforced, faster paid Claude analysis, source export.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 13:33:41 +02:00
Marco Sadjadi
74ca59b8b7 @
All checks were successful
Deploy to Production / deploy (push) Successful in 1m19s
fix(pricing): honest Enterprise claims — drop unbuilt BYOC/SSO/dedicated-cluster

BYOC, dedicated cluster and SSO/SAML are advertised but not implemented (the
platform deploys local Docker containers on one shared host; no cloud-provider
abstraction exists). Reframe as "on request / scoped per contract" on the
pricing page and in the sitewide SoftwareApplication JSON-LD, since Enterprise
is contact-sales and scoped per deal anyway. Avoids advertising features that
do not exist (UWG / trust risk).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@
2026-05-31 13:22:50 +02:00
Marco Sadjadi
4d717d877f @
All checks were successful
Deploy to Production / deploy (push) Successful in 1m21s
feat(pricing): generic "Claude AI" label on paid tiers instead of model names

Naming "Claude Haiku 4.5" on Pro read as a cheap tier. All paid tiers now show
"Claude AI" with the differentiation moved to the detail line (speed / flagship
quality / top-tier + EU residency); Hobby keeps "Open-tier AI".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@
2026-05-31 13:09:00 +02:00
Marco Sadjadi
4687c8be52 @
All checks were successful
Deploy to Production / deploy (push) Successful in 1m25s
fix(billing): correct Stripe API version + harden checkout; clarify wizard secrets

- Stripe apiVersion was pinned to 2025-10-29.acacia, but stripe@22 is built
  for 2026-04-22.dahlia — where ui_mode embedded_page exists. The mismatch
  made the embedded checkout create call fail/hang, surfacing in the browser
  as an opaque CORS error (CF returns a 5xx without our ACAO header). Pin to
  dahlia + add a 20s client timeout so any failure returns a readable 502.
- new-server wizard: step 1 now warns not to paste API keys into the prompt;
  the credentials section (which already collects each secret in its own
  encrypted field) is relabelled and its empty state invites adding one.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@
2026-05-31 12:08:05 +02:00
Marco Sadjadi
1349dc1dc0 @
feat(web): SEO — server-rendered template pages + /guides articles

- templates/[slug] converted from client to server component: per-template
  generateMetadata (title/description/canonical/OG) + SoftwareApplication
  JSON-LD; code-audit toggle split into a client island; missing/non-public
  templates now return a real 404.
- sitemap.ts pulls public template slugs live from the API (best-effort) +
  the new /guides routes.
- new /guides section: 3 server-rendered SEO articles (host MCP with OAuth,
  hosted-platforms comparison, MintMCP alternative) with TechArticle JSON-LD;
  Guides link added to the marketing nav.
- lib/seo.ts: articleJsonLd + templateJsonLd builders.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@
2026-05-31 12:08:05 +02:00
Marco Sadjadi
21a5cf5762 @
All checks were successful
Deploy to Production / deploy (push) Successful in 1m25s
feat(web): subtle hover/tap video controls (seek + play/pause)

Add a discreet bottom control bar to the hero video — play/pause, elapsed
time, a seek slider, and mute — that reveals on hover (desktop) or tap
(touch) and auto-hides ~2.8s after the last interaction while playing; it
stays visible while paused so the scrubber is reachable. The seek slider is
a real <input type=range> (keyboard/drag/touch, accessible) laid invisibly
over a custom rail+fill so the look matches the page. Autoplay/muted/loop,
the centre play overlay, the play-failed fallback link and poster are
unchanged; the always-on mute button is now folded into the bar.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@
2026-05-30 20:55:48 +02:00
Marco Sadjadi
cf423de3d5 @
All checks were successful
Deploy to Production / deploy (push) Successful in 1m22s
feat(billing): in-app embedded Stripe checkout + webhook hardening

Checkout previously used hosted ui_mode → window.location to checkout.stripe.com,
which pops out of the installed PWA into the system browser. Switch to embedded:

- API: ui_mode embedded_page (stripe-node v22 / API 2025-10 renamed the enum),
  return_url instead of success/cancel_url, returns client_secret.
- web: @stripe/react-stripe-js EmbeddedCheckout mounted in an in-app modal;
  NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY baked at build (Dockerfile arg + compose arg).
- .env.production.example: full Stripe section (was missing) + admin-email
  placeholder (INF-001).

Also bundled (same files): BILL-002 invoice.paid resets quota only on
subscription_cycle; BILL-003 webhook dedup rolled back on handler failure;
BILL-001 change-plan writes plan locally; BILL-004 webhook cross-checks
sub.customer before trusting metadata.orgId; INF-003 API routed off the raw
docker.sock through a locked-down tecnativa/docker-socket-proxy (CONTAINERS+POST).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@
2026-05-29 20:56:40 +02:00
Marco Sadjadi
9d5386ccba @
fix(security): sovereign-audit hardening pass — RCE, multi-tenant, reliability

Reasoning-based audit fixes (all verified by typecheck, attack paths re-traced):

- build-time RCE: validate spec.dependencies to npm-registry semver only
  (no git/url/file specifiers) + --ignore-scripts in runner Dockerfile.
- container hardening fail-CLOSED: harden unless RUNNER_DISABLE_HARDENING=1,
  no longer gated on a fragile NODE_ENV string compare.
- secret env keys validated (UPPER_SNAKE, reject NODE_*/PATH/LD_*).
- cross-org image-tag collision: qualify tag with serverId.
- /iterate now enforces suspension + daily-build limits like /servers.
- preview SSE: clear keepalive in finally + on client close (timer/FD leak).
- SMS OTP: atomic attempt counter (lt(attempts,MAX) in UPDATE) — brute-force race.
- getSession orders membership by createdAt (deterministic primary org).
- template scopes aggregated from real tool scopes (was hardcoded mcp:read).
- template category filter pushed into WHERE (was applied after LIMIT).
- support admin reply/status: 404 on unknown ticket; status change now audited.
- build worker: queue defaultJobOptions, docker build/run/stop timeouts,
  old-container teardown in finally (no orphan on post-deploy DB failure).
- nginx: HSTS, X-Frame-Options DENY, nosniff, Referrer-Policy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@
2026-05-29 20:56:30 +02:00
Marco Sadjadi
092290bb38 fix(preview/stream): await onSpec/onError handlers
All checks were successful
Deploy to Production / deploy (push) Successful in 1m21s
The llm package called the user-supplied onSpec/onError handlers
without awaiting them. In the /preview/stream route onSpec is async
(it does `await cacheSpec(...)` then writes the SSE `spec` event), so
the api handler's `await streamSpecFromAnthropic(...)` returned BEFORE
the terminal event had been written. The route's finally block then
ran `reply.raw.end()`, the queued `send('spec', ...)` hit a closed
stream and silently no-op'd, and the browser saw zero terminal
events — frontend ran into the "Spec generation failed." fallback
even though Anthropic had delivered a perfectly valid spec.

Verified against prod log: req-8 ran 66s with 200 and produced no
preview_spec_* log line, which is exactly the success-but-event-lost
signature.

Fix:
- StreamHandlers.onSpec / onError typed as Promise<void> | void
- Both call sites in streamSpecFromAnthropic now `await` them
- /preview/stream sets `resolved = true` at the END of each handler
  (after the SSE write completes) so the post-stream "unresolved"
  fallback only fires on a genuine programming bug
- Added preview_spec_ready info log on the happy path so future
  diagnosis doesn't have to infer success from the absence of error
  logs
2026-05-28 22:00:03 +02:00
Marco Sadjadi
29e699dc74 fix(preview/stream): emit CORS headers before flushHeaders()
All checks were successful
Deploy to Production / deploy (push) Successful in 1m22s
@fastify/cors injects Access-Control-Allow-* in the onSend hook, but
the SSE endpoint goes straight to reply.raw.flushHeaders() — onSend
never runs, so the browser saw "No 'Access-Control-Allow-Origin'
header" and blocked the fetch before any bytes flowed.

Set Allow-Origin (reflecting the configured app origin),
Allow-Credentials, and Vary: Origin manually right before the SSE
content-type headers. Matches what the cors plugin would have
emitted on a normal response.
2026-05-28 21:53:35 +02:00
Marco Sadjadi
31bfeed9dd feat(dashboard): delete button on server detail page
All checks were successful
Deploy to Production / deploy (push) Successful in 1m27s
The DELETE /v1/servers/:id endpoint existed (tears down the runner
container + removes the row) but nothing in the UI called it, so
servers could only be removed via SSH+psql. Adds a danger-variant
button in the top-right of the detail header with a native confirm,
spinner state, and inline error surfacing. Redirects to /servers
on success.
2026-05-28 21:44:52 +02:00
Marco Sadjadi
ec819082a6 fix(llm): escape backticks in SYSTEM_PROMPT (broke typecheck)
All checks were successful
Deploy to Production / deploy (push) Successful in 1m9s
2026-05-28 21:39:34 +02:00
Marco Sadjadi
147ba69968 fix(runner): alias params/input to args so tool implementations don't ReferenceError
Some checks failed
Deploy to Production / deploy (push) Has been cancelled
Auth chain finally landed but tool calls crashed in the wetter server
with "Error: params is not defined". The MCP SDK passes the validated
tool args as a single parameter; our template names that parameter
`args` but the model frequently writes `params.location` / `input.x`
because that's how OpenAPI and JSON-RPC reference docs read.

Two-sided fix:
- render.ts wraps every implementation with `const params = args; const
  input = args;` inside the try block. Whichever alias the model
  picked, the variable resolves to the same validated object.
- SYSTEM_PROMPT now states the variable name EXPLICITLY ("variable
  named EXACTLY `args`, e.g. args.location") so new generations stop
  drifting on that detail.

Existing wetter runner needs a rebuild to pick up the alias shim.
2026-05-28 21:39:11 +02:00
Marco Sadjadi
b421457010 fix(oauth): accept client_secret_basic on /oauth/token (RFC 6749 §2.3.1)
All checks were successful
Deploy to Production / deploy (push) Successful in 1m21s
Sovereign-audit Phase 3 caught the next layer of the same bug:
form-urlencoded parsing now works, but the AS metadata advertises
both `client_secret_basic` and `client_secret_post` while the handler
only read credentials from the body. Claude Desktop (and most OAuth
SDKs) prefer Basic auth, so every token exchange landed at
"401 invalid_client" — visible in prod logs as POST /oauth/token from
160.79.106.37 returning 401 in <4ms (failing the missing-secret check).

Parse Authorization: Basic header, decode base64, percent-decode each
side (RFC 6749 §2.3.1 mandates pct-encoding of user/pass before the
base64 step), and treat the resulting credentials as if they came from
the body. Header takes precedence when both are present.
2026-05-28 21:28:23 +02:00
Marco Sadjadi
44cebc9fd8 fix(oauth): accept application/x-www-form-urlencoded on /oauth/token
All checks were successful
Deploy to Production / deploy (push) Successful in 1m24s
Sovereign-audit traced "Authorization with the MCP server failed" past
discovery, DCR, /authorize → redirect → code, and into POST /oauth/token,
which Fastify rejected with 415 before our handler ever ran.

RFC 6749 §3.2 makes form-urlencoded the mandatory wire format for the
token endpoint, and every DCR-emitting client (Claude Desktop, Cursor,
OpenAI Codex, …) posts it that way. Fastify ships no built-in parser
for that media type so the route 415'd from the framework's content-
type layer — invisible to a code review of the route handler.

Adds a small URLSearchParams-based parser next to the existing JSON
one, parses the form body into a plain object so the route's zod
schema picks it up unchanged. No new dependency.
2026-05-28 21:21:40 +02:00
Marco Sadjadi
0c6d738a6b feat(preview): SSE-streamed generation, no CF 100s edge cap
All checks were successful
Deploy to Production / deploy (push) Successful in 1m27s
Architectural fix for "spec_too_large" / preview_timeout — the sync
endpoint had to fit the whole model run into Cloudflare's ~100s edge
window, which made the system fragile against any prompt that produced
a verbose spec. The new streaming path pipes Anthropic's token deltas
as Server-Sent Events; every chunk resets CF's idle timer and a 15s
keepalive comment guarantees activity even during slow first-token
windows.

@bmm/llm: new streamSpecFromAnthropic() exposes the SDK's .stream()
flow with the same typed-error contract as generateSpec — same
SpecTruncatedError / SpecValidationError / SpecTimeoutError raised from
the relevant moment.

API: POST /v1/servers/preview/stream returns text/event-stream with
events 'text' (deltas), 'spec' (final success payload, same shape as
the sync endpoint), 'error' (typed). Anthropic-only — GLM/hobby falls
back to the sync route via 409 streaming_unavailable.

Frontend: apiSseStream() handles the POST + ReadableStream + SSE
parser. The wizard's analyze() prefers the stream and only uses the
sync endpoint on the explicit 409 fallback.

nginx (api.buildmymcpserver.com): the /v1/builds/ location block (which
already had proxy_buffering off + 600s read timeout for the WS build
stream) now also matches /v1/servers/preview/stream so the SSE
response isn't buffered.
2026-05-28 21:11:05 +02:00
Marco Sadjadi
b930a454e8 fix(llm): tighter system prompt + 12288 max_tokens for paid tiers
All checks were successful
Deploy to Production / deploy (push) Successful in 1m33s
Sonnet 4.6 was still hitting max_tokens on ambitious prompts like
"WorldWeather MCP for any location" because the implementation bodies
ballooned with defensive scaffolding. Two changes:

1. SYSTEM_PROMPT now imposes hard limits the model can self-enforce:
   - at most 6 tools (combine related capabilities with a mode param)
   - implementation body <= 40 lines, no comments, no overengineering
   - descriptions <= 100 chars
   These keep a typical preview under ~7k output tokens.

2. team/enterprise maxTokens 8192 -> 12288. At ~130 tok/s that fits in
   ~94s, still under Cloudflare's 100s edge cap. Hobby (GLM) and pro
   (Haiku) keep their existing limits — they were not hitting the
   ceiling.

SpecTruncatedError still fires + surfaces 422 spec_too_large when even
12288 isn't enough, so the user gets actionable feedback instead of an
opaque zod error.
2026-05-28 21:01:50 +02:00
Marco Sadjadi
4d136c4fb2 fix(mcp): RFC 9728 protected-resource metadata path + audience binding
All checks were successful
Deploy to Production / deploy (push) Successful in 1m31s
Codex/RFC review showed that Claude Desktop addresses the MCP resource
as <PUBLIC_URL>/mcp (the streamable-HTTP endpoint) rather than the
base URL. Per RFC 9728 the protected-resource metadata then lives at
.well-known/oauth-protected-resource inserted between host and path:

  https://mcp.buildmymcpserver.com/.well-known/oauth-protected-resource/<slug>/mcp

Runner template now:
  - publishes `resource: <PUBLIC_URL>/mcp`
  - sets WWW-Authenticate to the RFC 9728 well-known URL
  - serves /.well-known/oauth-protected-resource[/*] so the metadata
    answers at both the legacy and RFC paths during transition
  - accepts both audiences (<PUBLIC_URL>/mcp + <PUBLIC_URL>) during
    rollout so already-issued tokens keep working

API:
  - resolveServerByResource() tries port first, then path segment
    (production path-routing), with a guard against treating "mcp" as
    a tenant slug
  - AS metadata advertises resource_parameter_supported: true

nginx (scripts/setup-runner-tls.sh + scripts/bmm-mcp-runners.nginx):
  - new location matches /.well-known/oauth-protected-resource/<slug>/...
    and proxies to the slug's runner with the slug stripped, so the
    runner sees the local well-known path

Docs (oauth + api-reference) updated to the RFC paths.
2026-05-28 20:54:27 +02:00
Marco Sadjadi
1d845abf92 fix(oauth): resolve server by path segment, not subdomain
All checks were successful
Deploy to Production / deploy (push) Successful in 1m24s
Claude Desktop got past discovery + DCR but /oauth/authorize rejected
the resource parameter with invalid_resource. Root cause:
resolveServerByResource() extracted the slug from the URL's first
hostname label (subdomain routing), but production runs path routing —
mcp.buildmymcpserver.com/<slug>. The function saw resource
"https://mcp.buildmymcpserver.com/text-generation", tried to look up
slug="mcp", missed, returned null → 400.

Path lookup is now tried first (matches the production topology and
the resource URL we publish via /.well-known/oauth-protected-resource),
port lookup second (local dev), subdomain lookup last with an explicit
"mcp" guard so the legacy path doesn't shadow the new one.
2026-05-28 19:58:31 +02:00
Marco Sadjadi
86cf89ef42 fix(oauth): serve AS metadata at the RFC 8414 strict path
All checks were successful
Deploy to Production / deploy (push) Successful in 1m24s
Root cause of Claude Desktop's repeated "Registrierung beim
Anmeldedienst fehlgeschlagen" reference ofid_897eda676d452435:

RFC 8414 §3 constructs the well-known discovery URL by INSERTING
"/.well-known/oauth-authorization-server" between the host and the
issuer path. For issuer https://api.buildmymcpserver.com/oauth the
correct location is

  https://api.buildmymcpserver.com/.well-known/oauth-authorization-server/oauth

We previously served only the issuer-appended form
(/oauth/.well-known/...), which is the historically common but
RFC-incorrect placement. Claude Desktop's MCP SDK is strict per
RFC 8414, hit the 404, and bailed out of discovery before ever
reaching /oauth/register — so the DCR fix from earlier never had
a chance to run.

Now serves the same metadata at four paths via a single handler:
  - /.well-known/oauth-authorization-server/oauth (RFC 8414 strict)
  - /.well-known/oauth-authorization-server      (root fallback)
  - /oauth/.well-known/oauth-authorization-server (historical)
  - /.well-known/openid-configuration            (OIDC fallback)

A single buildAsMetadata() helper keeps them in sync.
2026-05-28 19:47:47 +02:00
Marco Sadjadi
d2b19a5439 fix(preview): max_tokens 4096→8192 + detect truncation explicitly
All checks were successful
Deploy to Production / deploy (push) Successful in 1m24s
Root cause of repeat 422s: 4096 was too tight for ambitious prompts
(Marco's research-assistant prompt produces ~12kB of JSON before the
model gets cut off mid-string). The error then surfaced as an opaque
"Unterminated string in JSON" zod failure instead of pointing the user
at the real problem.

Two fixes:
- maxTokens back to 8192 (the original) for all Claude tiers, 4096 for
  GLM. Timeouts bumped to 95s — Sonnet 4.6 at ~130 tok/s does 8192 in
  ~63s, ~30s headroom for cold starts, still under Cloudflare's 100s
  edge cap.
- Detect stop_reason === 'max_tokens' on the Anthropic response BEFORE
  parsing and throw the new SpecTruncatedError. /preview catches it
  and returns 422 spec_too_large with a clear "split the prompt"
  message instead of leaking the zod parse failure.
2026-05-28 19:34:40 +02:00
Marco Sadjadi
979d1abfca feat(preview): log spec validation failures with raw output
All checks were successful
Deploy to Production / deploy (push) Successful in 1m25s
422s from /preview hid the actual reason: zod_message tells which field
was wrong and a 400-char preview of the model output reveals refusals
or non-JSON returns. Both stay in the api log only — never surfaced
to the client unchanged.
2026-05-28 19:19:57 +02:00
Marco Sadjadi
5a8e736113 fix(llm): preview timeout 60s→90s + maxTokens 8192→4096
All checks were successful
Deploy to Production / deploy (push) Successful in 1m21s
Enterprise plan was hitting SpecTimeoutError exactly at 60s because the
Sonnet 4.6 preview was budgeted for 8192 tokens at ~80 tok/s (≈102s
worst case) inside a 60s window. The frontend then rolled back to step
1 with no spec.

A real spec is small (<= ~10 tools, ~1.5–2.5k output tokens in practice)
so 4096 is plenty and lets even Sonnet finish in ~51s worst case. The
90s timeout buys headroom for cold starts while staying under
Cloudflare's 100s edge cap. Hobby/GLM bumped to 90s too — same
headroom argument.
2026-05-28 18:51:51 +02:00
Marco Sadjadi
1093dc40a7 fix(runner): correct PUBLIC_URL + mount runner-map volume
All checks were successful
Deploy to Production / deploy (push) Successful in 1m38s
Two overlapping bugs were killing OAuth discovery for every external
MCP client (Claude Desktop, Cursor, etc.):

1. worker.ts injected PUBLIC_URL=http://<RUNNER_HOST>:<port> into the
   runner container even when MCP_DOMAIN was set. Result: the runner's
   /.well-known/oauth-protected-resource advertised an unreachable URL
   and the WWW-Authenticate header pointed at a non-HTTPS loopback
   address. Claude Desktop refused to follow the discovery chain.
   Now derives PUBLIC_URL from the same computePublicUrl() helper that
   builds the user-visible URL stored in mcp_servers.public_url, so the
   container's self-reported resource matches its actual route.

2. docker-compose.prod.yml never mounted /opt/buildmymcpserver/runner-map
   into the api / generator containers. The .conf snippet written by
   the generator landed in an ephemeral container path; the host
   inotify watcher saw an empty directory and produced an empty
   runner-map.combined. Result: nginx 404'd every /<slug>/* request,
   the runner was unreachable from the public domain, and OAuth
   discovery couldn't even begin. Mount added to both services.

Existing weather server has the wrong PUBLIC_URL baked in and must be
recreated after deploy. No customers yet.

export computePublicUrl from deploy.ts so worker.ts can call it.
2026-05-28 17:54:56 +02:00
Marco Sadjadi
3a05766f88 fix(oauth): allow generic RFC 7591 DCR + expand install snippets
All checks were successful
Deploy to Production / deploy (push) Successful in 1m28s
- /oauth/register: drop resource_required check, accept generic
  registrations (Claude Desktop omits resource in DCR body per spec).
  serverId stored as NULL; /authorize still enforces org-ownership
  + access-token aud claim still pinned to resource. Fixes Claude
  Desktop DCR failure (ofid_d7e39530c109fa7f).
- /oauth/authorize: skip strict server.id check when client.serverId
  is NULL (generic client); org check remains the security boundary.
- schema: oauth_clients.server_id no longer NOT NULL.
- migration 0002: ALTER COLUMN server_id DROP NOT NULL (already
  applied on prod).
- install-snippets: add Claude Code (CLI), VS Code, Codex, raw URL
  tabs. Claude Desktop now shows form-field values (Name / Remote MCP
  Server URL / OAuth Client ID / Secret) matching the new Custom
  Connector UI instead of the obsolete JSON config.
- types: InstallTarget enum extended.
- hero-video: clicking the audio toggle restarts the video from
  frame 0 so unmute aligns with the spoken opening.
- marketing: drop em-dashes from rendered copy.
2026-05-28 17:20:01 +02:00
Marco Sadjadi
e75f9ad4fe feat(marketing): real brand logos for the integrations grid
All checks were successful
Deploy to Production / deploy (push) Successful in 1m2s
Owner: "die logos müssen stimmen echte sein fetche sie." Replaced the
ASCII single-character marks (P / S / N / G / S / {}) with the actual
brand SVGs.

Sources:
- PostgreSQL, Notion, GitHub, Stripe paths from Simple Icons (CC0,
  https://simpleicons.org). Inlined as React components with
  fill="currentColor" so the icon colour is CSS-driven and matches
  whatever foreground the brand chip uses.
- Salesforce was deindexed from Simple Icons in 2022 at the brand's
  request, so I drew a clean generic cloud in the same silhouette
  family — close enough to read as Salesforce-cloud-shape without
  copying their trademarked mark.
- Custom REST gets a stylised pair of curly braces rendered as
  stroked paths, signalling "any HTTP API" without pretending to be
  a specific brand.

Brand colours used as chip backgrounds, all official values:
- PostgreSQL #336791  · Salesforce #00a1e0 · Notion #ffffff
- GitHub     #181717  · Stripe     #635bff · REST   #6366f1

Notion is the one inversion — its mark is rendered in #0a0a0b on a
white chip because that's how Notion's actual brand mark reads. The
others all render the icon in white on a brand-colour chip.

Use of the marks is nominative fair use — they show compatibility
with each platform, not endorsement.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 16:54:04 +02:00
Marco Sadjadi
7a32385e2b feat(marketing): give each below-the-fold section its own visual archetype
All checks were successful
Deploy to Production / deploy (push) Successful in 1m2s
Owner: "die sektionen unter dem video sehen viel zu ähnlich aus — das
kannst du besser." Correct — every section was the same `panel + 3-col
grid` pattern, no page rhythm. Each section now reads as its own type
of moment:

- **Clients** ("Connects everywhere your AI lives"): typographic logo
  row, no panels. Each client carries a small mono mark in a 7×7 box
  (C, ⌘, ✦, <>, →) plus a 17px tracking-tight wordmark. Group hover
  flips the mark and label to the accent colour so the row reads as
  interactive trust signal, not a wall of text. Generous py-20/24
  spacing — this is a beat between sections, not a feature card.

- **Examples** ("Wrap any HTTP API. In minutes."): asymmetric 2-col
  header (h2 left, supporting copy right) over a 3-col card grid
  where each integration carries a coloured 48×48 brand mark —
  Postgres `#336791`, Salesforce `#00a1e0`, Notion black-on-white,
  GitHub `#181717`, Stripe `#635bff`, Custom REST `#6366f1`. The marks
  give each card its own visual identity, breaking the uniform-card
  pattern. h2 sized 32/40 px (was a flat 28 px).

- **Marketplace** ("Skip the prompt. Fork what works."): split layout.
  Left column: eyebrow + headline + supporting paragraph + bullet
  list of the three selling points (no longer equal-weight cards) +
  PulseLink CTA. Right column: new `MarketplaceMock` — a faux-browser
  frame containing four realistic template cards (notion-search /
  github-issues / stripe-readonly / linear-tasks) with author chips,
  ✓ verified badges, tool counts, and a fork glyph. Visitor SEES the
  marketplace instead of reading copy about it.

- **Pricing** ("Pay for tool calls. Not for boilerplate."): 4-card
  row but Pro is featured — indigo border, indigo glow shadow
  `0 0 0 4px rgba(99,102,241,0.12)`, "RECOMMENDED" pill floating at
  -top-3, and accent-coloured feature bullets. Other tiers stay
  calm so the eye lands on Pro first. Price typography enlarged from
  26 px to 40 px so prices read as the headline of each card.

Spacing rhythm: every section is now py-20/28 sm:py-24/28 (was
py-12-14 sm:py-16-20) — gives the below-the-fold the breathing room
it needed; the page no longer feels like a stack of crammed cards.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 16:36:06 +02:00
Marco Sadjadi
05746e13e6 fix(video): drop WebM source + load()-before-play() + open-in-tab fallback
All checks were successful
Deploy to Production / deploy (push) Successful in 59s
Owner: "wird nicht richtig gestream hab browser daten gelöscht aber kann
[nicht]" — clearing the cache didn't help. Three things changed:

1. **Single MP4 source.** Chrome listed the WebM source first because
   we offered it first; on the owner's setup the VP9 decode appears to
   stall silently and Chrome does NOT fall back to MP4 — it parks the
   element at networkState=2/readyState=0 forever. Removing the WebM
   source forces Chrome onto the MP4 (Main profile / yuv420p / TV-range
   / faststart, 2.6 MB) which we've already verified plays correctly.

2. **.load() before .play() in togglePlay.** When the original autoplay
   was blocked before the source ever fetched, some Chrome builds leave
   the element in a "stuck unloaded" state where subsequent .play()
   calls inside a user gesture also no-op. Calling .load() first resets
   the resource-selection algorithm, then .play() fetches and plays.

3. **playFailed escape hatch.** If .play() still rejects even after
   .load() + user gesture (extension sandbox, hardware decoder
   failure), surface a small "your browser blocked playback — open
   the video directly" link to the raw MP4. The visitor isn't trapped
   staring at a poster.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 03:26:56 +02:00
Marco Sadjadi
b464b5640f feat(video): play-overlay for blocked autoplay + click-to-play
All checks were successful
Deploy to Production / deploy (push) Successful in 1m0s
Owner reported "video läuft nicht, sehe nur foto" — classic blocked-
autoplay on browsers with prefers-reduced-motion / data-saver / strict
autoplay policies. The poster sat there forever and the visitor
thought the page was broken because the only control was a tiny
mute pill they didn't realise would also start playback.

Fixes:
- Tracks `playing` state via the video element's own play/pause events
  so React knows whether the browser actually granted autoplay.
- Renders a large centre PLAY button overlay whenever the video is
  paused. The button covers the full frame (universal YouTube / Vimeo
  pattern: click anywhere on the video to play); the inner indigo
  circle with the triangle is the visual affordance, with hover scale
  for tactile feedback.
- Wires onClick directly on the <video> element too so the click-
  anywhere-to-play works whether or not the overlay happens to be up.
- Mute toggle now calls e.stopPropagation so tapping it doesn't
  accidentally trigger play/pause via the video's onClick handler.
- Best-effort .play() call in the mount effect, with the rejection
  silently swallowed — failure just means the user has to click play
  themselves, which the overlay already affords.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 03:21:04 +02:00
Marco Sadjadi
438ce3cfbc feat(video): v10 hero video with mute toggle — voice + bg music
All checks were successful
Deploy to Production / deploy (push) Successful in 1m6s
Ships the long-form (71.5 s) hero video to the marketing /flow section
along with the iteration trail of architectural visual fixes the owner
worked through over the last sprint.

## Video composition (remotion/)

Eight phases driven by the 71.47 s voice-over in `audio.mp3` plus the
`Sub-bass Lullaby.wav` background music (ducked to 0.16 with fade in /
fade out). Every scene was rebuilt for v10 with concrete fixes:

- **HookScene** (12 s) — adds FloatingChaos overlay: a docker-compose
  excerpt, an oauth_callback.ts snippet, an .env file with a yellow
  squiggle warning ("in git history since v0.3.1"), and a live-ticking
  502 retry toast. Tangle now reads as a developer's desktop right
  before they give up, not as four icons drifting.

- **PromptScene** (12.2 s) — 6.5 s post-typing dead-zone replaced with
  the parse beat: three sequential highlights on the prompt text
  (MCP server / searches / Notion workspace), three chips below the
  input (intent / tool / secret → vault), three-stat summary panel
  (tools · 2, secrets · 1, targets · 3). At local frame 250 (≈ 21 s
  global, on the voice line "the prompt path and the secret path
  never cross") a mini two-rail diagram with an explicit X-marker
  ring lands, visualising the architectural promise the moment it's
  spoken.

- **SecretsScene** (15.2 s) — kept the arrow-fork + AES-256 stamp +
  env-var injection beats; added the lock-snap flash at frame 66,
  pinned the vault at full opacity throughout, and added a dashed
  vault → container connector so the secret's provenance is visible.
  The "what the AI sees" panel is now 680 px wide with an eye icon,
  four corner viewfinder brackets around the prompt text, and three
  explicit denied lines (no secrets / no environment variables / no
  tokens).

- **BuildScene** (7.2 s) — unchanged beats: streaming log, server
  card emerges with code + 🔒 NOTION_API_KEY slot pills, isolated-
  container caption, <60s countdown.

- **IsolationScene** (14 s) — completely restructured. Orbit-and-dock
  chips that collided with the card and with the tokens-only badge
  are replaced by a clean vertical chip column at x=760: read-only
  filesystem · dropped capabilities · no new privileges · 512 MB
  memory cap · 0.5 CPU limit · ✓ your token only (last in green).
  A vault graphic now sits below the server card with a dashed arrow
  up into its env slot so the architecture story is complete in one
  frame. PKCE jargon removed: "OAuth 2.1 · PKCE" → "only your token
  gets in" with a small "oauth 2.1 · proof-key flow" subtitle for
  the curious. Handshake stages simplified to your client → verified
  → scoped token. Final settlement arrow in success-green curves
  from the scoped-token pill back into the card.

- **LibraryScene** (7 s) — cards enlarged from 340×180 to 400×220
  with 36 px gaps. The "templates carry code, not credentials"
  sub-caption was pulled (felt on-the-nose; the detached lock and
  empty NOTION_API_KEY=? slot carry the story visually).

- **DiscoveryScene** (3 s) — the most-iterated scene. Earlier
  versions had a fake "1,200+ developers building" fork counter
  (pulled — solo-founder, hadn't earned). Replaced with a two-lane
  architecture diagram that visualises "no paths cross" literally:
  top lane prompt → AI → code, bottom lane vault → encrypted →
  env, both converging at the server box on the right. v10
  refinements: all seven boxes visible from frame 0 (no late
  server arrival), a parallel glow tour walks across both lanes
  simultaneously, a dashed vertical divider with a "no shared
  node" chip pinned in the middle, and the closing line "One
  sentence in. Live server out." slides down from above and lands
  centred while the diagram fades to 0.12 opacity behind it —
  no overlap.

- **LogoLockup** (1.7 s) — wordmark + fade-to-black for a clean
  loop seam.

The Subtitle / CAPTIONS layer added in v7 was pulled wholesale —
owner found the kinetic-typography overlay aggressive and noted
that technical terms (PKCE etc.) created friction with no payoff.
Scene visuals and voice now carry the whole story; the Subtitle
component file is retained for possible future use.

Render pipeline (`render:mp4` / `render:webm` / `render:poster` in
remotion/package.json) is unchanged. The MP4 is post-processed to
H.264 Main / yuv420p / TV-range with faststart + AAC audio. The
WebM is re-encoded at VP9 CRF 38 / Opus 64k to stay under the 3 MB
budget. Final artefacts in apps/web/public/videos/: 2.59 MB mp4,
2.99 MB webm, 62 KB poster.

## Web integration (apps/web/components/hero-video.tsx)

New client component wraps the <video> element and pins a frosted-
glass mute toggle bottom-right of the player. Why not native
`controls`: the browser chrome fights the section's design vocabulary
and we only need one affordance — unmute — so we render exactly
that. The toggle's icon flips between VolumeX (currently muted) and
Volume2 (currently unmuted), accent colour switches indigo when sound
is on. Initial state is muted so autoplay still fires; on unmute we
call .play() defensively because mobile Safari pauses on
muted-property changes mid-playback.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 02:31:10 +02:00
Marco Sadjadi
6197ee7f5e feat: particle cloud (no discrete dots) + geo-IP country preselect on login
All checks were successful
Deploy to Production / deploy (push) Successful in 1m1s
Two coordinated polish moves the owner asked for.

## 1. Hero particle field — "no white dots, just a glow that follows the mouse and is always in motion"

Previous tuning (uPointSize 2.8, uBaseAlpha 0.6) gave discrete indigo
dots that additively saturated to near-white in dense clusters. The
owner wanted no granular dots visible at all — a continuous indigo
cloud that the cursor pulls toward itself.

Changes:

- **Render fragment**: replaced the anti-aliased disc SDF
  (`smoothstep(0.5, 0.42, d)` — hard edge) with a Gaussian falloff
  (`exp(-d * d * 6.0)` — smooth blob, no edge). Each particle is now
  a soft volume that blends seamlessly with neighbours.

- **Sim fragment**: replaced the outward-gradient ring push with a
  mouse-halo attraction. Particles drift toward an ideal radius
  (~0.20) around the cursor, with exp-bell falloff so they don't
  collapse onto the cursor or feel influenced from across the canvas.
  `ringField()` helper is now unused but kept for future use.

- **JS uniforms**: `uPointSize` 2.8→14 (256-tier) / 3.6→20 (128-tier);
  `uBaseAlpha` 0.6→0.055. Individual particles are below the
  perception threshold for "dot" but 65k of them additively composite
  into a continuous cloud. With the much lower per-particle alpha,
  the cumulative brightness never saturates to white.

- **ParticleField tick loop**: asymmetric ring-active fade — `alpha
  = 0.14` ramping in (fast cursor response), `0.012` decaying out
  (slow glow trail after the pointer moves away). Matches the brief
  "glow longer + attractive to mouse but always in motion".

- **ParticleHero index.tsx**: added an always-on indigo radial
  gradient behind the WebGL canvas, so the hero never reads as
  visually empty between frames — the canvas additively paints the
  dynamic cloud on top. Removed the white-dot stipple from the
  static fallback (it was the most likely source of the "weisse
  punkte" complaint for any visitor on the fallback path).

## 2. SMS login — pre-select country picker from visitor's geo-IP

The country picker on `/login` previously defaulted to `'CH'` for
everyone. Visitors from DE / AT / US / etc. had to manually scroll
to their dial code — small friction but it sits on the highest-stakes
conversion step in the funnel.

- **New API route** `apps/api/src/routes/geo.ts` →
  `GET /v1/geo/country` returns `{ country: 'CH' | 'DE' | … | null }`
  by reading Cloudflare's `CF-IPCountry` header. Public, no auth —
  reading a 2-letter country code from a geo-IP header isn't PII
  under GDPR / DSG. `'XX'` and `'T1'` (CF's "unknown" + Tor) are
  normalised to `null`. Outside CF (dev), header is missing → null.

- **Login page** picks up the result in the existing `useEffect`,
  guards against codes not in our country list, and calls `setCountry`
  to override the `'CH'` default. Stays at `'CH'` if the detection
  fails or the visitor is on a Tor exit. Verified live: the endpoint
  returns `{"country":"DE"}` from CF's German edge.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 13:17:20 +02:00
Marco Sadjadi
035e55f00c feat(web): mobile-fit hero tiles + voluminous calmer particle field + FAQ accordion
All checks were successful
Deploy to Production / deploy (push) Successful in 1m2s
Three coordinated polish items requested:

1. **Hero step-rotator tiles fit mobile without horizontal scroll.**
   The previous snippets contained a 50+ char `Live at https://notion-x9.mcp.buildmymcpserver.com` URL that overflowed the ~295 px text area on a 375 px viewport. Rewrote all three snippets to be naturally short — same product story, no full URLs. The <pre> drops `overflow-x-auto` and gains `whitespace-pre-wrap break-words` so any token that does exceed the column wraps gracefully instead of forcing a scrollbar.

2. **ParticleHero — more volumetric, slower, steadier at load-in.**
   The "stuttery / too fast" feedback came from two issues compounding: tiny dots (1.8 px on 256-tier, with 0.42 base alpha) gave the eye too few pixels to track between frames, so individual particles read as snapping rather than drifting; and the simplex-noise drift evolved at 0.08 time-scale with 0.045 velocity, fast enough that frame-to-frame deltas exceeded a tracked particle's diameter.

   Render uniforms tuned:
   - `uPointSize` 1.8 → 2.8 (256-tier), 2.4 → 3.6 (128-tier)
   - `uBaseAlpha` 0.42 → 0.60

   Simulation shader tuned:
   - Drift noise time scale 0.08 → 0.045 (the most impactful single change — particles now move at half the previous speed)
   - Drift velocity magnitude 0.045 → 0.028
   - Ring breathing noise time scale 0.35 → 0.22
   - Ring polar-wave time scales 1.2 / 0.7 → 0.7 / 0.42

   Net effect: same number of particles (65k) but each individually larger, brighter, and moving more slowly. The cumulative additive bloom is denser without the jitter that read as visual stutter.

3. **FAQ collapsed into a native `<details>` accordion.**
   Crawlers and screen readers still see every Q+A in the SSR'd HTML — `<details><summary>...</summary><p>answer</p></details>` is the standard semantic pattern for disclosure widgets. Users see one question at a time and expand on demand, which keeps the page from feeling like an endless wall of marketing text below the fold.

   Container narrowed `max-w-6xl` → `max-w-3xl` for accordion typography (long-form prose reads better single-column). The default WebKit disclosure-triangle marker is suppressed with `list-none` + `[&_summary::-webkit-details-marker]:hidden`, and a `lucide-react` `ChevronDown` icon rotates 180° via `group-open:rotate-180` to indicate state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 12:35:03 +02:00
Marco Sadjadi
6f8b8da151 feat(web): glow-pulse on primary CTAs + hero fills full first viewport
All checks were successful
Deploy to Production / deploy (push) Successful in 1m1s
Two coordinated polish moves:

1. **<PulseLink> / <PulseButton>** — new `apps/web/components/pulse.tsx`.
   Click anywhere on a wrapped link or button and a small indigo dot
   detonates from the click point, scaling 1x→80x over 650ms before
   fading to transparent. Same visual language as the hero load-in
   glow — the click effectively says "this is the brand reaching back."

   The dot lives in a `pointer-events: none` overlay, so it never
   blocks the underlying navigation. `overflow-hidden + relative` are
   added to the host so the bloom stays inside the rounded shape.
   `glow-pulse` keyframe sits in globals.css next to the existing
   `pulse-dot` / `shimmer` / `fade-in` definitions; reduced-motion
   suppresses the animation to instant-opacity-0 so the click flow
   is preserved without the bloom.

   Wired into the highest-conversion CTAs only — the user explicitly
   asked "wo's Sinn macht":
   - Hero "Start building free" + "Read the docs"
   - Marketing header Login / Dashboard button
   - Dashboard header "+ New server" pill

   Deliberately NOT applied to dashboard nav links, logout, destructive
   buttons, form internals, carousel dots — pulse on every click would
   be noise.

2. **Hero fills 100svh − nav** (`min-height: calc(100svh - 3rem)`).
   `svh` (small viewport height) instead of `vh` so the hero doesn't
   jump when the mobile address bar hides/shows. The 3rem subtracts
   the sticky marketing nav (h-12 = 48px), so the hero ends right at
   the loadscreen's natural bottom edge.

   `flex items-center` plus the inner grid's existing `md:items-center`
   keep the content vertically centred inside the tall section. The
   ParticleHero background now has cinematic-scale room and the indigo
   radial-glow + dot-mask read as the dominant background motif —
   which is the effect the user loved at load-in.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 12:20:25 +02:00
Marco Sadjadi
0cf9c66b6b feat(web): restore tall hero + carousel slide + viewport-fixed scroll cue
All checks were successful
Deploy to Production / deploy (push) Successful in 1m0s
Three coordinated tweaks to the landing-page above-the-fold:

1. **Hero padding restored to py-14/sm:py-20/md:py-28** (was py-12/14/16).
   Compressing it for the scroll-cue position fight made the hero feel
   cramped and gave the ParticleHero background less room to breathe.
   With the cue moved out (see #3), there's no reason to shrink the hero.

2. **Step rotator switches to carousel-style horizontal slide.** The
   AnimatePresence transition was a fade+y-shift cross-fade — clean but
   sequential. Now the leaving card slides left out (x:-220) while the
   entering card slides right in (x:220→0), both coexisting in the same
   3D-space and inheriting the same mouse-tilt. The container gets
   `min-h-[240px]` so the absolutely-positioned cards have layout to
   anchor to (claude_desktop_config.json is the tallest at 7 lines).
   Reduced-motion still gets the opacity-only cross-fade — sliding
   content sideways is exactly the kind of motion that preference is
   meant to suppress.

3. **`<ScrollCue>` extracted into its own client component**, fixed-
   positioned at viewport bottom (bottom-5) with a frosted pill style.
   Fades to opacity:0 once `window.scrollY > 80`, so it doesn't shadow
   the rest of the page. Lives next to `<section>` in page.tsx rather
   than inside the hero — that way it anchors to the loadscreen's
   natural bottom edge whether the hero is short or tall.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 12:11:42 +02:00
Marco Sadjadi
e4e437c44c feat(web): hero redesign — cycling step rotator + full-width video section
All checks were successful
Deploy to Production / deploy (push) Successful in 1m2s
Restructures the landing page above-the-fold into two distinct sections:

1. **Hero — left copy + cycling tile, no static stack of three blocks**
   New `<HeroStepRotator>` (Framer Motion client component) shows ONE
   tile centred in the column, cycling prompt.txt → build.log →
   claude_desktop_config.json every 3.5s. Auto-advance pauses on hover
   and exposes a 3-dot tablist so users can jump to any step. The active
   dot grows wide with an accent glow.

   Mouse interaction: spring-smoothed 3D tilt on rotateX/rotateY plus a
   radial glow that translates toward the cursor — both driven by motion
   values, so the transforms stay on the GPU compositor instead of
   re-rendering on every mousemove. `useReducedMotion()` strips the
   tilt + glow translation and collapses the page transition to an
   instant cross-fade (the rotation itself still advances — it's content,
   not decoration).

   Hero padding tightened (py-12/14/16 vs py-14/20/28) so the video
   section below is teased above the fold. New scroll cue ("see it run"
   + animated chevron) sits at the bottom of the hero, anchored to
   #flow.

2. **Flow video — full-width edge-to-edge under the hero (new section)**
   The hero.mp4 / hero.webm pair moves out of the "How it works"
   section into its own #flow section. No max-w wrapper — it spans the
   viewport with `w-full aspect-video`, so on a 1080p monitor the video
   gets the full 1920px width. Adds a subtle radial vignette so the
   black edges blend into the page chrome.

3. **"How it works" — now lean**
   Video removed (it's the flow section now). Just the three textual
   cards as supporting copy.

Adds `framer-motion@11.18.2` to apps/web/package.json. Build passes
typecheck + Next.js production build with no new warnings; LCP path is
untouched since the rotator is client-hydrated after first paint and
Framer Motion is tree-shaken to the components we import.

Note: visitors with `prefers-reduced-motion: reduce` will still see the
video's poster instead of autoplay — Chrome blocks the network fetch
entirely for autoplay media when reduced-motion is set. The flow video
remains visible for the rest, and the step rotator continues to cycle
its content (with instant cross-fade instead of slide+scale).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 12:05:28 +02:00
Marco Sadjadi
22ba23f353 fix(video): make Beat 2 visible — bigger particles, parallel schematic stroke
All checks were successful
Deploy to Production / deploy (push) Successful in 52s
User report: "I only see 'Search our Notion workspace' — no video."
Cause: Beat 2 (frames 55-165) was a near-empty dead moment. Particles
were 1.5-2.5px on a 1080p canvas (nearly invisible), and the server
schematic didn't start drawing until local frame 30 (= global 85),
leaving a 30-frame gap of empty space mid-clip. The viewer's brain
correctly registered "the video stops after Beat 1."

Fixes:
- 60 particles (was 36) at radius 6→3 with SVG Gaussian-blur glow
  filter, always indigo (was an indecisive two-color split).
- Schematic stroke starts at local frame 8 (was 30) so the box draws
  IN PARALLEL with particle convergence — eye always has something
  to track.
- Central radial-glow attractor visible the whole beat — gives the
  "something is forming here" cue before the schematic appears.
- Server schematic enlarged 460×300 → 720×420 so it commands
  attention rather than feeling small.
- Inner tool-row dots and port dots doubled in size with stronger
  drop-shadow.
- Beat 3 schematic + client panel sizes scaled to match, and the
  wire base position adjusted (server CX moved from 960 to 760 so
  the wire has room to breathe before reaching the client).
- Poster frame moved from 60 (mid-fade dead spot) to 180 (Beat 3
  Connection layout — the most "this is a real product" shot).

File sizes still well under budget: 514 KB mp4, 319 KB webm, 29 KB poster.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 11:06:26 +02:00
Marco Sadjadi
fd147f9998 feat(web): Remotion hero video — Section 2 (prompt → server → connect)
All checks were successful
Deploy to Production / deploy (push) Successful in 1m13s
New @bmm/video workspace at remotion/. Renders an 8s 1920×1080 H.264
+ WebM + JPG poster sequence that visualises the three-step "How it
works" pitch literally:

- Beat 1 (0-2s): "Search our Notion workspace" word-by-word entrance
  with spring-in from below + brief indigo under-glow + monospace
  prompt.txt label. Blinking cursor bridges the loop seam.
- Beat 2 (2-5s): each prompt word detonates into ~9 particles per
  word; particles drift, then magnetically converge onto target slots
  along a server schematic that strokes itself on. Scan-line sweep +
  corner labels (mcp-notion, OAuth 2.1, search_pages, get_page_content)
  sell that this is a real artefact, not a placeholder.
- Beat 3 (5-8s): Claude Desktop client panel slides in from the right;
  a Bézier wire animates between server and client; three data-packet
  dots travel along the wire; 200-OK tag pops; green live-dot pulses
  on the server. Last 12 frames fade to black so frame 239 ≈ frame 0
  and browser <video loop> has no visible seam.

Brand palette is hard-coded in lib/colors.ts to match globals.css —
keeps the Remotion bundle self-contained (no Tailwind import needed).
springIn / softSpring / clampLerp / rand helpers in lib/easings.ts
power the motion vocabulary. Concurrency=1 + yuv420p in the config
gives a deterministic render that plays on every <video> tag.

File sizes: hero.mp4 449 KB, hero.webm 258 KB, hero-poster.jpg 33 KB —
all well under the 3 MB / 250 KB ceilings.

Section 2 ("How it works") now opens with the video in a
border-bordered aspect-video panel between the heading and the three
existing cards. autoPlay+muted+loop+playsInline satisfies every mobile
autoplay policy; motion-reduce:hidden swaps in the static poster for
prefers-reduced-motion users.

Scripts:
- pnpm --filter @bmm/video render:all  (mp4 + webm + poster)
- pnpm --filter @bmm/video to-web      (copy to apps/web/public/videos/)
- pnpm --filter @bmm/video build       (both, end-to-end)

`to-web` is the script name because `publish` collides with pnpm's
built-in npm-publish command which refused to run with an unclean tree.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 10:57:08 +02:00
Marco Sadjadi
591a1cb575 ops: backup hardening + restore drill + self-hosted uptime monitor
All checks were successful
Deploy to Production / deploy (push) Successful in 1m10s
Adds /opt/bmm-ops/ scripts (deployed separately from the app, so tar
overlays don't clobber them) for three previously-missing production
readiness items:

1. Backup hardening (backup.sh):
   - Previous cron one-liner did pg_dump | gzip with no validation.
   - Now: pipefail-safe pg_dump, gunzip -t integrity check, pg_dump
     header sanity (scans first 5 lines — line 1 is just "--", actual
     "PostgreSQL database dump" comment lands on line 2), size-warning
     under 1KB, atomic move-into-place so partial backups never replace
     the previous good file. 14-day retention preserved.
   - Optional offsite via BMM_BACKUP_REMOTE (rclone). Reads env via
     grep+cut, NOT `source` — the .env.production has unquoted text
     values (e.g. ADMIN_NAME) that crash a sourced shell.

2. Restore drill (restore-test.sh, Sun 04:30 UTC weekly):
   - Restores the newest backup into a throwaway DB inside the same
     Postgres container, verifies the core tables exist (users,
     sessions, oauth_tokens, mcp_servers), drops the temp DB. Proves
     backups are actually restorable, not just byte-streams that look
     like backups. Silent-corruption detector.

3. Self-hosted uptime monitor (uptime-check.sh, every 5 min):
   - Probes homepage + /api/health + /robots.txt.
   - Edge-triggered alerting: SMS via Twilio only on up→down and
     down→up transitions (avoids SMS storm during sustained outages).
   - Pings HEALTHCHECKS_HEARTBEAT_URL on every success — when the box
     itself dies the heartbeat stops and the external watchdog alerts
     (covers the gap that self-hosted monitors can't see their own
     box failing).

notify.sh is the shared helper: Twilio SMS if all four creds set,
optional webhook to HEALTHCHECKS_FAIL_URL, always logs to syslog. Never
fails loudly — broken notification path still lands in journalctl
-t bmm-ops.

README.md documents the 3-2-1 strategy, manual full-recovery
procedure, and how to enable offsite (R2 / B2 / Hetzner Storage Box).

Smoke-tested all three on prod: backup wrote 8004 bytes with checks
passing, restore-test confirmed schema, uptime probe returned up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 23:46:42 +02:00
Marco Sadjadi
2267daadd4 perf(web): server-only StaticCodeBlock for above-the-fold marketing
All checks were successful
Deploy to Production / deploy (push) Successful in 52s
PageSpeed Insights mobile reported LCP element render delay of 2.3s
on the hero — the largest visible element is the build.log <pre> with
"> Generating spec... OK ..." text. TTFB is 0ms (CF cache hit), so the
delay was pure client-side: Lighthouse waited for the JS bundle to
parse and the 'use client' CodeBlock boundary to hydrate before it
considered the element "rendered."

CodeBlock pulls in lucide-react (Copy/Check icons) plus a useState
boundary just for the copy button. Above the fold on marketing, none
of that is needed — the user just needs to see the snippet.

Split:
- New `static-code-block.tsx`: server component, no 'use client',
  no icons, no copy button. Pure SSR markup that paints with the HTML.
- Marketing landing now uses StaticCodeBlock for all three hero
  snippets (prompt.txt / build.log / claude_desktop_config.json).
- Interactive CodeBlock stays in use for dashboard pages where users
  actually want to copy snippets.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 23:30:41 +02:00
Marco Sadjadi
9f1135325c feat(web): drop 'newest' sort + width-cap categories on /templates
All checks were successful
Deploy to Production / deploy (push) Successful in 52s
Two narrow fixes for mobile chip-row width:
- Removed the 'newest' sort button. Trending and Top cover the use
  cases; newest was largely redundant with Top sorted on createdAt.
- Capped the categories <select> at 140px (160px on sm+). Long
  category names were stretching the box and pushing the
  horizontally-scrollable chip row beyond a sane width on phones.
  Native <select> truncates the visible label with ellipsis; the
  dropdown panel still shows full names when opened.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 19:27:57 +02:00
Marco Sadjadi
00c6692c7a feat(web): mobile-responsive /templates + drop pre-launch SiteBanner
All checks were successful
Deploy to Production / deploy (push) Successful in 57s
Two related polish items:

1. Remove the global blue Preview banner from app/layout.tsx and delete
   the SiteBanner component. The component's own comment said "Remove
   once the service is open for production use" — Stripe live billing,
   OAuth, and per-runner TLS are all wired now, so the pre-launch notice
   is misleading.

2. Mobile-responsive treatment for the standalone /templates page (it
   lives outside (dashboard) layout, so it didn't inherit the new
   mobile chrome from the dashboard pass):
   - Top header tightened: "/templates" breadcrumb + Dashboard link +
     "+ New server" pill all hidden on mobile (the avatar UserMenu +
     bottom MobileActionBar cover those paths).
   - Logged-in users now get the same MobileActionBar tab-bar at the
     bottom (Market tab active), giving consistent app-shell across
     dashboard pages.
   - Filter row stacks vertically on mobile with search on top (thumb
     reach), then a horizontally-scrollable chip row for scope / sort /
     category so segmented controls don't squeeze below their min-width.
   - h1 scales 32px → 24px on mobile; padding tightened to px-4 py-8.
   - main gets pb-24 when logged in so cards clear the tab bar.

Logged-out marketplace browsing keeps the simpler marketing chrome
(Logo + "Start building" CTA) — no tab-bar, since visitors don't have
a dashboard to navigate into yet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 06:43:56 +02:00
Marco Sadjadi
f80bd8afbe feat(web): app-like mobile dashboard — bottom tab bar, minimal top
All checks were successful
Deploy to Production / deploy (push) Successful in 52s
Top header on mobile was cramped: Logo + 5 icon-only nav buttons + avatar
crammed into a 48px-tall row. Felt like a desktop nav shrunk down.

Pivot to native-mobile-app pattern:
- Top mobile: just Logo (left) + UserMenu avatar (right). Desktop top nav
  is `hidden sm:flex` so it disappears on phones.
- Bottom: full tab bar replacing the single-button MobileActionBar.
  Five destinations: Overview · Servers · Create (FAB-style center) ·
  Market · Settings.
- "Create" is a raised FAB-style button (round accent fill, -mt-3 to
  overlap the bar border) — same prominent-action pattern as Instagram /
  Notion mobile.
- Active tab gets accent color + aria-current=page.
- Audit demoted from primary nav on mobile (low frequency); still
  reachable via direct /audit URL.

Desktop unchanged — top nav stays.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 23:15:44 +02:00
Marco Sadjadi
a8e6f4fabd fix(web): UserMenu + CountryPicker dropdowns frosted (Tailwind v4 bug)
All checks were successful
Deploy to Production / deploy (push) Successful in 53s
Same Tailwind-v4 bracket-arbitrary issue we hit on the marketing burger
menu: bg-[--color-bg-elevated] compiles to `background-color:
--color-bg-elevated` (no var() wrap → invalid color → transparent).
Both dropdowns were rendering see-through against the dashboard.

Switch both to the proven pattern: backdrop-blur-md class + inline
style for backgroundColor + borderColor using color-mix() and explicit
var(). 88% elevated-panel fill gives a clear frosted-glass look while
keeping the menu items readable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 23:04:02 +02:00
Marco Sadjadi
c656bd3189 fix(web): UserMenu crashes for phone-only signups (null email + name)
All checks were successful
Deploy to Production / deploy (push) Successful in 56s
Dashboard layout threw TypeError: Cannot read properties of null (reading
'charAt') the moment a phone-only user reached any dashboard page —
user.email and user.name are both null for fresh SMS signups, and
the initial-letter computation didn't tolerate it.

Fallback chain for the visible identifier: name → email → phone →
'Account'. Avatar colour seed falls back to userId. The secondary line
under the name also uses phone when email is null.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 22:59:45 +02:00
Marco Sadjadi
d0f3c202eb fix(tls): pivot per-runner TLS to path-routing on single subdomain
All checks were successful
Deploy to Production / deploy (push) Successful in 54s
The per-subdomain approach (*.mcp.buildmymcpserver.com) failed at the
Cloudflare edge — Universal SSL only covers ONE-level wildcards, so the
TLS handshake on slug.mcp.buildmymcpserver.com hits SSL alert 40
handshake_failure. The two paths to fix that (CF Advanced Cert Manager
at $10/mo, or a Let's-Encrypt wildcard via DNS-01 with certbot) both
trade either money or ops for the URL aesthetic.

Pivot to path-routing on the single subdomain mcp.buildmymcpserver.com,
which IS covered by free Universal SSL. publicUrl format changes from
  https://<slug>.mcp.buildmymcpserver.com  →  https://mcp.buildmymcpserver.com/<slug>
No recurring cost, works with the existing CF setup, MCP clients don't
care about the URL shape (it comes from the wizard's install snippet).

Code changes:
- generator/lib/deploy.ts:
    * publicUrl computed as `${MCP_DOMAIN}/${slug}` instead of `${slug}.${MCP_DOMAIN}`
    * writeRunnerMapEntry writes one-line nginx snippet:
        if ($bmm_slug = "<slug>") { set $bmm_port <port>; }
      (was: a map-entry pair "<slug>.<MCP_DOMAIN> <port>;")
- setup-runner-tls.sh:
    * nginx vhost is now single server_name mcp.buildmymcpserver.com
    * regex location captures (?<bmm_slug>...)(?<bmm_path>/.*)?
    * includes runner-map.combined inside the location block so the
      generated if-snippets set $bmm_port; unknown slug → 404
    * proxy_pass strips the slug prefix: /<slug>/foo → 127.0.0.1:port/foo
    * Prereq docs updated: just A-record for mcp (no wildcard needed),
      same Origin CA cert reused
    * Added /health endpoint at vhost root for monitoring

Systemd watcher + map dir + volume mounts unchanged — same file paths,
just different snippet content. Re-running setup-runner-tls.sh on the
host overwrites the wildcard vhost with the new path-based one.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 22:51:30 +02:00
Marco Sadjadi
8c6f04f034 feat: oauth refresh-token grant + per-runner subdomain TLS plumbing
All checks were successful
Deploy to Production / deploy (push) Successful in 52s
OAUTH REFRESH-TOKEN
- oauth_tokens.subject column added (migration applied to prod DB): stores
  the JWT sub claim from the original authorization so refreshes can
  re-mint with the same identity without re-walking the (consumed) code.
- Authorization-code branch now writes subject AND uses a 30-day
  expires_at for the row (was 1h — same as access token, which killed
  refresh after 1h).
- New refresh_token grant branch:
    * looks up token by refresh-hash + expiry
    * client_id must match, client_secret verified if confidential
    * RFC 8707: requested resource must equal stored resource
    * OAuth 2.1 rotation: atomic UPDATE WHERE old_hash → new access JWT,
      new refresh token, extended expiry; loser of a race sees invalid_grant
- Access TTL (1h) and refresh TTL (30d) extracted as constants.

Clients no longer have to re-authorize hourly. Closes Zb-001.

PER-RUNNER SUBDOMAIN TLS (Z1-002)
Code path:
- New MCP_DOMAIN env (e.g. "mcp.buildmymcpserver.com") + RUNNER_MAP_DIR
  (default /var/runner-map) in generator config.
- deployContainer: writes /var/runner-map/<slug>.conf with content
  "slug.MCP_DOMAIN port;" and computes publicUrl as
  https://<slug>.<MCP_DOMAIN>. Falls back to http://host:port when
  MCP_DOMAIN is unset (zero behaviour change until host is configured).
- stopContainer (both api/lib/docker.ts and generator/lib/deploy.ts) now
  accepts an optional slug arg and removes the map fragment. Callers
  (DELETE /v1/servers/:id, admin template takedown) updated.

Infra path (one-time host setup — Marco runs as root):
- scripts/setup-runner-tls.sh:
    1. nginx vhost matching *.mcp.buildmymcpserver.com via regex →
       reads slug→port from /opt/buildmymcpserver/runner-map.combined
    2. systemd inotify service watches the map dir, combines fragments
       on any change, reloads nginx
    3. installs inotify-tools if missing, idempotent
- Prereqs documented at top: Cloudflare wildcard DNS proxied, Origin CA
  cert for *.mcp.buildmymcpserver.com, SSL mode Full (strict).
- After running: edit docker-compose.prod.yml to mount the map dir into
  api + generator, set MCP_DOMAIN in env, recreate containers.

Closes Zb-001 fully. Closes Z1-002 on the code side; one Marco-on-host
action away from closing it on the infra side.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 22:09:06 +02:00