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
+12
View File
@@ -58,6 +58,10 @@ services:
SMTP_FROM: ${SMTP_FROM} SMTP_FROM: ${SMTP_FROM}
SMTP_SECURE: ${SMTP_SECURE} SMTP_SECURE: ${SMTP_SECURE}
NODE_ENV: production NODE_ENV: production
# Optional: REDIS_URL enables multi-instance rate limiting. Leave unset
# for the current single-container deploy — the in-memory store is used.
REDIS_URL: ${REDIS_URL:-}
REDIS_TOKEN: ${REDIS_TOKEN:-}
volumes: volumes:
- ./public/footage:/app/public/footage - ./public/footage:/app/public/footage
- ./public/applications:/app/public/applications - ./public/applications:/app/public/applications
@@ -70,6 +74,14 @@ services:
- flux-net - flux-net
expose: expose:
- "3000" - "3000"
healthcheck:
test:
- CMD-SHELL
- "node -e \"fetch('http://localhost:3000/api/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))\""
interval: 30s
timeout: 5s
retries: 3
start_period: 40s
# ── Nginx Reverse Proxy ── # ── Nginx Reverse Proxy ──
nginx: nginx:
+10 -2
View File
@@ -5,8 +5,16 @@
#:xDATABASE_URL="postgresql://flux_user:STRONG_PASSWORD@postgres:5432/flux_db?schema=public" #:xDATABASE_URL="postgresql://flux_user:STRONG_PASSWORD@postgres:5432/flux_db?schema=public"
DATABASE_URL="postgresql://flux_user:dev_password@localhost:5432/flux_db?schema=public" DATABASE_URL="postgresql://flux_user:dev_password@localhost:5432/flux_db?schema=public"
#FLUX SECRET Esto no se que hace # SESSION_SECRET (REQUIRED, min 32 chars).
SESSION_SECRET="FLUX_SUPER_SECRET_KEY_2026_ARCHITECTURE" # Used to sign 7-day admin JWTs in src/lib/session.ts and CSRF tokens in
# src/lib/csrf.ts. The app refuses to boot without it. Generate with:
# openssl rand -base64 48
SESSION_SECRET="CHANGE_ME_openssl_rand_base64_48_min_32_chars"
# Optional: multi-instance rate limiting via Upstash Redis REST API.
# Leave both unset to use the in-memory bucket store (fine for single VPS).
#REDIS_URL="https://xxx.upstash.io"
#REDIS_TOKEN="xxxxx"
# OPEN AI KEY # OPEN AI KEY
OPENAI_API_KEY=sk-proj-dsdsdsds-kaDAHhZXPYsK6-4uw0UWJZ0YuLnQfVoEA OPENAI_API_KEY=sk-proj-dsdsdsds-kaDAHhZXPYsK6-4uw0UWJZ0YuLnQfVoEA
+12
View File
@@ -1,5 +1,7 @@
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s; limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/s; limit_req_zone $binary_remote_addr zone=login:10m rate=5r/s;
# Slow zone for media uploads: 5 requests per minute per IP.
limit_req_zone $binary_remote_addr zone=upload:10m rate=5r/m;
upstream nextjs { upstream nextjs {
server app:3000; server app:3000;
@@ -44,6 +46,14 @@ server {
ssl_session_tickets off; ssl_session_tickets off;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always; add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
# ── Security headers ────────────────────────────────────────────────
# 'unsafe-inline' / 'unsafe-eval' on script-src are required by Next.js
# for hydration. Tightening to nonces is tracked as future work.
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; media-src 'self' blob: https:; font-src 'self' data:; connect-src 'self' https://api.openai.com https://*.upstash.io; frame-ancestors 'none'; base-uri 'self'; form-action 'self';" always;
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
# Next.js bundles use content hashing — safe to cache forever # Next.js bundles use content hashing — safe to cache forever
location /_next/static/ { location /_next/static/ {
@@ -75,6 +85,7 @@ server {
# Asset uploads (large files, long timeout) # Asset uploads (large files, long timeout)
location /api/assets { location /api/assets {
limit_req zone=upload burst=10 nodelay;
client_max_body_size 500M; client_max_body_size 500M;
proxy_pass http://nextjs; proxy_pass http://nextjs;
proxy_set_header Host $host; proxy_set_header Host $host;
@@ -87,6 +98,7 @@ server {
} }
location /api/public-upload { location /api/public-upload {
limit_req zone=upload burst=10 nodelay;
client_max_body_size 500M; client_max_body_size 500M;
proxy_pass http://nextjs; proxy_pass http://nextjs;
proxy_set_header Host $host; proxy_set_header Host $host;
+2 -1
View File
@@ -6,7 +6,8 @@
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "eslint" "lint": "eslint",
"test:ai": "node --test tests/ai/golden.test.mjs"
}, },
"dependencies": { "dependencies": {
"@ai-sdk/openai": "^3.0.41", "@ai-sdk/openai": "^3.0.41",
@@ -0,0 +1,86 @@
-- ─────────────────────────────────────────────────────────────────────────
-- ADDITIVE MIGRATION — adds analytics tables + indices on hot filter columns.
-- Nothing in this file modifies or drops existing data. Safe to `migrate
-- deploy` in production. Idempotent: every CREATE uses IF NOT EXISTS.
-- ─────────────────────────────────────────────────────────────────────────
-- ── Indices on existing tables (speed up isActive/category filters) ──────
CREATE INDEX IF NOT EXISTS "GlobalNode_isActive_idx" ON "GlobalNode" ("isActive");
CREATE INDEX IF NOT EXISTS "GlobalNode_nodeType_idx" ON "GlobalNode" ("nodeType");
CREATE INDEX IF NOT EXISTS "GlobalNode_nodeType_isActive_idx" ON "GlobalNode" ("nodeType", "isActive");
CREATE INDEX IF NOT EXISTS "Application_isActive_idx" ON "Application" ("isActive");
CREATE INDEX IF NOT EXISTS "Application_category_idx" ON "Application" ("category");
CREATE INDEX IF NOT EXISTS "NewsArticle_isActive_idx" ON "NewsArticle" ("isActive");
CREATE INDEX IF NOT EXISTS "NewsArticle_isActive_publishedAt_idx" ON "NewsArticle" ("isActive", "publishedAt" DESC);
CREATE INDEX IF NOT EXISTS "SparePart_isActive_idx" ON "SparePart" ("isActive");
-- ── FluxAI telemetry ──────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS "AiConversation" (
"id" TEXT NOT NULL,
"sessionId" TEXT NOT NULL,
"visitorIp" TEXT,
"userAgent" TEXT,
"locale" TEXT,
"pageUrl" TEXT,
"industryLabel" TEXT,
"funnelStage" TEXT NOT NULL DEFAULT 'DISCOVERY',
"outcome" TEXT NOT NULL DEFAULT 'OPEN',
"messageCount" INTEGER NOT NULL DEFAULT 0,
"toolCallCount" INTEGER NOT NULL DEFAULT 0,
"estimatedSavingsPercent" DOUBLE PRECISION,
"productionVolume" TEXT,
"signalId" TEXT,
"startedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"lastMessageAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"closedAt" TIMESTAMP(3),
CONSTRAINT "AiConversation_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX IF NOT EXISTS "AiConversation_sessionId_key" ON "AiConversation" ("sessionId");
CREATE INDEX IF NOT EXISTS "AiConversation_funnelStage_idx" ON "AiConversation" ("funnelStage");
CREATE INDEX IF NOT EXISTS "AiConversation_outcome_idx" ON "AiConversation" ("outcome");
CREATE INDEX IF NOT EXISTS "AiConversation_startedAt_idx" ON "AiConversation" ("startedAt" DESC);
CREATE INDEX IF NOT EXISTS "AiConversation_industryLabel_idx" ON "AiConversation" ("industryLabel");
CREATE TABLE IF NOT EXISTS "AiEvent" (
"id" TEXT NOT NULL,
"conversationId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"payloadJson" TEXT NOT NULL,
"toolName" TEXT,
"latencyMs" INTEGER,
"tokensIn" INTEGER,
"tokensOut" INTEGER,
"cachedTokens" INTEGER,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AiEvent_pkey" PRIMARY KEY ("id")
);
CREATE INDEX IF NOT EXISTS "AiEvent_conversationId_createdAt_idx" ON "AiEvent" ("conversationId", "createdAt");
CREATE INDEX IF NOT EXISTS "AiEvent_type_idx" ON "AiEvent" ("type");
CREATE INDEX IF NOT EXISTS "AiEvent_toolName_idx" ON "AiEvent" ("toolName");
-- ── Foreign keys (added separately so missing references don't break load) ──
DO $$ BEGIN
ALTER TABLE "AiConversation"
ADD CONSTRAINT "AiConversation_signalId_fkey"
FOREIGN KEY ("signalId") REFERENCES "OperationsSignal"("id")
ON DELETE SET NULL ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
ALTER TABLE "AiEvent"
ADD CONSTRAINT "AiEvent_conversationId_fkey"
FOREIGN KEY ("conversationId") REFERENCES "AiConversation"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
+67
View File
@@ -60,6 +60,10 @@ model GlobalNode {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@index([isActive])
@@index([nodeType])
@@index([nodeType, isActive])
} }
// ------------------------------------------------------ // ------------------------------------------------------
@@ -90,6 +94,9 @@ model Application {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@index([isActive])
@@index([category])
} }
// ------------------------------------------------------ // ------------------------------------------------------
@@ -134,6 +141,9 @@ model NewsArticle {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@index([isActive])
@@index([isActive, publishedAt(sort: Desc)])
} }
// ------------------------------------------------------ // ------------------------------------------------------
@@ -177,6 +187,8 @@ model SparePart {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@index([isActive])
} }
// ------------------------------------------------------ // ------------------------------------------------------
@@ -210,6 +222,9 @@ model OperationsSignal {
clientId String? clientId String?
client ClientUser? @relation(fields: [clientId], references: [id]) client ClientUser? @relation(fields: [clientId], references: [id])
// FluxAI telemetry back-ref: which AI conversations converted into this ticket.
conversations AiConversation[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -293,6 +308,58 @@ model SiteSetting {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
// ------------------------------------------------------
// 13b. FLUXAI TELEMETRY (Conversaciones + eventos del chat IA)
// ------------------------------------------------------
// Persiste cada conversación con FluxAI para análisis de funnel B2B.
// Una conversación se identifica por sessionId (UUID generado en cliente,
// persistido en localStorage). Los eventos individuales (mensajes,
// tool calls, errores) viven en AiEvent.
model AiConversation {
id String @id @default(cuid())
sessionId String @unique
visitorIp String? // sha256(ip + SESSION_SECRET) — pseudonymous
userAgent String?
locale String? // "it","en","es","fr","de"
pageUrl String? // entry page
industryLabel String? // SPIN-detected: "textile","food", etc.
funnelStage String @default("DISCOVERY") // DISCOVERY|QUALIFY|RECOMMEND|HANDOFF
outcome String @default("OPEN") // OPEN|CONSULTATION|ABANDONED
messageCount Int @default(0)
toolCallCount Int @default(0)
estimatedSavingsPercent Float?
productionVolume String?
signalId String?
signal OperationsSignal? @relation(fields: [signalId], references: [id])
startedAt DateTime @default(now())
lastMessageAt DateTime @default(now())
closedAt DateTime?
events AiEvent[]
@@index([funnelStage])
@@index([outcome])
@@index([startedAt(sort: Desc)])
@@index([industryLabel])
}
model AiEvent {
id String @id @default(cuid())
conversationId String
conversation AiConversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
type String // "user_msg" | "ai_msg" | "tool_call" | "tool_result" | "error"
payloadJson String // truncated to 8KB at write time
toolName String?
latencyMs Int?
tokensIn Int?
tokensOut Int?
cachedTokens Int? // populated when OpenAI returns cached_tokens
createdAt DateTime @default(now())
@@index([conversationId, createdAt])
@@index([type])
@@index([toolName])
}
// ------------------------------------------------------ // ------------------------------------------------------
// 13. CLIENT PORTAL (Usuarios B2B Aprobados) // 13. CLIENT PORTAL (Usuarios B2B Aprobados)
// ------------------------------------------------------ // ------------------------------------------------------
@@ -43,6 +43,8 @@ export default function AuthModal({ session }: { session: any }) {
setError(res.error); setError(res.error);
} else { } else {
setIsOpen(false); setIsOpen(false);
// NavBar listens to this event to refresh its session badge without polling.
if (typeof window !== "undefined") window.dispatchEvent(new CustomEvent("flux:session-changed"));
router.refresh(); router.refresh();
} }
setIsLoading(false); setIsLoading(false);
@@ -87,6 +89,7 @@ export default function AuthModal({ session }: { session: any }) {
setIsLoading(true); setIsLoading(true);
await logoutClient(); await logoutClient();
setIsOpen(false); setIsOpen(false);
if (typeof window !== "undefined") window.dispatchEvent(new CustomEvent("flux:session-changed"));
router.refresh(); router.refresh();
}; };
+7 -1
View File
@@ -5,7 +5,13 @@ import bcrypt from "bcryptjs";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { SignJWT, jwtVerify } from "jose"; import { SignJWT, jwtVerify } from "jose";
const getSecretKey = () => new TextEncoder().encode(process.env.SESSION_SECRET || "flux-super-secret-key-2026"); const getSecretKey = () => {
const s = process.env.SESSION_SECRET;
if (!s || s.length < 32) {
throw new Error("SESSION_SECRET environment variable is required (min 32 chars).");
}
return new TextEncoder().encode(s);
};
export async function registerClientRequest(formData: FormData) { export async function registerClientRequest(formData: FormData) {
const fullName = formData.get("fullName") as string; const fullName = formData.get("fullName") as string;
+174 -4
View File
@@ -1,8 +1,10 @@
import { openai } from '@ai-sdk/openai'; import { openai } from '@ai-sdk/openai';
import { streamText, stepCountIs, UIMessage, convertToModelMessages, tool } from 'ai'; import { streamText, stepCountIs, UIMessage, convertToModelMessages, tool } from 'ai';
import { z } from 'zod'; import { z } from 'zod';
import { createHash } from 'crypto';
import { prisma } from '@/lib/prisma'; import { prisma } from '@/lib/prisma';
import { checkChatRateLimit } from '@/lib/rateLimit'; import { checkChatRateLimit, getClientIp } from '@/lib/rateLimit';
import { log } from '@/lib/logger';
export const maxDuration = 60; export const maxDuration = 60;
@@ -161,11 +163,24 @@ function industryFromSlug(slug: string): string {
return 'other'; return 'other';
} }
// Lightweight industry sniffer used for AiConversation.industryLabel telemetry.
// Order matters — more specific terms first.
function detectIndustryFromText(text: string): string | null {
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;
}
// ─── ROUTE HANDLER ────────────────────────────────────────────── // ─── ROUTE HANDLER ──────────────────────────────────────────────
export async function POST(req: Request) { export async function POST(req: Request) {
// ─── Rate limit (per-IP token bucket, 30 req/min) ────────────── // ─── Rate limit (per-IP token bucket, 30 req/min) ──────────────
const rate = checkChatRateLimit(req); const rate = await checkChatRateLimit(req);
if (!rate.ok) { if (!rate.ok) {
return new Response( return new Response(
JSON.stringify({ JSON.stringify({
@@ -183,16 +198,86 @@ export async function POST(req: Request) {
); );
} }
const { messages, context }: { const {
messages,
context,
sessionId,
locale,
pageUrl,
}: {
messages: UIMessage[]; messages: UIMessage[];
context?: { section?: string; activeTab?: string }; context?: { section?: string; activeTab?: string };
sessionId?: string;
locale?: string;
pageUrl?: string | null;
} = await req.json(); } = await req.json();
const contextNote = context?.section const contextNote = context?.section
? `\n\n[CONTEXT: User is currently viewing the "${context.section}" section${context.activeTab ? `, tab: "${context.activeTab}"` : ''}.` ? `\n\n[CONTEXT: User is currently viewing the "${context.section}" section${context.activeTab ? `, tab: "${context.activeTab}"` : ''}.`
: ''; : '';
// Build system prompt with live database context // ─── FluxAI telemetry: upsert conversation + record user message ────────
// Wrapped in try/catch — telemetry never blocks the chat response.
let conversationId: string | null = null;
const startedAt = Date.now();
if (sessionId) {
try {
const ipHash = createHash('sha256')
.update(`${getClientIp(req)}|${process.env.SESSION_SECRET ?? ''}`)
.digest('hex')
.slice(0, 32);
const lastUserMsg = [...messages].reverse().find((m) => m.role === 'user');
const lastUserText = lastUserMsg
? (lastUserMsg as unknown as { parts?: { type: string; text?: string }[] }).parts
?.filter((p) => p.type === 'text')
.map((p) => p.text || '')
.join(' ')
.slice(0, 8000)
: '';
const detectedIndustry = lastUserText ? detectIndustryFromText(lastUserText) : null;
const conv = await prisma.aiConversation.upsert({
where: { sessionId },
update: {
lastMessageAt: new Date(),
messageCount: { increment: 1 },
...(detectedIndustry ? { industryLabel: detectedIndustry } : {}),
// Once we have an industry, advance to QUALIFY.
...(detectedIndustry ? { funnelStage: 'QUALIFY' } : {}),
},
create: {
sessionId,
visitorIp: ipHash,
userAgent: req.headers.get('user-agent')?.slice(0, 240) ?? null,
locale: locale ?? null,
pageUrl: pageUrl ?? null,
industryLabel: detectedIndustry,
funnelStage: detectedIndustry ? 'QUALIFY' : 'DISCOVERY',
messageCount: 1,
},
});
conversationId = conv.id;
if (lastUserText) {
await prisma.aiEvent.create({
data: {
conversationId: conv.id,
type: 'user_msg',
payloadJson: JSON.stringify({ text: lastUserText }).slice(0, 8000),
},
});
}
} catch (e) {
log.warn('chat.telemetry_upsert_failed', { err: String(e) });
}
}
// Build system prompt with live database context.
// The static section (personality, knowledge, rules) is identical across
// requests, so we tag it with `providerOptions.openai.promptCacheKey` —
// a no-op today, but ready for prompt caching when the SDK lands it.
const systemPrompt = await buildSystemPrompt(); const systemPrompt = await buildSystemPrompt();
const coreMessages = await convertToModelMessages(messages); const coreMessages = await convertToModelMessages(messages);
@@ -201,6 +286,91 @@ export async function POST(req: Request) {
model: openai('gpt-4o'), model: openai('gpt-4o'),
system: systemPrompt + contextNote, system: systemPrompt + contextNote,
messages: coreMessages, messages: coreMessages,
providerOptions: { openai: { promptCacheKey: 'fluxai-v1' } },
onFinish: async ({ usage, toolCalls, toolResults }) => {
if (!conversationId) return;
try {
const latencyMs = Date.now() - startedAt;
// 1. Persist the assistant message (compact)
await prisma.aiEvent.create({
data: {
conversationId,
type: 'ai_msg',
payloadJson: JSON.stringify({
toolCalls: toolCalls?.map((tc) => ({ name: tc.toolName })) ?? [],
}).slice(0, 8000),
latencyMs,
tokensIn:
(usage as unknown as { inputTokens?: number; promptTokens?: number })?.inputTokens ??
(usage as unknown as { promptTokens?: number })?.promptTokens ??
null,
tokensOut:
(usage as unknown as { outputTokens?: number; completionTokens?: number })?.outputTokens ??
(usage as unknown as { completionTokens?: number })?.completionTokens ??
null,
cachedTokens:
(usage as unknown as { cachedTokens?: number })?.cachedTokens ?? null,
},
});
// 2. Persist each tool call/result
const tcArr = toolCalls ?? [];
const trArr = toolResults ?? [];
let advanceStage: string | null = null;
let savings: number | null = null;
let volume: string | null = null;
for (const tc of tcArr) {
await prisma.aiEvent.create({
data: {
conversationId,
type: 'tool_call',
toolName: tc.toolName,
payloadJson: JSON.stringify(
(tc as unknown as { args?: unknown; input?: unknown }).args ??
(tc as unknown as { input?: unknown }).input ??
{},
).slice(0, 8000),
},
});
if (tc.toolName === 'energy_savings_calculator') advanceStage = 'RECOMMEND';
if (tc.toolName === 'schedule_consultation') {
advanceStage = 'HANDOFF';
const args = ((tc as unknown as { args?: unknown; input?: unknown }).args ??
(tc as unknown as { input?: unknown }).input ??
{}) as {
estimatedSavingsPercent?: number | null;
productionVolume?: string | null;
};
if (typeof args.estimatedSavingsPercent === 'number') savings = args.estimatedSavingsPercent;
if (typeof args.productionVolume === 'string') volume = args.productionVolume;
}
}
for (const tr of trArr) {
await prisma.aiEvent.create({
data: {
conversationId,
type: 'tool_result',
toolName: (tr as unknown as { toolName?: string }).toolName ?? null,
payloadJson: JSON.stringify((tr as unknown as { result?: unknown }).result ?? {}).slice(0, 8000),
},
});
}
// 3. Update conversation funnel + counters
await prisma.aiConversation.update({
where: { id: conversationId },
data: {
toolCallCount: { increment: tcArr.length },
...(advanceStage ? { funnelStage: advanceStage } : {}),
...(savings != null ? { estimatedSavingsPercent: savings } : {}),
...(volume ? { productionVolume: volume } : {}),
},
});
} catch (e) {
log.warn('chat.telemetry_finish_failed', { err: String(e) });
}
},
// 🔥 RESTORED: AI SDK 6 multi-step autonomy. The agent can now chain // 🔥 RESTORED: AI SDK 6 multi-step autonomy. The agent can now chain
// search → calculator → case-study → consultation in a single turn, // search → calculator → case-study → consultation in a single turn,
// exactly as the SPIN methodology in the system prompt was designed for. // exactly as the SPIN methodology in the system prompt was designed for.
+140 -38
View File
@@ -1,12 +1,50 @@
// /src/app/api/consultation/route.ts // /src/app/api/consultation/route.ts
// Public API endpoint for ConsultationScheduler OperationsSignal // Public API endpoint for ConsultationScheduler -> OperationsSignal.
// Uses SMTP mailer (no Resend dependency) // Hardened (v2):
// - Zod schema validates every field, rejects malformed emails / oversize input.
// - Double-submit CSRF check rejects cross-site form posts.
// - escapeHtml() everywhere in the email template (no raw interpolation).
// - Structured logging (no silent console.error).
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { sendEmail } from "@/lib/mailer"; import { sendEmail } from "@/lib/mailer";
import { escapeHtml, escapeAttr, safeMailto } from "@/lib/escapeHtml";
import { log } from "@/lib/logger";
import { verifyCsrfToken, CSRF_COOKIE_NAME, CSRF_HEADER_NAME } from "@/lib/csrf";
const ConsultationSchema = z.object({
contact: z.object({
name: z.string().min(1).max(120),
email: z.string().email().max(254),
company: z.string().min(1).max(160),
phone: z.string().max(40).optional().nullable(),
message: z.string().max(4000).optional().nullable(),
preferredContact: z.enum(["email", "phone", "whatsapp"]).optional().nullable(),
timeframe: z.string().max(80).optional().nullable(),
}),
aiContext: z
.object({
industryLabel: z.string().max(120).optional().nullable(),
process: z.string().max(120).optional().nullable(),
estimatedSavingsPercent: z.number().min(0).max(100).optional().nullable(),
productionVolume: z.string().max(120).optional().nullable(),
conversationInsights: z.array(z.string().max(500)).max(20).optional().nullable(),
suggestedTopics: z.array(z.string().max(160)).max(20).optional().nullable(),
sessionId: z.string().uuid().optional().nullable(),
})
.partial()
.optional(),
meta: z
.object({
source: z.string().max(80).optional().nullable(),
url: z.string().url().max(500).optional().nullable(),
})
.partial()
.optional(),
});
// Helper: sequential ticket ID
async function generateConsultationTicketId(): Promise<string> { async function generateConsultationTicketId(): Promise<string> {
const year = new Date().getFullYear(); const year = new Date().getFullYear();
const count = await prisma.operationsSignal.count({ const count = await prisma.operationsSignal.count({
@@ -16,34 +54,54 @@ async function generateConsultationTicketId(): Promise<string> {
} }
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { // ── CSRF: double-submit cookie + header must match ──────────────────────
const body = await request.json(); const csrfCookie = request.cookies.get(CSRF_COOKIE_NAME)?.value ?? null;
const { contact, aiContext, meta } = body; const csrfHeader = request.headers.get(CSRF_HEADER_NAME);
if (!csrfCookie || !csrfHeader || csrfCookie !== csrfHeader || !verifyCsrfToken(csrfHeader)) {
if (!contact?.name || !contact?.email || !contact?.company) { log.warn("consultation.csrf_rejected", { hasCookie: !!csrfCookie, hasHeader: !!csrfHeader });
return NextResponse.json({ error: "Missing required contact fields" }, { status: 400 }); return NextResponse.json({ error: "Invalid CSRF token" }, { status: 403 });
} }
// ── Body parse + schema validation ──────────────────────────────────────
let parsed: z.infer<typeof ConsultationSchema>;
try {
const body = await request.json();
parsed = ConsultationSchema.parse(body);
} catch (e) {
log.warn("consultation.validation_failed", { error: e instanceof z.ZodError ? e.issues : String(e) });
return NextResponse.json(
{ error: "Invalid payload", details: e instanceof z.ZodError ? e.issues : undefined },
{ status: 400 },
);
}
const { contact, aiContext, meta } = parsed;
try {
const ticketId = await generateConsultationTicketId(); const ticketId = await generateConsultationTicketId();
// Build structured AI analysis // Build structured AI analysis (plain text, no markup needed)
const aiParts: string[] = []; const aiParts: string[] = [];
if (aiContext?.industryLabel && aiContext?.process) aiParts.push(`[INDUSTRY] ${aiContext.industryLabel}${aiContext.process}`); if (aiContext?.industryLabel && aiContext?.process)
if (aiContext?.estimatedSavingsPercent) aiParts.push(`[ESTIMATED SAVINGS] ~${aiContext.estimatedSavingsPercent}% energy reduction`); aiParts.push(`[INDUSTRY] ${aiContext.industryLabel} - ${aiContext.process}`);
if (aiContext?.estimatedSavingsPercent)
aiParts.push(`[ESTIMATED SAVINGS] ~${aiContext.estimatedSavingsPercent}% energy reduction`);
if (aiContext?.productionVolume) aiParts.push(`[PRODUCTION VOLUME] ${aiContext.productionVolume}`); if (aiContext?.productionVolume) aiParts.push(`[PRODUCTION VOLUME] ${aiContext.productionVolume}`);
if (aiContext?.conversationInsights?.length > 0) aiParts.push(`[AI DISCUSSION POINTS]\n${aiContext.conversationInsights.map((i: string) => `${i}`).join("\n")}`); if (aiContext?.conversationInsights?.length)
if (aiContext?.suggestedTopics?.length > 0) aiParts.push(`[SUGGESTED ENGINEERING TOPICS]\n${aiContext.suggestedTopics.map((t: string) => ` ${t}`).join("\n")}`); aiParts.push(`[AI DISCUSSION POINTS]\n${aiContext.conversationInsights.map((i) => `- ${i}`).join("\n")}`);
if (contact?.preferredContact) aiParts.push(`[PREFERRED CONTACT] ${contact.preferredContact.toUpperCase()}`); if (aiContext?.suggestedTopics?.length)
if (contact?.timeframe) aiParts.push(`[TIMEFRAME] ${contact.timeframe}`); aiParts.push(`[SUGGESTED ENGINEERING TOPICS]\n${aiContext.suggestedTopics.map((t) => `-> ${t}`).join("\n")}`);
if (meta?.source) aiParts.push(`[SOURCE] ${meta.source}${meta.url || "N/A"}`); if (contact.preferredContact) aiParts.push(`[PREFERRED CONTACT] ${contact.preferredContact.toUpperCase()}`);
if (contact.timeframe) aiParts.push(`[TIMEFRAME] ${contact.timeframe}`);
if (meta?.source) aiParts.push(`[SOURCE] ${meta.source} - ${meta.url || "N/A"}`);
const aiAnalysis = aiParts.join("\n\n"); const aiAnalysis = aiParts.join("\n\n");
const messageParts: string[] = []; const messageParts: string[] = [];
if (contact.message) messageParts.push(contact.message); if (contact.message) messageParts.push(contact.message);
if (contact.timeframe) messageParts.push(`Timeframe: ${contact.timeframe}`); if (contact.timeframe) messageParts.push(`Timeframe: ${contact.timeframe}`);
if (contact.preferredContact) messageParts.push(`Preferred contact: ${contact.preferredContact}`); if (contact.preferredContact) messageParts.push(`Preferred contact: ${contact.preferredContact}`);
// Save to DB
const signal = await prisma.operationsSignal.create({ const signal = await prisma.operationsSignal.create({
data: { data: {
ticketId, ticketId,
@@ -60,23 +118,33 @@ export async function POST(request: NextRequest) {
}, },
}); });
// Resolve email targets // ── Link conversation -> signal (best-effort, never blocks the response)
if (aiContext?.sessionId) {
try {
await prisma.aiConversation.updateMany({
where: { sessionId: aiContext.sessionId },
data: { outcome: "CONSULTATION", signalId: signal.id, closedAt: new Date() },
});
} catch (linkErr) {
log.warn("consultation.link_conversation_failed", { sessionId: aiContext.sessionId, err: String(linkErr) });
}
}
const route = await prisma.notificationRoute.findUnique({ where: { routeType: "CONSULTATION" } }); const route = await prisma.notificationRoute.findUnique({ where: { routeType: "CONSULTATION" } });
const targetEmails = route && route.isActive const targetEmails =
route && route.isActive
? route.emails.split(",").map((e: string) => e.trim()).filter(Boolean) ? route.emails.split(",").map((e: string) => e.trim()).filter(Boolean)
: ["engineering@fluxsrl.com"]; : ["engineering@fluxsrl.com"];
const appUrl = process.env.NEXT_PUBLIC_APP_URL || "https://flux.com"; const appUrl = process.env.NEXT_PUBLIC_APP_URL || "https://flux.com";
// Send via SMTP
const emailResult = await sendEmail({ const emailResult = await sendEmail({
to: targetEmails, to: targetEmails,
subject: `[CONSULTATION] ${aiContext?.industryLabel || "General"} inquiry from ${contact.company} ${ticketId}`, subject: `[CONSULTATION] ${aiContext?.industryLabel || "General"} inquiry from ${contact.company} - ${ticketId}`,
html: generateConsultationEmail(contact, aiContext, ticketId, appUrl), html: generateConsultationEmail(contact, aiContext, ticketId, appUrl),
replyTo: contact.email, replyTo: contact.email,
}); });
// Track email delivery
await prisma.operationsSignal.update({ await prisma.operationsSignal.update({
where: { id: signal.id }, where: { id: signal.id },
data: { data: {
@@ -86,6 +154,8 @@ export async function POST(request: NextRequest) {
}, },
}); });
log.info("consultation.submitted", { ticketId, emailSent: emailResult.success });
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
ticketId, ticketId,
@@ -93,33 +163,65 @@ export async function POST(request: NextRequest) {
emailError: emailResult.error, emailError: emailResult.error,
}); });
} catch (error) { } catch (error) {
console.error("Consultation API error:", error); log.error("consultation.submit_failed", error);
return NextResponse.json({ error: "Failed to submit consultation request" }, { status: 500 }); return NextResponse.json({ error: "Failed to submit consultation request" }, { status: 500 });
} }
} }
function generateConsultationEmail(contact: any, aiContext: any, ticketId: string, appUrl: string) { type ParsedContact = z.infer<typeof ConsultationSchema>["contact"];
const insights = (aiContext?.conversationInsights || []).map((i: string) => `<li style="margin-bottom: 6px;">${i}</li>`).join(""); type ParsedAiContext = z.infer<typeof ConsultationSchema>["aiContext"];
const topics = (aiContext?.suggestedTopics || []).map((t: string) => `<li style="margin-bottom: 4px; color: #0066CC;">${t}</li>`).join("");
function generateConsultationEmail(
contact: ParsedContact,
aiContext: ParsedAiContext | undefined,
ticketId: string,
_appUrl: string,
) {
const insightsHtml = (aiContext?.conversationInsights ?? [])
.map((i) => `<li style="margin-bottom: 6px;">${escapeHtml(i)}</li>`)
.join("");
const topicsHtml = (aiContext?.suggestedTopics ?? [])
.map((t) => `<li style="margin-bottom: 4px; color: #0066CC;">${escapeHtml(t)}</li>`)
.join("");
const safeName = escapeHtml(contact.name);
const safeCompany = escapeHtml(contact.company);
const safeEmail = escapeHtml(contact.email);
const mailHref = escapeAttr(safeMailto(contact.email));
const safePhone = contact.phone ? escapeHtml(contact.phone) : "";
const safePreferred = escapeHtml((contact.preferredContact || "email").toUpperCase());
const safeTimeframe = escapeHtml(contact.timeframe || "N/A");
const safeIndustry = aiContext?.industryLabel ? escapeHtml(aiContext.industryLabel) : "";
const safeProcess = aiContext?.process ? escapeHtml(aiContext.process) : "General";
const safeSavings = aiContext?.estimatedSavingsPercent
? escapeHtml(String(aiContext.estimatedSavingsPercent))
: "";
const safeVolume = aiContext?.productionVolume ? escapeHtml(aiContext.productionVolume) : "";
const safeMessage = contact.message ? escapeHtml(contact.message) : "";
const safeTicketId = escapeHtml(ticketId);
return ` return `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 0 auto; color: #1D1D1F;"> <div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 0 auto; color: #1D1D1F;">
<div style="border-bottom: 2px solid #00F0FF; padding-bottom: 20px; margin-bottom: 24px;"> <div style="border-bottom: 2px solid #00F0FF; padding-bottom: 20px; margin-bottom: 24px;">
<p style="color: #86868B; font-size: 12px; letter-spacing: 2px; text-transform: uppercase;">FLUX AI Engineering Consultation</p> <p style="color: #86868B; font-size: 12px; letter-spacing: 2px; text-transform: uppercase;">FLUX AI - Engineering Consultation</p>
<h1 style="margin: 8px 0 0 0; font-size: 22px;">New Consultation Request</h1> <h1 style="margin: 8px 0 0 0; font-size: 22px;">New Consultation Request</h1>
<p style="font-family: monospace; color: #00F0FF;">${ticketId}</p> <p style="font-family: monospace; color: #00F0FF;">${safeTicketId}</p>
</div> </div>
<div style="background: #F5F5F7; padding: 20px; border-radius: 12px; margin-bottom: 24px;"> <div style="background: #F5F5F7; padding: 20px; border-radius: 12px; margin-bottom: 24px;">
<p style="margin: 4px 0;"><strong>${contact.name}</strong> ${contact.company}</p> <p style="margin: 4px 0;"><strong>${safeName}</strong> - ${safeCompany}</p>
<p style="margin: 4px 0;">Email: <a href="mailto:${contact.email}" style="color: #0066CC;">${contact.email}</a></p> <p style="margin: 4px 0;">Email: <a href="${mailHref}" style="color: #0066CC;">${safeEmail}</a></p>
${contact.phone ? `<p style="margin: 4px 0;">Phone: ${contact.phone}</p>` : ""} ${safePhone ? `<p style="margin: 4px 0;">Phone: ${safePhone}</p>` : ""}
<p style="margin: 4px 0;">Preferred: <strong>${(contact.preferredContact || "email").toUpperCase()}</strong> · Timeframe: <strong>${contact.timeframe || "N/A"}</strong></p> <p style="margin: 4px 0;">Preferred: <strong>${safePreferred}</strong> &middot; Timeframe: <strong>${safeTimeframe}</strong></p>
</div> </div>
${aiContext?.industryLabel ? `<div style="background: #F0FBFF; border-left: 4px solid #00F0FF; padding: 16px 20px; border-radius: 0 12px 12px 0; margin-bottom: 24px;"><h3 style="margin: 0 0 8px 0; font-size: 13px; color: #00F0FF;">AI Context</h3><p style="margin: 4px 0;"><strong>Industry:</strong> ${aiContext.industryLabel}${aiContext.process || "General"}</p>${aiContext.estimatedSavingsPercent ? `<p style="color: #059669;"><strong>Savings:</strong> ~${aiContext.estimatedSavingsPercent}%</p>` : ""}${aiContext.productionVolume ? `<p><strong>Volume:</strong> ${aiContext.productionVolume}</p>` : ""}</div>` : ""} ${
${insights ? `<div style="margin-bottom: 24px;"><h3 style="font-size: 13px; color: #86868B;">Key Points</h3><ul style="padding-left: 20px;">${insights}</ul></div>` : ""} safeIndustry
${topics ? `<div style="margin-bottom: 24px;"><h3 style="font-size: 13px; color: #0066CC;">Prepare Topics</h3><ul style="padding-left: 20px; list-style: none;">${topics}</ul></div>` : ""} ? `<div style="background: #F0FBFF; border-left: 4px solid #00F0FF; padding: 16px 20px; border-radius: 0 12px 12px 0; margin-bottom: 24px;"><h3 style="margin: 0 0 8px 0; font-size: 13px; color: #00F0FF;">AI Context</h3><p style="margin: 4px 0;"><strong>Industry:</strong> ${safeIndustry} - ${safeProcess}</p>${safeSavings ? `<p style="color: #059669;"><strong>Savings:</strong> ~${safeSavings}%</p>` : ""}${safeVolume ? `<p><strong>Volume:</strong> ${safeVolume}</p>` : ""}</div>`
${contact.message ? `<div style="margin-bottom: 24px;"><h3 style="font-size: 13px; color: #86868B;">Client Notes</h3><div style="padding: 12px; border-left: 4px solid #1D1D1F; background: #FAFAFA;">${contact.message}</div></div>` : ""} : ""
<div style="border-top: 1px solid #E5E5EA; padding-top: 16px; margin-top: 32px; font-size: 11px; color: #86868B; text-align: center;"><p>FLUX Operations · Reply to contact ${contact.name} directly.</p></div> }
${insightsHtml ? `<div style="margin-bottom: 24px;"><h3 style="font-size: 13px; color: #86868B;">Key Points</h3><ul style="padding-left: 20px;">${insightsHtml}</ul></div>` : ""}
${topicsHtml ? `<div style="margin-bottom: 24px;"><h3 style="font-size: 13px; color: #0066CC;">Prepare Topics</h3><ul style="padding-left: 20px; list-style: none;">${topicsHtml}</ul></div>` : ""}
${safeMessage ? `<div style="margin-bottom: 24px;"><h3 style="font-size: 13px; color: #86868B;">Client Notes</h3><div style="padding: 12px; border-left: 4px solid #1D1D1F; background: #FAFAFA;">${safeMessage}</div></div>` : ""}
<div style="border-top: 1px solid #E5E5EA; padding-top: 16px; margin-top: 32px; font-size: 11px; color: #86868B; text-align: center;"><p>FLUX Operations &middot; Reply to contact ${safeName} directly.</p></div>
</div> </div>
`; `;
} }
+19
View File
@@ -0,0 +1,19 @@
// src/app/api/csrf/route.ts
// -----------------------------------------------------------------------------
// Issues a fresh CSRF token for the current browser session. Public POST
// endpoints (e.g. /api/consultation) require the matching cookie + header
// (double-submit pattern). See src/lib/csrf.ts for the verification flow.
// -----------------------------------------------------------------------------
import { NextResponse } from "next/server";
import { CSRF_COOKIE_NAME, csrfCookieOptions, issueCsrfToken } from "@/lib/csrf";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
export async function GET() {
const token = issueCsrfToken();
const res = NextResponse.json({ token });
res.cookies.set(CSRF_COOKIE_NAME, token, csrfCookieOptions);
return res;
}
+36
View File
@@ -0,0 +1,36 @@
// src/app/api/health/route.ts
// ─────────────────────────────────────────────────────────────────────────────
// Readiness probe. Returns 200 only if Postgres responds. Used by Docker
// healthcheck and by external uptime monitors so Nginx can fail fast and
// orchestrators can recycle the container if the DB connection dies.
// ─────────────────────────────────────────────────────────────────────────────
import { prisma } from "@/lib/prisma";
import { log } from "@/lib/logger";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
export async function GET() {
const startedAt = Date.now();
try {
await prisma.$queryRaw`SELECT 1`;
return Response.json({
ok: true,
db: "up",
latencyMs: Date.now() - startedAt,
ts: new Date().toISOString(),
});
} catch (e) {
log.error("health.db_unreachable", e);
return Response.json(
{
ok: false,
db: "down",
latencyMs: Date.now() - startedAt,
ts: new Date().toISOString(),
},
{ status: 503 },
);
}
}
+37 -20
View File
@@ -2,19 +2,20 @@ import { NextRequest, NextResponse } from "next/server";
import fs from "fs"; import fs from "fs";
import path from "path"; import path from "path";
import { revalidateContent } from "@/lib/revalidate"; import { revalidateContent } from "@/lib/revalidate";
import { detectFileType, expectedTypeForExtension } from "@/lib/fileType";
import { log } from "@/lib/logger";
// 1. REGLAS DE SEGURIDAD ESTRICTAS // 1. STRICT SECURITY RULES
const ALLOWED_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.webp', '.mp4', '.mov']; const ALLOWED_EXTENSIONS = [".jpg", ".jpeg", ".png", ".webp", ".mp4", ".mov"];
const MAX_FILE_SIZE = 500 * 1024 * 1024; // 500MB Límite const MAX_FILE_SIZE = 500 * 1024 * 1024; // 500MB
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const formData = await request.formData(); const formData = await request.formData();
const file = formData.get("file") as File; const file = formData.get("file") as File;
const ticketId = formData.get("ticketId") as string; const ticketId = formData.get("ticketId") as string;
const clientName = formData.get("clientName") as string || "unregistered"; const clientName = (formData.get("clientName") as string) || "unregistered";
// 2. VALIDACIONES INICIALES
if (!file || !ticketId) { if (!file || !ticketId) {
return NextResponse.json({ error: "Faltan datos requeridos (archivo o ticketId)" }, { status: 400 }); return NextResponse.json({ error: "Faltan datos requeridos (archivo o ticketId)" }, { status: 400 });
} }
@@ -25,50 +26,66 @@ export async function POST(request: NextRequest) {
const ext = path.extname(file.name).toLowerCase(); const ext = path.extname(file.name).toLowerCase();
if (!ALLOWED_EXTENSIONS.includes(ext)) { if (!ALLOWED_EXTENSIONS.includes(ext)) {
return NextResponse.json({ error: `Formato no permitido. Solo aceptamos: ${ALLOWED_EXTENSIONS.join(", ")}` }, { status: 400 }); return NextResponse.json(
{ error: `Formato no permitido. Solo aceptamos: ${ALLOWED_EXTENSIONS.join(", ")}` },
{ status: 415 },
);
} }
// 3. SANITIZACIÓN DE NOMBRES (Evita inyección de código y caracteres raros) // 2. SANITIZE NAMES
const safeTicketId = ticketId.replace(/[^a-zA-Z0-9-]/g, ""); const safeTicketId = ticketId.replace(/[^a-zA-Z0-9-]/g, "");
// Convertimos "David Herran!" a "david-herran"
const safeClientName = clientName.toLowerCase().replace(/[^a-z0-9]/g, "-").substring(0, 30); const safeClientName = clientName.toLowerCase().replace(/[^a-z0-9]/g, "-").substring(0, 30);
const folderName = `${safeTicketId}-${safeClientName}`; const folderName = `${safeTicketId}-${safeClientName}`;
// 4. CREACIÓN DE LA CARPETA DEL CLIENTE
// Ruta final: /public/operations-inbox/REQ-2026-X8Y-david-herran/
const uploadDir = path.join(process.cwd(), "public", "operations-inbox", folderName); const uploadDir = path.join(process.cwd(), "public", "operations-inbox", folderName);
// Escudo Anti-Hacking (Verifica que la ruta resuelta no se escape de la carpeta public) // 3. PATH TRAVERSAL GUARD
if (!path.resolve(uploadDir).startsWith(path.resolve(process.cwd(), "public", "operations-inbox"))) { if (!path.resolve(uploadDir).startsWith(path.resolve(process.cwd(), "public", "operations-inbox"))) {
return NextResponse.json({ error: "Ruta de directorio inválida" }, { status: 400 }); return NextResponse.json({ error: "Ruta de directorio inválida" }, { status: 400 });
} }
// 4. READ BUFFER FIRST so we can sniff magic bytes BEFORE writing to disk.
// This prevents stored-XSS payloads (HTML/JS renamed to .png).
const buffer = Buffer.from(await file.arrayBuffer());
const detected = detectFileType(buffer);
const expected = expectedTypeForExtension(ext);
if (!detected || (expected && detected !== expected && !(expected === "jpeg" && detected === "jpeg"))) {
log.warn("public_upload.magic_mismatch", {
ext,
detected,
expected,
size: file.size,
ticketId: safeTicketId,
});
return NextResponse.json(
{ error: "El contenido del archivo no coincide con su extensión." },
{ status: 415 },
);
}
// 5. CREATE FOLDER + WRITE
if (!fs.existsSync(uploadDir)) { if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true }); fs.mkdirSync(uploadDir, { recursive: true });
} }
// 5. GUARDAR EL ARCHIVO FÍSICAMENTE
const safeFileName = file.name.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9._-]/g, ""); const safeFileName = file.name.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9._-]/g, "");
const filePath = path.join(uploadDir, safeFileName); const filePath = path.join(uploadDir, safeFileName);
const buffer = Buffer.from(await file.arrayBuffer());
fs.writeFileSync(filePath, buffer); fs.writeFileSync(filePath, buffer);
// 6. DEVOLVER LA URL PÚBLICA PARA GUARDARLA EN POSTGRES
const publicUrl = `/operations-inbox/${folderName}/${safeFileName}`; const publicUrl = `/operations-inbox/${folderName}/${safeFileName}`;
// Invalida caché del operations-inbox / dashboard
revalidateContent({ scope: "operations-inbox", slug: folderName }); revalidateContent({ scope: "operations-inbox", slug: folderName });
log.info("public_upload.saved", { ticketId: safeTicketId, file: safeFileName, detected, bytes: file.size });
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
url: publicUrl, url: publicUrl,
fileName: safeFileName, fileName: safeFileName,
type: ext === '.mp4' || ext === '.mov' ? 'video' : 'image' type: ext === ".mp4" || ext === ".mov" ? "video" : "image",
}); });
} catch (error) { } catch (error) {
console.error("Error crítico en subida pública:", error); log.error("public_upload.failed", error);
return NextResponse.json({ error: "Error interno del servidor" }, { status: 500 }); return NextResponse.json({ error: "Error interno del servidor" }, { status: 500 });
} }
} }
@@ -0,0 +1,140 @@
// src/app/hq-command/dashboard/conversations/[id]/page.tsx
// -----------------------------------------------------------------------------
// FluxAI conversation detail — full event timeline, tool calls expanded,
// link to the OperationsSignal when the chat converted.
// -----------------------------------------------------------------------------
export const dynamic = "force-dynamic";
export const revalidate = 0;
import Link from "next/link";
import { notFound } from "next/navigation";
import { prisma } from "@/lib/prisma";
import { ArrowLeft, Bot, User, Wrench, AlertTriangle, MessageSquare } from "lucide-react";
interface PageProps {
params: Promise<{ id: string }>;
}
export default async function ConversationDetailPage({ params }: PageProps) {
const { id } = await params;
const conversation = await prisma.aiConversation.findUnique({
where: { id },
include: {
events: { orderBy: { createdAt: "asc" } },
signal: true,
},
});
if (!conversation) notFound();
const durationMs =
(conversation.closedAt ?? conversation.lastMessageAt).getTime() -
conversation.startedAt.getTime();
const durationLabel =
durationMs < 60000 ? `${Math.round(durationMs / 1000)}s` : `${Math.round(durationMs / 60000)} min`;
return (
<div className="min-h-screen px-6 md:px-12 py-10 max-w-5xl mx-auto">
<Link
href="/hq-command/dashboard/conversations"
className="inline-flex items-center gap-2 text-xs text-white/50 hover:text-white mb-6"
>
<ArrowLeft size={14} /> Back to conversations
</Link>
<header className="mb-8">
<h1 className="text-2xl font-light tracking-tight">Conversation</h1>
<p className="text-xs font-mono text-white/40 mt-1">{conversation.sessionId}</p>
</header>
<section className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-8">
<Meta label="Started" value={conversation.startedAt.toISOString().slice(0, 16).replace("T", " ")} />
<Meta label="Duration" value={durationLabel} />
<Meta label="Industry" value={conversation.industryLabel ?? "—"} />
<Meta label="Locale" value={conversation.locale ?? "—"} />
<Meta label="Stage" value={conversation.funnelStage} />
<Meta label="Outcome" value={conversation.outcome} />
<Meta label="Messages" value={String(conversation.messageCount)} />
<Meta label="Tool calls" value={String(conversation.toolCallCount)} />
</section>
{conversation.signal ? (
<div className="mb-8 rounded-2xl border border-emerald-500/30 bg-emerald-500/[0.04] p-5">
<div className="text-xs uppercase tracking-widest text-emerald-300">
Converted to consultation
</div>
<div className="mt-2 text-sm">
<span className="font-mono text-emerald-300">{conversation.signal.ticketId}</span>
{" · "}
<span className="text-white/70">{conversation.signal.clientName}</span>
{" · "}
<span className="text-white/50">{conversation.signal.clientCompany}</span>
</div>
</div>
) : null}
{conversation.pageUrl ? (
<p className="mb-6 text-xs text-white/40">
Entry page: <span className="text-white/60">{conversation.pageUrl}</span>
</p>
) : null}
<h2 className="text-xs uppercase tracking-widest text-white/40 mb-3">Event timeline</h2>
<ol className="space-y-2">
{conversation.events.map((ev) => (
<li
key={ev.id}
className="rounded-xl border border-white/10 bg-white/[0.02] px-4 py-3 text-sm"
>
<div className="flex items-center gap-2">
<EventIcon type={ev.type} />
<span className="text-xs uppercase tracking-widest text-white/40">
{ev.type}
{ev.toolName ? ` · ${ev.toolName}` : ""}
</span>
<span className="ml-auto text-[10px] text-white/30">
{ev.createdAt.toISOString().slice(11, 19)}
</span>
</div>
<pre className="mt-2 whitespace-pre-wrap text-xs text-white/70 break-words font-mono">
{truncate(ev.payloadJson, 1200)}
</pre>
{ev.tokensIn || ev.tokensOut || ev.latencyMs ? (
<div className="mt-2 flex gap-4 text-[10px] text-white/40">
{ev.latencyMs ? <span>{ev.latencyMs} ms</span> : null}
{ev.tokensIn ? <span>in: {ev.tokensIn}</span> : null}
{ev.tokensOut ? <span>out: {ev.tokensOut}</span> : null}
{ev.cachedTokens ? <span>cached: {ev.cachedTokens}</span> : null}
</div>
) : null}
</li>
))}
</ol>
</div>
);
}
function truncate(s: string, n: number) {
if (s.length <= n) return s;
return s.slice(0, n) + "…";
}
function Meta({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-xl border border-white/5 bg-white/[0.02] px-4 py-3">
<div className="text-[10px] uppercase tracking-widest text-white/30">{label}</div>
<div className="text-sm mt-1">{value}</div>
</div>
);
}
function EventIcon({ type }: { type: string }) {
if (type === "user_msg") return <User size={14} className="text-[#00F0FF]" />;
if (type === "ai_msg") return <Bot size={14} className="text-purple-300" />;
if (type === "tool_call") return <Wrench size={14} className="text-amber-300" />;
if (type === "tool_result") return <Wrench size={14} className="text-emerald-300" />;
if (type === "error") return <AlertTriangle size={14} className="text-red-400" />;
return <MessageSquare size={14} className="text-white/40" />;
}
@@ -0,0 +1,298 @@
// src/app/hq-command/dashboard/conversations/page.tsx
// -----------------------------------------------------------------------------
// FluxAI Conversations — analytics MVP for the HQ Command Center.
// Surfaces what visitors are actually asking, which industries dominate,
// and how many chats convert into consultation tickets.
// -----------------------------------------------------------------------------
export const dynamic = "force-dynamic";
export const revalidate = 0;
import Link from "next/link";
import { prisma } from "@/lib/prisma";
import {
ArrowLeft,
MessageSquare,
Target,
Activity,
TrendingUp,
} from "lucide-react";
type StageRow = { funnelStage: string; _count: { _all: number } };
type IndustryRow = { industryLabel: string | null; _count: { _all: number } };
type OutcomeRow = { outcome: string; _count: { _all: number } };
export default async function ConversationsDashboardPage({
searchParams,
}: {
searchParams: Promise<{ stage?: string; outcome?: string; industry?: string }>;
}) {
const { stage, outcome, industry } = (await searchParams) ?? {};
// ── KPI: counts + breakdowns ─────────────────────────────────────────────
const [
total,
stageBreakdown,
industryBreakdown,
outcomeBreakdown,
averages,
] = await Promise.all([
prisma.aiConversation.count(),
prisma.aiConversation.groupBy({
by: ["funnelStage"],
_count: { _all: true },
}) as unknown as Promise<StageRow[]>,
prisma.aiConversation.groupBy({
by: ["industryLabel"],
_count: { _all: true },
orderBy: { _count: { industryLabel: "desc" } },
take: 5,
}) as unknown as Promise<IndustryRow[]>,
prisma.aiConversation.groupBy({
by: ["outcome"],
_count: { _all: true },
}) as unknown as Promise<OutcomeRow[]>,
prisma.aiConversation.aggregate({
_avg: { messageCount: true, toolCallCount: true },
}),
]);
const consultationCount =
outcomeBreakdown.find((r) => r.outcome === "CONSULTATION")?._count._all ?? 0;
const conversionRate = total > 0 ? Math.round((consultationCount / total) * 10000) / 100 : 0;
// ── Recent conversations list ────────────────────────────────────────────
const conversations = await prisma.aiConversation.findMany({
where: {
...(stage ? { funnelStage: stage } : {}),
...(outcome ? { outcome } : {}),
...(industry ? { industryLabel: industry } : {}),
},
orderBy: { startedAt: "desc" },
take: 50,
select: {
id: true,
sessionId: true,
startedAt: true,
lastMessageAt: true,
industryLabel: true,
funnelStage: true,
outcome: true,
messageCount: true,
toolCallCount: true,
estimatedSavingsPercent: true,
pageUrl: true,
locale: true,
},
});
return (
<div className="min-h-screen px-6 md:px-12 py-10 max-w-7xl mx-auto">
<Link
href="/hq-command/dashboard"
className="inline-flex items-center gap-2 text-xs text-white/50 hover:text-white mb-6"
>
<ArrowLeft size={14} /> Back to Command Center
</Link>
<header className="mb-10">
<h1 className="text-3xl font-light tracking-tight">FluxAI Conversations</h1>
<p className="text-sm text-white/50 mt-1">
Funnel analytics for every chat with the on-site engineering assistant.
</p>
</header>
{/* ── KPI tiles ───────────────────────────────────────────────────── */}
<section className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-10">
<Kpi
icon={<MessageSquare size={16} />}
label="Total conversations"
value={String(total)}
accent="text-[#00F0FF]"
/>
<Kpi
icon={<Target size={16} />}
label="Conversion rate"
value={`${conversionRate}%`}
sub={`${consultationCount} → consultation`}
accent="text-emerald-400"
/>
<Kpi
icon={<Activity size={16} />}
label="Avg messages / chat"
value={(averages._avg.messageCount ?? 0).toFixed(1)}
accent="text-purple-400"
/>
<Kpi
icon={<TrendingUp size={16} />}
label="Avg tool calls"
value={(averages._avg.toolCallCount ?? 0).toFixed(1)}
accent="text-amber-400"
/>
</section>
{/* ── Funnel + Industries ─────────────────────────────────────────── */}
<section className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-10">
<Panel title="Funnel stages">
{stageBreakdown.length === 0 ? (
<Empty label="No conversations yet." />
) : (
<ul className="space-y-2">
{(["DISCOVERY", "QUALIFY", "RECOMMEND", "HANDOFF"] as const).map((s) => {
const row = stageBreakdown.find((r) => r.funnelStage === s);
const count = row?._count._all ?? 0;
const pct = total > 0 ? Math.round((count / total) * 100) : 0;
return (
<li key={s} className="flex items-center justify-between text-sm">
<span className="text-white/70">{s}</span>
<span className="text-white/40">{count} · {pct}%</span>
</li>
);
})}
</ul>
)}
</Panel>
<Panel title="Top industries">
{industryBreakdown.length === 0 ? (
<Empty label="No industry signals captured yet." />
) : (
<ul className="space-y-2">
{industryBreakdown.map((row, i) => (
<li key={i} className="flex items-center justify-between text-sm">
<span className="text-white/70">
{row.industryLabel ?? "Unknown"}
</span>
<span className="text-white/40">{row._count._all}</span>
</li>
))}
</ul>
)}
</Panel>
</section>
{/* ── Recent conversations table ──────────────────────────────────── */}
<section>
<h2 className="text-sm uppercase tracking-widest text-white/40 mb-3">
Last 50 conversations
</h2>
<div className="overflow-x-auto rounded-2xl border border-white/10">
<table className="w-full text-sm">
<thead className="bg-white/[0.02] text-xs uppercase tracking-widest text-white/40">
<tr>
<th className="px-4 py-3 text-left">Started</th>
<th className="px-4 py-3 text-left">Industry</th>
<th className="px-4 py-3 text-left">Stage</th>
<th className="px-4 py-3 text-left">Outcome</th>
<th className="px-4 py-3 text-right">Msgs</th>
<th className="px-4 py-3 text-right">Tools</th>
<th className="px-4 py-3 text-left">Locale</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody>
{conversations.length === 0 ? (
<tr>
<td colSpan={8} className="px-4 py-8 text-center text-white/40">
No conversations match these filters yet.
</td>
</tr>
) : (
conversations.map((c) => (
<tr key={c.id} className="border-t border-white/5 hover:bg-white/[0.02]">
<td className="px-4 py-3 text-white/60 whitespace-nowrap">
{c.startedAt.toISOString().slice(0, 16).replace("T", " ")}
</td>
<td className="px-4 py-3">{c.industryLabel ?? "—"}</td>
<td className="px-4 py-3">
<StagePill stage={c.funnelStage} />
</td>
<td className="px-4 py-3">
<OutcomePill outcome={c.outcome} />
</td>
<td className="px-4 py-3 text-right text-white/60">{c.messageCount}</td>
<td className="px-4 py-3 text-right text-white/60">{c.toolCallCount}</td>
<td className="px-4 py-3 text-white/60">{c.locale ?? "—"}</td>
<td className="px-4 py-3 text-right">
<Link
href={`/hq-command/dashboard/conversations/${c.id}`}
className="text-[#00F0FF] hover:underline text-xs"
>
Open
</Link>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</section>
</div>
);
}
function Kpi({
icon,
label,
value,
sub,
accent,
}: {
icon: React.ReactNode;
label: string;
value: string;
sub?: string;
accent?: string;
}) {
return (
<div className="rounded-2xl border border-white/10 bg-white/[0.02] p-5">
<div className={`flex items-center gap-2 text-xs uppercase tracking-widest ${accent ?? "text-white/40"}`}>
{icon}
<span>{label}</span>
</div>
<div className="mt-3 text-2xl font-light tracking-tight">{value}</div>
{sub ? <div className="text-xs text-white/40 mt-1">{sub}</div> : null}
</div>
);
}
function Panel({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="rounded-2xl border border-white/10 bg-white/[0.02] p-6">
<h3 className="text-xs uppercase tracking-widest text-white/40 mb-4">{title}</h3>
{children}
</div>
);
}
function Empty({ label }: { label: string }) {
return <p className="text-sm text-white/40 italic">{label}</p>;
}
function StagePill({ stage }: { stage: string }) {
const map: Record<string, string> = {
DISCOVERY: "bg-white/10 text-white/70",
QUALIFY: "bg-purple-500/15 text-purple-300",
RECOMMEND: "bg-amber-500/15 text-amber-300",
HANDOFF: "bg-emerald-500/15 text-emerald-300",
};
return (
<span className={`px-2 py-1 rounded-full text-[10px] uppercase tracking-widest ${map[stage] ?? "bg-white/10 text-white/70"}`}>
{stage}
</span>
);
}
function OutcomePill({ outcome }: { outcome: string }) {
const map: Record<string, string> = {
OPEN: "bg-white/10 text-white/60",
CONSULTATION: "bg-emerald-500/15 text-emerald-300",
ABANDONED: "bg-red-500/10 text-red-300/70",
};
return (
<span className={`px-2 py-1 rounded-full text-[10px] uppercase tracking-widest ${map[outcome] ?? "bg-white/10 text-white/60"}`}>
{outcome}
</span>
);
}
+9
View File
@@ -177,6 +177,15 @@ export default async function DashboardPage() {
color: "text-fuchsia-400", color: "text-fuchsia-400",
bg: "bg-fuchsia-500/10", bg: "bg-fuchsia-500/10",
border: "hover:border-fuchsia-500/50" border: "hover:border-fuchsia-500/50"
},
{
title: "FluxAI Conversations",
description: "Funnel analytics, top industries, and full transcripts of every chat with FluxAI.",
icon: Sparkles,
href: "/hq-command/dashboard/conversations",
color: "text-[#00F0FF]",
bg: "bg-[#00F0FF]/10",
border: "hover:border-[#00F0FF]/50"
} }
]; ];
+10 -1
View File
@@ -251,9 +251,18 @@ export default function ConsultationScheduler({ data }: { data: ConsultationData
}; };
try { try {
// ── Fetch a fresh CSRF token (sets the matching cookie too) ──
const csrfRes = await fetch("/api/csrf", { method: "GET", credentials: "same-origin" });
const { token: csrfToken } = (await csrfRes.json()) as { token?: string };
if (!csrfToken) throw new Error("Could not obtain CSRF token");
const res = await fetch("/api/consultation", { const res = await fetch("/api/consultation", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, credentials: "same-origin",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": csrfToken,
},
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
+8 -1
View File
@@ -18,6 +18,8 @@ import CaseStudyViewer from "./CaseStudyViewer";
import EquipmentConfigurator from "./EquipmentConfigurator"; import EquipmentConfigurator from "./EquipmentConfigurator";
import EfficiencyCard from "./EfficiencyCard"; import EfficiencyCard from "./EfficiencyCard";
import { getAiSessionId } from "@/lib/aiSessionId";
export default function SilentObserver() { export default function SilentObserver() {
const { const {
isAiExpanded, toggleAi, setAiExpanded, isAiExpanded, toggleAi, setAiExpanded,
@@ -54,15 +56,20 @@ export default function SilentObserver() {
}; };
// ═══ AI SDK 6: Transport with dynamic body ═══ // ═══ AI SDK 6: Transport with dynamic body ═══
// sessionId is stable per visitor (localStorage UUID) so the chat route can
// stitch all messages into the same AiConversation row for analytics.
const transport = useMemo(() => new DefaultChatTransport({ const transport = useMemo(() => new DefaultChatTransport({
api: "/api/chat", api: "/api/chat",
body: () => ({ body: () => ({
sessionId: getAiSessionId(),
locale,
pageUrl: typeof window !== "undefined" ? window.location.href : null,
context: { context: {
section: sectionRef.current, section: sectionRef.current,
activeTab: tabRef.current, activeTab: tabRef.current,
}, },
}), }),
}), []); }), [locale]);
// ═══ AI SDK 6: useChat ═══ // ═══ AI SDK 6: useChat ═══
const { messages, sendMessage, addToolOutput, status } = useChat({ const { messages, sendMessage, addToolOutput, status } = useChat({
+16 -5
View File
@@ -62,20 +62,31 @@ export default function NavBar() {
}; };
document.addEventListener("mousedown", handleClickOutside); document.addEventListener("mousedown", handleClickOutside);
// Verificar si existe la cookie "flux_b2b_session" // Cookie check is now event-driven (no setInterval polling).
// Triggers:
// - Initial mount
// - "flux:session-changed" CustomEvent dispatched by AuthModal on login/logout
// - visibilitychange (catches logout-in-another-tab)
// - storage events (multi-tab logout via shared cookie)
const checkSession = () => { const checkSession = () => {
const cookies = document.cookie.split("; "); const cookies = document.cookie.split("; ");
const sessionExists = cookies.some(c => c.startsWith("flux_b2b_session=")); const sessionExists = cookies.some((c) => c.startsWith("flux_b2b_session="));
setHasSession(sessionExists); setHasSession(sessionExists);
}; };
checkSession(); checkSession();
// Re-chequear cuando el modal dispare un refresh
const interval = setInterval(checkSession, 2000); const handleVisibility = () => {
if (document.visibilityState === "visible") checkSession();
};
window.addEventListener("flux:session-changed", checkSession);
document.addEventListener("visibilitychange", handleVisibility);
return () => { return () => {
window.removeEventListener("scroll", handleScroll); window.removeEventListener("scroll", handleScroll);
document.removeEventListener("mousedown", handleClickOutside); document.removeEventListener("mousedown", handleClickOutside);
clearInterval(interval); window.removeEventListener("flux:session-changed", checkSession);
document.removeEventListener("visibilitychange", handleVisibility);
}; };
}, []); }, []);
@@ -6,18 +6,19 @@ import { ArrowRight, Zap, Scale, Cpu } from "lucide-react";
import { Link } from "@/i18n/routing"; import { Link } from "@/i18n/routing";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { getIconForSlug } from "@/lib/applicationIcons"; import { getIconForSlug } from "@/lib/applicationIcons";
import type { AppCard, DashboardMetric } from "@/types/cms";
import { parseJsonField } from "@/types/cms";
export default function ApplicationsDashboard({ dbApps = [] }: { dbApps?: any[] }) { export default function ApplicationsDashboard({ dbApps = [] }: { dbApps?: AppCard[] }) {
const activeApps = dbApps.filter(app => app.isActive); const activeApps = dbApps.filter((app) => app.isActive);
if (!activeApps || activeApps.length === 0) return null; if (!activeApps || activeApps.length === 0) return null;
const [activeSlug, setActiveSlug] = useState(activeApps[0]?.slug); const [activeSlug, setActiveSlug] = useState<string | undefined>(activeApps[0]?.slug);
const activeApp = activeApps.find(app => app.slug === activeSlug) || activeApps[0]; const activeApp = activeApps.find((app) => app.slug === activeSlug) || activeApps[0];
const t = useTranslations("AppsDashboard"); // 🔥 LLAMAMOS AL DICCIONARIO const t = useTranslations("AppsDashboard");
let metrics = []; const metrics = parseJsonField<DashboardMetric[]>(activeApp?.dashboardMetricsJson, []);
try { metrics = JSON.parse(activeApp.dashboardMetricsJson || "[]"); } catch (e) {}
const triggerFluxAI = (prompt: string) => { const triggerFluxAI = (prompt: string) => {
window.dispatchEvent(new CustomEvent("flux:trigger-ai", { detail: { prompt } })); window.dispatchEvent(new CustomEvent("flux:trigger-ai", { detail: { prompt } }));
+2 -1
View File
@@ -9,6 +9,7 @@ import { MapPin, Calendar, X, ArrowUpRight, Globe, Package, Building2, Layers }
import Image from "next/image"; import Image from "next/image";
import CaseStudyModal, { CaseStudyData } from "@/components/ui/CaseStudyModal"; import CaseStudyModal, { CaseStudyData } from "@/components/ui/CaseStudyModal";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import type { AppCard, NodeMarker } from "@/types/cms";
const RADIUS = 2; const RADIUS = 2;
const CAM_FOV = 50; const CAM_FOV = 50;
@@ -536,7 +537,7 @@ function NodeCard({ node, isDark, onClose, onViewCase }: {
// ───────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────
// MAIN COMPONENT // MAIN COMPONENT
// ───────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────
export default function GlobalOperations({ dbNodes = [], dbApps = [] }: { dbNodes?: any[]; dbApps?: any[] }) { export default function GlobalOperations({ dbNodes = [], dbApps = [] }: { dbNodes?: NodeMarker[]; dbApps?: AppCard[] }) {
const [filter, setFilter] = useState("all"); const [filter, setFilter] = useState("all");
const [subFilter, setSubFilter] = useState<string | null>(null); const [subFilter, setSubFilter] = useState<string | null>(null);
const [selectedId, setSelectedId] = useState<string | null>(null); const [selectedId, setSelectedId] = useState<string | null>(null);
@@ -1,310 +0,0 @@
"use client";
import { useState, useRef, Suspense, useEffect } from "react";
import { Canvas, useFrame, useLoader, useThree } from "@react-three/fiber";
import { OrbitControls, QuadraticBezierLine } from "@react-three/drei";
import * as THREE from "three";
import { motion, AnimatePresence } from "framer-motion";
import { MapPin, Calendar, History, X, ArrowUpRight } from "lucide-react";
import CaseStudyModal, { CaseStudyData } from "@/components/ui/CaseStudyModal";
import { useLocale, useTranslations } from "next-intl";
const RADIUS = 2;
function latLongToVector3(lat: number, lon: number, radius: number) {
const phi = (90 - lat) * (Math.PI / 180);
const theta = (lon + 180) * (Math.PI / 180);
const x = -(radius * Math.sin(phi) * Math.cos(theta));
const z = (radius * Math.sin(phi) * Math.sin(theta));
const y = (radius * Math.cos(phi));
return new THREE.Vector3(x, y, z);
}
// ── COMPONENTE OPTIMIZADO 1: TEXTURA DE LA TIERRA CON ALTA RESOLUCIÓN ──
function EarthMesh({ isDark }: { isDark: boolean }) {
const earthTexture = useLoader(THREE.TextureLoader, "https://unpkg.com/three-globe/example/img/earth-water.png");
const { gl } = useThree();
// 🔥 Filtro de hardware para forzar nitidez al hacer Zoom
useEffect(() => {
if (earthTexture) {
earthTexture.anisotropy = gl.capabilities.getMaxAnisotropy(); // Máximo detalle en ángulos inclinados
earthTexture.minFilter = THREE.LinearMipmapLinearFilter;
earthTexture.magFilter = THREE.LinearFilter;
earthTexture.colorSpace = THREE.SRGBColorSpace; // Colores más vibrantes y definidos
earthTexture.generateMipmaps = true;
earthTexture.needsUpdate = true;
}
}, [earthTexture, gl]);
return (
<mesh>
{/* Regresamos a 64 segmentos para optimizar rendimiento (la nitidez ahora viene de la textura) */}
<sphereGeometry args={[RADIUS * 0.99, 64, 64]} />
<meshBasicMaterial
map={earthTexture}
color={isDark ? "#06F5E1" : "#86868B"}
transparent
opacity={isDark ? 0.4 : 0.3}
blending={isDark ? THREE.AdditiveBlending : THREE.NormalBlending}
/>
</mesh>
);
}
// ── COMPONENTE OPTIMIZADO 2: EL NODO INTELIGENTE QUE RESPONDE AL ZOOM ──
function MapNode({ marker, isSelected, hqPosition, onSelectMarker, isDark }: any) {
const meshRef = useRef<THREE.Group>(null);
const pos = latLongToVector3(marker.lat, marker.lon, RADIUS);
const isHQ = marker.nodeType === "hq";
const isEvent = marker.nodeType === "event";
const nodeColor = isHQ ? (isDark ? "#FFFFFF" : "#1D1D1F") : isEvent ? "#A855F7" : "#0066CC";
const baseSize = isHQ ? 0.04 : isEvent ? 0.035 : 0.025;
useFrame(({ camera }) => {
if (!meshRef.current) return;
const dist = camera.position.length();
const scaleFactor = Math.max(0.2, dist / 12);
const finalScale = isSelected ? scaleFactor * 1.8 : scaleFactor;
meshRef.current.scale.set(finalScale, finalScale, finalScale);
});
const distance = hqPosition.distanceTo(pos);
const arcElevation = RADIUS + (distance * 0.25) + 0.1;
const midPoint = hqPosition.clone().lerp(pos, 0.5).normalize().multiplyScalar(arcElevation);
return (
<group>
<group ref={meshRef} position={pos}>
<mesh>
<sphereGeometry args={[baseSize, 32, 32]} />
<meshBasicMaterial color={nodeColor} />
</mesh>
{/* CAJA DE COLISIÓN AMPLIADA */}
<mesh
visible={false}
onClick={(e) => {
e.stopPropagation();
onSelectMarker(isSelected ? null : marker.id);
}}
onPointerOver={(e) => { e.stopPropagation(); document.body.style.cursor = 'pointer'; }}
onPointerOut={(e) => { e.stopPropagation(); document.body.style.cursor = 'auto'; }}
>
<sphereGeometry args={[baseSize * 4, 16, 16]} />
<meshBasicMaterial transparent opacity={0} />
</mesh>
</group>
{!isHQ && (
<QuadraticBezierLine
start={hqPosition}
end={pos}
mid={midPoint}
color={nodeColor}
lineWidth={isSelected ? 2.5 : 1.5}
transparent
opacity={isSelected ? 0.9 : 0.25}
/>
)}
</group>
);
}
// ── COMPONENTE DE ENSAMBLAJE DE LA ESFERA ──
function HologramSphere({ activeFilter, activeSubFilter, selectedMarker, onSelectMarker, isDark, dbNodes, hqPosition }: any) {
const globeRef = useRef<THREE.Group>(null);
// 🔥 MAGIA DE ROTACIÓN INTELIGENTE 🔥
useFrame(({ camera }) => {
// La cámara inicia en Z=7. Si el usuario hace zoom in (distancia < 6.5), detenemos la rotación.
// Si vuelve a alejar el mapa a su estado normal (distancia > 6.5), retoma la rotación.
const distance = camera.position.length();
if (globeRef.current && !selectedMarker && distance > 6.5) {
globeRef.current.rotation.y += 0.0005;
}
});
return (
<group ref={globeRef}>
<mesh><sphereGeometry args={[RADIUS * 1.04, 64, 64]} /><meshBasicMaterial color={isDark ? "#00F0FF" : "#0066CC"} transparent opacity={isDark ? 0.1 : 0.05} side={THREE.BackSide} blending={THREE.AdditiveBlending} depthWrite={false} /></mesh>
<mesh><sphereGeometry args={[RADIUS * 0.98, 64, 64]} /><meshBasicMaterial color={isDark ? "#050505" : "#E5E5EA"} /></mesh>
{/* Esfera Terrestre mejorada con texturas nítidas */}
<EarthMesh isDark={isDark} />
<mesh><sphereGeometry args={[RADIUS, 64, 64]} /><meshBasicMaterial color={isDark ? "#0066CC" : "#86868B"} wireframe transparent opacity={0.06} /></mesh>
{dbNodes.map((marker: any) => {
const isHQ = marker.nodeType === "hq";
const isEvent = marker.nodeType === "event";
const matchesMain = activeFilter === "all" || (activeFilter === "installation" && !isEvent && !isHQ) || (activeFilter === "event" && isEvent) || (activeFilter === "legacy" && isHQ);
const matchesSub = !activeSubFilter || marker.application === activeSubFilter || isHQ;
const isVisible = matchesMain && matchesSub;
if (!isVisible) return null;
return (
<MapNode
key={marker.id}
marker={marker}
isSelected={selectedMarker === marker.id}
hqPosition={hqPosition}
onSelectMarker={onSelectMarker}
isDark={isDark}
/>
);
})}
</group>
);
}
// ── INTERFAZ GRÁFICA PRINCIPAL ──
export default function GlobalOperations({ dbNodes = [], dbApps = [] }: { dbNodes?: any[], dbApps?: any[] }) {
const [activeFilter, setActiveFilter] = useState("all");
const [activeSubFilter, setActiveSubFilter] = useState<string | null>(null);
const [selectedMarkerId, setSelectedMarkerId] = useState<string | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [isDark, setIsDark] = useState(false);
const t = useTranslations("GlobalOperations");
const dynamicSubFilters = dbApps
.filter(app => app.isActive)
.map(app => ({ id: app.slug, label: app.title }));
useEffect(() => {
const checkTheme = () => setIsDark(document.documentElement.classList.contains("dark"));
checkTheme();
const observer = new MutationObserver(checkTheme);
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
return () => observer.disconnect();
}, []);
const filters = [
{ id: "all", label: t("filterAll"), icon: MapPin },
{ id: "installation", label: t("filterInstallations"), icon: MapPin },
{ id: "event", label: t("filterEvents"), icon: Calendar },
{ id: "legacy", label: t("filterHQ"), icon: History }
];
const selectedData = dbNodes.find(d => d.id === selectedMarkerId);
const hqNode = dbNodes.find(d => d.application === "hq");
const hqLat = hqNode ? hqNode.lat : 45.78;
const hqLon = hqNode ? hqNode.lon : 11.76;
const hqPosition = latLongToVector3(hqLat, hqLon, RADIUS);
const handleMainFilter = (id: string) => {
setActiveFilter(id);
setActiveSubFilter(null);
setSelectedMarkerId(null);
};
return (
<>
<section id="global" className="relative w-full max-w-7xl mx-auto px-6 py-32 z-10">
<div className="flex flex-col lg:flex-row gap-12 items-center h-auto lg:h-[700px]">
<div className="w-full lg:w-1/3 flex flex-col h-full justify-center mb-12 lg:mb-0">
<div className="p-6 md:p-8 -ml-6 md:-ml-8 rounded-3xl bg-white/40 dark:bg-[#0A0A0C]/50 backdrop-blur-md border border-white/50 dark:border-white/5 shadow-sm transition-colors">
<h2 className="text-sm font-semibold tracking-widest text-[#0066CC] dark:text-[#4DA6FF] uppercase mb-4">{t("subtitle")}</h2>
<h3 className="text-4xl md:text-5xl font-light text-[#1D1D1F] dark:text-[#F5F5F7] tracking-tight mb-8 leading-[1.1] transition-colors">
{t("title1")} <br /><span className="font-medium">{t("title2")}</span>
</h3>
<div className="flex flex-wrap gap-2 mb-4">
{filters.map((f) => (
<button key={f.id} onClick={() => handleMainFilter(f.id)} className={`px-4 py-2 rounded-full text-sm font-medium transition-all duration-300 border ${ activeFilter === f.id ? "bg-[#1D1D1F] dark:bg-white text-white dark:text-[#1D1D1F] border-[#1D1D1F] dark:border-white shadow-md" : "bg-white/60 dark:bg-white/5 text-[#86868B] dark:text-[#A1A1A6] border-white/60 dark:border-white/10 hover:text-[#1D1D1F] dark:hover:text-white" } backdrop-blur-md`}>
{f.label}
</button>
))}
</div>
<AnimatePresence>
{activeFilter === "installation" && (
<motion.div initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: "auto" }} exit={{ opacity: 0, height: 0 }} className="flex flex-wrap gap-2 mb-4 overflow-hidden">
<span className="w-full text-xs font-semibold uppercase tracking-widest text-[#86868B] mb-1">{t("filterByApp")}</span>
{dynamicSubFilters.map((sub) => (
<button key={sub.id} onClick={() => { setActiveSubFilter(activeSubFilter === sub.id ? null : sub.id); setSelectedMarkerId(null); }} className={`px-3 py-1.5 rounded-full text-xs transition-all duration-200 border ${ activeSubFilter === sub.id ? "bg-[#0066CC]/10 dark:bg-[#0066CC]/20 text-[#0066CC] dark:text-[#4DA6FF] border-[#0066CC]/30 font-medium" : "bg-white/40 dark:bg-transparent text-[#86868B] border-transparent hover:text-[#1D1D1F] dark:hover:text-white" }`}>
{sub.label}
</button>
))}
</motion.div>
)}
</AnimatePresence>
</div>
<AnimatePresence mode="wait">
{!selectedMarkerId && (
<motion.div key={activeFilter + (activeSubFilter || "")} initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -10 }} className="bg-white/60 dark:bg-[#1D1D1F]/60 backdrop-blur-2xl border border-white/40 dark:border-white/10 shadow-[0_8px_32px_rgba(0,0,0,0.04)] dark:shadow-[0_8px_32px_rgba(0,0,0,0.4)] rounded-3xl p-8 mt-4 transition-colors">
<h4 className="text-[#86868B] text-xs font-semibold uppercase tracking-wider mb-2 flex items-center gap-2"><span className="w-2 h-2 rounded-full bg-[#0066CC] animate-pulse"></span>{t("networkStatus")}</h4>
<p className="text-xl font-light text-[#1D1D1F] dark:text-[#F5F5F7] mb-6 leading-relaxed transition-colors">
{activeSubFilter
? t("statusShowing", { app: activeSubFilter.replace("-", " ") })
: t("statusTracking", { count: dbNodes.filter(n =>
(activeFilter === "all") ||
(activeFilter === "installation" && n.nodeType === "installation") ||
(activeFilter === "event" && n.nodeType === "event") ||
(activeFilter === "legacy" && n.nodeType === "hq")
).length })}
</p>
</motion.div>
)}
</AnimatePresence>
</div>
<div className="w-full lg:w-2/3 h-[500px] lg:h-full relative rounded-[2.5rem] overflow-hidden bg-white/30 dark:bg-transparent backdrop-blur-sm dark:backdrop-blur-none border border-white/40 dark:border-transparent transition-all duration-700">
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[150%] h-[150%] bg-[radial-gradient(circle_at_center,rgba(0,102,204,0.15)_0%,transparent_60%)] hidden dark:block pointer-events-none blur-3xl" />
<div className="absolute top-4 right-4 z-10 text-[10px] font-mono text-[#86868B] dark:text-[#A1A1A6] tracking-widest uppercase bg-white/50 dark:bg-white/5 px-3 py-1 rounded-full backdrop-blur-md border border-white/50 dark:border-white/10 transition-colors pointer-events-none">
{t("helpText")}
</div>
<AnimatePresence>
{selectedData && (
<motion.div initial={{ opacity: 0, x: 20, y: 20 }} animate={{ opacity: 1, x: 0, y: 0 }} exit={{ opacity: 0, x: 20, y: 20 }} className="absolute bottom-4 right-4 z-20 w-[calc(100%-2rem)] md:w-80 bg-white/80 dark:bg-[#1D1D1F]/80 backdrop-blur-2xl border border-white/60 dark:border-white/10 shadow-lg dark:shadow-[0_10px_40px_rgba(0,0,0,0.5)] rounded-2xl p-6 transition-colors">
<div className="flex justify-between items-start mb-4">
<div className="flex items-center gap-2 text-[#0066CC] dark:text-[#4DA6FF]">
<MapPin size={14} />
<span className="text-[10px] font-semibold uppercase tracking-wider">
{selectedData.nodeType === 'event' ? t("typeEvent") : selectedData.nodeType === 'hq' ? t("typeHQ") : t("typeInstall")}
</span>
</div>
<button onClick={() => setSelectedMarkerId(null)} className="text-[#86868B] hover:text-[#1D1D1F] dark:hover:text-white transition-colors">
<X size={16} />
</button>
</div>
<h4 className="text-xl font-medium text-[#1D1D1F] dark:text-[#F5F5F7] mb-1">{selectedData.title}</h4>
<p className="text-sm text-[#86868B] dark:text-[#A1A1A6] mb-4">{selectedData.location}</p>
<div className="bg-[#F5F5F7] dark:bg-white/5 rounded-lg p-3 mb-4 border border-transparent dark:border-white/5">
<span className="text-[10px] uppercase tracking-wider text-[#86868B] dark:text-[#A1A1A6] block mb-1">{t("statusDetails")}</span>
<span className="text-sm font-medium text-[#1D1D1F] dark:text-[#F5F5F7]">{selectedData.stats}</span>
</div>
<button onClick={() => setIsModalOpen(true)} className="w-full flex justify-center items-center gap-2 bg-[#1D1D1F] dark:bg-[#0066CC] text-white py-2.5 rounded-xl text-sm font-medium hover:bg-[#0066CC] dark:hover:bg-[#4DA6FF] transition-colors">
{t("viewCaseStudy")} <ArrowUpRight size={14} />
</button>
</motion.div>
)}
</AnimatePresence>
<Canvas camera={{ position: [0, 0, 7], fov: 55 }} dpr={[1, 2]} style={{ touchAction: "none" }}>
<ambientLight intensity={1.5} />
<directionalLight position={[10, 10, 5]} intensity={2} />
<OrbitControls enableZoom={true} minDistance={3} maxDistance={12} enablePan={false} autoRotate={false} />
<Suspense fallback={null}>
<HologramSphere activeFilter={activeFilter} activeSubFilter={activeSubFilter} selectedMarker={selectedMarkerId} onSelectMarker={setSelectedMarkerId} isDark={isDark} dbNodes={dbNodes} hqPosition={hqPosition} />
</Suspense>
</Canvas>
</div>
</div>
</section>
<CaseStudyModal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)} data={selectedData as CaseStudyData || null} />
</>
);
}
+64
View File
@@ -0,0 +1,64 @@
// src/lib/aiSessionId.ts
// -----------------------------------------------------------------------------
// Per-visitor pseudonymous session id used to stitch together FluxAI
// conversations across messages. The id is generated on first chat and
// persisted in localStorage; if storage is unavailable (Safari ITP / privacy
// mode) we fall back to a per-tab id in sessionStorage; if both fail we use
// an ephemeral in-memory id (no tracking across reload).
// -----------------------------------------------------------------------------
const STORAGE_KEY = "flux:ai:session";
function randomUUID(): string {
// crypto.randomUUID is available in all modern browsers + Node 14.17+.
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
// Tiny fallback (RFC 4122 v4-ish — sufficient for telemetry, not for security).
const rnd = (n: number) =>
Array.from({ length: n }, () => Math.floor(Math.random() * 16).toString(16)).join("");
return `${rnd(8)}-${rnd(4)}-4${rnd(3)}-${rnd(4)}-${rnd(12)}`;
}
let memoryId: string | null = null;
export function getAiSessionId(): string {
if (typeof window === "undefined") {
// SSR — caller must pass the id from the client.
if (!memoryId) memoryId = randomUUID();
return memoryId;
}
// localStorage first
try {
const existing = window.localStorage.getItem(STORAGE_KEY);
if (existing) return existing;
const fresh = randomUUID();
window.localStorage.setItem(STORAGE_KEY, fresh);
return fresh;
} catch {
// ignore — privacy mode
}
// sessionStorage next
try {
const existing = window.sessionStorage.getItem(STORAGE_KEY);
if (existing) return existing;
const fresh = randomUUID();
window.sessionStorage.setItem(STORAGE_KEY, fresh);
return fresh;
} catch {
// ignore
}
// In-memory (tab-scoped) last resort
if (!memoryId) memoryId = randomUUID();
return memoryId;
}
export function resetAiSessionId(): void {
memoryId = null;
if (typeof window === "undefined") return;
try { window.localStorage.removeItem(STORAGE_KEY); } catch {}
try { window.sessionStorage.removeItem(STORAGE_KEY); } catch {}
}
+76
View File
@@ -0,0 +1,76 @@
// src/lib/csrf.ts
// -----------------------------------------------------------------------------
// CSRF protection for public POST endpoints (consultation form, etc).
//
// Pattern: stateless double-submit cookie + header.
// 1. Server issues a token "<nonce>.<HMAC(nonce, SESSION_SECRET)>".
// Stored in a JS-readable cookie (so the client can copy it into a header).
// 2. Client POSTs with both the cookie and an X-CSRF-Token header.
// 3. Server verifies cookie === header AND the HMAC is valid.
//
// Why double-submit + HMAC (not just cookie):
// - The HMAC binds the cookie to the server, so an attacker can't mint a
// cookie via a subdomain or via a JS-injection on an unrelated site.
// - Tokens are stateless: no DB roundtrip, no Redis dep, no replay window
// beyond the cookie TTL.
// -----------------------------------------------------------------------------
import { createHmac, randomBytes, timingSafeEqual } from "crypto";
export const CSRF_COOKIE_NAME = "flux_csrf";
export const CSRF_HEADER_NAME = "x-csrf-token";
const CSRF_TTL_MS = 1000 * 60 * 60; // 1h — long enough for slow form fills
function getSecret(): Buffer {
const s = process.env.SESSION_SECRET;
if (!s) throw new Error("SESSION_SECRET required for CSRF");
return Buffer.from(s, "utf8");
}
function hmac(payload: string): string {
return createHmac("sha256", getSecret()).update(payload).digest("base64url");
}
/**
* Mint a fresh CSRF token. Format: "<nonce>.<issuedAtMs>.<hmac>".
* The browser will get this in a cookie; the JS client copies it into a header.
*/
export function issueCsrfToken(): string {
const nonce = randomBytes(16).toString("base64url");
const issuedAt = Date.now();
const payload = `${nonce}.${issuedAt}`;
return `${payload}.${hmac(payload)}`;
}
/**
* Constant-time verification. Returns true iff the token is well-formed,
* the HMAC matches, and the token has not expired.
*/
export function verifyCsrfToken(token: string | null | undefined): boolean {
if (!token || typeof token !== "string") return false;
const parts = token.split(".");
if (parts.length !== 3) return false;
const [nonce, issuedAtStr, mac] = parts;
if (!nonce || !issuedAtStr || !mac) return false;
const issuedAt = Number(issuedAtStr);
if (!Number.isFinite(issuedAt)) return false;
if (Date.now() - issuedAt > CSRF_TTL_MS) return false;
const expected = hmac(`${nonce}.${issuedAtStr}`);
const a = Buffer.from(mac);
const b = Buffer.from(expected);
if (a.length !== b.length) return false;
return timingSafeEqual(a, b);
}
/**
* Cookie config helpers kept here so client and server agree on flags.
*/
export const csrfCookieOptions = {
// NOT httpOnly: the client needs to read it to copy into the header.
httpOnly: false,
sameSite: "lax" as const,
secure: process.env.NODE_ENV === "production",
path: "/",
maxAge: CSRF_TTL_MS / 1000,
};
+49
View File
@@ -0,0 +1,49 @@
// src/lib/escapeHtml.ts
// -----------------------------------------------------------------------------
// Escape user-controlled strings before interpolating into HTML markup.
// Used by transactional email templates (src/app/api/consultation/route.ts)
// and anywhere we render untrusted text into raw HTML strings.
// -----------------------------------------------------------------------------
const HTML_ESCAPES: Record<string, string> = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
"/": "&#x2F;",
"`": "&#x60;",
"=": "&#x3D;",
};
/**
* Escape characters that have special meaning inside HTML text content.
* Safe for <div>{value}</div>-style interpolation.
*/
export function escapeHtml(value: unknown): string {
if (value == null) return "";
return String(value).replace(/[&<>"'`=/]/g, (c) => HTML_ESCAPES[c] ?? c);
}
/**
* Escape values that will be inserted inside double-quoted HTML attributes
* (e.g. href="..."). Strips control characters and escapes the quote chars.
*/
export function escapeAttr(value: unknown): string {
if (value == null) return "";
return String(value)
// eslint-disable-next-line no-control-regex
.replace(/[\x00-\x1F\x7F]/g, "")
.replace(/[&<>"'`]/g, (c) => HTML_ESCAPES[c] ?? c);
}
/**
* Conservative validator for use in mailto: hrefs. Accepts the same shape
* Zod's z.string().email() does. Returns empty string when invalid so the
* resulting <a href=""> does nothing harmful.
*/
export function safeMailto(email: unknown): string {
if (typeof email !== "string") return "";
if (!/^[^\s@<>"]+@[^\s@<>"]+\.[^\s@<>"]+$/.test(email)) return "";
return encodeURI(`mailto:${email}`);
}
+86
View File
@@ -0,0 +1,86 @@
// src/lib/fileType.ts
// ─────────────────────────────────────────────────────────────────────────────
// Magic-byte sniffer for uploaded files. Trusts file content, NOT the
// client-provided extension or MIME type. Pure stdlib, zero deps.
//
// Used by /api/public-upload and /api/assets to reject HTML/JS payloads
// renamed to .png/.mp4 (a classic stored-XSS vector).
// ─────────────────────────────────────────────────────────────────────────────
export type DetectedFileType = "jpeg" | "png" | "webp" | "gif" | "mp4" | "mov" | null;
function startsWith(buf: Buffer, bytes: number[], offset = 0): boolean {
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;
}
/**
* Inspect the first ~16 bytes of a buffer and return the detected media type,
* or null if the file does not match any allow-listed signature.
*/
export function detectFileType(buf: Buffer): DetectedFileType {
if (!buf || buf.length < 12) return null;
// JPEG: FF D8 FF
if (startsWith(buf, [0xff, 0xd8, 0xff])) return "jpeg";
// PNG: 89 50 4E 47 0D 0A 1A 0A
if (startsWith(buf, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return "png";
// GIF: 47 49 46 38 (39|37) 61 — "GIF89a" / "GIF87a"
if (startsWith(buf, [0x47, 0x49, 0x46, 0x38]) && (buf[4] === 0x39 || buf[4] === 0x37) && buf[5] === 0x61) {
return "gif";
}
// WebP: RIFF....WEBP (52 49 46 46 _ _ _ _ 57 45 42 50)
if (
startsWith(buf, [0x52, 0x49, 0x46, 0x46]) &&
startsWith(buf, [0x57, 0x45, 0x42, 0x50], 8)
) {
return "webp";
}
// ISO Base Media (MP4 / MOV). The format begins with a 4-byte box size,
// then "ftyp", then a 4-byte major brand.
if (startsWith(buf, [0x66, 0x74, 0x79, 0x70], 4)) {
const brand = buf.subarray(8, 12).toString("ascii");
// Common MP4 brands
if (
brand === "isom" || brand === "iso2" || brand === "mp41" || brand === "mp42" ||
brand === "avc1" || brand === "M4V " || brand === "M4A " || brand === "dash" ||
brand === "MSNV"
) return "mp4";
// QuickTime
if (brand === "qt ") return "mov";
}
return null;
}
/**
* Map extension to expected detected type so callers can verify the upload's
* content matches its file name. Returns null when the extension is not on
* the public upload allow list.
*/
export function expectedTypeForExtension(ext: string): DetectedFileType | null {
switch (ext.toLowerCase()) {
case ".jpg":
case ".jpeg":
return "jpeg";
case ".png":
return "png";
case ".webp":
return "webp";
case ".gif":
return "gif";
case ".mp4":
return "mp4";
case ".mov":
return "mov";
default:
return null;
}
}
+34
View File
@@ -0,0 +1,34 @@
// src/lib/logger.ts
// ─────────────────────────────────────────────────────────────────────────────
// Minimal structured logger — JSON lines per event so `docker logs flux-app`
// can be piped through `jq` and shipped to Loki/Sentry/CloudWatch without code
// changes. Zero deps. Replace console.error/log with log.error/log.info.
// ─────────────────────────────────────────────────────────────────────────────
type LogContext = Record<string, unknown>;
function serialiseError(err: unknown) {
if (err instanceof Error) {
return { name: err.name, message: err.message, stack: err.stack };
}
return { value: String(err) };
}
function emit(level: "info" | "warn" | "error", event: string, ctx?: LogContext, err?: unknown) {
const line = JSON.stringify({
lvl: level,
event,
ts: new Date().toISOString(),
...(err !== undefined ? { err: serialiseError(err) } : {}),
...ctx,
});
if (level === "error") console.error(line);
else if (level === "warn") console.warn(line);
else console.log(line);
}
export const log = {
info: (event: string, ctx?: LogContext) => emit("info", event, ctx),
warn: (event: string, ctx?: LogContext) => emit("warn", event, ctx),
error: (event: string, err: unknown, ctx?: LogContext) => emit("error", event, ctx, err),
};
+127 -43
View File
@@ -1,34 +1,17 @@
// src/lib/rateLimit.ts // src/lib/rateLimit.ts
// ───────────────────────────────────────────────────────────────────────────── // -----------------------------------------------------------------------------
// Lightweight in-memory rate limiter (token bucket per IP). // Token-bucket rate limiter with pluggable backend.
// Single Node process, no Redis dep — protects /api/chat from quota burning. //
// Scales to one container; if you add replicas, swap the Map for Upstash Redis. // - InMemoryStore (default): a per-process Map. Fine for single-container
// ───────────────────────────────────────────────────────────────────────────── // deploys (current VPS). If you add replicas, the limit gets multiplied.
// - RedisStore (Upstash REST): synchronised across instances. Activated
// automatically when REDIS_URL + REDIS_TOKEN env vars are set.
//
// Both stores expose the same `consume(key, capacity, refillPerSec)` API so
// callers don't change when the deploy shape changes.
// -----------------------------------------------------------------------------
interface Bucket { import { log } from "@/lib/logger";
tokens: number;
updatedAt: number;
}
interface RateLimitConfig {
capacity: number; // Max tokens in the bucket
refillPerSec: number; // Tokens added each second
}
const buckets = new Map<string, Bucket>();
// Garbage-collect stale buckets every 10 min so memory doesn't grow unbounded
let lastGc = Date.now();
const GC_INTERVAL = 10 * 60 * 1000;
const STALE_THRESHOLD = 30 * 60 * 1000;
function gc(now: number) {
if (now - lastGc < GC_INTERVAL) return;
for (const [key, bucket] of buckets) {
if (now - bucket.updatedAt > STALE_THRESHOLD) buckets.delete(key);
}
lastGc = now;
}
export interface RateLimitResult { export interface RateLimitResult {
ok: boolean; ok: boolean;
@@ -36,24 +19,46 @@ export interface RateLimitResult {
retryAfterSec: number; retryAfterSec: number;
} }
export function rateLimit(key: string, config: RateLimitConfig): RateLimitResult { interface RateLimitStore {
consume(key: string, capacity: number, refillPerSec: number): Promise<RateLimitResult> | RateLimitResult;
}
// ── In-memory store ──────────────────────────────────────────────────────────
interface Bucket {
tokens: number;
updatedAt: number;
}
class InMemoryStore implements RateLimitStore {
private buckets = new Map<string, Bucket>();
private lastGc = Date.now();
private readonly GC_INTERVAL = 10 * 60 * 1000;
private readonly STALE_THRESHOLD = 30 * 60 * 1000;
private gc(now: number) {
if (now - this.lastGc < this.GC_INTERVAL) return;
for (const [key, bucket] of this.buckets) {
if (now - bucket.updatedAt > this.STALE_THRESHOLD) this.buckets.delete(key);
}
this.lastGc = now;
}
consume(key: string, capacity: number, refillPerSec: number): RateLimitResult {
const now = Date.now(); const now = Date.now();
gc(now); this.gc(now);
const existing = buckets.get(key);
let bucket: Bucket;
const existing = this.buckets.get(key);
if (!existing) { if (!existing) {
bucket = { tokens: config.capacity - 1, updatedAt: now }; this.buckets.set(key, { tokens: capacity - 1, updatedAt: now });
buckets.set(key, bucket); return { ok: true, remaining: capacity - 1, retryAfterSec: 0 };
return { ok: true, remaining: bucket.tokens, retryAfterSec: 0 };
} }
const elapsedSec = (now - existing.updatedAt) / 1000; const elapsedSec = (now - existing.updatedAt) / 1000;
const refilled = Math.min(config.capacity, existing.tokens + elapsedSec * config.refillPerSec); const refilled = Math.min(capacity, existing.tokens + elapsedSec * refillPerSec);
if (refilled < 1) { if (refilled < 1) {
const retryAfterSec = Math.ceil((1 - refilled) / config.refillPerSec); const retryAfterSec = Math.ceil((1 - refilled) / refillPerSec);
existing.tokens = refilled; existing.tokens = refilled;
existing.updatedAt = now; existing.updatedAt = now;
return { ok: false, remaining: 0, retryAfterSec }; return { ok: false, remaining: 0, retryAfterSec };
@@ -62,12 +67,81 @@ export function rateLimit(key: string, config: RateLimitConfig): RateLimitResult
existing.tokens = refilled - 1; existing.tokens = refilled - 1;
existing.updatedAt = now; existing.updatedAt = now;
return { ok: true, remaining: Math.floor(existing.tokens), retryAfterSec: 0 }; return { ok: true, remaining: Math.floor(existing.tokens), retryAfterSec: 0 };
}
}
// ── Upstash Redis store (REST API, fetch-only, no extra deps) ────────────────
class UpstashRedisStore implements RateLimitStore {
constructor(private url: string, private token: string) {}
private async pipeline(commands: (string | number)[][]): Promise<unknown[]> {
const res = await fetch(`${this.url}/pipeline`, {
method: "POST",
headers: { Authorization: `Bearer ${this.token}`, "Content-Type": "application/json" },
body: JSON.stringify(commands),
cache: "no-store",
});
if (!res.ok) throw new Error(`Upstash error ${res.status}`);
const data = (await res.json()) as { result: unknown }[];
return data.map((d) => d.result);
}
async consume(key: string, capacity: number, refillPerSec: number): Promise<RateLimitResult> {
// Lua-free fallback: GET state, compute, SET with TTL. Race window is small
// and the worst case is one extra token consumed across replicas — acceptable.
const now = Date.now();
const stateKey = `rl:${key}`;
try {
const [raw] = await this.pipeline([["GET", stateKey]]);
let tokens = capacity;
let updatedAt = now;
if (typeof raw === "string") {
const parsed = JSON.parse(raw) as { tokens: number; updatedAt: number };
const elapsedSec = (now - parsed.updatedAt) / 1000;
tokens = Math.min(capacity, parsed.tokens + elapsedSec * refillPerSec);
updatedAt = now;
}
if (tokens < 1) {
const retryAfterSec = Math.ceil((1 - tokens) / refillPerSec);
await this.pipeline([["SET", stateKey, JSON.stringify({ tokens, updatedAt }), "EX", 1800]]);
return { ok: false, remaining: 0, retryAfterSec };
}
tokens = tokens - 1;
await this.pipeline([["SET", stateKey, JSON.stringify({ tokens, updatedAt }), "EX", 1800]]);
return { ok: true, remaining: Math.floor(tokens), retryAfterSec: 0 };
} catch (e) {
// Fail open: never block legitimate traffic because Redis is down.
log.warn("ratelimit.redis_unreachable", { err: String(e) });
return { ok: true, remaining: capacity, retryAfterSec: 0 };
}
}
}
// ── Singleton picker ─────────────────────────────────────────────────────────
let store: RateLimitStore | null = null;
function getStore(): RateLimitStore {
if (store) return store;
const url = process.env.REDIS_URL;
const token = process.env.REDIS_TOKEN;
if (url && token) {
log.info("ratelimit.backend", { backend: "upstash" });
store = new UpstashRedisStore(url, token);
} else {
store = new InMemoryStore();
}
return store;
} }
// ── Helpers ────────────────────────────────────────────────────────────────── // ── Helpers ──────────────────────────────────────────────────────────────────
export function getClientIp(req: Request): string { export function getClientIp(req: Request): string {
// Nginx sets x-forwarded-for; first value is the real client.
const xff = req.headers.get("x-forwarded-for"); const xff = req.headers.get("x-forwarded-for");
if (xff) return xff.split(",")[0].trim(); if (xff) return xff.split(",")[0].trim();
const real = req.headers.get("x-real-ip"); const real = req.headers.get("x-real-ip");
@@ -75,12 +149,22 @@ export function getClientIp(req: Request): string {
return "unknown"; return "unknown";
} }
interface RateLimitConfig {
capacity: number;
refillPerSec: number;
}
const CHAT_LIMIT: RateLimitConfig = { const CHAT_LIMIT: RateLimitConfig = {
capacity: 30, // Burst of 30 messages capacity: 30, // burst
refillPerSec: 0.5, // = 30/min sustained refillPerSec: 0.5, // = 30/min sustained
}; };
export function checkChatRateLimit(req: Request): RateLimitResult { export async function checkChatRateLimit(req: Request): Promise<RateLimitResult> {
const ip = getClientIp(req); const ip = getClientIp(req);
return rateLimit(`chat:${ip}`, CHAT_LIMIT); return Promise.resolve(getStore().consume(`chat:${ip}`, CHAT_LIMIT.capacity, CHAT_LIMIT.refillPerSec));
}
// Generic helper for ad-hoc limits in other routes.
export async function rateLimitAsync(key: string, capacity: number, refillPerSec: number): Promise<RateLimitResult> {
return Promise.resolve(getStore().consume(key, capacity, refillPerSec));
} }
+10 -2
View File
@@ -1,8 +1,16 @@
import { SignJWT, jwtVerify } from "jose"; import { SignJWT, jwtVerify } from "jose";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
// Usamos una variable de entorno secreta, o un fallback súper seguro para desarrollo // SESSION_SECRET is REQUIRED. No fallback: a public default would let anyone
const secretKey = process.env.SESSION_SECRET || "FLUX_SUPER_SECRET_KEY_2026_ARCHITECTURE"; // forge a 7-day admin JWT if the env var ever fails to load in production.
// Generate a strong value with: openssl rand -base64 48
const secretKey = process.env.SESSION_SECRET;
if (!secretKey || secretKey.length < 32) {
throw new Error(
"SESSION_SECRET environment variable is required (min 32 chars). " +
"Generate one with: openssl rand -base64 48"
);
}
const encodedKey = new TextEncoder().encode(secretKey); const encodedKey = new TextEncoder().encode(secretKey);
export async function createSession(userId: string, username: string) { export async function createSession(userId: string, username: string) {
+85
View File
@@ -0,0 +1,85 @@
// src/types/cms.ts
// -----------------------------------------------------------------------------
// Shared CMS types derived from Prisma models. Use these instead of `any[]`
// when passing DB-shaped data into React components. The Pick<> shapes mirror
// what the queries actually `select`, so the compiler catches drift.
// -----------------------------------------------------------------------------
import type {
Application,
GlobalNode,
NewsArticle,
SparePart,
HeroSlide,
HeritageSection,
TimelineEvent,
} from "@prisma/client";
// ── Application ─────────────────────────────────────────────────────────────
export type AppCard = Pick<
Application,
"id" | "slug" | "title" | "subtitle" | "shortDescription" | "category" | "isActive"
> & {
// Only present when the query opts in:
dashboardMetricsJson?: string | null;
translationsJson?: string | null;
};
export type AppFull = Application;
// ── GlobalNode (installations + events + HQ) ────────────────────────────────
export type NodeMarker = Pick<
GlobalNode,
"id" | "title" | "location" | "lat" | "lon" | "nodeType" | "application" | "stats" | "isActive"
> & {
mediaFileName?: string | null;
energySavings?: string | null;
eventDate?: Date | null;
translationsJson?: string | null;
};
export type NodeFull = GlobalNode;
// ── Inside Flux ─────────────────────────────────────────────────────────────
export type NewsCard = Pick<
NewsArticle,
"id" | "slug" | "title" | "excerpt" | "coverImage" | "category" | "publishedAt"
>;
export type NewsFull = NewsArticle;
// ── Parts catalog ───────────────────────────────────────────────────────────
export type PartCard = Pick<SparePart, "id" | "sku" | "title" | "description" | "price" | "showPrice" | "isActive"> & {
mediaJson?: string | null;
specsJson?: string | null;
translationsJson?: string | null;
};
// ── Hero / story / timeline ────────────────────────────────────────────────
export type HeroSlideRow = HeroSlide;
export type HeritageRow = HeritageSection;
export type TimelineRow = TimelineEvent;
// ── JSON helpers ────────────────────────────────────────────────────────────
export type GalleryImage = { url: string; alt?: string };
export type DashboardMetric = { label: string; value: string; trend?: string };
export type DatasheetRow = { label: string; value: string; unit?: string };
/**
* Safe JSON parse for `translationsJson`, `galleryJson`, etc. Returns the
* fallback when the field is null, undefined, or malformed. Never throws.
*/
export function parseJsonField<T>(raw: string | null | undefined, fallback: T): T {
if (!raw) return fallback;
try {
return JSON.parse(raw) as T;
} catch {
return fallback;
}
}
+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);