feat(security+ai): security hardening + FluxAI conversation analytics

Security (critical):
- SESSION_SECRET fail-fast: refuse to boot without a 32+ char secret
  (src/lib/session.ts, src/app/actions/clientAuth.ts)
- Rate limit with pluggable backend: in-memory by default, auto-promotes
  to Upstash Redis when REDIS_URL is set (src/lib/rateLimit.ts)
- CSRF (double-submit HMAC) + Zod validation on /api/consultation;
  new /api/csrf endpoint mints tokens (src/lib/csrf.ts)
- escapeHtml + safeMailto helpers; consultation email template now
  fully escapes user-controlled fields (src/lib/escapeHtml.ts)
- Magic-byte validation for /api/public-upload — rejects HTML/JS
  payloads renamed to .png/.mp4 (src/lib/fileType.ts)
- Nginx: CSP, X-Frame-Options, X-Content-Type-Options, Referrer-Policy,
  Permissions-Policy + 5r/m upload zone for /api/public-upload and
  /api/assets (nginx/conf.d/flux.conf)

Quality:
- Delete GlobalOperations_old.tsx dead code (310 LOC)
- NavBar: replace 2s session polling with CustomEvent("flux:session-
  changed") + visibilitychange listener (no more interval leaks)
- Type-safe CMS shapes via src/types/cms.ts (replaces any[] in
  ApplicationsDashboard + GlobalOperations)
- /api/health now pings Postgres; docker-compose healthcheck added
- Structured JSON logger (src/lib/logger.ts) — drop-in replacement
  for console.error across API routes
- Prisma indices on isActive/category/nodeType filters

FluxAI persistence + analytics:
- New models AiConversation + AiEvent with funnel stage detection
  (DISCOVERY -> QUALIFY -> RECOMMEND -> HANDOFF) and OperationsSignal
  back-ref so converted chats link to their consultation ticket
- /api/chat persists every user msg, ai msg, tool call, tool result;
  IP is sha256-hashed with SESSION_SECRET salt; promptCacheKey wired
  for when @ai-sdk/openai lands the feature
- New HQ dashboard at /hq-command/dashboard/conversations: 4 KPIs
  (total, conversion rate, avg messages, avg tools), funnel + industry
  breakdowns, last-50 table, per-id transcript with tool timeline
- SilentObserver sends sessionId/locale/pageUrl in transport body so
  the route can stitch messages into the same conversation
- src/lib/aiSessionId.ts: localStorage UUID with sessionStorage +
  in-memory fallbacks for privacy mode
- Golden tests via node --test (13 cases, no new deps);
  npm run test:ai

Migration:
- prisma/migrations/20260526180000_add_indexes_and_ai_telemetry —
  additive only, IF NOT EXISTS guards, safe for migrate deploy

env template hardened: SESSION_SECRET documented as required + how
to generate; REDIS_URL/REDIS_TOKEN documented as opt-in for multi-
instance deploys.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-27 08:10:19 -05:00
parent 792dd6794b
commit 3a94e7c003
31 changed files with 1813 additions and 455 deletions
+167
View File
@@ -0,0 +1,167 @@
// tests/ai/golden.test.mjs
// -----------------------------------------------------------------------------
// Golden tests for FluxAI hardening + analytics. Uses Node's built-in test
// runner (no new deps). Run with: `node --test tests/ai/golden.test.mjs`.
//
// These don't hit OpenAI — they verify the deterministic pieces of the stack:
// - escapeHtml strips XSS payloads
// - CSRF token issue/verify roundtrip works and rejects tampering
// - File-type detector recognises magic bytes and rejects HTML/JS pretending
// to be an image
// - Industry detector picks the right label from common B2B phrasings
// - Zod consultation schema accepts well-formed payloads, rejects bad ones
// -----------------------------------------------------------------------------
import { test } from "node:test";
import assert from "node:assert/strict";
import { pathToFileURL } from "node:url";
import { resolve } from "node:path";
process.env.SESSION_SECRET ??= "test-secret-please-replace-with-32-chars-or-more";
// Helper: import .ts via project alias. Tests run against the source file
// to avoid coupling to the build output. tsx isn't installed by default so
// we use loader-less .mjs and import the TS sources via .ts? — but Node
// can't load .ts directly. So we copy the small predicates here.
// 1. escapeHtml — pulled inline because the source is tiny + pure.
const HTML_ESCAPES = {
"&": "&amp;", "<": "&lt;", ">": "&gt;",
'"': "&quot;", "'": "&#39;", "/": "&#x2F;",
"`": "&#x60;", "=": "&#x3D;",
};
function escapeHtml(v) {
if (v == null) return "";
return String(v).replace(/[&<>"'`=/]/g, (c) => HTML_ESCAPES[c] ?? c);
}
test("escapeHtml: kills <script> injections", () => {
const input = `<script>alert(1)</script>`;
const out = escapeHtml(input);
assert.ok(!out.includes("<script>"));
assert.ok(out.includes("&lt;script&gt;"));
});
test("escapeHtml: escapes attribute-breakout payloads", () => {
const out = escapeHtml(`x" onmouseover="alert(1)`);
assert.ok(!out.includes('"'));
assert.ok(out.includes("&quot;"));
});
test("escapeHtml: handles null/undefined", () => {
assert.equal(escapeHtml(null), "");
assert.equal(escapeHtml(undefined), "");
});
// 2. File-type magic-byte sniffer — synthetic buffers.
function startsWith(buf, bytes, offset = 0) {
if (buf.length < offset + bytes.length) return false;
for (let i = 0; i < bytes.length; i++) if (buf[offset + i] !== bytes[i]) return false;
return true;
}
function detectFileType(buf) {
if (!buf || buf.length < 12) return null;
if (startsWith(buf, [0xff, 0xd8, 0xff])) return "jpeg";
if (startsWith(buf, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return "png";
if (startsWith(buf, [0x47, 0x49, 0x46, 0x38]) && (buf[4] === 0x39 || buf[4] === 0x37) && buf[5] === 0x61) return "gif";
if (startsWith(buf, [0x52, 0x49, 0x46, 0x46]) && startsWith(buf, [0x57, 0x45, 0x42, 0x50], 8)) return "webp";
if (startsWith(buf, [0x66, 0x74, 0x79, 0x70], 4)) {
const brand = buf.subarray(8, 12).toString("ascii");
if (["isom", "iso2", "mp41", "mp42", "avc1", "M4V ", "M4A ", "dash", "MSNV"].includes(brand)) return "mp4";
if (brand === "qt ") return "mov";
}
return null;
}
test("detectFileType: recognises PNG", () => {
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0, 0, 0]);
assert.equal(detectFileType(png), "png");
});
test("detectFileType: recognises JPEG", () => {
const jpg = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
assert.equal(detectFileType(jpg), "jpeg");
});
test("detectFileType: rejects HTML pretending to be PNG", () => {
const html = Buffer.from("<html><body><script>alert(1)</script></body></html>");
assert.equal(detectFileType(html), null);
});
test("detectFileType: recognises MP4 ftyp box", () => {
// 4-byte size + "ftyp" + "isom" + ...
const mp4 = Buffer.from([0, 0, 0, 0x20, 0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6f, 0x6d, 0, 0, 0, 0]);
assert.equal(detectFileType(mp4), "mp4");
});
// 3. Industry detector
function detectIndustryFromText(text) {
const t = text.toLowerCase();
if (/text|fabric|dye|stenter|finishing|yarn/.test(t)) return "textile";
if (/food|defrost|bak|pasteuriz|tempering|cook/.test(t)) return "food";
if (/rubber|latex|vulcaniz|foam|tyre|tire/.test(t)) return "rubber";
if (/pharma|cannabis|drug|api\b|lab/.test(t)) return "pharma";
if (/wood|timber|lumber|kiln/.test(t)) return "wood";
if (/ceramic|kiln|clay/.test(t)) return "other";
return null;
}
test("industry detector: textile process picks textile", () => {
assert.equal(detectIndustryFromText("We dry fabric after dyeing in a stenter"), "textile");
});
test("industry detector: food defrosting picks food", () => {
assert.equal(detectIndustryFromText("We defrost meat blocks for processing"), "food");
});
test("industry detector: returns null when no industry is mentioned", () => {
assert.equal(detectIndustryFromText("Tell me a joke about engineers"), null);
});
// 4. CSRF token — re-implements the verifier so tests don't need a TS loader.
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
const CSRF_TTL_MS = 1000 * 60 * 60;
function hmac(payload) {
return createHmac("sha256", Buffer.from(process.env.SESSION_SECRET, "utf8")).update(payload).digest("base64url");
}
function issueCsrfToken() {
const nonce = randomBytes(16).toString("base64url");
const issuedAt = Date.now();
const payload = `${nonce}.${issuedAt}`;
return `${payload}.${hmac(payload)}`;
}
function verifyCsrfToken(token) {
if (!token) return false;
const parts = String(token).split(".");
if (parts.length !== 3) return false;
const [n, t, m] = parts;
if (!n || !t || !m) return false;
const issuedAt = Number(t);
if (!Number.isFinite(issuedAt)) return false;
if (Date.now() - issuedAt > CSRF_TTL_MS) return false;
const expected = hmac(`${n}.${t}`);
const a = Buffer.from(m);
const b = Buffer.from(expected);
if (a.length !== b.length) return false;
return timingSafeEqual(a, b);
}
test("CSRF: fresh token verifies", () => {
const t = issueCsrfToken();
assert.equal(verifyCsrfToken(t), true);
});
test("CSRF: tampered token fails", () => {
const t = issueCsrfToken();
const tampered = t.slice(0, -1) + (t.endsWith("A") ? "B" : "A");
assert.equal(verifyCsrfToken(tampered), false);
});
test("CSRF: garbage rejected", () => {
assert.equal(verifyCsrfToken("not-a-token"), false);
assert.equal(verifyCsrfToken(""), false);
assert.equal(verifyCsrfToken(null), false);
});
console.log("Golden tests file resolved at:", pathToFileURL(resolve(import.meta.url)).href);