Initial kitchen scaffold — Phase 1 kitchen port (build-verified 2026-07-11)

FastAPI backend (Python 3.11, MSSQL ODBC for SambaPOS, Azure DI OCR),
kitchen_db on central PG. React/TS/Vite frontend with navy sidebar layout.

Backend: auth.py (APP_SLUG=kitchen, SimpleNamespace — archive routes use
.kitchen_id/.is_admin without modification), main.py (51 migrations, scheduler,
internal router for KDS bookings feed), api/internal.py, full archive API
(31 routers: invoices, recipes, menus, sambapos, resos, newbook, disputes,
purchase_orders, etc.), models, migrations, OCR pipeline.
kitchen_id pinned to 1 (B1 — single hotel).

Frontend: AuthGate (app=kitchen, token shim for archive compat — B5b pending),
Layout (navy sidebar, 6 sections, Lucide icons, teal --app-primary),
App.tsx (Outlet pattern, UploadApp outside Layout), index.css (full :root block).
strict: false — archive components have type issues; build clean.

Note: 45 archive components call fetch('/api/...') without /kitchen/ prefix
(B5b). Runtime 404s; deferred until after initial testing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-12 12:15:39 +00:00
commit 8d688b459d
10003 changed files with 1928395 additions and 0 deletions

126
backend/auth.py Normal file
View file

@ -0,0 +1,126 @@
import os
import base64
import hashlib
import secrets
from types import SimpleNamespace
from typing import Optional
from fastapi import Depends, Header, HTTPException, Request, status
from jose import JWTError, jwt
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from database import get_db
CENTRAL_AUTH_SECRET = os.getenv("CENTRAL_AUTH_SECRET", "")
STACK_INTERNAL_SECRET = os.getenv("STACK_INTERNAL_SECRET", "")
JWT_ALGORITHM = "HS256"
APP_SLUG = os.getenv("APP_SLUG", "kitchen")
async def get_current_user(request: Request):
"""
Verify the stack hnf_session cookie.
Returns a SimpleNamespace so existing routers can use current_user.kitchen_id,
current_user.is_admin, etc. without modification. kitchen_id is pinned to 1
(single hotel remove when stripping multi-tenant scaffolding, see log B1).
"""
token = request.cookies.get("hnf_session")
if not token:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
try:
payload = jwt.decode(token, CENTRAL_AUTH_SECRET, algorithms=[JWT_ALGORITHM])
except JWTError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid session")
apps = payload.get("apps", [])
if APP_SLUG not in apps:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="No permission for this app")
prefix = f"{APP_SLUG}:"
raw_caps = payload.get("caps", [])
caps = [c[len(prefix):] for c in raw_caps if isinstance(c, str) and c.startswith(prefix)]
is_admin = payload.get("is_admin", False)
return SimpleNamespace(
id=0,
email=payload.get("sub", ""),
username=payload.get("sub", ""),
name=payload.get("name", ""),
display_name=payload.get("name", ""),
is_admin=is_admin,
is_active=True,
kitchen_id=1,
caps=caps,
role="admin" if is_admin else "user",
)
def has_cap(user, cap: str) -> bool:
return user.is_admin or cap in user.caps
def require_cap(cap: str):
async def checker(user=Depends(get_current_user)):
if not has_cap(user, cap):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Missing capability: {cap}",
)
return user
return checker
async def get_admin_user(user=Depends(get_current_user)):
if not user.is_admin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
return user
def verify_internal_secret(authorization: Optional[str] = Header(None)) -> None:
"""Verify STACK_INTERNAL_SECRET for inter-app calls (e.g. KDS bookings feed)."""
if not STACK_INTERNAL_SECRET:
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Internal secret not configured")
if not authorization or authorization != f"Bearer {STACK_INTERNAL_SECRET}":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid internal secret")
# ─── API KEY AUTH (public/external API) ──────────────────────────────────────
def generate_api_key() -> tuple[str, str, str]:
raw = secrets.token_bytes(32)
body = base64.urlsafe_b64encode(raw).decode().rstrip("=")
full_key = f"fk_{body}"
key_hash = hashlib.sha256(full_key.encode()).hexdigest()
key_prefix = full_key[:12] + "..."
return full_key, key_hash, key_prefix
async def verify_api_key(api_key: str, db: AsyncSession) -> Optional[dict]:
if not api_key or not api_key.startswith("fk_"):
return None
key_hash = hashlib.sha256(api_key.encode()).hexdigest()
result = await db.execute(
text("SELECT id, name, key_prefix, is_active FROM api_keys WHERE key_hash = :h"),
{"h": key_hash},
)
row = result.fetchone()
if not row or not row.is_active:
return None
await db.execute(text("UPDATE api_keys SET last_used_at = NOW() WHERE id = :id"), {"id": row.id})
await db.commit()
return {"id": row.id, "name": row.name, "key_prefix": row.key_prefix}
async def get_api_key_auth(
x_api_key: Optional[str] = Header(None, alias="X-API-Key"),
db: AsyncSession = Depends(get_db),
) -> dict:
if not x_api_key:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing X-API-Key header")
info = await verify_api_key(x_api_key, db)
if not info:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or revoked API key")
return info