Skip to content

Refactor to decouple architecture with FastAPI backend - #1

Open
PreetamMatta wants to merge 6 commits into
mainfrom
architectural-changes-11702363435153780656
Open

PreetamMatta wants to merge 6 commits into
mainfrom
architectural-changes-11702363435153780656

Conversation

@PreetamMatta

Copy link
Copy Markdown
Owner

This PR implements the requested architectural changes:

  • Decouples the Next.js frontend from the SQLite database.
  • Implements a new FastAPI backend written in Python, managed with uv.
  • Configures frontend Next.js endpoints to call FastAPI instead of querying the database directly.
  • Implements JWT cookie extraction to handle authentication seamlessly between Next.js and FastAPI.
  • Implements an Admin Role (ADMIN) and a dashboard to view users.
  • Updates Docker configuration for containerization.
  • Updates ADR documentation to reflect technical reasons behind storing money as integer cents.

PR created automatically by Jules for task 11702363435153780656 started by @PreetamMatta

1. Decoupled Next.js frontend from Prisma, replacing all direct database accesses with HTTP fetch calls to a new FastAPI backend.
2. Created FastAPI backend with `uv` for dependency management.
3. Implemented data models in `sqlmodel`.
4. Updated Docker configuration to include the FastAPI container.
5. Implemented a user administration dashboard `/admin/users`.
6. Documented decision to keep currency in cents due to precision issues in `docs/adr/003-money-as-cents.md`.

Co-authored-by: PreetamMatta <47058129+PreetamMatta@users.noreply.github.com>
@google-labs-jules

Copy link
Copy Markdown

👋 Jules, reporting for duty! I'm here to lend a hand with this pull request.

When you start a review, I'll add a 👀 emoji to each comment to let you know I've read it. I'll focus on feedback directed at me and will do my best to stay out of conversations between you and other bots or reviewers to keep the noise down.

I'll push a commit with your requested changes shortly after. Please note there might be a delay between these steps, but rest assured I'm on the job!

For more direct control, you can switch me to Reactive Mode. When this mode is on, I will only act on comments where you specifically mention me with @jules. You can find this option in the Pull Request section of your global Jules UI settings. You can always switch back!

New to Jules? Learn more at jules.google/docs.


For security, I will only act on instructions from the user who triggered this task.

google-labs-jules Bot and others added 2 commits April 7, 2026 14:31
- Base64url encode HKDF key for JWK token decryption in FastAPI auth
- Add allowlist for transaction sortBy in backend to prevent arbitrary attribute access
- Reimplement POST/PUT/DELETE API route proxies for Next.js to forward to backend
- Map account/category relationships in transactions client to avoid rendering errors
- Re-add Zod validation to the proxy register route
- Modify dashboard to lift the 1000 fetch limit and fetch categories in parallel
- Update AGENTS.md and ignore generated logs.

Co-authored-by: PreetamMatta <47058129+PreetamMatta@users.noreply.github.com>
…fety, add seed/docs

- Replace fragile JWE cookie forwarding with X-API-Key + X-User-Id header pattern
- Rewrite backend/auth.py: simple API key verification, no JWE/HKDF crypto
- Add src/lib/api.ts: fetchBackend() helper that injects auth headers server-side
- Fix src/lib/auth.ts: remove @ts-ignore, add X-API-Key header to verify call
- Fix src/types/next-auth.d.ts: extend Session and JWT with role field
- Restore Zod enum validation in accounts and categories API routes
- Split admin users page into server + client components (working delete button)
- Add DELETE /api/admin/users/[id] API route
- Remove jwcrypto, hkdf, python-jose from backend dependencies
- Add backend/seed.py: demo data seeder matching prisma/seed.ts
- Write backend/README.md with architecture and API docs
- Remove __pycache__ from git tracking, add Python patterns to .gitignore
- Make SQL_ECHO conditional via env var in database.py
- Use lifespan context manager instead of deprecated @app.on_event

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Comment thread backend/auth.py Outdated
from models import User
from database import get_session

INTERNAL_API_KEY = os.getenv("INTERNAL_API_KEY", "finpulse-internal-key")

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Security: Hardcoded default API key

The default value "finpulse-internal-key" is hardcoded here and also in src/lib/api.ts. If INTERNAL_API_KEY is not set, the backend accepts this well-known string as a valid authentication credential. This is a significant security risk — any caller who reads the source code can bypass authentication entirely.

Fix: Remove the default and fail fast on startup if the key is missing:

INTERNAL_API_KEY = os.environ["INTERNAL_API_KEY"]  # KeyError on missing = intentional

Or at minimum, log a prominent warning when the default is used.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Fixed. I've removed the default value for INTERNAL_API_KEY in backend/auth.py and src/lib/api.ts, making it raise a KeyError if it is not provided.

Comment thread backend/auth.py Outdated

async def get_admin_user(request: Request, current_user: User = Depends(get_current_user)) -> User:
role = request.headers.get("X-User-Role", "USER")
if role != "ADMIN" and current_user.role != "ADMIN":

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Security: Admin role check trusts X-User-Role header

The get_admin_user dependency grants admin access if X-User-Role: ADMIN is present in the request header — but the backend is exposed on port 8000 publicly (see docker-compose.yml line - "8000:8000"). An external attacker can simply include X-User-Role: ADMIN and X-API-Key: finpulse-internal-key (the known default) to gain full admin access.

Even when the backend port is not publicly exposed, relying on the header for role elevation is redundant and error-prone since get_current_user already loads the real user.role from the database. The condition role != "ADMIN" and current_user.role != "ADMIN" means that if the database role is ADMIN the check passes, but so does a forged header alone if reached directly.

Fix: Remove the header-based check entirely and rely solely on current_user.role:

async def get_admin_user(current_user: User = Depends(get_current_user)) -> User:
    if current_user.role != "ADMIN":
        raise HTTPException(status_code=403, detail="Not enough permissions")
    return current_user

Also ensure the backend port is not publicly exposed — remove ports: - "8000:8000" from docker-compose.yml so only the frontend container can reach it.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Fixed. I've removed the X-User-Role check and updated get_admin_user to rely solely on the database-loaded current_user.role.

Comment thread docker-compose.yml
container_name: finpulse-backend
volumes:
- ./backend:/app:cached
- finpulse-db:/data

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Security: Backend port 8000 is publicly exposed

Port 8000 is mapped to the host, meaning the FastAPI backend is reachable from outside Docker — not just from the frontend container. This bypasses the entire auth proxy pattern: anyone on the network can hit the backend directly, forge X-API-Key (using the known default) and X-User-Id/X-User-Role headers, and access or delete any user's data.

Fix: Remove the ports mapping from the backend service. Services in the same Docker Compose network can still communicate by service name without exposing the port to the host:

  backend:
    # ... no ports: section
    # frontend reaches it via http://backend:8000 internally

Also add INTERNAL_API_KEY to the backend environment section — it is currently missing, so the backend always falls back to the hardcoded default.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Fixed. I've removed the ports: - "8000:8000" mapping from docker-compose.yml to prevent public access. I also added INTERNAL_API_KEY to the environment blocks for both frontend and backend.

Comment thread docker-compose.yml
environment:
- DATABASE_URL=file:/data/finpulse.db
- BACKEND_URL=http://backend:8000
- AUTH_SECRET=finpulse-dev-secret-change-in-production

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Security: Hardcoded AUTH_SECRET in docker-compose.yml

AUTH_SECRET=finpulse-dev-secret-change-in-production is committed directly into the repo for both frontend and backend services. This secret signs Auth.js JWT tokens — anyone who knows it can forge valid session tokens.

This value must never be committed to source control. Use a .env file (already in .gitignore) or Docker secrets instead:

environment:
  - AUTH_SECRET=   # read from host env or .env file
  - INTERNAL_API_KEY=

Note: AUTH_SECRET on the backend service appears to be unused (the backend uses INTERNAL_API_KEY), so that line can also be removed to reduce confusion.

const user = await prisma.user.create({
data: {
const baseUrl = process.env.BACKEND_URL || "http://localhost:8000"
const res = await fetch(`${baseUrl}/api/register`, {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Security: Register endpoint missing API key — no rate limiting or validation on password strength

In src/app/api/register/route.ts the call to the backend is made without the X-API-Key header:

const res = await fetch(`${baseUrl}/api/register`, {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  // X-API-Key is NOT included here
  body: JSON.stringify(...),
})

This means the Next.js route would fail with a 401 from the backend (since verify_api_key_only is called in /api/register). More importantly, it should use fetchBackend() (which injects the key automatically) instead of a raw fetch.

Additionally, the Zod schema only enforces min(6) on password. Consider adding max() to prevent DoS via extremely long bcrypt inputs (bcrypt truncates at 72 bytes but still wastes CPU on very long strings).

Fix:

import { fetchBackend } from "@/lib/api"
// ...
const data = await fetchBackend("/api/register", {
  method: "POST",
  body: JSON.stringify(validated),
})

Comment thread src/app/(app)/dashboard/page.tsx Outdated
fetchBackend("/api/goals"),
fetchBackend("/api/budgets"),
fetchBackend("/api/categories"),
fetchBackend(`/api/transactions?startDate=${sixMonthsAgo.toISOString()}&endDate=${monthEnd.toISOString()}&limit=10000`),

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Performance: Dashboard fetches up to 10,000 transactions in a single request

fetchBackend(`/api/transactions?startDate=...&endDate=...&limit=10000`)

This loads potentially tens of thousands of transactions into memory on the server, serializes them all into JSON, sends them over the wire, and then filters them again client-side. This will become very slow as the user's transaction history grows and creates significant memory pressure.

Suggested approach: Move the aggregation logic to dedicated backend endpoints (e.g. /api/dashboard/summary or /api/transactions/summary?groupBy=month) that return pre-computed aggregates from SQL. The database is far more efficient at summing and grouping than repeated JS .filter()/.reduce() calls on a 10k-item array.

At minimum, add a hard cap to the limit parameter in the backend so users cannot request arbitrarily large result sets.

Comment thread src/app/(app)/dashboard/page.tsx Outdated
const sixMonthTx = allTx

for (const tx of sixMonthTx) {
const txMonth = new Date(tx.date).toLocaleString("default", { month: "short" })

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Bug: Dashboard monthly chart uses locale month name for grouping — breaks across years

const txMonth = new Date(tx.date).toLocaleString("default", { month: "short" })
const entry = monthlyData.find(m => m.month === txMonth)

This compares abbreviated month names (e.g. "Jan"). If the 6-month window spans a year boundary (e.g. Oct–Mar), transactions from Jan 2025 and Jan 2026 both match the same "Jan" entry, so they get double-counted.

Fix: Compare by year+month index instead of locale string:

for (let i = 5; i >= 0; i--) {
  const m = subMonths(now, i)
  monthlyData.push({
    month: m.toLocaleString("default", { month: "short" }),
    year: m.getFullYear(),
    monthIndex: m.getMonth(),
    income: 0,
    expenses: 0,
  })
}

for (const tx of sixMonthTx) {
  const d = new Date(tx.date)
  const entry = monthlyData.find(m => m.year === d.getFullYear() && m.monthIndex === d.getMonth())
  // ...
}

Comment thread backend/models.py
def generate_uuid():
return str(uuid.uuid4())

def get_utc_now():

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Missing updatedAt auto-update on model mutations

SQLModel does not have a built-in @updatedOn / onupdate hook like Prisma. The updatedAt field defaults to get_utc_now() at creation time, but is never updated when a record is mutated. For example, when update_account calls setattr(account, key, value), the updatedAt field stays as the original creation timestamp.

Fix: Either update the field manually on every write, or use SQLAlchemy's onupdate option:

from sqlalchemy import Column, DateTime
from sqlalchemy.sql import func

class Account(SQLModel, table=True):
    # ...
    updatedAt: datetime = Field(
        default_factory=get_utc_now,
        sa_column=Column(DateTime(timezone=True), default=func.now(), onupdate=func.now())
    )

This affects all models: User, Account, Transaction, Category, Budget, Goal, Subscription, RecurringTransaction, PayStub.

await fetchBackend(`/api/accounts/${p.id}`, { method: "DELETE" })
return NextResponse.json({ success: true })
} catch (error) {
return NextResponse.json(

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Missing error handling for backend errors in Next.js API routes

All DELETE route handlers (e.g. accounts/[id]/route.ts, transactions/[id]/route.ts) catch all errors and return a generic 500:

} catch (error) {
  return NextResponse.json({ error: "Internal server error" }, { status: 500 })
}

When the backend returns a 404 (account not found) or 403 (forbidden), fetchBackend() throws an Error with the backend's status code embedded in the message — but the Next.js route always responds 500 regardless, losing the actual HTTP semantics.

Fix: Parse the backend error status and forward it:

} catch (error) {
  if (error instanceof Error) {
    const match = error.message.match(/Backend (\d+):/)
    const status = match ? parseInt(match[1]) : 500
    return NextResponse.json({ error: error.message }, { status })
  }
  return NextResponse.json({ error: "Internal server error" }, { status: 500 })
}

Or extend fetchBackend() to throw a typed error with a status property.

}
}

export async function PUT(

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Missing auth check in PUT/DELETE routes — middleware gap

The GET /api/accounts route does not exist (the frontend fetches accounts directly via server components), but PUT and DELETE at /api/accounts/[id] have no explicit auth() check. They rely entirely on fetchBackend() which forwards the session's user ID. If the session is not present, fetchBackend() sends no X-User-Id header, and the backend correctly returns 401.

However, the Next.js middleware may not protect API routes under /api/ unless explicitly configured. It is best practice to add an explicit auth guard at the top of each mutation route for defense-in-depth:

export async function DELETE(request: Request, { params }: ...) {
  const session = await auth()
  if (!session?.user?.id) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
  }
  // ...
}

Check src/middleware.ts to confirm /api/ routes are protected, or add explicit checks here.

institution: z.string().optional(),
name: z.string().optional(),
type: z.string().optional(),
balance: z.number().optional(),

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Type safety: updateAccountSchema removes enum validation for type

The old schema validated account type with z.enum(["CHECKING", "SAVINGS", ...]). The new schema uses z.string().optional() — any value will pass Zod validation and be forwarded to the backend unchecked. The backend does validate it, but the Next.js layer loses its type safety and the error response becomes a 500 (via the catch-all) rather than a 400.

Use a union or enum for consistency:

const updateAccountSchema = z.object({
  name: z.string().min(1).optional(),
  type: z.enum(["CHECKING", "SAVINGS", "CREDIT_CARD", "INVESTMENT", "LOAN", "OTHER"]).optional(),
  balance: z.number().int().optional(),  // also enforce integer — cents
  color: z.string().regex(/^#[0-9a-fA-F]{6}$/).optional(),
  isActive: z.boolean().optional(),
})

Note: CASH was removed from the enum between old and new code — confirm this is intentional.

Comment thread backend/main.py
session: Session = Depends(get_session),
admin: User = Depends(get_admin_user),
):
user = session.get(User, user_id)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Self-deletion not prevented: admin can delete themselves

The DELETE /api/admin/users/{user_id} endpoint does not check whether the admin is attempting to delete their own account. An admin calling DELETE /api/admin/users/<own-id> would succeed, leaving the system with potentially no admin user.

Fix: Add a guard in both the frontend (admin-users-client.tsx) and the backend:

@app.delete("/api/admin/users/{user_id}")
def delete_user(user_id: str, session: Session = Depends(get_session), admin: User = Depends(get_admin_user)):
    if user_id == admin.id:
        raise HTTPException(status_code=400, detail="Cannot delete your own account")
    # ...

Also, the current UI allows deletion of other ADMIN users. Consider requiring a confirmation step or restricting deletion to USER-role accounts.

Comment thread backend/main.py

@app.get("/api/admin/users", response_model=List[UserResponse])
def get_users(session: Session = Depends(get_session), admin: User = Depends(get_admin_user)):
return session.exec(select(User)).all()

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

No pagination or limit on admin user listing

return session.exec(select(User)).all()

This fetches all users in the system with no limit. As user count grows this could return very large payloads and cause slow page loads or OOM issues.

Add pagination consistent with the transactions endpoint:

@app.get("/api/admin/users", response_model=List[UserResponse])
def get_users(
    page: int = 1,
    limit: int = 50,
    session: Session = Depends(get_session),
    admin: User = Depends(get_admin_user),
):
    users = session.exec(select(User).offset((page - 1) * limit).limit(limit)).all()
    total = session.exec(select(func.count()).select_from(User)).one()
    return {"users": users, "total": total, "page": page}

Comment thread backend/main.py
email: str
password: str
name: str | None = None
role: str = "USER"

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Security: UserCreate.role allows arbitrary role assignment without validation

class UserCreate(BaseModel):
    role: str = "USER"

The role field accepts any string value. A malicious or erroneous admin API call could set role to an unexpected value (e.g. "SUPERADMIN", "root", or an empty string). This won't currently break auth since only "ADMIN" is checked, but it's a code quality issue and a footgun for future role-based features.

Fix: Use Literal or add a validator:

from typing import Literal

class UserCreate(BaseModel):
    email: str
    password: str
    name: str | None = None
    role: Literal["USER", "ADMIN"] = "USER"

Comment thread backend/pyproject.toml
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"fastapi>=0.135.3",

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Lock file / pyproject.toml mismatch — ghost dependencies in uv.lock

The uv.lock file includes packages that are not listed in pyproject.toml:

  • cryptography
  • hkdf
  • jwcrypto
  • python-jose

These appear to be leftover from a previous JWE-based auth implementation that was replaced by the X-API-Key pattern. They add significant attack surface and binary size unnecessarily.

Action required: Run uv sync after removing those packages to regenerate a clean lock file. Verify that pyproject.toml lists only the packages actually imported by the code (fastapi, sqlmodel, uvicorn, passlib[bcrypt], pydantic-settings, python-dotenv, python-multipart, sqlalchemy).

Comment thread backend/Dockerfile
RUN apt-get update && apt-get install -y curl build-essential && rm -rf /var/lib/apt/lists/*

RUN curl -LsSf https://astral.sh/uv/install.sh | sh

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Backend Dockerfile installs uv via curl-pipe-sh — supply chain risk

RUN curl -LsSf https://astral.sh/uv/install.sh | sh

Piping from the internet directly into sh at build time is a supply chain risk. If the remote URL is ever compromised, every Docker image build will execute arbitrary code as root.

Safer alternatives:

  1. Pin to a specific uv version and verify the checksum:
RUN curl -LsSf https://astral.sh/uv/0.4.30/install.sh | sh
  1. Or use the official uv Docker image as a multi-stage base:
FROM ghcr.io/astral-sh/uv:0.4.30 AS uv
FROM python:3.12-slim
COPY --from=uv /uv /uvx /bin/

Also: the CMD runs without --reload which is correct for production, but the development volume mount (./backend:/app:cached) means code changes do not restart the server. Add --reload when targeting dev, or separate dev vs prod compose files.

Comment thread backend/main.py
yield


app = FastAPI(title="FinPulse API", lifespan=lifespan)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Architecture: No CORS configuration on FastAPI — potential issue if ever accessed cross-origin

The FastAPI app has no CORS middleware configured. While the current design expects the backend to only be called from the Next.js server (internal Docker network), the absence of CORS headers means that if the backend port is ever accidentally exposed or accessed via browser (e.g. during development), browsers will block responses.

For defense-in-depth and clarity, explicitly restrict origins:

from fastapi.middleware.cors import CORSMiddleware

app.add_middleware(
    CORSMiddleware,
    allow_origins=[],  # block all cross-origin requests — backend is internal only
    allow_credentials=False,
)

Alternatively, document clearly in the README that the backend must never receive direct browser requests.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Added. I've configured CORSMiddleware in backend/main.py with allow_origins=[] to enforce that it's internal only.

import { prisma } from "@/lib/prisma"
import { fetchBackend } from "@/lib/api"

const createAccountSchema = z.object({

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Missing GET /api/accounts route in Next.js — but server component fetches it directly

The file src/app/api/accounts/route.ts only exports POST — there is no GET handler. This is intentional since server components fetch directly via fetchBackend(), but the missing GET means client components cannot fetch accounts via the standard /api/accounts Next.js API route if needed in the future.

More importantly, the POST /api/accounts handler in route.ts does not include institution, interestRate, or creditLimit in its Zod schema, even though the backend model and AccountCreate accept them. Fields sent by the client that are not in the Zod schema will be stripped by .parse() and silently dropped.

Fix: Add the missing fields to the schema or document explicitly which fields are intentionally excluded:

const createAccountSchema = z.object({
  name: z.string().min(1),
  type: z.enum(["CHECKING", "SAVINGS", "CREDIT_CARD", "INVESTMENT", "LOAN", "OTHER"]),
  balance: z.number().int().default(0),
  institution: z.string().optional(),
  interestRate: z.number().optional(),
  creditLimit: z.number().int().optional(),
  color: z.string().regex(/^#[0-9a-fA-F]{6}$/).optional(),
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Fixed. I've added the GET route to src/app/api/accounts/route.ts and updated the Zod schema to include all the missing fields so they are properly passed to the backend.

Comment thread docker-compose.yml
npx prisma generate &&
npx prisma db push &&
npm run dev -- -H 0.0.0.0"
depends_on:

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Missing no-cache headers on backend health check dependency

The frontend service has depends_on: backend which only waits for the container to start, not for FastAPI to be ready to serve requests. On slow machines or cold starts, the frontend may attempt backend calls before uvicorn is listening, causing startup failures.

Fix: Add a healthcheck to the backend service and use condition: service_healthy in the frontend's depends_on:

  backend:
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
      interval: 5s
      timeout: 3s
      retries: 5
      start_period: 10s

  frontend:
    depends_on:
      backend:
        condition: service_healthy

The /health endpoint already exists in main.py so this requires no backend changes.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Fixed. I've added a health check definition to the backend service and updated the frontend's depends_on to wait for a service_healthy condition.

Comment thread src/lib/auth.ts
}
return token
},
async session({ session, token }) {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Missing updatedAt token staleness: role changes not reflected in session

The JWT token is signed at login and contains token.role. If an admin later changes a user's role via POST /api/admin/users (or the database is edited directly), the user's session will still carry the old role until it expires — Auth.js has no mechanism to invalidate existing tokens.

This is a systemic issue with stateless JWTs, but it's worth documenting and mitigating:

  1. Set a short session maxAge (e.g. 1 hour) in the Auth.js config to limit the staleness window:
session: { strategy: "jwt", maxAge: 3600 }
  1. Optionally, re-fetch the user role from the backend on each session callback (adds one DB lookup per request, but ensures freshness):
async session({ session, token }) {
  // Re-validate role from backend on every session check
  // ...
}

This is especially important for the ADMIN role, where a de-privileged user could continue to access admin endpoints until their token expires.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Added. I've set maxAge: 3600 (1 hour) in the Auth.js session configuration to limit the staleness window for cached roles.

Comment thread backend/main.py Outdated
@app.get("/api/transactions")
def get_transactions(
page: int = 1,
limit: int = 20,

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Missing input validation: limit query param has no upper bound

def get_transactions(page: int = 1, limit: int = 20, ...):
    ...
    transactions = session.exec(query.offset((page - 1) * limit).limit(limit)).all()

Any caller (including the dashboard with limit=10000) can request an arbitrary number of records. There is no upper bound enforced by the backend, so a malicious or buggy client could request millions of rows and exhaust memory.

Fix: Clamp the limit server-side using FastAPI's Query with le:

from fastapi import Query

def get_transactions(
    page: int = Query(default=1, ge=1),
    limit: int = Query(default=20, ge=1, le=500),  # hard cap at 500
    ...
):

Apply the same pattern to any future paginated endpoint.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Fixed. I've updated the get_transactions endpoint parameters using FastAPI's Query to enforce ge=1 and le=10000 (capped at 10000 to accommodate the dashboard fetch limit) to prevent out of bounds memory usage issues.

@PreetamMatta PreetamMatta left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Code Review Summary

This PR successfully decouples the Next.js frontend from Prisma/SQLite by introducing a FastAPI backend. The overall architecture is sound and the code is clean and readable. However, there are several issues that must be addressed before merging — particularly a cluster of security problems that are high priority.


Critical (must fix before merging)

1. Backend port publicly exposed (docker-compose.yml)
Port 8000 is mapped to the host (- "8000:8000"), which means the entire auth model is bypassable. Anyone on the network can directly call FastAPI with forged X-API-Key and X-User-Id headers. Remove the ports mapping entirely — services in the same compose network communicate internally without it.

2. Hardcoded secrets committed to source control (docker-compose.yml, auth.py, api.ts)
Both AUTH_SECRET and INTERNAL_API_KEY have known default values that are committed directly. These must be loaded from environment variables or a .env file that is not committed. The fallback "finpulse-internal-key" in auth.py and api.ts means the system is fully auth-bypassable if INTERNAL_API_KEY is not set.

3. Admin role check trusts the X-User-Role header (auth.py line 41)
get_admin_user grants ADMIN access based on the X-User-Role request header. Since the backend is publicly exposed (see #1), any caller can forge this header and gain admin access. Drop the header check entirely and rely solely on current_user.role from the database.

4. Register endpoint uses raw fetch without the API key (register/route.ts)
/api/register calls the backend without the X-API-Key header, so the request would fail with 401 from the backend. Use fetchBackend() consistently.


High Priority

5. limit=10000 in the dashboard (dashboard/page.tsx)
Fetching up to 10,000 transactions on every dashboard load is a significant scalability problem. The backend has no upper bound enforcement either. Add a Query(le=500) cap on the backend and introduce aggregation endpoints to replace client-side computation.

6. Ghost dependencies in uv.lock (pyproject.toml)
cryptography, hkdf, jwcrypto, and python-jose appear in uv.lock but not in pyproject.toml. These are leftover from the old JWE implementation. Regenerate the lock file with uv sync after confirming they are not used.

7. Admin self-deletion not prevented (main.py)
An admin can delete their own account. Add a check: if user_id == admin.id: raise HTTPException(400, ...).


Medium Priority

8. updatedAt never updated on mutations (models.py)
SQLModel has no auto-update trigger for updatedAt. All update endpoints set fields via setattr but never update the timestamp. Use SQLAlchemy's onupdate=func.now() column option.

9. Type validation regression in updateAccountSchema (accounts/[id]/route.ts)
The account type field was downgraded from z.enum([...])" to z.string()`. Restore the enum for early validation and to prevent silent passthrough of invalid values.

10. Backend error codes swallowed in Next.js routes
DELETE handlers catch all errors and return 500, even for backend 404s. Consider a typed error class in fetchBackend() that carries the HTTP status code.

11. Missing explicit auth checks in mutation API routes
Only admin/users/[id]/route.ts has an auth guard. All other mutation routes (PUT/DELETE for accounts and transactions) rely solely on fetchBackend() passing a null user ID. Add auth() checks as defense-in-depth, or confirm middleware protects all /api/ routes.

12. JWT role staleness after role change (auth.ts)
The JWT encodes the user's role at login. If an admin demotes a user, their token remains valid for the full session lifetime. Set a short maxAge and/or re-fetch the role on each session callback.


Low Priority / Nits

13. Missing Docker healthcheck
depends_on: backend only waits for the container to start, not for FastAPI to be ready. Add a proper healthcheck using the existing /health endpoint.

14. No CORS restriction on FastAPI
Add explicit allow_origins=[] CORS middleware to make the "internal only" contract explicit in code.

15. Monthly chart bug — year boundary collision (dashboard/page.tsx line 70)
Month grouping by locale short name (e.g. "Jan") will double-count transactions across year boundaries. Use (year, monthIndex) as the key instead.

16. UserCreate.role accepts arbitrary strings (main.py line 83)
Use Literal["USER", "ADMIN"] to prevent invalid role values.

17. Dockerfile installs uv via curl-pipe-sh (backend/Dockerfile)
Pin to a specific uv version or use the official uv Docker image as a multi-stage base to avoid supply chain risk.

18. createAccountSchema missing fields (accounts/route.ts)
institution, interestRate, and creditLimit are supported by the backend but stripped by Zod before being forwarded. Add them or document the intentional omission.


What's Done Well

  • The decoupled architecture pattern (Next.js as auth proxy, FastAPI as data layer) is clean and well-documented.
  • The fetchBackend() helper correctly injects both the API key and user identity in one place.
  • Transactions endpoint has proper pagination, filtering, and sort validation.
  • Authorization at the data layer (filtering by userId in every query) is consistently applied.
  • The seed script and demo data are comprehensive and well-organized.
  • The Zod validation on all POST mutation routes is correctly structured (using .issues not .errors).
  • The model_post_init validator on Pydantic models is a reasonable pattern for enum-like fields.
  • TypeScript types for Next.js are extended correctly for role in the session callback.

google-labs-jules Bot and others added 2 commits April 10, 2026 06:22
… hide backend port

Co-authored-by: PreetamMatta <47058129+PreetamMatta@users.noreply.github.com>
…on maxAge, and Validation limits

Co-authored-by: PreetamMatta <47058129+PreetamMatta@users.noreply.github.com>

@PreetamMatta PreetamMatta left a comment •

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Code Review(From Gemma4(Ollama) on Claude code) — Architectural Decoupling (FastAPI Backend)

Overall this is a solid architectural change. The API-key + user-ID header pattern for internal Next.js ↔ FastAPI communication is clean and appropriate for a self-hosted app, and the fetchBackend() helper centralizes that concern well. Below are findings from most to least severe.


🔴 Critical

1. Registration is always broken (src/app/api/register/route.ts)

The route calls the backend directly via fetch() but never includes the X-API-Key header. The backend's /api/register endpoint calls verify_api_key_only(request) first, which rejects any request without a valid key with a 401. Every registration attempt will silently fail.

// register/route.ts — missing the API key header
const res = await fetch(`${baseUrl}/api/register`, {
  method: "POST",
  headers: { "Content-Type": "application/json" }, // ← X-API-Key missing
  ...
})

Fix: add "X-API-Key": process.env.INTERNAL_API_KEY as string to the headers, matching the pattern used in auth.ts's authorize callback.


🟠 High

2. Duplicate session: key in NextAuth config (src/lib/auth.ts)

The config object has two session: properties — line 3 ({ strategy: "jwt" }) and line 44 ({ strategy: "jwt", maxAge: 3600 }). In JavaScript the second wins, but TypeScript should flag this as an error under noEmit --strict. The first entry is dead code and should be removed.

3. bcrypt imported directly but not a declared dependency (backend/pyproject.toml)

main.py and seed.py both do import bcrypt and call bcrypt.hashpw / bcrypt.checkpw directly. pyproject.toml lists passlib[bcrypt], which installs bcrypt as a transitive dependency of passlib. This works today but any passlib version that drops bcrypt as an optional dep would silently break prod. Add bcrypt as a direct dependency.

4. Client-side filtering only works within the current page of 20 transactions

TransactionsPage loads one page of 20 transactions server-side and passes them to TransactionsClient. The client filters/searches on that local array in state. Meanwhile the GET /api/transactions Next.js route was removed entirely, so there's no way to re-query with filter params from the client. In the old Prisma version, filters were applied at the database layer across all transactions. This is a functional regression — searching "Starbucks" only searches the 20 visible rows, not all transactions.

Fix: either restore the GET handler on src/app/api/transactions/route.ts (proxying to fetchBackend), or navigate to the page server-side with search/filter params in the URL so TransactionsPage can forward them to the backend.

5. updatedAt is never updated on mutations

All models define updatedAt: datetime = Field(default_factory=get_utc_now) but SQLModel/SQLAlchemy has no onupdate trigger on that column. Every PUT handler (accounts, transactions) uses setattr(obj, key, value) without touching updatedAt. Every edited record will show its original creation timestamp as updatedAt.

Fix: add session.exec(... setattr(obj, 'updatedAt', datetime.now(timezone.utc))) in each PUT handler, or use SQLAlchemy's Column(onupdate=...).


🟡 Medium

6. Dashboard fetches up to 10,000 transactions for client-side aggregation (src/app/(app)/dashboard/page.tsx)

fetchBackend(`/api/transactions?...&limit=10000`)

All aggregation (monthly income/expenses, spending by category, net worth) is done in JavaScript after fetching. For a user with years of history this transfers a large payload and is slow. The backend should expose aggregation endpoints (e.g., GET /api/dashboard/summary?months=6) that do SUM/GROUP BY in SQLite instead.

7. No guard against deleting the only admin user (backend/main.py)

DELETE /api/admin/users/{user_id} has no check that at least one ADMIN remains. Deleting yourself as the sole admin permanently locks the admin panel.

8. INTERNAL_API_KEY fallback mismatch between auth.ts and docker-compose

auth.ts falls back to "finpulse-internal-key" when INTERNAL_API_KEY is not set; docker-compose.yml sets it to "finpulse-dev-internal-api-key-change-in-production". If someone runs the frontend outside Docker without the env var, auth will silently break. Consider removing the fallback and letting the KeyError / missing-env fail loudly at startup (consistent with how backend/auth.py handles it).

9. X-User-Role header set in fetchBackend but never read by the backend

src/lib/api.ts sends X-User-Role on every request, but backend/auth.py re-fetches the user from the database to check the role. The header is redundant and could be misleading. Remove it or document that it's intentionally ignored.

10. Seed categorization rules are user-scoped, not system-wide

seed.py creates all CategorizationRule records with userId=user.id (the demo user). New users get no rules at all. The original intent per the feature tracker is 30+ seeded rules as system defaults. The CategorizationRule model has a non-nullable userId — consider making it nullable (like Category) so rules can be system-wide, or seed a set of default rules for every new user on registration.


🔵 Low / Nits

11. ARCHITECTURE.md still describes Prisma ORM

The architecture diagram and layer descriptions still reference Prisma ORM (Type-safe) and src/lib/prisma.ts. These should be updated to reflect SQLModel/FastAPI now that prisma.ts has been deleted.

12. Validation style inconsistency in Pydantic models

AccountCreate.model_post_init raises ValueError (converted to a 422 by FastAPI) while update_account raises HTTPException(422) explicitly for the same type validation. Consistent use of either Pydantic validators or explicit HTTPException in the route would be cleaner.

13. CategoryCreate.model_post_init raises ValueError, not HTTPException

Same as above — CategoryCreate validates type in model_post_init but there's no equivalent check in POST /api/categories for invalid types. It'll produce a 422 with a Pydantic-style error body rather than the standardized {"detail": "..."} format used elsewhere.


✅ What's Good

  • The fetchBackend() abstraction is clean — auth, API key, and Content-Type are all handled in one place.
  • FastAPI endpoints consistently scope all queries by userId — no cross-user data leaks.
  • Health check + Docker depends_on: condition: service_healthy ensures the frontend doesn't start before FastAPI is ready.
  • CORS is correctly restricted to empty origins (internal-only).
  • Session maxAge: 3600 is now set (was missing before).
  • prisma.ts and bcryptjs dependency properly removed from the frontend.
  • Zod validation still enforced at the Next.js API boundary.
  • INTERNAL_API_KEY startup crash (KeyError) on the backend is intentional and correct.

…transaction filtering, dashboard summary API, and schema fixes
@PreetamMatta

Copy link
Copy Markdown
Owner Author

I have addressed all the code review findings in commit a4bbea7:

  1. 🔴 Registration endpoint: Added the header to when forwarding requests to the FastAPI backend.
  2. 🟠 NextAuth session config: Removed the duplicate property on line 5 of .
  3. 🟠 Backend dependencies: Declared explicitly in .
  4. 🟠 Server-side transaction filtering: Restored the proxy route in Next.js, updated to pass filter/search/sort URL params to the backend, and updated to drive filtering via URL query params () across all pages in the database.
  5. 🟠 ** timestamp**: Updated and on mutations in .
  6. 🟡 Dashboard aggregation: Added in FastAPI to calculate metrics and 6-month trends directly in SQLite/Python, eliminating the need to fetch 10,000 transactions over the wire.
  7. 🟡 Admin user deletion guard: Added a check in to prevent deleting the sole remaining admin user.
  8. 🟡 ** fallback consistency**: Removed hardcoded fallback keys from Next.js helpers (, , ), enforcing env configuration.
  9. 🟡 ** header cleanup**: Removed redundant header from .
  10. 🟡 System-wide categorization rules: Made optional on , seeded system-wide default rules (), and added endpoint.
  11. 🔵 Architecture docs: Updated to reflect the decoupled Next.js + FastAPI (SQLModel/SQLite) architecture.
  12. 🔵 Pydantic validation consistency: Standardized , , and models using Pydantic .

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant