diff --git a/.github/workflows/frontend-cd.yml b/.github/workflows/frontend-cd.yml
index f4bcd88..5a230f8 100644
--- a/.github/workflows/frontend-cd.yml
+++ b/.github/workflows/frontend-cd.yml
@@ -48,6 +48,7 @@ jobs:
VITE_CLERK_PUBLISHABLE_KEY: ${{ secrets.VITE_CLERK_PUBLISHABLE_KEY }}
CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }}
VITE_FASTAPI_URL: ${{ steps.env.outputs.fastapi_url }}
+ ARCJET_KEY: ${{ secrets.ARCJET_KEY }}
- name: Deploy to Cloudflare Workers
run: cd apps/web && npx wrangler deploy --name ${{ steps.env.outputs.worker_name }}
diff --git a/apps/web/.env.example b/apps/web/.env.example
index 4556958..62028eb 100644
--- a/apps/web/.env.example
+++ b/apps/web/.env.example
@@ -9,3 +9,6 @@ VITE_CONVEX_URL=https://your-project.convex.cloud
# FastAPI backend URL
VITE_FASTAPI_URL=http://localhost:8000
+
+# Arcjet (server-only rate limiting / shield — copy from Arcjet dashboard)
+ARCJET_KEY=
diff --git a/apps/web/package.json b/apps/web/package.json
index 53f4e51..3fc9a8d 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -17,6 +17,10 @@
"check-types": "tsc --noEmit"
},
"dependencies": {
+ "@arcjet/headers": "^1.3.1",
+ "@arcjet/ip": "^1.3.1",
+ "@arcjet/protocol": "^1.3.1",
+ "@arcjet/transport": "^1.3.1",
"@clerk/tanstack-react-start": "^0.29.1",
"@convex-dev/react-query": "^0.1.0",
"@harness/convex-backend": "workspace:*",
@@ -33,6 +37,7 @@
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
+ "arcjet": "^1.3.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"convex": "^1.31.7",
@@ -51,6 +56,7 @@
"tailwindcss": "^4.1.18",
"tw-animate-css": "^1.3.6",
"use-sync-external-store": "^1.6.0",
+ "vinxi": "^0.5.11",
"vite-tsconfig-paths": "^6.0.2",
"zod": "^4.1.11"
},
diff --git a/apps/web/src/components/usage-dialog.tsx b/apps/web/src/components/usage-dialog.tsx
new file mode 100644
index 0000000..f8ee515
--- /dev/null
+++ b/apps/web/src/components/usage-dialog.tsx
@@ -0,0 +1,26 @@
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { UsageDisplay } from "./usage-display";
+
+export function UsageDialog({
+ open,
+ onOpenChange,
+}: {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+}) {
+ return (
+
+ );
+}
diff --git a/apps/web/src/components/usage-display.tsx b/apps/web/src/components/usage-display.tsx
new file mode 100644
index 0000000..ad72f36
--- /dev/null
+++ b/apps/web/src/components/usage-display.tsx
@@ -0,0 +1,227 @@
+import { convexQuery } from "@convex-dev/react-query";
+import { api } from "@harness/convex-backend/convex/_generated/api";
+import { useQuery } from "@tanstack/react-query";
+import { cn } from "@/lib/utils";
+
+function ProgressBar({
+ pct,
+ label,
+ sublabel,
+}: {
+ pct: number;
+ label: string;
+ sublabel?: string;
+}) {
+ const color =
+ pct >= 90 ? "bg-red-500" : pct >= 70 ? "bg-yellow-500" : "bg-emerald-500";
+
+ return (
+
+
+ {label}
+ = 90
+ ? "text-red-400"
+ : pct >= 70
+ ? "text-yellow-400"
+ : "text-foreground/60",
+ )}
+ >
+ {Math.round(pct)}%
+
+
+
+ {sublabel &&
{sublabel}
}
+
+ );
+}
+
+function ModelBreakdown({
+ items,
+}: {
+ items: Array<{ model: string; pct: number; tokensUsed: number }>;
+}) {
+ if (items.length === 0) return null;
+
+ const sorted = [...items].sort((a, b) => b.pct - a.pct);
+
+ return (
+
+
+ By Model
+
+
+ {sorted.map((item) => (
+
+
+ {item.model}
+
+
+
+ {Math.round(item.pct)}%
+
+
+ ))}
+
+
+ );
+}
+
+function HarnessBreakdown({
+ items,
+}: {
+ items: Array<{
+ harnessId: string;
+ harnessName: string;
+ pct: number;
+ tokensUsed: number;
+ }>;
+}) {
+ if (items.length === 0) return null;
+
+ const sorted = [...items].sort((a, b) => b.pct - a.pct);
+
+ return (
+
+
+ By Harness
+
+
+ {sorted.map((item) => (
+
+
+ {item.harnessName}
+
+
+
+ {Math.round(item.pct)}%
+
+
+ ))}
+
+
+ );
+}
+
+export function formatResetTime(isoString: string): string {
+ const reset = new Date(isoString);
+ const now = new Date();
+ const diffMs = reset.getTime() - now.getTime();
+ if (diffMs <= 0) return "now";
+
+ const hours = Math.floor(diffMs / (1000 * 60 * 60));
+ const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
+
+ if (hours > 24) {
+ const days = Math.floor(hours / 24);
+ return `${days}d ${hours % 24}h`;
+ }
+ if (hours > 0) return `${hours}h ${minutes}m`;
+ return `${minutes}m`;
+}
+
+export function UsageDisplay() {
+ const { data: usage, error } = useQuery(
+ convexQuery(api.usage.getUserUsage, {}),
+ );
+
+ if (error) {
+ return (
+
+ Could not load usage.
+
+ );
+ }
+
+ if (!usage) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ {(usage.dailyLimitReached || usage.weeklyLimitReached) && (
+
+ {usage.dailyLimitReached
+ ? "Daily usage limit reached."
+ : "Weekly usage limit reached."}{" "}
+ Your limit will reset soon.
+
+ )}
+
+
+
+
+ );
+}
+
+/**
+ * Compact usage badge for the sidebar/header.
+ * Shows the higher of daily/weekly percentage with a color indicator.
+ */
+export function UsageBadge({ onClick }: { onClick?: () => void }) {
+ const { data: usage } = useQuery(convexQuery(api.usage.getUserUsage, {}));
+
+ if (!usage) return null;
+
+ const pct = Math.max(usage.dailyPctUsed, usage.weeklyPctUsed);
+ const color =
+ pct >= 90
+ ? "text-red-400"
+ : pct >= 70
+ ? "text-yellow-400"
+ : "text-foreground/50";
+ const dotColor =
+ pct >= 90 ? "bg-red-400" : pct >= 70 ? "bg-yellow-400" : "bg-emerald-400";
+
+ return (
+
+ );
+}
diff --git a/apps/web/src/env.ts b/apps/web/src/env.ts
index 1c8b986..c035417 100644
--- a/apps/web/src/env.ts
+++ b/apps/web/src/env.ts
@@ -4,6 +4,7 @@ import { z } from "zod";
export const env = createEnv({
server: {
SERVER_URL: z.string().url().optional(),
+ ARCJET_KEY: z.string().min(1),
},
/**
@@ -23,7 +24,11 @@ export const env = createEnv({
* What object holds the environment variables at runtime. This is usually
* `process.env` or `import.meta.env`.
*/
- runtimeEnv: import.meta.env,
+ runtimeEnv: {
+ ...import.meta.env,
+ SERVER_URL: process.env.SERVER_URL,
+ ARCJET_KEY: process.env.ARCJET_KEY,
+ },
/**
* By default, this library will feed the environment variables directly to
diff --git a/apps/web/src/lib/arcjet.ts b/apps/web/src/lib/arcjet.ts
new file mode 100644
index 0000000..7284925
--- /dev/null
+++ b/apps/web/src/lib/arcjet.ts
@@ -0,0 +1,38 @@
+import { createClient } from "@arcjet/protocol/client.js";
+import { createTransport } from "@arcjet/transport";
+import arcjetCore, { shield, tokenBucket } from "arcjet";
+
+import { env } from "@/env";
+
+const BASE_URL = "https://decide.arcjet.com";
+
+const client = createClient({
+ baseUrl: BASE_URL,
+ // biome-ignore lint/suspicious/noExplicitAny: Arcjet SDK doesn't export the sdkStack enum type
+ sdkStack: "NODEJS" as any,
+ // Keep in sync with arcjet version in package.json
+ sdkVersion: "1.3.1",
+ timeout: 500,
+ transport: createTransport(BASE_URL),
+});
+
+export const aj = arcjetCore({
+ client,
+ key: env.ARCJET_KEY,
+ log: {
+ debug: () => {},
+ info: () => {},
+ warn: () => {},
+ error: console.error,
+ },
+ rules: [
+ tokenBucket({
+ mode: "LIVE",
+ characteristics: ["userId"],
+ refillRate: 20,
+ interval: "1m",
+ capacity: 30,
+ }),
+ shield({ mode: "LIVE" }),
+ ],
+});
diff --git a/apps/web/src/lib/chat-ratelimit.ts b/apps/web/src/lib/chat-ratelimit.ts
new file mode 100644
index 0000000..f287a6b
--- /dev/null
+++ b/apps/web/src/lib/chat-ratelimit.ts
@@ -0,0 +1,63 @@
+import { ArcjetHeaders } from "@arcjet/headers";
+import { createServerFn } from "@tanstack/react-start";
+import {
+ getRequestHeaders,
+ getRequestHost,
+ getRequestProtocol,
+ getWebRequest,
+} from "vinxi/http";
+import { aj } from "./arcjet";
+
+export interface RateLimitResult {
+ allowed: boolean;
+ retryAfter?: number;
+}
+
+/**
+ * Pre-flight rate limit check via Arcjet.
+ * Called before the frontend sends a chat stream request to FastAPI.
+ * Checks per-user request rate (not token budget — that's in Convex/FastAPI).
+ */
+export const checkChatRateLimit = createServerFn({ method: "POST" })
+ .inputValidator((data: { userId: string }) => data)
+ .handler(async ({ data }): Promise => {
+ const headers = getRequestHeaders();
+ const host = getRequestHost();
+ const protocol = getRequestProtocol();
+ const request = getWebRequest();
+ const url = new URL(request.url);
+
+ const ip =
+ (headers["x-forwarded-for"] as string)?.split(",")[0]?.trim() ||
+ (headers["cf-connecting-ip"] as string) ||
+ "127.0.0.1";
+
+ const decision = await aj.protect(
+ { getBody: async () => "" },
+ {
+ cookies: headers.cookie ?? "",
+ host,
+ headers: new ArcjetHeaders(headers),
+ ip,
+ method: "POST",
+ path: url.pathname,
+ protocol: `${protocol}:`,
+ query: url.search,
+ userId: data.userId,
+ requested: 1,
+ },
+ );
+
+ if (decision.isDenied()) {
+ let retryAfter: number | undefined;
+ for (const result of decision.results) {
+ if (result.reason.isRateLimit() && "reset" in result.reason) {
+ retryAfter = result.reason.reset as number;
+ break;
+ }
+ }
+ return { allowed: false, retryAfter };
+ }
+
+ return { allowed: true };
+ });
diff --git a/apps/web/src/lib/use-chat-stream.ts b/apps/web/src/lib/use-chat-stream.ts
index ece2b84..5145462 100644
--- a/apps/web/src/lib/use-chat-stream.ts
+++ b/apps/web/src/lib/use-chat-stream.ts
@@ -1,6 +1,7 @@
-import { useAuth } from "@clerk/tanstack-react-start";
+import { useAuth, useUser } from "@clerk/tanstack-react-start";
import { useCallback, useRef, useState } from "react";
import { env } from "../env";
+import { checkChatRateLimit } from "./chat-ratelimit";
const FASTAPI_URL = env.VITE_FASTAPI_URL ?? "http://localhost:8000";
@@ -37,6 +38,13 @@ export interface ConvoStreamState {
model: string | null;
}
+export interface BudgetExceededInfo {
+ dailyPct: number;
+ weeklyPct: number;
+ dailyReset: string;
+ weeklyReset: string;
+}
+
interface UseChatStreamCallbacks {
onToken: (conversationId: string, content: string) => void;
onThinking: (conversationId: string, content: string) => void;
@@ -60,6 +68,7 @@ interface UseChatStreamCallbacks {
event: { sandbox_id: string; status: string },
) => void;
onError: (conversationId: string, error: string) => void;
+ onBudgetExceeded?: (conversationId: string, info: BudgetExceededInfo) => void;
onAbort?: (conversationId: string) => void;
}
@@ -96,6 +105,7 @@ export function useChatStream(callbacks: UseChatStreamCallbacks) {
);
const abortControllers = useRef