Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions v5/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# ── Notion ───────────────────────────────────────────────────────────
# Internal integration token from https://www.notion.so/my-integrations
NOTION_API_KEY=ntn_YourTokenHere

# Notion database IDs (required — share each database with the integration)
NOTION_DB_TOOLS=YourDatabaseIdHere
NOTION_DB_CATEGORIES=YourDatabaseIdHere
NOTION_DB_LOCATIONS=YourDatabaseIdHere
NOTION_DB_UNITS=YourDatabaseIdHere
NOTION_DB_RESOURCES=YourDatabaseIdHere
NOTION_DB_MAINTENANCE_LOGS=YourDatabaseIdHere
NOTION_DB_FLAGS=YourDatabaseIdHere

# ── AI APIs ──────────────────────────────────────────────────────────
# Claude API key for the chat assistant (Vercel AI SDK)
ANTHROPIC_API_KEY=sk-ant-api03-YourKeyHere

# ── Admin ────────────────────────────────────────────────────────────
# Shared secret guarding POST /api/admin/revalidate
ADMIN_REVALIDATE_SECRET=YourSecretHere

# ── Rate limiting (optional) ─────────────────────────────────────────
# Upstash Redis backs the API rate limiter. When both are set, limits are
# enforced across all serverless instances. Without them, the limiter falls
# back to an in-memory store (resets on cold start; fine for basic abuse
# prevention).
UPSTASH_REDIS_REST_URL=
UPSTASH_REDIS_REST_TOKEN=
14 changes: 14 additions & 0 deletions v5/src/app/api/chat/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
import type { MakerLabTool, MakerLabUnit } from "../../../components/catalog-types";
import type { ResourceRecord } from "../../../lib/types";
import { languageNameForLocale } from "../../../i18n/config";
import { getClientIp, rateLimitAsync } from "../../../lib/rate-limit";

const MAX_PDFS_PER_CHAT = 3;
const MAX_PDF_BYTES = 10 * 1024 * 1024; // 10MB ceiling
Expand All @@ -45,6 +46,19 @@ interface ChatRequest {
const PRIORITIES = ["Critical", "High", "Medium", "Low"] as const;

export async function POST(req: Request) {
// Rate limit before any expensive work (Notion fetch / model call).
const ip = getClientIp(req);
const { allowed } = await rateLimitAsync(`chat:${ip}`, {
limit: 20,
windowMs: 60_000,
});
if (!allowed) {
return Response.json(
{ error: "Too many requests. Please slow down." },
{ status: 429, headers: { "Retry-After": "60" } }
);
}

const { messages, toolId, locale }: ChatRequest = await req.json();
const tools = await getCatalogTools();
const focused = toolId ? await getCatalogTool(toolId) : null;
Expand Down
14 changes: 14 additions & 0 deletions v5/src/app/api/upload-notion/route.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { NextRequest } from "next/server";
import { getClientIp, rateLimitAsync } from "../../../lib/rate-limit";

const NOTION_API = "https://api.notion.com/v1";
// file_uploads requires a newer Notion-Version than the catalog reads use.
Expand All @@ -16,6 +17,19 @@ interface CreateFileUploadResponse {
}

export async function POST(req: NextRequest) {
// Rate limit before any expensive work (Notion upload session / byte transfer).
const ip = getClientIp(req);
const { allowed } = await rateLimitAsync(`upload:${ip}`, {
limit: 15,
windowMs: 60_000,
});
if (!allowed) {
return Response.json(
{ error: "Too many requests. Please slow down." },
{ status: 429, headers: { "Retry-After": "60" } }
);
}

const token = process.env.NOTION_API_KEY;
if (!token) {
return Response.json({ error: "Server misconfigured" }, { status: 500 });
Expand Down
93 changes: 93 additions & 0 deletions v5/src/lib/rate-limit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import "server-only";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Add server-only to the v5 dependency graph

This new import is not resolvable when the v5 app is installed/built from its own manifest: v5/package.json does not list server-only, and v5/package-lock.json has no node_modules/server-only entry. Since both API routes now import this module through rate-limit.ts, a clean v5 build or deployment will fail module resolution before those routes can run; add the dependency to the v5 package/lock or avoid the import here.

Useful? React with 👍 / 👎.


interface RateLimitEntry {
count: number;
resetAt: number;
}

const store = new Map<string, RateLimitEntry>();
const UPSTASH_URL = process.env.UPSTASH_REDIS_REST_URL || "";
const UPSTASH_TOKEN = process.env.UPSTASH_REDIS_REST_TOKEN || "";
const useUpstash = Boolean(UPSTASH_URL && UPSTASH_TOKEN);

// Clean up expired entries periodically (every 60s)
let lastCleanup = Date.now();
function cleanup() {
const now = Date.now();
if (now - lastCleanup < 60_000) return;
lastCleanup = now;
for (const [key, entry] of store) {
if (now > entry.resetAt) store.delete(key);
}
}

/**
* Simple in-memory sliding window rate limiter.
* Resets on serverless cold start — good enough for abuse prevention.
*/
export function rateLimit(
key: string,
{ limit, windowMs }: { limit: number; windowMs: number }
): { allowed: boolean; remaining: number } {
// Keep sync behavior for callers. If Upstash is configured, callers should use rateLimitAsync.
if (useUpstash) {
throw new Error("rateLimitAsync must be used when Upstash Redis is configured");
}
cleanup();

const now = Date.now();
const entry = store.get(key);

if (!entry || now > entry.resetAt) {
store.set(key, { count: 1, resetAt: now + windowMs });
return { allowed: true, remaining: limit - 1 };
}

entry.count++;
const allowed = entry.count <= limit;
return { allowed, remaining: Math.max(0, limit - entry.count) };
}

export async function rateLimitAsync(
key: string,
{ limit, windowMs }: { limit: number; windowMs: number }
): Promise<{ allowed: boolean; remaining: number }> {
if (!useUpstash) {
return rateLimit(key, { limit, windowMs });
}

const redisKey = `rl:${key}`;
const ttlSec = Math.max(1, Math.ceil(windowMs / 1000));
const url = `${UPSTASH_URL}/pipeline`;
const body = JSON.stringify([
["INCR", redisKey],
["EXPIRE", redisKey, ttlSec, "NX"],
]);

const res = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${UPSTASH_TOKEN}`,
"Content-Type": "application/json",
},
body,
cache: "no-store",
});

if (!res.ok) {
// Fail open to avoid downtime on transient Redis issues.
return { allowed: true, remaining: limit - 1 };
}

const parsed = (await res.json()) as Array<{ result?: number }>;
const count = Number(parsed?.[0]?.result || 0);
const allowed = count <= limit;
return { allowed, remaining: Math.max(0, limit - count) };
}

/** Extract client IP from request headers (works on Vercel) */
export function getClientIp(req: Request): string {
const forwarded = req.headers.get("x-forwarded-for");
if (forwarded) return forwarded.split(",")[0].trim();
return req.headers.get("x-real-ip") || "unknown";
}
Loading