From 09688c1114a12d0619c350cad628611d98605fb3 Mon Sep 17 00:00:00 2001 From: Marco Sadjadi Date: Tue, 19 May 2026 18:20:31 +0200 Subject: [PATCH] feat(web): real 3-step wizard, settings, audit, docs, marketing pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sprint 3.5: close every dead link and replace the single-step wizard with the spec-mandated 3-step flow. Wizard: - Step 1 collects prompt + name + slug, calls /v1/servers/preview. - Step 2 renders parsed tools (name, description, input schema as copyable JSON) + a credential field per requiredSecret Claude actually identified. Self-contained servers see 'No credentials needed' instead of generic Notion placeholders. - Step 3 streams the live build over WebSocket and shows install snippets. New dashboard pages: - /settings — org, plan/usage, members table, API keys + billing stubs (Sprint 4), encryption status. Reads /v1/me/org. - /audit — filterable table over /v1/audit with action pills, resource refs, IP, metadata JSON. Docs site (/docs + 6 sub-pages): - Sticky 240px sidebar, max-w-prose article column, shared DocsTitle/H2/Code primitives. - Quickstart, MCP concepts, OAuth 2.1 flow (full walkthrough with curl), Authoring tools, Self-hosting, API reference, FAQ. Marketing pages: - /changelog with tagged release timeline. - /security with 8 pillars + disclosure. - /privacy with GDPR-aware sections. - /terms (10 clauses). - /pricing full page (nav now points here instead of /#pricing anchor). - /status with live 10s probes against /api/health and /login. Footer 'system status' badge now links to /status. All 20 routes 200 OK in smoke crawl. Typecheck clean across packages. --- CHOICES.md | 65 ++++ apps/web/app/(dashboard)/audit/page.tsx | 120 +++++++ apps/web/app/(dashboard)/servers/new/page.tsx | 295 +++++++++++++----- apps/web/app/(dashboard)/settings/page.tsx | 178 +++++++++++ apps/web/app/(marketing)/changelog/page.tsx | 102 ++++++ apps/web/app/(marketing)/layout.tsx | 8 +- apps/web/app/(marketing)/pricing/page.tsx | 149 +++++++++ apps/web/app/(marketing)/privacy/page.tsx | 103 ++++++ apps/web/app/(marketing)/security/page.tsx | 97 ++++++ apps/web/app/(marketing)/status/page.tsx | 139 +++++++++ apps/web/app/(marketing)/terms/page.tsx | 77 +++++ apps/web/app/docs/api-reference/page.tsx | 90 ++++++ apps/web/app/docs/authoring/page.tsx | 94 ++++++ apps/web/app/docs/concepts/page.tsx | 86 +++++ apps/web/app/docs/faq/page.tsx | 71 +++++ apps/web/app/docs/layout.tsx | 85 +++++ apps/web/app/docs/oauth/page.tsx | 125 ++++++++ apps/web/app/docs/page.tsx | 89 ++++++ apps/web/app/docs/self-hosting/page.tsx | 82 +++++ apps/web/components/docs-page.tsx | 75 +++++ 20 files changed, 2055 insertions(+), 75 deletions(-) create mode 100644 apps/web/app/(dashboard)/audit/page.tsx create mode 100644 apps/web/app/(dashboard)/settings/page.tsx create mode 100644 apps/web/app/(marketing)/changelog/page.tsx create mode 100644 apps/web/app/(marketing)/pricing/page.tsx create mode 100644 apps/web/app/(marketing)/privacy/page.tsx create mode 100644 apps/web/app/(marketing)/security/page.tsx create mode 100644 apps/web/app/(marketing)/status/page.tsx create mode 100644 apps/web/app/(marketing)/terms/page.tsx create mode 100644 apps/web/app/docs/api-reference/page.tsx create mode 100644 apps/web/app/docs/authoring/page.tsx create mode 100644 apps/web/app/docs/concepts/page.tsx create mode 100644 apps/web/app/docs/faq/page.tsx create mode 100644 apps/web/app/docs/layout.tsx create mode 100644 apps/web/app/docs/oauth/page.tsx create mode 100644 apps/web/app/docs/page.tsx create mode 100644 apps/web/app/docs/self-hosting/page.tsx create mode 100644 apps/web/components/docs-page.tsx diff --git a/CHOICES.md b/CHOICES.md index 75f7d9b..6e2ab49 100644 --- a/CHOICES.md +++ b/CHOICES.md @@ -82,3 +82,68 @@ for prod. No CJS. ## Conventional commits per task, single branch Single `main` branch, conventional-commits messages. No PR flow because this is one autonomous run. + +--- + +# Sprint 3.5 — Real 3-step wizard, filled-in pages + +Done after live-running the Sprint 1-3 build and finding two UX gaps: +1. The wizard was a single-step that asked for secrets the user didn't necessarily need. +2. Half the nav links 404'd because the marketing/dashboard surfaces had un-built routes. + +## Two-phase generation (preview → cache → build) +The wizard now runs Claude twice in spirit but **only once on the wire**: +- Step 1 → `POST /v1/servers/preview` calls Claude synchronously, validates the spec, and + caches it in Redis under `preview:` with a 5-minute TTL. +- Step 2 renders the parsed tools + the credentials *Claude actually identified*. + No more generic Notion placeholders — the form is the spec. +- Step 3 → `POST /v1/servers` carries `previewId`. The BullMQ worker reads the cached + spec from Redis (`loadCachedSpec`) and skips the second Claude round-trip. Saves ~30s. + +If the cache is missed (TTL expired or restart) the worker regenerates fresh and logs +a `warn` so the build still completes. + +## Shared `@bmm/llm` package +`SYSTEM_PROMPT` + `generateSpec()` + `SpecValidationError` + `BannedPatternError` live +in `packages/llm`. Both `apps/api` (for the synchronous preview path) and +`apps/generator` (for the worker path) import the same module. No code duplication, no +prompt drift between the two surfaces. + +## Audit log writes +`apps/api/src/lib/audit.ts` exposes a single `audit()` helper that swallows its own +errors (audit failures never block the request path). Five write sites: +- `auth.login`, `auth.logout`, `server.create`, `server.iterate`, `server.delete`. +- Each entry includes `orgId`, `userId`, `resourceType`, `resourceId`, `metadata`, + `ipAddress`. The `/audit` UI page reads from `GET /v1/audit?limit=&action=`. + +## All marketing + dashboard links now resolve +Built out the previously-404 routes as full pages (no "Coming soon" placeholders): +- `/docs` + 6 sub-pages (Quickstart, MCP concepts, OAuth flow, Authoring tools, + Self-hosting, API reference, FAQ) under a static sidebar layout. MDX migration is + Sprint 4; for Sprint 3.5 they're plain TSX with the new ``, + ``, `` primitives. +- `/changelog` — release timeline with tags (launch / feature / fix). +- `/security` — eight pillars with bodies (per-server isolation, encryption, + OAuth 2.1, no-token-passthrough, static checks, container hardening, audit log, + rate limiting) plus disclosure email and compliance roadmap. +- `/privacy` — six sections (what we collect, what we don't, where it lives, + subprocessors, retention, GDPR rights). +- `/terms` — ten clauses. +- `/pricing` — full grid of the four tiers + pricing FAQ. Marketing nav now points + to `/pricing` instead of the landing anchor. +- `/status` — live JS probes against the dashboard and `/api/health` every 10s. + +## Settings + Audit dashboard pages +- `/settings` reads `GET /v1/me/org` and renders five cards: Organization, Plan & usage, + Members (table), API keys (Sprint 4 stub), Encryption (read-only status). +- `/audit` reads `GET /v1/audit` and renders a filterable table with action pills, + resource refs, IP, and metadata JSON. + +## What's *still* Sprint 4 +- Real Stripe billing (the "Manage billing" button in /settings is intentionally + disabled). +- Pagefind search in /docs. +- MDX migration of the docs pages. +- Real BetterStack-hosted status board at status.buildmymcpserver.com. +- Custom domain CNAME validation per server. +- `bmm` CLI + per-org API keys. diff --git a/apps/web/app/(dashboard)/audit/page.tsx b/apps/web/app/(dashboard)/audit/page.tsx new file mode 100644 index 0000000..9960fa3 --- /dev/null +++ b/apps/web/app/(dashboard)/audit/page.tsx @@ -0,0 +1,120 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { apiFetch } from '@/lib/api'; +import { Input } from '@/components/input'; + +interface AuditEntry { + id: string; + action: string; + resourceType: string | null; + resourceId: string | null; + metadata: Record | null; + ipAddress: string | null; + userId: string | null; + createdAt: string; +} + +const ACTION_FILTERS = [ + { value: '', label: 'All actions' }, + { value: 'auth.login', label: 'Logins' }, + { value: 'auth.logout', label: 'Logouts' }, + { value: 'server.create', label: 'Server created' }, + { value: 'server.iterate', label: 'Server iterated' }, + { value: 'server.delete', label: 'Server deleted' }, +]; + +export default function AuditPage() { + const [entries, setEntries] = useState(null); + const [action, setAction] = useState(''); + const [search, setSearch] = useState(''); + + useEffect(() => { + const q = action ? `?action=${encodeURIComponent(action)}` : ''; + apiFetch<{ entries: AuditEntry[] }>(`/v1/audit${q}`).then((r) => setEntries(r.entries)); + }, [action]); + + const visible = entries?.filter((e) => + search + ? e.action.includes(search) || + e.resourceId?.includes(search) || + e.ipAddress?.includes(search) + : true, + ); + + return ( +
+
+

Audit log

+

+ Every privileged action in your workspace, with IP and metadata. +

+
+ +
+ + setSearch(e.target.value)} + placeholder="Filter by resource id or ip…" + className="w-72" + /> +
+ +
+ {!visible && ( +
Loading…
+ )} + {visible && visible.length === 0 && ( +
+ No matching entries. +
+ )} + {visible && visible.length > 0 && ( + + + + + + + + + + + + {visible.map((e) => ( + + + + + + + + ))} + +
WhenActionResourceIPMetadata
+ {new Date(e.createdAt).toLocaleString()} + + + {e.action} + + + {e.resourceType ? `${e.resourceType}/${e.resourceId?.slice(0, 8) ?? '—'}` : '—'} + {e.ipAddress ?? '—'} + {e.metadata ? JSON.stringify(e.metadata) : '—'} +
+ )} +
+
+ ); +} diff --git a/apps/web/app/(dashboard)/servers/new/page.tsx b/apps/web/app/(dashboard)/servers/new/page.tsx index 33656ab..dfc87d0 100644 --- a/apps/web/app/(dashboard)/servers/new/page.tsx +++ b/apps/web/app/(dashboard)/servers/new/page.tsx @@ -8,15 +8,50 @@ import { Input, Label, Textarea } from '@/components/input'; import { StreamingLogs } from '@/components/streaming-logs'; import { InstallSnippets } from '@/components/install-snippets'; import { CodeBlock } from '@/components/code-block'; +import { Loader2 } from 'lucide-react'; const EXAMPLE_PROMPTS = [ - 'Read-only Postgres reader for the users and orders tables at db.example.com', - 'Search and read pages from our Notion workspace via the Notion API', - 'Wrap our internal HTTP API at api.acme.com — endpoints /search and /lookup', - 'Stripe charges and customers (read-only)', + { + label: 'Echo / demo (no external API)', + text: 'Build a simple echo server with two tools: echo (string in, same string back) and now (returns current UTC timestamp). No external API needed.', + }, + { + label: 'Notion search', + text: 'Search and read pages from our Notion workspace via the Notion API. Tools: search_pages(query), get_page_content(page_id). Auth: NOTION_API_KEY.', + }, + { + label: 'Postgres reader', + text: 'Read-only Postgres reader for the users and orders tables at db.example.com. One tool per table: list_users(limit), list_orders(limit, customer_id?). Auth: DATABASE_URL.', + }, + { + label: 'Wrap our REST API', + text: 'Wrap our internal HTTP API at api.acme.com — endpoints /search?q= and /lookup?id=. Tools: search(query), lookup(id). Auth: ACME_API_TOKEN.', + }, + { + label: 'Stripe charges (read-only)', + text: 'Stripe charges and customers, read-only. Tools: list_recent_charges(limit), get_customer(customer_id). Auth: STRIPE_SECRET_KEY.', + }, ]; -type Step = 'prompt' | 'building' | 'done'; +type Step = 'prompt' | 'analyzing' | 'confirm' | 'building' | 'done'; + +interface PreviewTool { + name: string; + description: string; + inputSchema: Record; +} + +interface PreviewResponse { + previewId: string; + source: 'claude' | 'mock'; + spec: { + name: string; + description?: string; + tools: PreviewTool[]; + requiredSecrets: string[]; + scopes: string[]; + }; +} interface BuildResult { serverId: string; @@ -26,10 +61,14 @@ interface BuildResult { export default function NewServerPage() { const router = useRouter(); const [step, setStep] = useState('prompt'); + + const [prompt, setPrompt] = useState(''); const [name, setName] = useState(''); const [slug, setSlug] = useState(''); - const [prompt, setPrompt] = useState(''); - const [secretRows, setSecretRows] = useState<{ key: string; value: string }[]>([{ key: '', value: '' }]); + + const [preview, setPreview] = useState(null); + const [secretValues, setSecretValues] = useState>({}); + const [error, setError] = useState(null); const [buildId, setBuildId] = useState(null); const [serverId, setServerId] = useState(null); @@ -38,20 +77,61 @@ export default function NewServerPage() { const trySlug = (n: string) => n.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 32); - async function submit() { + async function analyze() { setError(null); - if (!name || !slug || prompt.length < 10) { - setError('Name, slug and a prompt of at least 10 characters are required.'); + if (prompt.trim().length < 10) { + setError('Prompt must be at least 10 characters.'); return; } - const secrets: Record = {}; - for (const row of secretRows) { - if (row.key && row.value) secrets[row.key.trim()] = row.value; + if (!name || !slug) { + setError('Name and slug are required.'); + return; } + setStep('analyzing'); + try { + const res = await apiFetch('/v1/servers/preview', { + method: 'POST', + body: JSON.stringify({ prompt }), + }); + setPreview(res); + const initial: Record = {}; + for (const key of res.spec.requiredSecrets) initial[key] = ''; + setSecretValues(initial); + setStep('confirm'); + } catch (e) { + const detail = (e as { detail?: { error?: string; detail?: string } }).detail; + setError(detail?.detail ?? detail?.error ?? (e as Error).message); + setStep('prompt'); + } + } + + async function build() { + setError(null); + if (!preview) return; + + const filledSecrets: Record = {}; + for (const [k, v] of Object.entries(secretValues)) { + if (v.trim()) filledSecrets[k] = v; + } + const missing = preview.spec.requiredSecrets.filter((k) => !filledSecrets[k]); + if (missing.length > 0) { + setError(`Fill in: ${missing.join(', ')} — or remove if not needed.`); + return; + } + try { const res = await apiFetch<{ server: { id: string }; build: { id: string } }>( '/v1/servers', - { method: 'POST', body: JSON.stringify({ name, slug, prompt, secrets }) }, + { + method: 'POST', + body: JSON.stringify({ + name, + slug, + prompt, + secrets: filledSecrets, + previewId: preview.previewId, + }), + }, ); setBuildId(res.build.id); setServerId(res.server.id); @@ -62,35 +142,40 @@ export default function NewServerPage() { } } + const stepNumber = step === 'prompt' ? 1 : step === 'analyzing' || step === 'confirm' ? 2 : 3; + return (

New MCP server

- STEP {step === 'prompt' ? '1' : step === 'building' ? '2' : '3'} / 3 + STEP {stepNumber} / 3
{step === 'prompt' && (
- +