diff --git a/backend/app.py b/backend/app.py index ff532fb..5beff88 100644 --- a/backend/app.py +++ b/backend/app.py @@ -19,9 +19,11 @@ from contextlib import asynccontextmanager from fastapi import FastAPI, HTTPException, Request from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse from starlette.middleware.base import BaseHTTPMiddleware from .db import get_connection, init_db, probe_images_writable, probe_writable +from . import settings as settings_mod __version__ = "0.5.0-m3-s0" @@ -138,6 +140,13 @@ 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}}) + + @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).""" @@ -159,5 +168,15 @@ async def meta() -> dict[str, Any]: return {"version": __version__} +@app.get("/api/v1/settings") +async def get_settings() -> dict[str, Any]: + return settings_mod.get_settings() + + +@app.put("/api/v1/settings") +async def put_settings(payload: dict[str, Any]) -> dict[str, Any]: + return settings_mod.update_settings(payload) + + # expose for tests __all__ = ["app", "__version__"] diff --git a/backend/crypto.py b/backend/crypto.py new file mode 100644 index 0000000..ae96f73 --- /dev/null +++ b/backend/crypto.py @@ -0,0 +1,65 @@ +"""S1 AES-GCM crypto helpers (SETTINGS-20, SETTINGS-25, QUALITY-125). + +Master key = SLAB_AES_KEY env (32-byte base64). Ciphertext = nonce(12) || ct (GCM tag verified on decrypt). +value_enc stores base64(nonce||ct). Decrypt failures are detectable (no crash, no secret leak). +""" +from __future__ import annotations + +import base64 +import os + +from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from cryptography.exceptions import InvalidTag + + +class DecryptError(Exception): + """Clean, detectable error for tampered/wrong-key ciphertext. Never leaks raw blob.""" + pass + + +def _get_key() -> bytes: + key_b64 = os.environ.get("SLAB_AES_KEY") + if not key_b64: + raise RuntimeError("SETTINGS-25: SLAB_AES_KEY is required (32-byte base64 in env/compose)") + try: + key = base64.b64decode(key_b64, validate=True) + except Exception: + raise RuntimeError("SETTINGS-25: SLAB_AES_KEY must be valid base64") from None + if len(key) != 32: + raise RuntimeError("SETTINGS-25: SLAB_AES_KEY must decode to exactly 32 bytes") + return key + + +def encrypt(plaintext: str) -> str: + """Encrypt plaintext -> base64(nonce(12) || ciphertext) for storage in value_enc.""" + if not isinstance(plaintext, str): + raise TypeError("plaintext must be str") + key = _get_key() + aesgcm = AESGCM(key) + nonce = os.urandom(12) + ct = aesgcm.encrypt(nonce, plaintext.encode("utf-8"), None) + blob = nonce + ct + return base64.b64encode(blob).decode("ascii") + + +def decrypt(ciphertext_b64: str) -> str: + """Decrypt base64 blob; verifies GCM tag. Raises DecryptError on tamper/wrong key (no leak).""" + if not isinstance(ciphertext_b64, str): + raise TypeError("ciphertext_b64 must be str") + key = _get_key() + try: + blob = base64.b64decode(ciphertext_b64, validate=True) + except Exception: + raise DecryptError("corrupt base64") from None + if len(blob) < 12: + raise DecryptError("corrupt: too short for nonce") + nonce = blob[:12] + ct = blob[12:] + aesgcm = AESGCM(key) + try: + pt = aesgcm.decrypt(nonce, ct, None) + return pt.decode("utf-8") + except InvalidTag: + raise DecryptError("decrypt failed: invalid tag (tampered or wrong key)") from None + except Exception as e: + raise DecryptError(f"decrypt failed: {type(e).__name__}") from None diff --git a/backend/requirements.txt b/backend/requirements.txt index 20cb2c0..9c34c92 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -3,4 +3,5 @@ fastapi==0.115.2 uvicorn[standard]==0.30.6 pytest==8.3.3 httpx==0.27.2 +cryptography==43.0.1 # stdlib only for sqlite3, logging, json, contextvars, threading diff --git a/backend/settings.py b/backend/settings.py new file mode 100644 index 0000000..a5b190b --- /dev/null +++ b/backend/settings.py @@ -0,0 +1,130 @@ +"""S1 Settings service (DATA-MODEL §6, API.md §10, SETTINGS-5/15/20/25/30/75/85/90). + +Secrets (value_enc) are AES-GCM write-only; GET returns only {configured: bool}. +woo_base_url is env read-only. Flags default false. Partial PUT. Decrypt fail -> configured:false (no 500, no leak). +""" +from __future__ import annotations + +import os +from typing import Any + +from fastapi import HTTPException + +from .crypto import decrypt, encrypt, DecryptError +from .db import get_connection + +SECRET_KEYS: set[str] = { + "woo_app_password", + "inference_base_url", + "inference_api_key", +} +PLAIN_KEYS: set[str] = { + "woo_wp_username", + "inference_model", + "inference_enabled", + "content_llm_enabled", + "brand_voice", + "geo_context", + "woo_create_status", + "taxonomy_last_sync_at", + "taxonomy_anchor", +} +ALL_KEYS = SECRET_KEYS | PLAIN_KEYS + +DEFAULTS: dict[str, Any] = { + "inference_enabled": False, + "content_llm_enabled": False, + "woo_create_status": "draft", +} + + +def _row_to_value(row: dict) -> Any: + if row is None: + return None + v = row.get("value") + if v is None: + return None + if isinstance(v, str): + if v.lower() in ("true", "false"): + return v.lower() == "true" + return v + + +def get_settings() -> dict[str, Any]: + conn = get_connection() + rows = {} + for r in conn.execute("SELECT key, value, value_enc FROM settings").fetchall(): + rows[r["key"]] = {"value": r["value"], "value_enc": r["value_enc"]} + + result: dict[str, Any] = {} + result["woo_base_url"] = os.environ.get("WOO_BASE_URL", "") + + for k in sorted(ALL_KEYS): + row = rows.get(k, {}) + if k in SECRET_KEYS: + enc = row.get("value_enc") + if enc: + try: + decrypt(enc) # verify roundtrip possible + result[k] = {"configured": True} + except Exception: + result[k] = {"configured": False} + else: + result[k] = {"configured": False} + else: + if k in rows: + val = _row_to_value(row) + result[k] = val if val is not None else DEFAULTS.get(k) + else: + result[k] = DEFAULTS.get(k) + + return result + + +def update_settings(payload: dict[str, Any]) -> dict[str, Any]: + if "woo_base_url" in payload: + raise HTTPException( + status_code=400, + detail={ + "error": { + "code": "validation", + "message": "woo_base_url is read-only (SETTINGS-85: env only)", + "detail": {"field": "woo_base_url"}, + } + }, + ) + + conn = get_connection() + for k, v in payload.items(): + if k not in ALL_KEYS: + continue # ignore unknown for partial + if k in SECRET_KEYS: + if v in (None, "", False): + conn.execute( + "INSERT OR REPLACE INTO settings(key, value, value_enc) VALUES (?,?,NULL)", + (k, None), + ) + else: + try: + enc = encrypt(str(v)) + except Exception: + enc = None + conn.execute( + "INSERT OR REPLACE INTO settings(key, value, value_enc) VALUES (?,?,?)", + (k, None, enc), + ) + else: + # plain + if v in (None, ""): + conn.execute("DELETE FROM settings WHERE key=?", (k,)) + else: + if k in ("inference_enabled", "content_llm_enabled"): + val_str = "true" if bool(v) else "false" + else: + val_str = str(v) + conn.execute( + "INSERT OR REPLACE INTO settings(key, value, value_enc) VALUES (?,?,NULL)", + (k, val_str), + ) + conn.commit() + return get_settings() diff --git a/backend/tests/test_crypto.py b/backend/tests/test_crypto.py new file mode 100644 index 0000000..de1aff8 --- /dev/null +++ b/backend/tests/test_crypto.py @@ -0,0 +1,52 @@ +"""TDD red tests for S1 crypto (QUALITY-125, SETTINGS-20/25).""" +import base64 +import os +import tempfile + +import pytest + +from backend import crypto + + +def _valid_key_b64() -> str: + # 32 raw bytes base64 + raw = b"0123456789abcdef0123456789abcdef" # exactly 32 + return base64.b64encode(raw).decode() + + +def test_crypto_roundtrip_encrypt_decrypt(monkeypatch): + """Given: valid SLAB_AES_KEY; When: encrypt then decrypt; Then: original value (QUALITY-125).""" + key_b64 = _valid_key_b64() + monkeypatch.setenv("SLAB_AES_KEY", key_b64) + plaintext = "my-secret-app-password-123" + ct_b64 = crypto.encrypt(plaintext) + assert isinstance(ct_b64, str) + recovered = crypto.decrypt(ct_b64) + assert recovered == plaintext + + +def test_crypto_decrypt_tampered_raises_clean_error(monkeypatch): + """Given: valid key; When: tamper the blob (flip a byte); Then: raises detectable error, no crash, no raw leak.""" + key_b64 = _valid_key_b64() + monkeypatch.setenv("SLAB_AES_KEY", key_b64) + plaintext = "secret" + ct_b64 = crypto.encrypt(plaintext) + # tamper: flip last byte + blob = bytearray(base64.b64decode(ct_b64)) + blob[-1] ^= 0xFF + tampered_b64 = base64.b64encode(blob).decode() + with pytest.raises(Exception) as exc: # will be specific like InvalidTag or our wrapper + crypto.decrypt(tampered_b64) + # must not contain the raw blob or secret + msg = str(exc.value).lower() + assert "secret" not in msg + assert plaintext not in msg + # detectable, e.g. contains "decrypt" or "tag" or "corrupt" + assert any(k in msg for k in ("decrypt", "tag", "invalid", "corrupt", "fail")) + + +def test_crypto_missing_key_raises(monkeypatch): + """Given: no SLAB_AES_KEY; When: encrypt; Then: clear runtime error.""" + monkeypatch.delenv("SLAB_AES_KEY", raising=False) + with pytest.raises(RuntimeError, match="SLAB_AES_KEY"): + crypto.encrypt("x") diff --git a/backend/tests/test_settings_api.py b/backend/tests/test_settings_api.py new file mode 100644 index 0000000..9dbed81 --- /dev/null +++ b/backend/tests/test_settings_api.py @@ -0,0 +1,140 @@ +"""TDD red tests for S1 Settings API (SETTINGS-5,75,85,90, QUALITY-125, DATA-MODEL §6).""" +import base64 +import json +import os +import tempfile + +import pytest +from fastapi.testclient import TestClient + +import backend.db as db_mod + + +def _valid_key_b64() -> str: + raw = b"0123456789abcdef0123456789abcdef" + return base64.b64encode(raw).decode() + + +def _setup_env(monkeypatch, tmp): + db_path = os.path.join(tmp, "test.db") + img_dir = os.path.join(tmp, "images") + os.mkdir(img_dir) + monkeypatch.setenv("SLAB_DB_PATH", db_path) + monkeypatch.setenv("SLAB_IMAGES_DIR", img_dir) + monkeypatch.setenv("WOO_BASE_URL", "https://example.com") + monkeypatch.setenv("SLAB_AES_KEY", _valid_key_b64()) + monkeypatch.setenv("LOG_LEVEL", "INFO") + db_mod._reset_for_tests() + + +def test_settings_get_masks_secrets_and_shows_woo_base_url(monkeypatch): + """Given: some plain + secret settings; When: GET /api/v1/settings; Then: secrets only {configured:bool}, never value; woo_base_url from env (SETTINGS-5,75).""" + with tempfile.TemporaryDirectory() as tmp: + _setup_env(monkeypatch, tmp) + from backend.app import app + client = TestClient(app) + # seed some data via direct DB (will be replaced by service later) + conn = db_mod.get_connection() + db_mod.init_db() + conn.execute("INSERT OR REPLACE INTO settings(key, value) VALUES ('woo_wp_username', 'wpuser')") + conn.execute("INSERT OR REPLACE INTO settings(key, value_enc) VALUES ('woo_app_password', 'badbase64')") # will be decrypt fail path + conn.execute("INSERT OR REPLACE INTO settings(key, value) VALUES ('inference_model', 'gpt-4o')") + conn.execute("INSERT OR REPLACE INTO settings(key, value) VALUES ('inference_enabled', 'true')") + conn.commit() + resp = client.get("/api/v1/settings") + assert resp.status_code == 200 + body = resp.json() + assert body["woo_base_url"] == "https://example.com" + assert body["woo_wp_username"] == "wpuser" + assert body["woo_app_password"] == {"configured": False} # decrypt fail -> not configured + assert body["inference_model"] == "gpt-4o" + assert body["inference_enabled"] is True + assert "inference_api_key" in body and body["inference_api_key"] == {"configured": False} + # never leak + assert "value" not in str(body) + assert "badbase64" not in str(body) + db_mod._reset_for_tests() + + +def test_settings_put_rejects_woo_base_url(monkeypatch): + """Given: valid env; When: PUT /api/v1/settings with woo_base_url; Then: 400 validation (SETTINGS-85).""" + with tempfile.TemporaryDirectory() as tmp: + _setup_env(monkeypatch, tmp) + from backend.app import app + client = TestClient(app) + db_mod.init_db() + resp = client.put("/api/v1/settings", json={"woo_base_url": "https://evil.com"}) + assert resp.status_code == 400 + err = resp.json()["error"] + assert err["code"] == "validation" + assert "woo_base_url" in err["message"].lower() + db_mod._reset_for_tests() + + +def test_settings_put_stores_encrypted_secrets(monkeypatch): + """Given: secret in PUT; When: PUT; Then: value_enc is base64 nonce||ct (not plaintext), plain fields direct (SETTINGS-20).""" + with tempfile.TemporaryDirectory() as tmp: + _setup_env(monkeypatch, tmp) + from backend.app import app + client = TestClient(app) + db_mod.init_db() + payload = { + "woo_app_password": "super-secret-pass", + "inference_base_url": "https://api.openai.com/v1", + "inference_api_key": "sk-xxx", + "inference_model": "gpt-4o-mini", + "inference_enabled": True, + "content_llm_enabled": False, + "brand_voice": "professional", + "geo_context": "PNW", + "woo_create_status": "draft", + } + resp = client.put("/api/v1/settings", json=payload) + assert resp.status_code == 200 + # check DB + conn = db_mod.get_connection() + rows = {r["key"]: (r["value"], r["value_enc"]) for r in conn.execute("SELECT key, value, value_enc FROM settings").fetchall()} + assert rows["woo_app_password"][0] is None + assert rows["woo_app_password"][1] is not None + # value_enc must be valid base64 and >12 bytes when decoded (nonce+ct) + enc = rows["woo_app_password"][1] + blob = base64.b64decode(enc) + assert len(blob) > 12 + assert rows["inference_api_key"][0] is None and rows["inference_api_key"][1] is not None + assert rows["inference_model"][0] == "gpt-4o-mini" and rows["inference_model"][1] is None + assert rows["inference_enabled"][0] == "true" + assert rows.get("content_llm_enabled", (None, None))[0] == "false" + db_mod._reset_for_tests() + + +def test_settings_flags_default_false_when_absent(monkeypatch): + """Given: no flag rows; When: GET; Then: inference_enabled/content_llm_enabled == false (SETTINGS-90).""" + with tempfile.TemporaryDirectory() as tmp: + _setup_env(monkeypatch, tmp) + from backend.app import app + client = TestClient(app) + db_mod.init_db() + resp = client.get("/api/v1/settings") + assert resp.status_code == 200 + body = resp.json() + assert body["inference_enabled"] is False + assert body["content_llm_enabled"] is False + db_mod._reset_for_tests() + + +def test_settings_decrypt_failure_returns_reenter_state_not_500(monkeypatch): + """Given: corrupt value_enc for secret; When: GET; Then: configured:false, 200 not 500 (SETTINGS-20/15 spirit).""" + with tempfile.TemporaryDirectory() as tmp: + _setup_env(monkeypatch, tmp) + from backend.app import app + client = TestClient(app) + db_mod.init_db() + conn = db_mod.get_connection() + # insert corrupt (not even base64 or short) + conn.execute("INSERT OR REPLACE INTO settings(key, value_enc) VALUES ('inference_api_key', '!!!notbase64!!!')") + conn.commit() + resp = client.get("/api/v1/settings") + assert resp.status_code == 200 # not 500 + body = resp.json() + assert body["inference_api_key"] == {"configured": False} + db_mod._reset_for_tests() diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 5d4d0a7..a8a359c 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -3,12 +3,13 @@ import { supported } from './browser'; import { createCaptureSession, type CaptureSession } from './capture/session'; import type { Capture } from './capture/capture.types'; - import Resume from './capture/Resume.svelte'; - import BatchPhotos from './capture/BatchPhotos.svelte'; - import PickCalibration from './capture/PickCalibration.svelte'; - import MaskPanel from './capture/MaskPanel.svelte'; - import AxisPanel from './capture/AxisPanel.svelte'; - import MeasurePanel from './capture/MeasurePanel.svelte'; +import Resume from './capture/Resume.svelte'; +import BatchPhotos from './capture/BatchPhotos.svelte'; +import PickCalibration from './capture/PickCalibration.svelte'; +import MaskPanel from './capture/MaskPanel.svelte'; +import AxisPanel from './capture/AxisPanel.svelte'; +import MeasurePanel from './capture/MeasurePanel.svelte'; +import Settings from './create/settings/Settings.svelte'; const gate = typeof window !== 'undefined' ? supported() : 'window unavailable'; const session: CaptureSession = createCaptureSession(); @@ -16,6 +17,7 @@ let cap: Capture = { k: 'batch', photos: [] }; let busy = false; let pngUrl: string | null = null; + let showSettings = false; const unsubCap = session.capture.subscribe((c) => { cap = c; @@ -88,6 +90,10 @@ {pngUrl} /> {/if} + + {#if showSettings} + + {/if} {/if} diff --git a/frontend/src/create/settings/Settings.svelte b/frontend/src/create/settings/Settings.svelte new file mode 100644 index 0000000..19029d1 --- /dev/null +++ b/frontend/src/create/settings/Settings.svelte @@ -0,0 +1,170 @@ + + +
+

Settings

+ {#if loading} +

Loading...

+ {:else if settings} +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ +
+ +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + + {#if message}

{message}

{/if} + {/if} +
+ +