{
"$type": "site.standard.document",
"bskyPostRef": {
"cid": "bafyreihj4ds4mldm7qeyi7jcg4grehy5yd5775utn72vr7e5rmdatmpdg4",
"uri": "at://did:plc:25rdn5elo5izoxrmtis34zuk/app.bsky.feed.post/3mphor4ruezk2"
},
"coverImage": {
"$type": "blob",
"ref": {
"$link": "bafkreie4u3mtuphqhi42phwlfztf3g4flq2hn63tg3akcnlnflijnbzik4"
},
"mimeType": "image/webp",
"size": 238188
},
"path": "/kajotainc/building-a-passwordless-gemini-advised-dashboard-on-the-zero-stack-3di2",
"publishedAt": "2026-06-29T23:19:04.000Z",
"site": "https://dev.to",
"tags": [
"hackathon",
"aws",
"vercel",
"ai",
"kajota-pulse.vercel.app",
"github.com/KaJota-inc/kajota-pulse"
],
"textContent": "_I built Kajota Pulse and wrote this article as my entry for the **AWS × Vercel \"H0: Hack the Zero Stack\"_ * hackathon (**#H0Hackathon**). Live app: kajota-pulse.vercel.app · Code: github.com/KaJota-inc/kajota-pulse*\n\n## The problem nobody builds for\n\nAcross African micro-commerce, \"co-sellers\" buy stock from wholesalers and resell to their network for a markup. There's a whole industry of tools for _writing the listing_. There's almost nothing for the question that actually decides whether a co-seller makes money: **what should I stock this week?**\n\nSo we built **Kajota Pulse** — a Bloomberg-terminal-style dashboard that watches the marketplace and, in one click, tells a seller what to buy and why. It's the \"monitor\" pillar of a three-app stack: **Coach** drafts the listing, **Pulse** says what to stock, **Mesh** settles the deal on-chain.\n\nThe hackathon constraint was the fun part: build it on the **zero stack** — Vercel for compute, an AWS database for state, no servers to manage. Here's what that actually took.\n\n## Architecture in one breath\n\nNext.js 16 (App Router) on Vercel → **AWS Aurora Serverless v2 (PostgreSQL)** for every number on the dashboard → **Gemini 2.5 Flash** for the advice → **MongoDB Atlas Database Triggers** streaming the real Kajota catalogue in. Five Postgres tables, two SQL views, two Gemini endpoints, one ingest endpoint. No VPC, no connection pooler, no server.\n\nThe interesting engineering wasn't the UI. It was three things that don't show up in tutorials.\n\n## Gotcha 1 — Serverless + Aurora forces _passwordless_ auth (and that's a feature)\n\nWe provisioned Aurora Serverless v2 with the new internet-access-gateway networking model so Vercel could reach it without VPC plumbing. Then every password connection failed with `PAM authentication failed`.\n\nThe new model **mandates IAM database authentication** — and as a bonus, it doesn't support the RDS Data API either. So instead of a stored password, every connection mints a short-lived (15-minute) IAM auth token:\n\n\n\n const signer = new Signer({ hostname, port, username, region, credentials });\n pool = new Pool({\n host, port, user, database,\n password: () => signer.getAuthToken(), // fresh token at each handshake\n ssl: { rejectUnauthorized: false },\n });\n\n\n`pg` supports an async `password` callback, so this is clean. And the security property is genuinely nice: **there is no long-lived database password anywhere** — not in Vercel, not in the repo, not in a secret manager.\n\n## Gotcha 2 — Vercel's Lambda shadows your AWS credentials\n\nThis one cost an hour. The IAM signer needs AWS credentials to sign the token. We set `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` in Vercel… and it still failed.\n\nVercel functions run on Lambda, and **the Lambda runtime injects its _own_ execution-role `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY`**, which shadow yours. The signer was minting tokens with the wrong (no-`rds-db:connect`) identity.\n\nThe fix: use custom env names and pass them explicitly to the signer.\n\n\n\n function signerCredentials() {\n const accessKeyId = process.env.PULSE_AWS_ACCESS_KEY_ID;\n const secretAccessKey = process.env.PULSE_AWS_SECRET_ACCESS_KEY;\n return accessKeyId && secretAccessKey ? { accessKeyId, secretAccessKey } : undefined;\n }\n\n\nA dedicated IAM user with _only_ `rds-db:connect` on the cluster's dbuser resource, surfaced under `PULSE_AWS_*`, and the shadowing problem disappears.\n\n## Gotcha 3 — Real change-streams are messier than seed data\n\nThe dashboard is only as good as its data, so we wired **three MongoDB Atlas Database Triggers** on the real Kajota collections (`products`, `cosell_products`, `orders`). Each trigger POSTs its change event to `/api/ingest`, which upserts into Aurora. Hooking this to _production_ data immediately surfaced three bugs that a seed file would never reveal:\n\n 1. **Extended JSON.** Atlas serializes change events as EJSON, so a Mongo `_id` arrives as `{\"$oid\":\"…\"}` and a price as `{\"$numberInt\":\"9500\"}` — not as a string and a number. Without decoders you get `id=\"[object Object]\"` and `price=NaN` in your database. Two small helpers (`ejsonId`, `ejsonNum`) fixed it.\n 2. **Collection naming.** The real collection is `cosell_products` (underscore), but our router matched `cosellproducts`. Events silently fell through as \"ignored.\" Now the router normalizes names.\n 3. **Foreign keys vs. event ordering.** Change-stream events arrive _out of order_ — a co-sell listing can land before the product it references. Our FK constraints silently dropped those rows. The fix is counterintuitive but correct for event-streamed ingestion: **drop the FKs** and treat each table as an independent projection.\n\n\n\nNone of these reproduce against fixtures. They only appear when real production writes hit your pipeline — which is exactly why we wired it to live data instead of demoing on a seed.\n\n## The feature that makes it an advisor, not a dashboard\n\nA dashboard shows you numbers and makes you do the synthesis. We wanted Pulse to _answer the question_. So `/api/recommend` pulls the live signals — trending demand, category margins, competitor stock-outs, price position — and hands them to Gemini 2.5 Flash with a **structured-output schema** :\n\n> **Organic Shea Butter** → _Stock 10–15 units before the weekend._\n> _\"+27 favorites, sits in the high-margin Beauty category (18%), and a competitor just ran out of a similar cream.\"_\n\nTwo details that matter for a demo that can't break:\n\n * **Structured JSON output** (`responseMimeType: \"application/json\"` + a `responseSchema`) means we render a clean ranked list, not parse prose.\n * **A deterministic fallback.** If Gemini is ever unavailable, a heuristic ranking (demand × margin × opportunity) runs instead, so the card is never empty in front of a judge — or a customer.\n\n\n\n## What \"zero stack\" actually bought us\n\n * **No servers.** Vercel functions for the API, Aurora for state. Nothing to patch or scale.\n * **Scale to zero.** Aurora Serverless v2 idles to zero ACUs; the cold-start (~8s) is the only tax, and it's easy to pre-warm.\n * **One-command verification.** `node scripts/verify-live.mjs` checks the live landing page, the Aurora badge, both Gemini endpoints, the ingest auth gate, and a real IAM-authenticated row count — 5/5. Anyone can run it.\n\n\n\n## Takeaways\n\n 1. The new Aurora networking model _forces_ passwordless IAM auth. Lean into it — it's a better security posture than a stored password, and once you handle the Lambda credential-shadowing quirk, it's a few lines.\n 2. If you want to know whether your data pipeline works, point it at _real_ data, not a seed. The three ingestion bugs we found were all invisible until production writes hit them.\n 3. For an LLM feature in a live demo, use structured output and always ship a deterministic fallback. \"Never empty\" beats \"usually impressive.\"\n\n\n\n**Live:** kajota-pulse.vercel.app · **Code:** github.com/KaJota-inc/kajota-pulse · Built on Next.js 16 (Vercel) + Aurora Serverless v2 + Gemini 2.5 Flash.\n\n### Adapting this post\n\n * **Dev.to / Hashnode / Medium:** publish as-is (add a cover image — the demo GIF works).\n * **LinkedIn:** lead with \"The new Aurora model won't let you use a password — here's why that's a good thing,\" then the three gotchas, then the link.\n * **X/Bluesky thread:** one gotcha per post (passwordless IAM → Lambda shadowing → EJSON/FK), close with the live link + GIF.\n\n",
"title": "Building a passwordless, Gemini-advised dashboard on the \"zero stack\""
}