[object Object]

← 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

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 →