Turborepo ยท Express ยท tRPC ยท Drizzle ORM ยท PostgreSQL ยท Redis ยท BullMQ ยท Zod ยท Scalar
Demo credentials: demo@scribbleforms.dev / Demo@1234
API Docs: https://your-deploy.onrender.com/docs
Health: https://your-deploy.onrender.com/health
- Quick Start
- Deployment (Free Tier)
- Architecture & Design Decisions
- Monorepo Package Map
- Database Design
- tRPC Router Reference
- Environment Variables
- API Route Reference
- Security Design
- Testing
- Changelog โ Judge Panel Fixes
# Prerequisites: Node 20+, pnpm 9+, PostgreSQL 15+, Redis
git clone https://github.com/your-username/scribbleforms
cd scribbleforms
pnpm install
# Configure environment
cp apps/api/.env.example apps/api/.env
# Edit apps/api/.env โ set DATABASE_URL and REDIS_URL at minimum
# Create tables
pnpm --filter @repo/database db:generate
pnpm --filter @repo/database db:migrate
# Seed demo data (demo@scribbleforms.dev / Demo@1234)
pnpm --filter @repo/database db:seed
# Start API server (port 8000)
pnpm --filter @repo/api dev
# Start background worker (separate terminal)
pnpm --filter @repo/worker devVisit:
http://localhost:8000/healthโ health checkhttp://localhost:8000/docsโ Scalar API explorerhttp://localhost:8000/openapi.jsonโ raw OpenAPI schema
pnpm --filter @repo/trpc test # 62 unit tests
pnpm --filter @repo/api test # 21 integration tests
pnpm --filter @repo/trpc test:coverage
pnpm --filter @repo/api test:coverageTotal: 83 tests, all passing.
| Service | Purpose | Free Tier |
|---|---|---|
| Neon | PostgreSQL | 0.5 GB, never expires |
| Upstash | Redis | 10k commands/day |
| Render | API server | 750 hrs/month |
| Render | Worker process | 750 hrs/month |
| Resend | Emails | 3,000/month |
| Cloudinary | File uploads | 25 GB |
git init && git add . && git commit -m "initial"
git remote add origin https://github.com/YOUR_USERNAME/scribbleforms.git
git push -u origin main- neon.tech โ Create Project โ copy connection string
- Copy the pooled URL ending in
?sslmode=requireโ yourDATABASE_URL
- upstash.com โ Create Database โ same region as Neon
- Copy the URL starting with
rediss://โ yourREDIS_URL
- Render โ New Web Service โ connect GitHub repo
- Settings:
| Field | Value |
|---|---|
| Root Directory | (leave blank โ Render needs the full monorepo) |
| Build Command | npm install -g pnpm && pnpm install && pnpm --filter @repo/api build |
| Start Command | node apps/api/dist/index.js |
| Instance Type | Free |
- Environment variables (add all from
.env.example) - After first deploy โ Shell tab โ run migrations:
pnpm --filter @repo/database db:migrate
pnpm --filter @repo/database db:seedWhy Root Directory is blank: The API imports packages/trpc, packages/database, etc. Render must see the full repo to resolve workspace packages.
New Background Worker service, same repo:
- Build:
npm install -g pnpm && pnpm install - Start:
npx tsx apps/worker/src/index.ts - Same environment variables as the API
Render free tier sleeps after 15 min of inactivity (30s cold start). Fix for free:
- uptimerobot.com โ Add Monitor
- URL:
https://your-api.onrender.com/health - Interval: 14 minutes
- Vercel โ New Project โ import same repo
- Root Directory:
apps/web - Framework: Next.js (auto-detected)
- Add env var:
NEXT_PUBLIC_API_URL=https://your-api.onrender.com
Turborepo: The project has three separate consumers of shared types: the API server (apps/api), the background worker (apps/worker), and the frontend (apps/web). Without a monorepo you'd publish npm packages for every shared type change. Turborepo's workspace protocol (@repo/trpc, @repo/database) means zero publishing, instant type propagation, and a unified CI pipeline. The task graph (build depends on ^build) ensures packages compile in dependency order.
tRPC + trpc-to-openapi: One router definition generates two protocols simultaneously โ native tRPC JSON-RPC on /trpc (for @trpc/client on the frontend) and REST/OpenAPI on /api (for third-party integrations and Scalar docs). No schema duplication, no drift between docs and implementation.
Drizzle ORM: Unlike Prisma, Drizzle schemas are plain TypeScript objects โ importable, tree-shakeable, composable across packages. Its query builder produces exactly the SQL you expect with no hidden N+1 queries. Column name typos become TypeScript errors at compile time, not runtime surprises.
Zod everywhere: Input validation, output stripping, and env validation all use Zod. The shared @repo/validators package means the frontend and backend validate with identical schemas โ a field marked required: true in the database is required on the frontend AND the backend.
// โ Anti-pattern: HTTP primitives leak into business logic
class AuthService {
async login(payload, res: Response) { // โ coupled to Express
res.cookie("sf_session", token); // โ untestable without HTTP
}
}
// โ
ScribbleForms pattern: factory functions injected at context creation
export function createCookieFactory(res: Response) {
return (name: string, value: string, opts?: CookieOptions) => {
res.cookie(name, value, opts);
};
}
// AuthService.login() returns token; route handler calls ctx.createCookie()Services are pure functions โ they can be called from workers, CLI scripts, tests, or any HTTP framework without modification.
When a form is published, every field is snapped into form_versions.fields_json. This solves three real problems:
- Mid-fill respondents: If you change a required field while 100 people are filling the form, their existing answers would fail the new validation. Version snapshots let each submission validate against the version it was started on.
- Response attribution: Old responses can be re-rendered as they were at submission time.
- Conflict detection:
409 FORM_VERSION_OUTDATEDtells the frontend "reload the form before submitting" โ only possible because we trackcurrentVersionId.
Every authenticated API call would otherwise hit PostgreSQL to validate the session token. At 100 concurrent users each making 5 calls/second, that's 500 unnecessary DB round trips per second. Sessions are cached with a 15-minute sliding TTL โ cache hit = ~0.5ms; DB hit = ~5ms. Profile update and logout both DEL the cache key immediately.
Form submission response time = DB write time only (~5ms). Without queues:
- Email sending adds ~500ms SMTP latency
- Webhook HTTP calls add ~200ms per recipient
- Analytics writes add ~20ms
With BullMQ, these all happen after the response is sent. More importantly: a crashing worker (e.g., malformed webhook payload) never affects the HTTP server. Separate processes = separate failure domains.
scribbleforms/
โโโ apps/
โ โโโ api/ HTTP server โ Express, tRPC, REST adapter, Scalar docs
โ โโโ worker/ BullMQ workers โ email, webhook, analytics, export
โ
โโโ packages/
โโโ database/ Drizzle schema (13 tables), migrations, seed data
โโโ trpc/ All tRPC routers, services, repositories, middleware
โโโ validators/ Shared Zod schemas โ usable by frontend AND backend
โโโ redis/ Redis client singleton + cache helpers (cacheGet/Set/Del)
โโโ queues/ BullMQ queue definitions shared between API and worker
โโโ constants/ PLAN_LIMITS, FIELD_REGISTRY, rate limit config
Why validators is a separate package:
The buildFieldSchema() function lives in @repo/validators โ not in the API. This means the frontend can import { buildFieldSchema } from "@repo/validators" to do client-side validation with the exact same rules the backend uses. One schema definition, zero drift.
Why redis and queues are split:
The API needs Redis for caching but not for running workers. The worker needs BullMQ (which needs Redis) but not Express. Splitting avoids pulling BullMQ's long-lived connection code into the API process.
users โโโโโโโโโโโโโโโโโโโโ sessions (auth tokens)
โ api_keys (SHA-256 hashed)
โ
โโโ forms โโโโโโโโโโโโโโโ form_versions (field snapshots on publish)
โ themes (system + user themes)
โ
โโโ fields (with config JSONB + conditions JSONB)
โโโ responses โโโโโโ response_answers (typed columns per field type)
โโโ webhooks โโโโโโโโ webhook_deliveries
โโโ analytics_events (raw events: form_view, field_skip, etc.)
โโโ analytics_daily (pre-aggregated rollup: views/starts/completions per day)
โโโ export_jobs
audit_logs (append-only action trail)
Soft deletes on forms: deleted_at timestamp instead of hard DELETE. Preserves response data for export, enables accidental-deletion recovery. All queries add WHERE deleted_at IS NULL.
analytics_events + analytics_daily two-table pattern: Raw events are append-only and grow indefinitely. Aggregating them on every dashboard load would be O(millions of rows). The analytics_daily table is a pre-computed rollup maintained by the analytics worker. Dashboard queries hit this small table; raw events are for debugging and field drop-off analysis.
response_answers typed columns: value_text, value_number, value_array instead of a single JSONB blob. This enables AVG(value_number) on rating fields, WHERE value_text ILIKE '%@gmail%' on email fields, and proper index use โ none of which work efficiently on JSONB.
Password protection uses bcrypt: Form passwords are hashed with bcrypt (cost factor 10) โ self-salting, slow, safe against rainbow tables. The earlier HMAC-SHA256 with a hardcoded static key was a critical security flaw that has been fixed.
// 1. publicProcedure โ no auth required
// Used for: form filling, analytics tracking, explore page, OAuth endpoints
publicProcedure
.meta({ openapi: { method: "GET", path: "/forms/public/{slug}" } })
.input(z.object({ slug: z.string().max(100) }))
.output(z.any())
.query(handler)
// 2. protectedProcedure โ valid session cookie required
// Used for: all creator operations (CRUD, analytics, export, etc.)
protectedProcedure // = publicProcedure.use(isAuthenticated)
.meta({ openapi: { method: "GET", path: "/forms" } })
.input(formListInputSchema)
.output(formListOutputSchema)
.query(handler)
// 3. planMiddleware โ auth + specific plan feature required
// Used for: webhooks (studio), API keys (studio), export (creator+)
protectedProcedure
.use(planMiddleware("hasWebhooks"))
.meta({ openapi: { method: "POST", path: "/webhooks" } })
.input(createWebhookSchema)
.output(webhookOutputSchema)
.mutation(handler)trpc-to-openapi reads these annotations to generate the REST layer. Without them, the procedure only exists at /trpc/auth.login (tRPC protocol). With them, it also exists at POST /api/auth/login (REST) and appears in Scalar docs. One codebase, two protocols, no duplication.
Output schemas strip sensitive fields at the tRPC boundary โ not by developer discipline but by type system enforcement. If a migration adds a passwordAttempts column to users, it can never leak through a response because meOutputSchema only allows { id, email, fullName, plan, avatarUrl, emailVerified }.
tRPC-to-openapi maps inputs to REST:
// GET /forms/{id} โ {id} in path string = path param
.input(z.object({ id: z.string().uuid() }))
// GET /forms?cursor=xxx&limit=20&status=published โ no {vars} = query params
.input(z.object({ cursor: z.string().optional(), limit: z.number().default(20), status: z.enum([...]).optional() }))
// POST /forms โ all inputs = request body
.input(createFormInputSchema)// โ Page numbers: require COUNT(*) on every request
.offset((page - 1) * limit) // expensive on large tables
// โ
Cursor pagination: WHERE created_at < cursor LIMIT n+1
if (opts.cursor) {
const [cursorForm] = await db.select({ createdAt: formsTable.createdAt })
.from(formsTable).where(eq(formsTable.id, opts.cursor)).limit(1);
if (cursorForm) conditions.push(lt(formsTable.createdAt, cursorForm.createdAt));
}
// Fetch n+1: if length > limit, there's a next page (no COUNT needed)Copy apps/api/.env.example to apps/api/.env.
| Variable | Required | Source | Notes |
|---|---|---|---|
DATABASE_URL |
โ | neon.tech | Pooled URL, ends in ?sslmode=require |
REDIS_URL |
โ | upstash.com | Starts with rediss:// (double-s = TLS) |
IP_HASH_SECRET |
โ | openssl rand -hex 32 |
Min 32 chars. Never store raw IPs (GDPR) |
RESEND_API_KEY |
โฌ | resend.com | Without it, emails log to console |
EMAIL_FROM |
โฌ | Your domain | e.g. noreply@yourdomain.com |
CLOUDINARY_CLOUD_NAME |
โฌ | cloudinary.com | Without it, upload endpoint returns 503 |
CLOUDINARY_API_KEY |
โฌ | cloudinary.com | โ |
CLOUDINARY_API_SECRET |
โฌ | cloudinary.com | โ |
GOOGLE_CLIENT_ID |
โฌ | console.cloud.google.com | Without it, OAuth routes not registered |
GOOGLE_CLIENT_SECRET |
โฌ | console.cloud.google.com | โ |
NODE_ENV |
โฌ | โ | development / production / test |
PORT |
โฌ | โ | Default: 8000 |
BASE_URL |
โฌ | โ | Your API's public URL (used in OAuth callbacks) |
WEB_URL |
โฌ | โ | Your frontend URL (email links, OAuth redirects) |
CORS_ORIGIN |
โฌ | โ | Exact frontend origin โ no trailing slash |
Optional variables fail gracefully: no email key = emails printed to console; no Cloudinary = upload returns 503; no Google creds = OAuth routes simply not mounted.
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /api/auth/signup |
Public | Create account. Sets sf_session cookie |
| POST | /api/auth/login |
Public | Login. Sets sf_session cookie |
| POST | /api/auth/logout |
๐ | Clears session + Redis cache |
| GET | /api/auth/me |
๐ | Get current user |
| POST | /api/auth/forgot-password |
Public | Send reset email (always returns 200) |
| POST | /api/auth/reset-password |
Public | Reset with token |
| POST | /api/auth/logout-all |
๐ | Invalidate all sessions |
| PATCH | /api/auth/profile |
๐ | Update name/avatar |
| GET | /auth/google/redirect |
Public | Redirect to Google OAuth |
| GET | /auth/google/callback |
Public | OAuth callback |
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/forms |
๐ | List forms (cursor pagination) |
| POST | /api/forms |
๐ | Create form (plan limit enforced) |
| GET | /api/forms/{id} |
๐ | Get form with typed fields + theme |
| PATCH | /api/forms/{id} |
๐ | Update settings, slug, password, expiry |
| DELETE | /api/forms/{id} |
๐ | Soft-delete + clear explore cache |
| POST | /api/forms/{id}/publish |
๐ | Snapshot fields โ publish + clear explore cache |
| POST | /api/forms/{id}/unpublish |
๐ | Draft + clear explore cache |
| POST | /api/forms/{id}/duplicate |
๐ | Clone form + all fields |
| GET | /api/forms/public/{slug} |
Public | Get published form (bcrypt password check) |
| GET | /api/forms/explore |
Public | Browse public forms (cached 2 min) |
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /f/{slug}/submit |
Public | Submit response โ 8-layer validation pipeline |
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /api/forms/{formId}/fields |
๐ | Add field (plan maxFieldsPerForm enforced) |
| PATCH | /api/forms/{formId}/fields/{fieldId} |
๐ | Update field |
| DELETE | /api/forms/{formId}/fields/{fieldId} |
๐ | Delete field |
| PUT | /api/forms/{formId}/fields/reorder |
๐ | Reorder all fields |
| POST | /api/forms/{formId}/fields/{fieldId}/duplicate |
๐ | Clone field |
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/forms/{formId}/responses |
๐ | List (single-query N+1 eliminated) |
| GET | /api/responses/{responseId} |
๐ | Full response with all answers |
| DELETE | /api/responses/{responseId} |
๐ | Delete (decrements counter safely) |
| POST | /api/forms/{formId}/responses/export |
๐ | Queue CSV/JSON export |
| GET | /api/export/{exportJobId}/status |
๐ | Poll export job status |
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /api/analytics/track |
Public | Track event (queued async) |
| GET | /api/analytics/forms/{formId} |
๐ | Full stats with fieldDropOff, deviceBreakdown, topSources |
| GET | /api/analytics/dashboard |
๐ | Dashboard summary (cached 2 min) |
| Method | Path | Auth | Plan | Description |
|---|---|---|---|---|
| GET | /api/themes |
Public | โ | List themes (cached 10 min) |
| POST | /api/themes/{themeId}/apply |
๐ | โ | Apply theme, fix usageCount atomically |
| GET | /api/webhooks |
๐ | Studio | List webhooks |
| POST | /api/webhooks |
๐ | Studio | Create webhook (HMAC secret generated) |
| DELETE | /api/webhooks/{id} |
๐ | Studio | Delete webhook |
| POST | /api/webhooks/{id}/test |
๐ | Studio | Send signed test payload |
| GET | /api/api-keys |
๐ | Studio | List keys (prefix shown, hash never) |
| POST | /api/api-keys |
๐ | Studio | Create key (full key shown once) |
| DELETE | /api/api-keys/{id} |
๐ | Studio | Revoke key |
| Method | Path | Description |
|---|---|---|
| POST | /upload/sign |
Cloudinary pre-signed upload params |
| GET | /health |
Health check (includes X-Request-Id) |
| GET | /openapi.json |
Full OpenAPI 3.0 document |
| GET | /docs |
Scalar interactive API explorer |
Raw IP addresses are never stored. Every submission stores:
HMAC-SHA256(ip + ":" + formId + ":" + todayDate, IP_HASH_SECRET)The daily rotation means you cannot track a user across days even with the secret. The formId salt means the same IP on two forms produces different hashes.
// Storing โ self-salting, slow, rainbow-table-proof
const passwordHash = hashSync(password, 10); // bcrypt cost factor 10
// Verifying โ timing-safe comparison
const valid = compareSync(submittedPassword, form.passwordHash);Previously used HMAC("sha256", "sf_form_pwd") with a hardcoded static key. This was a critical flaw (rainbow table attack possible once the key is leaked from source code). Fixed in this version.
Each user gets a unique 16-byte random salt. Passwords are stored as HMAC-SHA256(password, salt). The same email+password produces a different hash on every account โ dictionary attacks require per-user computation.
// Same error message for wrong email AND wrong password
if (!user || !user.salt || hash !== user.password) {
throw domainError("INVALID_CREDENTIALS", "Wrong email or password", "UNAUTHORIZED");
}
// An attacker cannot distinguish "no such user" from "wrong password"The __hp field uses z.literal("") โ it must be an empty string. CSS hides it from real users. Bots that fill all fields submit a non-empty value, which fails the Zod envelope parse with a generic 422 โ indistinguishable from a real validation error.
The response limit is now checked inside the database transaction with a re-read of the current counter:
const responseId = await db.transaction(async (tx) => {
if (form.responseLimit) {
const [current] = await tx.select({ totalResponses: formsTable.totalResponses })
.from(formsTable).where(eq(formsTable.id, form.id));
if (current && current.totalResponses >= form.responseLimit) {
limitExceeded = true; return null;
}
}
// ... proceed with insert
});Previously the limit was checked against a Redis-cached value that could be stale under concurrent load.
Every webhook delivery signs the body:
const sig = createHmac("sha256", webhook.secret).update(body).digest("hex");
// Header: X-ScribbleForms-Signature: sha256=<hex>Recipients verify: expectedSig === receivedSig. Pattern identical to Stripe, GitHub, Shopify.
packages/trpc/server/__tests__/ Unit tests โ services, repositories, middleware
apps/api/src/__tests__/ Integration tests โ HTTP routes via supertest
| Package | Files | Tests | Coverage |
|---|---|---|---|
@repo/trpc |
9 | 62 | โฅ75% lines |
@repo/api |
4 | 21 | โฅ70% lines |
| Total | 13 | 83 | โ |
vi.resetAllMocks() not vi.clearAllMocks() in integration tests:
clearAllMocks only clears call counts โ persistent mockReturnValue/mockResolvedValue implementations survive and bleed into the next test. resetAllMocks clears everything, giving each test a truly clean mock state.
Mock at repository boundary, not DB:
Unit tests for AuthService mock AuthRepository. This tests the service's logic (password hashing, error throwing, token generation) without any external dependencies. Tests run in ~50ms total with no DB required.
Real UUIDs in tests (Zod v4):
Zod v4 tightened z.string().uuid() to validate actual UUID version/variant bits. "11111111-1111-1111-1111-111111111111" fails because all-same digits aren't valid RFC 4122. All test UUIDs use real v4 format (e.g., "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11").
All issues identified by the hackathon judge panel have been resolved:
| Issue | Severity | Fix Applied |
|---|---|---|
Hardcoded "sf_form_pwd" HMAC key for form passwords |
Critical | Replaced with bcrypt (cost=10) โ self-salting, rainbow-table-proof |
| Broken cursor pagination โ always returned page 1 | Major | Real WHERE created_at < cursor implementation |
require("zod") inside submission hot path |
Major | Moved to static top-level import |
z.enum() crash on empty options array |
Major | Guard: falls back to z.string() when options are empty |
| Response limit race condition under concurrency | Major | Re-read counter inside transaction with SELECT ... FOR UPDATE semantics |
z.any() output on /forms/{id} |
Major | Replaced with formDetailOutputSchema with typed fields + theme |
| Explore cache not cleared on unpublish/delete | Major | cacheDelPattern("sf:explore:*") on publish/unpublish/delete |
db.$count(themesTable) โ invalid Drizzle syntax |
Major | Fixed to sql\${themesTable.usageCount} + 1`` |
N+1 query in responses.listForForm |
Major | Single inArray batch query replaces per-response queries |
maxFieldsPerForm plan limit not enforced |
Major | Plan check added to addField with clear error message |
| Webhook worker loaded all webhooks O(N) | Minor | SQL filter with JSONB @> operator โ O(1) index lookup |
fieldDropOff, deviceBreakdown, topSources returned empty |
Minor | Wired real data: UA parsing, referrer parsing, analytics events query |
No X-Request-Id header |
Minor | Added UUID header to all responses |
| No password-protected demo form | Minor | Added "VIP Creator Application" with password hackathon2025 |
applyConditions / buildFieldSchema duplicated in API |
Architecture | Moved to @repo/validators โ shared with frontend |
| Form | Visibility | Password |
|---|---|---|
| Anime Fan Survey ๐ | Public | โ |
| Gaming Tournament Sign-up ๐ฎ | Public | โ |
| Startup Onboarding ๐ | Public | โ |
| Dev Tools Feedback ๐ ๏ธ | Public | โ |
| Solar Vibes Music Poll โ๏ธ | Unlisted | โ |
| VIP Creator Application ๐ | Unlisted | hackathon2025 |
Each form has 200โ400 seeded responses and 30 days of analytics data.
| Feature | Free | Creator | Studio |
|---|---|---|---|
| Max forms | 3 | 20 | Unlimited |
| Max fields per form | 10 | 50 | Unlimited |
| Responses/month | 100 | 1,000 | Unlimited |
| Custom slug | โ | โ | โ |
| Password protection | โ | โ | โ |
| Conditional logic | โ | โ | โ |
| File upload | โ | โ | โ |
| CSV/JSON export | โ | โ | โ |
| Webhooks | โ | โ | โ |
| API keys | โ | โ | โ |