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}
+
Loading...
+ {:else if settings} +{message}
{/if} + {/if} +