diff --git a/CLAUDE.md b/CLAUDE.md index 5b03236..ac370b7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,3 +1,7 @@ + +**Security gate.** Every change made in this workspace must comply with the Secure Development Rules Reference: `~/aios/security/rules/secure-development-rules-reference.md` (index: `~/aios/security/rules/RULES-INDEX.md`). Before completing any task, check the work against those rules and flag every breach with its SEC ID and severity. CRITICAL breaches block completion -- fix or escalate before proceeding. For security-relevant changes (auth, secrets, input handling, dependencies, agent/MCP/hook/skill config), read the matching rule section first. + + # termo-site [faraapacalda.ro](https://faraapacalda.ro) — Next 16 App Router, TypeScript strict, @@ -17,6 +21,12 @@ npx playwright test # needs .data/ populated AND a production build local bundle instead of the published release. That is the way to develop against a bundle the nightly has not published yet. +**A server already on :3000 makes Playwright test stale code.** `reuseExistingServer` is +on outside CI, so if anything is serving :3000 the webServer step is skipped and no +rebuild happens — the suite silently runs against whatever was built last, including a +different `NEXT_PUBLIC_*` inlining. A suite that finishes suspiciously fast (no build) is +the tell. Free the port first: `kill $(lsof -ti:3000)`. + ## Pure SSG is a constraint, not a preference Pages use `export const dynamic = 'error'` plus `generateStaticParams`. Data is read at diff --git a/app/api/app-poll/route.ts b/app/api/app-poll/route.ts new file mode 100644 index 0000000..60bdfe2 --- /dev/null +++ b/app/api/app-poll/route.ts @@ -0,0 +1,71 @@ +import { createHash } from "node:crypto"; + +import { validateAppPoll } from "@/lib/app-poll"; + +// Anonymous "would you want an app?" poll. Same posture as /api/feedback: raw +// IPs never touch storage, only a salted hash used for best-effort rate +// limiting. No GET - unlike the feedback tally there is nothing here the site +// should expose publicly. +// +// The poll is answered in two steps and both write the SAME row, upserted on +// the client's nonce. That way someone who taps "Da" and closes the dialog +// without picking a platform still counts as a "Da" rather than vanishing. +const WINDOW_MS = 10 * 60 * 1000; +const MAX_PER_WINDOW = 6; // two writes per answer, plus slack +const recent = new Map(); + +function rateLimited(ipHash: string): boolean { + const now = Date.now(); + const hits = (recent.get(ipHash) ?? []).filter((t) => now - t < WINDOW_MS); + if (hits.length >= MAX_PER_WINDOW) return true; + hits.push(now); + recent.set(ipHash, hits); + if (recent.size > 5000) recent.clear(); // crude memory bound + return false; +} + +export async function POST(req: Request) { + const url = process.env.SUPABASE_URL; + const key = process.env.SUPABASE_SERVICE_ROLE_KEY; + const salt = process.env.FEEDBACK_IP_SALT; + if (!url || !key || !salt) { + return new Response(null, { status: 503 }); + } + + let body: unknown; + try { + body = await req.json(); + } catch { + return new Response(null, { status: 400 }); + } + const result = validateAppPoll(body as Record); + if (!result.ok) { + // Honeypot hits get a clean 204 so bots learn nothing. + return new Response(null, { status: result.reason === "honeypot" ? 204 : 400 }); + } + + const ip = (req.headers.get("x-forwarded-for") ?? "unknown").split(",")[0].trim(); + const ipHash = createHash("sha256").update(salt + ip).digest("hex"); + if (rateLimited(ipHash)) { + return new Response(null, { status: 429 }); + } + + // Upsert on the unique nonce: step 1 inserts the interest, step 2 merges the + // platform onto that same row. + const res = await fetch(`${url}/rest/v1/fac_app_poll?on_conflict=nonce`, { + method: "POST", + headers: { + apikey: key, + Authorization: `Bearer ${key}`, + "Content-Type": "application/json", + Prefer: "return=minimal,resolution=merge-duplicates", + }, + body: JSON.stringify({ + ...result.record, + updated_at: new Date().toISOString(), + ip_hash: ipHash, + ua: (req.headers.get("user-agent") ?? "").slice(0, 300), + }), + }); + return new Response(null, { status: res.ok ? 204 : 502 }); +} diff --git a/app/globals.css b/app/globals.css index b6ee899..3b40811 100644 --- a/app/globals.css +++ b/app/globals.css @@ -26,6 +26,10 @@ /* utilities */ .tnum { font-variant-numeric: tabular-nums; } .hairline-b { border-bottom: 1px solid var(--color-hairline); } + +/* Only one ask on screen: while the app poll holds the view, the floating + feedback pill steps aside. AppPollModal sets the attribute. */ +body[data-app-poll="open"] [aria-label="Feedback despre site"] { display: none; } .display-num { font-family: var(--font-display); font-weight: 700; letter-spacing: -0.01em; } /* ===== verdict band ===== diff --git a/app/layout.tsx b/app/layout.tsx index e866c2b..ef3b1c0 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -2,6 +2,7 @@ import type { Metadata } from 'next'; import { Inter, Source_Serif_4 } from 'next/font/google'; import Script from 'next/script'; import './globals.css'; +import AppPollModal from '@/components/AppPollModal'; import FeedbackWidget from '@/components/FeedbackWidget'; import SiteNav from '@/components/SiteNav'; import SourceFooter from '@/components/SourceFooter'; @@ -38,6 +39,7 @@ export default function RootLayout({ children }: { children: React.ReactNode }) {children} +