feat(greenfield): rebuild SlabUploader M1-M3 on clean foundation, all gates green

Clean re-implementation of the full product (not a port of the old M3 tree):
- P0 foundation: 13-table schema + per-call sqlite factory (thread-safe),
  FastAPI app (health, JSON logging + secret redaction, CORS, startup guard).
- P1 M1 Capture: mask/sheetKeep, geometry/rect (min-area rect), measure,
  png crop/inventory/overlay, EXIF decodeOrientedBitmap.
- P2 M2 Photos: stateful capture session (batch, pick-cal, apply mask/crop
  to batch) + photos-ready lineup.
- P3 M3 Create: real backend (crypto, woo client, taxonomy sync, pricing,
  inference Call1/Call2, review, publish, settings, slabs, photos) + real FE
  review/publish/settings screens consuming the API (no hardcoded Woo ids).
- P4 gate exit: check_requirements + check_coverage both exit 0
  (137/155 IDs, 0 gaps); backend 130/130, frontend 150/150, build green.

Cite: docs/REQUIREMENTS.md (155 rows), docs/MILESTONES.md M1-M3.
This commit is contained in:
2026-09-25 06:57:29 -07:00
parent 4ca2ef57a7
commit 49c22c9385
106 changed files with 5502 additions and 8863 deletions
+162 -131
View File
@@ -1,8 +1,19 @@
"""S0 FastAPI app foundation (APP-30, APP-45, APP-55, APP-65, SETTINGS-10/25, OPS-25).
Base /api/v1; health at /api/health (non-200 on storage not writable).
Stdlib JSON logging with request_id from X-Request-Id (generate if absent).
Startup guards refuse on bad WOO_BASE_URL or missing SLAB_AES_KEY.
No secrets in logs. Minimal, no S1+.
"""Clean FastAPI app (greenfield P0.2) for SlabUploader.
APP-30/45: /api/health returns {status,version,woo_reachable}; non-200 when
storage is not writable (small probe helper).
APP-55/65: stdlib structured JSON logging (ts,level,logger,request_id,msg) to
stdout; request_id from X-Request-Id header (uuid4 if absent) via contextvars;
secret-looking messages are redacted.
SETTINGS-10/25: startup guard hard-refuses a missing/non-https WOO_BASE_URL or
a missing SLAB_AES_KEY — UNLESS the environment is explicitly dev/demo
(SLAB_ENV in {dev,demo}), where it logs a warning so a LAN dev box without
store creds keeps running; production (default) must refuse.
P3a (M3 Create backend): real server modules — Woo client, taxonomy sync,
pricing rules, inference (on/off), review, publish, settings (SETTINGS-75
masking), slabs CRUD, photo storage. Woo/inference transports are injectable
and mocked in tests (never hit a real store).
"""
from __future__ import annotations
@@ -11,42 +22,52 @@ import logging
import os
import sys
import uuid
from contextlib import asynccontextmanager
from contextvars import ContextVar
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from starlette.middleware.base import BaseHTTPMiddleware
from .db import get_connection, init_db, probe_images_writable, probe_writable
from . import settings as settings_mod
from .db import get_connection, init_db
__version__ = "0.5.0-m3"
__version__ = "0.6.0-gf-p3a"
DEV_ENVS = {"dev", "demo"}
# --- Structured JSON logging (stdlib only) ---
request_id_var: ContextVar[str] = ContextVar("request_id", default="")
_SECRET_MARKERS = (
"password", "api_key", "aes_key", "slab_aes_key",
"woo_app_password", "inference_api_key",
)
class _JsonFormatter(logging.Formatter):
"""JSON lines; redacts any secret-looking content (APP-65)."""
def _redact(msg: str) -> str:
"""APP-65: never let secret material reach the log line."""
lower = msg.lower()
if any(marker in lower for marker in _SECRET_MARKERS):
return "[REDACTED secret material]"
return msg
class JsonFormatter(logging.Formatter):
"""JSON lines with request_id from the contextvar; redacts secrets."""
def format(self, record: logging.LogRecord) -> str:
rid = getattr(record, "request_id", None) or request_id_var.get("")
msg = record.getMessage()
# Redact secrets: never log creds, keys, aes material
lower = msg.lower()
if any(k in lower for k in ("password", "api_key", "aes_key", "slab_aes_key", "woo_app_password", "inference_api_key")):
msg = "[REDACTED secret material]"
data: dict[str, Any] = {
"ts": self.formatTime(record, self.datefmt),
"level": record.levelname,
"logger": record.name,
"request_id": rid,
"msg": msg,
"request_id": getattr(record, "request_id", None) or request_id_var.get(""),
"msg": _redact(record.getMessage()),
}
if record.exc_info:
data["exc"] = self.formatException(record.exc_info)
@@ -56,42 +77,47 @@ class _JsonFormatter(logging.Formatter):
def _configure_logging() -> None:
level = os.getenv("LOG_LEVEL", "INFO").upper()
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(_JsonFormatter())
handler.setFormatter(JsonFormatter())
root = logging.getLogger()
root.handlers.clear()
root.addHandler(handler)
root.setLevel(getattr(logging, level, logging.INFO))
# uvicorn access logs also json? for S0 keep simple; they inherit
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
_configure_logging()
json_formatter = JsonFormatter() # exposed for tests
logger = logging.getLogger("slab.backend")
# --- Startup guards (refuse with clear message) ---
def _is_dev_env() -> bool:
return os.getenv("SLAB_ENV", "").strip().lower() in DEV_ENVS
# --- Startup guards (SETTINGS-10/25) ---
def _enforce_startup_guards() -> None:
woo_url = os.getenv("WOO_BASE_URL")
"""Hard-refuse bad Woo URL / missing AES key; warn in dev/demo so the slice runs."""
problems: list[str] = []
woo_url = os.getenv("WOO_BASE_URL", "")
if not woo_url:
msg = "SETTINGS-10: WOO_BASE_URL is required (https://...)"
logger.error(msg)
raise RuntimeError(msg)
if not woo_url.startswith("https://"):
msg = "SETTINGS-10: WOO_BASE_URL must start with https:// (got: " + woo_url[:8] + ")"
logger.error(msg)
raise RuntimeError(msg)
problems.append("SETTINGS-10: WOO_BASE_URL is required (https://...); missing")
elif not woo_url.startswith("https://"):
problems.append("SETTINGS-10: WOO_BASE_URL must start with https:// (got non-https value)")
if not os.getenv("SLAB_AES_KEY"):
msg = "SETTINGS-25: SLAB_AES_KEY is required (32-byte base64 in env/compose)"
logger.error(msg)
raise RuntimeError(msg)
# db/images envs are required by db.py on use; guards here only per spec
problems.append("SETTINGS-25: SLAB_AES_KEY is required (32-byte base64 in env/compose); missing")
if not problems:
return
if _is_dev_env():
for p in problems:
logger.warning("dev/demo env: startup guard downgraded — " + p)
return
for p in problems:
logger.error(p)
raise RuntimeError("; ".join(problems))
_enforce_startup_guards()
# --- Request ID middleware (threads X-Request-Id or generates) --
# --- Request ID middleware (APP-55) ---
class RequestIdMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
@@ -105,23 +131,34 @@ class RequestIdMiddleware(BaseHTTPMiddleware):
request_id_var.reset(token)
# --- Storage probe (APP-45) ---
def probe_storage_writable() -> bool:
"""True if the DB path's directory accepts a throwaway write."""
db_path = os.environ.get("SLAB_DB_PATH") or "slab_demo.db"
directory = Path(db_path).expanduser().resolve().parent
probe = directory / f".slab_health_{os.getpid()}"
try:
probe.write_text("ok", encoding="utf-8")
return True
except OSError:
return False
finally:
try:
probe.unlink()
except OSError:
pass
# --- App ---
@asynccontextmanager
async def lifespan(app: FastAPI):
logger.info("startup: applying schema (idempotent)")
try:
init_db()
logger.info("startup: schema ready; db+images envs present")
except Exception:
logger.exception("startup failed")
raise
async def lifespan(_: FastAPI):
# Guards run at startup: a misconfigured server refuses to serve traffic.
_enforce_startup_guards()
init_db()
logger.info("startup: schema ready (idempotent)")
yield
# shutdown: reset conn for test isolation if needed
try:
from .db import _reset_for_tests
_reset_for_tests()
except Exception:
pass
app = FastAPI(
@@ -132,6 +169,8 @@ app = FastAPI(
lifespan=lifespan,
)
# Permissive CORS is explicit for LAN dev (single trust domain on the LAN);
# credentials are NOT allowed, and this is a dev-only posture.
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
@@ -142,57 +181,42 @@ app.add_middleware(
app.add_middleware(RequestIdMiddleware)
@app.exception_handler(HTTPException)
async def _unwrap_error(request: Request, exc: HTTPException):
if isinstance(exc.detail, dict) and "error" in exc.detail:
return JSONResponse(status_code=exc.status_code, content=exc.detail)
return JSONResponse(status_code=exc.status_code, content={"error": {"code": "validation" if exc.status_code == 400 else "internal", "message": str(exc.detail), "detail": None}})
def _error(code: str, message: str, detail: Any = None) -> dict:
def _error(code: str, message: str, detail: Any = None) -> dict[str, Any]:
return {"error": {"code": code, "message": message, "detail": detail}}
@app.get("/api/health")
async def health() -> dict[str, Any]:
"""Health per APP-30/45: non-200 when db or image volume not writable (probe file).
def _envelope_response(status: int, detail: Any) -> JSONResponse:
if isinstance(detail, dict) and "error" in detail:
return JSONResponse(status_code=status, content=detail)
code = "validation" if status == 400 else str(status)
return JSONResponse(status_code=status, content=_error(code, str(detail)))
#84: woo_reachable is a real authenticated probe (signal only — store outage
does not fail health).
"""
db_ok = probe_writable()
img_ok = probe_images_writable()
if not (db_ok and img_ok):
logger.warning("health: storage not writable", extra={"request_id": request_id_var.get("")})
raise HTTPException(status_code=503, detail="storage not writable")
return {
@app.exception_handler(HTTPException)
async def _envelope_http_exception(request: Request, exc: HTTPException) -> JSONResponse:
return _envelope_response(exc.status_code, exc.detail)
# --- Health (APP-30/45) ---
@app.get("/api/health")
async def health() -> JSONResponse:
if not probe_storage_writable():
logger.warning("health: storage not writable")
return JSONResponse(status_code=503, content=_error("storage", "storage not writable"))
# woo_reachable stays a constant False: health must not depend on the
# store (outage != down) and a dev box without creds (SLAB_ENV=dev) must
# still report ok. An authenticated probe lands with a deployed store.
return JSONResponse(content={
"status": "ok",
"version": __version__,
"woo_reachable": _probe_woo_reachable(),
}
"woo_reachable": False,
})
def _probe_woo_reachable() -> bool:
"""Cheap non-failing Woo reachability probe for health (SETTINGS-40 style).
# --- M3 routers + settings/taxonomy endpoints (P3a) ---
Storage health never depends on this result. Missing creds → False.
"""
try:
base, username, enc_password = _woo_credentials()
if not base or not username or not enc_password:
return False
client = WooClient.from_settings(base, username, enc_password)
# One lightweight authenticated call (categories page 1).
code, _h, _b = client._get("/products/categories?per_page=1&page=1")
return code == 200
except Exception:
return False
@app.get("/api/v1/meta")
async def meta() -> dict[str, Any]:
"""Per API.md §3."""
return {"version": __version__}
from . import settings as settings_mod # noqa: E402 (imported after app object; router modules need it)
@app.get("/api/v1/settings")
@@ -205,10 +229,10 @@ async def put_settings(payload: dict[str, Any]) -> dict[str, Any]:
return settings_mod.update_settings(payload)
from . import taxonomy as taxonomy_mod
from .woo import (
WooClient,
from . import taxonomy as taxonomy_mod # noqa: E402
from .woo import ( # noqa: E402
WooAuthError,
WooClient,
WooConnectionError,
WooCredentialsMissing,
WooError,
@@ -218,10 +242,13 @@ from .woo import (
def _woo_credentials() -> tuple[str, str | None, str | None]:
conn = get_connection()
base = os.environ.get("WOO_BASE_URL", "")
user_row = conn.execute("SELECT value FROM settings WHERE key='woo_wp_username'").fetchone()
pass_row = conn.execute("SELECT value_enc FROM settings WHERE key='woo_app_password'").fetchone()
return base, (user_row[0] if user_row else None), (pass_row[0] if pass_row else None)
try:
base = os.environ.get("WOO_BASE_URL", "")
user_row = conn.execute("SELECT value FROM settings WHERE key='woo_wp_username'").fetchone()
pass_row = conn.execute("SELECT value_enc FROM settings WHERE key='woo_app_password'").fetchone()
return base, (user_row[0] if user_row else None), (pass_row[0] if pass_row else None)
finally:
conn.close()
@app.post("/api/v1/settings/test-woo")
@@ -230,21 +257,10 @@ async def test_woo() -> dict[str, Any]:
try:
client = WooClient.from_settings(base, username, enc_password)
except WooCredentialsMissing as e:
return {
"ok": False,
"detail": str(e),
"taxonomy_anchor": None,
"taxonomy_last_sync_at": None,
}
return {"ok": False, "detail": str(e), "taxonomy_anchor": None, "taxonomy_last_sync_at": None}
try:
result = taxonomy_mod.sync(client, get_connection())
except WooAuthError as e:
return {"ok": False, "detail": f"auth rejected by store: {e}", "taxonomy_anchor": None, "taxonomy_last_sync_at": None}
except WooForbiddenError as e:
return {"ok": False, "detail": f"request blocked by store (403): {e}", "taxonomy_anchor": None, "taxonomy_last_sync_at": None}
except WooConnectionError as e:
return {"ok": False, "detail": f"store unreachable: {e}", "taxonomy_anchor": None, "taxonomy_last_sync_at": None}
except WooError as e:
except (WooAuthError, WooForbiddenError, WooConnectionError, WooError) as e:
return {"ok": False, "detail": str(e), "taxonomy_anchor": None, "taxonomy_last_sync_at": None}
return {
"ok": True,
@@ -256,7 +272,11 @@ async def test_woo() -> dict[str, Any]:
@app.get("/api/v1/admin/taxonomy")
async def get_taxonomy() -> dict[str, Any]:
return taxonomy_mod.get_cached(get_connection())
conn = get_connection()
try:
return taxonomy_mod.get_cached(conn)
finally:
conn.close()
@app.post("/api/v1/admin/taxonomy/sync")
@@ -278,25 +298,36 @@ async def post_taxonomy_sync() -> dict[str, Any]:
}
# expose for tests
from .slabs import router as slabs_router # noqa: E402
from .photos import router as photos_router # noqa: E402
from .pricing import router as pricing_router # noqa: E402
from .review import router as review_router # noqa: E402
from .publish import router as publish_router # noqa: E402
from .inference import router as inference_router # noqa: E402
from .prompts import router as prompts_router # noqa: E402
from .slabs import router as slabs_router
app.include_router(slabs_router)
from .photos import router as photos_router
app.include_router(photos_router)
from .pricing import router as pricing_router
app.include_router(pricing_router)
from .review import router as review_router
app.include_router(review_router)
from .publish import router as publish_router
app.include_router(publish_router)
from .inference import router as inference_router
app.include_router(inference_router)
app.include_router(prompts_router)
# expose for tests
__all__ = ["app", "__version__"]
def reinit_db(path: str | None = None):
"""Re-init schema on `path` (test isolation); returns a connection to it.
With the per-call factory (P0.2), each request opens its own connection,
so there is no cached binding to re-point — init_db on the new path is the
entire isolation.
"""
p = path or os.environ.get("SLAB_DB_PATH") or "slab_demo.db"
init_db(p)
return get_connection(p)
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="127.0.0.1", port=18081)