/* ============================================================ Shared server library. Runs on Cloudflare Pages Functions (V8 isolate, WebCrypto available). No external/AI APIs. Security-critical helpers live here. ============================================================ */ /* ---- server-side source of truth for pricing (never trust client) ---- */ /* prices in CENTS. Keep in sync with assets/js/data.js display values. */ export const PRODUCTS = { "stack-2k": { name: "$20 Stack — 2K Movie Roll", price: 5000, face: 200000 }, "stack-4k": { name: "$20 Stack — 4K Bundle", price: 10000, face: 400000 }, "stack-8k": { name: "$20 Stack — 8K Case", price: 20000, face: 800000 }, "stack-10k": { name: "$20 Stack — 10K Duffel", price: 30000, face: 1000000 }, "stack-16k": { name: "$20 Stack — 16K Pallet Pack", price: 40000, face: 1600000 }, "stack-20k": { name: "$20 Stack — 20K Vault Crate", price: 50000, face: 2000000 } }; export const SHIPPING = { "usps-standard": { label: "USPS 2–7 Day", price: 1200 }, "usps-2day": { label: "USPS 2-Day", price: 4000 }, "usps-overnight": { label: "USPS Overnight", price: 6500 } }; /* ---- security headers applied to every response ---- */ export function securityHeaders() { return { "Content-Security-Policy": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; " + "img-src 'self' data:; font-src 'self'; connect-src 'self'; " + "frame-ancestors 'none'; base-uri 'none'; form-action 'self'; object-src 'none'", "X-Frame-Options": "DENY", "X-Content-Type-Options": "nosniff", "Referrer-Policy": "strict-origin-when-cross-origin", "Permissions-Policy": "geolocation=(), microphone=(), camera=(), payment=(), usb=()", "Strict-Transport-Security": "max-age=31536000; includeSubDomains", "Cross-Origin-Opener-Policy": "same-origin", "X-Robots-Tag": "noindex" // overridden per-page for public pages via _headers }; } export function json(data, status = 200, extraHeaders = {}) { return new Response(JSON.stringify(data), { status, headers: Object.assign( { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" }, extraHeaders ) }); } /* ---- request helpers ---- */ export function clientIP(request) { return request.headers.get("CF-Connecting-IP") || request.headers.get("X-Forwarded-For") || "0.0.0.0"; } export function country(request) { return (request.cf && request.cf.country) || request.headers.get("CF-IPCountry") || ""; } /* ---- input hygiene ---- */ export function str(v, max = 200) { if (v == null) return ""; return String(v).replace(/[\u0000-\u001F\u007F]/g, "").trim().slice(0, max); } export function isEmail(v) { return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(v); } export function clampInt(v, lo, hi) { v = parseInt(v, 10); if (isNaN(v)) v = lo; return Math.max(lo, Math.min(hi, v)); } /* ---- crypto ---- */ const enc = new TextEncoder(); export async function sha256Hex(text) { const buf = await crypto.subtle.digest("SHA-256", enc.encode(text)); return [...new Uint8Array(buf)].map(b => b.toString(16).padStart(2, "0")).join(""); } export function timingSafeEqual(a, b) { if (typeof a !== "string" || typeof b !== "string" || a.length !== b.length) return false; let out = 0; for (let i = 0; i < a.length; i++) out |= a.charCodeAt(i) ^ b.charCodeAt(i); return out === 0; } /* base64url helpers */ function b64url(bytes) { let s = btoa(String.fromCharCode(...new Uint8Array(bytes))); return s.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); } function b64urlStr(text) { return b64url(enc.encode(text)); } async function hmac(secret, msg) { const key = await crypto.subtle.importKey("raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]); const sig = await crypto.subtle.sign("HMAC", key, enc.encode(msg)); return b64url(sig); } /* ---- signed sessions (HMAC-signed, delivered as HttpOnly cookies) ---- Payload carries the username (u) and role (r). The browser can NEVER read these (HttpOnly); it only replays the cookie, and the server re-derives the role from it. Admin and ops ride in SEPARATE cookies (bb_a1 / bb_a2) so one staff role's session is a different variable the other never touches. */ /* The signing secret MUST exist and be strong. If it is missing or weak, we refuse to issue OR accept any session — the whole auth system fails CLOSED instead of signing tokens with a guessable key an attacker could forge. */ export function sessionSecretOk(env) { const s = env && env.SESSION_SECRET; return typeof s === "string" && s.length >= 24; } export async function makeSession(env, user, role = "customer", ttlSec = 60 * 60 * 8) { if (!sessionSecretOk(env)) return null; // caller must treat null as "cannot sign in" const payload = JSON.stringify({ u: user, r: role, exp: Math.floor(Date.now() / 1000) + ttlSec }); const body = b64urlStr(payload); const sig = await hmac(env.SESSION_SECRET, body); return body + "." + sig; } export async function verifySession(env, token) { if (!sessionSecretOk(env)) return null; // no valid secret → no valid session if (!token || token.indexOf(".") === -1) return null; const [body, sig] = token.split("."); const expect = await hmac(env.SESSION_SECRET, body); if (!timingSafeEqual(sig, expect)) return null; let data; try { data = JSON.parse(atob(body.replace(/-/g, "+").replace(/_/g, "/"))); } catch (e) { return null; } if (!data || !data.exp || data.exp < Math.floor(Date.now() / 1000)) return null; return data; } /* Cookie names — intentionally distinct per role so they stay isolated. bb_a1 = admin (full dashboard), bb_a2 = ops (operations), bb_user = customer */ export const COOKIE_ADMIN = "bb_a1"; export const COOKIE_OPS = "bb_a2"; export const COOKIE_USER = "bb_user"; function cookieFor(name, token, ttlSec) { return `${name}=${token}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=${ttlSec}`; } function clearNamed(name) { return `${name}=; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=0`; } export function roleCookie(role, token, ttlSec = 60 * 60 * 8) { if (role === "admin") return cookieFor(COOKIE_ADMIN, token, ttlSec); if (role === "ops") return cookieFor(COOKIE_OPS, token, ttlSec); return cookieFor(COOKIE_USER, token, 60 * 60 * 24 * 30); } export function clearAllSessionCookies() { return [clearNamed(COOKIE_ADMIN), clearNamed(COOKIE_OPS), clearNamed(COOKIE_USER)]; } export function readCookie(request, name) { const c = request.headers.get("Cookie") || ""; const m = c.match(new RegExp("(?:^|;\\s*)" + name + "=([^;]+)")); return m ? m[1] : null; } /* CSRF defense: a cross-site
can't set this custom header. */ function csrfOk(request) { return request.headers.get("X-BB-Admin") === "1"; } /* Require a specific staff role. Reads ONLY that role's own cookie, so an admin-1 request cannot satisfy an ops check or vice versa. */ export async function requireRole(request, env, role) { if (!csrfOk(request)) return null; const name = role === "admin" ? COOKIE_ADMIN : role === "ops" ? COOKIE_OPS : COOKIE_USER; const s = await verifySession(env, readCookie(request, name)); if (!s || s.r !== role) return null; return s; } /* Require ANY staff role (admin OR ops) — used by shared dashboard/order endpoints both panels read. Still checks each role's own cookie separately. */ export async function requireStaff(request, env) { return (await requireRole(request, env, "admin")) || (await requireRole(request, env, "ops")); } /* ---- staff accounts come from ENCRYPTED env secrets, never from source ---- Admin 1 uses its OWN secrets (ADMIN1_USER / ADMIN1_PASS) — NOT the ENCRYPTION list. ENCRYPTION1..ENCRYPTION6 are ALL decoys (random junk). Admin 2's real credentials are buried after them at ENCRYPTION7/8, so the ENCRYPTION list is entirely noise except the last two: ADMIN1_USER = admin 1 username (default "admin1") ADMIN1_PASS = admin 1 password ENCRYPTION1..ENCRYPTION6 = decoys (random junk) ENCRYPTION7 = admin 2 username (default "owner") ENCRYPTION8 = admin 2 password The plaintext lives only in Cloudflare's encrypted store; it is hashed into D1 on first successful login and never written to the repo. */ export function staffRoster(env) { return [ { user: (env.ADMIN1_USER || "admin1").toLowerCase(), pass: env.ADMIN1_PASS || "", role: "admin" }, { user: (env.ENCRYPTION7 || "owner").toLowerCase(), pass: env.ENCRYPTION8 || "", role: "ops" } ]; } /* ---- per-ACCOUNT brute-force lockout (by username, any IP) ---- 6 wrong passwords within an hour locks THAT account for a FULL HOUR — so no one can brute-force a specific login, even from rotating IPs. Silent: the user just sees the generic "invalid username or password" the whole time. */ const ACCT_MAX_FAILS = 6; // lock on the 6th wrong attempt const ACCT_LOCK_SECS = 3600; // locked for 1 hour export async function accountLocked(env, username) { const now = Math.floor(Date.now() / 1000); const row = await env.DB.prepare("SELECT locked_until FROM account_attempts WHERE username = ?") .bind(String(username).toLowerCase()).first(); return !!(row && row.locked_until > now); } export async function recordLoginFailure(env, username) { const now = Math.floor(Date.now() / 1000); const u = String(username).toLowerCase(); const row = await env.DB.prepare("SELECT count, first_ts FROM account_attempts WHERE username = ?").bind(u).first(); if (!row || (now - row.first_ts) > ACCT_LOCK_SECS) { await env.DB.prepare("INSERT OR REPLACE INTO account_attempts (username, count, first_ts, locked_until) VALUES (?,1,?,0)").bind(u, now).run(); } else { const next = row.count + 1; const lockUntil = next >= ACCT_MAX_FAILS ? now + ACCT_LOCK_SECS : 0; await env.DB.prepare("UPDATE account_attempts SET count = ?, locked_until = ? WHERE username = ?").bind(next, lockUntil, u).run(); } } export async function clearLoginFailures(env, username) { await env.DB.prepare("DELETE FROM account_attempts WHERE username = ?").bind(String(username).toLowerCase()).run(); } /* ---- rate limiter (fixed window, backed by D1) ---- */ export async function rateLimit(env, key, limit, windowSec) { const now = Math.floor(Date.now() / 1000); const row = await env.DB.prepare("SELECT count, window_start FROM rate_limits WHERE k = ?").bind(key).first(); if (!row || (now - row.window_start) >= windowSec) { await env.DB.prepare("INSERT OR REPLACE INTO rate_limits (k, count, window_start) VALUES (?, 1, ?)").bind(key, now).run(); return { ok: true, remaining: limit - 1 }; } if (row.count >= limit) return { ok: false, remaining: 0, retry: windowSec - (now - row.window_start) }; await env.DB.prepare("UPDATE rate_limits SET count = count + 1 WHERE k = ?").bind(key).run(); return { ok: true, remaining: limit - row.count - 1 }; } /* ---- order id: BB-XXXXXX (unambiguous alphabet) ---- */ export function newOrderId() { const alpha = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"; // no 0/O/1/I/L const rnd = crypto.getRandomValues(new Uint8Array(6)); let s = ""; for (let i = 0; i < 6; i++) s += alpha[rnd[i] % alpha.length]; return "BB-" + s; } export function centsToStr(c) { return "$" + (c / 100).toLocaleString("en-US"); } /* ---- password hashing (PBKDF2 SHA-256, 120k iters) ---- */ export async function hashPassword(password) { const salt = crypto.getRandomValues(new Uint8Array(16)); const keyMat = await crypto.subtle.importKey("raw", enc.encode(password), "PBKDF2", false, ["deriveBits"]); const bits = await crypto.subtle.deriveBits({ name: "PBKDF2", salt, iterations: 120000, hash: "SHA-256" }, keyMat, 256); return "pbkdf2$120000$" + b64url(salt) + "$" + b64url(bits); } export async function verifyPassword(password, stored) { const parts = String(stored || "").split("$"); if (parts.length !== 4 || parts[0] !== "pbkdf2") return false; const iters = parseInt(parts[1], 10); const salt = Uint8Array.from(atob(parts[2].replace(/-/g, "+").replace(/_/g, "/")), c => c.charCodeAt(0)); const keyMat = await crypto.subtle.importKey("raw", enc.encode(password), "PBKDF2", false, ["deriveBits"]); const bits = await crypto.subtle.deriveBits({ name: "PBKDF2", salt, iterations: iters, hash: "SHA-256" }, keyMat, 256); return timingSafeEqual(b64url(bits), parts[3]); } /* customer session cookie (separate from admin) */ export function userCookie(token, ttlSec = 60 * 60 * 24 * 30) { return `bb_user=${token}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=${ttlSec}`; } export function clearUserCookie() { return "bb_user=; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=0"; }