SPB Git forge

spb/polyllm

Public
15commits 1branches 0releases
2.2 MBsize
maindefault branch
13 days agolast push
TypeScript 97.4% SQL 1% JavaScript 0.9% CSS 0.6%
18.1 KB

# F — Marketing site, onboarding, auth pages, PWA polish (2026-09-11)

Workstream F of docs/UPGRADE-PLAN.md. Everything below compiles (pnpm typecheck green, eslint clean on every file listed here, pnpm test 125/125). No dev server was run; visual QA belongs to the integration phase.

# Files

# Created

File Purpose
src/app/api/public/models/route.ts Public, unauthenticated GET /api/public/models (see API below).
src/lib/marketing/public-models.ts Pure curation (curatePublicModels), formatContext, formatPricePair, PublicModelSummary type.
tests/unit/public-models.test.ts Vitest: filtering, ordering (new → flagship, provider round-robin), cap, field shape, formatters.
src/components/marketing/fake-stream.ts Shared scripted-streaming engine (useFakeStreams, fastestIndex, approxTokens). rAF-driven, instant under reduced motion.
src/components/marketing/frames.tsx PhoneFrame (390×844, status bar, home indicator) and DesktopFrame (window chrome with URL bar).
src/components/marketing/product-mock.tsx Faithful mocks of the real app: SidebarMock, ModelPill, ContextIndicator, MessageMeta, ComposerMock, MobileComposerMock ([+] Ask anything… [mic] [send]), BottomNavMock (reads the real MOBILE_TABS), MobileHeaderMock, DesktopChatMock/MobileChatMock, DesktopArenaMock/MobileArenaMock (.snap-row, sticky model tabs, dots), ArenaMiniCard, DesktopModelsMock/MobileModelsMock, DesktopUsageMock/MobileUsageMock, ScoreboardMock, MockMarkdown (bold, code, paragraphs, pipe tables).
src/components/marketing/hero-visual.tsx Animated hero: desktop window (sidebar, model pill, streaming answer + caret, metadata line, mini Arena card that fades in) / phone frame with the real mobile layout below md. One shared stream.
src/components/marketing/model-strip.tsx Live marquee (.marquee + .mask-x, duplicated list, hover-pause) fed by SWR from /api/public/models; skeleton first; static wrapped grid under reduced motion; hidden on error/empty.
src/components/marketing/security-flow.tsx SecurityFlow (Browser → PolyLLM encrypted layer → Provider) and KeyLifecycle (5 steps) for /security.
src/components/marketing/security-teaser.tsx Homepage #security section linking to /security.
src/components/marketing/install-hint.tsx "Install PolyLLM" — beforeinstallprompt (Chromium/Android) or iOS Share → Add to Home Screen tip; hidden when standalone/dismissed/installed.
src/app/(marketing)/security/page.tsx Security page (see verified claims).
src/app/(marketing)/contact/page.tsx Contact page: e-mail, who built it, hosting, security reports.
src/app/app/onboarding/page.tsx + onboarding-flow.tsx Six-step onboarding (details below).

# Changed

File Change
src/app/(marketing)/page.tsx New section order: Hero → ModelStrip → Router → Arena → Catalog → Config → Workspace → Analytics → Endpoints → Demo → Also → SecurityTeaser → FAQ → FinalCta. Metadata description updated.
src/components/marketing/hero.tsx Headline "One interface. / Every model." (text-5xl token, text-balance), sub-line, CTAs "Start using PolyLLM" → /signup, "Try Arena" → /app/arena (proxy → /login?next=/app/arena → Arena), provider row, HeroVisual.
src/components/marketing/demo.tsx Rewritten: Segmented tabs Chat / Arena / Models / Usage, Replay, desktop frame (≥ md) + phone frame (< md and ≥ xl side by side), per-tab note. Desktop UI is never squeezed into the phone.
src/components/marketing/features.tsx Rewritten: FeatureRouter (AUTO recommendation card), FeatureArena (scoreboard + Blind Arena), FeatureCatalog, FeatureWorkspace (projects / prompt {{variables}} / context library), FeatureAnalytics, FeatureEndpoints (Ollama, LM Studio, vLLM), FeatureAlso (PWA, keyboard, exports, compare-in-chat, presets, temporary chats). Spatial grouping (.panel, hairlines) instead of boxed cards.
src/components/marketing/mock-data.ts 9 mock models (one per provider, with tags), hero/chat/arena scripts, usage series/KPIs/savings, scoreboard, FAQ rewritten (router, blind arena, custom endpoints, temporary chats). PROVIDER_LABEL is exhaustive over ProviderId (includes custom).
src/components/marketing/footer.tsx Columns Product / Models / Account / Legal; "Made by" block: contact@spboucher.ai (mailto), Simon-Pierre Boucher, Hosted on MacLustr — www.maclustr.io (external); providers trademark note; InstallHint (compact).
src/components/marketing/header.tsx Nav: Features, Demo, Models (/models), Security (/security), FAQ.
src/components/marketing/legal.tsx Contact e-mail aligned to contact@spboucher.ai.
src/components/marketing/providers-row.tsx Removed (superseded by the provider row in the hero and the live strip).
src/app/sitemap.ts Added /models (daily, 0.9), /security (0.7), /contact (0.4).
src/components/app/onboarding.tsx Onboarding export kept. Now a first-run gate: redirects once per browser session to /app/onboarding when onboardingCompletedAt === null and the (loaded) connections list is empty; afterwards renders a small "Finish setting up PolyLLM" link. Uses the same SWR key as the store (/api/providers) to wait for isLoading.
src/app/(auth)/layout.tsx Mobile-first shell: brand mark + wordmark above the form on phones, safe-area padding (pt-safe, pb-[max(20px,var(--sab))]), footer links (Privacy, Terms, Security, Help). Desktop ≥ lg: two columns, quiet brand panel (headline, 3 points, provider marks).
src/app/(auth)/_components/auth-card.tsx Borderless full-width on phones (26 px title, 15 px copy, 44 px inputs, 48 px submit), elevated card from sm. Form logic untouched.

# API

GET /api/public/models — no auth. Headers Cache-Control: public, max-age=600, s-maxage=600, stale-while-revalidate=300.

Response 200:

json
{ "models": [ { "key": "openai/gpt-5.5", "displayName": "GPT-5.5", "provider": "openai", "contextTokens": 400000,
               "inputPerMillion": 1.25, "outputPerMillion": 10, "status": "active", "firstSeenAt": "2026-08-30T…Z" } ],
  "total": 132, "generatedAt": "…" }
  • ≤ 40 entries, only hidden = false and status ∈ {active, preview}.
  • Ordering: models first seen in the last 30 days first, then by registry sortWeight (flagships), then newest; the ranked list is interleaved per provider so the first screen shows all providers.
  • 503 { models: [] } (cached 60 s) if the database is unavailable; the strip hides itself.
  • Reads the models table directly (@/db) — no change to lib/ai/registry (B's area).

# Onboarding (/app/onboarding)

Client component, inside the app shell (the shell already hides the bottom nav on this route). Six steps:

  1. Create account — already done → green check with name/e-mail.
  2. Choose providers — grid of tiles (PROVIDER_ORDER, PROVIDERS meta), multi-select with aria-pressed; connected ones show "Connected".
  3. Add first API key — rows for chosen + connected providers; "Get a key" (provider console) and "Add key" → useAddKeyDialog / AddKeyDialog (E's component, unchanged). Continue requires ≥ 1 valid connection; Skip otherwise.
  4. Validate key — the dialog result (ok, modelsAvailable, error) is recorded as the validation; "Test connection" calls the existing POST /api/providers {action:"validate"}; status Valid / Rejected / Not validated with hint, models count, latency, relative time; "Replace key" on failure.
  5. Select favorite models — connected, non-deprecated models grouped per provider (6 per provider, filter input), star toggles useApp().toggleFavorite (44 px rows).
  6. Send first prompt — four suggested prompts + "Open the chat". finish() = updatePreferences({ onboardingCompleted: true }), localStorage marker polyllm:onboarding-done:<userId>, setSelectedModelKey(firstFavorite), router.replace('/app/chat?model=<key>&q=<prompt>'), router.refresh().

Layout: phones = progress bar + swipeable .snap-row panels (useSnapCarousel, each panel scrolls on its own) + fixed footer with 44 px buttons (Back / Skip / Continue) above the safe area; desktop = centered two-column (sticky step list with check marks, content column). "Skip setup" in the header marks onboarding complete and returns to chat.

TODO(integration: chat) — ChatView currently applies ?preset=, ?prompt= (prompt preset id) and ?model=. The onboarding passes the suggested text as ?q=; add one line in components/chat/chat-view.tsx to prefill the composer draft from search.get("q") (e.g. if (q && !conversationId) setDraft(q) in the existing search-params effect). Until then the user lands on a new chat with the favorite model selected and an empty composer.

# Verified security claims (source references)

Every statement on /security, the homepage security teaser and the FAQ was checked against the code on 2026-09-11:

Claim Where verified
AES-256-GCM, data key derived with HKDF-SHA256 from API_KEY_ENCRYPTION_SECRET (info polyllm:provider-key:v1), random 12-byte IV, 16-byte auth tag, AAD userId|provider, envelope v1.<iv>.<tag>.<ct> base64url src/lib/crypto/keys.ts (dataKey, encryptSecret, decryptSecret)
Key hint = recognizable prefix (≤ 8 chars) + •••••••• + last 4; SHA-256 fingerprint for same-key detection keyHint, fingerprintSecret in src/lib/crypto/keys.ts
Key validated against the provider on save; shape check 8–512 chars, no whitespace upsertConnection in src/lib/providers/keys.ts; PUT /api/providers passes validate: true
Decrypted only right before a provider call (getDecryptedKey, validateConnection) src/lib/providers/keys.ts; callers api/providers, api/models/sync, chat/arena services
PublicConnection has no key material src/lib/providers/keys.ts interface
Audit events provider.key_added / provider.key_replaced / provider.key_deleted with IP src/lib/providers/keys.ts → writeAudit (src/lib/audit.ts: action, ip, UA truncated to 300, meta)
Other audit actions: account.created, login (ip + UA), email.verified, email.change_requested, password.reset_requested, password.reset, account.deleted; conversation actions export, share, branch, duplicate src/lib/auth.ts hooks; src/app/api/conversations/[id]/actions/route.ts
Audit log shown to the user (Settings → Account, GET /api/account) — not included in the JSON export src/app/app/settings/account/page.tsx, src/app/api/account/route.ts
Logs: structured JSON, redactSecrets on every string, keys matching api key/password/secret/token/authorization/cookie/prompt/content/messages → [redacted] src/lib/log.ts
Passwords: Argon2id, memoryCost 19 456 KiB (19 MiB), timeCost 2, parallelism 1; min 10 / max 128 chars ARGON2_OPTS and emailAndPassword in src/lib/auth.ts
E-mail verification required, no auto sign-in on sign-up, verification link 24 h, reset link 60 min, sessions revoked on password reset, change e-mail / delete account confirmed by e-mail src/lib/auth.ts (requireEmailVerification, autoSignIn: false, expiresIn, resetPasswordTokenExpiresIn, revokeSessionsOnPasswordReset, changeEmail, deleteUser)
Cookies: prefix polyllm, Secure in production, HttpOnly, SameSite=Lax; session 30 d, refreshed daily, 5-min cookie cache advanced.cookiePrefix, useSecureCookies: IS_PROD, session in src/lib/auth.ts; defaults httpOnly: true, sameSite: "lax" in node_modules/better-auth/dist/cookies/index.mjs
Auth accepted only from the app origin trustedOrigins: [APP_URL, "http://localhost:3000"]
Auth rate limits: 100/min default; sign-in 8, sign-up 4, forget/request-reset 4, send-verification 3, change-password 5, change-email 3, delete-user 3 (per minute) rateLimit.customRules in src/lib/auth.ts
App rate limits (per user id, in-memory sliding window, 429 + Retry-After): chat 60, arena 20, arena stream 80, key save 12, key validate 10, model sync 6, upload 40, search 120, share/conversation actions 20 per minute LIMITS in src/lib/rate-limit.ts; withUser in src/lib/api.ts; route files under src/app/api/*
CSP default-src 'self'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'; object-src 'none'; img-src 'self' data: blob: https:; font-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' (prod); connect-src 'self'; worker-src 'self' blob:; upgrade-insecure-requests next.config.ts
HSTS max-age=31536000; includeSubDomains (prod), X-Frame-Options: DENY, X-Content-Type-Options: nosniff, Referrer-Policy: strict-origin-when-cross-origin, Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), poweredByHeader: false next.config.ts
Unauthenticated /app and /admin redirected to /login?next= src/proxy.ts
Owner keys only for registry sync / admin / tests CLAUDE.md non-negotiables; syncAllWithEnvKeys in src/lib/ai/registry/index.ts

Stated as a trade-off on the page: 'unsafe-inline' in script-src/style-src (Next hydration + Radix). Not claimed anywhere: encrypted backups, nonce-based CSP, Redis-backed limits.

# Coming soon / not implemented

Nothing is labelled "Coming soon". All buttons work. The only pending hook is the ?q= draft prefill in ChatView (above).

# Notes for other areas

  • E (settings/appearance): components/marketing/install-hint.tsx is reusable (<InstallHint compact />); it stores dismissal in localStorage["polyllm:install-hint-dismissed"].
  • B (models): the footer and header link to /models; the hero strip links "Browse all" to /models. formatContext/formatPricePair in src/lib/marketing/public-models.ts are generic if useful.
  • A (chat): ?q= prefill (see above). The Onboarding component is still mounted from components/chat/empty-state.tsx; it now renders either nothing or a small link, so the empty state's own "Connect your first provider" card remains the primary CTA there.
  • Lint errors remaining in the tree at the time of writing are in G's files (components/search/search-sheet.tsx, components/app/command-palette.tsx), not in this area.

# QA checklist (integration phase, 375 / 390 / 393 / 430 / 1440)

Homepage

  • Hero headline wraps as two lines on phones without orphan; CTAs full-width on phones, inline from sm.
  • Phone frame (< md) shows header with model pill, user bubble, streaming answer with caret, metadata line, composer [+] Ask anything… [mic] [send], bottom nav with Chat active; no horizontal overflow at 375.
  • Desktop (≥ md) shows the window with sidebar, model pill, context indicator, streaming answer; mini Arena card appears bottom-right at ≥ lg after ~3 s and its bars finish; at 1440 the card does not overflow the container (xl:-right-10).
  • Live strip: skeleton → marquee; pauses on hover; "New"/"Preview" badges; prefers-reduced-motion → static wrapped grid; strip hidden when /api/public/models returns 503 (stop the DB to test).
  • /api/public/models returns ≤ 40 models, Cache-Control header present, no auth needed, no user fields.
  • Features: no boxed-card overload; visuals fit at 375 (Router card, Scoreboard + Blind Arena stack, Models/Usage windows without the sidebar, Workspace panel, Endpoints list).
  • Demo: Segmented fills the width on phones; tabs switch instantly; Replay restarts streams; phone Arena swipes between the three answers and the tab pills/dots follow; desktop Arena shows three columns, "fastest" badge, vote pills, Winner badge when done; Models/Usage tabs render both frames at ≥ xl.
  • #features, #arena, #demo, #security, #faq anchors land correctly with the sticky header (scroll-mt-16).
  • Footer: four columns collapse to 2 columns on phones; Made-by block shows mailto, name, MacLustr external link (new tab); Install hint appears only on Chromium (beforeinstallprompt) or iOS Safari, disappears when dismissed or in standalone mode.

Security / Contact

  • /security flow renders as a vertical stack with down arrows on phones and a 3-column row at ≥ lg; header dl rows wrap long CSP values without overflow; rate-limit tables are two columns at ≥ md.
  • /contact mailto works; external MacLustr link opens in a new tab.
  • /sitemap.xml lists /models, /security, /contact.

Onboarding

  • New user (no providers, onboardingCompletedAt null) opening /app/chat is redirected once to /app/onboarding; navigating back to chat in the same session shows the "Finish setting up PolyLLM" link instead of redirecting again.
  • Phone: progress bar, swipe between steps (.snap-row), footer buttons 44 px above the safe area, bottom nav hidden, each step scrolls independently, no horizontal overflow at 375.
  • Desktop: sticky step list with check marks; clicking a step jumps to it.
  • Step 3 opens AddKeyDialog; after a successful save, step 4 shows "Valid · N models"; "Test connection" updates status and shows latency; a rejected key shows the provider error and "Replace key".
  • Step 5 stars persist (/api/models favorites) and the count updates; filter input works.
  • Step 6 / "Open the chat" marks onboarding completed (users.onboarding_completed_at), selects the first favorite model and opens /app/chat?model=…&q=…; "Skip setup" does the same without a prompt.

Auth

  • Login/signup/forgot/reset/verify at 375: brand mark + wordmark above the form, no card border, 44 px inputs (16 px text on touch), 48 px submit, password strength meter, footer above the home indicator.
  • ≥ lg: brand panel left, form right; the header logo is invisible on the right column (panel has its own).
  • All existing behaviours unchanged: unverified → resend flow, ?next= redirect after login, notices (reset, verified, signed-out).

PWA

  • /manifest.webmanifest linked from the layout (already); Lighthouse "installable" passes; "Install PolyLLM" hint on the homepage footer (Chromium) triggers the native prompt.