diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 0ae234b..d2f7f93 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -353,3 +353,17 @@ All contributors will be recognized in the project. We value every contribution
Built with ❤️ by the FlipTrack community
+
+---
+
+## Rate Limiting
+
+A reusable `rateLimit(request, limit, windowMs)` utility is available in `app/utils/rate-limit.server.ts`.
+
+Example:
+
+```ts
+await rateLimit(request, 5, 60_000);
+```
+
+This implementation currently uses an in-memory store. Because FlipTrack is deployed on Vercel, the in-memory cache is per serverless invocation and is not shared across instances. For production deployments requiring distributed rate limiting, consider using a shared backend such as Upstash Redis or Vercel KV.
diff --git a/app/routes/api.insights.ts b/app/routes/api.insights.ts
index b387740..8f8d047 100644
--- a/app/routes/api.insights.ts
+++ b/app/routes/api.insights.ts
@@ -1,5 +1,6 @@
import type { Route } from "./+types/api.insights";
import { getSupabaseServerClient } from "~/utils/supabase.server";
+import { rateLimit } from "~/utils/rate-limit.server";
import { PrismaClient } from "@prisma/client";
import { generateText } from "ai";
import { createGroq } from "@ai-sdk/groq";
@@ -7,6 +8,8 @@ import { createGroq } from "@ai-sdk/groq";
const prisma = new PrismaClient();
export async function action({ request }: Route.ActionArgs) {
+ await rateLimit(request, 10, 60_000);
+
const groq = createGroq({ apiKey: process.env.GROQ_API_KEY });
const { supabase } = getSupabaseServerClient(request);
const { data: { user } } = await supabase.auth.getUser();
diff --git a/app/routes/login-page.tsx b/app/routes/login-page.tsx
index 2abedaa..9c09860 100644
--- a/app/routes/login-page.tsx
+++ b/app/routes/login-page.tsx
@@ -6,6 +6,7 @@ import { LoginForm } from "~/blocks/login-page/login-form";
import { OAuthOptions } from "~/blocks/login-page/o-auth-options";
import { MagicLinkOption } from "~/blocks/login-page/magic-link-option";
import { SignupLink } from "~/blocks/login-page/signup-link";
+import { rateLimit } from "~/utils/rate-limit.server";
export async function loader({ request }: Route.LoaderArgs) {
const { supabase, headers } = getSupabaseServerClient(request);
@@ -15,7 +16,10 @@ export async function loader({ request }: Route.LoaderArgs) {
}
export async function action({ request }: Route.ActionArgs) {
+ await rateLimit(request, 5, 60_000);
+
const { supabase, headers } = getSupabaseServerClient(request);
+
const formData = await request.formData();
const email = formData.get("email") as string;
const password = formData.get("password") as string;
diff --git a/app/routes/signup-page.tsx b/app/routes/signup-page.tsx
index f34dd79..0fadb79 100644
--- a/app/routes/signup-page.tsx
+++ b/app/routes/signup-page.tsx
@@ -7,6 +7,7 @@ import { SignupForm } from "~/blocks/signup-page/signup-form";
import { OAuthSignup } from "~/blocks/signup-page/o-auth-signup";
import { LoginLink } from "~/blocks/signup-page/login-link";
import { TermsAcceptance } from "~/blocks/signup-page/terms-acceptance";
+import { rateLimit } from "~/utils/rate-limit.server";
const prisma = new PrismaClient();
@@ -18,7 +19,10 @@ export async function loader({ request }: Route.LoaderArgs) {
}
export async function action({ request }: Route.ActionArgs) {
+ await rateLimit(request, 5, 60_000);
+
const { supabase, headers } = getSupabaseServerClient(request);
+
const formData = await request.formData();
const name = formData.get("name") as string;
const email = formData.get("email") as string;
diff --git a/app/utils/rate-limit.server.ts b/app/utils/rate-limit.server.ts
new file mode 100644
index 0000000..1fbd2f1
--- /dev/null
+++ b/app/utils/rate-limit.server.ts
@@ -0,0 +1,63 @@
+import { PrismaClient } from "@prisma/client";
+
+const prisma = new PrismaClient();
+
+export function getClientIp(request: Request): string {
+ return (
+ request.headers.get("x-vercel-forwarded-for") ||
+ request.headers.get("x-real-ip") ||
+ request.headers.get("x-forwarded-for")?.split(",")[0].trim() ||
+ "unknown_ip"
+ );
+}
+
+export async function rateLimit(
+ request: Request,
+ limit = 5,
+ windowMs = 60_000
+) {
+ const ip = getClientIp(request);
+ const now = new Date();
+
+ const resetAt = new Date(now.getTime() + windowMs);
+
+ const result = await prisma.rateLimit.upsert({
+ where: { ip },
+ create: {
+ ip,
+ count: 1,
+ resetAt,
+ },
+ update: {
+ count: {
+ increment: 1,
+ },
+ },
+ });
+
+ if (result.resetAt <= now) {
+ await prisma.rateLimit.update({
+ where: { ip },
+ data: {
+ count: 1,
+ resetAt,
+ },
+ });
+
+ return;
+ }
+
+ if (result.count > limit) {
+ throw new Response(
+ JSON.stringify({
+ error: "Too many attempts. Please try again later.",
+ }),
+ {
+ status: 429,
+ headers: {
+ "Content-Type": "application/json",
+ },
+ }
+ );
+ }
+}
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
index 7753209..dfee3b7 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -135,6 +135,7 @@
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7",
@@ -567,29 +568,6 @@
"node": ">=6.9.0"
}
},
- "node_modules/@emnapi/core": {
- "version": "1.10.0",
- "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
- "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "@emnapi/wasi-threads": "1.2.1",
- "tslib": "^2.4.0"
- }
- },
- "node_modules/@emnapi/runtime": {
- "version": "1.10.0",
- "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
- "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "tslib": "^2.4.0"
- }
- },
"node_modules/@emnapi/wasi-threads": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
@@ -2428,6 +2406,7 @@
"resolved": "https://registry.npmjs.org/@react-router/serve/-/serve-7.16.0.tgz",
"integrity": "sha512-ZI26LhXH65HP6z89+VzTK4XXDibnJczCUNQOgZIabKXSx8bmSzwujHe0brFc/eBW8N+tFBn/OZ2UuqELi9MwBg==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@mjackson/node-fetch-server": "^0.2.0",
"@react-router/express": "7.16.0",
@@ -2865,6 +2844,7 @@
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.107.0.tgz",
"integrity": "sha512-ChKzdlWVweMUUhr0U79JhMmgm1haS/C5JquaiCDr70JaGARRtjjoY9rkIheXWybXxTSNzRiQs3Sk8IAg1HS3ZA==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@supabase/auth-js": "2.107.0",
"@supabase/functions-js": "2.107.0",
@@ -2982,6 +2962,7 @@
"integrity": "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==",
"devOptional": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
@@ -2992,6 +2973,7 @@
"integrity": "sha512-esJiCAnl0kfpNdE69f3So4WJUXy95dLZydX0KwK46riIHDzHM7O9Vtf9xCHW0PXIqvgqNrswl522kA/5yx+F4w==",
"devOptional": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -3002,6 +2984,7 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"devOptional": true,
"license": "MIT",
+ "peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -3267,6 +3250,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.10.12",
"caniuse-lite": "^1.0.30001782",
@@ -3875,6 +3859,7 @@
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
"integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
@@ -4270,18 +4255,6 @@
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"license": "ISC"
},
- "node_modules/jiti": {
- "version": "2.7.0",
- "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
- "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "peer": true,
- "bin": {
- "jiti": "lib/jiti-cli.mjs"
- }
- },
"node_modules/jose": {
"version": "5.10.0",
"resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz",
@@ -4931,6 +4904,7 @@
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=12"
},
@@ -5009,6 +4983,7 @@
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
+ "peer": true,
"dependencies": {
"@prisma/engines": "5.22.0"
},
@@ -5156,6 +5131,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
"integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -5165,6 +5141,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz",
"integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -5177,6 +5154,7 @@
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.77.0.tgz",
"integrity": "sha512-Sslh9YDYc0GDlWT/lxasnIduNo4v3yyvqRGvmGKUre5AFjDs/HV9/OafHGD8d+sB2yoL4UIL9L8X9i0WlZZebg==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=18.0.0"
},
@@ -5200,6 +5178,7 @@
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz",
"integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
@@ -5280,6 +5259,7 @@
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.16.0.tgz",
"integrity": "sha512-wArC8lVyJb3+jM9OpDyW6hLCizACWkvQR/sSGqSs+o5uEXEtGlqdZ4v8hENR3Jad6i+LRkK93q/+bQAcvl6V1A==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0"
@@ -5394,7 +5374,8 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/redux-thunk": {
"version": "3.1.0",
@@ -5786,6 +5767,7 @@
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"devOptional": true,
"license": "Apache-2.0",
+ "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -5967,6 +5949,7 @@
"integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.4",
@@ -6128,6 +6111,7 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"license": "MIT",
+ "peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
diff --git a/prisma/migrations/20260708160733_add_rate_limit_model/migration.sql b/prisma/migrations/20260708160733_add_rate_limit_model/migration.sql
new file mode 100644
index 0000000..f035e4f
--- /dev/null
+++ b/prisma/migrations/20260708160733_add_rate_limit_model/migration.sql
@@ -0,0 +1,65 @@
+-- CreateTable
+CREATE TABLE "Invite" (
+ "id" TEXT NOT NULL,
+ "teamId" TEXT NOT NULL,
+ "email" TEXT NOT NULL,
+ "role" TEXT NOT NULL,
+ "token" TEXT NOT NULL,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "expiresAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "Invite_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "Integration" (
+ "id" TEXT NOT NULL,
+ "userId" TEXT NOT NULL,
+ "teamId" TEXT,
+ "platform" TEXT NOT NULL,
+ "accessToken" TEXT NOT NULL,
+ "refreshToken" TEXT,
+ "expiresAt" TIMESTAMP(3),
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "Integration_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "RateLimit" (
+ "ip" TEXT NOT NULL,
+ "count" INTEGER NOT NULL DEFAULT 1,
+ "resetAt" TIMESTAMP(3) NOT NULL,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "RateLimit_pkey" PRIMARY KEY ("ip")
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "Invite_token_key" ON "Invite"("token");
+
+-- CreateIndex
+CREATE INDEX "Invite_teamId_idx" ON "Invite"("teamId");
+
+-- CreateIndex
+CREATE INDEX "Invite_token_idx" ON "Invite"("token");
+
+-- CreateIndex
+CREATE INDEX "Integration_userId_idx" ON "Integration"("userId");
+
+-- CreateIndex
+CREATE INDEX "Integration_teamId_idx" ON "Integration"("teamId");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "Integration_userId_platform_key" ON "Integration"("userId", "platform");
+
+-- AddForeignKey
+ALTER TABLE "Invite" ADD CONSTRAINT "Invite_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "Integration" ADD CONSTRAINT "Integration_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "Integration" ADD CONSTRAINT "Integration_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 1c71dde..030f6cd 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -127,12 +127,12 @@ model User {
}
model Team {
- id String @id @default(uuid())
- name String
- ownerId String @unique
- createdAt DateTime @default(now())
- members User[]
- invites Invite[]
+ id String @id @default(uuid())
+ name String
+ ownerId String @unique
+ createdAt DateTime @default(now())
+ members User[]
+ invites Invite[]
integrations Integration[]
}
@@ -279,17 +279,25 @@ model Integration {
id String @id @default(uuid())
userId String
teamId String?
- platform String // e.g., "SHOPIFY", "EBAY"
- accessToken String // Encrypted access token or API key
- refreshToken String? // Encrypted refresh token (optional)
+ platform String // e.g., "SHOPIFY", "EBAY"
+ accessToken String // Encrypted access token or API key
+ refreshToken String? // Encrypted refresh token (optional)
expiresAt DateTime? // Expiration date/time (optional)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
- user User @relation(fields: [userId], references: [id], onDelete: Cascade)
- team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade)
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+ team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade)
@@unique([userId, platform])
@@index([userId])
@@index([teamId])
}
+
+model RateLimit {
+ ip String @id
+ count Int @default(1)
+ resetAt DateTime
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+}