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
Binary file added .DS_Store
Binary file not shown.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,5 @@ secrets.prod.env
secrets.dev.env
secrets.env
backups/

node_modules
35 changes: 35 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Repository Guidelines

## Project Structure & Module Organization
- `api/app/`: FastAPI backend (`routes/`, `services/`, `models/`, `schemas/`, `tasks/`, `middlewares/`).
- `api/tests/`: pytest suite for API behavior and regressions.
- `ui-new/src/`: React + TypeScript frontend (`pages/`, `components/`, `api/`, `stores/`).
- `ui/app/`: legacy Flask/Jinja UI still shipped in Docker compose.
- `docs/`: architecture and auth notes. `docker-compose*.yml` defines `dev`, `prod`, and `ci` environments.

## Build, Test, and Development Commands
- `uv python install 3.12 && uv sync --dev`: install Python runtime and backend dependencies.
- `make dev`: run Docker services and start `ui-new` in dev mode.
- `make test`: boot the dev stack in background and run backend `pytest` in the `api` container.
- `make ci`: CI-equivalent API test run (`pytest -v -s --log-level DEBUG`) with teardown.
- `cd ui-new && npm run dev|build|lint|test:run|format:check`: frontend dev, build, lint, test, and formatting checks.

## Coding Style & Naming Conventions
- Python: 4-space indentation, Black formatting (88-char line length), isort with Black profile.
- Install hooks with `pre-commit install`, then run `pre-commit run --all-files` before PRs.
- Python naming: modules/functions in `snake_case`, classes in `PascalCase`.
- Frontend: function components only. ESLint forbids class components and `component.displayName`.
- Use named exports in app code (no default exports), and prefer the `@/` import alias.
- React component folders should follow `ComponentName/ComponentName.tsx` with a local `index.ts`.

## Testing Guidelines
- Backend tests belong in `api/tests/test_*.py`; shared fixtures live in `api/conftest.py`.
- Frontend tests use Vitest + Testing Library and must match `src/**/*.test.{ts,tsx}`.
- No explicit coverage threshold is enforced; add tests for behavior changes and edge cases.
- For UI logic changes, include at least one focused component or flow test.

## Commit & Pull Request Guidelines
- Current history favors concise, imperative commits, often with prefixes like `feat:`, `fix:`, or `wip:`.
- Prefer `type: short summary` (example: `fix: handle expired keepz token`).
- PRs should include: problem statement, change summary, test evidence (commands run), and screenshots for UI changes.
- Link related issues/tasks and call out config or migration impacts explicitly.
13 changes: 11 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,11 @@ COMPOSE = docker compose -f $(COMPOSE_BASE) -f $(or $(COMPOSE_SUFFIX_$(ENV)),$(e

.ONESHELL:

.PHONY: dev prod up up-detached down test ci db-backup db-restore add-entity
.PHONY: dev prod up up-detached down test ci db-backup db-restore db-migrate add-entity dev-ui-new

dev: ENV = dev
dev: up
dev:
$(COMPOSE) up --build

prod: ENV = prod
prod: up-detached
Expand Down Expand Up @@ -66,9 +67,17 @@ db-restore:
@echo "Restoring $(ENV) database from $(BACKUP_FILE)"
$(COMPOSE) exec -T db psql -U $(DB_USER) -d $(DB_NAME) < $(BACKUP_FILE)

.PHONY: db-migrate
db-migrate: ENV = dev
db-migrate:
$(COMPOSE) exec api python -m app.scripts.migrate_add_treasury_author

.PHONY: add-entity
add-entity: ENV = dev
add-entity:
# Example: make add-entity NAME=skywinder TELEGRAM_ID=123456789 ID=201
@if [ -z "$(NAME)" ]; then echo "Usage: make add-entity NAME=<name> [TELEGRAM_ID=<id>] [ID=<id>]"; exit 1; fi
$(COMPOSE) exec api python -m app.scripts.add_entity --name "$(NAME)" $(if $(ID),--id $(ID),) $(if $(TELEGRAM_ID),--telegram-id $(TELEGRAM_ID),)

dev-ui-new:
cd ui-new && npm run dev
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ make prod

API: http://0.0.0.0:8000/docs
UI: http://0.0.0.0:9000
UI (new): http://0.0.0.0:5173

## dev

Expand Down Expand Up @@ -56,13 +57,18 @@ pre-commit install
```console
make dev
```
open http://localhost:8000/docs and http://localhost:9000
open http://localhost:8000/docs, http://localhost:9000 and http://localhost:5173

### tests
```console
make test
```

### database schema patch (for existing DBs after additive column changes)
```console
make db-migrate ENV=dev
```

## todo release
- [x] base classes
- [x] errors
Expand Down
10 changes: 9 additions & 1 deletion api/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ class Config:
telegram_bot_api_token: str | None = field(
default=getenv("REFINANCE_TELEGRAM_BOT_API_TOKEN", "")
)
# Required by the web Telegram widget/deep-link flow, not by token signing itself.
telegram_bot_username: str | None = field(
default=getenv("REFINANCE_TELEGRAM_BOT_USERNAME", "")
)

ui_url: str | None = field(default=getenv("REFINANCE_UI_URL", ""))
api_url: str | None = field(default=getenv("REFINANCE_API_URL", ""))
Expand All @@ -44,6 +48,10 @@ class Config:
keepz_poll_interval_seconds: int = field(
default=int(getenv("REFINANCE_KEEPZ_POLL_INTERVAL_SECONDS", "60"))
)
csrf_disabled: bool = field(
default=getenv("REFINANCE_CSRF_DISABLED", "").lower()
in ("1", "true", "yes"),
)
# Optional database URL for Postgres or other databases
database_url_env: str | None = field(default=getenv("REFINANCE_DATABASE_URL", None))
fee_presets_raw: str = field(default=getenv("REFINANCE_FEE_PRESETS", ""))
Expand All @@ -53,7 +61,7 @@ def database_url(self) -> str:
# Use provided DATABASE_URL if available, else fall back to Postgres service
if self.database_url_env:
return self.database_url_env
return "postgresql://postgres:postgres@db:5432/refinance"
return "postgresql://postgres@db:5432/refinance"

@property
def fee_presets(self) -> list[dict[str, str | int]]:
Expand Down
88 changes: 88 additions & 0 deletions api/app/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ def __init__(self, config: Config = Depends(get_config)) -> None:
# Seed bootstrap data only once per process.
if db_url not in self.__class__._bootstrapped_urls:
self.create_tables()
self.ensure_schema_compatibility()
self.seed_bootstrap_data()
self.__class__._bootstrapped_urls.add(db_url)

Expand All @@ -56,6 +57,93 @@ def get_session(self) -> Session:
"""Return a new SQLAlchemy session."""
return self.session_local()

def ensure_schema_compatibility(self) -> None:
"""
Apply idempotent, additive schema patches for existing databases.

This project does not use Alembic migrations, and create_all() does not
add new columns to existing tables. Keep these patches minimal and safe.
"""
with self.get_session() as session:
db_engine = session.get_bind()
dialect = db_engine.dialect.name.lower()

if dialect == "postgresql":
self._ensure_treasury_author_column_postgresql(session)
elif dialect == "sqlite":
self._ensure_treasury_author_column_sqlite(session)
else:
logger.debug(
"Skipping schema compatibility patches for dialect '%s'", dialect
)

session.commit()

def _ensure_treasury_author_column_postgresql(self, session: Session) -> None:
"""Ensure treasuries.author_entity_id and its FK exist in Postgres."""
column_exists = session.execute(
text(
"""
SELECT 1
FROM information_schema.columns
WHERE table_schema = current_schema()
AND table_name = 'treasuries'
AND column_name = 'author_entity_id'
"""
)
).first()

if not column_exists:
logger.info("Adding missing column treasuries.author_entity_id")
session.execute(
text(
"""
ALTER TABLE treasuries
ADD COLUMN author_entity_id INTEGER NULL
"""
)
)

fk_exists = session.execute(
text(
"""
SELECT 1
FROM pg_constraint
WHERE conname = 'treasuries_author_entity_id_fkey'
AND conrelid = 'treasuries'::regclass
"""
)
).first()

if not fk_exists:
logger.info("Adding missing FK constraint treasuries_author_entity_id_fkey")
session.execute(
text(
"""
ALTER TABLE treasuries
ADD CONSTRAINT treasuries_author_entity_id_fkey
FOREIGN KEY (author_entity_id)
REFERENCES entities(id)
"""
)
)

def _ensure_treasury_author_column_sqlite(self, session: Session) -> None:
"""Ensure treasuries.author_entity_id exists in SQLite."""
rows = session.execute(text("PRAGMA table_info(treasuries)")).fetchall()
columns = {row[1] for row in rows}

if "author_entity_id" not in columns:
logger.info("Adding missing column treasuries.author_entity_id (sqlite)")
session.execute(
text(
"""
ALTER TABLE treasuries
ADD COLUMN author_entity_id INTEGER NULL
"""
)
)

def seed_bootstrap_data(self) -> None:
"""
Seed bootstrap data for Tags and Entities, and update the autoincrement
Expand Down
1 change: 0 additions & 1 deletion api/app/dependencies/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,6 @@ def keepz_deposit_provider_service(self):
db=self.db,
deposit_service=self.deposit_service,
keepz_service=self.keepz_service,
config=self.config,
)
return self._keepz_deposit_provider_service

Expand Down
6 changes: 6 additions & 0 deletions api/app/errors/entity.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

from app.errors.base import ApplicationError


class DuplicateEntityAuthBinding(ApplicationError):
http_code = 409
error_code = 4001
error = "Entity auth binding already exists"

# not needed anymore, as we have a separate seed/bootstrapping system
#
# class EntitiesAlreadyPresent(ApplicationError):
Expand Down
83 changes: 83 additions & 0 deletions api/app/middlewares/csrf.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
"""CSRF protection middleware.

Validates X-CSRF-Token header for state-changing requests.
Sets csrf_token cookie on responses when missing (so client can send it back).
Excludes /tokens and /deposit-callbacks (pre-auth and webhook endpoints).
Disabled when REFINANCE_CSRF_DISABLED=1 (e.g. for tests).
"""

import secrets

from app.config import get_config
from fastapi import Request
from fastapi.responses import JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware


CSRF_COOKIE_NAME = "csrf_token"
CSRF_HEADER_NAME = "X-CSRF-Token"
EXCLUDED_PREFIXES = ("/tokens", "/deposit-callbacks")
SAFE_METHODS = {"GET", "HEAD", "OPTIONS"}


def _get_cookie(request: Request, name: str) -> str | None:
"""Extract cookie value from request."""
cookie_header = request.headers.get("cookie")
if not cookie_header:
return None
for part in cookie_header.split(";"):
part = part.strip()
if part.startswith(f"{name}="):
return part[len(name) + 1 :].strip()
return None


def _path_matches_exclusion(path: str) -> bool:
"""Check if path should be excluded from CSRF validation."""
# Strip query string for prefix check
base_path = path.split("?")[0] if "?" in path else path
return any(base_path.rstrip("/").startswith(p.rstrip("/")) for p in EXCLUDED_PREFIXES)


class CSRFMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
if get_config().csrf_disabled:
return await call_next(request)

method = request.method.upper()

# For safe methods, skip validation
if method in SAFE_METHODS:
response = await call_next(request)
return self._maybe_set_cookie(request, response)

# Excluded paths (tokens, webhooks)
if _path_matches_exclusion(request.url.path):
response = await call_next(request)
return self._maybe_set_cookie(request, response)

# Validate CSRF for state-changing requests
cookie_token = _get_cookie(request, CSRF_COOKIE_NAME)
header_token = request.headers.get(CSRF_HEADER_NAME)

if not cookie_token or not header_token or not secrets.compare_digest(cookie_token, header_token):
return JSONResponse(
status_code=403,
content={"detail": "CSRF validation failed"},
)

response = await call_next(request)
return self._maybe_set_cookie(request, response)

def _maybe_set_cookie(self, request: Request, response):
"""Add Set-Cookie for csrf_token if request had none."""
if _get_cookie(request, CSRF_COOKIE_NAME) is not None:
return response

token = secrets.token_urlsafe(32)
# SameSite=Lax allows cookie on top-level navigations (e.g. login link)
# Not HttpOnly: client JS must read cookie to send X-CSRF-Token header
cookie_value = f"{CSRF_COOKIE_NAME}={token}; Path=/; SameSite=Lax"
response.headers.append("Set-Cookie", cookie_value)
response.headers.append(CSRF_HEADER_NAME, token)
return response
11 changes: 11 additions & 0 deletions api/app/models/treasury.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,25 @@
"""Treasury model"""

from typing import TYPE_CHECKING

from app.models.base import BaseModel
from sqlalchemy import Boolean, ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column, relationship

if TYPE_CHECKING:
from app.models.entity import Entity


class Treasury(BaseModel):
__tablename__ = "treasuries"

name: Mapped[str] = mapped_column(String, unique=True)
active: Mapped[bool] = mapped_column(Boolean, default=True)
author_entity_id: Mapped[int | None] = mapped_column(
ForeignKey("entities.id"), nullable=True
)
author_entity: Mapped["Entity | None"] = relationship(
foreign_keys=[author_entity_id]
)

# Relationship backrefs will be configured on Transaction model
11 changes: 1 addition & 10 deletions api/app/routes/balance.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,11 @@
from app.models.entity import Entity
from app.schemas.balance import BalanceSchema
from app.services.balance import BalanceService
from fastapi import APIRouter, Depends, Query
from fastapi import APIRouter, Depends

balance_router = APIRouter(prefix="/balances", tags=["Balances"])


@balance_router.get("", response_model=dict[int, BalanceSchema])
def get_balances(
entity_ids: list[int] = Query(default_factory=list),
balance_service: BalanceService = Depends(get_balance_service),
actor_entity: Entity = Depends(get_entity_from_token),
):
return balance_service.get_balances_many(entity_ids=entity_ids)


@balance_router.get("/{entity_id}", response_model=BalanceSchema)
def get_balance(
entity_id: int,
Expand Down
Loading
Loading