Skip to content
Open
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
8 changes: 8 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
**/node_modules
**/.next
**/out
**/__pycache__
**/.venv
**/.git
**/data
.env
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
OPENROUTER_API_KEY=
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -174,3 +174,7 @@ cython_debug/

.DS_Store

# Project: local SQLite data directory (bind-mounted into the container)
/data/


25 changes: 25 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
FROM node:24-slim AS frontend-build

WORKDIR /app/frontend

COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci

COPY frontend ./
RUN npm run build

FROM python:3.13-slim

COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/

WORKDIR /app

COPY backend/pyproject.toml ./
COPY backend/app ./app
COPY --from=frontend-build /app/frontend/out ./app/static

RUN uv sync --no-dev

EXPOSE 8000

CMD ["uv", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
31 changes: 30 additions & 1 deletion backend/AGENTS.md
Original file line number Diff line number Diff line change
@@ -1 +1,30 @@
This file should be updated with a description of the Backend
# Backend

FastAPI app served together with the statically-exported Next.js frontend (see frontend/AGENTS.md), packaged into one Docker container per root AGENTS.md.

## Stack

FastAPI + Uvicorn, managed with `uv` (see `pyproject.toml`; `[tool.uv] package = false` — this is an application, not a distributable package). Python >=3.12.

## Structure

- `app/main.py` — the FastAPI app. API routes live under `/api/*`; everything else is served as static files (the frontend build output, mounted at `/`) via `StaticFiles(..., html=True)`. Routes are registered before the static mount so `/api/*` always takes precedence over it.
- `app/static/` — the files served at `/`. Currently a hand-written placeholder page; replaced by the real Next.js static export in Part 3 of `docs/PLAN.md`.
- `tests/` — pytest, using FastAPI's `TestClient`. `pyproject.toml` sets `pythonpath = ["."]` so `from app.main import app` resolves when running `pytest` from this directory.

## Running locally (without Docker)

```
uv sync
uv run uvicorn app.main:app --reload
```

## Testing

```
uv run pytest
```

## Data

SQLite data (from Part 5 of `docs/PLAN.md` onward) lives under `data/` at the project root, bind-mounted into the container at `/app/data` by `scripts/start`.
Empty file added backend/app/__init__.py
Empty file.
33 changes: 33 additions & 0 deletions backend/app/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import os
import secrets

from fastapi import HTTPException, Request

SESSION_COOKIE_NAME = "session_token"

USERNAME = os.environ.get("APP_USERNAME", "user")
PASSWORD = os.environ.get("APP_PASSWORD", "password")

_sessions: dict[str, str] = {}


def authenticate(username: str, password: str) -> bool:
return username == USERNAME and password == PASSWORD


def create_session(username: str) -> str:
token = secrets.token_urlsafe(32)
_sessions[token] = username
return token


def destroy_session(token: str) -> None:
_sessions.pop(token, None)


def require_session(request: Request) -> str:
token = request.cookies.get(SESSION_COOKIE_NAME)
username = _sessions.get(token) if token else None
if username is None:
raise HTTPException(status_code=401, detail="Not authenticated")
return username
53 changes: 53 additions & 0 deletions backend/app/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
from pathlib import Path

from fastapi import Depends, FastAPI, HTTPException, Request, Response
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel

from app.auth import (
SESSION_COOKIE_NAME,
authenticate,
create_session,
destroy_session,
require_session,
)

app = FastAPI()

STATIC_DIR = Path(__file__).parent / "static"


class LoginRequest(BaseModel):
username: str
password: str


@app.get("/api/hello")
def hello() -> dict[str, str]:
return {"message": "Hello from the API"}


@app.post("/api/login")
def login(credentials: LoginRequest, response: Response) -> dict[str, str]:
if not authenticate(credentials.username, credentials.password):
raise HTTPException(status_code=401, detail="Invalid credentials")
token = create_session(credentials.username)
response.set_cookie(SESSION_COOKIE_NAME, token, httponly=True, samesite="lax")
return {"username": credentials.username}


@app.post("/api/logout")
def logout(request: Request, response: Response) -> dict[str, bool]:
token = request.cookies.get(SESSION_COOKIE_NAME)
if token:
destroy_session(token)
response.delete_cookie(SESSION_COOKIE_NAME)
return {"ok": True}


@app.get("/api/me")
def me(username: str = Depends(require_session)) -> dict[str, str]:
return {"username": username}


app.mount("/", StaticFiles(directory=STATIC_DIR, html=True), name="static")
18 changes: 18 additions & 0 deletions backend/app/static/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Project Management MVP</title>
</head>
<body>
<h1>Project Management MVP</h1>
<p id="message">Loading...</p>
<script>
fetch("/api/hello")
.then((response) => response.json())
.then((data) => {
document.getElementById("message").textContent = data.message;
});
</script>
</body>
</html>
20 changes: 20 additions & 0 deletions backend/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
[project]
name = "backend"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"fastapi>=0.115",
"uvicorn[standard]>=0.34",
]

[dependency-groups]
dev = [
"pytest>=8.3",
"httpx2>=0.1",
]

[tool.uv]
package = false

[tool.pytest.ini_options]
pythonpath = ["."]
40 changes: 40 additions & 0 deletions backend/tests/test_auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
from fastapi.testclient import TestClient

from app.main import app


def test_login_with_correct_credentials_sets_session_cookie():
response = TestClient(app).post(
"/api/login", json={"username": "user", "password": "password"}
)
assert response.status_code == 200
assert response.json() == {"username": "user"}
assert "session_token" in response.cookies


def test_login_with_wrong_credentials_is_rejected():
response = TestClient(app).post(
"/api/login", json={"username": "user", "password": "wrong"}
)
assert response.status_code == 401


def test_me_requires_a_session():
response = TestClient(app).get("/api/me")
assert response.status_code == 401


def test_me_returns_the_logged_in_user():
client = TestClient(app)
client.post("/api/login", json={"username": "user", "password": "password"})
response = client.get("/api/me")
assert response.status_code == 200
assert response.json() == {"username": "user"}


def test_logout_clears_the_session():
client = TestClient(app)
client.post("/api/login", json={"username": "user", "password": "password"})
client.post("/api/logout")
response = client.get("/api/me")
assert response.status_code == 401
11 changes: 11 additions & 0 deletions backend/tests/test_hello.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from fastapi.testclient import TestClient

from app.main import app

client = TestClient(app)


def test_hello_returns_expected_message():
response = client.get("/api/hello")
assert response.status_code == 200
assert response.json() == {"message": "Hello from the API"}
11 changes: 11 additions & 0 deletions backend/tests/test_static.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from fastapi.testclient import TestClient

from app.main import app

client = TestClient(app)


def test_root_serves_static_page():
response = client.get("/")
assert response.status_code == 200
assert "Project Management MVP" in response.text
Loading