Compare commits

..

8 Commits

Author SHA1 Message Date
davidherran a81ee50ed8 feat(resilience): operational hardening (NEXT phase of the audit)
Deploy to VPS / deploy (push) Has been cancelled
Acts on the audit's NEXT block — operational resilience.

Backups (N1):
- New `backup` compose service (postgres:16-alpine) runs scripts/backup-loop.sh:
  immediate pg_dump on start, then nightly, gzip, 14-day rotation into
  ./backups on the host. Configurable via BACKUP_RETENTION_DAYS /
  BACKUP_INTERVAL_SECONDS. (Offsite copy is the documented next step.)

Resource limits + healthchecks (N2):
- deploy.resources.limits.memory on postgres (2g), app (1500m), nginx (256m),
  backup (256m) so no container can starve the others (the Nginx outage was a
  reminder).
- Nginx now has a healthcheck hitting a new self-served `/nginx-health`
  endpoint on the default_server (no upstream dependency).

Chat resilience (N3):
- buildSystemPrompt() wraps its 4 Prisma queries in try/catch with safe
  defaults — if Postgres is down the assistant degrades instead of 500-ing.
- Result is cached for 60s (only on healthy builds) so we don't run 4 queries
  per message; CMS edits still appear within the TTL.
- POST fails fast with 503 if OPENAI_API_KEY is missing (instead of breaking
  mid-stream after headers are sent).
- streamText gets an onError handler that logs + persists an `error` AiEvent.

Idempotent submissions (N4):
- consultation/route.ts and operations.ts now wrap the email-tracking UPDATE
  in try/catch — the lead/signal is already saved, so a telemetry hiccup can't
  500 the request and trigger a duplicate retry. operations.ts also returns
  emailError.

Performance (N5):
- Index GlobalNode(application, isActive) — backs the case-study join on every
  application page. Migration 20260609130000_index_globalnode_application.

Verified: next build compiles (Docker parity, SESSION_SECRET unset),
TypeScript clean, prisma schema valid, golden tests 17/17,
`docker compose config` valid.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 23:07:38 -05:00
davidherran 18d5ed87c8 fix(security+db): close the real audit findings (SEC-04/05/01, DB-01)
Deploy to VPS / deploy (push) Has been cancelled
Acts on the verified findings from the 2026-06 audit (docs/AUDIT_2026-06_
VERIFIED.md). The audit's #1 "middleware never runs" was a false positive
(verified in prod: /hq-command redirects to login). These are the genuine
gaps:

- SEC-04 (HIGH): /api/assets (GET/POST/PUT/DELETE/PATCH) and
  /api/branding/favicon (POST) had NO auth. The middleware matcher excludes
  /api, so they were world-reachable — anyone could list/upload/rename/
  delete CMS files or regenerate the favicon. Added a new getAdminSession()
  helper (src/lib/session.ts) and a requireAdmin() guard on every handler.

- DB-01 (HIGH): the ClientUser table (B2B client portal) was defined in the
  schema but NEVER created by any migration, and OperationsSignal.clientId +
  its FK were missing too. B2B register/login failed at runtime; the
  dashboard silently showed 0 clients. New additive migration
  20260609120000_add_client_user creates the table, the unique email index,
  the clientId column (IF NOT EXISTS), and the FK (duplicate-object guarded).

- SEC-05 (MED-HIGH): operations.ts generateRichEmailHtml() interpolated
  item.title/sku/quantity, clientName/Company/Email/Phone and the free-text
  message straight into HTML — stored XSS into the team's internal inbox.
  Now escaped via escapeHtml/escapeAttr/safeMailto; file links validated to
  internal paths only.

- SEC-01 (MED): removed the hardcoded SESSION_SECRET fallback in src/proxy.ts;
  it now validates lazily and throws if the secret is missing (mirrors
  session.ts), so a runtime env failure can't fall back to a public key.

Verified: next build compiles with SESSION_SECRET unset (Docker parity),
TypeScript clean, prisma schema valid, golden tests 17/17.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 22:40:20 -05:00
davidherran b76c14b780 feat(ui): friendlier wording in the contact form
Deploy to VPS / deploy (push) Has been cancelled
Match the broadened "Contact FLUX Team" CTA — the consultation form read as
engineering-only, which can deter non-technical enquiries.

- Form header: "Engineering Consultation" -> "Get in Touch"
- Subtitle now reassures "our team will get back to you"
- Submit button: "Request Consultation" -> "Send Request"
- Success title: "Consultation Requested" -> "Request Sent"
- Next-steps step 1: "Engineer reviews..." -> "Our team reviews..."

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 09:58:13 -05:00
davidherran 63a896b017 feat(ui): broaden footer CTA from "Contact FLUX Engineering" to "Contact FLUX Team"
Deploy to VPS / deploy (push) Has been cancelled
"Engineering" framed the CTA as technical-only and could deter non-technical
visitors (pricing, general questions, partnerships) from reaching out.

- Button label: "Contact FLUX Engineering" -> "Contact FLUX Team"
- AI trigger prompt broadened to invite any enquiry (energy savings, custom
  solution, pricing, availability, or just learning more), not only a
  technical consultation.
- DEFAULT_FOOTER ctaSubtitle softened to "Connect with our team to calculate
  your ROI, explore solutions, or simply ask a question." (the live footer
  can still override this from Site Settings in the HQ).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 09:06:04 -05:00
davidherran 673c32d0e1 feat(nginx): canonical-host guard + scanner-probe blocking
Deploy to VPS / deploy (push) Has been cancelled
Hardens the edge against the bot noise and IP-based access seen in the
production logs (raw-IP hits, SSRF probes to 169.254.169.254 / localhost /
metadata.google.internal, scans for /config/database.php, /.git-credentials,
wp-admin, etc.).

1. Canonical-host guard — default_server blocks on 80 and 443 that catch
   any Host that is NOT rf-flux.com/www.rf-flux.com and return 444 (drop).
   - Kills the redirect-to-raw-IP bug at the edge: IP requests never reach
     Next.js, so the middleware can't build an IP-based redirect.
   - Blocks SSRF probes and most bot scans before they touch the app.
   - ACME HTTP-01 still works (acme-challenge location kept on :80).
   - Legitimate traffic is unaffected: exact server_name beats
     default_server, so the rf-flux.com blocks always win.

2. Scanner-probe blocking — a regex location in the rf-flux.com server
   that returns 444 for .php/.env/.git/wp-admin/etc. This is a Next.js app
   so none of those are real; the patterns never match real assets
   (.jpg/.png/.webp/.mp4/.glb/.pdf) or app routes.

Apply with `nginx -t` then `nginx -s reload` — no rebuild, no downtime.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 08:29:39 -05:00
davidherran 8a98f88047 fix(ui): dark mode support on Team and Privacy pages
Deploy to VPS / deploy (push) Has been cancelled
The new public pages were built light-mode only, so toggling dark mode
(which adds .dark to <html>) left them unchanged. Add dark: variants
matching the rest of the site:

- team/page.tsx: page bg, eyebrow, title, description, empty state
- team/TeamGrid.tsx: card bg/border/shadow, portrait gradient, name,
  role, bio, social-link buttons (cyan accent on hover in dark)
- privacy/page.tsx: page bg, title, dates, the template-notice callout,
  section headings, body text, mailto links

Palette consistent with ApplicationClient/CaseStudyModal:
light #F5F5F7/#1D1D1F/#0066CC -> dark #050505/#fff/#00F0FF, secondary
#A1A1A6, cards #111.

Verified: build compiles, TypeScript clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:33:53 -05:00
davidherran 7c689e034e fix(session): validate SESSION_SECRET lazily, not at module load
Deploy to VPS / deploy (push) Has been cancelled
The module-level SESSION_SECRET check threw during `next build` (page-data
collection imports session.ts but has no runtime env vars), breaking the
Docker build at /hq-command/dashboard.

Move the check inside getEncodedKey() so it runs at runtime when a session
is actually created. Security is unchanged: the secret is still never
defaulted, and any attempt to mint a session without a valid 32+ char
secret throws loudly. It just no longer blocks the build.

Verified: `next build` now compiles with SESSION_SECRET unset (replicating
the Docker builder stage) — 56/56 pages generated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:14:05 -05:00
davidherran 3e0b286f1a fix(deploy): stop tracking public/team + public/branding
Deploy to VPS / deploy (push) Has been cancelled
These upload directories are owned by root on the VPS (the container
entrypoint chowns mounted volumes to 1001:1001), so `git reset --hard`
failed trying to create public/team/.gitkeep as the deploy user.

Treat them like the other upload dirs (applications, cases, news, parts,
operations-inbox, footage) — gitignored, created by the Docker volume
mount at runtime. Removes public/team/.gitkeep from tracking and adds
public/team/ + public/branding/ to .gitignore.

This unblocks clean `git reset --hard origin/main` on the VPS.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 16:57:07 -05:00
23 changed files with 870 additions and 89 deletions
+3
View File
@@ -50,7 +50,10 @@ public/news/
public/parts/ public/parts/
public/operations-inbox/ public/operations-inbox/
public/footage/ public/footage/
public/team/
public/branding/
# Local Claude Code / MCP config — agent-specific, not project # Local Claude Code / MCP config — agent-specific, not project
.mcp.json .mcp.json
.claude/ .claude/
backups/
+50
View File
@@ -17,6 +17,12 @@ services:
- pgdata:/var/lib/postgresql/data - pgdata:/var/lib/postgresql/data
networks: networks:
- flux-net - flux-net
# Resource caps so no single container can starve the others (the Nginx
# outage earlier was a reminder). VPS has ~11 GB; these leave headroom.
deploy:
resources:
limits:
memory: 2g
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"] test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"]
interval: 5s interval: 5s
@@ -81,6 +87,10 @@ services:
- flux-net - flux-net
expose: expose:
- "3000" - "3000"
deploy:
resources:
limits:
memory: 1500m
healthcheck: healthcheck:
test: test:
- CMD-SHELL - CMD-SHELL
@@ -114,6 +124,46 @@ services:
- app - app
networks: networks:
- flux-net - flux-net
deploy:
resources:
limits:
memory: 256m
healthcheck:
# Nginx self-health (served directly by the default_server, no upstream).
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/nginx-health || exit 1"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
# ── Automated Postgres backups ──
# Nightly pg_dump -> gzip into ./backups on the host, 14-day rotation.
# NOTE: this is LOCAL to the VPS. Offsite copy (S3/rsync) is the recommended
# next step once the client provides storage credentials.
backup:
image: postgres:16-alpine
restart: always
depends_on:
postgres:
condition: service_healthy
environment:
DB_USER: ${DB_USER}
DB_PASSWORD: ${DB_PASSWORD}
DB_NAME: ${DB_NAME}
BACKUP_DIR: /backups
RETENTION_DAYS: ${BACKUP_RETENTION_DAYS:-14}
BACKUP_INTERVAL_SECONDS: ${BACKUP_INTERVAL_SECONDS:-86400}
volumes:
- ./backups:/backups
- ./scripts/db-backup.sh:/usr/local/bin/db-backup.sh:ro
- ./scripts/backup-loop.sh:/usr/local/bin/backup-loop.sh:ro
entrypoint: ["/bin/sh", "/usr/local/bin/backup-loop.sh"]
networks:
- flux-net
deploy:
resources:
limits:
memory: 256m
volumes: volumes:
pgdata: pgdata:
+458
View File
@@ -0,0 +1,458 @@
# FLUX SRL — Audit Verification & Corrected Priorities
**Date:** 2026-06-09
**Method:** 10-dimension multi-agent audit (59 agents, adversarial verification) + manual re-verification of every critical/high finding against the running production system.
> **Why this file exists:** the automated audit (full report below) is thorough but produced one **false-positive CRITICAL** and overstated a few others. This top section is the corrected, ground-truth verdict. Trust this section over the raw report where they disagree.
---
## Manual verification results (the corrections)
### ❌ SEC-02 "Auth middleware never runs" — FALSE POSITIVE
The audit's #1 critical claimed `src/proxy.ts` is never executed because it exports `proxy` not `middleware`.
**Verified in production:** `curl https://www.rf-flux.com/hq-command/dashboard` (no cookie) → **307 redirect to `/hq-command/login`**. The middleware **is running**. Next.js 16 *does* recognize `src/proxy.ts` exporting `proxy` (the rename is real in Next 16; the code comment was correct). The empty `middleware-manifest.json` in a local build is misleading — runtime behavior disproves the claim.
**Consequence:** the HQ admin surface **is** protected by the middleware. SEC-03 (below) drops from critical to low.
### ⚠️ SEC-03 "HQ server actions lack auth" — OVERSTATED (low, not critical)
Because the middleware *does* run and its matcher covers `/hq-command/*`, POSTs to those routes (which is how server actions are invoked) are gated — no cookie → redirect before the action executes. Adding an in-action `getSession()` check is still good defense-in-depth, but it is **not** an open door.
### ✅ SEC-04 "/api/assets + /api/branding/favicon unauthenticated" — REAL (HIGH) — the actual top security issue
Verified: neither route checks a session, and the middleware matcher **excludes `/api`**, so the middleware does not protect them. Anyone can `GET/POST/PUT/DELETE` `/api/assets` — list CMS structure, upload files into public scopes, rename/delete content — and POST `/api/branding/favicon`. **This is the real #1.**
### ✅ DB-01 "ClientUser table never migrated" — REAL (HIGH), impact nuanced
Verified: the init migration creates 10 tables; **`ClientUser` is not among them**, and no later migration adds it. Code calls `prisma.clientUser` in `clientAuth.ts` (B2B register/login) and the dashboard.
**Real impact:** the **B2B client portal (register/login) is broken** at runtime. The HQ dashboard does *not* crash (its counts run inside a try/catch and silently show 0 clients), so the audit's "undeployable crash" framing was overstated — but the B2B portal genuinely doesn't work.
### ✅ SEC-05 "Operations email not HTML-escaped" — REAL (MEDIUM-HIGH)
Verified: `src/app/actions/operations.ts → generateRichEmailHtml()` interpolates `item.title`, `payload.clientName`, `clientCompany`, etc. straight into HTML with no `escapeHtml()`. Stored-XSS into the team's internal operations inbox from the CartDrawer form. Real.
### ✅ SEC-01 "Hardcoded secret fallback in proxy" — REAL but MITIGATED (MEDIUM)
`src/proxy.ts:13` still has `|| "FLUX_SUPER_SECRET_KEY_2026_ARCHITECTURE"`. Mitigated because signing (`session.ts`) throws without the env var, so tokens can't be forged in normal operation. Should still be removed (the verifier asymmetry is real).
### ✅ INFRA-03 "Secrets in git" — REAL (known)
OpenAI key + Gmail app password are in git history. Rotate; convert tracked `env``.env.example`.
---
## Corrected priority list — what actually matters
### 🔴 NOW (real, small, do first)
1. **Auth on `/api/assets` + `/api/branding/favicon`** (SEC-04) — add `getAdminSession()` guard at the top of each handler. *Effort: S*
2. **Create the `ClientUser` migration** (DB-01) — additive migration; unblocks the B2B portal. *Effort: S*
3. **Escape HTML in operations emails** (SEC-05) — reuse `escapeHtml` like consultation/route already does. *Effort: S*
4. **Remove the proxy secret fallback** (SEC-01) — throw if `SESSION_SECRET` missing, mirroring `session.ts`. *Effort: S*
5. **Whitelist chat context fields** (AI-01) — sanitize `context.section/activeTab` before injecting into the system prompt (prompt-injection hygiene). *Effort: S*
6. **Rotate OpenAI key + SMTP password; untrack `env`** (INFRA-03). *Effort: S — needs the client for the key*
### 🟡 NEXT (operational resilience — all real)
- Automated nightly **DB backups** (none today) + offsite. *M*
- **Container memory/CPU limits** + Nginx healthcheck in compose. *S*
- `buildSystemPrompt()` **try/catch + static fallback** so chat degrades instead of 500 if DB is down (TEST-02). *S*
- **Validate `OPENAI_API_KEY`** at startup + wrap `streamText` in try/catch (TEST-03). *S*
- **Idempotent consultation** submission (no orphan records on SMTP fail) (TEST-04). *M*
- **Monitoring/uptime** (UptimeRobot on `/api/health`) + log aggregation. *M*
- Index `GlobalNode.application`; cache the system prompt (PERF-01/03). *S*
### 🟢 LATER (polish/scale)
- Defense-in-depth `getSession()` inside HQ actions (SEC-03). a11y pass (focus trap, labels, skip link, reduced-motion). i18n: number formatting + translate error boundaries. SEO: image/video sitemaps, FAQ/VideoObject schema. Reduce `any`, split oversized files, adopt `log.*` everywhere. Jsonb migration for hot JSON fields. Lazy-load Three.js.
---
## What's genuinely strong (verified)
CSRF on public endpoints · magic-byte upload validation · JWT signing that throws on missing secret · **the middleware DOES protect HQ** · complete 5-locale parity · the AI translation glossary · hreflang/canonical/breadcrumb SEO · Nginx edge caching + the new canonical-host guard + scanner blocking · structured logger + safe-JSON helpers + escapeHtml exist (gap is adoption consistency) · clean idempotent migrations · bcrypt password hashing · well-designed 9-tool SPIN FluxAI.
---
## Dimension scorecard (audit, with my adjustments)
| Dimension | Audit | Adjusted | Note |
|---|---|---|---|
| Security | 3.5 | **6.5** | SEC-02 false positive removed; middleware protects HQ. Real gaps: SEC-04/05/01. |
| Performance | 6.0 | 6.0 | Solid baseline; prompt rebuilt per message. |
| Code quality | 5.5 | 5.5 | `any` usage, logging inconsistency. |
| Database | 4.5 | **5.5** | ClientUser migration missing (B2B), but not a dashboard crash. |
| FluxAI | 6.0 | 6.0 | Strong tools; add prompt-injection guard + history. |
| SEO | 6.5 | 6.5 | Good base; add media sitemaps + FAQ/Video schema. |
| i18n | 6.0 | 6.5 | Excellent infra; few hardcoded strings. |
| Infra | 4.0 | 4.5 | No backups/monitoring; secrets in git. The real weak spot. |
| Accessibility/UX | 4.5 | 5.0 | a11y gaps; dark mode now covered. |
| Testing/reliability | 3.0 | 3.5 | One suite; add API integration tests + degradation. |
**Adjusted overall: ~6.0/10** (audit said 4.8 — the false-positive critical dragged it down). The build is solid; the genuine priorities are a handful of small auth/data fixes plus operational resilience (backups + monitoring).
---
---
# Appendix — Full automated audit report (raw, uncorrected)
> The section below is the unedited 10-dimension report. Where it conflicts with the verification above (notably SEC-02), the verification above is authoritative.
# FLUX SRL — Consolidated Audit Report
**Project:** rf-flux.com (Flux SRL) — Next.js 16 · Prisma 7 · PostgreSQL · AI SDK 6 · 5 locales · Docker + Nginx on OVH
**Audit scope:** 10 dimensions, adversarially verified (each critical/high finding independently re-checked against source)
**Date:** 2026-06-09
**Auditor:** Lead consolidation pass
---
## 1. Executive Summary
Flux SRL is a capably-built marketing + B2B platform with genuinely good bones — multi-stage Docker, CSRF on public endpoints, magic-byte upload validation, a structured logger, a working AI translation glossary, complete 5-locale message parity, and a recently-hardened Nginx (canonical-host guard, scanner blocking). But the audit surfaced a **cluster of authorization failures that, taken together, mean the entire HQ admin surface is currently unprotected**, plus operational gaps (no backups, no monitoring, secrets in git) that make this risky to run in production as-is.
The single most important finding: the auth middleware **does not execute at all** because the file exports a function named `proxy` instead of `middleware`. Every downstream auth assumption collapses from that one fact — and because the HQ server actions and the `/api/assets` + `/api/branding` routes have *no* internal session checks, they are reachable by anyone.
### Overall health: **4.8 / 10** (weighted toward the security + infra blockers)
### Dimension scorecard
| Dimension | Score | One-line verdict |
|---|---|---|
| Security | 3.5 | Middleware never runs; HQ actions + asset APIs fully unauthenticated. |
| Performance | 6.0 | Solid caching baseline; chat rebuilds prompt + 4 DB queries every message. |
| Code quality | 5.5 | 187 `any` usages, inconsistent logging, empty catch blocks; good helpers exist but under-used. |
| Database | 4.5 | `ClientUser` table referenced in code but never migrated — runtime crash risk. |
| FluxAI | 6.0 | Strong tool architecture; prompt injection via context, no history, plaintext message storage. |
| SEO | 6.5 | Good metadata + JSON-LD; no image/video sitemaps for a visual industrial site. |
| i18n | 6.0 | Excellent infra and parity; a few components ship hardcoded English. |
| Infra | 4.0 | No resource limits, no backups, no monitoring, secrets in tracked `env` file. |
| Accessibility/UX | 4.5 | No skip link, no Escape-to-close, no reduced-motion, broken Tailwind class. |
| Testing/reliability | 3.0 | One unit-test file; zero integration tests; multiple silent-fail paths. |
### Top 5 things to fix first
1. **Make the middleware actually run** (SEC-02). Rename/export `proxy` as `middleware` in `src/middleware.ts`. Until this lands, *nothing else in the auth layer matters* — the page-level guards are dead code.
2. **Add session checks inside HQ server actions and the asset/branding APIs** (SEC-03, SEC-04). Defense-in-depth: even with middleware fixed, these must self-verify. They are directly POST-able today.
3. **Rotate the leaked credentials and stop tracking `env`** (INFRA-03). The OpenAI key and Gmail app password are in git history. Rotate now; they're compromised regardless of what you do next.
4. **Create the missing `ClientUser` migration** (DB-01). Code calls `prisma.clientUser` in the dashboard and B2B auth; the table was never created. This is an active crash, not a hypothetical.
5. **Stand up backups + container resource limits** (INFRA-05, INFRA-01). No automated DB backup and no memory caps on an OVH single-VPS is a "lose everything on one bad day" setup.
---
## 2. Critical & High Findings (confirmed / partially-confirmed only)
> Severities below use the **adjusted** verdict from verification. Refuted findings are excluded (see §6).
### Security
**SEC-02 — Auth middleware never executes (CRITICAL)**
`src/proxy.ts:17` — The file exports `async function proxy(...)` plus `config`, but Next.js 16 only invokes a file named `middleware.ts` exporting `middleware`. The `.next/server/middleware-manifest.json` is empty, confirming it was never compiled. A code comment even documents the rename intentionally. **Impact:** every `/hq-command/*` route has zero middleware protection; direct navigation and prefetch hit the dashboard with no auth gate. **Fix:** create `src/middleware.ts` with `export { proxy as middleware }` and re-export `config`; verify the matcher covers `/hq-command`. Then redeploy and confirm the manifest is populated.
**SEC-03 — HQ server actions lack session verification (CRITICAL)**
`src/app/hq-command/dashboard/inbox/actions.ts:11-188``getSignals()`, `getClients()`, `approveAccessRequest()`, `updateSignalStatus()`, `resolveAndCleanSignal()`, `deleteSignal()`, `resendSignalEmail()`, `deleteClient()` run raw Prisma mutations with no `getSession()` call. Marked `"use server"` but self-unauthenticated. **Impact:** these are invokable directly via POST (curl/fetch) — approve/delete B2B clients, mutate signals, trigger emails, all without a token. **Fix:** add a `requireAdminSession()` helper and call it at the top of every action: `const s = await getAdminSession(); if (!s) throw new Error('Unauthorized');`. Don't rely on the middleware alone.
**SEC-04 — `/api/assets` and `/api/branding/favicon` are unauthenticated (CRITICAL, raised from high)**
`src/app/api/assets/route.ts`, `src/app/api/branding/favicon/route.ts` — All methods (GET/POST/PUT/DELETE/PATCH) on `/api/assets` and POST on the favicon route have no session check. Path sanitization prevents traversal but not auth. **Impact:** anyone can enumerate CMS structure, upload SVG-with-JS into public scopes, delete branding/content, or regenerate favicons → defacement/XSS/downtime. **Fix:** at the top of each handler, `const s = await getAdminSession(); if (!s) return NextResponse.json({error:'Unauthorized'},{status:401});`.
**SEC-01 — Hardcoded session-secret fallback in proxy (HIGH, lowered from critical)**
`src/proxy.ts:13``process.env.SESSION_SECRET || "FLUX_SUPER_SECRET_KEY_2026_ARCHITECTURE"`, used for JWT *verification* at line 35. Verifier confirmed it's mitigated in practice: `session.ts`/`clientAuth.ts` *throw* if the secret is unset at signing time, so tokens can't be forged under normal operation. The danger is the asymmetry — if env loading ever fails at runtime, the verifier would accept tokens signed with the public string. **Fix:** remove the fallback; throw loudly if `SESSION_SECRET` is missing, mirroring `session.ts`.
**SEC-05 — Missing HTML escaping in operations email templates (HIGH)**
`src/app/actions/operations.ts:119-156``generateRichEmailHtml()` interpolates `item.title`, `item.sku`, `payload.clientName`, `clientCompany`, `clientEmail`, `clientPhone`, `message` straight into HTML. `escapeHtml()` exists in `src/lib/escapeHtml.ts` but isn't imported here. **Impact:** HTML/script injection into internal operations emails from the CartDrawer form. **Fix:** escape every user field before interpolation, matching the pattern already correct in `src/app/api/consultation/route.ts`.
### Database
**DB-01 — `ClientUser` table referenced but never migrated (CRITICAL)**
`prisma/schema.prisma:370-387` defines `ClientUser` and the `OperationsSignal.clientId` relation (lines 226-227), but **no migration creates the table** — not the init migration nor any of the 5 later ones. Code actively calls it: `src/app/hq-command/dashboard/page.tsx:67-68` (`prisma.clientUser.count()`), `src/app/actions/clientAuth.ts:25,30,50+` (findUnique/create/update). **Impact:** immediate runtime failure on any B2B auth or dashboard-count path; undeployable. **Fix:** generate a migration creating `ClientUser` (id, email unique, passwordHash, fullName, companyName, phone?, isApproved default false, lastLoginAt?, createdAt, updatedAt) and the FK from `OperationsSignal.clientId`. Run `prisma migrate dev` and verify against a fresh DB.
**DB-04 — JSON stored as `String` instead of `Jsonb` (HIGH)**
`prisma/schema.prisma` — 24+ JSON fields across 12 models (`translationsJson`, `galleryJson`, `sectionsJson`, `payloadJson`, etc.) are `String`. **Impact:** no DB-level JSON queries/indexing/validation; all parse/serialize is manual (and is the root cause of several CQ-03/CQ-04 parse-error findings). **Fix:** migrate query-hot fields first (`payloadJson` on AiEvent, `galleryJson`/`sectionsJson` on Application) to `Jsonb` via add-column → backfill → drop → rename.
**DB-05 — AI telemetry grows unbounded; no retention policy (HIGH)**
`prisma/schema.prisma:322-365``AiConversation`/`AiEvent` persist every chat indefinitely. No TTL, cleanup job, or purge anywhere in `src/`. Stores hashed IP + userAgent + full message text → GDPR Art. 5(1)(e) storage-limitation exposure. **Fix:** add a `retentionDays` setting (default 90), a daily cleanup deleting rows older than the window, and document it. Consider archival before delete.
**DB-03 — `GlobalNode.application` has no referential integrity (MEDIUM, lowered from high)**
`prisma/schema.prisma:40` — plain `String`, validated only as non-empty in `network/actions.ts:34`. Verifier downgraded: orphaned nodes persist and are simply excluded from filtered queries (deterministic, not data loss); UI dropdown prevents most bad input. **Fix:** validate `Application.findUnique({where:{slug}})` exists before create, or migrate to an `applicationId` FK.
**DB-02 — `PageContent` missing `createdAt` (MEDIUM, lowered from high)**
`prisma/schema.prisma:266` — has `updatedAt` only; every other model has both (except `SiteSetting`). **Impact:** no creation audit trail for page content. **Fix:** add `createdAt DateTime @default(now())`.
### Infrastructure
**INFRA-01 — No container resource limits (CRITICAL)**
`docker-compose.yml:41-116` — neither `app` (with `restart: always`) nor `nginx` nor postgres define memory/CPU limits. **Impact:** a runaway/leaking process consumes all VPS RAM and OOM-kills siblings → documented downtime. **Fix:** `app` mem_limit 1G (reservation 512M), nginx 256M/128M, cpus 1.0 each; load-test.
**INFRA-03 — Secrets committed in tracked `env` file (CRITICAL)**
`env:29-40` — real `OPENAI_API_KEY` (sk-proj-…), `SMTP_USER`, and a 16-char Gmail app password, tracked since first commit `fc24313`. `.gitignore` pattern `.env*` doesn't match the bare `env` filename. **Fix (in order):** (1) **rotate the OpenAI key and Gmail app password now** — they're already compromised; (2) `git rm --cached env`, add `env` to `.gitignore`; (3) commit `.env.example` with placeholders; (4) move to GitHub Secrets / a secrets manager. History scrub is optional but the rotation is not.
**INFRA-05 — No automated database backups (CRITICAL)**
`src/app/hq-command/dashboard/health/actions.ts:31-52` — backup is a manual HTTP export of unencrypted JSON covering only 10 of 16 tables (omits HeroSlide, SiteSetting, AiConversation, AiEvent, ClientUser, TeamMember). No cron, no offsite, no retention, no WAL/PITR. **Impact:** total loss if the PG volume corrupts. **Fix:** nightly `pg_dump | gzip` to offsite (S3, encrypted), 30-day retention; enable WAL archiving for PITR; document + test RTO/RPO monthly.
**INFRA-02 — Nginx has no healthcheck; unconditional `depends_on` (HIGH, lowered from critical)**
`docker-compose.yml:93-116` — nginx lacks a healthcheck and depends on `app` with no `condition`, while `app` correctly uses `condition: service_healthy` for postgres. Verifier softened the "deadlock" claim: it's a startup race, not a hang — nginx proxies to the unready app and returns 502/503 during the ~40s start window. **Fix:** add an nginx healthcheck (tcp:80) and `depends_on: app: condition: service_healthy`.
**INFRA-04 — Deploy script has no error handling or rollback (HIGH, lowered from critical)**
`.github/workflows/deploy.yml:28-51` — the health check at line 49 uses `curl -sf … || echo`, swallowing failure so `script_stop` is bypassed; migrations (line 42) run post-live with no error handling; no snapshot, no rollback. **Impact:** a failed migration or post-deploy crash leaves prod broken with manual-only recovery. **Fix:** make the health check fail the deploy (`curl -sf … || exit 1`, retried 3× with backoff); take a pre-deploy snapshot; restore on failure.
**INFRA-06 — No monitoring, alerting, or log aggregation (HIGH, lowered from critical)**
`docker-compose.yml` — structured logger + `/api/health` exist, but nothing ships metrics/logs to any platform (no Prometheus/Grafana/Sentry/Loki). **Impact:** blind operations; degradation invisible until users complain. **Fix:** expose `/metrics` (prom-client), ship structured logs to Loki/CloudWatch, alert on mem >80% / error-rate >1% / p99 >500ms.
**INFRA-07 — Missing OCSP stapling (HIGH)**
`nginx/conf.d/flux.conf:68-82` — no `ssl_stapling`. **Impact:** slower TLS handshakes; visitor IPs leak to Let's Encrypt. **Fix:** add `ssl_stapling on; ssl_stapling_verify on; ssl_trusted_certificate …/chain.pem; resolver 1.1.1.1 8.8.8.8 valid=300s;`.
**INFRA-11 — CSP allows `unsafe-inline` + `unsafe-eval` (MEDIUM, lowered from high)**
`nginx/conf.d/flux.conf:87` — present in `script-src` for Next.js hydration. Verifier noted strong compensating controls (Zod validation, `escapeHtml`, magic-byte checks, minimal `dangerouslySetInnerHTML`), so it's real technical debt rather than an active hole. **Fix:** move to nonce-based CSP; run `Report-Only` first.
**INFRA-08 — Nginx cache-poisoning surface via hidden `Set-Cookie` (MEDIUM, lowered from high)**
`nginx/conf.d/flux.conf:282-286``proxy_ignore_headers`/`proxy_hide_header Set-Cookie` on public pages. Verifier refuted the "session cookies lost" and "sensitive header disclosure" parts (authenticated requests bypass cache via `$cookie_flux_session`); the residual risk is poisoning public pages with a non-sensitive injected cookie. **Fix:** refine cache-bypass logic rather than blanket-hiding `Set-Cookie`.
**INFRA-09 — Large `client_max_body_size` without explicit body timeout (MEDIUM, lowered from high)**
`nginx/conf.d/flux.conf:71,135,148` — 500M limit; global block lacks `client_body_timeout`. Verifier softened: nginx's 60s default applies and upload endpoints set proxy timeouts. **Fix:** add `client_body_timeout 60s;` to the http block; reduce 500M where not genuinely needed.
**INFRA-10 — No logrotate (MEDIUM, lowered from high)**
`nginx/nginx.conf:39` — logs unbounded, but verifier noted `/var/log/nginx` isn't a mounted volume, so logs live in ephemeral container storage (purged on restart). Residual risk: log loss on restart and container-layer growth on long uptimes. **Fix:** add logrotate (daily, compress, keep 7, SIGHUP).
### Performance
**PERF-01 — System prompt rebuilt with 4 DB queries on every chat request (HIGH)**
`src/app/api/chat/route.ts:42-281``buildSystemPrompt()` runs `application.findMany` + 2× `globalNode.count` + `sparePart.count` (Promise.all) on every POST, with no caching layer (the `promptCacheKey` is a no-op). **Impact:** 4 round-trips per message; 400+/min at 100 concurrent users. **Fix:** cache the built prompt in-memory with a 3060s TTL, invalidated on CMS write.
**PERF-03 — Missing index on `GlobalNode.application` (HIGH)**
`prisma/schema.prisma` — queries filter on `(application, isActive)`, `(nodeType, isActive, application)`, and `(application, nodeType, isActive)` (applications page 136-142; chat 503, 621) but there's no `application` index. **Impact:** full table scans on the synchronous FluxAI search path. **Fix:** add `@@index([application])`, `@@index([application, isActive])`, `@@index([application, nodeType])`; migrate.
**PERF-02 — Synchronous `fs.readdirSync` in SSR paths (MEDIUM, lowered from high)**
`src/app/[locale]/page.tsx:102`, `src/app/[locale]/applications/[slug]/page.tsx:32` — real blocking calls, but verifier downgraded: they're fallback paths inside try/catch on small dirs, hit during 60s ISR regen, not per-request. **Fix:** switch to `fs.promises.readdir`, or remove the fallback once HeroSlide/CMS migration is complete.
### FluxAI
**AI-01 — Prompt injection via unvalidated context fields (HIGH)**
`src/app/api/chat/route.ts:215-216``context.section` and `context.activeTab` come from `req.json()` (TS types only, no runtime validation) and are interpolated into `contextNote`, concatenated to the system prompt at line 287. A manipulated frontend store can inject instructions overriding FluxAI's personality/tool limits. **Fix:** whitelist `context.section`/`activeTab` against the known section set; reject anything else (Zod).
**AI-03 — User message text stored unencrypted (HIGH)**
`src/app/api/chat/route.ts:268` — full user text (≤8000 chars) persisted as plaintext in `AiEvent.payloadJson` (`String`). Customer questions about volumes/processes sit in cleartext. **Impact:** breach exposes competitive intel; GDPR Art. 32 (encryption at rest). **Fix:** encrypt sensitive payloads (pgcrypto/KMS) or store only industry label + tool names; pair with the DB-05 retention policy.
**AI-02 — No conversation history / resume (HIGH)**
`src/components/ai/SilentObserver.tsx:76-78``useChat` initializes fresh with no `initialMessages`; backend persists everything but the UI never fetches it. **Impact:** multi-turn B2B sales context lost on refresh, undermining the funnel-tracking investment. **Fix:** add `/api/chat/history?sessionId`, hydrate `initialMessages` on mount, offer "continue previous conversation?".
**AI-06 — Telemetry write errors silently swallowed (HIGH)**
`src/app/api/chat/route.ts:272-274, 370-371` — telemetry writes are wrapped in try/catch with only `log.warn()`; the `onFinish` writes can fail after the response streamed, losing conversation records. **Impact:** undercounted funnel analytics. **Fix:** circuit-breaker + alert on >5 failures/hour; buffer-and-retry unsent events.
### SEO
**SEO-02 — No image or video sitemaps (HIGH)**
`src/app/sitemap.ts:1-106` — only page URLs. Rich media (news `coverImage`/`galleryJson`, application galleries, heritage `mediaUrl` videos) is invisible to Google Image/Video Search; `robots.ts` declares only `/sitemap.xml`. **Fix:** add `sitemap-images.xml` (`<image:image>` per article cover/gallery + app hero) and `sitemap-videos.xml` (heritage videos with title/description/duration); declare both in robots.
### i18n
**I18N-01 — Hardcoded English in EnergySavingsCalculator (HIGH)**
`src/components/ai/EnergySavingsCalculator.tsx:61,118,146-149,156,164-178` — 13+ literal strings ("Annual Savings", "CO2 Reduced", "Payback", "Request Detailed Engineering Study", …); component never imports `useTranslations`, and no `EnergySavingsCalculator` namespace exists in any of the 5 locale files. **Impact:** IT/ES/DE/VEC users see English in a customer-facing calculator. **Fix:** add the namespace to `en.json`, translate to all 4 locales, wire `useTranslations("EnergySavingsCalculator")`.
**I18N-02 — Hardcoded alert in CartDrawer (HIGH)**
`src/components/layout/CartDrawer.tsx:43``alert("You must accept the privacy policy.")` bypasses the otherwise-used translation system; a modal blocker shown in English to all locales. **Fix:** move to a translated key and prefer the Toast component over native `alert()`.
### Accessibility / UX
**A11Y-02 — Broken Tailwind class breaks nav styling (HIGH)**
`src/components/layout/NavBar.tsx:190``"text-[#86868B hover:text-[#1D1D1F]"` is missing a `]`. **Impact:** the color class doesn't apply to inactive nav links in light mode. **Fix:** `"text-[#86868B] hover:text-[#1D1D1F]"`.
**A11Y-03 — Icon-only buttons missing `aria-label` (HIGH)**
`src/components/layout/NavBar.tsx:229,278,294` — theme toggle, cart, mobile-menu announce only "button". **Fix:** add descriptive `aria-label`s (e.g., `aria-label={isDark ? 'Switch to light mode' : 'Switch to dark mode'}`).
**A11Y-04 — Modal can't be closed with Escape (HIGH)**
`src/components/ui/CaseStudyModal.tsx:232-237` — no keydown handler; only a click-able close button (WCAG 2.1.1). **Fix:** `useEffect` keydown listener → `if (e.key==='Escape') onClose()`.
**A11Y-05 — No skip-to-main-content link (HIGH)**
`src/app/[locale]/layout.tsx:145-204` — ~13 interactive elements before main; main div has no `id` and isn't a `<main>` (WCAG 2.4.1). **Fix:** add `<a href="#main-content" class="sr-only focus:not-sr-only">Skip to main content</a>` and an `id`/`<main>` target.
**A11Y-08 — No `prefers-reduced-motion` handling (HIGH)**
`src/app/globals.css` — 260+ framer-motion animations, zero reduced-motion checks. **Impact:** vestibular-disorder risk. **Fix:** add `@media (prefers-reduced-motion: reduce){*{animation:none!important;transition:none!important}}` and gate heavy motion via `useReducedMotion()`.
**A11Y-09 — Hero videos lack captions (MEDIUM, lowered from high)**
`src/components/sections/HeroReel.tsx:71-83` — no `<track>`; verifier noted videos are muted/decorative with overlaid text, softening impact (WCAG 1.2.2). **Fix:** add captions where videos carry meaning, or mark decorative explicitly.
**A11Y-01 — Empty `alt` on cart product images (MEDIUM, lowered from high)**
`src/components/layout/CartDrawer.tsx:148``alt=""`; verifier noted adjacent visible title+SKU text mitigates. **Fix:** `alt={item.title}` for defense-in-depth.
### Code quality
**CQ-01 — 187 `any` usages across 48 files (HIGH)**
Concentrated in `ApplicationClient.tsx` (1208 lines), `chat/route.ts` (920 lines), `SilentObserver.tsx` (22 instances), despite `strict: true`. **Fix:** use existing `cms.ts` types (`AppFull`, `NodeFull`); add a `model-viewer.d.ts` for the web-component casts; chip away by file.
**CQ-02 — Inconsistent logging: 50 `console.error` vs 9 `log.error` (HIGH)**
A structured JSON logger exists (`src/lib/logger.ts`) but is bypassed in `sitemap.ts:75,102`, `heritage/page.tsx:210`, `mailer.ts:96`, `imageOptimizer.ts:117`, etc. **Impact:** unstructured output breaks Loki/CloudWatch parsing. **Fix:** replace `console.*` with `log.*` in server code; add a lint rule / pre-commit hook.
**CQ-04 — Empty catch blocks suppress JSON parse errors (HIGH)**
`ApplicationClient.tsx:618,826-829`, `news/[slug]/page.tsx:259`, `parts/page.tsx:55` — silent `catch(e){}` on dimensions/media/specs parsing. **Fix:** at minimum `catch(e){ log.warn('parse_failed', e) }`.
**CQ-03 — Unguarded `JSON.parse` for sections/advantages (MEDIUM, lowered from high)**
`ApplicationClient.tsx:1026-1027``data.sectionsJson`/`advantagesJson` parsed with no try/catch (verifier confirmed *this* pair; refuted the claims about lines 600/609/CaseStudyModal which do have guards). **Fix:** use the existing `parseJsonField` helper from `cms.ts`.
**CQ-07 — `any`-typed Prisma access in chat (MEDIUM, lowered from high)**
`chat/route.ts:413-414``(app: any)` access with inconsistent null-handling; verifier refuted the line-450 "undefined .score" claim (it's always initialized). Real issue is type-safety, not a live crash. **Fix:** use Prisma `select` with explicit types instead of `any`.
### Testing / reliability
**TEST-01 — No integration/e2e tests for critical API routes (HIGH)**
Only `tests/ai/golden.test.mjs` (17 unit tests). Zero coverage of `/api/consultation`, `/api/chat`, `/api/health`, `/api/public-upload`. **Fix:** add integration tests (Node test runner) with mocked Prisma/nodemailer; aim >80% on `src/app/api`.
**TEST-02 — `buildSystemPrompt()` has no DB-failure handling (HIGH)**
`chat/route.ts:42-72` — 4 parallel Prisma queries, no try/catch; called at line 281 before `streamText()`. If Postgres is down, the whole chat 500s instead of degrading. Asymmetric with the telemetry try/catch. **Fix:** wrap in try/catch → fall back to a static `DEFAULT_SYSTEM_PROMPT` (keeps personality/tools, omits dynamic counts).
**TEST-03 — OpenAI key never validated at startup or in health check (HIGH)**
`chat/route.ts`, `health/route.ts``openai('gpt-4o')` is called with no key validation; `/api/health` only does `SELECT 1`. **Impact:** invalid/missing key surfaces mid-stream after headers are sent. **Fix:** validate `OPENAI_API_KEY` at startup; wrap `streamText()` in try/catch returning a structured error (with `retryAfterSec` on 429).
**TEST-04 — Consultation email isn't idempotent; orphaned records on SMTP failure (HIGH)**
`api/consultation/route.ts:80-155` — signal created (line 105) before email send (line 141); on SMTP failure the client gets a 500 with no `ticketId`, retries duplicate, and the fallback route isn't re-attempted. **Fix:** add an idempotency key, return 202 with `ticketId` + `emailError` on send failure, and add a background retry with backoff.
---
## 3. Medium / Low / Info
**Security**
- SEC-06 (med) — `operations.ts:129-132`: file links built from unvalidated `fileUrl` → validate it starts with `/`.
- SEC-07 (low, downgraded) — `parts/page.tsx`: client-side gate not SSR redirect; data isn't leaked → optional `redirect()` for cleanliness.
- SEC-08 (med) — HQ actions have no rate limiting → rate-limit by admin id + action.
- SEC-09 (low) — `rateLimit.ts:144`: trusts `X-Forwarded-For` unvalidated → validate single-IP / trust only Nginx.
- SEC-10 (low) — `chat/route.ts:225`: `SESSION_SECRET` reused as telemetry HMAC salt → use a separate `VISITOR_HASH_SECRET`.
**Database**
- DB-06 (med) — `schema.prisma:230`: `AiConversation→OperationsSignal` no `onDelete` → orphans; document or cascade.
- DB-07 (med) — add `@@index([type, status, createdAt(sort: Desc)])` on OperationsSignal.
- DB-08 (low) — Application.order non-unique → validate uniqueness per `isActive` group.
- DB-09 (low) — NewsArticle.publishedAt has no future-date guard → filter `publishedAt <= now()`.
**Performance**
- PERF-04 (med) — `chat/route.ts:519`: JS keyword filter post-query → use Prisma `contains`/`mode:'insensitive'`.
- PERF-05 (med) — lazy-load `GlobalOperations` (Three.js ~300KB) via `dynamic({ssr:false})`.
- PERF-06 (med) — 48 `use client` components; push pure-display ones back to server components.
- PERF-07 / INFRA-12 (med) — Prisma pool `max=10` hardcoded → make `DB_POOL_MAX` env-configurable (2030 prod).
- PERF-08/10 (low) — ISR `revalidate=60` × 40 renders/min → consider 300600s or on-demand revalidation.
- PERF-09 (med) — large Docker build context → audit with `--progress=plain`, exclude heavy media.
- INFRA-13 (med) — Nginx upstream `keepalive=32` → raise to 128256.
**FluxAI**
- AI-04 (med) — no golden eval for tool selection → build 2030 query eval set, run monthly.
- AI-05 (low) — `stepCountIs(5)` may truncate → bump to 78, monitor traces.
- AI-07 (low) — no per-tool cost tracking → log `(conversationId, toolName, tokensIn/Out)`.
- AI-08 (med) — `SilentObserver.tsx:232,237`: tool failures silently omitted → render "no results" card.
- AI-09 (low) — duplicate of PERF-01 (prompt caching).
- AI-10 (med) — `useChat` has no `onError` → add error state + Retry button.
**SEO**
- SEO-01 (med) — OG images lack width/height/type → add `{width:1200,height:630,type:'image/jpeg'}`.
- SEO-03 (med) — no FAQPage schema → add `faqPageSchema()`.
- SEO-04 (med) — no VideoObject schema on heritage videos → add `videoObjectSchema()`.
- SEO-05/06 (low) — Product schema lacks AggregateRating/Offer; Article lacks keywords/commentCount.
- SEO-07 (med) — markdown parser allows empty `alt` → fallback `alt || 'Article image'` + CMS validation.
- SEO-08 (low) — add breadcrumb schema to home/team/heritage/news-hub.
- SEO-09 (low) — LocalBusiness hours lack timezone → add `Europe/Rome`.
- SEO-10 (med) — GlobalNode case studies lack structured data → add `caseStudySchema()`.
- SEO-11 (low) — add Twitter `creator`/`site` handles.
- SEO-12 (info) — no Core Web Vitals monitoring → add `web-vitals` + dashboard.
**i18n**
- I18N-03 (med) — error boundaries hardcoded English → translate `[locale]/error.tsx` at minimum.
- I18N-04 (med) — `formatNumber()` calls `toLocaleString()` without locale → pass active locale / `Intl.NumberFormat`.
- I18N-05 (med) — register `EnergySavingsCalculator` namespace in all 5 locale files (pairs with I18N-01).
- I18N-08 (low) — `getLocalizedData` fallback is English-only → optional locale-chain fallback.
**Accessibility / UX**
- A11Y-06 (med) — `ConsultationScheduler.tsx:394`: errors lack `aria-live`/`role=alert`.
- A11Y-07 (med) — form inputs lack `<label htmlFor>` associations.
- A11Y-10 (med) — 71 buttons default to `type=submit` → add `type=button` to non-submit buttons.
- A11Y-11 (med) — AI chat modal has no focus trap.
- A11Y-12 (med) — language dropdown lacks arrow-key navigation.
- A11Y-13 (med) — opacity-reduced nav text may fail 4.5:1 contrast → test + use solid colors.
- A11Y-14 (med) — success message lacks `role=status`/`aria-live`.
- A11Y-15 (low) — verify dark-mode coverage on newer pages + HQ panel.
**Code quality**
- CQ-05 (med) — oversized files (ApplicationClient 1208, chat/route 920, AssetBucketBrowser 874) → extract sub-components/tools.
- CQ-06 (med) — model-viewer `as any` casts → proper `.d.ts` type.
- CQ-08 (med) — inconsistent error-handling pattern across layers → standardize log-then-return/throw.
- CQ-09 (low) — `privacy/page.tsx:12`: hardcoded `privacy@rf-flux.com` (TODO unconfirmed) → env/SiteSettings + confirm with FLUX legal.
- CQ-10 (low) — repeated `as unknown as` casts on AI SDK responses → define interfaces.
- CQ-11 (low) — `i18nHelper.ts:27`: `console.error``log.error`.
**Infra / testing**
- INFRA-14 (med) — backup/restore lacks HMAC integrity → sign exports, verify on restore.
- INFRA-15 (med) — deploy doesn't verify commit signatures → `git verify-commit HEAD`.
- TEST-05 (med) — no i18n parity/fallback tests.
- TEST-06 (med) — error boundaries log to console, not the structured logger.
- TEST-07 (med) — server-component DB errors bubble ungracefully → per-query try/catch + degraded UI.
- TEST-08 (med) — no retry/backoff for transient email/Prisma/OpenAI failures.
- TEST-09 (med) — in-memory rate limit multiplies across replicas → require Redis for multi-instance.
- TEST-10 (med) — health check doesn't verify migrations/SMTP/OpenAI/env.
- TEST-11/12/13 (low) — telemetry truncation unlogged; no polyglot-file upload tests; `restoreDatabase()` partial-failure handling.
---
## 4. What's Already Strong
Credit where due — several things are done well and should be preserved:
- **Public-endpoint security baseline.** CSRF is correctly implemented on public endpoints, rate limiting covers chat, file uploads use magic-byte validation, and the consultation route already escapes HTML correctly (the model SEC-05 should copy). Recent commits added a **canonical-host guard and scanner-probe blocking** in Nginx — good hardening.
- **JWT signing discipline.** `session.ts`/`clientAuth.ts` correctly *throw* when `SESSION_SECRET` is missing rather than falling back — this is what saved SEC-01 from being critical.
- **i18n infrastructure.** Complete top-level key parity across all 5 locales (18 sections), correct next-intl integration, preserved `{count}`/`{app}` placeholders, and a well-built **AI translation glossary** that masks/restores protected brand/technical terms (FLUX, Radio Frequency, solid-state, RF, MHz/kHz/GHz, kW/kWh/MW). Pluralization (`componentFound`/`componentsFound`) is handled correctly.
- **SEO foundations.** Correct hreflang alternates, canonical URLs, breadcrumb JSON-LD on detail pages, and core schemas (Organization, LocalBusiness, WebSite, Article, Product). robots.txt disallow rules are correct; sitemap covers all public routes.
- **Caching & DB pooling.** Nginx edge caching, ISR, image optimization, and a real Prisma connection pool are all in place — a solid performance baseline.
- **Architecture awareness.** A structured JSON logger, safe-JSON helpers (`parseJsonField`), `escapeHtml`, and proper `cms.ts` types all exist — the gaps are *adoption consistency*, not missing infrastructure. bcrypt is used for password hashing; migrations are clean and idempotent (`IF NOT EXISTS` guards). FluxAI's 9-tool SPIN-funnel architecture is well-designed. Semantic HTML (`main`/`nav`/`header`/`footer`) and dark-mode coverage are largely in place.
---
## 5. Prioritized Remediation Roadmap
### NOW — blockers; do before any further production traffic
| Item | Finding | Effort |
|---|---|---|
| Rename/export middleware so auth actually runs | SEC-02 | **S** |
| Add session checks inside HQ server actions | SEC-03 | **M** |
| Add auth to `/api/assets` + `/api/branding` | SEC-04 | **S** |
| Rotate leaked OpenAI key + Gmail password; untrack `env` | INFRA-03 | **S** |
| Create the missing `ClientUser` migration | DB-01 | **S** |
| Escape HTML in operations email templates | SEC-05 | **S** |
| Whitelist chat context fields (prompt injection) | AI-01 | **S** |
| Remove the proxy session-secret fallback | SEC-01 | **S** |
### NEXT — operational resilience + data integrity
| Item | Finding | Effort |
|---|---|---|
| Automated nightly offsite encrypted backups + WAL/PITR | INFRA-05 | **M** |
| Container memory/CPU limits | INFRA-01 | **S** |
| Nginx healthcheck + `condition: service_healthy` | INFRA-02 | **S** |
| Deploy: failing health check + snapshot/rollback | INFRA-04 | **M** |
| AI telemetry retention policy + cleanup job | DB-05 | **M** |
| Encrypt / minimize stored chat message text | AI-03 | **M** |
| `buildSystemPrompt()` try/catch + fallback prompt | TEST-02 | **S** |
| Validate OpenAI key at startup + wrap `streamText` | TEST-03 | **S** |
| Idempotent consultation submission | TEST-04 | **M** |
| Cache system prompt (TTL) | PERF-01 | **S** |
| Add `GlobalNode.application` indexes | PERF-03 | **S** |
| Integration tests for the 4 critical API routes | TEST-01 | **L** |
| Monitoring + log aggregation + alerts | INFRA-06 | **M** |
| Fix `aria-label`s, skip link, Escape-to-close, reduced-motion, broken nav class | A11Y-02/03/04/05/08 | **M** |
| i18n: EnergySavingsCalculator + CartDrawer alert | I18N-01/02/05 | **M** |
### LATER — hardening, polish, scale
| Item | Finding | Effort |
|---|---|---|
| Migrate hot JSON `String` fields → `Jsonb` | DB-04 | **L** |
| Nonce-based CSP (drop unsafe-inline/eval) | INFRA-11 | **M** |
| OCSP stapling, logrotate, body timeout, keepalive tuning | INFRA-07/09/10/13 | **S** |
| Image + video sitemaps; VideoObject/FAQ/CaseStudy schema | SEO-02/03/04/10 | **M** |
| Reduce `any` usage; extract oversized files; standardize logging | CQ-01/02/04/05 | **L** |
| Lazy-load Three.js; trim `use client`; configurable pool | PERF-05/06/07 | **M** |
| Conversation history/resume; tool eval set; useChat onError | AI-02/04/10 | **M** |
| Remaining a11y (focus trap, labels, contrast, button types) | A11Y-06/07/10/11/12/13/14 | **M** |
| Locale-aware number formatting; translate error boundaries | I18N-03/04 | **S** |
---
## 6. False Positives Considered (refuted / materially downgraded)
These were checked and either refuted or substantially softened during verification — listed so you know they were examined, not missed:
- **SEC-07 (B2B `/parts` not redirecting)** — *refuted as a security issue.* The page returns 200 but never exposes parts data (empty array), and shows a proper "Access Restricted" locked-state UI. A style/pattern preference, not a leak. Downgraded high → low.
- **CQ-07 line-450 "undefined `.score`"** — *refuted.* The `scored` array always initializes `score` (line 415, returned 443); `.score` can't be undefined there. The surrounding `any`-typing concern remains valid; the crash claim does not.
- **CQ-03 (lines 600/609 + CaseStudyModal "silent failure")** — *partially refuted.* Lines 600/609 are inside a try/catch with `.w/.h/.d` validation, and CaseStudyModal:189-197 has real try/catch. Only the `sectionsJson`/`advantagesJson` parse at lines 1026-1027 is genuinely unguarded.
- **INFRA-08 (cache poisoning)** — *partially refuted.* "Session cookies lost for users" and "sensitive header disclosure" are false (authenticated requests bypass cache via `$cookie_flux_session`). Residual risk is limited to poisoning public pages with non-sensitive cookies.
- **INFRA-02 / INFRA-04 (nginx + deploy)** — *softened, not refuted.* The "deadlock"/"no-recovery" framing was overstated (startup race + manual recovery, not hangs); both remain real high-severity gaps.
- **INFRA-09 / INFRA-10 (body timeout / logrotate)** — *softened.* nginx's 60s default timeout and ephemeral (unmounted) log storage reduce the blast radius; both downgraded high → medium.
- **DB-02 / DB-03 / PERF-02 / A11Y-01 / A11Y-09 / INFRA-11** — *confirmed but downgraded* (see §2) where verification found mitigating context (other models' parity, dropdown-constrained input, ISR-only timing, adjacent visible text, muted decorative video, compensating input-validation controls).
---
*Bottom line, David: the build quality is real, but the auth layer is currently a no-op and the platform has no backups, no monitoring, and live secrets in git. The "NOW" block is mostly small, surgical changes — SEC-02 alone is a one-file fix that re-activates a security layer you already wrote. Land that block first, then the operational resilience in "NEXT," and this moves from "risky to run" to "production-ready" quickly.*
+51
View File
@@ -8,6 +8,46 @@ upstream nextjs {
keepalive 32; keepalive 32;
} }
# ─────────────────────────────────────────────────────────────────────────
# CANONICAL-HOST GUARD (default_server for ports 80 + 443)
# Catches every request NOT addressed to rf-flux.com / www.rf-flux.com —
# raw-IP access (135.125.53.234), SSRF probes (Host: 169.254.169.254,
# localhost, metadata.google.internal) and the bulk of bot scans that hit
# the bare IP. Returns 444 (drop the connection, send nothing).
#
# Legitimate traffic is unaffected: the rf-flux.com server blocks below win
# because an exact server_name match always beats default_server.
# ─────────────────────────────────────────────────────────────────────────
server {
listen 80 default_server;
server_name _;
# Nginx self-health endpoint (served directly, no upstream) — used by the
# docker-compose healthcheck. Reachable on 127.0.0.1 inside the container
# (no Host match needed, so it lands here on the default_server).
location = /nginx-health { return 200 "ok\n"; access_log off; }
# Keep ACME HTTP-01 working so certbot can still renew on any host.
location /.well-known/acme-challenge/ { root /var/www/certbot; }
location / { return 444; }
}
server {
listen 443 ssl default_server;
http2 on;
server_name _;
# A cert is required to complete the TLS handshake before the Host is
# known; reuse the rf-flux.com cert, then drop. Bots hitting the IP get
# a cert-name mismatch and a closed connection — nothing is proxied.
ssl_certificate /etc/letsencrypt/live/rf-flux.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/rf-flux.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
return 444;
}
# Legacy domain redirect — anyone landing on lethepowerflux.com lands on # Legacy domain redirect — anyone landing on lethepowerflux.com lands on
# the canonical https://www.rf-flux.com host instead. SEO-safe 301. # the canonical https://www.rf-flux.com host instead. SEO-safe 301.
server { server {
@@ -55,6 +95,17 @@ server {
add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
# ── Scanner / exploit-probe blocking ────────────────────────────────
# This is a Next.js app — it has no PHP, no .env/.git served over HTTP,
# no wp-admin. Any request for those paths is a bot probing for secrets
# or exploits. Drop them cheaply with 444 before they touch the app.
# The patterns are scanner-specific and never match real assets
# (.jpg/.png/.webp/.mp4/.glb/.pdf/.svg) or app routes.
location ~* (?:\.(?:php|phtml|asp|aspx|jsp|cgi|env|sql|bak|ini|sh|yml|yaml|conf)$|/\.(?:git|env|aws|ssh|svn|hg|idea|vscode)|/(?:wp-admin|wp-login|wordpress|phpmyadmin|xmlrpc)) {
return 444;
access_log off;
}
# 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/ {
proxy_pass http://nextjs; proxy_pass http://nextjs;
@@ -0,0 +1,40 @@
-- ─────────────────────────────────────────────────────────────────────────
-- ADDITIVE MIGRATION — creates the ClientUser table (B2B client portal) and
-- wires OperationsSignal.clientId to it. The Prisma schema has defined this
-- model + relation for a while, but no migration ever created the table, so
-- the B2B register/login flow (src/app/actions/clientAuth.ts) and the
-- dashboard client counts were failing at runtime. This backfills it.
--
-- Idempotent: IF NOT EXISTS / duplicate-object guards make it safe to re-run
-- and safe for `migrate deploy` against the existing production DB.
-- ─────────────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS "ClientUser" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"passwordHash" TEXT NOT NULL,
"fullName" TEXT NOT NULL,
"companyName" TEXT NOT NULL,
"phone" TEXT,
"isApproved" BOOLEAN NOT NULL DEFAULT false,
"lastLoginAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ClientUser_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX IF NOT EXISTS "ClientUser_email_key" ON "ClientUser" ("email");
-- OperationsSignal.clientId — the FK column the schema references. Add it if
-- a prior schema state never created it (nullable, so existing rows are fine).
ALTER TABLE "OperationsSignal" ADD COLUMN IF NOT EXISTS "clientId" TEXT;
-- Foreign key OperationsSignal.clientId -> ClientUser.id
DO $$ BEGIN
ALTER TABLE "OperationsSignal"
ADD CONSTRAINT "OperationsSignal_clientId_fkey"
FOREIGN KEY ("clientId") REFERENCES "ClientUser"("id")
ON DELETE SET NULL ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
@@ -0,0 +1,10 @@
-- ─────────────────────────────────────────────────────────────────────────
-- ADDITIVE MIGRATION — index GlobalNode(application, isActive).
-- The application detail page queries case studies by application slug +
-- isActive (the GlobalNode.application -> Application.slug join). Without an
-- index this is a full table scan on every application page render.
-- Idempotent. Safe for `migrate deploy`.
-- ─────────────────────────────────────────────────────────────────────────
CREATE INDEX IF NOT EXISTS "GlobalNode_application_isActive_idx"
ON "GlobalNode" ("application", "isActive");
+3
View File
@@ -64,6 +64,9 @@ model GlobalNode {
@@index([isActive]) @@index([isActive])
@@index([nodeType]) @@index([nodeType])
@@index([nodeType, isActive]) @@index([nodeType, isActive])
// Case studies on an application page filter by application slug + isActive
// (src/app/[locale]/applications/[slug]/page.tsx). Back this join with an index.
@@index([application, isActive])
} }
// ------------------------------------------------------ // ------------------------------------------------------
View File
+15
View File
@@ -0,0 +1,15 @@
#!/bin/sh
# ─────────────────────────────────────────────────────────────────────────────
# Backup service entrypoint. Runs one backup immediately on start, then loops
# every BACKUP_INTERVAL_SECONDS (default 24h). A loop (vs cron) inherits the
# container environment cleanly and survives restarts without lost schedules.
# ─────────────────────────────────────────────────────────────────────────────
set -eu
INTERVAL="${BACKUP_INTERVAL_SECONDS:-86400}"
echo "[backup] service started; interval=${INTERVAL}s, retention=${RETENTION_DAYS:-14}d"
while true; do
/usr/local/bin/db-backup.sh || echo "[backup] cycle failed; will retry next interval"
sleep "$INTERVAL"
done
+31
View File
@@ -0,0 +1,31 @@
#!/bin/sh
# ─────────────────────────────────────────────────────────────────────────────
# Single Postgres backup: pg_dump -> gzip -> N-day rotation.
# Run by scripts/backup-loop.sh inside the `backup` compose service.
# Env: DB_USER, DB_PASSWORD, DB_NAME, BACKUP_DIR, RETENTION_DAYS
# ─────────────────────────────────────────────────────────────────────────────
set -eu
BACKUP_DIR="${BACKUP_DIR:-/backups}"
RETENTION_DAYS="${RETENTION_DAYS:-14}"
TS=$(date -u +%Y%m%d_%H%M%S)
OUT="${BACKUP_DIR}/flux_db_${TS}.sql.gz"
mkdir -p "$BACKUP_DIR"
export PGPASSWORD="$DB_PASSWORD"
echo "[backup] $(date -u +%Y-%m-%dT%H:%M:%SZ) starting pg_dump -> ${OUT}"
# --no-owner/--no-privileges keep the dump portable across roles on restore.
if pg_dump -h postgres -U "$DB_USER" -d "$DB_NAME" --no-owner --no-privileges | gzip -9 > "$OUT"; then
SIZE=$(du -h "$OUT" | cut -f1)
echo "[backup] OK: ${OUT} (${SIZE})"
else
echo "[backup] FAILED: pg_dump returned non-zero; removing partial file"
rm -f "$OUT"
exit 1
fi
# Rotation — drop dumps older than RETENTION_DAYS.
DELETED=$(find "$BACKUP_DIR" -name 'flux_db_*.sql.gz' -mtime +"$RETENTION_DAYS" -print -delete 2>/dev/null | wc -l || echo 0)
echo "[backup] rotation: kept last ${RETENTION_DAYS} days, pruned ${DELETED} old dump(s)"
+9 -9
View File
@@ -40,26 +40,26 @@ export default async function PrivacyPage({
]; ];
return ( return (
<main className="relative w-full min-h-screen bg-[#F5F5F7]"> <main className="relative w-full min-h-screen bg-[#F5F5F7] dark:bg-[#050505]">
<div className="max-w-3xl mx-auto px-6 pt-28 md:pt-36 pb-24"> <div className="max-w-3xl mx-auto px-6 pt-28 md:pt-36 pb-24">
<Breadcrumbs items={crumbs} /> <Breadcrumbs items={crumbs} />
<header className="mt-6 mb-10"> <header className="mt-6 mb-10">
<h1 className="text-3xl md:text-5xl font-light text-[#1D1D1F] tracking-tight"> <h1 className="text-3xl md:text-5xl font-light text-[#1D1D1F] dark:text-white tracking-tight">
Privacy &amp; Cookie <span className="font-medium">Policy</span> Privacy &amp; Cookie <span className="font-medium">Policy</span>
</h1> </h1>
<p className="mt-3 text-sm text-[#86868B]">Last updated: {LAST_UPDATED}</p> <p className="mt-3 text-sm text-[#86868B] dark:text-[#A1A1A6]">Last updated: {LAST_UPDATED}</p>
</header> </header>
{/* Template disclaimer — remove once reviewed by legal counsel */} {/* Template disclaimer — remove once reviewed by legal counsel */}
<div className="mb-10 rounded-2xl border border-amber-300/50 bg-amber-50 p-4 text-sm text-amber-900"> <div className="mb-10 rounded-2xl border border-amber-300/50 dark:border-amber-400/30 bg-amber-50 dark:bg-amber-400/10 p-4 text-sm text-amber-900 dark:text-amber-200">
<strong>Template notice:</strong> this is a standard GDPR-compliant <strong>Template notice:</strong> this is a standard GDPR-compliant
template provided as a starting point. Please have it reviewed and template provided as a starting point. Please have it reviewed and
adapted by your legal counsel before relying on it, and confirm the adapted by your legal counsel before relying on it, and confirm the
contact details below. contact details below.
</div> </div>
<div className="space-y-8 text-[#1D1D1F]"> <div className="space-y-8 text-[#1D1D1F] dark:text-[#F5F5F7]">
<Section title="1. Who we are"> <Section title="1. Who we are">
<P> <P>
{COMPANY} (&ldquo;we&rdquo;, &ldquo;us&rdquo;, &ldquo;our&rdquo;) {COMPANY} (&ldquo;we&rdquo;, &ldquo;us&rdquo;, &ldquo;our&rdquo;)
@@ -69,7 +69,7 @@ export default async function PrivacyPage({
</P> </P>
<P> <P>
For any privacy-related request you can contact us at{" "} For any privacy-related request you can contact us at{" "}
<a href={`mailto:${CONTACT_EMAIL}`} className="text-[#0066CC] underline underline-offset-2"> <a href={`mailto:${CONTACT_EMAIL}`} className="text-[#0066CC] dark:text-[#00F0FF] underline underline-offset-2">
{CONTACT_EMAIL} {CONTACT_EMAIL}
</a> </a>
. .
@@ -155,7 +155,7 @@ export default async function PrivacyPage({
</ul> </ul>
<P> <P>
To exercise any of these rights, contact us at{" "} To exercise any of these rights, contact us at{" "}
<a href={`mailto:${CONTACT_EMAIL}`} className="text-[#0066CC] underline underline-offset-2"> <a href={`mailto:${CONTACT_EMAIL}`} className="text-[#0066CC] dark:text-[#00F0FF] underline underline-offset-2">
{CONTACT_EMAIL} {CONTACT_EMAIL}
</a> </a>
. .
@@ -187,12 +187,12 @@ const SITE = "rf-flux.com";
function Section({ title, children }: { title: string; children: React.ReactNode }) { function Section({ title, children }: { title: string; children: React.ReactNode }) {
return ( return (
<section> <section>
<h2 className="text-lg md:text-xl font-semibold text-[#1D1D1F] mb-3">{title}</h2> <h2 className="text-lg md:text-xl font-semibold text-[#1D1D1F] dark:text-white mb-3">{title}</h2>
<div className="space-y-3 text-[15px] leading-relaxed">{children}</div> <div className="space-y-3 text-[15px] leading-relaxed">{children}</div>
</section> </section>
); );
} }
function P({ children }: { children: React.ReactNode }) { function P({ children }: { children: React.ReactNode }) {
return <p className="text-[#3A3A3C]">{children}</p>; return <p className="text-[#3A3A3C] dark:text-[#A1A1A6]">{children}</p>;
} }
+6 -6
View File
@@ -27,10 +27,10 @@ export default function TeamGrid({ members }: { members: TeamCard[] }) {
whileInView={{ opacity: 1, y: 0 }} whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-60px" }} viewport={{ once: true, margin: "-60px" }}
transition={{ duration: 0.5, delay: Math.min(i * 0.06, 0.4), ease: [0.16, 1, 0.3, 1] }} transition={{ duration: 0.5, delay: Math.min(i * 0.06, 0.4), ease: [0.16, 1, 0.3, 1] }}
className="group relative flex flex-col rounded-3xl bg-white border border-black/[0.06] shadow-[0_2px_20px_rgba(0,0,0,0.04)] hover:shadow-[0_12px_40px_rgba(0,0,0,0.10)] transition-all duration-500 overflow-hidden" className="group relative flex flex-col rounded-3xl bg-white dark:bg-[#111] border border-black/[0.06] dark:border-white/10 shadow-[0_2px_20px_rgba(0,0,0,0.04)] hover:shadow-[0_12px_40px_rgba(0,0,0,0.10)] dark:shadow-[0_2px_20px_rgba(0,0,0,0.4)] transition-all duration-500 overflow-hidden"
> >
{/* Portrait */} {/* Portrait */}
<div className="relative aspect-[4/5] w-full overflow-hidden bg-gradient-to-br from-[#EEF2F5] to-[#E3E9ED]"> <div className="relative aspect-[4/5] w-full overflow-hidden bg-gradient-to-br from-[#EEF2F5] to-[#E3E9ED] dark:from-[#1A1A1C] dark:to-[#0E0E10]">
{m.photoUrl ? ( {m.photoUrl ? (
<Image <Image
src={m.photoUrl} src={m.photoUrl}
@@ -50,13 +50,13 @@ export default function TeamGrid({ members }: { members: TeamCard[] }) {
{/* Body */} {/* Body */}
<div className="flex flex-col flex-1 p-6"> <div className="flex flex-col flex-1 p-6">
<h3 className="text-lg font-semibold text-[#1D1D1F] tracking-tight">{m.name}</h3> <h3 className="text-lg font-semibold text-[#1D1D1F] dark:text-white tracking-tight">{m.name}</h3>
<p className="text-[#0066CC] text-xs font-medium uppercase tracking-[0.12em] mt-1"> <p className="text-[#0066CC] dark:text-[#00F0FF] text-xs font-medium uppercase tracking-[0.12em] mt-1">
{m.role} {m.role}
</p> </p>
{m.bio && ( {m.bio && (
<p className="mt-4 text-sm leading-relaxed text-[#6E6E73] line-clamp-5">{m.bio}</p> <p className="mt-4 text-sm leading-relaxed text-[#6E6E73] dark:text-[#A1A1A6] line-clamp-5">{m.bio}</p>
)} )}
{/* Social links — only the ones that exist */} {/* Social links — only the ones that exist */}
@@ -106,7 +106,7 @@ function SocialLink({
title={label} title={label}
{...(external ? { target: "_blank", rel: "noopener noreferrer" } : {})} {...(external ? { target: "_blank", rel: "noopener noreferrer" } : {})}
onClick={() => trackEvent({ name: "contact_cta_clicked", params: { location: `team:${network}` } })} onClick={() => trackEvent({ name: "contact_cta_clicked", params: { location: `team:${network}` } })}
className="inline-flex items-center justify-center w-9 h-9 rounded-full border border-black/[0.08] text-[#6E6E73] hover:text-white hover:bg-[#1D1D1F] hover:border-[#1D1D1F] transition-colors" className="inline-flex items-center justify-center w-9 h-9 rounded-full border border-black/[0.08] dark:border-white/15 text-[#6E6E73] dark:text-[#A1A1A6] hover:text-white hover:bg-[#1D1D1F] hover:border-[#1D1D1F] dark:hover:bg-[#00F0FF] dark:hover:text-black dark:hover:border-[#00F0FF] transition-colors"
> >
{children} {children}
</a> </a>
+5 -5
View File
@@ -76,7 +76,7 @@ export default async function TeamPage({ params }: { params: Promise<{ locale: s
<> <>
{personSchemas.length > 0 && <JsonLd data={personSchemas} />} {personSchemas.length > 0 && <JsonLd data={personSchemas} />}
<main className="relative w-full min-h-screen bg-[#F5F5F7] overflow-hidden"> <main className="relative w-full min-h-screen bg-[#F5F5F7] dark:bg-[#050505] overflow-hidden">
{/* Ambient visual, consistent with the News / Heritage hubs */} {/* Ambient visual, consistent with the News / Heritage hubs */}
<div className="absolute inset-0 opacity-60 pointer-events-none"> <div className="absolute inset-0 opacity-60 pointer-events-none">
<BreathingField /> <BreathingField />
@@ -86,20 +86,20 @@ export default async function TeamPage({ params }: { params: Promise<{ locale: s
<Breadcrumbs items={crumbs} /> <Breadcrumbs items={crumbs} />
<header className="max-w-3xl mt-6 mb-16 md:mb-24"> <header className="max-w-3xl mt-6 mb-16 md:mb-24">
<p className="text-[#0066CC] text-xs md:text-sm font-semibold uppercase tracking-[0.2em] mb-4"> <p className="text-[#0066CC] dark:text-[#00F0FF] text-xs md:text-sm font-semibold uppercase tracking-[0.2em] mb-4">
{t("eyebrow")} {t("eyebrow")}
</p> </p>
<h1 className="text-4xl md:text-6xl font-light text-[#1D1D1F] tracking-tight leading-[1.05]"> <h1 className="text-4xl md:text-6xl font-light text-[#1D1D1F] dark:text-white tracking-tight leading-[1.05]">
{t("title1")}{" "} {t("title1")}{" "}
<span className="font-medium">{t("title2")}</span> <span className="font-medium">{t("title2")}</span>
</h1> </h1>
<p className="mt-6 text-base md:text-lg text-[#6E6E73] leading-relaxed max-w-2xl"> <p className="mt-6 text-base md:text-lg text-[#6E6E73] dark:text-[#A1A1A6] leading-relaxed max-w-2xl">
{t("description")} {t("description")}
</p> </p>
</header> </header>
{members.length === 0 ? ( {members.length === 0 ? (
<div className="text-center py-24 text-[#86868B]"> <div className="text-center py-24 text-[#86868B] dark:text-[#A1A1A6]">
<p>{t("empty")}</p> <p>{t("empty")}</p>
</div> </div>
) : ( ) : (
+34 -23
View File
@@ -3,6 +3,7 @@
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";
// ── Helper: Generate sequential ticket ID ── // ── Helper: Generate sequential ticket ID ──
async function generateTicketId(type: string): Promise<string> { async function generateTicketId(type: string): Promise<string> {
@@ -91,17 +92,23 @@ export async function submitOperationsSignal(payload: {
replyTo: payload.clientEmail, replyTo: payload.clientEmail,
}); });
// Track email delivery in DB // Track email delivery — best-effort. The signal (lead) is already saved,
await prisma.operationsSignal.update({ // so a telemetry-update hiccup must NOT fail the request and make the
where: { id: signal.id }, // client retry into a duplicate.
data: { try {
emailSentTo: emailResult.sentTo.join(", "), await prisma.operationsSignal.update({
emailSentAt: emailResult.sentAt, where: { id: signal.id },
emailError: emailResult.error, data: {
}, emailSentTo: emailResult.sentTo.join(", "),
}); emailSentAt: emailResult.sentAt,
emailError: emailResult.error,
},
});
} catch (trackErr) {
console.warn("[operations] email tracking update failed (lead already saved):", trackErr);
}
return { success: true, ticketId, emailSent: emailResult.success }; return { success: true, ticketId, emailSent: emailResult.success, emailError: emailResult.error };
} catch (error) { } catch (error) {
console.error("Error submitting signal:", error); console.error("Error submitting signal:", error);
return { error: "Failed to submit request. Please try again." }; return { error: "Failed to submit request. Please try again." };
@@ -119,17 +126,21 @@ function generateRichEmailHtml(payload: any, ticketId: string, aiAnalysis: strin
const cartRows = cartItems.map((item: any) => ` const cartRows = cartItems.map((item: any) => `
<tr> <tr>
<td style="padding: 16px 12px; border-bottom: 1px solid #E5E5EA;"> <td style="padding: 16px 12px; border-bottom: 1px solid #E5E5EA;">
<strong style="color: #1D1D1F; font-size: 14px;">${item.title}</strong><br/> <strong style="color: #1D1D1F; font-size: 14px;">${escapeHtml(item.title)}</strong><br/>
<span style="color: #86868B; font-size: 11px; font-family: monospace;">SKU: ${item.sku}</span> <span style="color: #86868B; font-size: 11px; font-family: monospace;">SKU: ${escapeHtml(item.sku)}</span>
</td> </td>
<td style="padding: 16px 12px; border-bottom: 1px solid #E5E5EA; text-align: center; font-family: monospace; font-weight: 600; color: #1D1D1F;">${item.quantity}</td> <td style="padding: 16px 12px; border-bottom: 1px solid #E5E5EA; text-align: center; font-family: monospace; font-weight: 600; color: #1D1D1F;">${escapeHtml(item.quantity)}</td>
</tr> </tr>
`).join(''); `).join('');
const fileLinks = files.map((fileUrl: string) => { const fileLinks = files
const isVideo = fileUrl.endsWith('.mp4') || fileUrl.endsWith('.mov'); // Only accept internal paths (start with a single "/"); ignore anything
return `<a href="${appUrl}${fileUrl}" style="display: inline-block; padding: 10px 16px; background: #0066CC; color: white; text-decoration: none; border-radius: 8px; margin: 4px 8px 4px 0; font-size: 13px; font-weight: 600; text-align: center;">View ${isVideo ? 'Video' : 'Image'}</a>`; // that could point off-site or break out of the href attribute.
}).join(''); .filter((fileUrl: string) => typeof fileUrl === 'string' && /^\/[^/]/.test(fileUrl))
.map((fileUrl: string) => {
const isVideo = fileUrl.endsWith('.mp4') || fileUrl.endsWith('.mov');
return `<a href="${escapeAttr(appUrl + fileUrl)}" style="display: inline-block; padding: 10px 16px; background: #0066CC; color: white; text-decoration: none; border-radius: 8px; margin: 4px 8px 4px 0; font-size: 13px; font-weight: 600; text-align: center;">View ${isVideo ? 'Video' : 'Image'}</a>`;
}).join('');
return ` return `
<div style="background-color: #F5F5F7; padding: 40px 20px; width: 100%; box-sizing: border-box; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;"> <div style="background-color: #F5F5F7; padding: 40px 20px; width: 100%; box-sizing: border-box; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;">
@@ -138,21 +149,21 @@ function generateRichEmailHtml(payload: any, ticketId: string, aiAnalysis: strin
<div style="padding: 40px 32px 32px 32px; text-align: center; border-bottom: 1px solid #E5E5EA;"> <div style="padding: 40px 32px 32px 32px; text-align: center; border-bottom: 1px solid #E5E5EA;">
<img src="${appUrl}/logoEmail.png" alt="FLUX SRL" style="height: 50px; width: auto; object-fit: contain; margin-bottom: 24px;" /> <img src="${appUrl}/logoEmail.png" alt="FLUX SRL" style="height: 50px; width: auto; object-fit: contain; margin-bottom: 24px;" />
<p style="color: #0066CC; font-size: 11px; letter-spacing: 2px; text-transform: uppercase; margin: 0; font-weight: 700;">Operations Command</p> <p style="color: #0066CC; font-size: 11px; letter-spacing: 2px; text-transform: uppercase; margin: 0; font-weight: 700;">Operations Command</p>
<h1 style="margin: 12px 0 16px 0; font-size: 26px; font-weight: 300; color: #1D1D1F; letter-spacing: -0.5px;">Incoming ${payload.type} Signal</h1> <h1 style="margin: 12px 0 16px 0; font-size: 26px; font-weight: 300; color: #1D1D1F; letter-spacing: -0.5px;">Incoming ${escapeHtml(payload.type)} Signal</h1>
<span style="display: inline-block; padding: 6px 16px; background-color: #F5F5F7; border-radius: 20px; font-family: monospace; color: #1D1D1F; font-size: 13px; font-weight: 600; border: 1px solid #E5E5EA;">Ticket: ${ticketId}</span> <span style="display: inline-block; padding: 6px 16px; background-color: #F5F5F7; border-radius: 20px; font-family: monospace; color: #1D1D1F; font-size: 13px; font-weight: 600; border: 1px solid #E5E5EA;">Ticket: ${escapeHtml(ticketId)}</span>
</div> </div>
<div style="padding: 32px; color: #1D1D1F;"> <div style="padding: 32px; color: #1D1D1F;">
<div style="background-color: #F5F5F7; padding: 24px; border-radius: 12px; margin-bottom: 32px; border: 1px solid #E5E5EA;"> <div style="background-color: #F5F5F7; padding: 24px; border-radius: 12px; margin-bottom: 32px; border: 1px solid #E5E5EA;">
<p style="margin: 0 0 8px 0; font-size: 15px;"><strong>${payload.clientName}</strong> · ${payload.clientCompany}</p> <p style="margin: 0 0 8px 0; font-size: 15px;"><strong>${escapeHtml(payload.clientName)}</strong> · ${escapeHtml(payload.clientCompany)}</p>
<p style="margin: 0 0 8px 0; font-size: 14px; color: #86868B;">Email: <a href="mailto:${payload.clientEmail}" style="color: #0066CC; text-decoration: none;">${payload.clientEmail}</a></p> <p style="margin: 0 0 8px 0; font-size: 14px; color: #86868B;">Email: <a href="${escapeAttr(safeMailto(payload.clientEmail))}" style="color: #0066CC; text-decoration: none;">${escapeHtml(payload.clientEmail)}</a></p>
<p style="margin: 0; font-size: 14px; color: #86868B;">Phone: ${payload.clientPhone || 'N/A'}</p> <p style="margin: 0; font-size: 14px; color: #86868B;">Phone: ${payload.clientPhone ? escapeHtml(payload.clientPhone) : 'N/A'}</p>
</div> </div>
${payload.message ? ` ${payload.message ? `
<div style="margin-bottom: 32px;"> <div style="margin-bottom: 32px;">
<h3 style="font-size: 12px; text-transform: uppercase; color: #86868B; letter-spacing: 1px; margin-bottom: 12px;">Client Notes</h3> <h3 style="font-size: 12px; text-transform: uppercase; color: #86868B; letter-spacing: 1px; margin-bottom: 12px;">Client Notes</h3>
<div style="padding: 20px; border-left: 4px solid #1D1D1F; background: #FAFAFA; border-radius: 0 8px 8px 0; font-size: 14px; line-height: 1.6;">${payload.message}</div> <div style="padding: 20px; border-left: 4px solid #1D1D1F; background: #FAFAFA; border-radius: 0 8px 8px 0; font-size: 14px; line-height: 1.6;">${escapeHtml(payload.message)}</div>
</div> </div>
` : ''} ` : ''}
+14
View File
@@ -32,6 +32,15 @@ import fs from "fs";
import path from "path"; import path from "path";
import { revalidateContent, type RevalidateScope } from "@/lib/revalidate"; import { revalidateContent, type RevalidateScope } from "@/lib/revalidate";
import { optimizeImage, isOptimizable } from "@/lib/imageOptimizer"; import { optimizeImage, isOptimizable } from "@/lib/imageOptimizer";
import { getAdminSession } from "@/lib/session";
// All asset operations are admin-only. The middleware (src/proxy.ts) does NOT
// cover /api, so each handler must verify the admin session itself.
async function requireAdmin(): Promise<NextResponse | null> {
const session = await getAdminSession();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
return null;
}
const SCOPE_ROOTS: Record<string, string> = { const SCOPE_ROOTS: Record<string, string> = {
applications: path.join(process.cwd(), "public", "applications"), applications: path.join(process.cwd(), "public", "applications"),
@@ -123,6 +132,7 @@ function buildBreadcrumbs(subPath: string) {
// GET — List files and folders // GET — List files and folders
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const unauth = await requireAdmin(); if (unauth) return unauth;
try { try {
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const scope = searchParams.get("scope") || "applications"; const scope = searchParams.get("scope") || "applications";
@@ -198,6 +208,7 @@ export async function GET(request: NextRequest) {
// produces a different hash, so the browser cache invalidates instantly // produces a different hash, so the browser cache invalidates instantly
// without any header trickery. // without any header trickery.
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
const unauth = await requireAdmin(); if (unauth) return unauth;
try { try {
const formData = await request.formData(); const formData = await request.formData();
const scope = (formData.get("scope") as string) || "applications"; const scope = (formData.get("scope") as string) || "applications";
@@ -280,6 +291,7 @@ export async function POST(request: NextRequest) {
// PUT — Create a new folder // PUT — Create a new folder
export async function PUT(request: NextRequest) { export async function PUT(request: NextRequest) {
const unauth = await requireAdmin(); if (unauth) return unauth;
try { try {
const body = await request.json(); const body = await request.json();
const { scope = "applications", slug = "", folderName, parentPath = "" } = body; const { scope = "applications", slug = "", folderName, parentPath = "" } = body;
@@ -315,6 +327,7 @@ export async function PUT(request: NextRequest) {
// { scope, slug, filePath: "..." } single delete // { scope, slug, filePath: "..." } single delete
// { scope, slug, filePaths: ["a", "b", ...] } bulk delete // { scope, slug, filePaths: ["a", "b", ...] } bulk delete
export async function DELETE(request: NextRequest) { export async function DELETE(request: NextRequest) {
const unauth = await requireAdmin(); if (unauth) return unauth;
try { try {
const body = await request.json(); const body = await request.json();
const { scope = "applications", slug = "", filePath, filePaths } = body; const { scope = "applications", slug = "", filePath, filePaths } = body;
@@ -371,6 +384,7 @@ export async function DELETE(request: NextRequest) {
// Cannot overwrite an existing file (returns 409). Sanitises target name // Cannot overwrite an existing file (returns 409). Sanitises target name
// the same way upload does, and creates intermediate folders if needed. // the same way upload does, and creates intermediate folders if needed.
export async function PATCH(request: NextRequest) { export async function PATCH(request: NextRequest) {
const unauth = await requireAdmin(); if (unauth) return unauth;
try { try {
const body = await request.json(); const body = await request.json();
const { scope = "applications", slug = "", fromPath, toPath } = body; const { scope = "applications", slug = "", fromPath, toPath } = body;
+4
View File
@@ -25,6 +25,7 @@ import sharp from "sharp";
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 { getAdminSession } from "@/lib/session";
interface VariantSpec { interface VariantSpec {
size: number; size: number;
@@ -48,6 +49,9 @@ const MAX_FILE_SIZE = 20 * 1024 * 1024; // 20MB cap
const ALLOWED_EXT = new Set([".png", ".jpg", ".jpeg", ".webp"]); const ALLOWED_EXT = new Set([".png", ".jpg", ".jpeg", ".webp"]);
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
// Admin-only: the middleware does not cover /api, so guard here.
const session = await getAdminSession();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
try { try {
const formData = await request.formData(); const formData = await request.formData();
const file = formData.get("file") as File | null; const file = formData.get("file") as File | null;
+63 -13
View File
@@ -39,22 +39,43 @@ const COMPARISON_DATA: Record<string, { rf: number; traditional: number; unit: s
// ─── DYNAMIC SYSTEM PROMPT BUILDER ────────────────────────────── // ─── DYNAMIC SYSTEM PROMPT BUILDER ──────────────────────────────
// Injects real-time database context so the AI knows what exists // Injects real-time database context so the AI knows what exists
// Cache the built prompt briefly so we don't run 4 DB queries on every single
// chat message. CMS changes appear within the TTL. Only healthy builds are
// cached, so a transient DB outage retries on the next message.
let _promptCache: { value: string; at: number } | null = null;
const SYSTEM_PROMPT_TTL_MS = 60_000;
async function buildSystemPrompt(): Promise<string> { async function buildSystemPrompt(): Promise<string> {
// Query real data from Prisma if (_promptCache && Date.now() - _promptCache.at < SYSTEM_PROMPT_TTL_MS) {
const [activeApps, installationCount, eventCount, partsCount] = await Promise.all([ return _promptCache.value;
prisma.application.findMany({ }
where: { isActive: true },
select: { slug: true, title: true, shortDescription: true, category: true },
orderBy: { title: 'asc' },
}),
prisma.globalNode.count({ where: { nodeType: 'installation', isActive: true } }),
prisma.globalNode.count({ where: { nodeType: 'event', isActive: true } }),
prisma.sparePart.count({ where: { isActive: true } }),
]);
const appList = activeApps.map((a: any) => ` - ${a.title} (slug: "${a.slug}", category: ${a.category})`).join('\n'); // Live DB context. If Postgres is unreachable, fall back to safe defaults so
// the assistant still answers (degraded) instead of 500-ing the whole chat.
let activeApps: Array<{ slug: string; title: string; shortDescription: string; category: string }> = [];
let installationCount = 0, eventCount = 0, partsCount = 0;
let dbOk = true;
try {
[activeApps, installationCount, eventCount, partsCount] = await Promise.all([
prisma.application.findMany({
where: { isActive: true },
select: { slug: true, title: true, shortDescription: true, category: true },
orderBy: { title: 'asc' },
}),
prisma.globalNode.count({ where: { nodeType: 'installation', isActive: true } }),
prisma.globalNode.count({ where: { nodeType: 'event', isActive: true } }),
prisma.sparePart.count({ where: { isActive: true } }),
]);
} catch (e) {
dbOk = false;
log.warn('chat.system_prompt_db_unavailable', { err: String(e) });
}
return `You are "FluxAI", the intelligent engineering advisor and sales specialist for FLUX Srl — a world leader in solid-state Radio Frequency (RF), Microwave, and Infrared industrial equipment. Founded by Patrizio Grando with 40+ years of legacy. Headquarters: Romano d'Ezzelino, Vicenza, Italy. const appList = activeApps.length
? activeApps.map((a) => ` - ${a.title} (slug: "${a.slug}", category: ${a.category})`).join('\n')
: ' (live catalog temporarily unavailable — describe FLUX applications from general RF knowledge)';
const prompt = `You are "FluxAI", the intelligent engineering advisor and sales specialist for FLUX Srl — a world leader in solid-state Radio Frequency (RF), Microwave, and Infrared industrial equipment. Founded by Patrizio Grando with 40+ years of legacy. Headquarters: Romano d'Ezzelino, Vicenza, Italy.
PERSONALITY: PERSONALITY:
- Senior RF engineer who also understands business ROI. - Senior RF engineer who also understands business ROI.
@@ -143,6 +164,10 @@ PROACTIVE NEXT STEPS (always suggest the next logical action):
comparison → "Let me quantify the difference for your specific operation..." → energy_savings_calculator comparison → "Let me quantify the difference for your specific operation..." → energy_savings_calculator
LANGUAGE: Respond in the exact same language the user writes in.`; LANGUAGE: Respond in the exact same language the user writes in.`;
// Only cache a healthy build so a transient DB outage retries next message.
if (dbOk) _promptCache = { value: prompt, at: Date.now() };
return prompt;
} }
// ─── HELPER: Parse JSON safely ────────────────────────────────── // ─── HELPER: Parse JSON safely ──────────────────────────────────
@@ -198,6 +223,17 @@ export async function POST(req: Request) {
); );
} }
// ─── Fail fast if the AI provider isn't configured ─────────────
// Without this, a missing/invalid key surfaces mid-stream after headers
// are already sent, producing a confusing broken response.
if (!process.env.OPENAI_API_KEY) {
log.error("chat.openai_key_missing", new Error("OPENAI_API_KEY is not set"));
return new Response(
JSON.stringify({ error: "The AI assistant is temporarily unavailable. Please try again later." }),
{ status: 503, headers: { "Content-Type": "application/json" } },
);
}
const { const {
messages, messages,
context, context,
@@ -287,6 +323,20 @@ export async function POST(req: Request) {
system: systemPrompt + contextNote, system: systemPrompt + contextNote,
messages: coreMessages, messages: coreMessages,
providerOptions: { openai: { promptCacheKey: 'fluxai-v1' } }, providerOptions: { openai: { promptCacheKey: 'fluxai-v1' } },
// Surface streaming/provider errors (OpenAI 429/500, bad key) in the logs
// and, when possible, persist them to the conversation timeline.
onError: ({ error }) => {
log.error("chat.stream_error", error, { conversationId: conversationId ?? undefined });
if (conversationId) {
prisma.aiEvent.create({
data: {
conversationId,
type: "error",
payloadJson: JSON.stringify({ message: error instanceof Error ? error.message : String(error) }).slice(0, 2000),
},
}).catch(() => {});
}
},
onFinish: async ({ usage, toolCalls, toolResults }) => { onFinish: async ({ usage, toolCalls, toolResults }) => {
if (!conversationId) return; if (!conversationId) return;
try { try {
+14 -8
View File
@@ -145,14 +145,20 @@ export async function POST(request: NextRequest) {
replyTo: contact.email, replyTo: contact.email,
}); });
await prisma.operationsSignal.update({ // Best-effort email tracking — the lead is already saved; never fail the
where: { id: signal.id }, // request (and risk a client retry / duplicate) over a telemetry update.
data: { try {
emailSentTo: emailResult.sentTo.join(", "), await prisma.operationsSignal.update({
emailSentAt: emailResult.sentAt, where: { id: signal.id },
emailError: emailResult.error, data: {
}, emailSentTo: emailResult.sentTo.join(", "),
}); emailSentAt: emailResult.sentAt,
emailError: emailResult.error,
},
});
} catch (trackErr) {
log.warn("consultation.email_tracking_failed", { ticketId, err: String(trackErr) });
}
log.info("consultation.submitted", { ticketId, emailSent: emailResult.success }); log.info("consultation.submitted", { ticketId, emailSent: emailResult.success });
+5 -5
View File
@@ -167,7 +167,7 @@ function SuccessView({ data, ticketId }: { data: ConsultationData; ticketId: str
<div> <div>
<p className="text-[15px] font-medium text-[#1D1D1F] dark:text-[#F5F5F7] mb-1 transition-colors"> <p className="text-[15px] font-medium text-[#1D1D1F] dark:text-[#F5F5F7] mb-1 transition-colors">
Consultation Requested Request Sent
</p> </p>
{ticketId && ( {ticketId && (
<p className="text-[11px] font-mono text-[#0066CC] dark:text-[#4DA6FF] mb-2">{ticketId}</p> <p className="text-[11px] font-mono text-[#0066CC] dark:text-[#4DA6FF] mb-2">{ticketId}</p>
@@ -188,7 +188,7 @@ function SuccessView({ data, ticketId }: { data: ConsultationData; ticketId: str
</span> </span>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{[ {[
"Engineer reviews your AI-prepared brief", "Our team reviews your AI-prepared brief",
`Custom RF analysis for your ${data.process} process`, `Custom RF analysis for your ${data.process} process`,
"Proposal with ROI projections and timeline", "Proposal with ROI projections and timeline",
].map((step, i) => ( ].map((step, i) => (
@@ -315,11 +315,11 @@ export default function ConsultationScheduler({ data }: { data: ConsultationData
<Calendar size={13} className="text-[#0066CC] dark:text-[#4DA6FF]" /> <Calendar size={13} className="text-[#0066CC] dark:text-[#4DA6FF]" />
</div> </div>
<span className="text-[9px] font-semibold uppercase tracking-widest text-[#0066CC] dark:text-[#4DA6FF]"> <span className="text-[9px] font-semibold uppercase tracking-widest text-[#0066CC] dark:text-[#4DA6FF]">
Engineering Consultation Get in Touch
</span> </span>
</div> </div>
<p className="text-[12px] text-[#86868B] dark:text-[#A1A1A6] mb-4 leading-relaxed"> <p className="text-[12px] text-[#86868B] dark:text-[#A1A1A6] mb-4 leading-relaxed">
Your conversation details are pre-loaded. Just add your contact info. Your conversation details are pre-loaded. Just add your contact info and our team will get back to you.
</p> </p>
<InsightsCard data={data} /> <InsightsCard data={data} />
@@ -416,7 +416,7 @@ export default function ConsultationScheduler({ data }: { data: ConsultationData
Sending... Sending...
</> </>
) : ( ) : (
<>Request Consultation <ArrowRight size={14} /></> <>Send Request <ArrowRight size={14} /></>
)} )}
</motion.button> </motion.button>
+8 -5
View File
@@ -2,19 +2,22 @@
import { ArrowUpRight } from "lucide-react"; import { ArrowUpRight } from "lucide-react";
// 1. El botón principal (Contact Engineering) // 1. El botón principal (Contact FLUX Team)
// Broadened from "Contact FLUX Engineering" so it reads as an invitation for
// ANY enquiry — pricing, general questions, a demo, partnerships — not only
// technical/engineering consultations.
export function AiContactButton() { export function AiContactButton() {
const handleContactEngineering = () => { const handleContact = () => {
const prompt = "I am ready to optimize my production. I would like to schedule a technical consultation with FLUX Engineering to explore custom RF solutions and calculate my ROI."; const prompt = "I'd like to get in touch with the FLUX team. I have a few questions and would like to explore how FLUX can help — whether that's energy savings, a custom solution, pricing, availability, or just learning more.";
window.dispatchEvent(new CustomEvent("flux:trigger-ai", { detail: { prompt } })); window.dispatchEvent(new CustomEvent("flux:trigger-ai", { detail: { prompt } }));
}; };
return ( return (
<button <button
onClick={handleContactEngineering} onClick={handleContact}
className="flex items-center gap-3 bg-white dark:bg-[#00F0FF] text-[#1D1D1F] px-8 py-4 rounded-full font-semibold hover:scale-105 hover:shadow-[0_0_30px_rgba(0,102,204,0.4)] dark:hover:shadow-[0_0_30px_rgba(0,240,255,0.4)] transition-all duration-300" className="flex items-center gap-3 bg-white dark:bg-[#00F0FF] text-[#1D1D1F] px-8 py-4 rounded-full font-semibold hover:scale-105 hover:shadow-[0_0_30px_rgba(0,102,204,0.4)] dark:hover:shadow-[0_0_30px_rgba(0,240,255,0.4)] transition-all duration-300"
> >
Contact FLUX Engineering <ArrowUpRight size={18} /> Contact FLUX Team <ArrowUpRight size={18} />
</button> </button>
); );
} }
+32 -8
View File
@@ -4,14 +4,22 @@ import { cookies } from "next/headers";
// SESSION_SECRET is REQUIRED. No fallback: a public default would let anyone // SESSION_SECRET is REQUIRED. No fallback: a public default would let anyone
// forge a 7-day admin JWT if the env var ever fails to load in production. // 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 // Generate a strong value with: openssl rand -base64 48
const secretKey = process.env.SESSION_SECRET; //
if (!secretKey || secretKey.length < 32) { // Validated LAZILY (inside getEncodedKey, not at module load). `next build`
throw new Error( // imports this module during page-data collection without runtime env vars,
"SESSION_SECRET environment variable is required (min 32 chars). " + // so a top-level throw would break the build. At runtime, any attempt to
"Generate one with: openssl rand -base64 48" // create a session without a valid secret still throws loudly — the secret
); // is never defaulted, so admin JWTs can't be forged.
function getEncodedKey(): Uint8Array {
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"
);
}
return 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) {
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 días de duración const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 días de duración
@@ -21,7 +29,7 @@ export async function createSession(userId: string, username: string) {
.setProtectedHeader({ alg: "HS256" }) .setProtectedHeader({ alg: "HS256" })
.setIssuedAt() .setIssuedAt()
.setExpirationTime("7d") .setExpirationTime("7d")
.sign(encodedKey); .sign(getEncodedKey());
// En Next.js 15+, cookies() es una Promesa // En Next.js 15+, cookies() es una Promesa
const cookieStore = await cookies(); const cookieStore = await cookies();
@@ -38,3 +46,19 @@ export async function deleteSession() {
const cookieStore = await cookies(); const cookieStore = await cookies();
cookieStore.delete("flux_session"); cookieStore.delete("flux_session");
} }
// Verify the current admin session (the "flux_session" JWT set at HQ login).
// Returns the payload, or null when there is no valid session. Use this to
// guard API route handlers under /api that the middleware does NOT cover
// (the proxy matcher excludes /api).
export async function getAdminSession(): Promise<{ userId: string; username: string } | null> {
const cookieStore = await cookies();
const token = cookieStore.get("flux_session")?.value;
if (!token) return null;
try {
const { payload } = await jwtVerify(token, getEncodedKey());
return payload as { userId: string; username: string };
} catch {
return null;
}
}
+1 -1
View File
@@ -48,7 +48,7 @@ export const DEFAULT_FOOTER: FooterSettings = {
ctaTitle1: "Ready to optimize", ctaTitle1: "Ready to optimize",
ctaTitle2: "your production?", ctaTitle2: "your production?",
ctaSubtitle: ctaSubtitle:
"Connect with our engineering team to calculate your ROI and explore custom RF solutions.", "Connect with our team to calculate your ROI, explore solutions, or simply ask a question.",
hqAddress: "Via Benedetto Marcello 32", hqAddress: "Via Benedetto Marcello 32",
hqCity: "36060 Romano d'Ezzelino", hqCity: "36060 Romano d'Ezzelino",
hqRegion: "Vicenza", hqRegion: "Vicenza",
+12 -4
View File
@@ -9,9 +9,17 @@ import { routing } from './i18n/routing';
// Configuramos el proxy de next-intl // Configuramos el proxy de next-intl
const handleI18nRouting = createIntlMiddleware(routing); const handleI18nRouting = createIntlMiddleware(routing);
// 🔒 2. Llave de seguridad del CMS // 🔒 2. Llave de seguridad del CMS.
const secretKey = process.env.SESSION_SECRET || "FLUX_SUPER_SECRET_KEY_2026_ARCHITECTURE"; // No fallback: a public default would let an attacker forge admin JWTs if the
const encodedKey = new TextEncoder().encode(secretKey); // env var ever failed to load. Validated lazily (the middleware runs at
// runtime where SESSION_SECRET is always present), mirroring src/lib/session.ts.
function getEncodedKey(): Uint8Array {
const secretKey = process.env.SESSION_SECRET;
if (!secretKey || secretKey.length < 32) {
throw new Error("SESSION_SECRET environment variable is required (min 32 chars).");
}
return new TextEncoder().encode(secretKey);
}
// 🔥 AHORA SE LLAMA "proxy" EN LUGAR DE "middleware" 🔥 // 🔥 AHORA SE LLAMA "proxy" EN LUGAR DE "middleware" 🔥
export async function proxy(request: NextRequest) { export async function proxy(request: NextRequest) {
@@ -32,7 +40,7 @@ export async function proxy(request: NextRequest) {
// Verificamos el pase // Verificamos el pase
if (cookie) { if (cookie) {
try { try {
await jwtVerify(cookie, encodedKey); await jwtVerify(cookie, getEncodedKey());
// Si tiene pase y está en el login, lo mandamos al dashboard // Si tiene pase y está en el login, lo mandamos al dashboard
if (isPublicHQRoute) { if (isPublicHQRoute) {
return NextResponse.redirect(new URL('/hq-command/dashboard', request.url)); return NextResponse.redirect(new URL('/hq-command/dashboard', request.url));