2026 · ATTENTIO — Association of Adults with ADHD

From a signature to steady support

ATTENTIO's new petitions and donations service: from a click on a search ad, through a petition signature and a payment, to reminders and monthly support. Designed for adults with ADHD — people a form can easily lose — and built so every step can be measured.

Role End-to-end design and build — Jakub Nowak

See the live petition(opens in a new tab)
ATTENTIO
  • 100/100Performance, accessibility, best practices and SEO in PageSpeed Insights — on mobile
  • 3/3Agentic browsing: ready for AI assistants
  • 1 731test cases in 278 files, including gates proven by sabotage

Scope

  • Funnel strategy & UX
  • UI & design system
  • Front-end
  • Back-end & payments
  • Measurement & campaigns
  • Accessibility
  • Performance
  • Security & privacy

Next.js 16 · React 19 · Payload CMS 3 · PostgreSQL + Drizzle · Stripe · Vercel · Cloudflare Workers

Stage 1 of 9 · Ad

A click Google pays for

Google Ad Grants gives non-profits up to USD 10,000 a month of search-ad credit. The service makes sure every click can be traced all the way to a donation.

Why it works this way

  • One petition = one landing page. The ad leads straight to the petition.
  • Campaign links are built in the admin panel only from registered UTM keys.
  • A gclid doesn't reveal which account the click came from, so conversions are routed to the Grants or the paid account by campaign tags — each account has its own actions: SIGNATURE, DONATION, UPSELL.
  • For Grants the report shows cost per signature in credit, not ROAS.

The attribution path: from click to conversion

  1. Clickgclid · gbraid · wbraid · UTM
  2. Click memoryfirst & last touch, HMAC-signed, 90 days
  3. Snapshotimmutable, stored next to the signature
  4. Donationinherits the signature's snapshot — even weeks later
  5. Conversion queuesuccess only: signature, booked donation, subscription
Google Ads · Data Manager APIMeta · Conversions APITikTok · Events API
Under the hood: measurement, consent and landing-page quality
  • Conversions are sent server-side from a queue processed daily — never during the visitor's request. An ad platform outage can't slow down the thank-you page.
  • Only final outcomes enter the queue. A payment reported before it's booked “teaches the bidding algorithm to buy traffic that doesn't pay”. A refund corrects the net value.
  • A shared event ID for pixel and server removes duplicates. Retries: 1 min, 5 min, 30 min, 2 h, 6 h, 24 h; events older than the platform's window are dropped before sending.
  • Consent Mode v2 in advanced mode, everything denied by default. Consent and success are re-checked at send time.
  • A build gate rejects event names that sound health-related — one badly named event could get the whole dataset flagged by a platform.
  • Landing-page experience feeds the ad's quality — which is why the petition page scores 100 in PageSpeed Insights on mobile.

Stage 2 of 9 · Petition

A page with one goal

An ad paid for this visit, so every pixel above the form is a decision. On mobile the order in the code is the reading order.

The petition page on a phone — production screenshot, names replaced with examples

Why it works this way

  • The sticky “Sign” button appears on mobile only when no part of the form is visible.
  • It leads to a separate form page; whatever was already typed travels in the tab's session storage — never in the URL or a cookie, because URLs reach ad pixels as Referer.
  • The counter refreshes every minute and on tab return, from an edge cache: a thousand open tabs is still one database read a minute.
  • Petition content is cached for days, signature state for seconds; the form waits for nothing.
The petition page on desktop — production screenshot
Under the hood: the new-signature animation
  • When a signature arrives, the newest person slides in, pushes the others and the third leaves — FLIP, only transform is animated.
  • Three Material 3 motions read from design tokens: enter 300 ms emphasized-decelerate, shift emphasized, exit 200 ms emphasized-accelerate.
  • It runs only when the count actually rose; refreshes never go to aria-live, so screen readers aren't interrupted every minute.
  • The “recent” list is redacted at write time to first name and initial — the read path physically has no access to the surname.

Stage 3 of 9 · Signature

A signature without friction

The signature is the page's only conversion, so it always works — even when JavaScript doesn't load.

Why it works this way

  • A server form: POST → 303 → GET. Script only adds comfort — some paid mobile traffic has JavaScript blocked.
  • Calm errors: nothing shouts while typing. After submitting — a summary linking to the fields; an error vanishes the moment the field is valid.
  • The button instantly says “Sending…”.
  • The same validation in the browser and on the server, on zod/mini.

Validation in the landing-page bundle (gzip)

zod64 889 B
zod/mini5 212 B
Under the hood: signature privacy
  • E-mail and phone: an HMAC-SHA256 hash with a pepper for lookups and an AES-256-GCM ciphertext with the key kept only in the environment — a leaked database dump doesn't hand over both ciphertext and key.
  • IP addresses are never stored raw — only as a hash, with IPv6 truncated to /64.
  • The consent log stores a hash of the exact clause text; a canary test forces a new version if a single word changes. The UI splits that very string, so displayed and stored text can't drift apart.
  • Signing again looks exactly like a new signature — the form never reveals whether an e-mail is already known.
  • The welcome e-mail goes out after the response is sent, so the click moves on instantly; the counter on the thank-you page already includes your signature.

Stage 4 of 9 · Thank-you

First we say thank you. Then we ask.

The thank-you page is the bridge from signature to donation. Its layout follows the order of decreasing cost to the person who just signed.

01 confirmation02 sharing03 money
Thank-you page — production screenshot, donation data hidden

Wesprzyj nasze działania!

Petycję trzeba dowieźć: prawnik, który napisze stanowisko, spotkanie w ministerstwie, dojazd.

Wybierz kwotę:

200 zł150 zł100 zł70 zł55 zł35 zł

Why it works this way

  • No menu or footer: a funnel step has counted exits, and the only one is “Back”.
  • Under the heading, the same progress bar with a green “+1” — the counter already includes your signature.
  • We ask for e-mail consent only if it wasn't given when signing — checked in the database, not in a cookie.
  • The donation card in ATTENTIO orange has dark text (5.56:1); white would give only 3.16:1.
  • Six amounts (200 · 150 · 100 · 70 · 55 · 35 zł) — each tile opens the form with that amount and the monthly cycle preselected.

A/B experiment on the ask

A

„Petycję trzeba dowieźć: prawnik, który napisze stanowisko, spotkanie w ministerstwie, dojazd…”

B

„Robimy to dla dorosłych z ADHD — żeby diagnoza, leczenie i codzienne sprawy przestały być torem przeszkód…”

  • Assignment is a pure SHA-256 function of the visitor ID — 50/50, no separate variant cookie.
Under the hood: sharing
  • Touch devices get one Web Share button; desktop gets channels ordered by reach — each link with its own UTM tag.
  • The share text speaks for the signer: “I signed this petition. Will you? It takes a minute.”
  • The card's ASCII background: a sum of three sine waves, 6 fps, a full cycle every 30 s — about 90× below the WCAG flash threshold.

Stage 5 of 9 · Donation

Five steps, no guesswork

The donation form is a decision system: amount, cycle, method, details, consent. Each choice has a rule — and every rule works without JavaScript too. Below are the first three steps with the real rules.

1 Wybierz kwotę wsparcia
2 Rodzaj wpłaty
3 Metoda płatności

Monthly gifts take cards and wallets — BLIK, Revolut Pay and transfer stay for one-time gifts.

Path: Stripe Checkout · subscription

Why it works this way

  • 11 amounts plus “Other” make two full rows of six. 800 instead of 700, because 700 would sit right above 70 zł.
  • 100 zł is preselected and “Recommended” sits inside the label, so screen readers announce it. On mobile six amounts first, the rest behind “Show more”.
  • “Monthly” is the default, but “One-time” sits next to it at the same size.
  • The button always says what will happen: “I'm giving 100 zł monthly” — even without JavaScript, thanks to CSS :has().
  • Details of someone who just signed are prefilled — from an encrypted cookie, never the URL. Consent isn't asked twice if it was given when signing.

Four browser paths, one server core

  • Card field on the donation pagePayment Element
  • BLIK code dialogPaymentIntent · blik
  • Wallet buttonExpress Checkout
  • Stripe page (monthly, no-JS, fallback)Checkout

przygotujWplate()

One core, because two paths building the same return URL in two ways is a recipe for a bug.

The donation form on mobile — production screenshot
The donation form on desktop — production screenshot, fundraiser totals blurred
Under the hood: amounts and limits
  • The amount parser accepts “1 000,50”, “30.50” and “zł” and rounds to whole grosze (30.10 × 100 is 3009.999… in floating point).
  • Minimum 2 zł (the operator's PLN minimum), BLIK up to 2,000 zł, a 50,000 zł maximum as a typo guard.
  • The monthly billing day is min(day, 28) — no month is skipped and March never gets two.
  • No fake “most popular” ranking — a stated rule instead of pretend data.

Stage 6 of 9 · Payment

At the payment step

One shared rule: payment data never touches the ATTENTIO server. Cards live in Stripe's frames and the BLIK code goes from the browser straight to the operator — which is why the PCI DSS scope is SAQ A.

Twoja wpłata100 złco miesiąc

Metoda płatności

  • Karta
  • BLIK
  • Apple Pay
  • Google Pay
Stripe frame1234 1234 1234 1234MM / RRCVClink Zapłać szybciej z Link

Co miesiąc 100 zł · rezygnacja jednym kliknięciem

Wpłacam 100 zł

Płatność obsługuje Stripe

01 · Card

The card stays in the operator's frame

  • The Stripe card field runs in deferred mode: the card format is checked first and only then is a payment created — a mistyped card leaves no trace at the operator.
  • The field's look is measured from our own form field in pixels (rem inside the frame is relative to Stripe's root), keeping a 48 px touch target inside the frame too.
  • The payment is restricted to exactly the chosen method — no second method question, and statements reconcile correctly. Link works inside the card field.
Twoja wpłata70 złjednorazowo

Metoda płatności

  • Karta
  • BLIK
  • Apple Pay
  • Google Pay

Płatność kodem z aplikacji banku

  1. Kliknij „Wpłacam 70 zł”
  2. Wpisz 6 cyfr kodu
  3. Zatwierdź w aplikacji banku

Płatność obsługuje Stripe

02 · BLIK

The code only after the click, the bank within 60 seconds

  • The code is asked for only after “Pay”, in a dialog.
  • The code goes from the browser to the operator, never through our server. The payment is created only after the code is typed.
  • The “Approve in your bank app” panel with a 60 s countdown appears BEFORE the bank answers — because the operator call only returns once the bank decides.
  • After a decline the used code is cleared.
Twoja wpłata100 złco miesiąc

Metoda płatności

  • Karta
  • BLIK
  • Apple Pay
  • Google Pay
Apple PayAnuluj
Karta
Visa •••• 4242
Odbiorca
ATTENTIO
Razem
100,00 zł

Double-click the side button

Płatność obsługuje Stripe

03 · Wallets

Only the wallet that was chosen

  • The wallet button shows only the wallet the donor picked.
  • The payment is created only on confirmation in the sheet; the domain is registered and Apple's verification file is served from the domain root.
Twoja wpłata100 złco miesiąc

Metoda płatności

  • Karta
  • BLIK
  • Apple Pay
  • Google Pay

Coś nie działa?

Ta sama kwota i ten sam cykl na bezpiecznej stronie Stripe’a.

Zapłać na stronie Stripe’a

Formularz renderowany na serwerze — działa bez JavaScriptu

Płatność obsługuje Stripe

04 · Fallback page

There is always a second way to give

  • The payment page always has a server-rendered fallback to Stripe Checkout — also the path for monthly gifts and no-JS visits.
  • Before opening Checkout the fallback cancels the original payment; if that one is already paid or processing, it refuses. Otherwise a donor could pay twice.
  • The payment secret reaches the payment page in a signed HttpOnly cookie valid for 15 minutes, never in the URL — full URLs reach ad platforms.

Methods

  • CardVisa · Mastercard · Linkone-timemonthly
  • Apple Pay · Google Paythe phone's walletone-timemonthly
  • BLIKa code from the bank appone-time
  • Revolut Payvia Stripe Checkoutone-time
  • Bank transfera page with a generated referenceone-time

The payment page's shield

  • CSP nonce + strict-dynamic, no unsafe-inline
  • frame-src · connect-src Stripe and Link only
  • worker-src'none'
  • form-action'self' + Checkout
  • Headers HSTS · COOP · X-Frame-Options DENY
  • Permissions-Policy 12 features off, “payment” deliberately not

PCI DSS scope: SAQ A

Under the hood: script inventory and header monitoring
  • Every script on payment pages has a written justification in an inventory; a test fails when an unjustified script appears.
  • The tag manager is switched off on payment routes by a gate placed before the config is read; entering a payment page is a full load, not a client-side navigation.
  • A daily job compares production headers and scripts to a frozen baseline; a new external script is a blocking difference.
  • worker-src 'none' because WordPress runs on the same domain and its service worker could see the payment page.

Stage 7 of 9 · Booking

Only a confirmation books money

Clicking “Pay” books nothing yet. The server action ends at “pending” at most — “booked” is set only by the operator's webhook or the nightly reconciliation.

Dziękujemy za wpłatę!

100 zł · miesięcznie

Server side

inicjowanaoczekujezaksięgowananieudanaanulowanazwróconaUPDATE … WHERE status IN (…)a late event can't roll state back
  • checkout.session.completed
  • checkout.session.async_payment_succeeded
  • checkout.session.async_payment_failed
  • payment_intent.processing
  • payment_intent.succeeded
  • payment_intent.payment_failed
  • charge.refunded
  • invoice.paid
  • invoice.payment_failed
  • customer.subscription.deleted

Why it works this way

  • Row first, money second: the donation (and subscription) exists in the database before the first call to the operator.
  • Idempotency: a deterministic key from session, amount and channel, sent as the Idempotency-Key. A 5-minute window tells a double click from a new, deliberate donation.
  • A webhook without an SDK: HMAC-SHA256 signature checked by hand, 300 s tolerance, constant-time comparison. An inbox keyed by event ID — a duplicate is skipped only if the original succeeded.
  • The operator's amount must equal the reserved amount, otherwise the event is rejected and retried.
  • The list of ten handled events is read from the route's code and frozen by a test — an event that's handled but not enabled at the operator simply never arrives.

Subscriptions and nightly reconciliation

  • A subscription becomes active only on a payment event.
  • Every day at 01:00 UTC donations stuck in “pending” are checked with the operator and closed by the same functions as the webhook; abandoned ones after 72 hours.
  • Partial refunds accumulate in a separate column.
Under the hood: the emergency brake
  • Accepting donations can be paused with one setting.
  • A channel without credentials ends in a named refusal.
  • Webhook tests run the real route against Postgres compiled to WebAssembly.

Stage 8 of 9 · Reminders

Signed, didn't donate: four messages

Two remarketing channels: our own messages, and server-side signals ad platforms build audiences from.

interactive mock-up · copy from the service's templates
  • +1 h e-mail — „Wróć do wpłaty — zajmie minutę”
  • +3 d e-mail — „Na co pójdzie Twoja wpłata”
  • +10 d SMS — scheduled
  • +30 d e-mail — scheduled

Odebrane

  • ATTENTIONa co pójdzie Twoja wpłata
  • ATTENTIOWróć do wpłaty — zajmie minutę

ATTENTIO

Konkretnie: na co idą pieniądze

Podpisów przybywa, ale sam podpis nie opłaci konsultacji prawnej ani wysyłki pism do ministerstwa. Jedna kwota = jedna godzina pracy prawnika.

Wesprzyj petycję

Why it works this way

  • Before every step the system excludes people who already donated, have a monthly gift (active, pending or paused), lack consent, or got an ask in the last 24 hours.
  • Copy rules: one message = one action = one button.
  • +1 h: “the most effective moment of the cadence: the person still remembers why they came”. SMS only 9:00–20:00, e-mails 6:00–22:00.
  • The last message really is the last. Unsubscribing is one click, no questions, with List-Unsubscribe One-Click (RFC 8058).

A daily job, hourly steps

Each step has a 36 h catch-up band and a 24 h repeat grace — chosen so nothing is missed and nothing goes out twice:

26 h longest gap<36 h band<71 h shortest step gap
14 h band overlap<24 h grace<71 h

“A skipped reminder is a loss, a repeated one is harm” — so the token is created before sending, not after.

Under the hood: safe links and signals for ad platforms
  • Each message carries two separate tokens — for donating and for unsubscribing. A shared one would lead “unsubscribe me” to the donation form, and people then click “spam”.
  • A link acts only on a real navigation (Sec-Fetch headers), so mail scanners don't burn it; then a 303 to a clean URL so the token never reaches pixels via Referer.
  • The SMS link carries no person ID — someone who entered another person's e-mail with their own number can't open that person's panel.
  • Attribution survives the e-mail loop: a donation from a reminder three weeks later is credited to the campaign that bought the signature.
  • Remarketing on platforms: audiences form in Google, Meta and TikTok from server-sent events — a signature without a later donation, a click on a reminder. Only with ad-personalisation consent; consent can only lower the data level.
  • Contact hashes for platforms are a separate unpeppered SHA-256 with Polish-letter normalisation (“Łukasz” doesn't become “ukasz”); raw IPs never leave.

Stage 9 of 9 · Monthly

From one-time to monthly: one offer

“Monthly” is the default across the funnel, but never forced. A one-time donor gets a single offer to switch — and a monthly donor can stop with one click.

  1. day 0 one-time gift
  2. day 21 one offer
  3. day 45 outer limit
interactive mock-up · the service's own rule

100 zł

max(10 zł, gift ÷ 3, rounded to 5 zł)

Proposal35 zła month

Zamienić na 35 zł miesięcznie?30/45 chars

ATTENTIO

Jedna propozycja

Regularna wpłata pozwala planować dłużej niż do końca kwartału — a przy pracy z ministerstwem wszystko trwa dłużej niż kwartał. Wpłatę cykliczną zatrzymujesz albo wstrzymujesz na miesiąc w panelu, jednym kliknięciem.

Ustaw wpłatę miesięczną

Why it works this way

  • Day 21 after a booked gift: “earlier is too early (…the ask sounds ungrateful), later the gift is no longer remembered”.
  • Anyone with a monthly gift — paused included — and anyone without e-mail consent is skipped.
  • The link opens the form with the monthly cycle, and the resulting conversion goes back to the campaign that brought the first gift.
  • A monthly gift reaches ad platforms as a separate event valued at 12 × the monthly amount — a recurring donor is a commitment, not one payment. They also drop out of acquisition campaigns.

The “My support” panel

Wpłata miesięczna

100 zł / mies.

Aktywna

“A cancellation maze hits people with ADHD harder.”
Under the hood: pause, cancel, instalments
  • A one-month pause suspends collection at the operator with a resume date — plus voiding already-open invoices, which the operator would otherwise keep retrying.
  • Cancelling ends all of the person's monthly gifts in one click, with no confirmation dialogs; the panel works without JavaScript.
  • Changes made on our side but not yet at the operator are pushed there by the nightly reconciliation.
  • Monthly recurring revenue is rebuilt from the subscription event log, not current state — so history doesn't rewrite itself.

Agentic Giving

More and more people ask an AI assistant instead of searching. The service speaks the assistants' languages: an assistant finds the petition, learns the rules and prepares a donation — and the last step, approving the payment, always belongs to a person.

I'd like to support the petition on reimbursing adult ADHD treatment with 50 zł.

attentio-przygotuj-darowizne · kwotaZl: 50

I've prepared a 50 zł donation to this petition. Open the page and confirm the payment — the amount is already filled in.

attentio.org.pl/petycje/…/wesprzyj · 50 zł

A person approves the payment

Why it works this way

  • The MCP server never charges a card: it returns the donation page with the amount filled in. A chat window has no payment instrument, so a charge needs a credential only a person brings.
  • The tool accepts no personal data — the person types their e-mail on the page.
  • If the database is down the tool still returns the URL: losing the row loses attribution, losing the URL loses the donation.
  • A plain GET to the server returns a readable description instead of a 405 — an assistant that looks with a browser won't give up.
“The server does NOT charge the card — a person takes the last step.”
from the MCP server's description
  • MCPa remote server in the official MCP registry: four tools — petitions, donation catalogue, rules, preparing a donation
  • WebMCP7 tools described on the site's forms and 3 read-only tools, no auto-submit
  • UCPa /.well-known/ucp profile, checkout sessions, Google Pay token, RFC 9421 HTTP signatures
  • ACPa /.well-known/acp.json document, checkout sessions with Shared Payment Token and order notifications
  • AP2SD-JWT payment mandates verified at completion, a merchant signature on every response
  • Stripe ACSa donation catalogue for shopping agents in the operator's format
  • Web Bot Authagent identity verified by signature — for the log and attribution
  • llms.txta description of the service for language models, served from the domain root

One core, thin bindings

  • Every protocol uses one donation-session core — the same one behind the site's form. A session is an ordinary donation row in the “initiated” state, no separate table.
  • The session ID is an HMAC of the platform and the idempotency key: a repeated request hits the same row, the same key with another basket is refused.
  • Catalogue: the 25–1000 zł ladder plus a 1 zł “brick” × quantity for a custom amount. An assistant can show monthly support, but setting it up leads to the site.
  • 3-D Secure, which can't happen inside a chat, turns into a hand-off to a person with a link to finish.
  • A donation from an agent gets agent / protocol / platform attribution without cookies, so campaign reports see this channel next to ads.

The “Ask an assistant” page

Five buttons open popular assistants with a ready question about ATTENTIO's petitions. Copy rule: never “the assistant will pay for you”.

  • ChatGPT
  • Gemini
  • Claude
  • Copilot
  • Perplexity
Under the hood: the MCP server
  • The JSON-RPC core is written without an SDK to keep dependencies out of the PCI scope; it supports two eras of the MCP protocol (with and without sessions).
  • The server is listed in the official MCP registry with a domain-ownership proof served from the root.
  • Methods polled by bots (initialise, list tools, ping) don't wake the database — their counters live in memory.

Four hundreds — on mobile

PageSpeed Insights report for the petition page the ads lead to. Speed here is an acquisition cost, not decoration.

100
Performance
100
Accessibility
100
Best practices
100
SEO
3/3
Agentic browsing
PageSpeed Insights report screenshot: 100, 100, 100, 100 and 3/3
  1. −60 kBvalidation on zod/mini instead of full zod
  2. −20,6%blocking CSS: a new core without illustration masks, then a per-route split — 5,407 elements × 45 properties compared, 0 differences
  3. −25 kBfonts trimmed to used axes and glyphs; fallbacks calibrated on a Polish sentence: CLS 0.031 → ≈0
  4. −1 250 msthe LCP image (4 KB AVIF) inlined into the HTML — no more queueing behind the document
  5. 99,55brand font only after LCP and one network step less — Lighthouse rounds it to 100
  6. 100in PageSpeed Insights on mobile
Under the hood: what else was measured
  • CSS inlined into the document: FCP 1,804 → 1,001 ms locally, at a cost of 21 kB per document.
  • The Onest font cut from 29,656 to 26,948 B — below the 27,500 B step where Lighthouse's network model gains a full 75 ms.
  • no-store removed from public pages: instant Back from bfcache; pages with personal data stay uncached.
  • The document shrank by 22% once the 404 page tree stopped being serialised into every page.

Designed for attention that wanders

Here the design system and accessibility are one decision: for adults with ADHD every needless choice, puzzle and maze is a cost, not a detail.

Motion: Material 3 Expressive springs

Six springs precomputed at build time into CSS linear() curves — exact for any distance, with no JavaScript in the browser. Pick one:

default spatialζ 0,8 · 431 ms

Contrast on orange

Wesprzyj

Seed #F26430 in the FIDELITY variant: the build asserts the primary container is exactly the seed. Contrast level 0.2 instead of 0 lifts text-on-container pairs from 4.53 to 5.56:1. CI guards 74 pairs and 14 banned ones.

Two-tone focus

No single colour reaches 3:1 on both white and orange — so the ring has two: a dark outline and a light halo. Try it with Tab.

Glyph contract

ĄĆĘŁŃÓŚŹŻ ąćęłńóśźż „” –—

A missing Polish character in a font subset fails the build. Bricolage Grotesque (headings), Onest (text), Atkinson Hyperlegible Next in reading mode — all self-hosted.

12 M3 Expressive shapes

polygon() only — the library went from 30 to 16.8 kB.

Principles that follow from ADHD

  • No cancellation maze, no two buttons in one message.
  • Calm validation and forms that remember what was typed (WCAG 3.3.7 — redundant entry).
  • DOM order = visual order, 48 px touch targets, labels matching the accessible name.
  • Reduced motion stops looping animations without breaking anything; reading mode switches to Atkinson Hyperlegible Next.
  • Legal documents are written in gender-neutral Polish — a test enforces it.
  • Accessibility tests: axe plus a separate “what axe can't do” suite — reflow at 320 px, text spacing, target spacing.

What you don't see holds the rest up

Architecture, privacy and quality — designed as carefully as the screens.

One domain, two worlds

attentio.org.plworkerCloudflare/petycje → Next.jsVercel · Frankfurtrest of the domainWordPress

The worker also merges two sitemaps into one and serves a short list of app paths from the domain root (llms.txt, verification files). Moving another section of the site means adding its prefix.

Six daily jobs (UTC)

  • 01:00 payment reconciliation
  • 03:00 cleanup & retention
  • 04:00 payment-page headers
  • 05:00 conversion upload
  • 06:00 edge & origin health
  • 08:00 reminders & offers

Privacy by design

  • Publicly only a first name and initial are shown. The petition's addressee gets name, surname and date — no contact data — in a CSV protected against formula injection.
  • Self-hosted fonts, no Google Fonts CDN. The editors' panel uses a second factor: WebAuthn passkeys — a password alone never creates a session.
  • The test environment has its own empty database built from migrations; a non-production deployment refuses to connect to the production database by its fingerprint.
  • Endpoints polled by bots don't wake the database — their counters live in memory.

Every release passes the gates

  1. prebuild
  2. typecheck
  3. test
  4. check:measurement
  5. test DB
  6. schema
  7. build

A red test blocks the release, emergency fixes included. Most gates record a sabotage — a deliberate break proving they turn red. Plus tests in a production-shaped environment and scanners: Snyk, Semgrep, Aikido, Socket, and a malicious-package filter at install time.

A supply chain under control

The service takes money, so every dependency is part of the payment page. Four scanners and a malicious-package filter answer four different questions — from the moment a package leaves the registry to the release.

package registry

  1. Will this package even install?Safe Chainmalicious-package list · release age ≥ 48 h
  2. Does a dependency misbehave without a known vulnerability?Socketinstall scripts · network access · typo-squatted names
  3. Is there a known vulnerability in the dependencies?npm audit · Snyk Open Source“high” threshold, production dependencies only
  4. Is there a hole or a secret in our code?Semgrep · Snyk Code · Aikidostatic analysis · secrets · infrastructure config

release

Malicious-package filter

A bad package never reaches the disk

Where it runs
a local proxy between the package manager and the registry
What leaves
nothing about our build — it only downloads lists
When
on every install: workstation, CI, hosting build
  • Wrappers on npm, npx, pip and uvx route downloads through a local proxy. It rejects packages on the malicious list and versions published less than 48 hours ago — poisoned releases are usually caught and pulled within that time.
  • It sits in three places: before dependencies install on the workstation, before every npm ci in GitHub Actions and in the hosting build's install command. An install error stops the deployment — the previous version stays live.
  • The installer's version and SHA-256 live in one place in the repository, and the installer checks the checksum of the program it downloads. A test makes sure no one quietly adds an exception to the 48-hour rule.
  • Its limit is written down: a package served from npm's local cache doesn't pass the proxy — the lockfile checksum protects it then.

Dependency behaviour

It judges packages, not our code

Where it runs
the Socket service — analysis of npm registry packages
What leaves
package name and version; source code — no
When
a report on every push to main
  • The only one in the set that looks at a package's BEHAVIOUR rather than a list of known vulnerabilities: install scripts, network access, obfuscated code, names typo-squatting popular packages.
  • It also serves as a check before a dependency bump: a score for the new version and a look at its install scripts before it enters the lockfile.
  • Alerts are closed in code: a version override in the dev-tool chain, or a stub in place of a package a dependency declares but never loads — the stub throws if anything ever reaches for it.

Vulnerabilities and data flow

Dependencies and code, traced across files

Where it runs
Snyk's cloud
What leaves
the dependency tree; for code analysis — source files
When
on demand, via two scripts: code and dependencies
  • Snyk Open Source reads the dependency tree from the lockfile and checks it against its vulnerability database — next to the npm audit gate, not instead of it.
  • Snyk Code traces data flow from source to sink across files, which the free local tools don't.
  • Code analysis sends files to the cloud, so a policy excludes the old site's content export and the built CSS — only our own code is scanned.
  • The tool version is pinned and guarded by a test: “latest” on every run is code nobody has reviewed.

Static analysis, locally

The code stays put; only rules come from the network

Where it runs
locally, on the workstation
What leaves
no code is sent; call tracing is off
When
on demand, via a script
  • Semgrep Community Edition analyses code in place; it fetches only rule sets from the network.
  • Custom rules turn one bug found into a question about the whole repository: does the same pattern sit anywhere else?
  • It caught what the other tools didn't — which is why the set has several code scanners, not one.

Code, secrets and infrastructure

Three engines in one scan

Where it runs
locally: static analysis, secrets, infrastructure config
What leaves
findings with paths; a secret masked except for its last 4 characters
When
before every commit (secrets) and on release in CI
  • One scan runs three engines: static code analysis, a secrets search in working files and an infrastructure-config check (hosting, edge worker, CI workflows).
  • A pre-commit hook looks for secrets before they reach git history — once there they can't be removed, only revoked.
  • The release scan in CI runs in report mode: a finding goes to review instead of blocking a release without human judgement.

How findings are handled

  • A scan result is a lead to verify, not proof: each one is checked in the code — is there a real path from a donor's request to the flaw — before anything changes.
  • Every tool has a pinned version and a test that turns red if someone unpins it or loosens its configuration.
  • Locally run tools start with an allow-listed environment.

The dependency gate in every release

npm run audit

  • threshold: “high” and above
  • production dependencies only — dev tools don't ship
  • a closed list of exceptions, each with a deadline
  • an exception expires by itself once a fix ships

One person, nine stages, one system

  1. Ada Grants click lands on the petition with its attribution remembered
  2. Petitionform before content, social proof right above it
  3. Signatureworks without JavaScript, one transaction
  4. Thank-youthank first, ask second
  5. Donationmonthly by default, one-time on equal footing
  6. Paymentcard data only at the operator, four paths, one core
  7. Bookingonly a confirmation sets “booked”
  8. Remindersfour messages
  9. Monthlyone offer on day 21; pause and cancel in one click

Role End-to-end design and build — Jakub Nowak

Let's talk about this project