PRIVEPAL AUDIT BUNDLE Generated at build time from commit: bb599755ddbbda9bc78911aafdf650d68f420336 Repository: https://github.com/yachty66/Privepal (web/ directory) Cross-check any file against the repo at that commit. The privacy claims to verify: 1. Chat history is stored only client-side in the browser 2. No tracking, no analytics scripts 3. CSP blocks all third-party network connections 4. Messages are relayed to confidential-compute inference without being logged or stored 5. Usage metrics are anonymous aggregate counters only ======================================================================== FILE: web/app/api/chat/route.ts ======================================================================== import { NextRequest } from "next/server"; import { metrics } from "@/lib/metrics"; // Streams chat completions from the Privatemode proxy (OpenAI-compatible). // The proxy handles attestation + encryption to the confidential-compute // backend. This route never stores or logs message content: it only // validates shape and forwards the stream. const PROXY_URL = process.env.PRIVATEMODE_PROXY_URL ?? "http://localhost:8080"; const ALLOWED_MODELS = new Set(["gpt-oss-120b", "kimi-k2.6"]); // Identity, added server-side (clients can only send user/assistant roles). // Deliberately honest: Privepal identity, no lying about the underlying model. const SYSTEM_PROMPT = `You are Privepal, a private AI chat assistant (privepal.com). You run on open-source models served inside confidential-computing hardware, so conversations cannot be read by anyone, not even Privepal's operators, and chats are stored only on the user's device. If asked who or what you are, say you are Privepal. Do not introduce yourself as ChatGPT, OpenAI, Kimi, or Moonshot. If someone specifically asks which underlying model powers you, answer honestly: Privepal serves open-source models (currently gpt-oss-120b by OpenAI and Kimi K2.6 by Moonshot AI) inside sealed hardware. Be helpful, direct, and concise.`; // Abuse limits per IP: enough for heavy personal use, hostile to scripts. const WINDOW_MS = 60_000; const MAX_PER_WINDOW = 20; const DAY_MS = 86_400_000; const MAX_PER_DAY = 400; const MAX_MESSAGES = 80; const MAX_MESSAGE_CHARS = 8_000; const MAX_TOTAL_CHARS = 32_000; const hits = new Map(); function rateLimited(ip: string): boolean { const now = Date.now(); // occasional sweep so the map cannot grow unbounded if (hits.size > 5_000) { for (const [k, v] of hits) { if (v.length === 0 || now - v[v.length - 1] > DAY_MS) hits.delete(k); } } const recent = (hits.get(ip) ?? []).filter((t) => now - t < DAY_MS); const inWindow = recent.filter((t) => now - t < WINDOW_MS).length; if (inWindow >= MAX_PER_WINDOW || recent.length >= MAX_PER_DAY) { hits.set(ip, recent); return true; } recent.push(now); hits.set(ip, recent); return false; } function badRequest(msg: string, status = 400) { if (status === 400) metrics.invalidRequest(); return new Response(JSON.stringify({ error: msg }), { status, headers: { "Content-Type": "application/json" }, }); } export async function POST(req: NextRequest) { // browsers always send Origin on cross-site POSTs: reject foreign ones const origin = req.headers.get("origin"); const host = req.headers.get("host"); if (origin && host && new URL(origin).host !== host) { return badRequest("forbidden", 403); } const ip = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown"; if (rateLimited(ip)) { metrics.rateLimited(); return badRequest("rate limit exceeded, slow down", 429); } let body: { messages?: unknown; model?: unknown }; try { body = await req.json(); } catch { return badRequest("invalid json"); } const { messages, model } = body; if (typeof model !== "string" || !ALLOWED_MODELS.has(model)) { return badRequest("invalid model"); } if (!Array.isArray(messages) || messages.length === 0) { return badRequest("invalid messages"); } if (messages.length > MAX_MESSAGES) { return badRequest("conversation too long"); } let total = 0; for (const m of messages) { if ( typeof m !== "object" || m === null || !["user", "assistant"].includes((m as { role?: string }).role ?? "") || typeof (m as { content?: unknown }).content !== "string" ) { return badRequest("invalid message shape"); } const len = (m as { content: string }).content.length; if (len > MAX_MESSAGE_CHARS) return badRequest("message too long"); total += len; } if (total > MAX_TOTAL_CHARS) return badRequest("conversation too large"); metrics.chatRequest(model); const upstreamStart = Date.now(); const upstream = await fetch(`${PROXY_URL}/v1/chat/completions`, { method: "POST", headers: { "Content-Type": "application/json", // keyless proxy mode: it forwards this header to the Privatemode API ...(process.env.PRIVATEMODE_API_KEY ? { Authorization: `Bearer ${process.env.PRIVATEMODE_API_KEY}` } : {}), }, body: JSON.stringify({ model, messages: [ { role: "system", content: SYSTEM_PROMPT }, ...(messages as { role: string; content: string }[]).map( ({ role, content }) => ({ role, content }) ), ], stream: true, max_tokens: 4096, // keep answers snappy: minimize hidden reasoning where supported ...(model === "gpt-oss-120b" ? { reasoning_effort: "low" } : {}), }), signal: req.signal, }); metrics.upstreamHeaderMs(Date.now() - upstreamStart); if (!upstream.ok || !upstream.body) { metrics.upstreamError(); // never include upstream body: it can echo message content return new Response( JSON.stringify({ error: "upstream error", status: upstream.status }), { status: 502, headers: { "Content-Type": "application/json" } } ); } // Forward the SSE stream untouched: the client parses the chunks. return new Response(upstream.body, { headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-store, no-transform", Connection: "keep-alive", }, }); } ======================================================================== FILE: web/proxy.ts ======================================================================== import { NextRequest, NextResponse } from "next/server"; // Per-request nonce CSP: no inline script can run unless it carries this // request's nonce, which an injected payload cannot know. This removes the // last XSS execution vector the previous 'unsafe-inline' policy allowed. export function proxy(request: NextRequest) { const nonce = Buffer.from(crypto.randomUUID()).toString("base64"); const isDev = process.env.NODE_ENV === "development"; const csp = [ "default-src 'self'", `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'${isDev ? " 'unsafe-eval'" : ""}`, // style attributes need unsafe-inline; scripts (the real threat) do not "style-src 'self' 'unsafe-inline'", "img-src 'self' data:", "font-src 'self'", "connect-src 'self'", "object-src 'none'", "base-uri 'self'", "form-action 'self'", "frame-ancestors 'none'", // http localhost assets would get force-upgraded to https and fail in dev ...(isDev ? [] : ["upgrade-insecure-requests"]), ].join("; "); const requestHeaders = new Headers(request.headers); requestHeaders.set("x-nonce", nonce); requestHeaders.set("Content-Security-Policy", csp); const response = NextResponse.next({ request: { headers: requestHeaders } }); response.headers.set("Content-Security-Policy", csp); return response; } export const config = { matcher: [ // all pages, skip static assets { source: "/((?!_next/static|_next/image|favicon.ico|icon.png|logo.png).*)", missing: [ { type: "header", key: "next-router-prefetch" }, { type: "header", key: "purpose", value: "prefetch" }, ], }, ], }; ======================================================================== FILE: web/lib/store.ts ======================================================================== // Client-side chat storage. Chats never leave the device: localStorage only. export type Role = "user" | "assistant"; export interface Message { role: Role; content: string; ttftMs?: number; } export interface Chat { id: string; // random human-readable name used in the local /c/ link; // optional because chats saved by older versions lack it slug?: string; title: string; model: string; messages: Message[]; createdAt: number; updatedAt: number; } const SLUG_ADJECTIVES = [ "amber", "brisk", "calm", "dusky", "eager", "fuzzy", "gentle", "hazel", "ivory", "jolly", "keen", "lucid", "mellow", "noble", "opal", "plum", "quiet", "rosy", "sable", "tidy", "umber", "vivid", "wry", "zesty", ]; const SLUG_NOUNS = [ "otter", "falcon", "birch", "comet", "dune", "ember", "fjord", "grove", "harbor", "iris", "jasper", "kite", "lagoon", "meadow", "nimbus", "orchid", "pebble", "quill", "reef", "sparrow", "thicket", "umbra", "willow", "zephyr", ]; export function newSlug(): string { const pick = (list: string[]) => list[Math.floor(Math.random() * list.length)]; const suffix = Math.random().toString(36).slice(2, 6); return `${pick(SLUG_ADJECTIVES)}-${pick(SLUG_NOUNS)}-${suffix}`; } const KEY = "privepal.chats"; export function loadChats(): Chat[] { if (typeof window === "undefined") return []; try { const raw = localStorage.getItem(KEY); return raw ? (JSON.parse(raw) as Chat[]) : []; } catch { return []; } } export function saveChats(chats: Chat[]) { try { localStorage.setItem(KEY, JSON.stringify(chats)); } catch { // storage full or unavailable: drop oldest chats and retry once try { localStorage.setItem(KEY, JSON.stringify(chats.slice(0, 20))); } catch { /* give up silently */ } } } export function newChat(model: string): Chat { const now = Date.now(); return { id: crypto.randomUUID(), slug: newSlug(), title: "New chat", model, messages: [], createdAt: now, updatedAt: now, }; } ======================================================================== FILE: web/lib/metrics.ts ======================================================================== // Aggregate, anonymous usage counters. Numbers only: no IPs, no cookies, // no identifiers, nothing per-user. This keeps the shield-page claims true // while still telling us whether anyone is using the product. // In-memory: resets on each deploy, which is fine for launch-phase insight. interface Metrics { startedAt: number; pageLoads: number; shieldChecks: number; chatRequests: number; chatByModel: Record; rateLimited: number; invalidRequests: number; upstreamErrors: number; upstreamHeaderMsTotal: number; upstreamHeaderMsCount: number; } // globalThis so route handlers and server components share one instance const g = globalThis as unknown as { __privepalMetrics?: Metrics }; function m(): Metrics { if (!g.__privepalMetrics) { g.__privepalMetrics = { startedAt: Date.now(), pageLoads: 0, shieldChecks: 0, chatRequests: 0, chatByModel: {}, rateLimited: 0, invalidRequests: 0, upstreamErrors: 0, upstreamHeaderMsTotal: 0, upstreamHeaderMsCount: 0, }; } return g.__privepalMetrics; } export const metrics = { pageLoad: () => void m().pageLoads++, shieldCheck: () => void m().shieldChecks++, chatRequest: (model: string) => { const s = m(); s.chatRequests++; s.chatByModel[model] = (s.chatByModel[model] ?? 0) + 1; }, rateLimited: () => void m().rateLimited++, invalidRequest: () => void m().invalidRequests++, upstreamError: () => void m().upstreamErrors++, upstreamHeaderMs: (ms: number) => { const s = m(); s.upstreamHeaderMsTotal += ms; s.upstreamHeaderMsCount++; }, snapshot: () => { const s = m(); return { ...s, uptimeHours: Math.round(((Date.now() - s.startedAt) / 3_600_000) * 10) / 10, avgUpstreamHeaderMs: s.upstreamHeaderMsCount ? Math.round(s.upstreamHeaderMsTotal / s.upstreamHeaderMsCount) : null, }; }, }; ======================================================================== FILE: web/app/api/shield/route.ts ======================================================================== // Live status for the shield screen. Checks that the attested channel to // confidential compute is up by querying the Privatemode proxy. The proxy // only completes requests after verifying enclave attestation, so a // successful round trip means the encrypted channel is live. const PROXY_URL = process.env.PRIVATEMODE_PROXY_URL ?? "http://localhost:8080"; import { metrics } from "@/lib/metrics"; export async function GET() { metrics.shieldCheck(); let proxyOk = false; let models: string[] = []; try { const res = await fetch(`${PROXY_URL}/v1/models`, { signal: AbortSignal.timeout(5000), cache: "no-store", headers: process.env.PRIVATEMODE_API_KEY ? { Authorization: `Bearer ${process.env.PRIVATEMODE_API_KEY}` } : undefined, }); if (res.ok) { proxyOk = true; const data = await res.json(); models = (data.data ?? []) .filter((m: { tasks?: string[] }) => m.tasks?.includes("generate")) .map((m: { id: string }) => m.id); } } catch { // proxy unreachable } return Response.json( { proxyOk, models, checkedAt: Date.now() }, { headers: { "Cache-Control": "no-store" } } ); } ======================================================================== FILE: web/app/api/version/route.ts ======================================================================== // Deployment provenance. When Railway builds this app directly from the // public GitHub repo, it injects the commit SHA at build time; exposing it // lets anyone tie the running deployment to exact public source code. const sha = process.env.RAILWAY_GIT_COMMIT_SHA ?? null; const branch = process.env.RAILWAY_GIT_BRANCH ?? null; export async function GET() { return Response.json( { commit: sha, branch, source: sha ? `https://github.com/yachty66/Privepal/tree/${sha}` : null, provenance: sha ? "built by Railway from the public GitHub repository" : "manual CLI deploy (provenance not yet verifiable)", }, { headers: { "Cache-Control": "no-store" } } ); } ======================================================================== FILE: web/app/page.tsx ======================================================================== "use client"; import { useCallback, useEffect, useRef, useState } from "react"; import Link from "next/link"; import { Chat, Message, loadChats, newChat, newSlug, saveChats, } from "@/lib/store"; import { streamChat } from "@/lib/sse"; import Markdown from "@/components/Markdown"; import { AUDIT_LINKS } from "@/lib/audit"; const MODELS = [ { id: "gpt-oss-120b", label: "Fast" }, { id: "kimi-k2.6", label: "Smart" }, ] as const; function CopyButton({ text, className = "", }: { text: string; className?: string; }) { const [copied, setCopied] = useState(false); return ( ); } const VERIFY_STEPS = [ "Connecting securely", "Verifying sealed hardware", "Locking encrypted channel", ]; export default function Home() { const [chats, setChats] = useState([]); const [activeId, setActiveId] = useState(null); const [input, setInput] = useState(""); const [model, setModel] = useState("gpt-oss-120b"); const [busy, setBusy] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(false); const [search, setSearch] = useState(""); const [desktopSidebar, setDesktopSidebar] = useState(true); const [thinking, setThinking] = useState(false); const [editing, setEditing] = useState<{ index: number; text: string } | null>( null ); const [channelOk, setChannelOk] = useState(null); const [verifyStep, setVerifyStep] = useState(0); const bottomRef = useRef(null); const abortRef = useRef(null); useEffect(() => { const raw = loadChats(); // drop empty chats left over from repeated "+ New" clicks, and give // chats saved by older versions a link slug let migrated = false; const loaded = raw .filter((c) => c.messages.length > 0) .map((c) => { if (c.slug) return c; migrated = true; return { ...c, slug: newSlug() }; }); if (migrated || loaded.length !== raw.length) saveChats(loaded); setChats(loaded); // /c/ opens that chat if it exists on this device const m = window.location.pathname.match(/^\/c\/([^/]+)$/); const fromUrl = m ? loaded.find((c) => c.slug === decodeURIComponent(m[1])) : undefined; if (fromUrl) setActiveId(fromUrl.id); else if (loaded.length > 0) setActiveId(loaded[0].id); // each step completes on a real network event, with a minimum display // time so the sequence stays readable on fast connections const minStep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); (async () => { // step 1: reach our server and learn which commit it runs const reach = fetch("/api/version").then((r) => r.ok); await Promise.all([reach.catch(() => false), minStep(700)]); setVerifyStep(1); // step 2: confirm the proxy's attested channel to the enclave is live const shield = fetch("/api/shield") .then((r) => r.json()) .then((s) => !!s.proxyOk) .catch(() => false); const [ok] = await Promise.all([shield, minStep(900)]); setVerifyStep(2); // step 3: lock in the result await minStep(700); setChannelOk(ok); setVerifyStep(3); })(); }, []); const active = chats.find((c) => c.id === activeId) ?? null; // mirror the active chat in the address bar as a local-only link useEffect(() => { const path = active?.slug ? `/c/${active.slug}` : "/"; if (window.location.pathname !== path) { window.history.replaceState(null, "", path); } }, [active?.slug]); // an edit index is only meaningful within the chat it was opened in useEffect(() => { setEditing(null); }, [activeId]); const persist = useCallback((next: Chat[]) => { setChats(next); saveChats(next); }, []); useEffect(() => { bottomRef.current?.scrollIntoView({ behavior: "smooth" }); }, [active?.messages.length, busy]); function createChat() { // reuse an existing empty chat instead of stacking up "New chat" entries const empty = chats.find((c) => c.messages.length === 0); if (empty) { setActiveId(empty.id); } else { const c = newChat(model); persist([c, ...chats]); setActiveId(c.id); } setSidebarOpen(false); } function deleteChat(id: string) { const chat = chats.find((c) => c.id === id); if ( !confirm( `Delete "${chat?.title ?? "this chat"}"? This cannot be undone.` ) ) return; const next = chats.filter((c) => c.id !== id); persist(next); if (activeId === id) setActiveId(next[0]?.id ?? null); } const ready = verifyStep === 3 && channelOk === true; async function send() { const text = input.trim(); if (!text || busy || !ready) return; let chat = active; let base = chats; if (!chat) { chat = newChat(model); base = [chat, ...chats]; setActiveId(chat.id); } setInput(""); await stream(chat, base, chat.messages, text); } // edit an earlier user message and continue the chat from there: // everything after the edited message is discarded async function resend(index: number, text: string) { const t = text.trim(); if (!t || busy || !ready || !active) return; setEditing(null); await stream(active, chats, active.messages.slice(0, index), t); } async function stream( chat: Chat, base: Chat[], keptMessages: Message[], text: string ) { const userMsg: Message = { role: "user", content: text }; const title = keptMessages.length === 0 ? text.slice(0, 40) + (text.length > 40 ? "..." : "") : chat.title; let working: Chat = { ...chat, title, model, messages: [...keptMessages, userMsg], updatedAt: Date.now(), }; const updateWorking = (w: Chat) => { working = w; persist(base.map((c) => (c.id === w.id ? w : c))); }; updateWorking(working); setBusy(true); setThinking(false); const controller = new AbortController(); abortRef.current = controller; const t0 = performance.now(); let ttftMs: number | undefined; let acc = ""; try { const res = await fetch("/api/chat", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ model, messages: working.messages.map(({ role, content }) => ({ role, content, })), }), signal: controller.signal, }); if (!res.ok) throw new Error(`HTTP ${res.status}`); updateWorking({ ...working, messages: [...working.messages, { role: "assistant", content: "" }], }); for await (const delta of streamChat(res)) { if (delta.reasoning && !acc) setThinking(true); if (delta.content) { if (ttftMs === undefined) { ttftMs = Math.round(performance.now() - t0); setThinking(false); } acc += delta.content; const msgs = [...working.messages]; msgs[msgs.length - 1] = { role: "assistant", content: acc, ttftMs }; updateWorking({ ...working, messages: msgs, updatedAt: Date.now() }); } } } catch (err) { if ((err as Error).name !== "AbortError") { const msgs = [...working.messages]; const last = msgs[msgs.length - 1]; const errText = "Something went wrong. Is the Privatemode proxy running?"; if (last?.role === "assistant" && !last.content) { msgs[msgs.length - 1] = { role: "assistant", content: errText }; } else { msgs.push({ role: "assistant", content: errText }); } updateWorking({ ...working, messages: msgs }); } } finally { setBusy(false); setThinking(false); abortRef.current = null; } } function stop() { abortRef.current?.abort(); } // client-side search across titles and full message content; nothing // ever leaves the device const query = search.trim().toLowerCase(); const visibleChats = query ? chats.filter( (c) => c.title.toLowerCase().includes(query) || c.messages.some((m) => m.content.toLowerCase().includes(query)) ) : chats; function snippet(c: Chat): string | null { if (!query) return null; const m = c.messages.find((m) => m.content.toLowerCase().includes(query) ); if (!m) return null; const i = m.content.toLowerCase().indexOf(query); const start = Math.max(0, i - 24); return ( (start > 0 ? "..." : "") + m.content.slice(start, i + query.length + 40) + (i + query.length + 40 < m.content.length ? "..." : "") ); } const sidebarContent = ( <>
{/* eslint-disable-next-line @next/next/no-img-element */} Privepal beta
setSearch(e.target.value)} placeholder="Search chats" className="w-full rounded-md border border-neutral-800 bg-neutral-950 px-3 py-1.5 text-sm outline-none placeholder:text-neutral-600 focus:border-neutral-600" />

Private by design. Chats are stored only on this device. Inference runs in confidential compute, unreadable even to the operator.

Security Privacy

); return (
{/* Sidebar (desktop) */} {desktopSidebar && ( )} {/* Sidebar (mobile drawer) */} {sidebarOpen && (
setSidebarOpen(false)} />
)} {/* Main */}
{MODELS.map((m) => ( ))}
iOS soon confidential
{!active || active.messages.length === 0 ? (
{/* eslint-disable-next-line @next/next/no-img-element */} Privepal

Fast. Private. Yours.

Ask anything. Nobody can read it, not even us.

{/* track */} {/* progress */} {verifyStep === 3 && channelOk && ( )} {verifyStep === 3 && !channelOk && ( × )}
{verifyStep < 3 ? (
Verifying private channel
{VERIFY_STEPS[Math.min(verifyStep, 2)]}...
) : null}
{verifyStep === 3 && (
{channelOk ? "Encrypted channel to sealed AI hardware: live" : "Encrypted channel down. Chat is disabled, no unencrypted fallback."}
Chats stored only on this device
No account, no tracking
See what is proven vs. what you take on trust
)} {verifyStep === 3 && (
Open source. Don't trust us? Audit the code with{" "} {AUDIT_LINKS.map((l, i) => ( {l.name} {i < AUDIT_LINKS.length - 1 ? " · " : ""} ))}
)}
) : ( active.messages.map((m, i) => (
{m.role === "user" && editing?.index === i ? (