Build-ready · Phase 1 security

Abuse-control blueprint for contact reveals.

A concise, implementation-ready plan for rate limiting, Turnstile challenges, and daily guest-interest digests that protects providers without adding friction.

Key guardrails

  • 10 reveals / 10 minutes before Turnstile.
  • Hard cap: 60 reveals / hour per visitor.
  • 60-minute cooldown token after verification.
Per-visitor Turnstile Daily digest

Locked decisions

Non-negotiable constraints to align product, security, and comms.

Selective challenge

Turnstile appears only after the rolling 10/10min threshold. A cooldown token bypasses it for 60 minutes after success.

Guest-interest digests

Send daily emails only within the defined window, and only if guest views ≥ 3.

Per-visitor limits only

No per-location throttles in this phase. All limits are keyed to the visitor identity.

Visitor identity key

A server-derived key that rotates daily, preventing long-term tracking while keeping limits consistent per visitor.

Hash recipe

visitor_key = sha256(ip + "|" + user_agent + "|" + day_salt)
  • day_salt rotates daily to prevent long-term correlation.
  • Only the hash is stored; raw identifiers are discarded.

Reveal contact details flow

Thresholds, cooldown behavior, and API contract for contact reveals.

Thresholds

  • Allow 10 reveals / 10 minutes per visitor.
  • Reveal #11 triggers Turnstile challenge.
  • Hard cap: 60 reveals / hour returns HTTP 429.

Cooldown token

  • Issued after successful Turnstile verification.
  • Valid for 60 minutes; bypasses Turnstile.
  • Still obeys the 60/hour hard cap.

Token design

  • Signed JWT (HMAC) with visitor_key + exp.
  • scope: "reveal_bypass" to keep intent explicit.
  • Store as httpOnly cookie when possible.

Reveal endpoint

POST /api/locations/:locationId/reveal
  • Request body: cooldown_token?, turnstile_token?
  • 200: { contactDetails, cooldown_token? }
  • 403: { error: "challenge_required" }
  • 429: { error: "rate_limited", retry_after_seconds }
  1. Compute visitor_key.
  2. Validate cooldown token; if valid, skip Turnstile.
  3. Check rolling counters for 10/10min and 60/hour.
  4. Verify Turnstile if required.
  5. Return contact details and issue cooldown token if verified.
  6. If guest, increment daily rollup counter.

Guest daily interest counter + digest email

Track guest interest only after successful reveals and send a single daily digest per location when interest is meaningful.

Guest view definition

Increment guest_views only when a guest successfully reveals contact details (after any Turnstile challenge).

Rollup logic

  • Increment per (location_id, local_date).
  • Store in a daily rollup table in D1.

Daily email rules

  • Run at 18:00 Europe/London.
  • Send only if guest_views ≥ 3.
  • Respect location-level digest_enabled.

Provider preferences

  • digest_enabled: default true
  • digest_threshold: default 3 (future-configurable)

Implementation routes & storage

Suggested worker routes and Cloudflare-friendly storage choices.

Worker routes

  • POST /api/locations/:locationId/reveal
  • CRON 18:00 Europe/London daily

Rolling counters

  • Cloudflare KV (simple) or Durable Objects (precise).
  • Track 10-min + hourly reveal limits.

Daily rollups

  • Cloudflare D1 for reportable daily counts.
  • Query for digest at run-time.

Cooldown token

  • Stateless JWT (HMAC), no DB lookup required.
  • Store in httpOnly cookie or localStorage + header.

Frontend UX (exact)

Inline challenge flow that keeps the reveal action fast and clear.

  1. User clicks “View contact details”.
  2. Call reveal endpoint with stored cooldown token.
  3. If challenge_required, render Turnstile widget inline.
  4. On success, call reveal endpoint with turnstile_token.
  5. Store returned cooldown token.
  6. Display contact details.

Notes on per-visitor throttles

This phase intentionally avoids per-location limits while still deterring scraping and repeat abuse.

What it prevents

  • Scraping many locations quickly (Turnstile triggers).
  • Single-visitor spamming (60/hour hard cap).

What it does not prevent

Distributed attacks across many IPs against one provider. If this becomes a problem, add per-location throttles as a future toggle.