Skip to content

Repository files navigation

Hackathon Idea Generator

Pick your interests, how long you have, and how many people are on your team. Get three buildable hackathon ideas back — each with core features, a concrete stack, a 30-minute MVP plan, and the one moment to show the judges.

Built on Stripe Projects for service provisioning: OpenRouter for generation, Neon Postgres for a small "recently generated" strip.


Stack

Piece Choice Why
Framework Next.js 16 (App Router, Turbopack) One process for UI + API, deploys to Vercel unchanged
LLM OpenRouter, free model chain $0 per request, see Cost posture
Database Neon serverless Postgres (optional) HTTP driver, no connection pooling to manage
Styling Hand-written CSS No build step, no framework to configure
Abuse controls proxy.ts + in-process rate limiter No new service, no new dependency, see Abuse controls
Tests node --test + tsx No test framework dependency

Provisioned services

All on free tiers. Provisioned via stripe projects add:

  • openrouter/api — LLM access
  • neon/free — Neon plan (free tier)
  • neon/postgres — the database itself

Credentials come from stripe projects env --pull and land in .env, which is gitignored. Never hand-edit .env — it is regenerated from the Stripe Projects vault. See .env.example for the variable names.

Run it

npm install
stripe projects env --pull    # populates .env
npm run dev                   # http://localhost:3000

Other commands:

npm run typecheck   # tsc --noEmit
npm test            # 117 unit tests, zero network calls
npm run build       # production build

Cost posture

The provisioned OpenRouter key reports is_free_tier: false, which means a paid model slug on this key would bill real money. Nothing in this app is authorised to spend, so lib/openrouter.ts refuses any model slug that does not end in :free:

assertFreeModel("openai/gpt-4o");  // throws OpenRouterError

The guard runs in both directions. assertFreeModel refuses a paid slug before the call; assertZeroCost refuses the result if a :free slug comes back with a non-zero usage.cost — which is what a model re-tiered upstream looks like:

assertFreeModel("openai/gpt-4o");            // throws before spending
assertZeroCost("vendor/model:free", 0.0004); // throws after one charge, not every charge

The second one throws inside the model chain's own try/catch, so the request falls through to the next model and the user still gets ideas. Detecting a charge and returning the result anyway would be detection without enforcement.

To deliberately unlock paid models, set ALLOW_PAID_MODELS=true. That disables both guards. It is a real money decision — leave it unset unless someone with budget authority says otherwise. Verified spend after end-to-end testing: usage_total = 0.

Model chain

Free models are rate-limited upstream and occasionally return schema-shaped JSON with every field empty. So the client walks a chain and abandons an entry on any failure — transport error, HTTP error, unparseable JSON, or output that parses but yields no usable idea:

  1. nvidia/nemotron-3-super-120b-a12b:free (default; best output quality)
  2. google/gemma-4-26b-a4b-it:free
  3. google/gemma-4-31b-it:free
  4. nvidia/nemotron-nano-9b-v2:free

Override the primary with OPENROUTER_MODEL.

Timeouts are sized to fit inside the 60s maxDuration declared by the API route — 50s per attempt, 55s total. Overshooting the function ceiling means the platform hard-kills the request and the user sees a blank 504 instead of our 503-with-explanation. The defaults are identical in local dev and production on purpose, so local testing predicts production behaviour. Raise them with GENERATION_ATTEMPT_MS / GENERATION_BUDGET_MS only if the deploy target's ceiling is genuinely higher.

Two free models were tested and rejected: openai/gpt-oss-20b:free returned valid JSON with empty pitch/features/mvp_plan, and google/gemma-4-31b-it:free was rate-limited at the time of testing (it stays in the chain as a fallback since the limit is transient).


Abuse controls

/api/generate is unauthenticated and expensive: one call pins a serverless function for up to 55s, spends free-tier OpenRouter quota, writes a Neon row, and adds an entry to the public "recently generated" strip. Roughly eight calls fill that strip. Without a limit, one while true; do curl ...; done exhausts all four at once, which is why this exists.

Rate limiting

proxy.ts runs before every route under /api and counts requests per client.

Route Per minute Per day
/api/generate 5 50
everything else under /api 30 600

Over the limit gets a 429 with Retry-After and the same { "error": ... } body shape as every other API error, so the existing UI renders it unchanged. Pages are not matched — a client that has spent its budget can still load the site.

Two details worth knowing before changing anything here:

  • The file is proxy.ts, not middleware.ts. Next.js 16 renamed the convention. The exported function must be called proxy, and setting runtime in that file is an error — Proxy is always Node.js runtime.
  • Blocked requests do not consume budget. A client over its per-minute limit does not also burn its daily allowance by retrying, so a short burst cannot lock someone out for the rest of the day.

Limits are tunable by environment variable (see .env.example). There is deliberately no single "disable rate limiting" switch, because that is the kind of flag that gets set once for a load test and then lives in production forever. To loosen a limit, raise the number.

What this does not do. Counters live in the process. On a platform running several instances the real ceiling is instances x limit, and a cold start resets them. That is a deliberate trade for a hobby-tier app — a shared atomic store (Upstash, Vercel KV) is the correct answer for a real product, and swapping it in means reimplementing checkRateLimit against an atomic INCR without moving any call site. The map is also size-capped at 20,000 keys: an unbounded map keyed on IP is itself the vulnerability, since one IPv6 /64 offers 2^64 distinct keys. For the same reason IPv6 addresses are grouped by /64 — per-address keying would let anyone with an IPv6 connection mint unlimited budgets, which makes the whole limiter decorative.

Client identity comes from x-vercel-forwarded-for, then x-real-ip, then x-forwarded-for. These are only meaningful because a proxy in front of the app overwrites them, which holds on Vercel. On a server exposed directly to the internet a client can supply its own and mint identities — verify that property before deploying anywhere else. With no such headers at all, every caller shares one bucket, which fails in the safe direction.

The public strip

The "recently generated" strip is the one surface where text influenced by an anonymous caller is shown to every visitor. It is not an XSS hole — React escapes output and there is no dangerouslySetInnerHTML or eval anywhere in this repo — the exposure is that an anonymous party can write to a public page.

recentIdeas() sanitises on the way out: URLs, bare www. links and email addresses are stripped, control characters and bidi overrides are flattened, and each field is capped (title 80, pitch 160, interest 40, six interests shown). Sanitising on read rather than write means rows written before this existed are cleaned too, and the stored record stays a faithful copy of what was generated.

That removes the mechanical payoff — link spam, contact details, text that renders in a different order than it is stored. It does not moderate meaning; nothing regex-shaped does. For that case, RECENT_STRIP=off takes the strip down without a code change. Generations keep being saved either way, they just stop being displayed.


Design notes

Persistence is optional, on purpose. The core value is the model call. The database only powers the "recently generated" strip, so every function in lib/db.ts swallows its own errors and degrades to a no-op. No connection string, an unreachable database, or a failed migration must never stop a user getting ideas. saveGeneration() returns false instead of throwing; recentIdeas() returns []. /api/recent always returns 200.

Validation has two postures. Request validation is strict — bad input is a client error, reject it with a 400. Model-output validation is tolerant-then-strict: coerce what can be coerced, drop ideas that are still unusable, then throw if nothing survives so the caller can try the next model.

The schema is the contract. lib/schema.ts holds the TypeScript types, the JSON schema sent to OpenRouter, and both validators. The form, the API route, and the model call all read from that one file.

Latency is inherent. Free models run ~20–30 tok/s, so a generation takes ~30s. The UI shows a live elapsed counter rather than pretending it is fast.

Files

proxy.ts                   Rate limiting for every /api route (Next 16 "Proxy")
app/
  page.tsx                 Form + results (client component)
  layout.tsx               Shell + metadata
  globals.css              All styling
  api/generate/route.ts    POST — validate, generate, best-effort save
  api/recent/route.ts      GET — recent strip, always 200
lib/
  schema.ts                Types, JSON schema, both validators, prompt builder
  openrouter.ts            Model chain, cost guards, timeouts
  db.ts                    Optional Neon persistence + public-strip sanitiser
  rate-limit.ts            Fixed-window counters, bounded memory, IP grouping
tests/
  schema.test.ts           Validation + prompt behaviour
  openrouter.test.ts       Cost guards + chain resolution (no network)
  openrouter-chain.test.ts Fail-closed cost enforcement, stubbed transport
  rate-limit.test.ts       Window, rollover, isolation, memory bound
  client-key.test.ts       Header precedence, port/zone stripping, IPv6 /64
  proxy.test.ts            End-to-end 429 behaviour against real NextRequests
  db.test.ts               Public-strip sanitiser + kill switch

Database schema

Created lazily on first use, memoised per process:

CREATE TABLE IF NOT EXISTS generations (
  id             BIGSERIAL PRIMARY KEY,
  created_at     TIMESTAMPTZ NOT NULL DEFAULT now(),
  interests      JSONB       NOT NULL,
  time_available TEXT        NOT NULL,
  team_size      INTEGER     NOT NULL,
  model          TEXT        NOT NULL,
  ideas          JSONB       NOT NULL
);

Deploy notes — live

Production URL: https://hackathon-idea-generator-delta.vercel.app

That is the only public address. The Vercel project runs with ssoProtection.deploymentType = all_except_custom_domains, so the per-deployment URL and the team-slug alias both 302 to vercel.com/login. Only the assigned production domain above is reachable without a Vercel account — hand out that one, and re-check it with an unauthenticated request if the protection setting is ever changed.

Deployed from a local working copy, no git remote involved:

export VERCEL_TOKEN=... VERCEL_ORG_ID=... VERCEL_PROJECT_ID=...   # from .env
vercel deploy --prod --scope <team-slug> -y --no-wait

Two environment variables are set on the Vercel project, Production target only, both stored as Vercel sensitive variables — they are write-only, so the API returns an empty string for them and neither vercel env pull nor the dashboard can read them back. Verifying them means making a real request, not reading the value:

Variable Verified by
OPENROUTER_API_KEY POST /api/generate returned ideas rather than the "not set" 503
NEON_POSTGRES_CONNECTION_STRING GET /api/recent returned enabled: true with rows

They are deliberately not set on the preview target: preview URLs are a wider surface than production, and the database credential is the same one. Adding a preview deploy later means adding them there too.

Measured against the live production URL:

  • Homepage 200, 8.9 KB.
  • POST /api/generate200 in 12.2s, nvidia/nemotron-3-super-120b-a12b:free (first in the chain), saved: true, 3 ideas. One sample, and comfortably inside the budget — but the local spread below is still the number to plan against, not this one run.
  • Rate limiting is enforced in production, not merely counted: 20 requests over a single reused connection gave 8 × 200 then 12 × 429 carrying the real message from proxy.ts. The next request landed on a different instance and passed with X-RateLimit-Remaining: 29 — the per-instance ceiling described below, observed in production rather than assumed.

app/api/generate/route.ts sets maxDuration = 60, the Vercel Hobby ceiling without Fluid compute.

Five measured generations: 23.8s, 29.8s, 42.4s, 44.8s, 49.5s. The spread is wide because free models are shared capacity, and the slowest observed run is already at the 50s per-attempt cap. Two consequences, stated plainly:

  • The 55s total budget sits below the 60s ceiling, so a slow model yields a clean 503 with an explanation rather than a platform hard-kill. That safety property holds.
  • There is only room for one real attempt inside that budget. The fallback chain only helps when the first model fails fast (bad slug, 429, malformed output) — verified: a bogus primary failed in <1s and the chain recovered on the next model. It does not help when the first model is merely slow.

Occasional 503s under the 60s ceiling are expected, not a bug. If that becomes unacceptable in practice, fix it in order of preference:

  1. Enable Fluid compute on the Vercel project, raise maxDuration to 300, and raise GENERATION_ATTEMPT_MS / GENERATION_BUDGET_MS to match.
  2. Drop IDEA_COUNT in lib/schema.ts from 3 to 2.
  3. Stream the response instead of returning it in one shot.

The rate-limit variables are still unset on Vercel, on purpose. Every one of them has a default in lib/rate-limit.ts, and those defaults are what the tests assert; setting them to the same numbers on the platform creates a second source of truth that can drift without anything failing. To change a limit, set the variable — that makes the override visible precisely because it is the only one there. Same for RECENT_STRIP: unset means the strip is on.

Security posture

The route is public and stays public — there is no auth, by design. What stands between it and abuse:

  • Rate limiting in proxy.ts, per client IP, before any expensive work happens.
  • Both cost guards in lib/openrouter.ts, which now refuse a charge rather than log one.
  • Output sanitising on the public strip, plus RECENT_STRIP=off as the lever for content a regex cannot judge.

The honest residual risk, in one line: counters are per instance, so a distributed attacker with many source addresses still gets instances x limit. If this ever gets real traffic, move checkRateLimit onto a shared atomic store — that is the one change that closes it.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages