Commit Graph

40 Commits

Author SHA1 Message Date
Marco Sadjadi
0c6d738a6b feat(preview): SSE-streamed generation, no CF 100s edge cap
All checks were successful
Deploy to Production / deploy (push) Successful in 1m27s
Architectural fix for "spec_too_large" / preview_timeout — the sync
endpoint had to fit the whole model run into Cloudflare's ~100s edge
window, which made the system fragile against any prompt that produced
a verbose spec. The new streaming path pipes Anthropic's token deltas
as Server-Sent Events; every chunk resets CF's idle timer and a 15s
keepalive comment guarantees activity even during slow first-token
windows.

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

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

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

nginx (api.buildmymcpserver.com): the /v1/builds/ location block (which
already had proxy_buffering off + 600s read timeout for the WS build
stream) now also matches /v1/servers/preview/stream so the SSE
response isn't buffered.
2026-05-28 21:11:05 +02:00
Marco Sadjadi
4d136c4fb2 fix(mcp): RFC 9728 protected-resource metadata path + audience binding
All checks were successful
Deploy to Production / deploy (push) Successful in 1m31s
Codex/RFC review showed that Claude Desktop addresses the MCP resource
as <PUBLIC_URL>/mcp (the streamable-HTTP endpoint) rather than the
base URL. Per RFC 9728 the protected-resource metadata then lives at
.well-known/oauth-protected-resource inserted between host and path:

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

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

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

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

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

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

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

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

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

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

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

Two fixes:
- maxTokens back to 8192 (the original) for all Claude tiers, 4096 for
  GLM. Timeouts bumped to 95s — Sonnet 4.6 at ~130 tok/s does 8192 in
  ~63s, ~30s headroom for cold starts, still under Cloudflare's 100s
  edge cap.
- Detect stop_reason === 'max_tokens' on the Anthropic response BEFORE
  parsing and throw the new SpecTruncatedError. /preview catches it
  and returns 422 spec_too_large with a clear "split the prompt"
  message instead of leaking the zod parse failure.
2026-05-28 19:34:40 +02:00
Marco Sadjadi
979d1abfca feat(preview): log spec validation failures with raw output
All checks were successful
Deploy to Production / deploy (push) Successful in 1m25s
422s from /preview hid the actual reason: zod_message tells which field
was wrong and a 400-char preview of the model output reveals refusals
or non-JSON returns. Both stay in the api log only — never surfaced
to the client unchanged.
2026-05-28 19:19:57 +02:00
Marco Sadjadi
3a05766f88 fix(oauth): allow generic RFC 7591 DCR + expand install snippets
All checks were successful
Deploy to Production / deploy (push) Successful in 1m28s
- /oauth/register: drop resource_required check, accept generic
  registrations (Claude Desktop omits resource in DCR body per spec).
  serverId stored as NULL; /authorize still enforces org-ownership
  + access-token aud claim still pinned to resource. Fixes Claude
  Desktop DCR failure (ofid_d7e39530c109fa7f).
- /oauth/authorize: skip strict server.id check when client.serverId
  is NULL (generic client); org check remains the security boundary.
- schema: oauth_clients.server_id no longer NOT NULL.
- migration 0002: ALTER COLUMN server_id DROP NOT NULL (already
  applied on prod).
- install-snippets: add Claude Code (CLI), VS Code, Codex, raw URL
  tabs. Claude Desktop now shows form-field values (Name / Remote MCP
  Server URL / OAuth Client ID / Secret) matching the new Custom
  Connector UI instead of the obsolete JSON config.
- types: InstallTarget enum extended.
- hero-video: clicking the audio toggle restarts the video from
  frame 0 so unmute aligns with the spoken opening.
- marketing: drop em-dashes from rendered copy.
2026-05-28 17:20:01 +02:00
Marco Sadjadi
438ce3cfbc feat(video): v10 hero video with mute toggle — voice + bg music
All checks were successful
Deploy to Production / deploy (push) Successful in 1m6s
Ships the long-form (71.5 s) hero video to the marketing /flow section
along with the iteration trail of architectural visual fixes the owner
worked through over the last sprint.

## Video composition (remotion/)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Changes:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 22:09:06 +02:00
Marco Sadjadi
1cccdbdff1 fix(auth): logout actually clears the session cookie in Chrome
All checks were successful
Deploy to Production / deploy (push) Successful in 53s
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>
2026-05-25 21:14:12 +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
aa79a71357 security: sovereign-audit Pass-2 fixes — auth-lib, oauth, templates
All checks were successful
Deploy to Production / deploy (push) Successful in 54s
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>
2026-05-25 18:15:54 +02:00
Marco Sadjadi
f8af3fc0fd security: sovereign-audit Phase 2 fixes — trustProxy, Docker hardening, banned-pattern overhaul
All checks were successful
Deploy to Production / deploy (push) Successful in 55s
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>
2026-05-25 18:02:59 +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
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
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
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
a288179954 fix(docker): healthcheck must hit 127.0.0.1, not localhost
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>
2026-05-21 18:07:01 +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
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
9cce4a94c2 fix(security): sovereign-audit — close 2 HIGH + 3 MEDIUM findings
Full reasoning-based audit of all 10 zones. 11 findings, all confirmed real,
zero false positives. 5 fixed now, 6 deferred to a justified backlog.

API-SERVERS-001 (HIGH) — DELETE /v1/servers/:id orphaned the container
  The route deleted the DB row but never stopped the Docker container — it
  kept running forever on its host port, still serving traffic with the
  user's secrets baked into its env. The takedown path got stopContainer in
  an earlier commit; this sibling path was missed. DELETE now tears the
  container down first. Verified: deleted 'gfgfg' — container 23e0c55c gone,
  :4110 connection-refused after.

INFRA-001 (HIGH) — SECRETS_ENCRYPTION_KEY zero-default usable in production
  The AES-256-GCM key defaults to 64 zeros and passes the min(64) check. A
  prod deploy that forgot to set it booted silently with every secret
  encrypted under a public key. config.ts now throws on boot when
  NODE_ENV=production and the key is still the placeholder. Verified: prod
  boot with the zero key is REFUSED.

API-SERVERS-002 (MEDIUM) — WS build stream had no authorization
  GET /v1/builds/:id/stream streamed build logs with no auth, while its REST
  twin checks orgId. Now authenticates from the session cookie and rejects
  builds outside the caller's org. Verified: no cookie -> 'unauthorized';
  cross-org build -> 'not_found'; own build -> streams (no regression).

OAUTH-001 (MEDIUM) — authorization code consumption was not atomic
  The 'already used?' check and the 'mark used' write were separate
  statements — two requests racing the same code could both mint tokens.
  Now a conditional UPDATE ... WHERE consumed_at IS NULL RETURNING; the
  loser of the race gets zero rows and invalid_grant.

OAUTH-002 (MEDIUM) — 'plain' PKCE accepted, contradicting AS metadata
  The AS metadata advertises code_challenge_methods_supported: ['S256'] but
  /oauth/authorize accepted 'plain'. Authorize is now z.literal('S256') and
  pkceVerify dropped the plain branch. Verified: authorize with plain -> 400.

Deferred to backlog (documented in TEMPLATE_SECURITY_AUDIT.md is template-only;
this audit's findings are in the commit + certification):
  GENERATOR-001 — secrets via docker -e (visible in docker inspect); needs
    --env-file rework
  RUNNER-001   — generated containers run as root; needs USER node + build
    re-test
  AUTH-001     — no rate limit on magic-link / oauth register; needs
    @fastify/rate-limit
  GENERATOR-002— allocatePort check/bind race; low, self-heals on rebuild
  AUTH-002     — expired magic_links/sessions/oauth rows never purged; needs
    a cron
  FEATURES-001 — tool-call metering not wired (metrics always 0); Sprint 4
    by plan
2026-05-20 18:15:03 +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
dda8f94de4 feat(wizard): editable spec in step 2 — name, description, JSON schema, secrets
The wizard's confirm step is no longer read-only. Users can refine what Claude
parsed before committing to a build.

Backend:
- @bmm/types adds SpecEdit (tools[name,description,inputSchema] + requiredSecrets);
  CreateServerInput accepts an optional specEdit alongside previewId.
- Servers create endpoint: when specEdit is provided, loads cached spec from Redis,
  index-merges the edits in (keeping LLM-generated implementations untouched),
  re-validates via GeneratorSpec, re-runs the banned-pattern scan, overwrites the
  Redis cache so the worker reads the user's version. Refuses with
  preview_expired/tool_count_mismatch/banned_pattern on safety failures.
- New overwriteSpec() helper in preview-cache.

Frontend:
- Step 2 renders each tool as an editable card: name input, description textarea,
  JSON schema textarea with parse-on-keystroke validation (inline error if invalid).
- Required secrets list is editable: keys via uppercase-snake-case input, +Add /
  remove buttons, secret values kept in sync when keys are renamed.
- Reset-to-AI-suggestion button appears when edits are dirty.
- Pre-submit validation: schema must parse, secret keys must match UPPER_SNAKE_CASE,
  required secret values must be provided.
- Warning copy: 'Renaming parameters may require an Iterate after build — the
  existing impl references the original names.'

Verified end-to-end via browser smoke test: edited description + renamed tool
landed correctly in mcp_servers.tools_schema and in the live container at :4107.
Implementation field preserved from the original cached spec.
2026-05-19 22:10:26 +02:00
Marco Sadjadi
1c92964bbd feat(api,generator): preview endpoint + spec cache + audit-log writes
- POST /v1/servers/preview runs Claude synchronously, validates output, caches spec
  in Redis under preview:<id> with 5min TTL, returns previewId+spec+detectedSecrets.
- POST /v1/servers accepts optional previewId; worker reuses the cached spec if
  the entry is still present, otherwise regenerates fresh. Skips the second
  Claude round-trip (~30s saved on the demoable path).
- audit() helper writes auth.login, auth.logout, server.create, server.iterate,
  server.delete to audit_log with ip, metadata, resourceId.
- GET /v1/me/org returns organization + members list for the settings page.
- GET /v1/audit?limit=&action=&resourceType= returns scoped audit entries.
2026-05-19 18:08:29 +02:00
Marco Sadjadi
bb0d9c2cda feat(llm): extract Claude SYSTEM_PROMPT + generateSpec into shared @bmm/llm package 2026-05-19 18:05:31 +02:00
Marco Sadjadi
ab67203921 fix: live-run wiring (SDK 1.29, zod 3.25, OAUTH_ISSUER split, alt host ports, web on 3001, log level cast, pino transport)
- Bump @modelcontextprotocol/sdk from 1.0.4 to 1.29.0 in runner-template
  (1.0.4 has no McpServer or StreamableHTTPServerTransport — file not found at runtime).
- Bump zod to 3.25.76 across workspace to satisfy modern SDK peer dep.
- Split OAUTH_ISSUER (canonical, host-reachable) from CONTROL_PLANE_URL (container-reachable for JWKS).
  Runner verifies iss against OAUTH_ISSUER; fetches JWKS from CONTROL_PLANE_URL.
  Both API and runner now agree on http://localhost:4000/oauth as the issuer in dev.
- Move postgres host port 5432 to 5440, redis 6379 to 6390 to avoid collisions with
  native installs on the dev machine.
- Move web from 3000 to 3001 (3000 occupied by Gitea on dev machine).
- Drop pino-pretty transport from API to avoid runtime require of an unbundled dep.
- Cast build_logs.level (varchar) to BuildEvent's literal union in WS replay path.
- Remove unused reqBase helper in oauth.ts.
2026-05-19 00:57:23 +02:00
Marco Sadjadi
ea1ec1e801 fix(api): preHandler return type for requireAuth; clean oauth.ts 2026-05-19 00:36:42 +02:00
Marco Sadjadi
648427000d chore(dev): bootstrap script wires docker + drizzle push + turbo dev 2026-05-19 00:35:27 +02:00
Marco Sadjadi
9658e843df feat(api): Fastify control plane (auth, servers, WS build stream, OAuth 2.1 AS, JWKS) 2026-05-19 00:24:47 +02:00