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
16 changes: 16 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,22 @@ SKIP_DB_LOCK=false
# Loopback, link-local and cloud metadata endpoints stay blocked either way.
ALLOW_PRIVATE_DB_HOSTS=false

# ── Database connection pool (advanced) ──
# All optional; the defaults shown are what the app uses when these are unset.
# DB_POOL_PRE_PING adds a liveness round trip to the database before handing
# out every pooled connection. It is off by default because that cost lands on
# every request and pool recycling (DB_POOL_RECYCLE below) already retires idle
# connections. Turn it on if you see "connection was closed" errors from a proxy
# that drops idle links, or if you transaction-pool through pgbouncer with a
# server_idle_timeout under DB_POOL_RECYCLE — recycling alone then leaves a gap
# in which a connection can die while idle.
DB_POOL_PRE_PING=false
# Seconds before a pooled connection is retired and reopened. Keep below the
# idle timeout of your server or connection pooler.
DB_POOL_RECYCLE=300
DB_POOL_SIZE=5
DB_MAX_OVERFLOW=10

# ── Maintenance & Storage ──
ACTIVITY_LOG_RETENTION_DAYS=30
ACTIVITY_CLEANUP_ENABLED=true
Expand Down
11 changes: 11 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Shell scripts must keep LF endings even on Windows checkouts. Docker copies
# them into Linux images verbatim, and a CRLF shebang makes the kernel look for
# an interpreter named "/bin/sh\r", which fails with a confusing
# "no such file or directory" on a file that plainly exists.
*.sh text eol=lf
docker-entrypoint.sh text eol=lf

# Same reasoning for container and tooling files read by Linux.
DOCKERFILE* text eol=lf
Dockerfile* text eol=lf
*.conf text eol=lf
15 changes: 14 additions & 1 deletion backend/DOCKERFILE
Original file line number Diff line number Diff line change
Expand Up @@ -48,5 +48,18 @@ HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
# the schema to head before the app serves its first request.
ENTRYPOINT ["/app/backend/docker-entrypoint.sh"]

# Bind every address family. "0.0.0.0" is IPv4-only, but the compose network
# runs with enable_ipv6, so DNS for `backend` answers with both an A and an
# AAAA record. nginx uses both, and the AAAA half of its requests hit
# ECONNREFUSED because nothing listens there -- "upstream server temporarily
# disabled" in the error log, and a wasted connection attempt on the way to
# the IPv4 fallback.
#
# The empty host is deliberate and "::" is *not* a substitute: asyncio sets
# IPV6_V6ONLY on any AF_INET6 server socket, so binding "::" listens on IPv6
# **only** and refuses IPv4 -- trading one broken family for the other. An
# empty host makes asyncio open one socket per family, which is real
# dual-stack. Verified in-container: 0.0.0.0 -> IPv4 only, :: -> IPv6 only,
# "" -> both.
CMD ["python", "-m", "uvicorn", "backend.app.server:create_app", \
"--factory", "--host", "0.0.0.0", "--port", "4321", "--log-level", "warning"]
"--factory", "--host", "", "--port", "4321", "--log-level", "warning"]
44 changes: 30 additions & 14 deletions backend/app/controllers/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,31 @@
_JWKS_CLIENT: PyJWKClient | None = None


# ── Password hashing ────────────────────────────────────────────────────
# bcrypt is deliberately expensive -- roughly 750ms per call at the default
# cost factor on our container. That cost is the point, but it is CPU-bound
# and releases nothing, so calling it straight from an async handler parks the
# event loop for its whole duration: every other request being served by that
# worker stalls too, not just the one doing the hashing. Push it to the
# threadpool so only the caller waits.


async def hash_password(password: str) -> str:
"""Hash a password with a fresh salt, off the event loop."""
return await run_in_threadpool(
lambda: bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode(
"utf-8"
)
)


async def verify_password(password: str, hashed: str) -> bool:
"""Check a password against its bcrypt hash, off the event loop."""
return await run_in_threadpool(
lambda: bcrypt.checkpw(password.encode("utf-8"), hashed.encode("utf-8"))
)


def _get_jwks_client():
global _JWKS_CLIENT
if _JWKS_CLIENT is None:
Expand Down Expand Up @@ -99,8 +124,7 @@ async def login(email: EmailStr, password: str):
# Accounts without a password hash (Google/Supabase-only) cannot log in via password.
if not user_details.get("hashed_pass"):
raise HTTPException(status_code=401, detail="Invalid credentials")
user_bytes = password.encode()
if bcrypt.checkpw(user_bytes, user_details["hashed_pass"].encode("utf-8")):
if await verify_password(password, user_details["hashed_pass"]):
return create_jwt(str(user_details["_id"]), user_details["role"])
raise HTTPException(status_code=401, detail="Invalid credentials")

Expand Down Expand Up @@ -150,10 +174,7 @@ async def signup(user: UserSignup):
raise HTTPException(
status_code=400, detail="An account with this email already exists"
)
encoded_pass = user.password.encode("utf-8")
salt = bcrypt.gensalt()
hashed_pw = bcrypt.hashpw(encoded_pass, salt)
hashed_pw = hashed_pw.decode("utf-8")
hashed_pw = await hash_password(user.password)
user_in_db = UserInDB(email=user.email, hashed_pass=hashed_pw, role=Role.user)
try:
uid = await create_user(user_in_db)
Expand Down Expand Up @@ -289,12 +310,8 @@ async def change_password(user_id, old_password, new_password):
status_code=400,
detail="Social login accounts cannot change password here",
)
old_pass_enc = old_password.encode("utf-8")
new_pass_enc = new_password.encode("utf-8")
if bcrypt.checkpw(old_pass_enc, user_details["hashed_pass"].encode("utf-8")):
salt = bcrypt.gensalt()
new_hashed_pw = bcrypt.hashpw(new_pass_enc, salt)
user_details["hashed_pass"] = new_hashed_pw.decode("utf-8")
if await verify_password(old_password, user_details["hashed_pass"]):
user_details["hashed_pass"] = await hash_password(new_password)
await update_user(
user_id,
hashed_pass=user_details["hashed_pass"],
Expand Down Expand Up @@ -516,7 +533,6 @@ async def reset_password(token: str, new_password: str):
.values(consumed=True)
)

salt = bcrypt.gensalt()
new_hashed = bcrypt.hashpw(new_password.encode("utf-8"), salt).decode("utf-8")
new_hashed = await hash_password(new_password)
await update_user(user_id, hashed_pass=new_hashed)
return True
69 changes: 58 additions & 11 deletions backend/app/controllers/system.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import os
import logging
import time

import httpx
from fastapi import HTTPException

from backend.app import config as cfgmod
Expand All @@ -15,6 +17,61 @@
log = logging.getLogger(__name__)


# ── JWKS reachability probe ─────────────────────────────────────────────
# /api/health is polled by the container healthcheck every 10-30s. Probing
# Supabase on each of those calls means a third-party network round trip on a
# fixed timer forever, and it made the endpoint take 1.8-3.2s. Worse, the old
# code built an httpx.AsyncClient per call and never closed it, so every probe
# leaked its connection pool.
#
# Reachability is not something that changes between two polls seconds apart,
# so cache the answer briefly and share one client. A liveness probe should be
# cheap; the diagnostic value survives a short TTL.
_JWKS_PROBE_TTL_SECONDS = 60.0
_jwks_probe_cache: tuple[str, str, float] | None = None # (url, status, checked_at)
_jwks_http_client: httpx.AsyncClient | None = None


def _get_jwks_http_client() -> httpx.AsyncClient:
global _jwks_http_client
if _jwks_http_client is None or _jwks_http_client.is_closed:
_jwks_http_client = httpx.AsyncClient(timeout=5)
return _jwks_http_client


async def close_jwks_http_client() -> None:
"""Release the shared probe client on shutdown."""
global _jwks_http_client, _jwks_probe_cache
if _jwks_http_client is not None and not _jwks_http_client.is_closed:
await _jwks_http_client.aclose()
_jwks_http_client = None
_jwks_probe_cache = None


async def _probe_jwks(supabase_url: str) -> str:
"""Report whether the Supabase JWKS endpoint answers, cached for a short TTL."""
global _jwks_probe_cache

now = time.monotonic()
if _jwks_probe_cache is not None:
cached_url, cached_status, checked_at = _jwks_probe_cache
if cached_url == supabase_url and (now - checked_at) < _JWKS_PROBE_TTL_SECONDS:
return cached_status

jwks_url = f"{supabase_url}/auth/v1/.well-known/jwks.json"
try:
resp = await _get_jwks_http_client().get(jwks_url)
if resp.status_code == 200:
status = "reachable"
else:
status = f"unexpected_status:{resp.status_code}"
except Exception as e:
status = f"unreachable:{e.__class__.__name__}"

_jwks_probe_cache = (supabase_url, status, now)
return status


async def get_state(user_id, workspace_id, db_id, db, cfg, kb):
"""
Assemble the user's connection, configuration, onboarding, and knowledge state.
Expand Down Expand Up @@ -88,17 +145,7 @@ async def get_health(pg_status="unknown"):
jwks_status = "unchecked"
supabase_url = get_supabase_url()
if supabase_url:
try:
import httpx

jwks_url = f"{supabase_url}/auth/v1/.well-known/jwks.json"
resp = await httpx.AsyncClient(timeout=5).get(jwks_url)
if resp.status_code == 200:
jwks_status = "reachable"
else:
jwks_status = f"unexpected_status:{resp.status_code}"
except Exception as e:
jwks_status = f"unreachable:{e.__class__.__name__}"
jwks_status = await _probe_jwks(supabase_url)

return {
"status": "ok" if pg_status == "connected" else "degraded",
Expand Down
46 changes: 42 additions & 4 deletions backend/app/pgdatabase/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,23 @@
_session_factory = None


def _env_int(name: str, default: int) -> int:
raw = os.getenv(name)
if raw is None or not raw.strip():
return default
try:
return int(raw)
except ValueError:
return default


def _env_bool(name: str, default: bool) -> bool:
raw = os.getenv(name)
if raw is None or not raw.strip():
return default
return raw.strip().lower() in ("1", "true", "yes", "on")


def get_engine():
global _engine
if _engine is None:
Expand All @@ -25,12 +42,33 @@ def get_engine():
db_url = "postgresql+asyncpg://" + db_url[len("postgresql://") :]
elif db_url.startswith("postgres://"):
db_url = "postgresql+asyncpg://" + db_url[len("postgres://") :]
# ── Pool tuning ──
# pool_pre_ping issues a liveness round trip to the database *before
# handing out every pooled connection*. That is one extra round trip on
# every request that touches the database, and it is not amortised --
# it repeats even when the connection is demonstrably healthy and was
# used moments ago. Against a managed Postgres it roughly doubled the
# cost of a checkout in our measurements.
#
# What it buys is protection against a connection that died while idle
# in the pool. pool_recycle already closes that window from the other
# side by retiring connections on a timer, well inside any sane server
# or pooler idle timeout, so pre-ping is mostly re-checking what
# recycling has already handled. Default it off and keep recycling.
#
# Every value is env-tunable: a deployment that genuinely sees stale
# connections (an aggressive proxy, a flaky link) can set
# DB_POOL_PRE_PING=true without a code change, and a deployment with
# the database next door can raise the pool instead.
_engine = create_async_engine(
db_url,
pool_size=5,
max_overflow=10,
pool_recycle=300,
pool_pre_ping=True,
pool_size=_env_int("DB_POOL_SIZE", 5),
max_overflow=_env_int("DB_MAX_OVERFLOW", 10),
pool_recycle=_env_int("DB_POOL_RECYCLE", 300),
pool_pre_ping=_env_bool("DB_POOL_PRE_PING", False),
# Required for pgbouncer-style transaction pooling, which cannot
# carry server-side prepared statements across checkouts. Measured
# as costing nothing here (166ms vs 174ms per query), so it stays.
connect_args={"statement_cache_size": 0},
)
return _engine
Expand Down
4 changes: 4 additions & 0 deletions backend/app/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,10 @@ async def lifespan(app):
cleanup_task.cancel()
with suppress(asyncio.CancelledError):
await cleanup_task
from backend.app.controllers.system import close_jwks_http_client

with suppress(Exception):
await close_jwks_http_client()
await dispose_db()


Expand Down
Loading
Loading