Skip to content
Draft
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
5 changes: 4 additions & 1 deletion backend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
&& rm -rf /var/lib/apt/lists/*

# Install Python dependencies
# Install Python dependencies (API image — no TensorFlow).
# TF lives only in the ml_service image; the API calls it over HTTP
# via ML_SERVICE_URL. When the env var is unset the in-process
# fallback still works if tensorflow happens to be installed.
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

Expand Down
81 changes: 81 additions & 0 deletions backend/app/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
"""
Centralized application settings via pydantic-settings.

All environment variables are declared once here. Other modules import
``settings`` (the singleton instance) rather than calling ``os.getenv``
directly.
"""

from __future__ import annotations

import base64
import json
from pathlib import Path

from pydantic import model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict


def _extract_jwt_role(jwt_token: str) -> str | None:
"""Best-effort JWT payload parse to read the Supabase key role claim."""
try:
parts = jwt_token.split(".")
if len(parts) < 2:
return None
payload = parts[1]
padding = "=" * (-len(payload) % 4)
decoded = base64.urlsafe_b64decode(
(payload + padding).encode("utf-8")
).decode("utf-8")
return json.loads(decoded).get("role")
except Exception:
return None


class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=str(Path(__file__).resolve().parent / ".env"),
env_file_encoding="utf-8",
extra="ignore",
)

# ---- Supabase ----
supabase_url: str
supabase_anon_key: str
supabase_service_key: str

# ---- Application ----
environment: str = "development"
cors_origins: str = ""
admin_secret: str = ""

# ---- ML service ----
ml_service_url: str = ""

# ---- Phase 3B serving controls ----
padly_group_neural_ranking_enabled: bool = False
padly_group_neural_kill_switch: bool = False
padly_stable_group_listing_writes_enabled: bool = False

# ---- Derived helpers ----
@property
def is_dev(self) -> bool:
return self.environment.lower() in ("development", "dev", "local")

# ---- Validators ----
@model_validator(mode="after")
def _validate_supabase_keys(self) -> "Settings":
if self.supabase_service_key == self.supabase_anon_key:
raise ValueError(
"Invalid configuration: SUPABASE_SERVICE_KEY matches SUPABASE_ANON_KEY"
)
role = _extract_jwt_role(self.supabase_service_key)
if role != "service_role":
raise ValueError(
f"Invalid SUPABASE_SERVICE_KEY role: expected 'service_role', "
f"got '{role or 'unknown'}'"
)
return self


settings = Settings()
57 changes: 11 additions & 46 deletions backend/app/db.py
Original file line number Diff line number Diff line change
@@ -1,54 +1,19 @@
from supabase import create_client, Client
from dotenv import load_dotenv
import os
from pathlib import Path
import base64
import json

# Load the .env file from the backend directory (one level up from app/)
env_path = Path(__file__).parent.parent / '.env'
load_dotenv(dotenv_path=env_path)

# Environment variables
SUPABASE_URL = os.getenv("SUPABASE_URL")
SUPABASE_ANON_KEY = os.getenv("SUPABASE_ANON_KEY")
SUPABASE_SERVICE_KEY = os.getenv("SUPABASE_SERVICE_KEY")
VERIFY_JWT = os.getenv("VERIFY_JWT", "false").lower() == "true"
SUPABASE_JWT_SECRET = os.getenv("SUPABASE_JWT_SECRET")
"""
Supabase client singletons.

Reads credentials from ``app.config.settings`` (which loads the ``.env``
file automatically via pydantic-settings). No direct ``os.getenv`` calls
are needed here.
"""

def _extract_jwt_role(jwt_token: str) -> str | None:
"""Best-effort JWT payload parse to validate expected Supabase key role."""
try:
parts = jwt_token.split('.')
if len(parts) < 2:
return None
payload = parts[1]
# JWT uses URL-safe base64 without padding.
padding = '=' * (-len(payload) % 4)
decoded = base64.urlsafe_b64decode((payload + padding).encode('utf-8')).decode('utf-8')
return json.loads(decoded).get('role')
except Exception:
return None

# Validation
if not SUPABASE_URL:
raise ValueError("Missing SUPABASE_URL in environment")
if not SUPABASE_ANON_KEY:
raise ValueError("Missing SUPABASE_ANON_KEY in environment")
if not SUPABASE_SERVICE_KEY:
raise ValueError("Missing SUPABASE_SERVICE_KEY in environment")
from supabase import create_client, Client

if SUPABASE_SERVICE_KEY == SUPABASE_ANON_KEY:
raise ValueError("Invalid configuration: SUPABASE_SERVICE_KEY matches SUPABASE_ANON_KEY")
from app.config import settings

service_role = _extract_jwt_role(SUPABASE_SERVICE_KEY)
if service_role != "service_role":
raise ValueError(
f"Invalid SUPABASE_SERVICE_KEY role: expected 'service_role', got '{service_role or 'unknown'}'"
)
SUPABASE_URL: str = settings.supabase_url
SUPABASE_ANON_KEY: str = settings.supabase_anon_key
SUPABASE_SERVICE_KEY: str = settings.supabase_service_key

# Create clients
# Service role client (admin operations, bypasses RLS)
supabase_admin: Client = create_client(SUPABASE_URL, SUPABASE_SERVICE_KEY)

Expand Down
5 changes: 3 additions & 2 deletions backend/app/dependencies/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@
Handles JWT token extraction from request headers
"""

import os
from fastapi import Header, HTTPException
from typing import Optional, Any

from app.config import settings


async def require_admin_key(
x_admin_secret: Optional[str] = Header(None)
Expand All @@ -20,7 +21,7 @@ async def require_admin_key(
Raises:
HTTPException 401: if header is missing or incorrect
"""
expected = os.getenv("ADMIN_SECRET")
expected = settings.admin_secret
if not expected:
raise HTTPException(
status_code=503,
Expand Down
23 changes: 11 additions & 12 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,10 @@
to find housing and compatible roommates.
"""

import os

from fastapi import FastAPI

_is_dev = os.getenv("ENVIRONMENT", "development").lower() in ("development", "dev", "local")
from fastapi.middleware.cors import CORSMiddleware

from app.config import settings
from app.routes import (
users_router,
listings_router,
Expand All @@ -21,20 +19,20 @@
matches_router,
recommendations_router,
interactions_router,
groups_router,
guest_interactions_router,
options_router,
roommate_intros_router,
)
from app.routes.roommate_intros import router as roommate_intros_router
from app.routes.groups import router as groups_router

# Initialize FastAPI application
app = FastAPI(
title="Padly API",
version="1.0.0",
description="Backend API for Padly - Housing and Roommate Matching Platform",
docs_url="/docs" if _is_dev else None,
redoc_url="/redoc" if _is_dev else None,
openapi_url="/openapi.json" if _is_dev else None,
docs_url="/docs" if settings.is_dev else None,
redoc_url="/redoc" if settings.is_dev else None,
openapi_url="/openapi.json" if settings.is_dev else None,
)

# Configure CORS (Starlette returns 400 on failed preflight — usually wrong Origin)
Expand All @@ -45,9 +43,10 @@
"https://padly.tech",
"https://www.padly.tech",
]
_extra = os.getenv("CORS_ORIGINS", "").strip()
if _extra:
_cors_origins.extend(o.strip() for o in _extra.split(",") if o.strip())
if settings.cors_origins.strip():
_cors_origins.extend(
o.strip() for o in settings.cors_origins.split(",") if o.strip()
)

app.add_middleware(
CORSMiddleware,
Expand Down
2 changes: 2 additions & 0 deletions backend/app/routes/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from .matches import router as matches_router
from .recommendations import router as recommendations_router
from .interactions import router as interactions_router
from .groups import router as groups_router
from .guest_interactions import router as guest_interactions_router
from .options import router as options_router
from .roommate_intros import router as roommate_intros_router
Expand All @@ -27,6 +28,7 @@
"matches_router",
"recommendations_router",
"interactions_router",
"groups_router",
"guest_interactions_router",
"options_router",
"roommate_intros_router",
Expand Down
Loading
Loading