TypeScript 97.4%
SQL 1%
JavaScript 0.9%
CSS 0.6%
1import type { Metadata } from "next";2import Link from "next/link";3import { ArrowRight, Cookie, Fingerprint, Gauge, KeyRound, ScrollText, ShieldCheck } from "lucide-react";4import { Button } from "@/components/ui/button";5import { Container, Eyebrow, Section, SectionHeading } from "@/components/marketing/section";6import { Reveal } from "@/components/marketing/reveal";7import { KeyLifecycle, SecurityFlow } from "@/components/marketing/security-flow";89export const metadata: Metadata = {10 title: "Security",11 description: "How PolyLLM protects your API keys and account: AES-256-GCM key encryption with HKDF-derived keys, Argon2id passwords, HttpOnly cookies, a strict Content-Security-Policy, per-route rate limits and an audit log. Every claim on this page is verified in the source code.",12 alternates: { canonical: "/security" },13 openGraph: { title: "PolyLLM — Security", description: "Where your keys go, how they are encrypted, and what we log.", url: "/security" },14};1516/* ------------------------------------------------------------------------------------------------17 * Facts verified in code (see docs/upgrade-notes/F-marketing.md for the file references)18 * ---------------------------------------------------------------------------------------------- */19const AUTH_LIMITS: [string, string][] = [20 ["Sign in", "8 / min"],21 ["Sign up", "4 / min"],22 ["Password reset request", "4 / min"],23 ["Verification e-mail", "3 / min"],24 ["Change password", "5 / min"],25 ["Change e-mail", "3 / min"],26 ["Delete account", "3 / min"],27 ["Any other auth route", "100 / min"],28];29const APP_LIMITS: [string, string][] = [30 ["Chat requests", "60 / min"],31 ["Arena sessions", "20 / min"],32 ["Arena streams", "80 / min"],33 ["Save a key", "12 / min"],34 ["Validate a key", "10 / min"],35 ["Model sync", "6 / min"],36 ["File uploads", "40 / min"],37 ["Search", "120 / min"],38 ["Share / conversation actions", "20 / min"],39];4041const HEADERS: [string, string][] = [42 ["Content-Security-Policy", "default-src 'self'; frame-ancestors 'none'; object-src 'none'; base-uri 'self'; form-action 'self'; connect-src 'self'; img-src 'self' data: blob: https:; font-src 'self' data:; worker-src 'self' blob:; upgrade-insecure-requests"],43 ["Strict-Transport-Security", "max-age=31536000; includeSubDomains (production)"],44 ["X-Frame-Options", "DENY"],45 ["X-Content-Type-Options", "nosniff"],46 ["Referrer-Policy", "strict-origin-when-cross-origin"],47 ["Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=()"],48];4950const AUDIT_EVENTS = ["account.created", "login", "email.verified", "email.change_requested", "password.reset_requested", "password.reset", "provider.key_added", "provider.key_replaced", "provider.key_deleted", "account.deleted", "export · share · branch · duplicate"];5152export default function SecurityPage() {53 return (54 <>55 {/* Header */}56 <section className="relative overflow-hidden">57 <div aria-hidden className="dot-grid pointer-events-none absolute inset-0 opacity-60 [mask-image:radial-gradient(ellipse_70%_60%_at_50%_0%,black_30%,transparent_100%)] dark:opacity-40" />58 <Container className="relative pb-10 pt-12 sm:pb-14 sm:pt-20">59 <Reveal className="max-w-3xl">60 <Eyebrow>Security</Eyebrow>61 <h1 className="mt-3 text-balance text-4xl font-semibold tracking-[-0.03em]">Where your keys go, how they are encrypted, and what we log.</h1>62 <p className="mt-5 max-w-2xl text-pretty text-base leading-7 text-fg-muted sm:text-lg sm:leading-8">63 PolyLLM handles the most sensitive thing a developer owns: API keys with a billing account attached. This page describes the actual implementation — every statement below is checked against the source code, not a policy document. When something is a trade-off, we say so.64 </p>65 </Reveal>66 </Container>67 </section>6869 {/* Flow */}70 <Section tone="subtle" className="py-12 sm:py-16">71 <SectionHeading eyebrow="Request path" title="Browser → PolyLLM encrypted server layer → Provider" description="Your browser never holds a provider key after the moment you paste it. The server encrypts it, stores the envelope, and only decrypts it in memory when it is about to talk to the provider you chose." />72 <div className="mt-10">73 <SecurityFlow />74 </div>75 </Section>7677 {/* Key lifecycle */}78 <Section id="keys" className="py-14 sm:py-20">79 <div className="grid gap-10 lg:grid-cols-12">80 <div className="lg:col-span-4">81 <SectionHeading eyebrow="API key lifecycle" title="From paste to provider, step by step" description="The implementation lives in a single module (src/lib/crypto/keys.ts) and one service (src/lib/providers/keys.ts). Here is what they do." />82 </div>83 <div className="lg:col-span-8">84 <KeyLifecycle />85 </div>86 </div>87 </Section>8889 {/* Account & sessions */}90 <Section id="account" tone="subtle" className="py-14 sm:py-20">91 <SectionHeading eyebrow="Account & sessions" title="Passwords, cookies and e-mail verification" />92 <div className="mt-10 grid gap-8 md:grid-cols-2 lg:grid-cols-3">93 <Reveal className="flex gap-3.5">94 <span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-accent-soft text-accent [&_svg]:size-4.5">95 <Fingerprint />96 </span>97 <div>98 <h3 className="text-[15px] font-semibold tracking-tight">Argon2id password hashing</h3>99 <p className="mt-1 text-[13.5px] leading-6 text-fg-muted">100 Passwords are hashed with Argon2id at the OWASP-recommended parameters: 19 MiB memory, 2 iterations, parallelism 1 (the <code className="rounded bg-bg-muted px-1 font-mono text-[12px] text-fg">argon2</code> native library, configured in Better Auth). Minimum 10 and maximum 128 characters. The password itself is never stored.101 </p>102 </div>103 </Reveal>104 <Reveal delay={0.05} className="flex gap-3.5">105 <span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-accent-soft text-accent [&_svg]:size-4.5">106 <Cookie />107 </span>108 <div>109 <h3 className="text-[15px] font-semibold tracking-tight">HttpOnly, SameSite=Lax, Secure cookies</h3>110 <p className="mt-1 text-[13.5px] leading-6 text-fg-muted">111 Sessions live in Better Auth cookies prefixed <code className="rounded bg-bg-muted px-1 font-mono text-[12px] text-fg">polyllm</code>: HttpOnly (not readable by JavaScript), SameSite=Lax, and Secure in production. Sessions last 30 days and are refreshed daily; a 5-minute cookie cache avoids a database round-trip on every request. Nothing sensitive is kept in localStorage.112 </p>113 </div>114 </Reveal>115 <Reveal delay={0.1} className="flex gap-3.5">116 <span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-accent-soft text-accent [&_svg]:size-4.5">117 <KeyRound />118 </span>119 <div>120 <h3 className="text-[15px] font-semibold tracking-tight">Verified e-mail, revocable sessions</h3>121 <p className="mt-1 text-[13.5px] leading-6 text-fg-muted">122 E-mail verification is mandatory before the first sign-in (links valid 24 h). Password reset links expire after 60 minutes and every other session is revoked when the password changes. Changing your e-mail or deleting the account requires a confirmation link sent to the current address. Auth requests are only accepted from the app's own origin.123 </p>124 </div>125 </Reveal>126 </div>127 </Section>128129 {/* Headers */}130 <Section id="headers" className="py-14 sm:py-20">131 <div className="grid gap-10 lg:grid-cols-12">132 <div className="lg:col-span-4">133 <SectionHeading134 eyebrow="Transport & headers"135 title="A strict Content-Security-Policy and hardened response headers"136 description={137 <>138 Set globally in <code className="rounded bg-bg-muted px-1 font-mono text-[12px] text-fg">next.config.ts</code>. The site cannot be framed, scripts and connections are restricted to our own origin, and HTTPS is enforced for a year with HSTS. The <code className="rounded bg-bg-muted px-1 font-mono text-[12px] text-fg">X-Powered-By</code> header is removed.139 </>140 }141 />142 <Reveal delay={0.1}>143 <p className="mt-6 rounded-xl border border-dashed border-border-strong/70 p-4 text-[13px] leading-6 text-fg-muted">144 <span className="font-medium text-fg">Trade-off, stated plainly:</span> <code className="font-mono text-[12px]">script-src</code> and <code className="font-mono text-[12px]">style-src</code> include <code className="font-mono text-[12px]">'unsafe-inline'</code> because Next.js hydration and Radix components inject inline scripts and style attributes. There are no third-party scripts on the site, so the practical exposure is small, but nonces would be stricter and are on the list.145 </p>146 </Reveal>147 </div>148 <Reveal delay={0.05} className="lg:col-span-8">149 <dl className="divide-y divide-hairline rounded-2xl bg-bg-subtle">150 {HEADERS.map(([k, v]) => (151 <div key={k} className="grid gap-1 px-4 py-3.5 sm:grid-cols-[14rem_1fr] sm:gap-4 sm:px-5">152 <dt className="font-mono text-[12.5px] font-medium text-fg">{k}</dt>153 <dd className="break-words font-mono text-[12px] leading-5 text-fg-muted">{v}</dd>154 </div>155 ))}156 </dl>157 </Reveal>158 </div>159 </Section>160161 {/* Rate limits */}162 <Section id="rate-limits" tone="subtle" className="py-14 sm:py-20">163 <SectionHeading164 eyebrow="Rate limits"165 title="Brute force and abuse are blunted per account"166 description="Authentication routes are limited by Better Auth per client; application routes use a sliding-window limiter keyed by user id. Limits are conservative for a single-user workspace and return HTTP 429 with a Retry-After header."167 />168 <div className="mt-10 grid gap-6 md:grid-cols-2">169 {[170 { title: "Authentication", rows: AUTH_LIMITS, icon: <ShieldCheck /> },171 { title: "Application", rows: APP_LIMITS, icon: <Gauge /> },172 ].map((group, gi) => (173 <Reveal key={group.title} delay={0.05 * gi}>174 <h3 className="flex items-center gap-2 text-[15px] font-semibold tracking-tight [&_svg]:size-4 [&_svg]:text-accent">175 {group.icon} {group.title}176 </h3>177 <dl className="mt-3 divide-y divide-hairline rounded-2xl bg-bg-elevated">178 {group.rows.map(([k, v]) => (179 <div key={k} className="flex items-center justify-between gap-4 px-4 py-2.5 text-[13.5px]">180 <dt className="text-fg-muted">{k}</dt>181 <dd className="font-mono text-[12.5px] tabular-nums text-fg">{v}</dd>182 </div>183 ))}184 </dl>185 </Reveal>186 ))}187 </div>188 </Section>189190 {/* Audit log */}191 <Section id="audit" className="py-14 sm:py-20">192 <div className="grid gap-10 lg:grid-cols-12">193 <div className="lg:col-span-5">194 <SectionHeading195 eyebrow="Audit log"196 title="Security-relevant actions are recorded and shown to you"197 description="Each event stores the action, your user id, the requesting IP address, a truncated user agent and non-sensitive metadata (for example which provider a key belongs to — never the key). The log is yours: the most recent events are displayed in Settings → Account, and deleting your account removes them with everything else."198 />199 <Reveal delay={0.1}>200 <p className="mt-5 flex items-start gap-2 text-[13.5px] leading-6 text-fg-muted">201 <ScrollText className="mt-1 size-4 shrink-0 text-accent" />202 Server logs are structured JSON; every string passes through a secret redactor (provider key patterns, bearer tokens) and keys named like api key, password, token, cookie, prompt or content are replaced by <code className="rounded bg-bg-muted px-1 font-mono text-[12px] text-fg">[redacted]</code>. Prompts and answers are not logged.203 </p>204 </Reveal>205 </div>206 <Reveal delay={0.05} className="lg:col-span-7">207 <ul className="flex flex-wrap gap-2">208 {AUDIT_EVENTS.map((e) => (209 <li key={e} className="rounded-lg bg-bg-subtle px-2.5 py-1.5 font-mono text-[12px] text-fg-muted">210 {e}211 </li>212 ))}213 </ul>214 <div className="mt-6 rounded-2xl bg-bg-subtle p-4 sm:p-5">215 <p className="text-[13px] font-medium">What we deliberately do not do</p>216 <ul className="mt-2 space-y-1.5 text-[13.5px] leading-6 text-fg-muted">217 <li>· No analytics trackers, advertising pixels or fingerprinting scripts on any page.</li>218 <li>· No third-party inference brokers: requests go to the provider you picked and nowhere else.</li>219 <li>· No training on your data, and temporary chats are never written to the database.</li>220 <li>· No owner keys for user traffic: the operator's own provider keys are used only to sync the public model registry.</li>221 </ul>222 </div>223 </Reveal>224 </div>225 </Section>226227 {/* CTA */}228 <Section tone="subtle" className="py-14 sm:py-16">229 <Reveal className="flex flex-col items-start justify-between gap-6 sm:flex-row sm:items-center">230 <div>231 <h2 className="text-balance text-2xl font-semibold tracking-tight">Questions about any of this?</h2>232 <p className="mt-2 max-w-xl text-[15px] leading-7 text-fg-muted">233 Write to{" "}234 <a href="mailto:contact@spboucher.ai" className="font-medium text-accent underline-offset-4 hover:underline">235 contact@spboucher.ai236 </a>237 . Responsible disclosure is welcome; please give us a reasonable window before publishing.238 </p>239 </div>240 <div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row">241 <Button asChild size="lg" className="w-full sm:w-auto">242 <Link href="/signup">243 Start using PolyLLM <ArrowRight />244 </Link>245 </Button>246 <Button asChild size="lg" variant="outline" className="w-full sm:w-auto">247 <Link href="/privacy">Privacy policy</Link>248 </Button>249 </div>250 </Reveal>251 </Section>252 </>253 );254}255