@growth-labs/feature-requests
v0.1.0
Published
Network-wide feature-request submission: a Turnstile-guarded handler that lands requests in a consumer-owned, reviewable D1 queue (never auto-published). Ships an Astro integration plus a framework-agnostic core handler.
Readme
@growth-labs/feature-requests
Network-wide feature-request submission for Growth Labs sites. A
Turnstile-guarded handler accepts a feature request (title, body, optional
contact email) and lands it in a reviewable, consumer-owned D1 queue with
status = 'pending'. Nothing is ever auto-published — moving a request forward
is a separate review action the site owns.
The package is site-agnostic: each site injects its own D1 binding and Turnstile configuration. It ships two surfaces:
- An Astro integration (default export) that injects the submission route.
- A framework-agnostic handler (
createFeatureRequestHandler) for bare Workers or custom routing.
Install
pnpm add @growth-labs/feature-requestsAstro integration
// astro.config.mjs
import featureRequests from '@growth-labs/feature-requests'
export default defineConfig({
integrations: [
featureRequests({
// Public site key for the client-side Turnstile widget (safe to expose).
turnstileSiteKey: '0x4AAA...',
// Binding NAMES the site wires in wrangler.toml (never values):
d1Binding: 'SITE_DB', // default
turnstileSecretBinding: 'TURNSTILE_SECRET_KEY', // default
// Optional:
routePath: '/api/feature-requests', // default
tableName: 'gl_feature_requests', // default
}),
],
})Bindings (wrangler.toml)
# Consumer-owned D1 — the site supplies the database.
[[d1_databases]]
binding = "SITE_DB"
database_name = "your-site-db"
database_id = "..."
# Turnstile secret via Cloudflare Secrets Store (binding NAME, never a value).
[[secrets_store_secrets]]
binding = "TURNSTILE_SECRET_KEY"
store_id = "..."
secret_name = "turnstile-secret-key"The package resolves either binding shape (a Secrets Store SecretsStoreSecret
or a legacy plain-string secret) server-side at request time, so the secret
value never enters the build or any serialized config.
Schema
Apply the migration to your D1 binding before mounting the route:
wrangler d1 migrations apply SITE_DB \
--local --migrations-path node_modules/@growth-labs/feature-requests/migrationsThis creates gl_feature_requests:
| Column | Type | Notes |
|--------------|------|----------------------------------------|
| id | TEXT | UUID primary key |
| title | TEXT | required |
| body | TEXT | required |
| email | TEXT | nullable (optional contact) |
| status | TEXT | always 'pending' on insert |
| created_at | TEXT | ISO-8601 |
Submitting
curl -X POST https://yoursite.com/api/feature-requests \
-H 'content-type: application/json' \
-d '{"title":"Dark mode","body":"Please add a dark theme.","email":"[email protected]","turnstileToken":"<widget-token>"}'Responses:
| Status | code | Meaning |
|--------|---------------------------|------------------------------------------|
| 201 | — | { success, id, status: 'pending' } |
| 400 | INVALID_BODY | title/body/email failed validation |
| 400 | MISSING_TURNSTILE_TOKEN | no token in the payload |
| 403 | TURNSTILE_FAILED | token rejected by Cloudflare |
| 403 | TURNSTILE_NOT_CONFIGURED| no secret resolved (fail-closed) |
| 502 | STORAGE_FAILED | D1 insert threw |
Framework-agnostic handler
For a bare Worker (no Astro), resolve the bindings yourself and build a handler:
import { createFeatureRequestHandler } from '@growth-labs/feature-requests/handler'
export default {
async fetch(request, env) {
if (request.method === 'POST' && new URL(request.url).pathname === '/api/feature-requests') {
const handler = createFeatureRequestHandler({
db: env.SITE_DB, // consumer-owned D1
turnstileSecret: env.TURNSTILE_SECRET_KEY, // Secrets Store binding or string
})
return handler(request)
}
return new Response('Not found', { status: 404 })
},
}Design notes
- Fail-closed. If no Turnstile secret resolves, every submission is rejected
with
TURNSTILE_NOT_CONFIGUREDrather than silently accepting unverified traffic. - Bound, never interpolated. Field values are always bound parameters. The table name is validated as a safe SQL identifier at handler construction.
- Reviewable, not published. The queue is the deliverable; the package never performs publish/accept/reject transitions.
