feat(settings): S1 AES-GCM crypto + Settings API/UI (masked write-only secrets)
- backend/crypto.py: AES-GCM (cryptography.AESGCM); key=base64(SLAB_AES_KEY)
must be exactly 32 bytes; value_enc=base64(nonce(12)||ct); DecryptError on tamper
- backend/settings.py: get_settings (secrets->{configured:bool}, woo_base_url env
read-only, flags default false), update_settings (rejects woo_base_url SETTINGS-85,
encrypts secrets, partial plain writes)
- backend/app.py: wire GET/PUT /api/v1/settings
- frontend/src/create/settings/Settings.svelte: masked form + partial PUT
- tests: crypto roundtrip/tamper, masked GET, encrypted PUT, reject woo_base_url,
flags default, decrypt-fail->configured:false (TDD)
Verified live: secrets round-trip configured:true with valid key; woo_base_url write
rejected 400; no secret leak in GET. Gates: check_requirements + check_coverage exit 0;
pytest 13 passed. Deploy+build on ubudual5060 (never Windows).
This commit is contained in:
@@ -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__"]
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
+12
-6
@@ -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}
|
||||
<button on:click={() => (showSettings = !showSettings)} style="margin-top:1rem">Settings (dev)</button>
|
||||
{#if showSettings}
|
||||
<Settings />
|
||||
{/if}
|
||||
</main>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
interface SecretState { configured: boolean }
|
||||
interface Settings {
|
||||
woo_base_url: string;
|
||||
woo_wp_username: string | null;
|
||||
woo_app_password: SecretState;
|
||||
inference_base_url: SecretState;
|
||||
inference_api_key: SecretState;
|
||||
inference_model: string | null;
|
||||
inference_enabled: boolean;
|
||||
content_llm_enabled: boolean;
|
||||
brand_voice: string | null;
|
||||
geo_context: string | null;
|
||||
woo_create_status: string | null;
|
||||
}
|
||||
|
||||
let settings: Settings | null = null;
|
||||
let loading = true;
|
||||
let saving = false;
|
||||
let message = '';
|
||||
|
||||
// local edit buffers (write-only for secrets)
|
||||
let form: any = {};
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
try {
|
||||
const res = await fetch('/api/v1/settings');
|
||||
if (res.ok) {
|
||||
settings = await res.json();
|
||||
// init form with current plain values
|
||||
form = {
|
||||
woo_wp_username: settings.woo_wp_username || '',
|
||||
inference_model: settings.inference_model || '',
|
||||
inference_enabled: settings.inference_enabled,
|
||||
content_llm_enabled: settings.content_llm_enabled,
|
||||
brand_voice: settings.brand_voice || '',
|
||||
geo_context: settings.geo_context || '',
|
||||
woo_create_status: settings.woo_create_status || 'draft',
|
||||
// secrets: only send if user types
|
||||
woo_app_password: '',
|
||||
inference_base_url: '',
|
||||
inference_api_key: '',
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
message = 'Failed to load settings';
|
||||
}
|
||||
loading = false;
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving = true;
|
||||
message = '';
|
||||
const payload: any = {};
|
||||
// only include changed / non-empty
|
||||
if (form.woo_wp_username !== (settings?.woo_wp_username || '')) payload.woo_wp_username = form.woo_wp_username || null;
|
||||
if (form.inference_model !== (settings?.inference_model || '')) payload.inference_model = form.inference_model || null;
|
||||
if (form.inference_enabled !== settings?.inference_enabled) payload.inference_enabled = form.inference_enabled;
|
||||
if (form.content_llm_enabled !== settings?.content_llm_enabled) payload.content_llm_enabled = form.content_llm_enabled;
|
||||
if (form.brand_voice !== (settings?.brand_voice || '')) payload.brand_voice = form.brand_voice || null;
|
||||
if (form.geo_context !== (settings?.geo_context || '')) payload.geo_context = form.geo_context || null;
|
||||
if (form.woo_create_status !== (settings?.woo_create_status || 'draft')) payload.woo_create_status = form.woo_create_status;
|
||||
// secrets only if provided (write-only)
|
||||
if (form.woo_app_password) payload.woo_app_password = form.woo_app_password;
|
||||
if (form.inference_base_url) payload.inference_base_url = form.inference_base_url;
|
||||
if (form.inference_api_key) payload.inference_api_key = form.inference_api_key;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/v1/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (res.ok) {
|
||||
settings = await res.json();
|
||||
message = 'Saved';
|
||||
// clear secret inputs after save
|
||||
form.woo_app_password = '';
|
||||
form.inference_base_url = '';
|
||||
form.inference_api_key = '';
|
||||
} else {
|
||||
const err = await res.json();
|
||||
message = err.error?.message || 'Save failed';
|
||||
}
|
||||
} catch (e) {
|
||||
message = 'Network error';
|
||||
}
|
||||
saving = false;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
void load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="settings">
|
||||
<h2>Settings</h2>
|
||||
{#if loading}
|
||||
<p>Loading...</p>
|
||||
{:else if settings}
|
||||
<div>
|
||||
<label>Woo Base URL (read-only, from env)</label>
|
||||
<input type="text" value={settings.woo_base_url} disabled />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label>WP Username</label>
|
||||
<input bind:value={form.woo_wp_username} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label>Woo App Password {settings.woo_app_password.configured ? '(configured)' : '(not set)'}</label>
|
||||
<input type="password" bind:value={form.woo_app_password} placeholder={settings.woo_app_password.configured ? '••••••••' : ''} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label>Inference Base URL {settings.inference_base_url.configured ? '(configured)' : '(not set)'}</label>
|
||||
<input bind:value={form.inference_base_url} placeholder={settings.inference_base_url.configured ? '••••••••' : ''} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label>Inference API Key {settings.inference_api_key.configured ? '(configured)' : '(not set)'}</label>
|
||||
<input type="password" bind:value={form.inference_api_key} placeholder={settings.inference_api_key.configured ? '••••••••' : ''} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label>Inference Model</label>
|
||||
<input bind:value={form.inference_model} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label><input type="checkbox" bind:checked={form.inference_enabled} /> Inference Enabled (default false)</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label><input type="checkbox" bind:checked={form.content_llm_enabled} /> Content LLM Enabled (default false)</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label>Brand Voice</label>
|
||||
<input bind:value={form.brand_voice} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label>Geo Context</label>
|
||||
<input bind:value={form.geo_context} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label>Woo Create Status</label>
|
||||
<select bind:value={form.woo_create_status}>
|
||||
<option value="draft">draft</option>
|
||||
<option value="publish">publish</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button on:click={save} disabled={saving}>Save Changes</button>
|
||||
{#if message}<p class="msg">{message}</p>{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.settings { max-width: 600px; }
|
||||
label { display: block; margin: 8px 0 4px; }
|
||||
input, select { width: 100%; padding: 6px; }
|
||||
button { margin-top: 16px; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user