Spam protection for public forms: honeypot, signed timing, Turnstile or hCaptcha and Postgres rate limits
Install
genpm add @core/antispamWhat you get
- Source in src/lib/antispam/, 9 files. (16.2 kB)
- AI rules in src/lib/antispam/AGENTS.md, plus IDE rule files.
- Env vars added to .env.example: ANTISPAM_SECRET, TURNSTILE_SITE_KEY, TURNSTILE_SECRET_KEY, ANTISPAM_IP_HEADER, ANTISPAM_TRUSTED_PROXIES.
- Resolves @core/db for you.
README
This package has no README.
This is exactly what your AI reads when it works in src/lib/antispam. Nothing else is added to its context.
@core/antispam — rules for AI agents
Purpose
Protects public endpoints that write (forms, comments, newsletter, login): honeypot field, signed minimum-time token,
captcha (Cloudflare Turnstile or hCaptcha, optional) and fixed-window rate limits stored in Postgres with HMAC-hashed
keys (no plain IPs or emails). Not a WAF or DDoS protection. Table: rate_limits.
Map
index.ts— public API:verifyHuman,rateLimit,formTimingToken,honeypotProps,verifyCaptcha,clientIp,pruneRateLimits.verify.ts— the full check.checks.ts— individual checks.rate-limit.ts— limiter.schema.ts— table.react.ts—<AntispamFields />(async server component) with the honeypot and the signed timing field for HTML forms.
Integration
- Env:
ANTISPAM_SECRET(≥ 32 random chars). Optional captcha:TURNSTILE_SITE_KEY+TURNSTILE_SECRET_KEY(orHCAPTCHA_SECRET_KEY). Generate and apply migrations (seesrc/lib/db/AGENTS.md). Client IP source (rate limits): by default the right-mostx-forwarded-forhop, skippingANTISPAM_TRUSTED_PROXIES - 1more hops (default1: the address your last proxy saw). Behind a platform that sets a dedicated header, setANTISPAM_IP_HEADER(cf-connecting-ipon Cloudflare,x-vercel-forwarded-foron Vercel,x-real-ip,fly-client-ip…). Platform headers are never trusted unless configured: clients can send them. - When rendering the form (server), add two fields:
<input {...honeypotProps} />and<input type="hidden" name="_ts" value={await formTimingToken()} />. With Turnstile, also render its widget (<div class="cf-turnstile" data-sitekey={TURNSTILE_SITE_KEY}>and its script). - In the handler, before doing anything else:
const check = await verifyHuman(request.headers, formData, { scope: 'forms:contact' }); if (!check.ok) return Response.json({ error: check.reason }, { status: check.reason === 'rate_limited' ? 429 : 400 }); - Per-account limits (login, password reset):
await rateLimit(login:${email}, { limit: 5, windowMs: 900_000 }). - Schedule
pruneRateLimits()daily (e.g. with @core/jobs). - Verify: submitting within 2 s or with the honeypot filled is rejected; a normal submission passes.
Conventions
- Every public write endpoint calls
verifyHuman(or at leastrateLimit) with its ownscope. - Show users a generic error; don't reveal which check failed beyond "try again later".
clientIpis only as reliable asANTISPAM_IP_HEADER/ANTISPAM_TRUSTED_PROXIESmatch your proxies; use it for limits, never for authorization.
Don't
- Don't store raw IPs, emails or captcha tokens;
rateLimitalready hashes keys. - Don't hide the honeypot with
type="hidden"(bots skip those); usehoneypotProps. - Don't skip the captcha check when the provider is down:
verifyCaptchafails closed on purpose.
# @core/antispam — rules for AI agents
## Purpose
Protects public endpoints that write (forms, comments, newsletter, login): honeypot field, signed minimum-time token,
captcha (Cloudflare Turnstile or hCaptcha, optional) and fixed-window rate limits stored in Postgres with HMAC-hashed
keys (no plain IPs or emails). Not a WAF or DDoS protection. Table: `rate_limits`.
## Map
- `index.ts` — public API: `verifyHuman`, `rateLimit`, `formTimingToken`, `honeypotProps`, `verifyCaptcha`, `clientIp`, `pruneRateLimits`.
- `verify.ts` — the full check. `checks.ts` — individual checks. `rate-limit.ts` — limiter. `schema.ts` — table.
- `react.ts` — `<AntispamFields />` (async server component) with the honeypot and the signed timing field for HTML forms.
## Integration
1. Env: `ANTISPAM_SECRET` (≥ 32 random chars). Optional captcha: `TURNSTILE_SITE_KEY` + `TURNSTILE_SECRET_KEY`
(or `HCAPTCHA_SECRET_KEY`). Generate and apply migrations (see `src/lib/db/AGENTS.md`).
Client IP source (rate limits): by default the right-most `x-forwarded-for` hop, skipping
`ANTISPAM_TRUSTED_PROXIES - 1` more hops (default `1`: the address your last proxy saw). Behind a platform that sets
a dedicated header, set `ANTISPAM_IP_HEADER` (`cf-connecting-ip` on Cloudflare, `x-vercel-forwarded-for` on Vercel,
`x-real-ip`, `fly-client-ip`…). Platform headers are never trusted unless configured: clients can send them.
2. When rendering the form (server), add two fields:
`<input {...honeypotProps} />` and `<input type="hidden" name="_ts" value={await formTimingToken()} />`.
With Turnstile, also render its widget (`<div class="cf-turnstile" data-sitekey={TURNSTILE_SITE_KEY}>` and its script).
3. In the handler, before doing anything else:
```ts
const check = await verifyHuman(request.headers, formData, { scope: 'forms:contact' });
if (!check.ok) return Response.json({ error: check.reason }, { status: check.reason === 'rate_limited' ? 429 : 400 });
```
4. Per-account limits (login, password reset): `await rateLimit(`login:${email}`, { limit: 5, windowMs: 900_000 })`.
5. Schedule `pruneRateLimits()` daily (e.g. with @core/jobs).
6. Verify: submitting within 2 s or with the honeypot filled is rejected; a normal submission passes.
## Conventions
- Every public write endpoint calls `verifyHuman` (or at least `rateLimit`) with its own `scope`.
- Show users a generic error; don't reveal which check failed beyond "try again later".
- `clientIp` is only as reliable as `ANTISPAM_IP_HEADER` / `ANTISPAM_TRUSTED_PROXIES` match your proxies; use it for limits, never for authorization.
## Don't
- Don't store raw IPs, emails or captcha tokens; `rateLimit` already hashes keys.
- Don't hide the honeypot with `type="hidden"` (bots skip those); use `honeypotProps`.
- Don't skip the captcha check when the provider is down: `verifyCaptcha` fails closed on purpose.
The exact tree that will be injected, after .genpmignore. Pinned to
// Tablas de @core/antispam. Las recoge drizzle-kit vía src/lib/db/drizzle.config.ts.
import { index, integer, pgTable, text, timestamp } from 'drizzle-orm/pg-core';
/** Ventanas fijas de límite de frecuencia. `key` es un hash: nunca se guarda la IP ni el email en claro. */
export const rateLimits = pgTable(
'rate_limits',
{
key: text('key').primaryKey(),
windowStart: timestamp('window_start', { withTimezone: true, mode: 'date' }).notNull(),
count: integer('count').notNull(),
expiresAt: timestamp('expires_at', { withTimezone: true, mode: 'date' }).notNull(),
},
(t) => [index('rate_limits_expires_idx').on(t.expiresAt)],
);
This package declares no MCP servers.
| Version | Commit | Published | Scan |
|---|---|---|---|
| 1.1.0 | 0328b98 | 3 hours ago | scan passed |
- genpm
- @core/db ^1.0.0
- proposed
- GenPM proposes the npm command and runs it only if you say yes.
- scan
- scan passed · 0 findings
- commit
- v1.1.0 → 0328b9831cf65db04ab02f0af62b93edbe95d6ac · verified after fetch
- scripts
- None. GenPM never runs package code.
- license
- MIT
- Quality
- 100/100
- Recognized licensepassed
- AGENTS.md explains its purposepassed
- AGENTS.md has integration stepspassed
- AGENTS.md lists conventions or don'tspassed
- Includes testspassed
- Security scan passedpassed
- Released in the last 6 monthspassed
- Verified publisherpassed
- Summary and keywordspassed
- report
- See something wrong?