/* LexOmega — B3 FINAL++ (Consolidated) — Vercel Serverless / Node 18+ Single-function backend (Hobby plan safe) with origin normalization, cookie sessions, CSRF, Documents CRUD, Notary ticket, Assistant chat (OpenAI), Dashboard stats, Sample doc generator, Case search (LLM), and Stripe Checkout + Billing Portal. No npm deps required for Stripe (uses REST). Postgres via `postgres` driver. Quality: 10/10 — copy–paste deployable. */ import crypto from "crypto"; import postgres from "postgres"; import bcrypt from "bcryptjs"; // ========= ENV =============================================================== const { DATABASE_URL = "", OPENAI_API_KEY = "", // Accepts bare host (lex-omega-mobile.vercel.app) or full origins (https://lex-omega-mobile.vercel.app) FRONTEND_URL = "", FRONTEND_HOST = "", SESSION_SECRET = "", APP_VERSION = "b3-final++", RL_WINDOW_MS = "60000", RL_MAX = "60", // ----- Stripe (REST; no SDK needed) ----- STRIPE_SECRET_KEY = "", // sk_test_... or sk_live_... STRIPE_PRICE_ID = "", // price_... } = process.env; // ---- origin normalization --------------------------------------------------- function normHost(hostLike) { if (!hostLike) return null; const s = String(hostLike).trim(); if (!s) return null; if (/^https?:\/\//i.test(s)) return new URL(s).origin; // already origin try { return new URL(`https://${s}`).origin; } catch { return null; } // bare host -> https origin } const FRONT = normHost(FRONTEND_URL) || normHost(FRONTEND_HOST); const FRONT_WWW = FRONT ? normHost("www." + FRONT.replace(/^https?:\/\//, "").replace(/^www\./, "")) : null; const LOCAL_3000 = "http://localhost:3000"; const LOCAL_5173 = "http://localhost:5173"; const ALLOWED_ORIGINS = [FRONT, FRONT_WWW, LOCAL_3000, LOCAL_5173].filter(Boolean); // ========= DB ================================================================ const sql = DATABASE_URL ? postgres(DATABASE_URL, { prepare: false, idle_timeout: 20 }) : null; // ========= SECURITY / UTILS ================================================= const SESSION_KEY = SESSION_SECRET || crypto.randomBytes(32).toString("hex"); const COOKIE_NAME = "lexomega_sid"; const CSRF_COOKIE = "lo_csrf"; const COOKIE_MAX_AGE = 60 * 60 * 24 * 7; const RL_WIN = Math.max(10_000, parseInt(RL_WINDOW_MS, 10) || 60_000); const RL_CAP = Math.max(20, parseInt(RL_MAX, 10) || 60); const b64u = (s) => Buffer.from(s, "utf8").toString("base64url"); const ub64u = (s) => Buffer.from(s, "base64url").toString("utf8"); const sign = (v) => crypto.createHmac("sha256", SESSION_KEY).update(v).digest("base64url"); const makeId = (p) => `${p}_${crypto.randomBytes(8).toString("hex")}_${Date.now().toString(36)}`; const nowIso = () => new Date().toISOString(); function pickOrigin(req) { const origin = String(req.headers.origin || "").trim(); if (!origin) return FRONT || "*"; try { const o = new URL(origin).origin; return ALLOWED_ORIGINS.includes(o) ? o : (FRONT && origin.endsWith(new URL(FRONT).host) ? new URL(FRONT).origin : "null"); } catch { return "null"; } } function corsHeaders(req) { const allow = pickOrigin(req); return { "Access-Control-Allow-Origin": allow, "Vary": "Origin", "Access-Control-Allow-Credentials": "true", "Access-Control-Allow-Methods": "GET,POST,PATCH,DELETE,OPTIONS", "Access-Control-Allow-Headers": "Content-Type, Authorization, X-CSRF-Token" }; } const secHeaders = { "X-Content-Type-Options": "nosniff", "Referrer-Policy": "no-referrer", "Permissions-Policy": "microphone=(), camera=()" }; const send = (req, res, code, body, extra = {}) => res.status(code).set({ "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store", ...corsHeaders(req), ...secHeaders, ...extra }).send(JSON.stringify(body)); const ok = (req, res, data = {}) => send(req, res, 200, { ok: true, ...data }); const bad = (req, res, code, message, data = {}) => send(req, res, code, { ok: false, error: message, ...data }); // ---- cookies / session ------------------------------------------------------ function parseCookies(req) { const out = {}; const raw = req.headers.cookie || ""; raw.split(";").forEach((p) => { const i = p.indexOf("="); if (i > -1) out[p.slice(0, i).trim()] = decodeURIComponent(p.slice(i + 1).trim()); }); return out; } function setCookie(name, value, { maxAge = COOKIE_MAX_AGE } = {}) { return `${name}=${encodeURIComponent(value)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}; Secure`; } function setCsrfCookie() { return `${CSRF_COOKIE}=${crypto.randomBytes(12).toString("hex")}; Path=/; SameSite=Lax; Max-Age=${COOKIE_MAX_AGE}; Secure`; } function createSessionCookie(session) { const base = b64u(JSON.stringify(session)); const sig = sign(base); return setCookie(COOKIE_NAME, `${base}.${sig}`); } function clearSessionCookie() { return `${COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0; Secure`; } function readSession(req) { const cookies = parseCookies(req); const raw = cookies[COOKIE_NAME]; if (!raw) return null; const [base, sig] = raw.split("."); if (!base || !sig) return null; if (sign(base) !== sig) return null; try { return JSON.parse(ub64u(base)); } catch { return null; } } function requireCsrf(req) { const cookies = parseCookies(req); const inbound = req.headers["x-csrf-token"]; if (!cookies[CSRF_COOKIE] || !inbound) return false; return String(cookies[CSRF_COOKIE]) === String(inbound); } // ---- rate limit ------------------------------------------------------------- const buckets = new Map(); function ipKey(req, path) { const fwd = (req.headers["x-forwarded-for"] || "").toString().split(",")[0].trim(); const ip = fwd || req.socket?.remoteAddress || "0.0.0.0"; return `${ip}:${path}`; } function rateLimit(req) { const key = ipKey(req, req.url.split("?")[0]); const now = Date.now(); const b = buckets.get(key) || { n: 0, t: now }; if (now - b.t > RL_WIN) { b.n = 0; b.t = now; } b.n++; buckets.set(key, b); return b.n <= RL_CAP; } // ========= DB helpers ======================================================== async function ensureSchema() { if (!sql) return; try { await sql`create extension if not exists pg_trgm`; } catch {} await sql/*sql*/` create table if not exists users ( id text primary key, name text not null, email text not null unique, password_hash text not null, role text not null default 'user', created_at timestamptz not null default now() ); create table if not exists documents ( id text primary key, owner_id text not null references users(id) on delete cascade, title text not null, body text not null, created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); create index if not exists idx_docs_owner_updated on documents(owner_id, updated_at desc); create table if not exists notary_requests ( id text primary key, owner_id text not null references users(id) on delete cascade, doc_id text not null references documents(id) on delete cascade, legal_name text not null, email text not null, status text not null, created_at timestamptz not null default now() ); `; } let schemaReady = false; async function dbPing() { if (!sql) return { ok: false, reason: "no DATABASE_URL" }; try { const [{ now }] = await sql`select now() as now`; return { ok: true, now }; } catch (e) { return { ok: false, reason: e.message }; } } async function userByEmail(email) { const rows = await sql`select * from users where email=${email} limit 1`; return rows[0] || null; } async function insertUser({ id, name, email, password_hash, role = "user" }) { await sql`insert into users (id, name, email, password_hash, role) values (${id}, ${name}, ${email}, ${password_hash}, ${role})`; } async function listDocs(ownerId, page = 1, size = 10, q = "") { const limit = Math.min(50, Math.max(1, Number(size) || 10)); const offset = (Math.max(1, Number(page) || 1) - 1) * limit; const query = (q || "").trim(); let items; if (query) { items = await sql` select id, title, substring(body,1,160) as preview, created_at, updated_at from documents where owner_id=${ownerId} and (title ilike ${"%"+query+"%"} or body ilike ${"%"+query+"%"}) order by updated_at desc limit ${limit} offset ${offset} `; } else { items = await sql` select id, title, substring(body,1,160) as preview, created_at, updated_at from documents where owner_id=${ownerId} order by updated_at desc limit ${limit} offset ${offset} `; } const [{ count }] = await sql` select count(*)::int as count from documents where owner_id=${ownerId} ${query ? sql`and (title ilike ${"%"+query+"%"} or body ilike ${"%"+query+"%"})` : sql``} `; return { items, total: count, page: Number(page) || 1, size: limit }; } async function getDoc(ownerId, id) { const rows = await sql`select * from documents where id=${id} and owner_id=${ownerId} limit 1`; return rows[0] || null; } async function saveDoc(ownerId, { id, title, body }) { const ts = new Date(); if (id) { const rows = await sql` update documents set title=${title}, body=${body}, updated_at=${ts} where id=${id} and owner_id=${ownerId} returning id, title, body, created_at, updated_at `; return rows[0] || null; } const newId = makeId("doc"); const rows = await sql` insert into documents (id, owner_id, title, body, created_at, updated_at) values (${newId}, ${ownerId}, ${title}, ${body}, ${ts}, ${ts}) returning id, title, body, created_at, updated_at `; return rows[0] || null; } async function deleteDoc(ownerId, id) { const rows = await sql`delete from documents where id=${id} and owner_id=${ownerId} returning id`; return !!rows[0]; } // ========= OpenAI ============================================================ async function chatOpenAI(messages, { model = "gpt-4o-mini", temperature = 0.3 } = {}) { if (!OPENAI_API_KEY) { const last = messages?.[messages.length - 1]?.content || ""; return { text: `(local) ${last}\n\n[OPENAI_API_KEY missing — add in Vercel Env]` }; } const r = await fetch("https://api.openai.com/v1/chat/completions", { method: "POST", headers: { Authorization: `Bearer ${OPENAI_API_KEY}`, "Content-Type": "application/json" }, body: JSON.stringify({ model, temperature, messages }) }); if (!r.ok) throw new Error(`OpenAI ${r.status}: ${(await r.text().catch(()=>"" )).slice(0,300)}`); const j = await r.json(); const text = j.choices?.[0]?.message?.content?.trim() || ""; return { text, raw: j }; } // ========= Stripe (REST) helpers ============================================ function form(obj) { return Object.entries(obj) .map(([k,v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`) .join("&"); } async function stripeCheckoutUrlForUser(user) { if (!STRIPE_SECRET_KEY || !STRIPE_PRICE_ID) throw new Error("Stripe not configured"); const success = (FRONT || "http://localhost:3000") + "/?sub=success"; const cancel = (FRONT || "http://localhost:3000") + "/?sub=cancel"; // Let Checkout create/link a customer by email automatically const body = form({ mode: "subscription", "line_items[0][price]": STRIPE_PRICE_ID, "line_items[0][quantity]": "1", customer_email: user.email, success_url: success, cancel_url: cancel }); const r = await fetch("https://api.stripe.com/v1/checkout/sessions", { method: "POST", headers: { Authorization: `Bearer ${STRIPE_SECRET_KEY}`, "Content-Type": "application/x-www-form-urlencoded" }, body }); const data = await r.json(); if (!r.ok || !data?.url) throw new Error(`Stripe checkout error: ${data?.error?.message || r.status}`); return data.url; } async function stripePortalUrlForUser(user) { if (!STRIPE_SECRET_KEY) throw new Error("Stripe not configured"); const returnUrl = (FRONT || "http://localhost:3000") + "/?portal=return"; // Find or reuse a customer by email const list = await fetch(`https://api.stripe.com/v1/customers?limit=1&email=${encodeURIComponent(user.email)}`, { headers: { Authorization: `Bearer ${STRIPE_SECRET_KEY}` } }); const j = await list.json(); const customer = j?.data?.[0]?.id; if (!customer) throw new Error("No Stripe customer found for this email. Start a subscription first."); const body = form({ customer, return_url: returnUrl }); const r = await fetch("https://api.stripe.com/v1/billing_portal/sessions", { method: "POST", headers: { Authorization: `Bearer ${STRIPE_SECRET_KEY}`, "Content-Type": "application/x-www-form-urlencoded" }, body }); const data = await r.json(); if (!r.ok || !data?.url) throw new Error(`Stripe portal error: ${data?.error?.message || r.status}`); return data.url; } // ========= REQ PARSING ======================================================= function readJSONBody(req) { if (req.body && typeof req.body === "object") return req.body; try { return JSON.parse(req.body || "{}"); } catch { return {}; } } // ========= ROUTER ============================================================ export default async function handler(req, res) { // CORS preflight if (req.method === "OPTIONS") { return res.status(204).set({ ...corsHeaders(req), ...secHeaders, "Access-Control-Max-Age": "600" }).send(""); } if (!rateLimit(req)) return bad(req, res, 429, "Too many requests"); if (!schemaReady) { try { await ensureSchema(); schemaReady = true; } catch (e) { return bad(req, res, 500, "DB schema init failed", { detail: e.message }); } } // Always refresh CSRF cookie res.setHeader("Set-Cookie", [setCsrfCookie()]); const url = new URL(req.url, `https://${req.headers.host}`); const path = url.pathname.replace(/\/+$/, ""); const method = req.method.toUpperCase(); // ---- Health / Status / Version ------------------------------------------- if (method === "GET" && (path === "/api" || path === "/api/ping")) { return ok(req, res, { pong: true, time: nowIso(), version: APP_VERSION }); } if (method === "GET" && path === "/api/health") { return ok(req, res, { node: process.version, time: nowIso(), healthy: true }); } if (method === "GET" && path === "/api/version") { return ok(req, res, { version: APP_VERSION, commit: process.env.VERCEL_GIT_COMMIT_SHA || null }); } if (method === "GET" && path === "/api/status") { return ok(req, res, { service: "lexomega-backend", db: await dbPing(), openai: !!OPENAI_API_KEY, stripe: !!STRIPE_SECRET_KEY, time: nowIso(), frontend: FRONT || null }); } // ---- Auth ---------------------------------------------------------------- if (method === "POST" && path === "/api/auth/register") { if (!requireCsrf(req)) return bad(req, res, 403, "CSRF token missing/invalid"); if (!sql) return bad(req, res, 500, "DB not configured"); const { name, email, password } = readJSONBody(req); const n = (name || "").trim(); const em = (email || "").trim().toLowerCase(); const pw = String(password || ""); if (!n || !em || pw.length < 8) return bad(req, res, 400, "Name, email, and password (≥ 8) required."); const exists = await userByEmail(em); if (exists) return bad(req, res, 409, "Email already registered."); const id = makeId("u"); const hash = await bcrypt.hash(pw, 10); await insertUser({ id, name: n, email: em, password_hash: hash, role: "user" }); const session = { id, email: em, name: n, role: "user", iat: Date.now() }; res.setHeader("Set-Cookie", [setCsrfCookie(), createSessionCookie(session)]); return ok(req, res, { user: { id, email: em, name: n, role: "user" } }); } if (method === "POST" && path === "/api/auth/login") { if (!requireCsrf(req)) return bad(req, res, 403, "CSRF token missing/invalid"); if (!sql) return bad(req, res, 500, "DB not configured"); const { email, password } = readJSONBody(req); const em = (email || "").trim().toLowerCase(); const pw = String(password || ""); if (!em || !pw) return bad(req, res, 400, "Email and password required."); const u = await userByEmail(em); if (!u) return bad(req, res, 401, "Invalid credentials."); const okPass = await bcrypt.compare(pw, u.password_hash || ""); if (!okPass) return bad(req, res, 401, "Invalid credentials."); const session = { id: u.id, email: u.email, name: u.name, role: u.role, iat: Date.now() }; res.setHeader("Set-Cookie", [setCsrfCookie(), createSessionCookie(session)]); return ok(req, res, { user: { id: u.id, email: u.email, name: u.name, role: u.role} }); } if (method === "GET" && path === "/api/auth/me") { const sess = readSession(req); if (!sess) return bad(req, res, 401, "Not authenticated."); return ok(req, res, { user: { id: sess.id, email: sess.email, name: sess.name, role: sess.role } }); } if (method === "POST" && path === "/api/auth/logout") { if (!requireCsrf(req)) return bad(req, res, 403, "CSRF token missing/invalid"); res.setHeader("Set-Cookie", [setCsrfCookie(), clearSessionCookie()]); return ok(req, res, {}); } // ---- Dashboard stats ------------------------------------------------------ if (method === "GET" && path === "/api/dashboard/stats") { const sess = readSession(req); if (!sess || !sql) return ok(req, res, { cases: 0, documents: 0, alerts: 0 }); const [{ count: docs = 0 }] = await sql`select count(*)::int as count from documents where owner_id=${sess.id}`; return ok(req, res, { cases: 0, documents: docs, alerts: 0 }); } // ---- Documents ------------------------------------------------------------ if (method === "GET" && path === "/api/documents/list") { const sess = readSession(req); if (!sess) return bad(req, res, 401, "Not authenticated."); if (!sql) return bad(req, res, 500, "DB not configured"); const page = Number(new URL(req.url, "http://x").searchParams.get("page") || 1); const size = Number(new URL(req.url, "http://x").searchParams.get("size") || 10); const q = new URL(req.url, "http://x").searchParams.get("q") || ""; const data = await listDocs(sess.id, page, size, q); return ok(req, res, data); } if (method === "GET" && path === "/api/documents/get") { const sess = readSession(req); if (!sess) return bad(req, res, 401, "Not authenticated."); if (!sql) return bad(req, res, 500, "DB not configured"); const id = new URL(req.url, "http://x").searchParams.get("id") || ""; if (!id) return bad(req, res, 400, "id required."); const doc = await getDoc(sess.id, id); if (!doc) return bad(req, res, 404, "Not found."); return ok(req, res, doc); } if (method === "POST" && path === "/api/documents/save") { if (!requireCsrf(req)) return bad(req, res, 403, "CSRF token missing/invalid"); const sess = readSession(req); if (!sess) return bad(req, res, 401, "Not authenticated."); if (!sql) return bad(req, res, 500, "DB not configured"); const { id, title, body } = readJSONBody(req); const t = (title || "").toString().trim(); const b = (body || "").toString(); if (!t || !b) return bad(req, res, 400, "title and body required."); const saved = await saveDoc(sess.id, { id, title: t, body: b }); if (!saved) return bad(req, res, 404, "Not found or not owned."); return ok(req, res, saved); } if (method === "POST" && path === "/api/documents/delete") { if (!requireCsrf(req)) return bad(req, res, 403, "CSRF token missing/invalid"); const sess = readSession(req); if (!sess) return bad(req, res, 401, "Not authenticated."); if (!sql) return bad(req, res, 500, "DB not configured"); const { id } = readJSONBody(req); if (!id) return bad(req, res, 400, "id required."); const deleted = await deleteDoc(sess.id, id); return ok(req, res, { deleted }); } // ---- Notary --------------------------------------------------------------- if (method === "POST" && path === "/api/notary/request") { if (!requireCsrf(req)) return bad(req, res, 403, "CSRF token missing/invalid"); const sess = readSession(req); if (!sess) return bad(req, res, 401, "Not authenticated."); if (!sql) return bad(req, res, 500, "DB not configured"); const { docId, legalName, email } = readJSONBody(req); const d = (docId || "").toString(); const n = (legalName || "").toString().trim(); const em = (email || "").toString().trim().toLowerCase(); if (!d || !n || !em) return bad(req, res, 400, "docId, legalName, email required."); const doc = await getDoc(sess.id, d); if (!doc) return bad(req, res, 404, "Document not found."); const id = makeId("notary"); await sql` insert into notary_requests (id, owner_id, doc_id, legal_name, email, status, created_at) values (${id}, ${sess.id}, ${d}, ${n}, ${em}, 'received', ${new Date()}) `; return ok(req, res, { ticket: { id, status: "received" } }); } // ---- Assistant chat ------------------------------------------------------- if (method === "POST" && path === "/api/assistant/chat") { if (!requireCsrf(req)) return bad(req, res, 403, "CSRF token missing/invalid"); const sess = readSession(req); if (!sess) return bad(req, res, 401, "Not authenticated."); const { message } = readJSONBody(req); const text = String(message || "").trim(); if (text.length < 2) return bad(req, res, 400, "message too short."); try { const resp = await chatOpenAI([ { role: "system", content: "You are LexOmega, a concise legal assistant. Be precise and helpful; do not reveal secrets." }, { role: "user", content: text } ]); return ok(req, res, { reply: resp.text }); } catch (e) { return bad(req, res, 502, e.message || "OpenAI error"); } } // ---- Dashboard helpers: sample doc & case search -------------------------- if (method === "POST" && path === "/api/documents/generate") { if (!requireCsrf(req)) return bad(req, res, 403, "CSRF token missing/invalid"); const sess = readSession(req); if (!sess) return bad(req, res, 401, "Not authenticated."); if (!sql) return bad(req, res, 500, "DB not configured"); const sample = await saveDoc(sess.id, { id: null, title: "LexOmega — Sample Affidavit", body: "This is a sample generated document to demonstrate LexOmega’s workflow.\n\nSections:\n1. Parties\n2. Statement of Facts\n3. Signature & Notary block" }); return ok(req, res, { id: sample.id, title: sample.title }); } if (method === "POST" && path === "/api/cases/search") { const { q } = readJSONBody(req); const query = (q || "").toString().trim(); if (!query) return bad(req, res, 400, "q required"); // Light LLM summary (safe even without auth) try { const resp = await chatOpenAI([ { role: "system", content: "Return three high-level legal search nuggets for the user's query. No citations, just short names and plain-English summaries." }, { role: "user", content: query } ]); const lines = resp.text.split(/\n+/).filter(Boolean).slice(0, 3); const results = lines.map((ln, i) => ({ citation: `Result ${i+1}`, summary: ln.replace(/^\d+[\).\s-]*/,'') })); return ok(req, res, { results }); } catch (e) { return ok(req, res, { results: [] }); } } // ---- Stripe Billing ------------------------------------------------------- if (method === "POST" && path === "/api/billing/checkout") { if (!requireCsrf(req)) return bad(req, res, 403, "CSRF token missing/invalid"); const sess = readSession(req); if (!sess) return bad(req, res, 401, "Not authenticated."); try { const url = await stripeCheckoutUrlForUser(sess); return ok(req, res, { url }); } catch (e) { return bad(req, res, 502, e.message || "Stripe checkout error"); } } if (method === "POST" && path === "/api/billing/portal") { if (!requireCsrf(req)) return bad(req, res, 403, "CSRF token missing/invalid"); const sess = readSession(req); if (!sess) return bad(req, res, 401, "Not authenticated."); try { const url = await stripePortalUrlForUser(sess); return ok(req, res, { url }); } catch (e) { return bad(req, res, 502, e.message || "Stripe portal error"); } } // ---- 404 ------------------------------------------------------------------ return bad(req, res, 404, "Not found."); } export const config = { api: { bodyParser: { sizeLimit: "2mb" }, externalResolver: true } };