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:
2026-09-19 15:48:42 -07:00
parent 0eb2b0f697
commit b3380f3848
8 changed files with 589 additions and 6 deletions
+12 -6
View File
@@ -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>