From 15a122017408978638bd586c644f9997c5ad0a6a Mon Sep 17 00:00:00 2001 From: tiXor-code Date: Fri, 4 Sep 2026 12:29:57 +0300 Subject: [PATCH 1/2] docs: security-gate header, and the stale :3000 server e2e trap The SEC-RULES header is the fleet-wide block every other repo's CLAUDE.md carries; it was added locally when this repo was enrolled and never committed. The Playwright note is from a real miss this session: a leftover `next start` on :3000 made `reuseExistingServer` skip the build, so the suite ran against a binary built from older source with a different NEXT_PUBLIC_ inlining. Two tests "failed" against code that was already fixed. Co-Authored-By: Claude Code --- CLAUDE.md | 10 ++++++++++ 1 file changed, 10 insertions(+) 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 From 94b26d5ddf43787c7ac910ea746c2915a1b74518 Mon Sep 17 00:00:00 2001 From: tiXor-code Date: Fri, 4 Sep 2026 12:30:13 +0300 Subject: [PATCH 2/2] poll: ask visitors on an answer page whether they want an app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A centred dialog on /strada/ and /punct-termic/ pages asks "Te-ar interesa o aplicație pentru faraapacalda.ro?", then Android or iOS. It holds the screen until answered or closed, per the brief. Deliberate choices worth reviewing: - It fires 10s after load, not on arrival. A dialog that covers content the moment someone lands from search is what Google treats as an intrusive interstitial, and this site lives on organic search. - Two steps, ONE row, upserted on a client-generated UUID nonce. Tapping "Da" writes immediately, so closing before picking a platform still counts as a "Da" instead of vanishing. Verified against the real table: insert 201, merge 200, one row with the platform merged in. - A new fac_app_poll table rather than reusing fac_feedback, because GET /api/feedback counts every vote=eq.up with no page filter — poll answers landing there would inflate the public "N" badge on the live site. RLS on, zero policies, same as fac_feedback: only the service role reaches it. - No GET route. Unlike the feedback tally there is nothing here to expose. - The feedback pill hides while the dialog is open (one ask at a time), via a body data attribute rather than coupling the two components. - The delay is overridable so the e2e build can push it out of reach; a spec that merely visits a street page can never race a screen-covering modal. Kill switch: NEXT_PUBLIC_APP_POLL=0. 12 unit tests, 9 e2e. Escape is bound at document level, not on the backdrop — a backdrop click moves focus to , which silently killed Escape and left the x as the only exit from a dialog that locks scroll. Found in visual verification, pinned by a regression test. Co-Authored-By: Claude Code --- app/api/app-poll/route.ts | 71 +++++++++++ app/globals.css | 4 + app/layout.tsx | 2 + components/AppPollModal.tsx | 248 ++++++++++++++++++++++++++++++++++++ e2e/app-poll.spec.ts | 151 ++++++++++++++++++++++ lib/app-poll.ts | 61 +++++++++ playwright.config.ts | 10 +- test/app-poll.test.ts | 99 ++++++++++++++ 8 files changed, 645 insertions(+), 1 deletion(-) create mode 100644 app/api/app-poll/route.ts create mode 100644 components/AppPollModal.tsx create mode 100644 e2e/app-poll.spec.ts create mode 100644 lib/app-poll.ts create mode 100644 test/app-poll.test.ts 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} +