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
9 changes: 5 additions & 4 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ NEXT_PUBLIC_VAPI_WORKFLOW_ID=
# Leave empty to keep that route disabled.
VAPI_WEBHOOK_SECRET=

# ── Rate limiting — Upstash Redis (free: 500k commands/mo, no card). Required in production;
# dev/CI use an in-memory limiter when unset.
UPSTASH_REDIS_REST_URL=
UPSTASH_REDIS_REST_TOKEN=

# ── Local emulators (optional; `pnpm dev:emulators` sets these for you) ────────
# When set, firebase-admin needs no service account and the browser Auth SDK targets the emulator.
FIRESTORE_EMULATOR_HOST=
Expand All @@ -45,10 +50,6 @@ SKIP_ENV_VALIDATION=
# ADDED IN LATER PHASES (documented ahead; not yet read by config/env.ts)
# ══════════════════════════════════════════════════════════════════════════════

# P1.5 Rate limiting / quotas — Upstash Redis free tier
UPSTASH_REDIS_REST_URL=
UPSTASH_REDIS_REST_TOKEN=

# P1.6 Observability — Sentry developer tier, PostHog free tier
SENTRY_DSN=
NEXT_PUBLIC_SENTRY_DSN=
Expand Down
2 changes: 2 additions & 0 deletions app/api/vapi/generate/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ vi.mock("@/firebase/admin", () => ({
collection: () => ({ doc: () => ({ get: () => Promise.resolve({ exists: false }) }) }),
}),
}));
vi.mock("@/server/ratelimit/ratelimit", () => ({ enforceLimit: () => Promise.resolve() }));
vi.mock("@/server/ratelimit/keys", () => ({ uidKey: (uid: string) => `uid:${uid}` }));
vi.mock("@ai-sdk/google", () => ({ createGoogle: () => () => ({}) }));
vi.mock("ai", () => ({ generateText: vi.fn() }));

Expand Down
7 changes: 7 additions & 0 deletions app/api/vapi/generate/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@ import { z } from "zod";
import { env } from "@/config/env";
import { GEMINI_FLASH_MODEL } from "@/config/llm";

import { isAppError } from "@/server/errors";
import { opLogger } from "@/server/observability/logger";
import { uidKey } from "@/server/ratelimit/keys";
import { enforceLimit } from "@/server/ratelimit/ratelimit";

import { getDb } from "@/firebase/admin";

Expand Down Expand Up @@ -57,6 +60,7 @@ export async function POST(request: Request) {
}

try {
await enforceLimit("interview.create", uidKey(userid));
const { text } = await generateText({
model: google(GEMINI_FLASH_MODEL),
prompt: `Prepare questions for a job interview.
Expand Down Expand Up @@ -88,6 +92,9 @@ export async function POST(request: Request) {

return Response.json({ success: true }, { status: 200 });
} catch (error) {
if (isAppError(error) && error.code === "RATE_LIMITED") {
return Response.json({ success: false, message: error.message }, { status: 429 });
}
log.error({ err: error }, "interview generation failed");
return Response.json({ success: false }, { status: 500 });
}
Expand Down
21 changes: 21 additions & 0 deletions config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ export const env = createEnv({
// Vapi workflow webhook shared secret (legacy; removed in P4.6). Route is disabled when unset.
VAPI_WEBHOOK_SECRET: z.string().min(16).optional(),

// Upstash Redis (rate limiting, later quotas). Required in production (cross-field check below);
// dev/CI fall back to an in-memory limiter when unset.
UPSTASH_REDIS_REST_URL: z.url().optional(),
UPSTASH_REDIS_REST_TOKEN: nonEmpty.optional(),

// Emulators (dev/CI). The Admin SDK honours these host variables natively.
FIRESTORE_EMULATOR_HOST: nonEmpty.optional(),
FIREBASE_AUTH_EMULATOR_HOST: nonEmpty.optional(),
Expand Down Expand Up @@ -59,6 +64,8 @@ export const env = createEnv({
FIREBASE_PRIVATE_KEY: process.env.FIREBASE_PRIVATE_KEY,
GEMINI_API_KEY: process.env.GEMINI_API_KEY,
VAPI_WEBHOOK_SECRET: process.env.VAPI_WEBHOOK_SECRET,
UPSTASH_REDIS_REST_URL: process.env.UPSTASH_REDIS_REST_URL,
UPSTASH_REDIS_REST_TOKEN: process.env.UPSTASH_REDIS_REST_TOKEN,
FIRESTORE_EMULATOR_HOST: process.env.FIRESTORE_EMULATOR_HOST,
FIREBASE_AUTH_EMULATOR_HOST: process.env.FIREBASE_AUTH_EMULATOR_HOST,
NEXT_PUBLIC_FIREBASE_AUTH_EMULATOR_HOST: process.env.NEXT_PUBLIC_FIREBASE_AUTH_EMULATOR_HOST,
Expand Down Expand Up @@ -92,3 +99,17 @@ if (
"FIREBASE_CLIENT_EMAIL and FIREBASE_PRIVATE_KEY are required unless FIRESTORE_EMULATOR_HOST / FIREBASE_AUTH_EMULATOR_HOST are set.",
);
}

// Production must rate-limit across instances: the in-memory fallback is per-process only.
// A production build running against the emulators (CI e2e) is not a deployment and may use it.
if (
typeof window === "undefined" &&
process.env.SKIP_ENV_VALIDATION !== "1" &&
env.NODE_ENV === "production" &&
!usesFirebaseEmulators() &&
(!env.UPSTASH_REDIS_REST_URL || !env.UPSTASH_REDIS_REST_TOKEN)
) {
throw new Error(
"UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN are required in production.",
);
}
27 changes: 27 additions & 0 deletions config/limits.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// Rate limits and quotas (docs/SECURITY.md §2.5, GUARDRAILS A3/B7). Every value here is the
// authoritative number; docs quote this file.
//
// Upstash Redis free tier (verified on the pricing page, 2026-09-20 — RISKS R3):
// 500,000 commands / month · 256 MB · 10 GB bandwidth · no credit card.
// A sliding-window check costs ~2-3 commands, so only expensive or abusable operations are
// limited — never plain page views.

export type LimitName =
"auth.signIn" | "auth.signUp" | "interview.create" | "feedback.create" | "profile.update";

export interface LimitRule {
/** Maximum requests allowed within `window`. */
requests: number;
/** Sliding window, e.g. "10 m", "1 h", "1 d". */
window: `${number} ${"s" | "m" | "h" | "d"}`;
/** What identifies the caller. */
keyBy: "ip" | "uid";
}

export const LIMITS: Record<LimitName, LimitRule> = {
"auth.signIn": { requests: 10, window: "10 m", keyBy: "ip" },
"auth.signUp": { requests: 10, window: "10 m", keyBy: "ip" },
"interview.create": { requests: 5, window: "1 h", keyBy: "uid" },
"feedback.create": { requests: 10, window: "1 d", keyBy: "uid" },
"profile.update": { requests: 20, window: "1 h", keyBy: "uid" },
};
2 changes: 1 addition & 1 deletion docs/RISKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ Severity: **S1** = security or data loss · **S2** = outage / quota burn / broke
|---|---|---|---|---|---|
| R1 | S1 | **Session cookie and decoded claims are logged to the server console in production today** (`lib/actions/auth.action.ts` `getCurrentUser`). Anyone with log access holds live sessions. | Now → closed by P1.1 | P1.1 is the first security PR; do not let P0 drag on. After P1.1 deploys, revoke all refresh tokens once (`auth.revokeRefreshTokens` for all users via script) so any leaked cookies die; note it in the release. | Log removed (PR 0.1); sign-out revokes tokens (PR 1.1). **Remaining step:** run `scripts/revoke-all-sessions.ts --confirm` once against production after the Phase 1 release (added in PR 1.4) |
| R2 | S1 | `POST /api/vapi/generate` and `createFeedback` are unauthenticated in production; anyone can burn Gemini quota and write to other users' data. | Now → closed by P1.4 | If abuse is observed before P1.4, deploy a one-line hotfix returning 404 from the route. In P1.4 add auth + zod + owner check with tests for both failure cases. | Closed (PR 1.4): daily-login/profile/signout routes deleted (server actions with session + zod); `createFeedback` derives the user from the session and validates the transcript; `/api/vapi/generate` requires the `x-vapi-secret` shared secret and is disabled without it |
| R3 | S2 | Free-tier limits in `docs/TECH_STACK.md` are **not verified**; vendors change them. Building quota logic on wrong numbers causes surprise outages. | P1.5 (Upstash), P2.3 (Gemini, Groq), P2.6 (R2), P5.1 (Gemini Live) | At sign-up for each vendor, record the actual limit in `config/limits.ts` **and** fix the table in `TECH_STACK.md` in the same PR. Never hard-code a limit from memory. | Open |
| R3 | S2 | Free-tier limits in `docs/TECH_STACK.md` are **not verified**; vendors change them. Building quota logic on wrong numbers causes surprise outages. | P1.5 (Upstash), P2.3 (Gemini, Groq), P2.6 (R2), P5.1 (Gemini Live) | At sign-up for each vendor, record the actual limit in `config/limits.ts` **and** fix the table in `TECH_STACK.md` in the same PR. Never hard-code a limit from memory. | In progress: Upstash verified 2026-09-20 (PR 1.5) and recorded in `config/limits.ts`; Gemini/Groq (P2.3), R2/Supabase (P2.6), Gemini Live (P5.1) still to verify at sign-up |
| R4 | S2 | Cloudflare R2 may ask for a card at account creation. ADR-0004 is *Proposed* for this reason. | P2.6, before writing the adapter | Check at sign-up. If a card is required, implement `server/storage/supabase.ts` behind the same `ObjectStorage` interface and flip ADR-0004 to Supabase. Do not write both adapters speculatively. | Open |
| R5 | S2 | Firebase Storage requires the Blaze plan (card) for this project. | Any time | Never use Firebase Storage; `storage.rules` stays absent. Grep for `getStorage` in CI (`knip`/lint rule). | Standing |
| R6 | S2 | `.github/workflows/ci.yml` and `lefthook.yml` **will fail on the legacy codebase** until P0 lands (no pnpm, no scripts, no tests). | P0.1–P0.5 | Expected red. Do not weaken either file to pass. Enable branch protection on `main` only after P0.5 is green. | Closed (PR 0.5): CI fully green (lint, typecheck, knip, gitleaks, unit+coverage, emulator integration, build, audit, size-limit, e2e); main/dev protection active since PR 0.1 |
Expand Down
12 changes: 5 additions & 7 deletions docs/SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,14 +41,12 @@ Threat model, controls, and the checklist every PR touching the server must sati
- Model output shown to users is rendered as text (no HTML/Markdown-to-HTML without sanitising).

### 2.5 Rate limiting and quotas
- `server/ratelimit` (Upstash, sliding window). Keys: `ip` for anonymous, `uid` for authenticated. Defaults in `config/limits.ts`:
- sign-in / sign-up / reset: 10 per 10 min per IP
- `server/ratelimit` (Upstash sliding window in production; in-memory fallback in dev/CI — production refuses to boot without Upstash). Keys: hashed `ip` for anonymous, `uid` for authenticated. Authoritative values live in `config/limits.ts`; current:
- sign-in / sign-up: 10 per 10 min per IP (reset: P3.4)
- interview create: 5 per hour per user
- attempt start: 10 per hour per user
- feedback generate: 1 per attempt (idempotent), 10 per day per user
- upload: 5 per hour per user
- assistant chat: 30 per 10 min per user
- `/api/voice/session`: 5 per 10 min per user + global concurrency cap
- feedback generate: 10 per day per user (1 per attempt once attempts land in P2.5)
- profile update: 20 per hour per user
- later: attempt start 10/h, upload 5/h, assistant chat 30/10 min, `/api/voice/session` 5/10 min + global concurrency cap
- Quotas (`server/services/quota.service.ts`) decrement `usage/{uid}/months/{m}` transactionally and refuse with `AppError('QUOTA_EXCEEDED')` and a UI that explains the reset time.

### 2.6 Secrets and configuration
Expand Down
2 changes: 1 addition & 1 deletion docs/TECH_STACK.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Every paid component of the original build is replaced here with a free alternat
| Auth | Firebase Auth email/password | **Keep Firebase Auth** + Google OAuth provider (free). | Free, battle-tested; add OAuth in M4. |
| File storage (photos, resumes, audio) | Firebase Storage (requires Blaze/card for new projects) | **Cloudflare R2** (10 GB free, S3-compatible) or **Supabase Storage** (1 GB free). Behind `ObjectStorage` interface. | Avoids card requirement. ADR-0004. |
| Hosting | Vercel | **Vercel Hobby** (free, non-commercial). | Best Next.js DX; free. |
| Rate limiting | none | **Upstash Redis** free tier + `@upstash/ratelimit`. | Serverless-safe; free tier ample for launch. |
| Rate limiting | none | **Upstash Redis** free tier + `@upstash/ratelimit`. Verified 2026-09-20: 500,000 commands/month, 256 MB, 10 GB bandwidth, no card. | Serverless-safe; a sliding-window check costs ~2–3 commands, so only expensive/abusable operations are limited (`config/limits.ts`). |
| Error monitoring | none | **Sentry** developer (free) tier. | Free for solo projects. |
| Product analytics + feature flags | none | **PostHog** free tier (events + flags + session replay quota). | One vendor covers three needs. |
| Logs | `console.log` | `pino` → Vercel runtime logs (free) ; optional **Axiom** free tier for retention. | Structured, redactable. |
Expand Down
7 changes: 7 additions & 0 deletions features/auth/actions.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
"use server";

import { createSession, destroySession } from "@/server/auth/session";
import { isAppError } from "@/server/errors";
import { opLogger } from "@/server/observability/logger";
import { clientIpKey } from "@/server/ratelimit/keys";
import { enforceLimit } from "@/server/ratelimit/ratelimit";
import { recordDailyLogin } from "@/server/services/streak.service";

import { getAdminAuth, getDb } from "@/firebase/admin";
Expand All @@ -16,6 +19,7 @@ export async function signUp(input: unknown): Promise<ActionResult> {
if (!parsed.success) return { success: false, message: "Invalid sign-up details." };

try {
await enforceLimit("auth.signUp", await clientIpKey());
const claims = await getAdminAuth().verifyIdToken(parsed.data.idToken);
const users = getDb().collection("users").doc(claims.uid);
if ((await users.get()).exists) {
Expand All @@ -24,6 +28,7 @@ export async function signUp(input: unknown): Promise<ActionResult> {
await users.set({ name: parsed.data.name, email: claims.email ?? "" });
return { success: true, message: "Account created successfully. Please sign in." };
} catch (error) {
if (isAppError(error)) return { success: false, message: error.message };
opLogger("auth.signUp").warn({ err: error }, "sign-up failed");
return { success: false, message: "Failed to create account. Please try again." };
}
Expand All @@ -34,11 +39,13 @@ export async function signIn(input: unknown): Promise<ActionResult> {
if (!parsed.success) return { success: false, message: "Invalid sign-in details." };

try {
await enforceLimit("auth.signIn", await clientIpKey());
const claims = await getAdminAuth().verifyIdToken(parsed.data.idToken);
await createSession(parsed.data.idToken);
await recordDailyLogin(claims.uid);
return { success: true, message: "Signed in." };
} catch (error) {
if (isAppError(error)) return { success: false, message: error.message };
opLogger("auth.signIn").warn({ err: error }, "sign-in failed");
return { success: false, message: "Failed to log into account. Please try again." };
}
Expand Down
15 changes: 15 additions & 0 deletions features/feedback/actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ vi.mock("@/server/services/feedback.service", () => ({
vi.mock("@/server/observability/logger", () => ({
opLogger: () => ({ error: vi.fn(), warn: vi.fn(), info: vi.fn() }),
}));
const enforceLimit = vi.fn<() => Promise<void>>(() => Promise.resolve());
vi.mock("@/server/ratelimit/ratelimit", () => ({ enforceLimit: () => enforceLimit() }));
vi.mock("@/server/ratelimit/keys", () => ({ uidKey: (uid: string) => `uid:${uid}` }));

const { createFeedback } = await import("./actions");

Expand All @@ -23,6 +26,8 @@ const validInput = {
beforeEach(() => {
requireUser.mockReset();
generateAndStoreFeedback.mockReset();
enforceLimit.mockReset();
enforceLimit.mockResolvedValue(undefined);
});

describe("createFeedback (SECURITY §3 checklist)", () => {
Expand Down Expand Up @@ -69,6 +74,16 @@ describe("createFeedback (SECURITY §3 checklist)", () => {
});
});

it("returns the rate-limit message and skips generation when over the limit", async () => {
requireUser.mockResolvedValue({ id: "u" });
enforceLimit.mockRejectedValue(
new AppError("RATE_LIMITED", "Too many requests. Try again in 2 min."),
);
const result = await createFeedback(validInput);
expect(result).toEqual({ success: false, message: "Too many requests. Try again in 2 min." });
expect(generateAndStoreFeedback).not.toHaveBeenCalled();
});

it("never leaks internal errors", async () => {
requireUser.mockResolvedValue({ id: "u" });
generateAndStoreFeedback.mockRejectedValue(new Error("gemini: quota exceeded for key AIza-x"));
Expand Down
3 changes: 3 additions & 0 deletions features/feedback/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
import { requireUser } from "@/server/auth/session";
import { isAppError } from "@/server/errors";
import { opLogger } from "@/server/observability/logger";
import { uidKey } from "@/server/ratelimit/keys";
import { enforceLimit } from "@/server/ratelimit/ratelimit";
import { generateAndStoreFeedback } from "@/server/services/feedback.service";

import { CreateFeedbackSchema } from "./schema";
Expand All @@ -15,6 +17,7 @@ export async function createFeedback(input: unknown): Promise<CreateFeedbackResu
const user = await requireUser();
const parsed = CreateFeedbackSchema.safeParse(input);
if (!parsed.success) return { success: false, message: "Invalid transcript." };
await enforceLimit("feedback.create", uidKey(user.id));

const { feedbackId } = await generateAndStoreFeedback({
interviewId: parsed.data.interviewId,
Expand Down
13 changes: 13 additions & 0 deletions features/profile/actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,17 @@ vi.mock("@/firebase/admin", () => ({
vi.mock("@/server/observability/logger", () => ({
opLogger: () => ({ error: vi.fn(), warn: vi.fn(), info: vi.fn() }),
}));
const enforceLimit = vi.fn<() => Promise<void>>(() => Promise.resolve());
vi.mock("@/server/ratelimit/ratelimit", () => ({ enforceLimit: () => enforceLimit() }));
vi.mock("@/server/ratelimit/keys", () => ({ uidKey: (uid: string) => `uid:${uid}` }));

const { updateProfile } = await import("./actions");

beforeEach(() => {
requireUserChecked.mockReset();
update.mockReset();
enforceLimit.mockReset();
enforceLimit.mockResolvedValue(undefined);
});

describe("updateProfile", () => {
Expand All @@ -43,6 +48,14 @@ describe("updateProfile", () => {
});
});

it("does not write when the caller is rate limited", async () => {
requireUserChecked.mockResolvedValue({ id: "me" });
enforceLimit.mockRejectedValue(new AppError("RATE_LIMITED"));
const result = await updateProfile({ description: "x", photoURL: "/profile.svg" });
expect(result.success).toBe(false);
expect(update).not.toHaveBeenCalled();
});

it("rejects oversized descriptions and unsupported images", async () => {
requireUserChecked.mockResolvedValue({ id: "me" });
const tooLong = await updateProfile({ description: "x".repeat(501), photoURL: "/profile.svg" });
Expand Down
3 changes: 3 additions & 0 deletions features/profile/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
import { requireUserChecked } from "@/server/auth/session";
import { isAppError } from "@/server/errors";
import { opLogger } from "@/server/observability/logger";
import { uidKey } from "@/server/ratelimit/keys";
import { enforceLimit } from "@/server/ratelimit/ratelimit";

import { getDb } from "@/firebase/admin";

Expand All @@ -18,6 +20,7 @@ export async function updateProfile(input: unknown): Promise<ActionResult> {
if (!parsed.success) {
return { success: false, message: parsed.error.issues[0]?.message ?? "Invalid profile." };
}
await enforceLimit("profile.update", uidKey(user.id));
await getDb().collection("users").doc(user.id).update(parsed.data);
return { success: true, message: "Profile updated." };
} catch (error) {
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@
"@hookform/resolvers": "^5.9.1",
"@radix-ui/react-slot": "^1.2.3",
"@t3-oss/env-nextjs": "^0.13.11",
"@upstash/ratelimit": "^2.1.0",
"@upstash/redis": "^1.38.4",
"@vapi-ai/web": "^2.3.5",
"ai": "^7.0.102",
"class-variance-authority": "^0.7.1",
Expand Down
Loading
Loading