@nurkamol/leads-kit
v0.11.0
Published
Contact-form enquiries in Workers KV, read back safely — Cloudflare Access auth, audited delete, and CSV/JSON/XML/Mailchimp/Klaviyo exports. Framework-free.
Maintainers
Readme
@nurkamol/leads-kit
Contact-form enquiries in Workers KV, read back safely.
Auth that verifies instead of trusting a header, an audited delete, and exports that will not quietly turn an enquiry into a mailing-list subscriber.
npm install @nurkamol/leads-kitStart here
npx leads-kit initRun it in an existing Astro or Next project. It reads your framework and your KV binding name from the config already there, writes the context module and every route, and prints what is left for you to decide.
It never overwrites — a file that exists is reported and skipped, and a
second run is not an error. It refuses rather than guesses: no framework
detected, no output. And it touches no configuration, because bindings,
secrets and Access are decisions or live-account operations, and a tool that
edits your deployment config while you read its output is one you cannot trust
the next time. --dry-run prints the plan and writes nothing.
Then:
npx leads-kit doctor --url https://yoursite.comLonger form: docs/getting-started.md — every code block in it is typechecked against this package's real API, so it cannot drift into being subtly wrong.
Or let the plugin do the fitting, which is worth it when the project is unusual:
/plugin marketplace add nurkamol/leads-kit
/plugin install leads-view
/leads-viewWhat it is
Two things, from one repo:
The npm package is the logic — reading the store, checking who is asking,
and turning records into files. It is framework-free: web standards only, no
node: imports, no framework imports, enforced by a test. That is what lets it
run on Workers, Deno, Bun, Node 18+ and both edge runtimes, and why the
framework adapters are eight lines each rather than a fork.
The Claude Code plugin is the fitting — the routes and the page, which have to match the host project's conventions and design tokens. Those cannot be a package: a component that ships its own palette looks pasted in, because it is.
/plugin marketplace add nurkamol/leads-kit
/plugin install leads-view
/leads-viewThe page IS in the package
// src/pages/leads.astro — the whole file
---
import { astroLeadsPage } from '@nurkamol/leads-kit/astro';
import { leadsContext } from '../lib/leads-context';
export const prerender = false;
export const GET = astroLeadsPage(() => leadsContext()!, {
siteName: 'Your Site',
backHref: '/',
});
---That renders the screenshot: a filterable list, status controls, delete,
exports, a summary, and an empty state — Access-verified, no-store, 404 when
the session does not check out.
Why, when nothing else visual is
Because the copy rotted. The page shipped as a template you adapted, and within
six releases the shipped copy still imported a module the reference project had
deleted, while its docs described an API two features out of date. A copy is a
fork, and a fork rots quietly. As a handler it cannot: a fix to the markup — an
accessibility fix above all — reaches every install through npm update
instead of sitting in one repo while forty others keep the bug.
The usual objection is that bundling a UI couples every install to one design. That is answered by the palette rather than waved away:
--lk-ink: var(--ink, #f0e3de);The host's token wins where it exists; the fallback fires where it does not. So the page looks native on a project with a design system and finished on one without. A default, not a decision. Every default pair clears WCAG AA — measured, including the delete control, whose obvious red came out at 4.20:1 and was replaced.
And it applies here specifically because /leads is internal. Only the
owner sees it, so "works the moment it is installed" is worth more than
"matches the brand exactly". On a public page the trade runs the other way,
which is why nothing else in this package renders anything.
Dark by default, light on request
The document is server-rendered as data-theme="dark", so a visitor with no
JavaScript gets a finished page rather than an unstyled one. A Theme button
switches to light and remembers the choice.
The light ramp defers to the host exactly as the dark one does
(var(--ink, #1a1715)), so a project with its own light theme keeps it. Every
default pair was measured: all clear 4.5:1, including the delete control on all
three surfaces.
The stored preference is read in a blocking inline script before <body>.
A deferred read would paint dark and then flip — a flash on every load for
anyone who chose light. This is the one place a blocking script earns its
place.
It is scoped to [data-theme] and deliberately not to
prefers-color-scheme: the page is server-rendered dark, so honouring the
media query would repaint on first paint for anyone on a light system, and the
control here is an explicit choice rather than a guess about the OS.
Pass themeToggle: false on a site whose tokens define only one theme — that
removes the button and the script, so nothing can write data-theme and
half-restyle the page.
Hand this to whoever reads the enquiries
docs/using-the-leads-page.md is written for
them rather than for you: what the statuses mean, why "not verified" is not an
accusation, why the contact-list export is not permission to email anyone, and
what to do when someone asks for their data. Copy it into the handover.
If you want the markup
renderLeadsPage(leads, options) returns the HTML string, and the plugin still
ships the Astro component. ejectLeadsPage is the same function under a name
that says what you are doing. Nothing here is a one-way door.
The risk this took on
Astro escapes interpolated values; a string template does not. Every field on that page is attacker-controlled, so bundling the renderer moved it out from under that protection — and a mistake would be stored XSS aimed at the one person who can read every enquiry.
src/ui/escape.ts is the answer, and a test feeds a deliberately hostile record
through the whole renderer: <script> in the name, an onerror payload in the
email, a javascript: URL, tags that try to close the card early. It asserts
the document contains exactly one <script> and one <article>, that no tag
carries an inline handler, and that the content is still present and
readable — a renderer that "sanitises" by deleting is one that hides what the
enquiry actually said.
What the package still will not render
The contact form. That one is public, sits inside your layout, and has to
carry your type scale and spacing — the argument that fails for an internal
admin page holds completely for a page your customers see. handleSubmit
accepts whatever markup you write; the form itself is yours.
Nor a component library, a layout, or anything else with a look. /leads is
the exception because it is the one screen where nobody but the owner is
looking.
Use it
Astro
// src/pages/api/leads.csv.ts
import { kvStore } from '@nurkamol/leads-kit';
import { astroExport } from '@nurkamol/leads-kit/astro';
export const prerender = false; // or you publish a CDN file of everyone's enquiries
// A FUNCTION, not an object. On Cloudflare the bindings live at
// locals.runtime.env, which only exists per request — build the context at
// module scope and there is no KV namespace to reach.
export const GET = astroExport(({ locals }) => {
const env = locals.runtime.env;
return {
store: kvStore(env.LEADS),
token: env.LEADS_EXPORT_TOKEN,
access: { teamDomain: env.ACCESS_TEAM_DOMAIN, aud: env.ACCESS_AUD },
};
}, { format: 'csv' });Next (App Router)
// app/api/leads/delete/route.ts
import { kvStore } from '@nurkamol/leads-kit';
import { checkOrigin, nextDelete } from '@nurkamol/leads-kit/next';
export const dynamic = 'force-dynamic';
const ctx = () => ({
store: kvStore(getKvBinding()),
token: process.env.LEADS_EXPORT_TOKEN,
access: {
teamDomain: process.env.ACCESS_TEAM_DOMAIN,
aud: process.env.ACCESS_AUD,
},
});
export async function POST(request: Request) {
// Next has no CSRF default. Without this, a hostile page can POST here
// carrying the visitor's own session cookie.
const blocked = checkOrigin(request, 'https://example.com');
if (blocked) return blocked;
return nextDelete(ctx, '/leads?deleted=1')(request);
}Anything else
import { leadsRouter } from '@nurkamol/leads-kit/worker';
const leads = leadsRouter(ctx);
export default {
async fetch(request) {
return (await leads(request)) ?? new Response('Not found', { status: 404 });
},
};A different store
Workers KV ships as kvStore(). Anything else is four methods:
const store: LeadStore = {
list: (prefix) => /* every key, paging to exhaustion */,
get: (key) => /* parsed record or null */,
put: (key, value, opts) => /* honour opts.expirationTtl */,
delete: (key) => /* … */,
};Accepting submissions
handleSubmit is the write path. The order of its steps is the point of the
function — every one is placed where it is for a reason invisible in a
status-code test, and a reordered version passes the same tests while being
wrong.
// src/pages/api/contact.ts
import { kvStore } from '@nurkamol/leads-kit';
import { astroSubmit } from '@nurkamol/leads-kit/astro';
export const prerender = false;
export const POST = astroSubmit(
({ locals }) => ({ store: kvStore(locals.runtime.env.LEADS) }),
{
schema: {
name: { required: true, minLength: 2, maxLength: 100 },
email: { required: true, type: 'email' },
phone: { type: 'phone' },
budget: { oneOf: BUDGET_OPTIONS },
message: { maxLength: 4000 },
},
turnstile: { secret: env.TURNSTILE_SECRET_KEY },
rateLimit: { limit: 5, windowSeconds: 600 },
retentionSeconds: 365 * 24 * 60 * 60,
notify: async (lead) => { /* your provider, ~20 lines of fetch */ },
redirects: { success: '/?sent=1#contact', invalid: '/?invalid=', honeypot: '/#contact' },
},
);| Step | Placed there because | | --- | --- | | 1. Cross-origin | Before parsing. Ordered after, it only ever fires on submissions that were being rejected anyway — present in the code, protecting nothing | | 2. Honeypot | Free and local. No network round trip on a request already known to be a bot | | 3. Rate limit | One store read; cheaper than siteverify | | 4. Turnstile | A network call, so last of the refusals — and before validation, so a refusal never depends on the payload being well-formed | | 5. Validate | | | 6. Store | Durable first | | 7. Notify | Third party last. Its failure is logged, never fatal |
Four decisions worth knowing about
Caught spam must not land on your success URL. That URL is usually the
conversion — analytics fires generate_lead on it for the no-JS path. Send
caught spam there and any bot running JavaScript inflates the only conversion
the site owns, silently, in a shape that looks like the site doing unusually
well. Nobody investigates that. Hence the separate honeypot redirect.
A Turnstile outage stores the lead. A bad token is refused — that is what
the widget is for. But a timeout, a 5xx, or Cloudflare's own internal-error
means you learnt nothing about the submission, and refusing there means an
outage silently costs real enquiries. Those are stored and flagged
unavailable, so they are visibly different from ones that passed.
acceptWithoutToken defaults to true. A challenge cannot mint a token
without JavaScript. With false, a no-JS visitor cannot submit at all — and if
the widget ever fails to load, every enquiry is refused silently. It does not
disable Turnstile: a supplied token is still verified and a bad one still
refused. Set it to false only once you have confirmed the widget mints tokens
for real visitors.
Phone validation is country-agnostic: 7–15 digits, per E.164. A 10-digit rule is a US rule, and shipping one silently rejects every UK, Irish and Australian visitor with an error they cannot act on, because their number is correct.
Rate limiting
Fixed window, keyed on the identifier you pass — and it fails open if the store is unreachable, the same judgement as the Turnstile outage rule.
Pass only an address your runtime vouches for (clientAddress,
cf-connecting-ip). Never a forwarded-for header on a deployment where nothing
overwrites it: an attacker sets those, so every request gets a fresh bucket and
the limit is decorative while still looking present.
leads-kit doctor
npx leads-kit doctor --url https://yoursite.comIt reads LEADS_EXPORT_TOKEN from the environment or a local .env. Do not
pass it as a flag — npm run echoes the command it runs, so the token would
land in your scrollback, in any CI log, and in ps output for every process on
the machine.
Every serious risk in this package is a configuration mistake, not a code
bug. The code is tested; the wiring is not, because the wiring lives in your
repo. A route missing prerender = false publishes every enquiry as a file on
a CDN. checkOrigin off leaves the delete endpoint reachable from a hostile
page carrying the visitor's own cookie. /leads left in the sitemap invites a
crawler to a page of personal data.
None of those fail loudly. All of them look completely normal.
It probes a deployed site for:
| | |
| --- | --- |
| Export routes answering 200 with no credentials | serving enquiries publicly |
| An Age header or a cacheable export | prerendered, or a cache rule matches it |
| A destructive route answering GET | prefetchers fire those unprompted |
| A cross-site POST reaching the handler | CSRF is not configured |
| A forged Cf-Access-Jwt-Assertion, an alg:none token, a forged cookie | the route trusts the header instead of verifying the signature |
| The admin page in the sitemap or robots.txt | advertised |
It only reads. The single POST it makes is the CSRF probe, deliberately
carrying a foreign Origin and an all-zero id so it cannot match a record — a
diagnostic that changes state is one people stop running.
Exits non-zero on a failure, so it belongs in CI after a deploy. And it says what it cannot see: whether Access actually covers the page (it sits in front, so a probe lands on its login screen), whether the KV TTL was set at write time, and whether the notifier reaches a real inbox.
Spam scoring
Turnstile stops bots. It does not stop a human paid to fill in forms, or a script driving a real browser — both pass a challenge exactly as a customer does. What separates them is the content: eleven links, a message pasted in four seconds, the same body for the ninth time.
spam: { elapsedMs, autoSpamAt: 6 } // or `spam: false` to skipIt scores. It never blocks, and there is no option that would let it.
That is the most important sentence here. Every signal has a false-positive case involving a real customer: a developer pasting three staging URLs, someone typing fast, a person submitting twice because the first reply never came. The cost of admitting spam is a message you delete in a second. The cost of refusing a client is that you never learn it happened. Those are not comparable, so the score goes on the record and a human decides.
autoSpamAt is the only lever, and all it does is pre-set status: 'spam' so
the enquiry lands in a filtered view. It is still stored, still exported, still
there.
Signals: link count, a deliberately short phrase list, shouting, thin or unbroken text, and submission speed — the last only when your form actually reports it, since inferring it would penalise anyone whose browser behaves unusually. A whole message in another script scores zero; that is a customer, not a signal.
Duplicates
findDuplicate fingerprints the message body and catches two things with one
mechanism: a spam run pasting the same text, and — far more common — a real
person double-clicking submit, which otherwise produces two identical records
a minute apart.
Messages under 40 characters are exempt. "Hi, can you help with a website?" is a sentence two different customers will both write, and treating the second as a duplicate would silently discard a real enquiry.
Notifiers
The Notifier interface is three lines, so these builders are not there to
save you writing it. They exist because the same handful of decisions gets made
badly in every hand-rolled version:
import { resendNotifier, slackNotifier, allNotifiers } from '@nurkamol/leads-kit';
notify: allNotifiers(
resendNotifier(env.RESEND_API_KEY, {
from: '[email protected]', fromName: 'Your Site', to: '[email protected]',
}),
slackNotifier(env.SLACK_WEBHOOK),
),Available: resendNotifier, brevoNotifier, postmarkNotifier,
mailChannelsNotifier, slackNotifier, webhookNotifier (n8n, Zapier, Make,
your own), and allNotifiers to combine them. No dependencies — each is one
fetch against a documented JSON API.
What they get right that a quick version usually doesn't:
reply_tois the enquirer, not the site. This is the most useful line in any of them: it turns "reply" into a reply to the person, rather than an email to yourself that you then copy an address out of.- A timeout. A form POST is waiting on this; a hanging provider must not become a hanging site.
- A non-2xx throws, carrying the provider's own message. A provider that answers 401 and is treated as success means notifications stop silently, and nobody finds out until a client asks why they were ignored. The body usually contains the one line that fixes it — "sender not verified", most often.
allNotifiersusesallSettled, notall. A broken Slack webhook must not stop the email that actually matters.- Slack sends
plain_text, notmrkdwn. The message is built from visitor input, and Slack will happily render an injected link or an@channel. webhookNotifiertakes afieldslist. A webhook is an export: every field you include leaves your infrastructure permanently, so sending the whole record — IP address included — to a third party should be a decision.
⚠ MailChannels needs a DKIM-signed domain and an SPF record naming it, and silently drops mail without them rather than erroring. A 202 there is not proof of delivery; check the inbox once.
Lead status
Before this, the only two things you could do with a lead were read it and destroy it — which makes the list a viewer rather than an inbox, and means the only way to clear something is to delete it. Deleting a real enquiry to tidy a list is how you lose the record of a client you won.
POST /api/leads/status { id, status } new | replied | archived | spam
GET /api/leads.csv?status=new filter by itFour statuses, not more: a status list becomes a workflow engine at six, and
the only question this answers is "does this still need me". spam is separate
from archived because they mean different things to whoever reads the list
next — one was dealt with, the other should never have arrived.
summarise() gains unanswered and byStatus. unanswered is the figure
worth putting at the top of a page; "12 total" is trivia.
One trap worth knowing about
KV cannot update a value while keeping its remaining expiry. A put with
no TTL removes the expiry; a put with your retention period restarts it. So
marking a lead "replied" on day 364 would silently grant it another full year —
the record outlives the promise on your privacy page, and nothing reports it,
because from outside it is just a record that has not expired yet.
setLeadStatus computes what is LEFT from the original receivedAt, so a
status change can never extend retention. Pass retentionSeconds on the
context for that to work. If the period has already elapsed the write is
refused rather than resurrecting a record that was due to go.
Data-subject requests
Someone can ask what you hold about them and ask you to delete it. Under GDPR you have a month; under CCPA, 45 days. Neither regime cares that it is "just a contact form" — a name, an address and free text about their situation is personal data.
GET /api/leads/[email protected] // everything you hold
POST /api/leads/erase { email, confirm: email } // delete all of it
GET /api/leads/audit?limit=50 // who did what, newest firstErasure needs confirm to equal email, because unlike deleting one enquiry
it takes an unbounded number of records with it and you may not know how many.
Both operations are audited — including the read, since "who looked this
person up" is a question worth being able to answer.
The audit records store the email's domain, never the address. A trail that keeps a second copy of what it just erased has undone the erasure it records.
Retention
import { sweepExpired } from '@nurkamol/leads-kit';
await sweepExpired(ctx, 365, { dryRun: true }); // report, touch nothing
await sweepExpired(ctx, 365); // then actually sweepexpirationTtl only covers records written after you started setting it.
Anything stored before has none, and KV keeps a value without one forever —
those records will outlive the privacy notice that promised they would not, and
nothing will ever flag it. Run this from a Cron Trigger.
Dry run first, always. A cutoff computed in the wrong unit is not something to discover afterwards.
Filtering
/api/leads.csv?since=2026-01-01&limit=100
/api/leads.csv?q=redesign
/api/[email protected]Date bounds become a key range, not a filter applied after reading — the
key format puts the timestamp first precisely so this works. Where no
value-level filter is present, limit applies to the key list, so it saves
reads rather than trimming results.
q cannot be pushed down: KV has no index, so the value must be read to be
searched. That one is honestly a scan, and is documented as one rather than
made to look cheap.
Formats
| | |
| --- | --- |
| csv json xml md | the records, as they are |
| xlsx | a real workbook. A CSV in Excel turns +998901234567 into scientific notation and strips leading zeros from ids — and none of that looks like an error to whoever opens it |
| mailchimp klaviyo contacts | contact lists — read the consent note below |
CLI
npx leads-kit export --url https://example.com --formats csv,klaviyoRefuses to write to a directory inside a git repo that is not gitignored. The retention promise in a privacy notice covers the database; it says nothing about a copy committed to a public repository forever, and that is the one mistake here that deleting the file afterwards does not undo.
About the contact-list exports
Mailchimp, Klaviyo and a neutral CRM shape — built so they can be imported without being mailable.
A contact form is not consent, and most privacy notices attached to one say in
as many words that the person will not be added to a mailing list. So no
subscribe column is emitted for either platform to read, every row carries the
consent status and its source in plain words, and a no-marketing-consent tag
lands on every contact. Import as non-subscribed.
If real consent is ever collected it belongs in the form, as a separate unticked box stored on the record — and then the privacy notice changes to match. Both, not one.
Two details that cost rows if you get them wrong, and are handled here: Klaviyo
rejects an entire profile on a malformed phone_number rather than ignoring
the field, so a number is emitted only when already E.164; and Mailchimp text
merge fields truncate near 255 characters, so the enquiry message is not in the
audience import at all.
What it will not do for you
Create a KV namespace, set a secret, or configure Cloudflare Access. Those are
live-account operations, they are not reversible from here, and a machine with
more than one Cloudflare account configured is a machine where the wrong one is
a plausible accident. RELEASING.md and the plugin walk you through them.
Verifying
Against the deployed site, always — a green build proves the bundler ran.
curl -s -o /dev/null -w '%{http_code}\n' -H 'Cf-Access-Jwt-Assertion: forged.token.here' -L https://host/leads/ # 404
curl -s -o /dev/null -w '%{http_code}\n' -H 'Cookie: CF_Authorization=forged' -L https://host/leads/ # 404
curl -s -o /dev/null -w '%{http_code}\n' https://host/api/leads.csv # 401
curl -s -o /dev/null -w '%{http_code}\n' https://host/api/leads/delete/ # 405The forged cases are the ones that matter. If a route treats the presence of
Cf-Access-Jwt-Assertion as the check rather than verifying its signature,
both return 200 and nothing about the page looks wrong.
Licence
MIT
