diff --git a/Makefile b/Makefile deleted file mode 100644 index db818a3..0000000 --- a/Makefile +++ /dev/null @@ -1,22 +0,0 @@ -.PHONY: dev stop docs-serve docs-build - -MKDOCS ?= mkdocs - -dev: - echo "Starting dev environment" - supabase start || npx supabase start - docker compose up --watch - -stop: - echo "Stopping dev environment" - docker compose down - supabase stop || npx supabase stop - -docs-serve: - $(MKDOCS) serve --config-file mkdocs.yml - -docs-build: - $(MKDOCS) build --config-file mkdocs.yml - -docs-clean: - rm -R site/ diff --git a/README.md b/README.md index 2f55793..3533f9f 100644 --- a/README.md +++ b/README.md @@ -20,16 +20,18 @@ Further improvements are planned to make this a cloud-native application. # Local development +For automated recipes, this project uses `Just`. Ensure you have it installed. + To start the backend, frontend and supabase, issue: ``` -make dev +just dev ``` To stop these services, do: ``` -make stop +just stop ``` # Docs @@ -51,11 +53,11 @@ $ uv pip install mkdocs Lastly, build the documentation: ```bash -$ make docs-build +$ just docs-build ``` This will output a `site/` directory containing the static site. To serve the website in localhost: ```bash -$ make docs-serve +$ just docs-serve ``` diff --git a/backend/.env.example b/backend/.env.example index 8dc002a..c43ad80 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,4 +1,4 @@ SUPABASE_URL=http://127.0.0.1:54321 -# Required only while supporting legacy HS256 Supabase tokens. -SUPABASE_JWT_SECRET=jwt_secret_here DATABASE_URL=postgresql+asyncpg://postgres:postgres@127.0.0.1:54322/postgres +ENVIRONMENT=development +CORS_ORIGINS=https://app.example.com,https://admin.example.com diff --git a/backend/app/auth/service.py b/backend/app/auth/service.py index 38c8af2..1c45294 100644 --- a/backend/app/auth/service.py +++ b/backend/app/auth/service.py @@ -31,25 +31,18 @@ def verify_jwt_token( token: str, # self contained token that already has info like id, email, etc settings: Settings, ) -> AuthUser: - """Verify either legacy HS256 or current Supabase ES256 access tokens.""" + """Verify Supabase ES256 access tokens.""" try: - algorithm = jwt.get_unverified_header(token).get("alg") - - if algorithm == "HS256": - key = settings.SUPABASE_JWT_SECRET.get_secret_value() - elif algorithm == "ES256": - key = ( - get_jwk_client(str(settings.SUPABASE_URL)) - .get_signing_key_from_jwt(token) - .key - ) - else: - raise TokenInvalidError("Unsupported access token algorithm") + key = ( + get_jwk_client(str(settings.SUPABASE_URL)) + .get_signing_key_from_jwt(token) + .key + ) payload = jwt.decode( token, key, - algorithms=[algorithm], + algorithms=["ES256"], audience="authenticated", ) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 11b260e..d5bdf33 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -6,16 +6,26 @@ class Settings(BaseSettings): # app name - APP_NAME: str = "Meu App FastAPI" + APP_NAME: str = "WebMUN API" ENVIRONMENT: str = "development" + # list of origins by separated commas (e.g., "app.com,app.xyz") + CORS_ORIGINS: str = "" + # db config DATABASE_URL: SecretStr # supabase config SUPABASE_URL: AnyHttpUrl - SUPABASE_JWT_SECRET: SecretStr - JWT_ALGORITHM: str = "HS256" + + @property + def list_cors_origins(self) -> list[str]: + origins = [origin.strip() for origin in self.CORS_ORIGINS.split(",")] + + if self.ENVIRONMENT == "development": + return ["http://localhost:5173"] + + return origins # Host development uses backend/.env; containers and cloud inject process env. model_config = SettingsConfigDict(env_file=".env", extra="ignore") diff --git a/backend/app/main.py b/backend/app/main.py index 921c259..512646b 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -14,14 +14,16 @@ from app.session.manager import ConnectionManager from app.session.views import router as session_router - # Startup and shutdown logic for shared variables, such as # (db session, settings, connection manager, etc) # You can view more of this on "FastAPI Lifespan" + +settings = get_settings() + + @asynccontextmanager async def lifespan(app: FastAPI): # startup phase - settings = get_settings() engine, session_factory = create_db(settings) app.state.db_engine = engine app.state.db_session_factory = session_factory @@ -48,9 +50,14 @@ async def app_exception_handler(request: Request, exc: AppException): # CORS config for Vite app.add_middleware( CORSMiddleware, - allow_origins=["http://localhost:5173"], - allow_methods=["*"], - allow_headers=["*"], + allow_origins=settings.list_cors_origins, + allow_credentials=True, + allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"] + if settings.ENVIRONMENT == "production" + else ["*"], + allow_headers=["Authorization", "Content-Type", "Accept"] + if settings.ENVIRONMENT == "production" + else ["*"], ) # include commitees here? diff --git a/backend/app/tests/auth/test_service.py b/backend/app/tests/auth/test_service.py index 3081b56..a239ef5 100644 --- a/backend/app/tests/auth/test_service.py +++ b/backend/app/tests/auth/test_service.py @@ -4,27 +4,28 @@ import jwt import pytest +from cryptography.hazmat.primitives.asymmetric import ec +import app.auth.service as auth_service from app.auth.service import TokenExpiredError, TokenInvalidError, verify_jwt_token -class FakeSecret: - def __init__(self, value: str): - self.value = value - - def get_secret_value(self) -> str: - return self.value +@pytest.fixture +def signing_key(monkeypatch): + private_key = ec.generate_private_key(ec.SECP256R1()) + jwk_client = SimpleNamespace( + get_signing_key_from_jwt=lambda _: SimpleNamespace(key=private_key.public_key()) + ) + monkeypatch.setattr(auth_service, "get_jwk_client", lambda _: jwk_client) + return private_key @pytest.fixture def settings(): - return SimpleNamespace( - SUPABASE_JWT_SECRET=FakeSecret("test-secret-that-is-at-least-32-bytes"), - JWT_ALGORITHM="HS256", - ) + return SimpleNamespace(SUPABASE_URL="https://supabase.example.test") -def make_token(*, user_id, expires_at: datetime) -> str: +def make_token(*, user_id, expires_at: datetime, signing_key) -> str: return jwt.encode( { "sub": str(user_id), @@ -32,16 +33,17 @@ def make_token(*, user_id, expires_at: datetime) -> str: "aud": "authenticated", "exp": expires_at, }, - "test-secret-that-is-at-least-32-bytes", - algorithm="HS256", + signing_key, + algorithm="ES256", ) -def test_verifies_valid_supabase_style_token(settings): +def test_verifies_valid_supabase_style_token(settings, signing_key): user_id = uuid4() token = make_token( user_id=user_id, expires_at=datetime.now(UTC) + timedelta(minutes=5), + signing_key=signing_key, ) user = verify_jwt_token(token, settings) @@ -50,16 +52,17 @@ def test_verifies_valid_supabase_style_token(settings): assert user.email == "delegate@example.test" -def test_rejects_expired_token(settings): +def test_rejects_expired_token(settings, signing_key): token = make_token( user_id=uuid4(), expires_at=datetime.now(UTC) - timedelta(minutes=1), + signing_key=signing_key, ) with pytest.raises(TokenExpiredError): verify_jwt_token(token, settings) -def test_rejects_invalid_token(settings): +def test_rejects_invalid_token(settings, signing_key): with pytest.raises(TokenInvalidError): verify_jwt_token("not-a-jwt", settings) diff --git a/backend/app/tests/conftest.py b/backend/app/tests/conftest.py index 9fc51e1..3ca7485 100644 --- a/backend/app/tests/conftest.py +++ b/backend/app/tests/conftest.py @@ -1,10 +1,19 @@ # Environment configuration file for testing +import os from datetime import datetime from uuid import UUID import pytest +# `app.main` builds middleware at import time, which loads Settings before the +# OpenAPI test can run. These are non-secret placeholders; database access is +# mocked in unit tests and no application lifespan is started by that test. +os.environ.setdefault( + "DATABASE_URL", "postgresql+asyncpg://postgres:postgres@localhost:5432/webmun_test" +) +os.environ.setdefault("SUPABASE_URL", "https://supabase.example.test") + from app.session.engine import SessionEngine from app.session.enums import ( SessionRole, diff --git a/docker-compose.yml b/docker-compose.yml index 4fbd687..51401b9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,13 +3,17 @@ services: build: context: ./frontend dockerfile: Dockerfile - user: "${LOCAL_UID:-1000}:${LOCAL_GID:-1000}" env_file: - ./frontend/.env.local - network_mode: host + environment: + OPENAPI_URL: http://backend:8000/openapi.json + extra_hosts: + - "host.docker.internal:host-gateway" volumes: - ./frontend:/app - packages:/app/node_modules + ports: + - "5173:5173" depends_on: backend: condition: service_healthy @@ -40,8 +44,13 @@ services: dockerfile: Dockerfile env_file: - ./backend/.env - # Supabase CLI binds locally to the host. This local stack targets Linux. - network_mode: host + extra_hosts: + - "host.docker.internal:host-gateway" + ports: + - "8000:8000" + environment: + - SUPABASE_URL=http://host.docker.internal:54321 + - DATABASE_URL=postgresql+asyncpg://postgres:postgres@host.docker.internal:54322/postgres volumes: - ./backend:/app - backend_packages:/app/.venv diff --git a/frontend/src/schemas/types.gen.ts b/frontend/src/schemas/types.gen.ts index 571a06b..17e3a5c 100644 --- a/frontend/src/schemas/types.gen.ts +++ b/frontend/src/schemas/types.gen.ts @@ -1,7 +1,7 @@ // This file is auto-generated by @hey-api/openapi-ts export type ClientOptions = { - baseUrl: 'http://localhost:8000' | (string & {}); + baseUrl: 'http://backend:8000' | (string & {}); }; /** diff --git a/justfile b/justfile new file mode 100644 index 0000000..0865222 --- /dev/null +++ b/justfile @@ -0,0 +1,27 @@ +set default-list := true + +MKDOCS := "mkdocs" + +# start the dev environment +dev: + echo "Starting dev environment" + supabase start || npx supabase start + docker compose up --watch + +# stop dev environment +stop: + echo "Stopping dev environment" + docker compose down + supabase stop || npx supabase stop + +# serve documentation using mkdocs +docs-serve: + {{MKDOCS}} serve --config-file mkdocs.yml + +# build documentation using mkdocs +docs-build: + {{MKDOCS}} build --config-file mkdocs.yml + +# cleanup documentation +docs-clean: + rm -rf site/