Compare commits

..
2 Commits
Author SHA1 Message Date
admin 7838c49721 feat(auth): harden sessions + HA Ingress support
CI / backend (pytest + alembic) (push) Has been cancelled
CI / frontend (build) (push) Has been cancelled
- backend: settings SESSION_COOKIE_SECURE + TRUSTED_NETWORK_AUTO_AUTH,
  require_session uses secrets.compare_digest and respects trusted-network
  opt-in, main.py adds require_family_session middleware gating all /api/
  routes except auth/admin/email-vote-token paths
- docker-compose: pass SESSION_COOKIE_SECURE + TRUSTED_NETWORK_AUTO_AUTH
  through to backend + scheduler (fixes env-file changes not reaching runtime)
- frontend: Ingress path-prefix support (APP_BASE_PATH, BrowserRouter basename,
  vite base './'), Login redirect honors APP_BASE_PATH
- nginx: no-cache headers on root + /assets/
- docs: Home Assistant Ingress install/troubleshooting + plan file
- tests: test_auth expects 401 on no-session GET

Defaults: SESSION_COOKIE_SECURE=false, TRUSTED_NETWORK_AUTO_AUTH=true
(HA is the auth boundary; MealPlanner must not be port-forwarded directly).
2026-06-30 16:11:33 -07:00
admin 7f5757094e feat(meals): suggest complementary sides
CI / backend (pytest + alembic) (push) Has been cancelled
CI / frontend (build) (push) Has been cancelled
2026-06-29 14:59:30 -07:00
26 changed files with 442 additions and 47 deletions
+2
View File
@@ -33,3 +33,5 @@ SECRET_KEY=change-me-to-a-random-secret-key
# Auth # Auth
ADMIN_TOKEN=change-me-to-a-random-admin-token ADMIN_TOKEN=change-me-to-a-random-admin-token
SESSION_PASSWORD=change-me-to-the-family-shared-password SESSION_PASSWORD=change-me-to-the-family-shared-password
SESSION_COOKIE_SECURE=false
TRUSTED_NETWORK_AUTO_AUTH=true
+1 -1
View File
@@ -53,7 +53,7 @@ def login(payload: LoginRequest, db: Session = Depends(get_db)):
value=cookie_value, value=cookie_value,
max_age=SESSION_MAX_AGE, max_age=SESSION_MAX_AGE,
httponly=True, httponly=True,
secure=True, secure=settings.SESSION_COOKIE_SECURE,
samesite="lax", samesite="lax",
path="/", path="/",
) )
+13
View File
@@ -35,6 +35,7 @@ from app.config import settings
from app.database import get_db from app.database import get_db
from app.models import FamilyProfile, MealPlan, MealPlanItem, MealType, Recipe from app.models import FamilyProfile, MealPlan, MealPlanItem, MealType, Recipe
from app.security import require_session from app.security import require_session
from app.services.meal_pairings import components_with_suggested_sides
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -257,6 +258,10 @@ def synthesize_plan(
raw_picks = _parse_picks(raw or "") raw_picks = _parse_picks(raw or "")
valid_recipe_ids = {r["id"] for r in library} valid_recipe_ids = {r["id"] for r in library}
picks = _validate_picks(raw_picks, valid_recipe_ids) picks = _validate_picks(raw_picks, valid_recipe_ids)
recipe_by_id = {
str(r.id): r
for r in db.query(Recipe).filter(Recipe.id.in_([uuid.UUID(rid) for rid in valid_recipe_ids])).all()
}
logger.info( logger.info(
"LLM plan: prompt=%d chars, raw_picks=%d, valid_picks=%d", "LLM plan: prompt=%d chars, raw_picks=%d, valid_picks=%d",
len(payload.prompt), len(raw_picks), len(picks), len(payload.prompt), len(raw_picks), len(picks),
@@ -279,6 +284,10 @@ def synthesize_plan(
recipe_id=uuid.UUID(pick.recipe_id), recipe_id=uuid.UUID(pick.recipe_id),
day_of_week=pick.day_of_week, day_of_week=pick.day_of_week,
meal_type=MealType(pick.meal_type), meal_type=MealType(pick.meal_type),
components=components_with_suggested_sides(
recipe_by_id.get(pick.recipe_id),
pick.meal_type,
),
)) ))
db.flush() db.flush()
@@ -310,6 +319,10 @@ def synthesize_plan(
recipe_id=uuid.UUID(chosen["id"]), recipe_id=uuid.UUID(chosen["id"]),
day_of_week=day, day_of_week=day,
meal_type=MealType(mt), meal_type=MealType(mt),
components=components_with_suggested_sides(
recipe_by_id.get(chosen["id"]),
mt,
),
)) ))
used_recipe_ids.add(uuid.UUID(chosen["id"])) used_recipe_ids.add(uuid.UUID(chosen["id"]))
filled_count += 1 filled_count += 1
+6 -2
View File
@@ -30,6 +30,10 @@ admin_router = APIRouter(
public_router = APIRouter(prefix="/api/meal-plans", tags=["meal-plans"]) public_router = APIRouter(prefix="/api/meal-plans", tags=["meal-plans"])
def _components_payload(components: dict | None) -> dict:
return dict(components or {})
def _to_response(week_start, plan_id, items: List[MealPlanItem], result: GenerationResult) -> GenerationResponse: def _to_response(week_start, plan_id, items: List[MealPlanItem], result: GenerationResult) -> GenerationResponse:
item_payloads: List[GenerationItem] = [] item_payloads: List[GenerationItem] = []
score_by_recipe = {s.recipe_id: s for s in result.selected} score_by_recipe = {s.recipe_id: s for s in result.selected}
@@ -41,7 +45,7 @@ def _to_response(week_start, plan_id, items: List[MealPlanItem], result: Generat
day_of_week=it.day_of_week, day_of_week=it.day_of_week,
estimated_cost=it.estimated_cost or 0, estimated_cost=it.estimated_cost or 0,
score=scored.score if scored else 0.0, score=scored.score if scored else 0.0,
components={k: float(v) for k, v in (scored.components.items() if scored else [])}, components=_components_payload(it.components or (scored.components if scored else None)),
) )
) )
return GenerationResponse( return GenerationResponse(
@@ -135,7 +139,7 @@ def get_plan(plan_id: UUID, db: Session = Depends(get_db)) -> GenerationResponse
day_of_week=it.day_of_week, day_of_week=it.day_of_week,
estimated_cost=it.estimated_cost or 0, estimated_cost=it.estimated_cost or 0,
score=it.score or 0.0, score=it.score or 0.0,
components={k: float(v) for k, v in (it.components.items() if it.components else [])}, components=_components_payload(it.components),
) )
for it in items for it in items
], ],
+3
View File
@@ -19,6 +19,7 @@ from app.schemas import (
from app.security import require_session from app.security import require_session
from app.services import approval as approval_service from app.services import approval as approval_service
from app.services.feedback_analyzer import FeedbackAnalyzer from app.services.feedback_analyzer import FeedbackAnalyzer
from app.services.meal_pairings import components_with_suggested_sides
from uuid import UUID from uuid import UUID
from typing import List, Optional from typing import List, Optional
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
@@ -683,6 +684,7 @@ def generate_single_item(
day_of_week=day_of_week, day_of_week=day_of_week,
meal_type=MealType[meal_type.upper()], meal_type=MealType[meal_type.upper()],
approval_status=MealPlanItemStatus.pending, approval_status=MealPlanItemStatus.pending,
components=components_with_suggested_sides(recipe, meal_type),
) )
db.add(new_item) db.add(new_item)
db.commit() db.commit()
@@ -769,6 +771,7 @@ def fill_empty_slots(
day_of_week=day, day_of_week=day,
meal_type=MealType[mt.upper()], meal_type=MealType[mt.upper()],
approval_status=MealPlanItemStatus.pending, approval_status=MealPlanItemStatus.pending,
components=components_with_suggested_sides(recipe, mt),
) )
db.add(new_item) db.add(new_item)
try: try:
+2
View File
@@ -29,6 +29,8 @@ class Settings(BaseSettings):
# Auth (R1-B+D) # Auth (R1-B+D)
ADMIN_TOKEN: str = "" ADMIN_TOKEN: str = ""
SESSION_PASSWORD: str = "" SESSION_PASSWORD: str = ""
SESSION_COOKIE_SECURE: bool = True
TRUSTED_NETWORK_AUTO_AUTH: bool = False
ADMIN_EMAIL: str = "" ADMIN_EMAIL: str = ""
APP_BASE_URL: str = "http://localhost" APP_BASE_URL: str = "http://localhost"
+28 -1
View File
@@ -1,9 +1,11 @@
from fastapi import FastAPI, Depends from fastapi import FastAPI, Depends, HTTPException, Request
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import text from sqlalchemy import text
from app.database import get_db from app.database import get_db
from app.config import settings from app.config import settings
from app.security import require_session
import logging import logging
logging.basicConfig(level=settings.LOG_LEVEL) logging.basicConfig(level=settings.LOG_LEVEL)
@@ -19,6 +21,31 @@ app = FastAPI(
app.mount("/static", StaticFiles(directory="static"), name="static") app.mount("/static", StaticFiles(directory="static"), name="static")
def _requires_session(path: str, method: str) -> bool:
if method == "OPTIONS" or not path.startswith("/api/"):
return False
if path.startswith("/api/auth/") or path.startswith("/api/admin/"):
return False
# Email approval links carry their own signed, single-use token.
if path.startswith("/api/meals/vote/"):
return False
return True
@app.middleware("http")
async def require_family_session(request: Request, call_next):
if _requires_session(request.url.path, request.method):
try:
require_session(request)
except HTTPException as exc:
return JSONResponse(
status_code=exc.status_code,
content={"detail": exc.detail},
headers=getattr(exc, "headers", None),
)
return await call_next(request)
@app.get("/health") @app.get("/health")
def health_check(db: Session = Depends(get_db)): def health_check(db: Session = Depends(get_db)):
return {"status": "ok"} return {"status": "ok"}
+2 -2
View File
@@ -211,7 +211,7 @@ class MealPlanItemResponse(MealPlanItemBase):
denial_expires_at: Optional[datetime] = None denial_expires_at: Optional[datetime] = None
used_pantry_items: Optional[List[UUID]] = [] used_pantry_items: Optional[List[UUID]] = []
score: Optional[float] = None score: Optional[float] = None
components: Optional[Dict[str, float]] = None components: Optional[Dict[str, Any]] = None
created_at: Optional[datetime] = None created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None updated_at: Optional[datetime] = None
recipe: Optional[RecipeResponse] = None recipe: Optional[RecipeResponse] = None
@@ -428,4 +428,4 @@ class LLMPlanResponse(BaseModel):
picked_count: int picked_count: int
filled_count: int filled_count: int
failed_count: int failed_count: int
reasoning: Optional[str] = None reasoning: Optional[str] = None
+2 -2
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from datetime import date from datetime import date
from decimal import Decimal from decimal import Decimal
from typing import Dict, List, Optional from typing import Any, Dict, List, Optional
from uuid import UUID from uuid import UUID
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -27,7 +27,7 @@ class GenerationItem(BaseModel):
day_of_week: int day_of_week: int
estimated_cost: Decimal estimated_cost: Decimal
score: float score: float
components: Dict[str, float] components: Dict[str, Any]
class GenerationDebug(BaseModel): class GenerationDebug(BaseModel):
+22 -28
View File
@@ -1,17 +1,6 @@
""" """Auth dependencies for the MealPlanner backend."""
Auth dependencies for the MealPlanner backend.
Two flavors: import secrets
- ``require_admin`` — bearer token for ``/api/admin/*`` routes; token compared
to ``settings.ADMIN_TOKEN`` (must be set in env).
- ``require_session`` — auto-returns the first family_profile_id (no login
required). This app runs on a private home network so auth is disabled
for family-facing routes. Kept as a dependency so admin/token endpoints
can be re-enabled later by restoring cookie logic.
The per-voter approval-token flow on meal items is intentionally NOT covered
here — it has its own short-lived single-use tokens elsewhere.
"""
from fastapi import HTTPException, Request, status from fastapi import HTTPException, Request, status
from itsdangerous import TimestampSigner from itsdangerous import TimestampSigner
@@ -35,7 +24,7 @@ def require_admin(request: Request) -> None:
detail="Admin auth not configured", detail="Admin auth not configured",
) )
auth = request.headers.get(bearer_header, "") auth = request.headers.get(bearer_header, "")
if not auth.startswith("Bearer ") or auth[7:] != expected: if not auth.startswith("Bearer ") or not secrets.compare_digest(auth[7:], expected):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid admin token", detail="Invalid admin token",
@@ -52,13 +41,7 @@ def issue_session(family_profile_id: str) -> str:
def require_session(request: Request) -> str: def require_session(request: Request) -> str:
"""Auto-authenticate: return the first family_profile_id from the DB. """Require a signed session cookie, with explicit LAN auto-auth opt-in."""
No cookie or password needed — this app runs on a private home network.
If no FamilyProfile exists yet, return \"bootstrap\" so the app can
initialise itself on first run.
"""
# 1. Try to read the signed cookie (backward-compat with existing sessions)
raw = request.cookies.get(SESSION_COOKIE) raw = request.cookies.get(SESSION_COOKIE)
if raw: if raw:
try: try:
@@ -66,13 +49,24 @@ def require_session(request: Request) -> str:
_signer().unsign(raw.encode(), max_age=SESSION_MAX_AGE).decode() _signer().unsign(raw.encode(), max_age=SESSION_MAX_AGE).decode()
) )
except Exception: except Exception:
pass # fall through to auto-auth raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired session",
)
# 2. Auto-auth: grab the first family profile from the DB if not settings.TRUSTED_NETWORK_AUTO_AUTH:
db = next(get_db()) raise HTTPException(
profile = db.query(FamilyProfile).first() status_code=status.HTTP_401_UNAUTHORIZED,
if profile: detail="Session required",
return str(profile.id) )
db_gen = get_db()
db = next(db_gen)
try:
profile = db.query(FamilyProfile).first()
if profile:
return str(profile.id)
finally:
db_gen.close()
# 3. Bootstrap hatch — no profile yet, return a sentinel value
return "bootstrap" return "bootstrap"
+116
View File
@@ -0,0 +1,116 @@
from __future__ import annotations
from typing import Any, Mapping
from app.models import MealType
_COMPLETE_MEAL_TERMS = {
"bowl", "burger", "burrito", "casserole", "chili", "curry", "fried rice",
"lasagna", "noodle", "paella", "pasta", "pizza", "quesadilla", "ramen",
"rice bowl", "risotto", "salad", "sandwich", "soup", "spaghetti", "stew",
"stir fry", "stir-fry", "taco", "tacos", "wrap",
}
_CARB_TERMS = {
"bread", "bun", "couscous", "farro", "grain", "noodle", "orzo", "pasta",
"pita", "potato", "quinoa", "rice", "tortilla",
}
_VEG_TERMS = {
"asparagus", "beans", "broccoli", "brussels", "cabbage", "carrot",
"cauliflower", "corn", "greens", "kale", "pepper", "salad", "spinach",
"vegetable", "zucchini",
}
_PROTEIN_TERMS = {
"beef", "breast", "chicken", "chop", "cod", "cutlet", "fish", "pork",
"salmon", "shrimp", "steak", "tilapia", "tofu", "turkey",
}
_PAIRINGS_BY_CUISINE = {
"asian": ("sesame broccoli", "steamed jasmine rice"),
"chinese": ("garlic green beans", "steamed jasmine rice"),
"indian": ("roasted cauliflower", "basmati rice"),
"italian": ("garlicky green beans", "orzo or crusty bread"),
"mediterranean": ("cucumber tomato salad", "warm pita or couscous"),
"mexican": ("sauteed peppers and onions", "cilantro lime rice"),
"thai": ("cucumber salad", "steamed jasmine rice"),
}
_DEFAULT_PAIRING = ("roasted broccoli", "rice pilaf or roasted potatoes")
def components_with_suggested_sides(
recipe: Any,
meal_type: MealType | str,
base_components: Mapping[str, Any] | None = None,
) -> dict[str, Any]:
components = dict(base_components or {})
suggestion = suggest_sides_for_recipe(recipe, meal_type)
if suggestion:
components["suggested_sides"] = suggestion
return components
def suggest_sides_for_recipe(recipe: Any, meal_type: MealType | str) -> dict[str, Any] | None:
meal_value = meal_type.value if isinstance(meal_type, MealType) else str(meal_type).lower()
if meal_value == MealType.BREAKFAST.value:
return None
explicit_sides = [
side.get("name")
for side in (getattr(recipe, "side_dishes", None) or [])
if isinstance(side, dict) and side.get("name")
]
if explicit_sides:
return {
"needed": True,
"items": explicit_sides[:2],
"note": "Use the recipe's recommended side dish pairing.",
}
text = _recipe_text(recipe)
if not _looks_like_simple_protein(recipe, text):
return None
vegetable, carb = _pairing_for_cuisine(getattr(recipe, "cuisine_tags", None) or [])
return {
"needed": True,
"vegetable": vegetable,
"carb": carb,
"note": "Simple protein entree; add a vegetable and carb to make it a complete meal.",
}
def _recipe_text(recipe: Any) -> str:
parts = [
getattr(recipe, "name", "") or "",
getattr(recipe, "protein_type", "") or "",
" ".join(getattr(recipe, "cuisine_tags", None) or []),
]
for ingredient in getattr(recipe, "ingredients", None) or []:
if isinstance(ingredient, dict):
parts.append(str(ingredient.get("name") or ingredient.get("ingredient") or ""))
return " ".join(parts).lower()
def _looks_like_simple_protein(recipe: Any, text: str) -> bool:
if any(term in text for term in _COMPLETE_MEAL_TERMS):
return False
has_protein = bool(getattr(recipe, "protein_type", None)) or any(
term in text for term in _PROTEIN_TERMS
)
if not has_protein:
return False
has_carb = any(term in text for term in _CARB_TERMS)
has_veg = any(term in text for term in _VEG_TERMS)
return not (has_carb and has_veg)
def _pairing_for_cuisine(tags: list[str]) -> tuple[str, str]:
lowered = {tag.lower() for tag in tags}
for key, pairing in _PAIRINGS_BY_CUISINE.items():
if key in lowered:
return pairing
return _DEFAULT_PAIRING
@@ -36,6 +36,26 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _suggested_sides_html(components: dict | None) -> str:
sides = (components or {}).get("suggested_sides")
if not isinstance(sides, dict):
return ""
items = sides.get("items")
if isinstance(items, list) and items:
text = "Pair with " + " + ".join(str(item) for item in items[:2])
else:
pair = [sides.get("vegetable"), sides.get("carb")]
pair = [str(part) for part in pair if part]
if not pair:
return ""
text = "Add " + " + ".join(pair)
return (
"<p style='font-size:13px;background:#ecfdf5;color:#065f46;"
"padding:8px;border-radius:6px;margin:8px 0'>"
f"{html.escape(text)}</p>"
)
def step_scrape(run: "WeeklyRun", db: "Session") -> None: def step_scrape(run: "WeeklyRun", db: "Session") -> None:
if run.scraped_at is not None: if run.scraped_at is not None:
logger.info("step_scrape: already done for %s, skipping", run.week_start_date) logger.info("step_scrape: already done for %s, skipping", run.week_start_date)
@@ -273,6 +293,7 @@ def step_email(run: "WeeklyRun", db: "Session") -> None:
f"<p style='font-size:13px;color:#888'>Est. ~${est_cost_per_serving:.2f}/serving</p>" f"<p style='font-size:13px;color:#888'>Est. ~${est_cost_per_serving:.2f}/serving</p>"
if est_cost_total > 0 else "" if est_cost_total > 0 else ""
) )
sides_block = _suggested_sides_html(item.components)
item_html_parts.append( item_html_parts.append(
f'<div style="border:1px solid #e5e7eb;border-radius:8px;padding:16px;margin-bottom:12px">' f'<div style="border:1px solid #e5e7eb;border-radius:8px;padding:16px;margin-bottom:12px">'
@@ -280,6 +301,7 @@ def step_email(run: "WeeklyRun", db: "Session") -> None:
f'<h3 style="margin:8px 0 4px">{recipe_name}</h3>' f'<h3 style="margin:8px 0 4px">{recipe_name}</h3>'
f'{ing_block}' f'{ing_block}'
f'{instructions_block}' f'{instructions_block}'
f'{sides_block}'
f'{cost_block}' f'{cost_block}'
f'<div style="margin-top:8px;display:flex;flex-wrap:wrap;gap:6px">' f'<div style="margin-top:8px;display:flex;flex-wrap:wrap;gap:6px">'
f'<a href="{vote_url}&amp;scope=approve" style="display:inline-block;padding:8px 14px;' f'<a href="{vote_url}&amp;scope=approve" style="display:inline-block;padding:8px 14px;'
+8 -1
View File
@@ -29,6 +29,7 @@ from app.services.planner.filter import filter_recipes
from app.services.planner.score import score_recipes from app.services.planner.score import score_recipes
from app.services.planner.select import select_set from app.services.planner.select import select_set
from app.services.planner.types import GenerationResult from app.services.planner.types import GenerationResult
from app.services.meal_pairings import components_with_suggested_sides
def _load_match_index(db: Session) -> Dict[UUID, List[dict]]: def _load_match_index(db: Session) -> Dict[UUID, List[dict]]:
@@ -234,9 +235,11 @@ def generate_meal_plan(
db.flush() db.flush()
_dinner_days = [1, 3, 5] # Mon, Wed, Fri — spread across the week _dinner_days = [1, 3, 5] # Mon, Wed, Fri — spread across the week
recipe_by_id = {recipe.id: recipe for recipe in recipes}
for index, scored_recipe in enumerate(chosen): for index, scored_recipe in enumerate(chosen):
day = _dinner_days[index] if index < len(_dinner_days) else index + 1 day = _dinner_days[index] if index < len(_dinner_days) else index + 1
meal_type = MealType.DINNER meal_type = MealType.DINNER
recipe = recipe_by_id.get(scored_recipe.recipe_id)
item = MealPlanItem( item = MealPlanItem(
meal_plan_id=plan.id, meal_plan_id=plan.id,
recipe_id=scored_recipe.recipe_id, recipe_id=scored_recipe.recipe_id,
@@ -245,7 +248,11 @@ def generate_meal_plan(
approval_status=MealPlanItemStatus.pending, approval_status=MealPlanItemStatus.pending,
estimated_cost=scored_recipe.cost.total_cost, estimated_cost=scored_recipe.cost.total_cost,
score=scored_recipe.score, score=scored_recipe.score,
components=scored_recipe.components, components=components_with_suggested_sides(
recipe,
meal_type,
scored_recipe.components,
),
) )
db.add(item) db.add(item)
+5 -4
View File
@@ -19,6 +19,7 @@ import pytest
os.environ.setdefault("ADMIN_TOKEN", "test-admin-token") os.environ.setdefault("ADMIN_TOKEN", "test-admin-token")
os.environ.setdefault("SESSION_PASSWORD", "test-family-password") os.environ.setdefault("SESSION_PASSWORD", "test-family-password")
os.environ.setdefault("SECRET_KEY", "test-secret-key-do-not-use-in-prod") os.environ.setdefault("SECRET_KEY", "test-secret-key-do-not-use-in-prod")
os.environ.setdefault("SESSION_COOKIE_SECURE", "false")
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
@@ -27,6 +28,7 @@ def _reload_settings(monkeypatch):
monkeypatch.setenv("ADMIN_TOKEN", "test-admin-token") monkeypatch.setenv("ADMIN_TOKEN", "test-admin-token")
monkeypatch.setenv("SESSION_PASSWORD", "test-family-password") monkeypatch.setenv("SESSION_PASSWORD", "test-family-password")
monkeypatch.setenv("SECRET_KEY", "test-secret-key-do-not-use-in-prod") monkeypatch.setenv("SECRET_KEY", "test-secret-key-do-not-use-in-prod")
monkeypatch.setenv("SESSION_COOKIE_SECURE", "false")
# Re-instantiate the singleton so dependents pick up env. # Re-instantiate the singleton so dependents pick up env.
from app import config as app_config from app import config as app_config
@@ -76,11 +78,10 @@ def test_session_required_for_mutation(client):
@pytest.mark.requires_postgres @pytest.mark.requires_postgres
def test_session_open_for_reads(client): def test_session_required_for_reads(client):
"""GET /api/profile is NOT auth-gated (reads stay open).""" """GET /api/profile requires a session for non-LAN exposure."""
r = client.get("/api/profile") r = client.get("/api/profile")
# Either 200 (profile exists) or 404 (no profile yet) — never 401. assert r.status_code == 401, r.text
assert r.status_code in (200, 404), r.text
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+56
View File
@@ -0,0 +1,56 @@
from types import SimpleNamespace
from app.models import MealType
from app.services.meal_pairings import components_with_suggested_sides, suggest_sides_for_recipe
def _recipe(**kwargs):
defaults = {
"name": "Grilled Chicken Breast",
"protein_type": "chicken",
"cuisine_tags": [],
"ingredients": [],
"side_dishes": [],
}
defaults.update(kwargs)
return SimpleNamespace(**defaults)
def test_simple_protein_gets_vegetable_and_carb_pairing():
sides = suggest_sides_for_recipe(_recipe(), MealType.DINNER)
assert sides is not None
assert sides["vegetable"] == "roasted broccoli"
assert sides["carb"] == "rice pilaf or roasted potatoes"
def test_complete_meal_does_not_get_extra_pairing():
recipe = _recipe(name="Chicken Pasta Bake", protein_type="chicken")
assert suggest_sides_for_recipe(recipe, MealType.DINNER) is None
def test_breakfast_does_not_get_side_pairing():
recipe = _recipe(name="Turkey Sausage", protein_type="turkey")
assert suggest_sides_for_recipe(recipe, MealType.BREAKFAST) is None
def test_recipe_side_dishes_are_used_when_present():
recipe = _recipe(side_dishes=[{"name": "green salad"}, {"name": "garlic bread"}])
sides = suggest_sides_for_recipe(recipe, "dinner")
assert sides is not None
assert sides["items"] == ["green salad", "garlic bread"]
def test_components_preserve_existing_scores():
components = components_with_suggested_sides(
_recipe(),
MealType.DINNER,
{"savings": 0.25},
)
assert components["savings"] == 0.25
assert components["suggested_sides"]["needed"] is True
+4
View File
@@ -25,6 +25,8 @@ services:
- SECRET_KEY=${SECRET_KEY} - SECRET_KEY=${SECRET_KEY}
- ADMIN_TOKEN=${ADMIN_TOKEN} - ADMIN_TOKEN=${ADMIN_TOKEN}
- SESSION_PASSWORD=${SESSION_PASSWORD} - SESSION_PASSWORD=${SESSION_PASSWORD}
- SESSION_COOKIE_SECURE=${SESSION_COOKIE_SECURE:-false}
- TRUSTED_NETWORK_AUTO_AUTH=${TRUSTED_NETWORK_AUTO_AUTH:-true}
- EMAIL_BACKEND=${EMAIL_BACKEND:-console} - EMAIL_BACKEND=${EMAIL_BACKEND:-console}
- ADMIN_EMAIL=${ADMIN_EMAIL:-} - ADMIN_EMAIL=${ADMIN_EMAIL:-}
- APP_BASE_URL=${APP_BASE_URL:-http://localhost} - APP_BASE_URL=${APP_BASE_URL:-http://localhost}
@@ -63,6 +65,8 @@ services:
- SECRET_KEY=${SECRET_KEY} - SECRET_KEY=${SECRET_KEY}
- ADMIN_TOKEN=${ADMIN_TOKEN} - ADMIN_TOKEN=${ADMIN_TOKEN}
- SESSION_PASSWORD=${SESSION_PASSWORD} - SESSION_PASSWORD=${SESSION_PASSWORD}
- SESSION_COOKIE_SECURE=${SESSION_COOKIE_SECURE:-false}
- TRUSTED_NETWORK_AUTO_AUTH=${TRUSTED_NETWORK_AUTO_AUTH:-true}
- EMAIL_BACKEND=${EMAIL_BACKEND:-console} - EMAIL_BACKEND=${EMAIL_BACKEND:-console}
- ADMIN_EMAIL=${ADMIN_EMAIL:-} - ADMIN_EMAIL=${ADMIN_EMAIL:-}
- APP_BASE_URL=${APP_BASE_URL:-http://localhost} - APP_BASE_URL=${APP_BASE_URL:-http://localhost}
+37
View File
@@ -0,0 +1,37 @@
# Home Assistant Ingress
MealPlanner can be exposed through Home Assistant by installing the `mealplanner-ingress` add-on. The add-on is an authenticated Ingress proxy to the existing MealPlanner Docker deployment; it does not run the database, backend, or frontend itself.
## Install
- In Home Assistant, go to Settings -> Add-ons -> Add-on Store -> Repositories.
- Add this repository URL: `https://git.research.bike/admin/Meal-Planner.git`.
- Install `MealPlanner Ingress` from the add-on store.
- Set `upstream_url` to the LAN URL for the existing MealPlanner nginx service, for example `http://192.168.1.54:8082`.
- Start the add-on and open the `MealPlanner` sidebar item.
## Required MealPlanner Env
Set these in MealPlanner's `.env` before exposing it through Home Assistant:
```env
ADMIN_TOKEN=<random-admin-token>
SESSION_PASSWORD=<family-shared-password>
SECRET_KEY=<random-secret>
SESSION_COOKIE_SECURE=false
TRUSTED_NETWORK_AUTO_AUTH=true
```
Home Assistant is the authentication boundary in this setup. `TRUSTED_NETWORK_AUTO_AUTH=true` removes the extra MealPlanner password prompt, so do not port-forward MealPlanner directly.
## Network Model
- Public Internet -> Home Assistant auth/MFA -> Ingress -> MealPlanner LAN URL.
- Do not port-forward MealPlanner directly.
- Keep the existing MealPlanner compose stack bound to the LAN only.
## Troubleshooting
- `could not read Username`: the Git repository is not anonymously cloneable from Home Assistant. Make the repository public, or use a separate public add-on repository.
- `not a valid app repository`: Home Assistant cloned the repository, but did not find valid add-on metadata. Confirm `repository.yaml` exists at the repository root and `mealplanner-ingress/config.yaml` exists on the default branch.
- Short/clipped display: do not use an embedded WebURL card for this app. Use the `MealPlanner Ingress` add-on sidebar item so Home Assistant proxies the full UI.
+2 -1
View File
@@ -6,6 +6,7 @@ import { OnboardingTour, useOnboarding } from './components/OnboardingTour'
import { showApiError } from './lib/toast' import { showApiError } from './lib/toast'
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts' import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'
import { requestFocusSearch } from './hooks/useFocusSearch' import { requestFocusSearch } from './hooks/useFocusSearch'
import { APP_BASE_PATH } from './api'
import Dashboard from './pages/Dashboard' import Dashboard from './pages/Dashboard'
import MealDetail from './pages/MealDetail' import MealDetail from './pages/MealDetail'
import Pantry from './pages/Pantry' import Pantry from './pages/Pantry'
@@ -81,7 +82,7 @@ function App() {
return ( return (
<ErrorBoundary> <ErrorBoundary>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<BrowserRouter> <BrowserRouter basename={APP_BASE_PATH || undefined}>
<GlobalShortcuts /> <GlobalShortcuts />
<div className="min-h-screen bg-surface-50"> <div className="min-h-screen bg-surface-50">
<Navigation /> <Navigation />
+10 -1
View File
@@ -1,6 +1,15 @@
import axios from 'axios' import axios from 'axios'
const API_BASE = import.meta.env.VITE_API_URL || '/api' export function getIngressBasePath() {
const parts = window.location.pathname.split('/').filter(Boolean)
if (parts[0] === 'api' && parts[1] === 'hassio_ingress' && parts[2]) {
return `/${parts.slice(0, 3).join('/')}`
}
return ''
}
export const APP_BASE_PATH = getIngressBasePath()
const API_BASE = import.meta.env.VITE_API_URL || `${APP_BASE_PATH}/api`
const api = axios.create({ const api = axios.create({
baseURL: API_BASE, baseURL: API_BASE,
+20 -1
View File
@@ -21,7 +21,7 @@ import {
type DroppableStateSnapshot, type DroppableStateSnapshot,
} from '@hello-pangea/dnd' } from '@hello-pangea/dnd'
import { mealPlannerApi } from '../api' import { mealPlannerApi } from '../api'
import type { MealPlan, MealPlanItem } from '../types' import type { MealPlan, MealPlanItem, SuggestedSides } from '../types'
import { Badge } from '../components/ui/Badge' import { Badge } from '../components/ui/Badge'
import { Card, CardBody, CardHeader } from '../components/ui/Card' import { Card, CardBody, CardHeader } from '../components/ui/Card'
import { SkeletonCard, Skeleton } from '../components/ui/Skeleton' import { SkeletonCard, Skeleton } from '../components/ui/Skeleton'
@@ -33,6 +33,18 @@ const DAY_NAMES = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
const FULL_DAY_NAMES = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'] const FULL_DAY_NAMES = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']
const MEAL_TYPES = ['breakfast', 'lunch', 'dinner'] as const const MEAL_TYPES = ['breakfast', 'lunch', 'dinner'] as const
function getSuggestedSides(item: MealPlanItem): SuggestedSides | null {
const value = item.components?.suggested_sides
if (!value || typeof value !== 'object') return null
return value as SuggestedSides
}
function formatSuggestedSides(sides: SuggestedSides): string | null {
if (sides.items?.length) return `Pair with ${sides.items.join(' + ')}`
const pair = [sides.vegetable, sides.carb].filter(Boolean).join(' + ')
return pair ? `Add ${pair}` : null
}
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* MealCard (draggable) */ /* MealCard (draggable) */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
@@ -55,6 +67,8 @@ function MealCard({ item, dragHandleProps, isDragging, onApprove: _onApprove, on
item.approval_status === 'denied' ? 'danger' : item.approval_status === 'denied' ? 'danger' :
item.approval_status === 'swapped' ? 'warning' : item.approval_status === 'swapped' ? 'warning' :
'neutral' 'neutral'
const suggestedSides = getSuggestedSides(item)
const sideText = suggestedSides ? formatSuggestedSides(suggestedSides) : null
return ( return (
<div className={`relative group block bg-surface-0 rounded-xl border overflow-hidden hover:shadow-md hover:border-primary-200 transition-all duration-200 ${isDragging ? 'shadow-lg border-primary-400 ring-2 ring-primary-200' : 'border-surface-200'}`}> <div className={`relative group block bg-surface-0 rounded-xl border overflow-hidden hover:shadow-md hover:border-primary-200 transition-all duration-200 ${isDragging ? 'shadow-lg border-primary-400 ring-2 ring-primary-200' : 'border-surface-200'}`}>
@@ -100,6 +114,11 @@ function MealCard({ item, dragHandleProps, isDragging, onApprove: _onApprove, on
{totalTime > 0 && `${totalTime} min · `} {totalTime > 0 && `${totalTime} min · `}
{item.recipe?.servings} servings {item.recipe?.servings} servings
</p> </p>
{sideText && (
<p className="mt-1 rounded-lg bg-primary-50 px-2 py-1 text-[11px] leading-snug text-primary-800">
{sideText}
</p>
)}
<div className="flex items-center gap-1.5 mt-1"> <div className="flex items-center gap-1.5 mt-1">
<Badge <Badge
variant={statusVariant} variant={statusVariant}
+2 -2
View File
@@ -1,6 +1,6 @@
import { useState } from 'react' import { useState } from 'react'
import { Lock, ArrowRight } from 'lucide-react' import { Lock, ArrowRight } from 'lucide-react'
import { mealPlannerApi } from '../api' import { APP_BASE_PATH, mealPlannerApi } from '../api'
import { Button } from '../components/ui/Button' import { Button } from '../components/ui/Button'
import { Card, CardBody } from '../components/ui/Card' import { Card, CardBody } from '../components/ui/Card'
import { Input } from '../components/ui/Input' import { Input } from '../components/ui/Input'
@@ -16,7 +16,7 @@ export default function Login() {
setLoading(true) setLoading(true)
try { try {
await mealPlannerApi.auth.login(password) await mealPlannerApi.auth.login(password)
window.location.href = '/' window.location.href = `${APP_BASE_PATH}/`
} catch { } catch {
setError('Incorrect password. Try again.') setError('Incorrect password. Try again.')
} finally { } finally {
+29 -1
View File
@@ -3,7 +3,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useParams, Link } from 'react-router-dom' import { useParams, Link } from 'react-router-dom'
import { Clock, Users, ChefHat, ArrowLeft, Printer, Star, AlertTriangle, MessageSquare } from 'lucide-react' import { Clock, Users, ChefHat, ArrowLeft, Printer, Star, AlertTriangle, MessageSquare } from 'lucide-react'
import { mealPlannerApi } from '../api' import { mealPlannerApi } from '../api'
import type { MealPlanItem, Feedback } from '../types' import type { MealPlanItem, Feedback, SuggestedSides } from '../types'
import { Button } from '../components/ui/Button' import { Button } from '../components/ui/Button'
import { Badge } from '../components/ui/Badge' import { Badge } from '../components/ui/Badge'
import { Card, CardBody, CardHeader } from '../components/ui/Card' import { Card, CardBody, CardHeader } from '../components/ui/Card'
@@ -22,6 +22,18 @@ const DENIAL_REASONS = [
{ value: 'other', label: 'Other' }, { value: 'other', label: 'Other' },
] ]
function getSuggestedSides(item: MealPlanItem): SuggestedSides | null {
const value = item.components?.suggested_sides
if (!value || typeof value !== 'object') return null
return value as SuggestedSides
}
function formatSuggestedSides(sides: SuggestedSides): string | null {
if (sides.items?.length) return `Pair with ${sides.items.join(' + ')}.`
const pair = [sides.vegetable, sides.carb].filter(Boolean).join(' and ')
return pair ? `Add ${pair}.` : null
}
function StarRating({ value, onChange }: { value: number; onChange: (n: number) => void }) { function StarRating({ value, onChange }: { value: number; onChange: (n: number) => void }) {
return ( return (
<div className="flex gap-1"> <div className="flex gap-1">
@@ -158,6 +170,8 @@ export default function MealDetail() {
const totalTime = recipe.total_time_minutes ?? const totalTime = recipe.total_time_minutes ??
(recipe.prep_time_minutes || 0) + (recipe.cook_time_minutes || 0) (recipe.prep_time_minutes || 0) + (recipe.cook_time_minutes || 0)
const suggestedSides = getSuggestedSides(item)
const sideText = suggestedSides ? formatSuggestedSides(suggestedSides) : null
return ( return (
<div className="space-y-6 max-w-5xl mx-auto"> <div className="space-y-6 max-w-5xl mx-auto">
@@ -240,6 +254,20 @@ export default function MealDetail() {
)} )}
</div> </div>
{sideText && (
<Card>
<CardHeader>
<h2 className="text-lg font-semibold text-surface-900">Complete the meal</h2>
</CardHeader>
<CardBody>
<p className="text-sm text-surface-700">{sideText}</p>
{suggestedSides?.note && (
<p className="mt-2 text-xs text-surface-500">{suggestedSides.note}</p>
)}
</CardBody>
</Card>
)}
{/* Ingredients */} {/* Ingredients */}
<Card> <Card>
<CardHeader> <CardHeader>
+17
View File
@@ -62,6 +62,21 @@ export interface Recipe {
created_at?: string created_at?: string
updated_at?: string updated_at?: string
total_time_minutes?: number total_time_minutes?: number
side_dishes?: SideDish[]
}
export interface SuggestedSides {
needed?: boolean
vegetable?: string
carb?: string
items?: string[]
note?: string
}
export interface SideDish {
name: string
ingredients?: Array<{ name: string; qty: number; unit?: string }>
prep_notes?: string
} }
export interface RecipeIngredient { export interface RecipeIngredient {
@@ -117,6 +132,8 @@ export interface MealPlanItem {
approval_status: 'pending' | 'approved' | 'denied' | 'swapped' approval_status: 'pending' | 'approved' | 'denied' | 'swapped'
denial_reason?: string denial_reason?: string
denial_details?: string denial_details?: string
score?: number | null
components?: Record<string, unknown> | null
estimated_cost?: number estimated_cost?: number
used_pantry_items: string[] used_pantry_items: string[]
recipe?: Recipe recipe?: Recipe
+1
View File
@@ -3,6 +3,7 @@ import react from '@vitejs/plugin-react'
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
base: './',
server: { server: {
port: 3000, port: 3000,
proxy: { proxy: {
+18
View File
@@ -0,0 +1,18 @@
# Home Assistant Ingress Add-on
## Goal
Expose MealPlanner through Home Assistant Ingress while keeping MealPlanner off the public Internet.
## Tasks
- [x] Add HA add-on metadata and nginx proxy wrapper -> Verify: `mealplanner-ingress/config.yaml`, `Dockerfile`, `run.sh` exist.
- [x] Make frontend path-prefix aware for Ingress -> Verify: Vite base, router basename, and API base derive from `/api/hassio_ingress/...`.
- [x] Re-enable session enforcement for family API routes -> Verify: missing session returns 401 unless `TRUSTED_NETWORK_AUTO_AUTH=true`.
- [x] Document install and env settings -> Verify: `docs/home-assistant-ingress.md` exists.
- [x] Run backend/frontend focused checks.
- [x] Push add-on repository metadata -> Verify: anonymous shallow clone contains `repository.yaml` and `mealplanner-ingress/config.yaml`.
## Done When
- [x] Add-on config validates enough to build in Home Assistant.
- [x] Frontend builds.
- [x] Backend auth tests/import checks pass.
- [x] Home Assistant accepts the repository.
+14
View File
@@ -34,6 +34,20 @@ http {
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate" always;
add_header Pragma "no-cache" always;
add_header Expires "0" always;
}
location /assets/ {
set $frontend_upstream http://frontend:80;
proxy_pass $frontend_upstream;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate" always;
add_header Pragma "no-cache" always;
add_header Expires "0" always;
} }
} }
} }