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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
Native <select> defers dropdown direction to the browser, which on mobile
routinely opens upward and hides countries behind the keyboard. Replaced
with a custom combobox that always opens DOWNWARD (absolute positioned
below the trigger) with a search input at top — at 150 countries a
scrollable list is unusable without search anyway.
COUNTRIES list expanded from 60 → 152 entries: every country with a
meaningful diaspora, including Russia, Pakistan, Bangladesh, Sri Lanka,
Cyprus, Malta, Albania, Bosnia, Kosovo, North Macedonia, Iran, Iraq,
Lebanon, Jordan, Kazakhstan, Morocco, Algeria, Tunisia, Ethiopia,
Tanzania, Uganda, Senegal, Ghana, Madagascar, Cameroon, Sri Lanka,
Belarus, Georgia, Armenia, Azerbaijan and the rest. Serbia was already in
the prior list — just unfindable without search.
Bonus: flag emojis computed from ISO-3166 alpha-2 codes (no asset files).
Search matches name + code + dial-prefix so "+41" or "CH" both find
Switzerland.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The clearCookie call on /v1/auth/logout was passing only {path:'/'},
missing the httpOnly + sameSite + secure flags the setCookie used. In
production (secure=true), Chrome treats a Set-Cookie clear directive
without Secure as a *different* cookie — it creates an empty insecure
cookie and leaves the original Secure session cookie in place. Result:
users who clicked "Sign out" stayed logged in for the full 30-day
session lifetime in the browser's view (DB session was destroyed
correctly; only the cookie persisted).
Now both setCookie and clearCookie pull from sessionCookieOpts() so
the attributes can't drift apart again.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Logged-out state was showing two CTAs ("Sign in" link + "Start building"
button) both going to /login — confusing because the prominent purple
button never literally said "Login". Consolidate to one button whose
label flips with auth state: "Login" when out, "Dashboard" when in.
Same slot, same colour, no header layout shift.
Defaults to "Login" while the /v1/auth/me probe is in flight so the
common (anonymous) visitor sees no flicker.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the dependency on an unbuilt email sender. New EMAIL_AUTH_ENABLED
env flag (default false). When off:
- POST /v1/auth/magic-link → 503 email_auth_disabled
- POST /v1/auth/verify → 503 email_auth_disabled
- GET /v1/auth/providers → { email: false, sms, google, github }
- Login page: hides the email/phone tab toggle (only one method),
hides the email form entirely, defaults to SMS/phone tab
Flipping EMAIL_AUTH_ENABLED=true re-enables the magic-link routes and
re-shows the email form section. Schema (magic_links table) unchanged
so this is a 1-env-flip re-enable, not a re-implementation.
SECURITY: closes audit finding Za-001 (account-takeover via
cross-provider email lookup). Without a magic-link flow, an attacker
who controls a target's inbox can no longer claim an existing
OAuth-created account. The remaining provider-mixing surface (Google
↔ GitHub at same email) requires controlling the OAuth provider
account itself, which is each provider's own security boundary.
Active login methods now: Google OAuth · GitHub OAuth · SMS code
(Twilio) · admin password (seeded, single user).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Six confirmed findings closed (3 MEDIUM, 3 LOW). Tier-1 surfaces from
Pass-1 re-verified non-regressed; this pass deepened the audit on the
auth library, OAuth issuer, and template marketplace.
Za-002 MEDIUM (scrypt cost) — bump SCRYPT_N from 2^14 → 2^17 (131072)
matching current OWASP guidance for password hashing in 2026. Hash
format embeds N (`scrypt$N$salt$hash`), so the existing admin
password at the old cost still verifies — backward-compatible. Also
added explicit maxmem ceilings since Node's default (~32MiB) is
insufficient for the new N.
Za-003 MEDIUM (single-use race) — consumeMagicLink was SELECT-then-
UPDATE; two parallel redemptions could both win and mint two
sessions from the same token. Now uses the same atomic
`UPDATE … WHERE id = ? AND consumedAt IS NULL RETURNING id` pattern
/oauth/token already had — loser of the race gets
invalid_or_expired_token.
Za-004 LOW (membership ordering) — `.orderBy(memberships.createdAt)`
added so when org-invites eventually let a user belong to multiple
orgs, the same one wins every login instead of insertion-order
roulette. Latent-bug pre-empt.
Zb-002 LOW (OAuth register spam) — /oauth/register now per-IP daily
rate-limited at 20/day (well above any legitimate MCP-client
bootstrap pattern). Prevents DB-row spam.
Zc-001 MEDIUM (banned-pattern drift) — three separate copies of
BANNED_PATTERNS had drifted apart. The publish-time scanner in
templates.ts was MISSING the 7 new patterns added in Pass-1
(process.binding, dlopen, .constructor.constructor, vm.runIn*,
globalThis['..']). Single source of truth in @bmm/llm now exports
SHARED_BANNED_PATTERNS; templates.ts composes PUBLISH_BANNED_PATTERNS
= SHARED ∪ code-only-extras (dynamic import, fs.rm, setTimeout-with-
string, process.kill, jailbreak markers).
Zc-002 LOW (N+1) — /v1/templates list was issuing one COUNT(*) per
template (101 queries for a 100-row page). Now one grouped query
with templateId GROUP BY, merged in JS. p95 doesn't degrade with
marketplace growth.
DEFERRED (documented, scoped for next sprint):
Za-001 HIGH — Account takeover via cross-provider email lookup.
Requires schema change (users.primaryProvider). Mitigation in
/settings/account banner planned.
Zb-001 MEDIUM — /oauth/token refresh_token grant: advertised in
AS metadata but unsupported_grant_type. Either implement (~40
LOC) or strip from metadata.
Zc-003 LOW — Admin takedown partial-failure consistency.
Zd-001 IMPROVE — DEK cache invalidation across replicas (single-
instance today).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Five confirmed findings from the sovereign-audit pass, ordered by severity:
Z3-001 CRITICAL — Fastify now trustProxy:true so req.ip resolves to the
real visitor IP via X-Forwarded-For instead of always being the nginx /
docker-bridge peer. Every per-IP rate-limit in the codebase was silently
collapsed into one global counter; this restores them.
Z1-001 CRITICAL — runner container hardening flags (--read-only,
--cap-drop=ALL, --security-opt=no-new-privileges:true, --pids-limit=100,
--memory=512m, --cpus=0.5, tmpfs /tmp) were sitting commented-out as a
TODO despite /security promising them. Now applied unconditionally on
production/staging; opt-out flag RUNNER_DISABLE_HARDENING=1 for Win-dev.
Z2-001 + Z2-002 CRITICAL / MEDIUM — banned-pattern blacklist tightened
(Function(...) without `new`, process.binding, process.dlopen,
.constructor.constructor, _load, vm.runIn*Context, globalThis['..'],
"system prompt override"). scanForInjection now also walks tool.name and
every inputSchema property description, not only implementation +
description — closes the prompt-injection-into-AI-client surface that
downstream clients (Claude Desktop, Cursor) read verbatim. The duplicate
BANNED_PATTERNS in apps/api/src/routes/servers.ts deleted in favour of
the single shared scanForInjection export from @bmm/llm.
Z4-001 HIGH — /v1/auth/magic-link gained the two-axis daily rate-limit
the SMS endpoint already had: 10/IP/day + 5/email/day. Combined with the
trustProxy fix above these are now real per-visitor limits.
Z4-002 MEDIUM — magic-link callback URL no longer printed to stdout in
production. In dev it still prints (so devs can click the link); in
production we log only "issued, URL withheld" and a loud error if no
email sender is wired (Resend integration is the actual launch
blocker — left as a TODO).
Z6-001 MEDIUM — /v1/builds/:id/stream WebSocket now refuses cross-origin
upgrades. SameSite=Lax already mitigates in modern browsers; this is the
defense-in-depth against browser bugs and non-browser clients.
FALSE POSITIVES dismissed: slug path-traversal (schema regex
^[a-z][a-z0-9-]*$ in @bmm/types catches it); session-after-promote
(getSession re-fetches isAdmin from DB on every request).
DEFERRED (not blockers, tracked):
- Z1-002 generated-server HTTPS — needs nginx wildcard subdomain TLS
- Z1-003 docker image cleanup cron
- Z2-001 v2 — real sandbox runtime (multi-week refactor)
- Z3-002 rawBody-per-request memory — branch on webhook path only
- Z5-001 multi-user org RBAC for billing — gated on Team feature
- Email sender integration (Resend) — launch blocker
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User-facing identity:
- UserMenu component in dashboard header: avatar (deterministic colour from
email hash), email + name, current plan badge, dropdown to Profile /
Billing / Support / Your data / (Admin panel if isAdmin) / Sign out
- /settings/profile: editable display name; email + phone shown read-only
(changing them requires support ticket — magic-link flow assumed)
- GET + PATCH /v1/account/profile
In-app subscription management (no more Stripe Portal redirect for the
common flows — cancellation, plan switch, invoice viewing all in-app):
- Billing status now combines DB state with a live Stripe lookup of the
subscription details + last 5 invoices. Single roundtrip.
- POST /v1/billing/cancel → schedules cancel_at_period_end
- POST /v1/billing/reactivate → undo scheduled cancel
- POST /v1/billing/change-plan → prorated swap between any tier+cycle
- /settings/billing rewritten: current plan card with renew/cancel date,
big cancel button + reactivate flow, plan-switcher grid, invoice list with
PDF + hosted-invoice links
- Stripe portal still linked at the bottom as the escape hatch for rare
actions (payment-method update, address change). New-subscription Checkout
still uses Stripe-hosted Checkout (industry standard for PCI).
Stripe SDK v22 / API 2024-09 fix: current_period_end moved to subscription
items; updated read paths accordingly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Table-cell Link only wrapped the subject text — clicks on email/status/time
cells did nothing, which read as 'cannot open ticket' for the admin. Convert
to a flex-grid Link wrapping the entire row, same pattern as the user-side
/settings/support list.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The /admin/support page existed but was invisible from the panel — sidebar
NAV array didn't list it. Adds Support as the 2nd nav item (right after
Overview, since unanswered tickets are the most-time-sensitive thing an
admin checks). Sidebar polls /v1/admin/support/counts every 30s and renders
an amber count badge next to the entry when tickets are awaiting_admin.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- @bmm/api: stripe@22 SDK, plan-aware price-id lookup, Redis-backed event
idempotency (7d TTL covers Stripe's retry window), startup warning when
STRIPE_PRICE_* env vars contain product ids (prod_) by mistake
- routes/billing.ts:
POST /v1/billing/checkout-session → Stripe-hosted Checkout, SEPA+card,
auto-VAT via Stripe Tax, tax_id
collection for B2B, address required
POST /v1/billing/portal → Customer Portal session
GET /v1/billing/status → drives the settings/billing UI
POST /v1/billing/webhook → signed, idempotent, handles
checkout.session.completed,
subscription.{created,updated,deleted},
invoice.{paid,payment_failed}
- index.ts: rawBody-aware JSON parser so Stripe signature verify gets the
exact payload bytes
- web: /settings/billing page (status, upgrade flow, manage-billing portal,
auto-checkout when arriving with ?tier=… from the pricing CTAs), pricing
page CTAs point to /settings/billing?tier=…
- Payment-failure path: suspend org only after 3rd failed attempt (Stripe
Smart Retries handles the soft-retries). Suspended orgs keep their running
servers but cannot create new ones (enforcement is in /v1/servers POST as
a follow-up).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The earlier caps (Team 150/day, Enterprise 1000/day) used Sonnet/Opus pricing
that put max-usage above the tier's monthly revenue — a Bot with a Team
subscription could out-cost €199 in Anthropic spend. Drop to 50/day Team
and 200/day Enterprise; both now keep ~55-65% margin even when maxed.
Pricing page Team feature line updated to match (150 -> 50). Build caps
loosened slightly less since the 24h cache TTL makes most builds cache-hits.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The free tier was hemorrhaging Anthropic cost with no abuse cap (no rate
limit on /preview, Opus default in the build worker, 5-min cache TTL that
made cache-miss the common case). This switches free users to GLM, paid
users to Claude tiers, and tightens every leak found in the audit.
Backend:
- @bmm/llm: GLM provider via Zhipu's OpenAI-compatible endpoint, pickPreviewModel
+ pickBuildModel helpers, plan-aware ModelChoice
- preview-cache TTL 5min -> 24h (kills the cache-miss path)
- /v1/servers/preview: picks model from caller's plan, returns model name to UI
- /v1/servers POST: enforces SERVER_LIMITS per plan (402), rate-limits builds
- daily rate-limit on preview (5/40/150/1000) and build (3/20/100/500)
- /v1/auth/me returns plan so the wizard can show the right model name
- generator worker: GLM default, Anthropic Sonnet fallback if GLM errors
Frontend:
- Wizard fetches plan, shows "<model> is drafting the tool spec" pre-emptively,
upgrade hint for hobby users, friendly errors for 402 / 429
- Pricing page: AI-model line per tier (Open-tier / Haiku / Sonnet / Opus),
Team €149 -> €199, Enterprise €499 -> €999, daily-preview limit per tier
- Privacy + Security: explicit subprocessor disclosure for Anthropic (US) /
Zhipu (CN) and which tier uses which
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tailwind v4's `bg-[--color-X]` bracket-arbitrary syntax does not wrap the
value in var(), so it compiles to `background-color: --color-bg-elevated`
— an invalid color, which the browser falls back to transparent. The
mobile menu was the one element that depended solely on this utility for
its background, so it rendered with none.
Use an inline style with explicit var() and color-mix to match the nav
bar's frosted look (var(--color-bg) at 80% + backdrop-blur).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The dropdown was bg/95 + backdrop-blur — fragile across mobile browsers
where backdrop-filter is unreliable, leaving 5% transparency that read as
"no background". Switch to a solid elevated panel with a soft shadow and
an explicit z-index.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
On phones the dashboard top bar is tight with the nav icons + the primary
action crammed alongside. Move the action into a sticky bottom bar in the
thumb zone, leave the top bar to navigation. Hidden on the create-wizard
route since that page owns its own action.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sonnet still overran Cloudflare's edge timeout — the 504 fired at 90s but
the proxy had already cut the connection, so the browser saw a headerless
524 reported as a CORS error.
Measured against the live API: Haiku 4.5 generates the spec at ~200 tok/s,
so a full 8k-token spec completes in ~40s. With a hard 60s timeout and no
retries the route is guaranteed to answer well inside the proxy window.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The /v1/servers/preview route ran claude-opus-4-7 synchronously; full spec
generation routinely exceeded Cloudflare's ~100s proxy cap, so the browser
received a headerless 524 and reported it as a CORS failure.
- preview now uses claude-sonnet-4-6 with a 45s per-attempt timeout and one
retry — comfortably inside the proxy budget
- generateSpec maps an exhausted timeout to SpecTimeoutError; the route
returns a clean 504 (with CORS headers) instead of a stalled connection
- analyze step: live elapsed-seconds counter as freeze-proof, plus a
reduced-motion exception so the loading spinner keeps spinning (a status
indicator, which WCAG exempts from reduced-motion)
- textarea resize grip restyled to dark theme (light hatch on dark square)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- login: SMS step now has a 60-country dial-code <select> (CH default)
and a national-number input, combined into strict E.164 client-side
- marketing header: probe /v1/auth/me, show "Dashboard" when signed in
instead of the Sign in / Start building CTAs
- dashboard overview: drop the duplicate "+ New server" button, the
navbar one is the single source
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Hero h1 was a fixed text-[44px] — overflowed narrow phones. Now
text-[30px] sm:text-[40px] md:text-[56px].
- Hero grid children get min-w-0 so the code blocks' overflow-x-auto
actually constrains instead of widening the page.
- Marketing nav: the inline links were hidden below md with no fallback.
Added a hamburger MobileMenu; "Sign in" collapses into it on the
smallest screens.
- Section vertical padding is now responsive (py-14 sm:py-20).
- globals.css: overflow-x: clip on <html> as a safety net.
- docs: the 240px sidebar is hidden below lg, article gets min-w-0.
- dashboard header: nav labels collapse to icons on small screens.
Verified: next build passes (40/40 pages).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The domain was parked on GoDaddy Airo, which registered a Workbox
service worker. It keeps serving cached GoDaddy pages in browsers that
visited the parked domain. Serving a self-destruct sw.js makes those
browsers wipe the caches and unregister the worker on their next visit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- experimental.inlineCss: drop the render-blocking CSS request — the
Tailwind bundle is inlined into the HTML head (faster FCP/LCP on mobile).
- browserslist pinned to modern engines so Next/SWC stops emitting
polyfills for Baseline features (Array.at, Object.fromEntries, …).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
White on #6366f1 was 4.47:1 — just under the 4.5:1 minimum for small
text (Lighthouse a11y flag). Darkened the banner to #4f46e5 (6.3:1).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Ported and adapted from the BuildMyDiscord SEO setup:
- lib/seo.ts — single source for site constants, the FAQ data (shared by
the rendered FAQ and the FAQPage schema so they never drift) and JSON-LD
builders.
- Rich root metadata: title template, keywords, Open Graph, Twitter card,
robots directives, canonical.
- JSON-LD: Organization + WebSite + SoftwareApplication sitewide, FAQPage
on the landing page. No AggregateRating — there are no real reviews yet.
- app/robots.ts — allow all, explicit allow-list for AI answer-engine
crawlers (GPTBot, ClaudeBot, PerplexityBot, …), disallow private routes.
- app/sitemap.ts — every public marketing + docs route.
- app/opengraph-image.tsx — monochrome on-brand 1200x630 share card.
- app/manifest.ts + public/llms.txt.
- Per-page metadata for pricing, changelog, security, privacy, terms,
docs, templates and status.
- opengraph-image + apple-icon pinned to the edge runtime — next/og
crashes during a Node-runtime prerender.
Verified: next build passes; /robots.txt, /sitemap.xml,
/manifest.webmanifest and /opengraph-image all generate.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
bg-[--color-accent] does not resolve under Tailwind v4 — the banner bar
showed near-black. Set #6366f1 inline so the preview notice is clearly
visible regardless of theme wiring.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Clear notice that the service is not yet open for production use.
Temporary — remove SiteBanner once live.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lets Cloudflare run in Full mode (encrypted Cloudflare<->origin) instead
of Flexible (plaintext origin hop). Full (strict) is a later swap to a
Cloudflare Origin Certificate.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The servers bind IPv4 (0.0.0.0) only. busybox wget resolves `localhost`
to ::1 first and does not fall back to IPv4, so the healthcheck failed
with "connection refused" and the container showed as unhealthy while
serving fine. Verified on the production api container.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Server recon (read-only SSH) showed the box already runs ~8 apps behind a
host-level nginx, with Gitea + an Actions runner. The host-networking
design collided with contentra on port 3001.
- docker-compose.prod.yml: bridge networking + per-app network, house
style; api/web/postgres/redis publish to 127.0.0.1 on verified-free
ports (4000/4001/5440/6390); only the generator keeps host networking
(no listening port, needs the host namespace for runner-port probing).
- Drop the Traefik config; the box uses a host nginx. Add a ready nginx
vhost in infra/nginx/buildmymcpserver.conf (listen 80, Cloudflare TLS).
- Add .gitea/workflows/deploy.yml mirroring the buildmydiscord pipeline.
- Narrow the generated-MCP port range to 4400-4900 (clear of screencraft
on 4321).
- .env.production.example + DEPLOY.md rewritten for buildmymcpserver.com
and the real topology.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The request said buildmymcp.com; the GoDaddy tab and the repo are named
buildmymcpserver.com. Added a top-of-file callout so the domain is resolved
before any DNS/nameserver change rather than baked in wrong.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The marketplace is the distribution channel — fork a working server or
publish your own — but it was absent from the landing page. Adds a
section between Examples and Pricing with a second conversion path into
/templates.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Multi-stage Dockerfiles for web/api/generator (pnpm workspace install,
tsx runtime — workspace packages are raw TS, same model as runner-template).
- docker-compose.prod.yml: postgres + redis + the three app services.
api/generator/web use host networking so the generator's host-port probe
is correct and every service shares one address space; api + generator
mount the Docker socket. Binds nothing on 80/443 — safe beside other apps.
- Optional Traefik reverse proxy in infra/traefik/ (heavily gated — only if
the box has no existing proxy).
- .env.production.example, .dockerignore, DEPLOY.md (Cloudflare zone, GoDaddy
nameserver switch, server deploy, Google Cloud Console OAuth app).
- api/generator `start` now runs via tsx; `node dist/index.js` could never
resolve the raw-TS workspace imports.
All three images verified building clean; the API container boots under tsx.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
/servers/new and /login/callback call useSearchParams() directly, which
bails the page out of static rendering and fails `next build` during
prerender. Split each into a thin Suspense wrapper + inner component.
Latent since `next dev` never prerenders — only surfaces in a prod build.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>