Invite-only, multi-user email collection and content distribution built on Cloudflare Workers + Durable Objects + KV + D1.
It gives you a small hosted workflow for:
- creating short public collection links
- distributing codes / links / any prepared text, one item per visitor (claim links)
- limiting each link by quota
- auto-closing links exactly when full
- managing multiple invited users from one admin panel
- storing user/session data separately from submission state
Landing page Public collection page
Admin dashboard Member workspace
A plain KV-only approach is nice for storage, but it is a bad fit for real-time counters on Cloudflare. This project uses:
- Durable Object for the authoritative invite state and submission count
- KV for mirrored email archives
- D1 for invited users and login sessions
That keeps the critical path consistent while still making export/archive cheap.
- Invite-only admin flow
/adminfor the super admin- create member accounts
- see latest generated credentials once
- Multi-user workspace
/loginand/appfor invited members- each member manages only their own links
- forced password change flow for first login
- Short public links
- public links use
/:shortId - IDs start at 4 characters and grow only after collisions
- reserved paths like
/adminand/apistay protected
- public links use
- Quota-aware collection
- link closes automatically when quota is reached
- manual open/close and delete actions
- Claim links (content distribution)
- paste a batch of items (one per line): coupon codes, download links, passwords, anything
- each visitor atomically claims exactly one unclaimed item
- optional email gate: one claim per email, repeat visits show the same item
- or fully anonymous one-click claiming
- link auto-closes when every item is claimed
- owner sees which email claimed which item
- Consistent writes
- submission count is handled inside a Durable Object
- KV stores a mirrored copy of each normalized email submission
- Minimal UI
- single Worker app
- admin, member workspace, and public page included
Browser
│
▼
Cloudflare Worker
├─ Admin + member auth routes
├─ Public invite pages
└─ API routes
│
├─ D1
│ ├─ users
│ └─ user_sessions
│
├─ Durable Object: InviteRegistry
│ ├─ invite config
│ ├─ submission count
│ └─ auto-close decision
│
└─ Workers KV
└─ mirrored email archive
| Store | Responsibility |
|---|---|
| D1 | users, passwords, sessions |
| Durable Object | authoritative invite state, quota checks, auto-close, submission registry |
| KV | mirrored submission archive |
GET /— landing pageGET /admin— super admin login / dashboardGET /login— member loginGET /app— member workspaceGET /:shortId— public collection pageGET /i/:inviteId— legacy redirect to/:shortId
GET /api/admin/usersPOST /api/admin/usersGET /api/admin/invitesPOST /api/admin/invites/:inviteId/toggleDELETE /api/admin/invites/:inviteId
GET /api/me/invitesPOST /api/me/invitesPOST /api/me/passwordPOST /api/me/invites/:inviteId/toggleDELETE /api/me/invites/:inviteId
POST /api/invites/:inviteId/submit— collect modePOST /api/invites/:inviteId/claim— claim modeGET /healthz
- lowercase letters, numbers, underscore
- minimum length: 4
- maximum length: 24
Examples:
- valid:
team_a,user01,mail_ops - invalid:
ab,my-name,UpperCase
- alphabet removes ambiguous characters
- starts at 4 chars
- grows to 5/6 chars only when collisions happen
npm install cp .dev.vars.example .dev.vars
Set local values in .dev.vars:
ADMIN_PASSWORD=replace-with-a-long-password SESSION_SECRET=replace-with-a-long-random-secret
Create a D1 database:
npx wrangler d1 create cloudflare-email-collector
Create a KV namespace:
npx wrangler kv namespace create EMAIL_COLLECTOR_KV
Then update wrangler.jsonc with the real database_id and KV id returned by Wrangler.
Local:
npx wrangler d1 execute cloudflare-email-collector --local --file=schema.sql
Remote:
npx wrangler d1 execute cloudflare-email-collector --remote --file=schema.sql
npm run types
npm run dev
printf '%s' '<your-admin-password>' | npx wrangler secret put ADMIN_PASSWORD openssl rand -hex 32 | npx wrangler secret put SESSION_SECRET
npm run deploy
The repo ships with workers_dev enabled and no custom domain bound by default.
If you want your own domain, add a routes block in wrangler.jsonc after deployment, for example:
"routes": [ { "pattern": "collect.example.com", "custom_domain": true } ]
- super admin signs in at
/admin - admin creates a member account
- member signs in at
/login - member creates a short link in
/app- collect: set a title and a quota
- claim: paste items one per line, choose whether an email is required
- public users submit email (collect) or claim one item (claim) on
/:shortId - Durable Object increments count and closes the link when full / all claimed
- member/admin sees the updated state immediately, including who claimed what
npm run test
npm run check
npm run types- do not commit
.dev.vars - use Worker secrets for
ADMIN_PASSWORDandSESSION_SECRET - member passwords are stored as hashes, not plaintext
- the admin-created initial password is shown once in the admin UI, so rotate it on first login
Because quota counting and auto-close need a strongly consistent source of truth. KV is fine for mirrored storage, but not for authoritative real-time counters.
Because users and sessions are relational, queryable, and easier to manage in D1. Invite state and submission counting are a better fit for a single durable authority.
Yes. Just create one member account and ignore the rest.
All claim state lives in one Durable Object, so two simultaneous visitors are serialized: each gets a different item, and the last item flips the link to closed atomically. With the email gate on, re-submitting the same email returns the already-assigned item instead of consuming a new one.
MIT