Anonymous feedback pages. Create a link, share it, get honest responses - no accounts required for respondents.
Openlet lets anyone create a personal feedback page in 30 seconds. Share the link with your audience. They leave a star rating and an optional message. You read the responses. They stay completely anonymous.
No login, no app, no friction for the person giving feedback.
| Layer | Tech |
|---|---|
| Frontend | React + Vite + TypeScript + Tailwind + shadcn/ui |
| Backend | Cloudflare Workers + Hono.js |
| Database | Cloudflare D1 (SQLite) |
| Auth | Google OAuth 2.0 + JWT via Web Crypto API (no external deps) |
| Spam prevention | Cloudflare Turnstile + FingerprintJS + cookie |
| Rate limiting | Cloudflare Workers Rate Limiting API |
| Hosting | Cloudflare Pages (frontend) + Workers (backend) |
openlet/
├── frontend/ # React + Vite
│ ├── src/
│ │ ├── pages/
│ │ │ ├── Index.tsx # Google sign-in
│ │ │ ├── AuthCallback.tsx # OAuth redirect handler (/auth/callback)
│ │ │ ├── Dashboard.tsx # Your feedback pages
│ │ │ ├── Create.tsx # Create a new page
│ │ │ ├── PublicPage.tsx # Public submission form (/p/:slug)
│ │ │ └── Responses.tsx # View responses (owner only)
│ │ ├── contexts/
│ │ │ └── AuthContext.tsx # Auth state + bootstrap refresh on mount
│ │ ├── lib/
│ │ │ └── api.ts # All API calls + silent token refresh
│ │ └── components/
│ │ └── ProtectedRoute.tsx
│ └── .env # VITE_API_URL, VITE_TURNSTILE_SITE_KEY, VITE_GOOGLE_CLIENT_ID
│
└── worker/ # Cloudflare Worker + Hono.js
├── src/
│ ├── index.js # Hono app, CORS, route mounting
│ ├── middleware/
│ │ └── auth.js # JWT sign/verify (Web Crypto), authMiddleware
│ └── routes/
│ ├── auth.js # google, refresh, logout
│ ├── pages.js # GET/POST/PUT/DELETE /pages
│ └── responses.js # POST /responses/:slug, GET /responses/:slug (paginated)
├── migrations/
│ ├── 0001_initial_schema.sql # users, pages, responses tables
│ ├── 0002_spam_prevention.sql # submission_log table
│ ├── 0003_blacklist_token.sql # refresh_token_blacklist table
│ └── 0004_google_oauth.sql # google_id + avatar on users, password nullable
├── wrangler.toml
└── .dev.vars # Local secrets (gitignored)
| Method | Route | Auth | Description |
|---|---|---|---|
| POST | /auth/google |
— | {code, redirectUri} → {accessToken, user} + sets refresh cookie |
| POST | /auth/refresh |
— | Rotates refresh cookie → {accessToken} |
| POST | /auth/logout |
— | Blacklists refresh token + clears cookie |
| GET | /pages |
✅ | List your pages with response counts |
| POST | /pages |
✅ | Create a page {title, question, slug} |
| GET | /pages/:slug |
— | Public page info |
| GET | /pages/:slug/check |
— | Slug availability check |
| PUT | /pages/:slug |
✅ | Update {title, question} |
| DELETE | /pages/:slug |
✅ | Delete page + all responses |
| POST | /responses/:slug |
— | Submit {rating, message, fingerprint, turnstileToken} anonymously |
| GET | /responses/:slug |
✅ | {page, stats, responses[], pagination} — owner only, cursor-paginated |
Every anonymous submission passes through three layers before being saved:
- Cookie — checked instantly on page load. If the user has already submitted to this slug, the form is never shown. No server round-trip.
- Cloudflare Turnstile — bot challenge loaded in the form. Submit button is disabled until the token is issued. Token is verified server-side against CF's API before anything else runs.
- IP + Fingerprint — after Turnstile passes, the worker checks
submission_logfor a matching(page_id, ip)or(page_id, fingerprint)pair. Either match blocks the submission with a429.
On success, the IP and fingerprint are written to submission_log for future checks.
Applied at the Worker level using Cloudflare's native Rate Limiting API — no external service, no DB reads, handled at the edge.
| Endpoint | Limit |
|---|---|
POST /auth/google |
3 requests / 60s per IP |
POST /responses/:slug |
10 requests / 60s per IP |
Rate limits are local to the Cloudflare edge location serving the request. They are not enforced during local development — the bindings are a Workers-only runtime feature.
cd worker
npm install
# Create D1 database
npx wrangler d1 create openlet
# Copy the database_id printed above into wrangler.toml
# Create local secrets file
cat > .dev.vars << EOF
JWT_SECRET=your_local_jwt_secret
TURNSTILE_SECRET=1x0000000000000000000000000000000AA
GOOGLE_CLIENT_ID=your_google_client_id
GOOGLE_CLIENT_SECRET=your_google_client_secret
EOF
# Apply all migrations
npx wrangler d1 migrations apply openlet --local
# Start dev server
npm run dev
# → http://localhost:8787cd frontend
npm install
# Set env vars
cat > .env << EOF
VITE_API_URL=http://localhost:8787
VITE_TURNSTILE_SITE_KEY=1x00000000000000000000AA
VITE_GOOGLE_CLIENT_ID=your_google_client_id
EOF
# Start dev server
npm run dev
# → http://localhost:5173Go to dash.cloudflare.com → Turnstile → Add widget → add your domain → copy the Site Key and Secret Key.
cd worker
npm install
# Set secrets in Cloudflare
npx wrangler secret put JWT_SECRET
npx wrangler secret put TURNSTILE_SECRET
npx wrangler secret put GOOGLE_CLIENT_ID
npx wrangler secret put GOOGLE_CLIENT_SECRET
# Apply all migrations to production DB
npx wrangler d1 migrations apply openlet --remote
# Deploy
npm run deploy
# → prints your worker URL: https://openlet.<subdomain>.workers.devIn worker/src/index.js, add your Cloudflare Pages domain to the allowed origins list, then redeploy the worker.
cd frontend
npm install
# Update env vars with real production values
cat > .env << EOF
VITE_API_URL=https://openlet.<subdomain>.workers.dev
VITE_TURNSTILE_SITE_KEY=your_real_turnstile_site_key
VITE_GOOGLE_CLIENT_ID=your_google_client_id
EOF
npm run build
npx wrangler pages deploy dist --project-name=openletOr connect the repo to Cloudflare Pages via the dashboard with:
- Build command:
npm run build - Output directory:
dist - Environment variables:
VITE_API_URL,VITE_TURNSTILE_SITE_KEY,VITE_GOOGLE_CLIENT_ID
| Variable | Where | Description |
|---|---|---|
JWT_SECRET |
CF Secret (wrangler secret put) |
Signs and verifies JWTs. Never commit this. |
TURNSTILE_SECRET |
CF Secret (wrangler secret put) |
Verifies Turnstile tokens server-side. Never commit this. |
GOOGLE_CLIENT_ID |
CF Secret (wrangler secret put) |
Google OAuth app client ID. |
GOOGLE_CLIENT_SECRET |
CF Secret (wrangler secret put) |
Google OAuth app client secret. Never commit this. |
For local dev, put all four in worker/.dev.vars (already gitignored):
JWT_SECRET=your_local_jwt_secret
TURNSTILE_SECRET=1x0000000000000000000000000000000AA
GOOGLE_CLIENT_ID=your_google_client_id
GOOGLE_CLIENT_SECRET=your_google_client_secret
| Variable | Where | Description |
|---|---|---|
VITE_API_URL |
.env / CF Pages env |
Base URL of the deployed worker |
VITE_TURNSTILE_SITE_KEY |
.env / CF Pages env |
Public site key from Cloudflare Turnstile dashboard |
VITE_GOOGLE_CLIENT_ID |
.env / CF Pages env |
Google OAuth app client ID |
users → id, email, password (nullable), name, google_id (UNIQUE), avatar, created_at
pages → id, user_id, slug (UNIQUE), title, question, created_at
responses → id, page_id, message (nullable), rating (1–5), created_at
submission_log → id, page_id, ip, fingerprint, submitted_at
refresh_token_blacklist → token_hash (PK, SHA-256 hex), expires_at (unix timestamp)Auth uses Google OAuth 2.0. The frontend redirects to Google, receives an authorization code at /auth/callback, and posts it to POST /auth/google. The worker exchanges the code for a Google access token, fetches the user's profile, and upserts the user by google_id. Existing users are linked by email on first OAuth login.
Migrations are managed via Wrangler's built-in D1 migration system under worker/migrations/.
- Spam prevention — Cloudflare Turnstile + browser fingerprint + cookie
- Rate limiting — Cloudflare Workers Rate Limiting API on auth and submission endpoints
- Public responses toggle (opt-in per page)
- Shareable response count badge for bios and readmes