← back to Freddy
freddy: port middleware.ts + lib/gemini.ts from Patty pattern
15e5788e0a5bdfa5d68035b83c6bafaac483a0a8 · 2026-05-30 12:21:48 -0700 · Steve Abrams
Files touched
A lib/gemini.tsA middleware.ts
Diff
commit 15e5788e0a5bdfa5d68035b83c6bafaac483a0a8
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Sat May 30 12:21:48 2026 -0700
freddy: port middleware.ts + lib/gemini.ts from Patty pattern
---
lib/gemini.ts | 109 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
middleware.ts | 93 +++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 202 insertions(+)
diff --git a/lib/gemini.ts b/lib/gemini.ts
new file mode 100644
index 0000000..1d5c99c
--- /dev/null
+++ b/lib/gemini.ts
@@ -0,0 +1,109 @@
+/**
+ * Gemini 2.0 Flash wrapper — single source of truth for Freddy's 4 AI call
+ * sites: causes/discover, matches/generate, contacts/discover, contacts/generate-letter.
+ *
+ * 2026-05-30 (tick 11 from TODO.md): ported from Patty's lib/gemini.ts after the
+ * soak window. Replaces the (key + URL + AbortSignal + fetch + parse + fence-strip)
+ * pattern that was duplicated across the 4 AI routes. Forces env discipline — if
+ * GEMINI_API_KEY isn't set, every callsite gets a 503 reason='no_key' instead of
+ * silently calling Google with key=''.
+ *
+ * Returns a Result-shaped object instead of throwing — callers map status codes
+ * (504 abort / 502 upstream / 500 parse). Generic <T> is the typed shape for
+ * parsed JSON when parseJson=true; raw string is also returned.
+ */
+
+const GEMINI_KEY = process.env.GEMINI_API_KEY;
+const MODEL = 'gemini-2.0-flash';
+const GEMINI_URL = `https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent`;
+
+export type GeminiOk<T> = {
+ ok: true;
+ data: T;
+ raw: string;
+ inputTokens: number;
+ outputTokens: number;
+};
+
+export type GeminiErr = {
+ ok: false;
+ reason: 'no_key' | 'timeout' | 'upstream' | 'parse';
+ status: number;
+ detail?: string;
+};
+
+export type GeminiResult<T> = GeminiOk<T> | GeminiErr;
+
+export async function callGemini<T = unknown>(opts: {
+ prompt: string;
+ maxTokens?: number;
+ temperature?: number;
+ timeoutMs?: number;
+ parseJson?: boolean;
+}): Promise<GeminiResult<T>> {
+ if (!GEMINI_KEY) {
+ return { ok: false, reason: 'no_key', status: 503, detail: 'GEMINI_API_KEY env unset' };
+ }
+
+ const {
+ prompt,
+ maxTokens = 4096,
+ temperature = 0.7,
+ timeoutMs = 25_000,
+ parseJson = true,
+ } = opts;
+
+ let res: Response;
+ try {
+ res = await fetch(`${GEMINI_URL}?key=${GEMINI_KEY}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ contents: [{ parts: [{ text: prompt }] }],
+ generationConfig: { temperature, maxOutputTokens: maxTokens },
+ }),
+ signal: AbortSignal.timeout(timeoutMs),
+ });
+ } catch (e) {
+ return { ok: false, reason: 'timeout', status: 504, detail: (e as Error).message };
+ }
+
+ if (!res.ok) {
+ const detail = await res.text().catch(() => '');
+ console.error('[lib/gemini] upstream error:', res.status, detail.slice(0, 500));
+ return { ok: false, reason: 'upstream', status: 502, detail: `gemini ${res.status}` };
+ }
+
+ const data = await res.json();
+ const raw: string = data.candidates?.[0]?.content?.parts?.[0]?.text ?? '';
+ const usage = data.usageMetadata ?? {};
+ const inputTokens: number = usage.promptTokenCount ?? 0;
+ const outputTokens: number = usage.candidatesTokenCount ?? 0;
+
+ if (!parseJson) {
+ return {
+ ok: true,
+ data: raw as unknown as T,
+ raw,
+ inputTokens,
+ outputTokens,
+ };
+ }
+
+ // Strip ```json fences then JSON.parse. Caller is responsible for
+ // structural validation of the parsed payload.
+ const cleaned = raw.replace(/```json\s*/gi, '').replace(/```\s*/g, '').trim();
+ try {
+ const parsed = JSON.parse(cleaned) as T;
+ return {
+ ok: true,
+ data: parsed,
+ raw,
+ inputTokens,
+ outputTokens,
+ };
+ } catch (e) {
+ console.error('[lib/gemini] parse error:', cleaned.slice(0, 500));
+ return { ok: false, reason: 'parse', status: 500, detail: (e as Error).message };
+ }
+}
diff --git a/middleware.ts b/middleware.ts
new file mode 100644
index 0000000..709bece
--- /dev/null
+++ b/middleware.ts
@@ -0,0 +1,93 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { verifyAuth } from '@/lib/auth';
+
+/**
+ * Freddy middleware — auth gate, request-id injection, rate-limit on AI routes.
+ *
+ * 2026-05-30 (tick 11 from TODO.md): ported after the soak window from Patty
+ * (canonical) and Grant. Centralizes the (verifyAuth → 401) pattern that was
+ * duplicated across every /api route. Adds `x-request-id` for log correlation.
+ * Adds in-memory sliding-window rate-limit on Freddy's 4 Gemini routes
+ * (causes/discover, matches/generate, contacts/discover, contacts/generate-letter).
+ *
+ * Like Patty, Freddy is single-tenant per-user (no organizations table yet),
+ * so it stays on legacy `verifyAuth` (returns username string). Adopt
+ * `verifyAuthWithOrg` later if/when a granter-org model lands.
+ *
+ * Runtime: Node (verifyAuth uses node:crypto). Next.js 15+ supports Node-runtime
+ * middleware natively.
+ */
+
+export const config = {
+ runtime: 'nodejs',
+ matcher: ['/api/:path*'],
+};
+
+// 2026-05-30 (P1-1): in-memory sliding-window rate-limit. Per-user-per-route
+// counters live in a Map; entries expire after WINDOW_MS. v1 implementation —
+// survives a single-process pm2 restart but not horizontal scale. Mirrors
+// Patty's pattern exactly so the swap-to-Redis story is uniform across the fleet.
+type Bucket = { count: number; resetAt: number };
+const buckets: Map<string, Bucket> = new Map();
+const WINDOW_MS = 60_000;
+const AI_LIMIT_PER_MINUTE = 10;
+
+const AI_ROUTES = new Set([
+ '/api/causes/discover',
+ '/api/matches/generate',
+ '/api/contacts/discover',
+ '/api/contacts/generate-letter',
+]);
+function isAiRoute(pathname: string): boolean {
+ return AI_ROUTES.has(pathname);
+}
+
+// Freddy has no public unauthenticated API yet (no equivalent to Patty's
+// /api/public/signatures). Keep this list narrow on purpose — every new entry
+// is an unauthenticated write surface that needs explicit review.
+const PUBLIC_API = new Set([
+ '/api/auth/login',
+ '/api/auth/logout',
+ '/api/auth/session',
+]);
+
+export function middleware(request: NextRequest) {
+ const { pathname } = request.nextUrl;
+
+ // Public endpoints bypass the gate.
+ if (PUBLIC_API.has(pathname)) {
+ return injectRequestId(NextResponse.next(), request);
+ }
+
+ // Auth gate for everything else under /api/**.
+ const username = verifyAuth(request);
+ if (!username) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ // Rate-limit the Gemini-paid AI routes. Key by username (single-tenant).
+ if (isAiRoute(pathname)) {
+ const key = `${username}:${pathname}`;
+ const now = Date.now();
+ const b = buckets.get(key);
+ if (!b || b.resetAt < now) {
+ buckets.set(key, { count: 1, resetAt: now + WINDOW_MS });
+ } else if (b.count >= AI_LIMIT_PER_MINUTE) {
+ return NextResponse.json(
+ { error: 'rate_limited', retry_after_ms: b.resetAt - now },
+ { status: 429, headers: { 'retry-after': Math.ceil((b.resetAt - now) / 1000).toString() } },
+ );
+ } else {
+ b.count++;
+ }
+ }
+
+ return injectRequestId(NextResponse.next(), request);
+}
+
+function injectRequestId(res: NextResponse, request: NextRequest): NextResponse {
+ const incoming = request.headers.get('x-request-id');
+ const reqId = incoming || crypto.randomUUID();
+ res.headers.set('x-request-id', reqId);
+ return res;
+}
← 5430886 harden(auth): add scrypt + rate-limit + refuse-to-boot to /a
·
back to Freddy
·
Add 3 guest login tiers via shared capability layer + deploy 1f9e080 →