Commit Graph

57 Commits

Author SHA1 Message Date
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
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
e9827b1f77 feat(login): custom CountryPicker — opens downward, searchable, ~150 countries
All checks were successful
Deploy to Production / deploy (push) Successful in 51s
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>
2026-05-25 21:38:36 +02:00
Marco Sadjadi
091454d273 fix(web): single Login/Dashboard button on marketing header
All checks were successful
Deploy to Production / deploy (push) Successful in 51s
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>
2026-05-25 19:30:27 +02:00
Marco Sadjadi
b248adf5c0 feat(auth): email login soft-disabled until SMTP/Resend is wired
All checks were successful
Deploy to Production / deploy (push) Successful in 54s
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>
2026-05-25 18:51:57 +02:00
Marco Sadjadi
1c58977596 feat: user menu + profile page + in-app subscription management
All checks were successful
Deploy to Production / deploy (push) Successful in 52s
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>
2026-05-25 17:46:36 +02:00
Marco Sadjadi
1b8f61df5f fix(admin): make whole support-ticket row clickable
All checks were successful
Deploy to Production / deploy (push) Successful in 52s
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>
2026-05-25 17:36:31 +02:00
Marco Sadjadi
20910f5466 fix(admin): Support entry in sidebar + awaiting-admin badge
All checks were successful
Deploy to Production / deploy (push) Successful in 52s
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>
2026-05-25 17:23:33 +02:00
Marco Sadjadi
ef30baf52a feat: Swiss-compliant launch — Impressum/AGB/Contact, support panel, DSG exports, cookie banner
All checks were successful
Deploy to Production / deploy (push) Successful in 57s
Legal (Swiss minimum, no individual named):
- Impressum page (UWG Art. 3 lit. s) — provider, contact via support panel,
  no email required, jurisdiction = Switzerland
- AGB page — subscription terms, payment, cancellation, suspension on payment
  fail, 14-day money-back, AI-processing-per-tier disclosure, Swiss law +
  Swiss venue, modeled after typical Schweizer SaaS terms
- Privacy: Stripe added as subprocessor with full data-flow disclosure

Support panel replaces email contact entirely:
- @bmm/db: support_status enum + support_tickets + support_messages tables,
  migration applied to prod DB
- @bmm/api: support routes (user create/list/view/reply, admin list/view/reply
  /set-status), public /v1/contact for logged-out visitors with per-IP rate
  limit of 3 submissions/day to prevent spam-flood
- Web: /settings/support (list + new), /settings/support/[id] (conversation),
  /admin/support, /admin/support/[id]
- Public /contact form with email collection for guest tickets

Data rights (DSG Art. 25 / GDPR Art. 15+20):
- /v1/account/export returns user-scoped JSON of profile, org, servers,
  builds, audit, support tickets and messages — excludes hashes, encrypted
  secrets, other-user data
- /settings/account: download button + deletion-via-ticket workflow

Production-readiness gaps closed:
- org.suspended now blocks /v1/servers POST and /v1/servers/preview (402);
  webhook flagged this state but enforcement was missing
- Cookie banner: minimal, essential-cookies-only disclosure (Swiss DSG +
  GDPR compliant without dark-pattern consent UI), mounts on both layouts

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 17:12:06 +02:00
Marco Sadjadi
c2a21fc3cd feat(billing): Stripe Checkout + Customer Portal + signed webhook
Some checks failed
Deploy to Production / deploy (push) Failing after 46s
- @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>
2026-05-25 16:30:42 +02:00
Marco Sadjadi
defb4186b4 fix(quotas): tighten Team/Enterprise daily preview caps to stay profitable
All checks were successful
Deploy to Production / deploy (push) Successful in 52s
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>
2026-05-24 00:14:07 +02:00
Marco Sadjadi
bc174c1302 feat: tiered LLM (GLM free / Claude paid) + rate limits + quota enforcement
All checks were successful
Deploy to Production / deploy (push) Successful in 53s
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>
2026-05-23 23:50:00 +02:00
Marco Sadjadi
66128c73d8 fix(web): mobile menu background via inline style (Tailwind v4 quirk)
All checks were successful
Deploy to Production / deploy (push) Successful in 57s
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>
2026-05-23 18:43:57 +02:00
Marco Sadjadi
389446ea16 fix(web): solid background for the marketing mobile menu
All checks were successful
Deploy to Production / deploy (push) Successful in 1m21s
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>
2026-05-23 18:24:36 +02:00
Marco Sadjadi
dc5bbaa0ae feat(web): mobile bottom action bar for + New server
All checks were successful
Deploy to Production / deploy (push) Successful in 53s
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>
2026-05-23 00:19:31 +02:00
Marco Sadjadi
083b6e5d41 fix(preview): switch spec generation to Haiku 4.5 to fit the proxy window
All checks were successful
Deploy to Production / deploy (push) Successful in 51s
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>
2026-05-22 00:03:12 +02:00
Marco Sadjadi
e198d44e1e fix(preview): stop spec generation timing out behind the edge proxy
All checks were successful
Deploy to Production / deploy (push) Successful in 50s
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>
2026-05-21 23:52:48 +02:00
Marco Sadjadi
5d0d5668d8 feat(web): country-code picker, auth-aware header, dedupe new-server CTA
All checks were successful
Deploy to Production / deploy (push) Successful in 50s
- 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>
2026-05-21 23:41:19 +02:00
Marco Sadjadi
88c7262a08 fix(web): mobile-responsive hero, marketing site, docs and dashboard
All checks were successful
Deploy to Production / deploy (push) Successful in 1m13s
- 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>
2026-05-21 23:25:26 +02:00
Marco Sadjadi
2e5bf5b44b fix(web): self-destructing sw.js to evict the stale GoDaddy Airo worker
All checks were successful
Deploy to Production / deploy (push) Successful in 1m0s
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>
2026-05-21 23:06:56 +02:00
Marco Sadjadi
cc3c5ad444 feat(auth): GitHub OAuth login + SMS one-time-code login
Some checks failed
Deploy to Production / deploy (push) Failing after 1m8s
GitHub: /v1/auth/github + /callback — authorization-code flow, fetches
the verified primary email via /user/emails, reuses upsertOAuthLogin.

SMS: phone is now a first-class login identity.
- schema: users.email nullable, users.phone added, new sms_codes table.
- @bmm/auth: issueSmsCode / consumeSmsCode — 6-digit code, hashed at
  rest, 10-min TTL, per-phone rate limit, 5-attempt cap, get-or-create
  user by phone.
- apps/api: /v1/auth/sms/request + /verify, Twilio REST send (no SDK),
  per-IP throttle. /v1/auth/providers now reports google/github/sms.
- login UI: Google + GitHub buttons, Email|Phone toggle, two-step SMS
  (number -> 6-digit code with one-time-code autofill).

SMS link was rejected in favour of an OTP code — carrier link-scanners
consume magic-link tokens before the user taps them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:59:58 +02:00
Marco Sadjadi
f5107922a0 perf(web): inline CSS + modern browserslist
All checks were successful
Deploy to Production / deploy (push) Successful in 1m11s
- 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>
2026-05-21 19:57:30 +02:00
Marco Sadjadi
36a1adf4d7 fix(web): banner contrast meets WCAG AA
All checks were successful
Deploy to Production / deploy (push) Successful in 53s
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>
2026-05-21 19:31:34 +02:00
Marco Sadjadi
b843394d0f feat(web): full SEO stack — metadata, JSON-LD, sitemap, robots, OG image
Some checks failed
Deploy to Production / deploy (push) Failing after 46s
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>
2026-05-21 19:16:40 +02:00
Marco Sadjadi
617886352c fix(web): banner background renders via inline color
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>
2026-05-21 19:01:50 +02:00
Marco Sadjadi
cd428d5ba3 style(web): biome — drop redundant role, format banner files
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 18:57:49 +02:00
Marco Sadjadi
390cf5e8a1 feat(web): sitewide pre-launch preview banner
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>
2026-05-21 18:57:16 +02:00
Marco Sadjadi
e46a9a1cf8 feat(web): surface the template marketplace on the landing page
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>
2026-05-21 00:37:06 +02:00
Marco Sadjadi
8a7ffe673d feat(deploy): production Dockerfiles, compose stack, and runbook
- 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>
2026-05-21 00:37:02 +02:00
Marco Sadjadi
2b098c5d33 fix(web): wrap useSearchParams in Suspense so next build can prerender
/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>
2026-05-21 00:36:56 +02:00
Marco Sadjadi
38aa5875d3 feat(auth): add "Continue with Google" OAuth 2.0 login
Server-side authorization-code flow: /v1/auth/google redirects to the
consent screen with a CSRF state cookie; /v1/auth/google/callback
exchanges the code, validates the ID token (iss/aud/exp/email_verified),
and mints a 30-day session via upsertOAuthLogin. /v1/auth/providers lets
the login UI hide the button until GOOGLE_OAUTH_ID/SECRET are set.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 00:26:44 +02:00
Marco Sadjadi
a68e882092 feat(crypto): envelope encryption + key rotation via admin panel
Closes structural weakness #4 from the audit (single global key, no rotation,
no KMS path). Customer secrets now use envelope encryption with a real
rotation story.

Model:
  KEK — Key Encryption Key, 32 bytes from env (SECRETS_ENCRYPTION_KEY). Never
        stored in the DB. Root of trust.
  DEK — Data Encryption Key, 32 random bytes we generate, stored in the new
        encryption_keys table *wrapped* (AES-256-GCM encrypted) with the KEK.
        Secrets are encrypted with the DEK.

Schema:
- encryption_keys (version, wrappedDek, active, rotatedBy, createdAt, retiredAt)
- secrets.keyId — which DEK encrypted this row. NULL = legacy (KEK-direct,
  pre-envelope); decryptSecret handles both and the first rotation migrates
  legacy rows onto a DEK.

crypto.ts (full rewrite):
- ensureActiveKey() — boot-time, loads keys + creates v1 if none. Fail-closed:
  index.ts process.exit(1) if it throws — the API will not serve if encryption
  can't initialize.
- encryptSecret() — encrypts with the active DEK, returns { value, keyId }.
- decryptSecret(value, keyId) — DEK path or legacy KEK-direct path.
- rotateKeys() — mints a fresh DEK, re-encrypts EVERY secret under it inside a
  single transaction (decrypt-old / encrypt-new per row), retires the old key,
  activates the new one. A partial failure is recoverable because every row
  carries its own keyId.
- encryptionStatus() — active version, key history, secret + legacy counts.

Admin:
- GET  /v1/admin/encryption        — status
- POST /v1/admin/encryption/rotate — triggers rotateKeys, audit-logged as
  admin.encryption.rotate with { newVersion, reEncrypted }.
- /admin/encryption page — active-key/secret/legacy cards, Rotate button with
  confirm, key-history table, plain-English how-it-works. Added to admin nav.

Verified end-to-end:
- boot → encryption_keys v1 active, '[crypto] envelope encryption ready'
- created a server with secret MY_API_KEY → stored ciphertext, keyId = v1
- POST rotate → { newVersion: 2, reEncrypted: 1 }; ciphertext changed, keyId
  now v2, v1 retired, v2 active. The decrypt-then-reencrypt round-trip
  succeeded (rotation throws otherwise) — the secret is provably recoverable.
- admin UI renders the status + history correctly.

Deferred, named honestly (not built this iteration):
- worker reads secrets from the DB instead of the BullMQ job-data plaintext
  copy — would also remove plaintext secrets from Redis. Separate change with
  its own risk surface on the iterate/fork flows.
- per-server secret-value rotation UI
- audit_log hash-chaining (tamper-evidence)
- rate limiting on auth endpoints
2026-05-20 22:36:08 +02:00
Marco Sadjadi
c78420e0be fix(wizard): fork 409 — auto-unique slug + editable name/slug in fork step
Bug: forking a template POSTed /v1/servers with slug = trySlug(template.title),
a fixed value. If the user had any server with that slug already (e.g. forking
the same template twice, or a name collision), the create returned 409
slug_taken — and the fork wizard skips Step 1, so there was no slug field to
fix it with. The user was stuck (8 repeated 409s in the report).

Fix:
- On fork setup, after the fork call, GET /v1/servers and auto-unique the
  default slug: echo-demo-template -> echo-demo-template-2 -> -3 ... against the
  user's existing slugs. Lookup failure is non-fatal (slug field is editable).
- Fork step 2 now renders editable Name + Slug fields in the fork banner
  ('must be unique in your workspace' hint) — the normal wizard has these in
  Step 1, which the fork flow skips, so they belong here.
- slug_taken build error now reads 'The slug "x" is already used by one of
  your servers — change the Slug field above' instead of the raw code.

Note: the SES lockdown-install.js and content.js 'query' errors in the report
are browser extensions, not the app.

Verified: forked echo-demo-template (whose base slug was already taken) — slug
auto-filled echo-demo-template-2, build succeeded, container live on :4111,
template fork_count incremented to 2.
2026-05-20 17:23:24 +02:00
Marco Sadjadi
414903f16d feat(marketplace): dashboard nav link + My-templates filter
The logged-in user can now reach the marketplace and filter to their own
templates.

Dashboard nav:
- Added 'Marketplace' item (Overview · Servers · Marketplace · Audit · Settings).

/templates page — login-aware:
- Detects session via /v1/auth/me. Logged-in users get a 'Dashboard' + '+ New
  server' header instead of 'Home' + 'Start building'.
- New [All templates | My templates] scope toggle, shown only when logged in.
- 'My templates' loads GET /v1/templates/mine and shows EVERY status the user
  owns (public / hidden / draft / takedown) with a colored status badge on each
  card — so a template you unshared doesn't appear to have vanished.
- Sort tabs (trending/top/newest) hide in 'mine' scope — meaningless for a
  handful of own templates. Category filter + search still apply (client-side).
- Takedown cards link to the source server's Publish tab instead of the detail
  route (which 410s); everything else opens the detail page.

Backend:
- GET /v1/templates/mine (requireAuth) — all own templates, any status,
  registered before /:slug so the static route always wins the match.
- GET /v1/templates/:slug — now does an optional session check: the OWNER can
  view their own hidden/draft template (so a 'My templates' card click never
  dead-ends in a 404). takedown stays 410 for everyone, owner included — that's
  an admin decision, not the owner's to reverse.

Detail page:
- Fork CTA is gated on status === 'public'. For a non-public template the owner
  sees an amber 'not forkable — re-share from the Publish tab' notice plus a
  'Manage in server' link, instead of a Fork button that would fail silently.

Verified:
- GET /v1/templates/mine → marco's 1 template; 401 without auth
- Owner GET of a hidden template → 200 status:hidden; anon → 404
- Dashboard nav shows Marketplace (screenshot)
- /templates 'My templates' toggle → only own template, public badge, sort tabs
  hidden (screenshot)
2026-05-20 17:18:58 +02:00
Marco Sadjadi
a189111782 feat(marketplace): default-on share in wizard + owner unshare anytime
Goal: maximize template volume without a dark pattern and without leaking data.

Wizard Done-page Share panel:
- 'Share as template in the marketplace (recommended)' checkbox, default ON,
  rendered inline in the build-success flow where every user lands.
- Honest copy — corrected a draft that claimed 'only abstracted code pattern is
  shared'. That is false: the FULL generated code becomes publicly viewable on
  the template detail page (by design, for pre-fork audit). The panel now says:
  'Your secrets stay private ... but your generated code becomes publicly
  viewable so others can audit it before forking. Unshare anytime.'
- When checked: inline minimal form — short description (prefilled from the
  spec), category select, optional per-secret credential hints. One 'Publish to
  marketplace' click. Not auto-published silently — that would be a consent dark
  pattern; one visible deliberate click keeps it clean.
- Forked servers don't show the panel (re-publishing a fork is an edge case).

Owner unshare/reshare:
- GET /v1/servers/:id/template — owner lookup, drives the Publish tab UI.
- PATCH /v1/templates/:slug/visibility { shared } — owner-only toggle between
  public and hidden. 403 for non-owners, 409 if an admin took it down (owner
  cannot resurrect an admin takedown). Audit-logged as template.unshare /
  template.reshare.
- Server-detail Publish tab now detects an existing template and shows the
  shared status (public/hidden/takedown badge), fork count, a marketplace link
  and an Unshare/Re-share button — instead of the publish form.

Why this is safe to default ON:
- Secrets are architecturally bound to mcp_servers, never copied into templates.
  Publish reads tools_schema + generated_code only; the secrets table is never
  touched. Data leak is structurally impossible, not policy-dependent.
- Publish re-scans the generated code for banned patterns AND hardcoded
  credentials (sovereign-audit hardening) before it can reach the marketplace.
- The user sees a visible, pre-ticked checkbox and reads one honest sentence
  before publishing. Privacy-conscious users untick; everyone else contributes
  volume. Informed consent, GDPR-clean.

Verified end-to-end via API:
  GET server/:id/template -> null (unpublished)
  POST /v1/templates -> published, slug share-test-server
  GET server/:id/template -> status public
  PATCH visibility {shared:false} -> hidden, drops out of public list
  PATCH visibility {shared:true} -> public again
UI: Publish tab renders the shared-status panel with View + Unshare (screenshot
confirmed).

Also: hero badge date set to 2026-05-20. Changed 'MCP spec 2025-11-25' to
'updated 2026-05-20' — claiming an MCP spec dated today would be factually wrong
(no such spec release exists); 'updated' is accurate and gives the requested
fresh date. The real spec date is still cited correctly in /docs.
2026-05-20 17:04:46 +02:00
Marco Sadjadi
2ad4a7e34c fix(security): template integration sovereign audit + critical fixes
P0 — three critical issues found by tracing every attack vector on the template
publish + fork + render path. All three fixed and verified with attack tests.

FIX A — Takedown actually stops malicious containers
  PATCH /v1/admin/templates with status=takedown previously only updated
  mcp_servers.status to 'paused' in the DB. The Docker container kept running
  and serving traffic on its allocated port — takedown was cosmetic. Now the
  endpoint enumerates every fork's container, calls 'docker rm -f' on each,
  clears container_id/public_url/host_port in the DB, and returns the
  stoppedContainers count. New apps/api/src/lib/docker.ts owns the stop logic.
  Verified: takedown stopped container f5632962, port 4109 connection refused.

FIX B — Reject specEdit on fork
  A hand-crafted POST /v1/servers with {templateId, previewId, specEdit} would
  enter the spec-edit branch, merge edits into the cached spec, but the worker
  reads the pre-built template code (separate cache key), ignoring the merged
  spec entirely. User thinks they changed something; deployed container behaves
  as the original. Now returns 400 spec_edit_forbidden_on_fork with an explainer
  pointing to the Iterate flow.

FIX C — templateId validation via Redis fork-ref
  templateId on POST /v1/servers was user-controlled and unvalidated:
  fork_count of any template could be pumped, mcp_servers got garbage
  template_id rows, takedown cascade would miss the bogus rows. Fork endpoint
  now writes a Redis key fork-ref:<previewId> -> templateId (5min TTL).
  Server-create requires the ref to exist AND match the submitted templateId.
  Verified attack: fake templateId without fork-ref returns 410 fork_ref_expired.

DEFENSE-IN-DEPTH — Hardened static checks

  Banned patterns (added):
    Function\s*\(['"`]    — Function('code')() form, no 'new' needed
    \bimport\s*\(           — dynamic import escapes bundle scope
    \bsetTimeout\s*\(['"`] — setTimeout('code', ms) eval form
    \bsetInterval\s*\(['"`]
    \bfs\s*\.\s*(unlink|rmdir|rm)\b
    \bprocess\s*\.\s*kill\b
    you are now in (developer|jailbreak|dan) mode — extra jailbreak markers

  Hardcoded-credential patterns (new — scanForLeakedSecrets):
    sk-ant-(api|sid)…  — Anthropic
    sk-…               — OpenAI
    sk_(live|test)_…   — Stripe
    ghp_…              — GitHub PAT
    github_pat_…       — GitHub fine-grained
    xox[bpoasr]-…      — Slack
    AKIA[0-9A-Z]{16}   — AWS
    -----BEGIN…PRIVATE KEY----- — RSA / SSH / GPG
  Triggered when a publisher pasted their key into the prompt and Claude
  embedded it literally in the generated code. Publish-blocking.
  Verified attack: smuggled 'Function("return 1")' into a build's
  generated_code, attempted publish → 422 publish_blocked.

  Slug regex tightened — fork + detail routes now require
  ^[a-z0-9][a-z0-9-]{0,63}$ (was loose min(1).max(64) — letting through
  '../admin', long strings, mixed case).

  UI warning — Publish-as-template form now shows an amber callout listing
  what's scanned and explicitly stating egress allowlisting is roadmap, not
  enforced today (was misleading: the field was collected, never enforced).

  TEMPLATE_SECURITY_AUDIT.md added — documents all 20 audited vectors with
  severity, status, and rationale for what's deferred.

UI polish
  globals.css — select/input/textarea/button get color-scheme: dark + custom
  chevron + option styling so Chrome's native popdown stops rendering as a
  white OS-themed widget on dark pages. The /templates category dropdown was
  the immediate trigger; same rule applies system-wide.
2026-05-19 23:35:45 +02:00
Marco Sadjadi
8334de13a8 feat(marketplace): template publish + fork + voting/ranking + admin moderation
What this enables:
- A user builds an MCP server. If others would benefit, they click 'Publish as
  template' on their server detail page. The spec + pre-rendered TypeScript
  snapshot is preserved.
- Visitors browse /templates, filter by category, sort by trending/top/newest.
  Each template card shows fork count + active deployment count as natural
  manipulation-resistant popularity signal.
- /templates/[slug] shows the full plan: tool list with input schemas,
  required-credential explanations (with 'how to get one' deep links), and a
  collapsible code preview so users can audit before forking.
- Fork is one click → /servers/new?template=slug. The wizard skips Step 1 and
  pre-fills Step 2 with the template's parsed spec. Forker only fills in their
  own credentials. mcp_servers.template_id is recorded; template.fork_count is
  bumped atomically. Each fork gets its own isolated container with its own
  port, its own AES-256 secrets — the template author has zero visibility into
  the fork's traffic or data.
- Admin /admin/templates moderation: verify quality templates (shows shield
  badge in marketplace), hide low-effort ones, takedown anything malicious.
  Takedowns cascade-pause every fork container — owners must re-deploy.

Why template+fork instead of shared-container:
- Shared containers would mean the publisher's quota + their secrets + their
  logs are exposed to forkers. Bad ergonomics, bad security, bad ownership.
- Templates/forks decouple the spec (shared, vouched-for) from the runtime
  (isolated per user). Network-effect moat without the trust collapse.

Why no 5-star voting in v1:
- Manipulation-anfällig, empty lists without adoption. We use fork count +
  active deploys + verified badge. Trending algorithm:
    score = (activeDeploys * 3 + forks) / sqrt(ageDays + 1)
  Real signal, no brigading attack surface.

Backend:
- New schema: templates table (16 cols incl. tools_schema, generated_code,
  required_secrets, allowedDomains, status enum, verified, fork_count).
- mcp_servers.template_id FK + idx for fork lookup.
- @bmm/types: SpecEdit unchanged, CreateServerInput accepts optional templateId.
- preview-cache.ts: new cachePrebuiltCode/loadPrebuiltCode for storing the
  template's full rendered server.ts alongside the spec. Generator worker
  detects this and skips the render step — uses the audited pre-built code
  verbatim. Banned-pattern re-scan at publish time.
- routes/templates.ts: 5 public/auth routes + 2 admin routes. Banned-pattern
  re-scan before publish. Slug auto-uniqued. forkCount atomic-increment via
  SQL.

UI:
- /templates marketplace with trending/top/newest tabs, category filter, search.
  Cards show forks + live count + author + verified badge.
- /templates/[slug] full detail with tools, credentials-with-hints, expandable
  code preview, fork CTA, ownership + stats sidebar, 'forking is safe' explainer.
- /servers/new?template=slug — wizard auto-jumps to Step 2 with template spec
  pre-filled, fork banner at top with link back to template.
- /servers/[id] new Publish tab with title, category, descriptions, per-secret
  hint fields (description + howToGetUrl per UPPER_SNAKE_CASE key).
- /admin/templates moderation with verify/hide/takedown actions.
- Marketing nav now includes /templates.

Verified end-to-end:
- Published Echo Demo Template from marco@test.local's live server
- Marketplace lists it correctly with stats
- Detail page renders with all sections
- Fork CTA navigates to wizard with ?template= param
- Wizard skips Step 1, shows fork banner, pre-fills spec
- Build succeeds in ~10s (cached spec + prebuilt code path skips Claude AND
  render), container live on :4109 with proper OAuth 401 → token → 200 flow
- DB: templates.fork_count=1, activeDeployments=1, mcp_servers.template_id
  populated on the fork
- /admin/templates shows the new template with verify/hide/takedown controls
2026-05-19 23:22:35 +02:00
Marco Sadjadi
c62fcd07ef feat(admin): password-auth admin panel with 8 pages + 15 API endpoints
Schema migrations:
- users.is_admin boolean
- users.password_hash text (scrypt N=16384, 16-byte salt)
- users.last_login_at timestamp
- organizations.suspended + suspended_reason
- admin_settings table (DB-stored prompt override + future settings)

Auth (@bmm/auth):
- hashPassword + verifyPassword via node:crypto scrypt (no extra dep)
- loginWithPassword: scrypt-verifies, issues 30-day session, updates last_login_at
- seedAdmin: idempotent upsert keyed on email; creates org + membership on first run
- AuthedUser now carries isAdmin flag

API:
- POST /v1/auth/admin/login (email + password) — 300ms throttle on failure
- requireAdmin preHandler — 401 if no session, 403 if non-admin
- Bootstrap: api on boot calls seedAdmin(ADMIN_EMAIL, ADMIN_PASSWORD, ADMIN_NAME)
  if env present. Idempotent.

Admin API routes (all gated by requireAdmin):
- GET /v1/admin/overview (totals, trends 7d, server-status breakdown, builds 24h, recent activity)
- GET /v1/admin/users (search, per-row org + plan + serverCount)
- PATCH /v1/admin/users/:id (isAdmin, name)
- DELETE /v1/admin/users/:id (self-delete blocked)
- GET /v1/admin/orgs (member + server counts)
- PATCH /v1/admin/orgs/:id (plan, quota, suspended; cascades to mcp_servers.status=paused on suspend)
- GET /v1/admin/servers (cross-org with status filter)
- POST /v1/admin/servers/:id/rebuild (re-queues build using last prompt)
- DELETE /v1/admin/servers/:id
- GET /v1/admin/builds (status filter, error messages, prompt previews)
- GET /v1/admin/builds/:id/logs
- GET /v1/admin/audit (system-wide with user email join)
- GET /v1/admin/system (DB ping, Redis ping, BullMQ queue depth, docker ps count)
- GET /v1/admin/prompt (builtin + override + updatedAt)
- PATCH /v1/admin/prompt (value: string | null) — saves DB override or drops it

UI (apps/web/app/admin/*):
- /admin/login — password form, separate from /login magic-link
- AdminLayout — Linear-style sidebar (8 nav items), bottom panel with user email +
  'user view' shortcut + logout, client-side requireAdmin guard with redirect
- /admin — overview dashboard with 4 metric cards, 2 panels (status + 24h builds),
  recent activity table linking to full audit
- /admin/users — search + admin toggle + delete (self-delete blocked)
- /admin/orgs — plan/quota/suspend actions via prompts
- /admin/servers — cross-org table with rebuild + delete actions, status filter
- /admin/builds — every build cross-fleet with error vs prompt preview
- /admin/audit — system-wide log + CSV export + filter dropdowns
- /admin/system — auto-refreshing 5s health probes for Postgres, Redis, queue, Docker
- /admin/prompt — live editor for the LLM system prompt with built-in baseline,
  override-state badge, drop-override action, diff preview, save-as-override

End-to-end verified: login as marco.frangiskatos@gmail.com + Melusa112233.*, every
admin page returns 200, admin login + overview tested via screenshot, docker probe
returns true count of running MCP containers.
2026-05-19 23:01:26 +02:00
Marco Sadjadi
9acc2adb0d fix(web): favicon matches nav-bar logo (monochrome outline, prefers-color-scheme)
The indigo filled square didn't match the in-page Logo component. The nav-bar
logo is a monochrome outlined rounded square + M path, currentColor on the
dark page background.

Favicon now follows the same design: outlined rect + M, stroke adapts:
  - light browser tabs  → #0A0A0B (near-black)
  - dark browser tabs   → #FAFAFA

Apple-icon stays as the indigo filled tile — iOS home-screen icons need solid
backgrounds, monochrome outlines disappear there.
2026-05-19 22:44:58 +02:00