diff --git a/PROTOTYPE.md b/PROTOTYPE.md new file mode 100644 index 0000000..d2d57f9 --- /dev/null +++ b/PROTOTYPE.md @@ -0,0 +1,63 @@ +# Prototype: per-user Woo credentials + +Branch: `proto-per-user-woo-creds` (cut from `overnight-whole-app`). + +## Goal + +2–3 owners can share one SlabUploader install and one Woo store URL, each using +their own WordPress user + application password. Inference URL/key stay global. +No full auth, ACL, or publish mutex in this prototype. + +## Credential scheme + +| Layer | What | +| --- | --- | +| Browser | `localStorage` keys `slabuploader.woo.device.username` / `…appPassword` | +| HTTP | Headers `X-Woo-Username` + `X-Woo-App-Password` on Woo-touching calls | +| Server | `client_for_request(conn, headers)` → override if both headers non-blank, else Settings singleton | +| Settings | Shop-default `woo_wp_username` / encrypted `woo_app_password` — **never** updated by the device-creds path | + +Woo-touching calls that attach headers: + +- `POST /api/v1/slabs/{id}/publish` +- `POST /api/v1/admin/taxonomy/sync` +- `POST /api/v1/settings/test-woo` + +Inference / Call 2 / Settings PUT for inference keys are unchanged (shared Settings). + +## Flow (request → WooClient) + +1. Frontend reads device creds from localStorage (`wooDeviceCreds.ts`). +2. `withWooHeaders()` merges `X-Woo-*` onto the fetch. +3. Backend `client_for_request` builds a **new** `WooClient` for that request only + (Basic auth from override plaintext, or decrypt Settings password). +4. Publish / taxonomy / media use that client instance — no process-global + “current user”. + +## UAT with two browsers + +1. Build/run this branch locally (do **not** deploy over partner UAT :10890; never :10880). +2. In WordPress, create two application passwords for two WP users on the same store. +3. Browser A (or profile A): Settings → **This device’s Woo credentials** → enter + user A + app password → **Save on this device** → **Test WooCommerce connection**. +4. Browser B: same for user B (separate localStorage). +5. Confirm Test Woo succeeds for both, and that **Save Changes** on shop-default + fields was not used to store the other user’s password. +6. Publish different slabs from each browser (do not work the same slab concurrently). +7. Optional: clear device creds and confirm fallback to shop-default Settings. + +## Intentional fallback + +- Missing or partial `X-Woo-*` headers → shop-default Settings credentials. +- Device localStorage empty → same fallback. +- Health probe (`woo_reachable`) always uses shop-default Settings (no request). + +## Risks left (prototype) + +- **LAN can forge headers** — there is no app login; any client on the network can + send arbitrary `X-Woo-*` values. Treat as trusted LAN only. +- Device passwords sit in **localStorage** (XSS-readable). Acceptable for this + prototype only. +- No slab ownership ACL / taxonomy mutex / publish CAS — operators must not edit + the same slab at once. +- Taxonomy cache remains shared; a sync from either user refreshes the same rows. diff --git a/backend/app.py b/backend/app.py index 8c4b7c6..0e8cb5f 100644 --- a/backend/app.py +++ b/backend/app.py @@ -213,22 +213,23 @@ from .woo import ( WooCredentialsMissing, WooError, WooForbiddenError, + client_for_request, + settings_woo_creds, ) 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) + """Shop-default Woo creds (Settings). Used by health probe only.""" + return settings_woo_creds(get_connection()) @app.post("/api/v1/settings/test-woo") -async def test_woo() -> dict[str, Any]: - base, username, enc_password = _woo_credentials() +async def test_woo(request: Request) -> dict[str, Any]: + """Probe Woo + sync taxonomy. Optional X-Woo-Username / X-Woo-App-Password + override shop-default Settings for this request only (never persisted). + """ try: - client = WooClient.from_settings(base, username, enc_password) + client = client_for_request(get_connection(), request.headers) except WooCredentialsMissing as e: return { "ok": False, @@ -260,10 +261,10 @@ async def get_taxonomy() -> dict[str, Any]: @app.post("/api/v1/admin/taxonomy/sync") -async def post_taxonomy_sync() -> dict[str, Any]: - base, username, enc_password = _woo_credentials() +async def post_taxonomy_sync(request: Request) -> dict[str, Any]: + """Refresh taxonomy cache. Honors X-Woo-* override headers for this request.""" try: - client = WooClient.from_settings(base, username, enc_password) + client = client_for_request(get_connection(), request.headers) except WooCredentialsMissing as e: return JSONResponse(status_code=502, content=_error("woo_unavailable", str(e))) try: diff --git a/backend/publish.py b/backend/publish.py index 503a9b0..01b5a8b 100644 --- a/backend/publish.py +++ b/backend/publish.py @@ -12,7 +12,7 @@ import re from datetime import datetime, timedelta, timezone from typing import Any -from fastapi import APIRouter +from fastapi import APIRouter, Request from fastapi.responses import JSONResponse from .db import get_connection @@ -23,6 +23,8 @@ from .woo import ( WooCredentialsMissing, WooError, WooForbiddenError, + client_for_request, + settings_woo_creds, ) from .review import compute_ready, _slab_dict @@ -139,7 +141,7 @@ def _classify(e: Exception) -> tuple[str, str, bool]: @router.post("/{slab_id}/publish") -async def publish(slab_id: str) -> dict: +async def publish(slab_id: str, request: Request) -> dict: conn = get_connection() row = conn.execute("SELECT * FROM slabs WHERE id=? AND deleted_at IS NULL", (slab_id,)).fetchone() if row is None: @@ -150,9 +152,9 @@ async def publish(slab_id: str) -> dict: if not ready: return JSONResponse(status_code=422, content=_error("unpublishable", "Slab is not ready", {"missing": missing})) # 2. SKU lookup (PUBLISH-15) — stop if it exists in the store. - base, user, enc = _woo_creds(conn) + # Per-request Woo creds: X-Woo-* headers → Settings fallback (never persisted). try: - client = WooClient.from_settings(base, user, enc) + client = client_for_request(conn, request.headers) except WooCredentialsMissing as e: return JSONResponse(status_code=422, content=_error("woo_unconfigured", str(e))) try: @@ -221,12 +223,8 @@ async def publish(slab_id: str) -> dict: def _woo_creds(conn) -> tuple[str, str | None, str | None]: - import os - - base = os.environ.get("WOO_BASE_URL", "") - u = conn.execute("SELECT value FROM settings WHERE key='woo_wp_username'").fetchone() - p = conn.execute("SELECT value_enc FROM settings WHERE key='woo_app_password'").fetchone() - return base, (u[0] if u else None), (p[0] if p else None) + """Shop-default Woo creds (Settings). Prefer client_for_request for HTTP paths.""" + return settings_woo_creds(conn) def _create_status(conn) -> str: diff --git a/backend/tests/test_per_user_woo_creds.py b/backend/tests/test_per_user_woo_creds.py new file mode 100644 index 0000000..ba0ec56 --- /dev/null +++ b/backend/tests/test_per_user_woo_creds.py @@ -0,0 +1,113 @@ +"""Prototype: per-request X-Woo-* headers build distinct WooClients without clobbering Settings.""" + +import base64 +import os +import shutil +import tempfile + +import pytest +from starlette.datastructures import Headers + +import backend.db as db_mod +from backend.crypto import encrypt, decrypt +from backend.woo import ( + WooClient, + client_for_request, + request_woo_override, + settings_woo_creds, + HEADER_WOO_USERNAME, + HEADER_WOO_APP_PASSWORD, +) + + +@pytest.fixture +def db_dir(): + tmp = tempfile.mkdtemp(prefix="per_user_woo_") + os.environ["SLAB_DB_PATH"] = os.path.join(tmp, "t.db") + os.environ["SLAB_IMAGES_DIR"] = tmp + os.environ["WOO_BASE_URL"] = "https://store.example.com" + os.environ["SLAB_AES_KEY"] = base64.b64encode(b"0123456789abcdef0123456789abcdef").decode() + db_mod._reset_for_tests() + conn = db_mod.init_db() + conn.execute( + "INSERT OR REPLACE INTO settings(key, value, value_enc) VALUES ('woo_wp_username','shop-default',NULL)" + ) + conn.execute( + "INSERT OR REPLACE INTO settings(key, value, value_enc) VALUES ('woo_app_password',NULL,?)", + (encrypt("shop-secret"),), + ) + conn.commit() + yield conn + db_mod._reset_for_tests() + shutil.rmtree(tmp, ignore_errors=True) + + +def _auth_pair(client: WooClient) -> tuple[str, str]: + raw = base64.b64decode(client._auth).decode("utf-8") + user, password = raw.split(":", 1) + return user, password + + +def test_override_headers_produce_distinct_auth_without_clobbering_settings(db_dir): + conn = db_dir + before = settings_woo_creds(conn) + assert before[1] == "shop-default" + assert decrypt(before[2]) == "shop-secret" + + h_a = Headers({HEADER_WOO_USERNAME: "alice", HEADER_WOO_APP_PASSWORD: "alice-app-pw"}) + h_b = Headers({HEADER_WOO_USERNAME: "bob", HEADER_WOO_APP_PASSWORD: "bob-app-pw"}) + + client_a = client_for_request(conn, h_a) + client_b = client_for_request(conn, h_b) + client_fallback = client_for_request(conn, Headers({})) + + assert _auth_pair(client_a) == ("alice", "alice-app-pw") + assert _auth_pair(client_b) == ("bob", "bob-app-pw") + assert _auth_pair(client_fallback) == ("shop-default", "shop-secret") + assert client_a._auth != client_b._auth + + after = settings_woo_creds(conn) + assert after == before + assert decrypt(after[2]) == "shop-secret" + # Explicit: Settings row values unchanged + row_u = conn.execute("SELECT value FROM settings WHERE key='woo_wp_username'").fetchone()[0] + row_p = conn.execute("SELECT value_enc FROM settings WHERE key='woo_app_password'").fetchone()[0] + assert row_u == "shop-default" + assert decrypt(row_p) == "shop-secret" + + +def test_partial_headers_fall_back_to_settings(db_dir): + conn = db_dir + # Username only — incomplete override + assert request_woo_override(Headers({HEADER_WOO_USERNAME: "alice"})) is None + client = client_for_request(conn, Headers({HEADER_WOO_USERNAME: "alice"})) + assert _auth_pair(client) == ("shop-default", "shop-secret") + + +def test_test_woo_endpoint_uses_request_override(db_dir, monkeypatch): + """API: POST /settings/test-woo with X-Woo-* builds client for that user.""" + from fastapi.testclient import TestClient + from backend import app as app_mod + + seen: list[str] = [] + + def fake_sync(client, conn): + seen.append(_auth_pair(client)[0]) + return {"taxonomy_anchor": "x", "taxonomy_last_sync_at": "2026-01-01T00:00:00"} + + monkeypatch.setattr(app_mod.taxonomy_mod, "sync", fake_sync) + tc = TestClient(app_mod.app) + r1 = tc.post( + "/api/v1/settings/test-woo", + headers={HEADER_WOO_USERNAME: "alice", HEADER_WOO_APP_PASSWORD: "pw-a"}, + ) + r2 = tc.post( + "/api/v1/settings/test-woo", + headers={HEADER_WOO_USERNAME: "bob", HEADER_WOO_APP_PASSWORD: "pw-b"}, + ) + r3 = tc.post("/api/v1/settings/test-woo") + assert r1.status_code == 200 and r1.json()["ok"] is True + assert r2.status_code == 200 and r2.json()["ok"] is True + assert r3.status_code == 200 and r3.json()["ok"] is True + assert seen == ["alice", "bob", "shop-default"] + assert settings_woo_creds(db_dir)[1] == "shop-default" diff --git a/backend/woo.py b/backend/woo.py index 921cc53..d81a998 100644 --- a/backend/woo.py +++ b/backend/woo.py @@ -1,8 +1,12 @@ """WooCommerce REST v3 client (stdlib urllib). Basic-auth, HTTPS-only, paginated. -Creds come from Settings (wp_username plain, app_password AES-GCM). Missing creds -raise WooCredentialsMissing (a fail-closed condition the caller maps to ok:false), -distinct from WooAuthError (a 401 from the store). +Default creds come from Settings (wp_username plain, app_password AES-GCM). +Missing creds raise WooCredentialsMissing (fail-closed), distinct from +WooAuthError (401 from the store). + +Prototype per-user override: callers may pass X-Woo-Username + X-Woo-App-Password +request headers into client_for_request(); those build a per-request WooClient +and are never written into Settings. See PROTOTYPE.md. """ import base64 @@ -177,3 +181,53 @@ class WooClient: if code not in (200, 201): raise WooServerError(f"media upload returned {code}") return int(json.loads(resp)["id"]) + + +# --- Per-request Woo credential override (prototype) --- +# Scheme: browser sends plaintext WP Application Password credentials on +# Woo-touching API calls via: +# X-Woo-Username +# X-Woo-App-Password +# Server uses those for THIS request's WooClient only; never writes them into +# the Settings row. If either header is missing/blank, fall back to the +# shop-default singleton (woo_wp_username + encrypted woo_app_password). + +HEADER_WOO_USERNAME = "X-Woo-Username" +HEADER_WOO_APP_PASSWORD = "X-Woo-App-Password" + + +def settings_woo_creds(conn) -> tuple[str, str | None, str | None]: + """Shop-default Woo creds from env + Settings (encrypted password).""" + import os + + base = os.environ.get("WOO_BASE_URL", "") + u = conn.execute("SELECT value FROM settings WHERE key='woo_wp_username'").fetchone() + p = conn.execute("SELECT value_enc FROM settings WHERE key='woo_app_password'").fetchone() + return base, (u[0] if u else None), (p[0] if p else None) + + +def request_woo_override(headers) -> tuple[str, str] | None: + """Return (username, app_password) if both override headers are present and non-blank.""" + if headers is None: + return None + # Starlette Headers are case-insensitive + user = (headers.get(HEADER_WOO_USERNAME) or "").strip() + password = (headers.get(HEADER_WOO_APP_PASSWORD) or "").strip() + if user and password: + return user, password + return None + + +def client_for_request(conn, headers=None, request: Callable[..., tuple[int, dict, bytes]] | None = None) -> WooClient: + """Build a WooClient for one HTTP request: override headers → Settings fallback. + + Never mutates Settings. Inference stays on shared Settings (caller responsibility). + """ + base, settings_user, settings_enc = settings_woo_creds(conn) + override = request_woo_override(headers) + if override is not None: + user, password = override + if request is None: + request = _default_request + return WooClient(user, password, base, request) + return WooClient.from_settings(base, settings_user, settings_enc, request=request) diff --git a/frontend/src/api/slabs.ts b/frontend/src/api/slabs.ts index 663b253..81c9231 100644 --- a/frontend/src/api/slabs.ts +++ b/frontend/src/api/slabs.ts @@ -7,12 +7,15 @@ // publish.py POST /api/v1/slabs/{id}/publish // inference.py POST /api/v1/slabs/{id}/inference/call1 (DISABLED 410) | call2 // settings.py GET/PUT /api/v1/settings ; POST /api/v1/settings/test-woo|test-inference +// Woo-backed calls attach X-Woo-Username / X-Woo-App-Password from localStorage when set. // taxonomy.py GET/POST /api/v1/admin/taxonomy[.../sync] // pricing.py CRUD /api/v1/admin/pricing[/{id}] // // Every non-2xx is surfaced as an ApiError carrying the backend `code` and a plain // owner-facing `message` (QUALITY-120: raw codes/stack traces never reach the owner). +import { withWooHeaders } from './wooDeviceCreds'; + export interface Slab { id: string; sku: string; @@ -338,6 +341,7 @@ export interface PublishResult { export function publishSlab(slabId: string): Promise { return fetch(`/api/v1/slabs/${encodeURIComponent(slabId)}/publish`, { method: 'POST', + headers: withWooHeaders(), }).then((r) => parse(r)); } @@ -448,7 +452,10 @@ export function syncTaxonomy(): Promise<{ taxonomy_anchor: string; taxonomy_last_sync_at: string; }> { - return fetch('/api/v1/admin/taxonomy/sync', { method: 'POST' }).then((r) => parse(r)); + return fetch('/api/v1/admin/taxonomy/sync', { + method: 'POST', + headers: withWooHeaders(), + }).then((r) => parse(r)); } // --------------------------------------------------------------------------- @@ -542,9 +549,10 @@ export interface TestInferenceResult { } export function testWoo(): Promise { - return fetch('/api/v1/settings/test-woo', { method: 'POST' }).then((r) => - parse(r), - ); + return fetch('/api/v1/settings/test-woo', { + method: 'POST', + headers: withWooHeaders(), + }).then((r) => parse(r)); } export function testInference(): Promise { diff --git a/frontend/src/api/wooDeviceCreds.ts b/frontend/src/api/wooDeviceCreds.ts new file mode 100644 index 0000000..a883346 --- /dev/null +++ b/frontend/src/api/wooDeviceCreds.ts @@ -0,0 +1,78 @@ +/** + * Browser-local Woo credentials (prototype per-user routing). + * + * Stored in localStorage on this device only — never written to server Settings + * via the device-creds path. Attached as X-Woo-Username / X-Woo-App-Password on + * Woo-touching API calls. See PROTOTYPE.md. + */ + +const LS_USER = 'slabuploader.woo.device.username'; +const LS_PASS = 'slabuploader.woo.device.appPassword'; + +export const HEADER_WOO_USERNAME = 'X-Woo-Username'; +export const HEADER_WOO_APP_PASSWORD = 'X-Woo-App-Password'; + +export interface DeviceWooCreds { + username: string; + appPassword: string; +} + +function safeGet(key: string): string { + try { + return (localStorage.getItem(key) || '').trim(); + } catch { + return ''; + } +} + +function safeSet(key: string, value: string): void { + try { + if (value) localStorage.setItem(key, value); + else localStorage.removeItem(key); + } catch { + /* private mode / quota — ignore */ + } +} + +export function getDeviceWooCreds(): DeviceWooCreds { + return { + username: safeGet(LS_USER), + appPassword: safeGet(LS_PASS), + }; +} + +/** Persist this browser's Woo login. Empty strings clear the slot. */ +export function setDeviceWooCreds(creds: DeviceWooCreds): void { + safeSet(LS_USER, (creds.username || '').trim()); + safeSet(LS_PASS, (creds.appPassword || '').trim()); +} + +export function clearDeviceWooCreds(): void { + safeSet(LS_USER, ''); + safeSet(LS_PASS, ''); +} + +/** Headers to merge into Woo-backed fetch calls. Empty object when unset. */ +export function wooOverrideHeaders(): Record { + const { username, appPassword } = getDeviceWooCreds(); + if (!username || !appPassword) return {}; + return { + [HEADER_WOO_USERNAME]: username, + [HEADER_WOO_APP_PASSWORD]: appPassword, + }; +} + +/** Merge Content-Type / caller headers with device Woo override when present. */ +export function withWooHeaders(extra?: HeadersInit): HeadersInit { + const woo = wooOverrideHeaders(); + if (!extra) return woo; + if (extra instanceof Headers) { + const h = new Headers(extra); + for (const [k, v] of Object.entries(woo)) h.set(k, v); + return h; + } + if (Array.isArray(extra)) { + return [...extra, ...Object.entries(woo)]; + } + return { ...extra, ...woo }; +} diff --git a/frontend/src/create/settings/Settings.svelte b/frontend/src/create/settings/Settings.svelte index 4ea7f72..70dcca2 100644 --- a/frontend/src/create/settings/Settings.svelte +++ b/frontend/src/create/settings/Settings.svelte @@ -2,6 +2,12 @@ import { decodeName } from '../decodeName'; import { onMount } from 'svelte'; import { speciesLeavesFromTaxonomy, type TaxonomyCategory } from './speciesLeaves'; + import { + getDeviceWooCreds, + setDeviceWooCreds, + clearDeviceWooCreds, + withWooHeaders, + } from '../../api/wooDeviceCreds'; interface SecretState { configured: boolean } // Masked mode: object {configured}. Unmasked UAT mode (SLAB_UAT_UNMASK_SECRETS): plain string. @@ -42,6 +48,10 @@ // local edit buffers (write-only for secrets) let form: any = {}; + // This device only — localStorage; never sent via Settings PUT. + let deviceWoo = { username: '', appPassword: '' }; + let deviceMsg = ''; + let rateRows: RateRow[] = []; let ratesLoading = false; let ratesSaving = false; @@ -206,6 +216,7 @@ message = 'Failed to load settings'; } loading = false; + deviceWoo = getDeviceWooCreds(); void loadRates(); } @@ -253,10 +264,30 @@ let wooTest: TestResult | null = null; let inferenceTest: TestResult | null = null; + function saveDeviceWoo() { + setDeviceWooCreds({ + username: deviceWoo.username, + appPassword: deviceWoo.appPassword, + }); + deviceWoo = getDeviceWooCreds(); + deviceMsg = deviceWoo.username && deviceWoo.appPassword + ? 'Saved on this device (localStorage). Not written to server Settings.' + : 'Cleared — Woo calls will use shop-default Settings credentials.'; + } + + function clearDeviceWoo() { + clearDeviceWooCreds(); + deviceWoo = { username: '', appPassword: '' }; + deviceMsg = 'Cleared — Woo calls will use shop-default Settings credentials.'; + } + async function testWoo() { wooTest = { ok: false, detail: 'Working…', busy: true }; try { - const res = await fetch('/api/v1/settings/test-woo', { method: 'POST' }); + const res = await fetch('/api/v1/settings/test-woo', { + method: 'POST', + headers: withWooHeaders(), + }); const body = await res.json(); wooTest = { ok: !!body.ok, detail: body.detail || (body.ok ? 'Connected & synced' : 'failed') }; if (wooTest.ok) await loadRates(); @@ -291,15 +322,52 @@ +
+

This device’s Woo credentials

+

+ Per-browser WP user + application password (localStorage). Sent as + X-Woo-Username / X-Woo-App-Password on publish, + taxonomy sync, and Test Woo — never written into shared server Settings. + Leave blank to use the shop-default below. +

+
+ + +
+
+ + +
+
+ + +
+ {#if deviceMsg}

{deviceMsg}

{/if} +
+ +
+

Shop-default Woo (server Settings)

+

+ Shared fallback for this install when this device has no local credentials. + Saving here updates the encrypted Settings row — do not put other users’ + passwords here. +

- +
- +
+
@@ -347,6 +415,7 @@

WooCommerce

+

Uses this device’s Woo credentials when set; otherwise shop-default Settings.

{#if wooTest}

{wooTest.detail}

@@ -426,4 +495,8 @@ .rate-head { color: #aaa; font-size: 0.85rem; } .species-name { margin: 0; color: #eee; } .rate-row input { margin: 0; } + .row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } + .row button { margin-top: 8px; } + button.ghost { background: transparent; color: #ccc; border: 1px solid #555; } + code { font-size: 0.85em; color: #9cf; }