diff --git a/PROTOTYPE.md b/PROTOTYPE.md
index d2d57f9..03911ab 100644
--- a/PROTOTYPE.md
+++ b/PROTOTYPE.md
@@ -13,44 +13,51 @@ No full auth, ACL, or publish mutex in this prototype.
| 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 |
+| Gate | Full-screen `WooSetupGate` on App mount when this browser lacks device creds |
+| HTTP | Headers `X-Woo-Username` + `X-Woo-App-Password` on Woo-touching calls (**required**) |
+| Server | `client_for_request(conn, headers)` → override only; **no Settings fallback** on those routes |
+| Settings | Shop-default `woo_wp_username` / encrypted `woo_app_password` — used by **health probe** only; **never** updated by the device-creds / gate path |
-Woo-touching calls that attach headers:
+Woo-touching calls that **require** headers (401 `woo_device_creds_required` if missing):
- `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).
+Optional test escape hatch: `SLAB_WOO_ALLOW_SETTINGS_FALLBACK=1` (prefer sending headers in tests).
+
+## Setup gate
+
+1. On App mount, if this browser has no device username + app password → blocking overlay.
+2. Fields: device WP user + app password (required); Woo base URL display-only from Settings;
+ inference URL/key show configured / not set (masked) and may be entered → PUT Settings
+ for inference only.
+3. Primary CTA: **Save device creds & Test Woo**. Gate dismisses only after Test Woo succeeds.
+4. Returning browser with device creds → skip gate.
+5. Publish (or Test Woo) Woo auth failure / missing headers → reopen gate.
## 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).
+ (Basic auth from override plaintext). Missing headers → `WooDeviceCredsRequired`.
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).
+1. Deploy this branch to the **proto** stack only (`slab-proto-woo-creds` on
+ `:10892` / `:10893`). Do **not** touch overnight `: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**.
+3. Browser A (or profile A): setup gate → enter user A + app password →
+ **Save device creds & Test Woo** → gate dismisses.
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.
+5. Confirm Test Woo succeeds for both, and that device passwords were not written
+ into shop-default Settings.
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).
+7. Optional: clear device creds in Settings → reload → gate reappears.
## Risks left (prototype)
diff --git a/backend/app.py b/backend/app.py
index 0e8cb5f..04ecb85 100644
--- a/backend/app.py
+++ b/backend/app.py
@@ -211,6 +211,7 @@ from .woo import (
WooAuthError,
WooConnectionError,
WooCredentialsMissing,
+ WooDeviceCredsRequired,
WooError,
WooForbiddenError,
client_for_request,
@@ -225,11 +226,16 @@ def _woo_credentials() -> tuple[str, str | None, str | None]:
@app.post("/api/v1/settings/test-woo")
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).
+ """Probe Woo + sync taxonomy. Requires X-Woo-Username / X-Woo-App-Password
+ (device creds); never reads shop-default Settings for this route.
"""
try:
client = client_for_request(get_connection(), request.headers)
+ except WooDeviceCredsRequired as e:
+ return JSONResponse(
+ status_code=401,
+ content=_error("woo_device_creds_required", str(e)),
+ )
except WooCredentialsMissing as e:
return {
"ok": False,
@@ -262,9 +268,14 @@ async def get_taxonomy() -> dict[str, Any]:
@app.post("/api/v1/admin/taxonomy/sync")
async def post_taxonomy_sync(request: Request) -> dict[str, Any]:
- """Refresh taxonomy cache. Honors X-Woo-* override headers for this request."""
+ """Refresh taxonomy cache. Requires X-Woo-* device headers (no Settings fallback)."""
try:
client = client_for_request(get_connection(), request.headers)
+ except WooDeviceCredsRequired as e:
+ return JSONResponse(
+ status_code=401,
+ content=_error("woo_device_creds_required", str(e)),
+ )
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 01b5a8b..e807053 100644
--- a/backend/publish.py
+++ b/backend/publish.py
@@ -21,6 +21,7 @@ from .woo import (
WooClient,
WooConnectionError,
WooCredentialsMissing,
+ WooDeviceCredsRequired,
WooError,
WooForbiddenError,
client_for_request,
@@ -124,6 +125,12 @@ def _attr_id_from_synced(attrs: dict, slug: str, name: str) -> int | None:
def _classify(e: Exception) -> tuple[str, str, bool]:
"""Return (code, plain message, recoverable). PUBLISH-36/37/38."""
+ if isinstance(e, WooDeviceCredsRequired):
+ return (
+ "woo_device_creds_required",
+ "Enter this device's WordPress user and application password.",
+ False,
+ )
if isinstance(e, WooCredentialsMissing):
return "woo_unconfigured", "Woo is not configured. Set the store credentials in Settings.", False
if isinstance(e, WooAuthError):
@@ -152,9 +159,17 @@ async def publish(slab_id: str, request: Request) -> 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.
- # Per-request Woo creds: X-Woo-* headers → Settings fallback (never persisted).
+ # Per-request Woo creds: require X-Woo-* device headers (no Settings fallback).
try:
client = client_for_request(conn, request.headers)
+ except WooDeviceCredsRequired as e:
+ return JSONResponse(
+ status_code=401,
+ content=_error(
+ "woo_device_creds_required",
+ "Enter this device's WordPress user and application password.",
+ ),
+ )
except WooCredentialsMissing as e:
return JSONResponse(status_code=422, content=_error("woo_unconfigured", str(e)))
try:
diff --git a/backend/tests/test_exit_inference_off.py b/backend/tests/test_exit_inference_off.py
index 3d82a08..29e6996 100644
--- a/backend/tests/test_exit_inference_off.py
+++ b/backend/tests/test_exit_inference_off.py
@@ -21,6 +21,7 @@ def env(monkeypatch):
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()
+ os.environ["SLAB_WOO_ALLOW_SETTINGS_FALLBACK"] = "1" # legacy tests; prefer X-Woo-* headers
db_mod._reset_for_tests()
from backend.crypto import encrypt
diff --git a/backend/tests/test_integration_woo_mock.py b/backend/tests/test_integration_woo_mock.py
index 62bad66..7918b99 100644
--- a/backend/tests/test_integration_woo_mock.py
+++ b/backend/tests/test_integration_woo_mock.py
@@ -59,6 +59,7 @@ def env(monkeypatch):
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()
+ os.environ["SLAB_WOO_ALLOW_SETTINGS_FALLBACK"] = "1" # legacy tests; prefer X-Woo-* headers
db_mod._reset_for_tests()
from backend.crypto import encrypt
diff --git a/backend/tests/test_per_user_woo_creds.py b/backend/tests/test_per_user_woo_creds.py
index ba0ec56..2b2a02b 100644
--- a/backend/tests/test_per_user_woo_creds.py
+++ b/backend/tests/test_per_user_woo_creds.py
@@ -1,4 +1,4 @@
-"""Prototype: per-request X-Woo-* headers build distinct WooClients without clobbering Settings."""
+"""Prototype: X-Woo-* headers required on HTTP Woo routes; never clobber Settings."""
import base64
import os
@@ -12,6 +12,7 @@ import backend.db as db_mod
from backend.crypto import encrypt, decrypt
from backend.woo import (
WooClient,
+ WooDeviceCredsRequired,
client_for_request,
request_woo_override,
settings_woo_creds,
@@ -27,6 +28,7 @@ def db_dir():
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()
+ os.environ.pop("SLAB_WOO_ALLOW_SETTINGS_FALLBACK", None)
db_mod._reset_for_tests()
conn = db_mod.init_db()
conn.execute(
@@ -59,33 +61,38 @@ def test_override_headers_produce_distinct_auth_without_clobbering_settings(db_d
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):
+def test_missing_headers_raise_device_creds_required(db_dir):
conn = db_dir
- # Username only — incomplete override
+ with pytest.raises(WooDeviceCredsRequired):
+ client_for_request(conn, Headers({}))
+ with pytest.raises(WooDeviceCredsRequired):
+ client_for_request(conn, Headers({HEADER_WOO_USERNAME: "alice"}))
assert request_woo_override(Headers({HEADER_WOO_USERNAME: "alice"})) is None
- client = client_for_request(conn, Headers({HEADER_WOO_USERNAME: "alice"}))
+
+
+def test_env_escape_hatch_allows_settings_fallback(db_dir, monkeypatch):
+ conn = db_dir
+ monkeypatch.setenv("SLAB_WOO_ALLOW_SETTINGS_FALLBACK", "1")
+ client = client_for_request(conn, Headers({}))
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."""
+def test_test_woo_endpoint_requires_headers(db_dir, monkeypatch):
+ """API: POST /settings/test-woo with X-Woo-* builds client; missing → 401."""
from fastapi.testclient import TestClient
from backend import app as app_mod
@@ -108,6 +115,64 @@ def test_test_woo_endpoint_uses_request_override(db_dir, monkeypatch):
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 r3.status_code == 401
+ assert r3.json()["error"]["code"] == "woo_device_creds_required"
+ assert seen == ["alice", "bob"]
assert settings_woo_creds(db_dir)[1] == "shop-default"
+
+
+def test_taxonomy_sync_requires_headers(db_dir, monkeypatch):
+ from fastapi.testclient import TestClient
+ from backend import app as app_mod
+
+ monkeypatch.setattr(
+ app_mod.taxonomy_mod,
+ "sync",
+ lambda client, conn: {
+ "taxonomy_anchor": "x",
+ "taxonomy_last_sync_at": "2026-01-01T00:00:00",
+ },
+ )
+ tc = TestClient(app_mod.app)
+ missing = tc.post("/api/v1/admin/taxonomy/sync")
+ assert missing.status_code == 401
+ assert missing.json()["error"]["code"] == "woo_device_creds_required"
+ ok = tc.post(
+ "/api/v1/admin/taxonomy/sync",
+ headers={HEADER_WOO_USERNAME: "alice", HEADER_WOO_APP_PASSWORD: "pw"},
+ )
+ assert ok.status_code == 200 and ok.json()["ok"] is True
+
+
+def test_publish_requires_headers(db_dir):
+ """POST publish without X-Woo-* → 401 woo_device_creds_required."""
+ from fastapi.testclient import TestClient
+ from backend import app as app_mod
+ import json
+
+ conn = db_dir
+ conn.execute(
+ "INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (1,'Walnut','walnut',NULL,100)"
+ )
+ conn.execute(
+ "INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (2,'Live edge','le',NULL,101)"
+ )
+ conn.execute(
+ "INSERT INTO woo_attributes(id,name,slug,woo_id) VALUES (8,'Edge Type','edge-type',200)"
+ )
+ conn.execute(
+ "INSERT INTO woo_attribute_terms(id,attribute_id,name,slug,woo_id) VALUES (1,8,'Flat','flat',300)"
+ )
+ conn.execute(
+ """INSERT INTO slabs(id,sku,length_in,thickness_in,sqft,bdft,width_min_in,width_max_in,width_avg_in,
+ species_id,wood_category_ids,edge_type_term_id,figure_term_ids,grade_term_id,thickness_term_id,
+ moisture_term_id,fig_tag_ids,feat_tag_ids,price,price_source,title,short_description,description,
+ content_source,status,server_rev,client_rev,created_at,updated_at) VALUES
+ ('s1','SLAB-01',114,1.75,30,18.4,18,24.3,24.3,1,'[2]',1,'[1]',1,1,1,'[]','[]',184,
+ 'recommendation','T','S','D','manual','ready',1,1,'t','t')"""
+ )
+ conn.commit()
+ tc = TestClient(app_mod.app)
+ r = tc.post("/api/v1/slabs/s1/publish")
+ assert r.status_code == 401, r.text
+ assert r.json()["error"]["code"] == "woo_device_creds_required"
diff --git a/backend/tests/test_publish.py b/backend/tests/test_publish.py
index daee066..d012cb4 100644
--- a/backend/tests/test_publish.py
+++ b/backend/tests/test_publish.py
@@ -36,6 +36,7 @@ def env(monkeypatch):
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()
+ os.environ["SLAB_WOO_ALLOW_SETTINGS_FALLBACK"] = "1" # legacy tests; prefer X-Woo-* headers
db_mod._reset_for_tests()
from backend.crypto import encrypt
diff --git a/backend/woo.py b/backend/woo.py
index d81a998..a407925 100644
--- a/backend/woo.py
+++ b/backend/woo.py
@@ -1,12 +1,11 @@
"""WooCommerce REST v3 client (stdlib urllib). Basic-auth, HTTPS-only, paginated.
-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).
+Shop-default creds live in Settings (wp_username plain, app_password AES-GCM) and
+are used by the health probe only. HTTP Woo routes (publish / taxonomy sync /
+test-woo) require X-Woo-Username + X-Woo-App-Password on the request — no
+Settings fallback. Missing headers raise WooDeviceCredsRequired. See PROTOTYPE.md.
-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.
+Optional escape hatch for legacy tests: SLAB_WOO_ALLOW_SETTINGS_FALLBACK=1.
"""
import base64
@@ -30,6 +29,10 @@ class WooCredentialsMissing(WooError):
"""Woo base URL or credentials are not configured (fail-closed)."""
+class WooDeviceCredsRequired(WooCredentialsMissing):
+ """X-Woo-Username + X-Woo-App-Password required; Settings fallback disabled."""
+
+
class WooAuthError(WooError):
"""The store rejected the credentials (401)."""
@@ -188,9 +191,9 @@ class WooClient:
# 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).
+# HTTP Woo routes REQUIRE both headers (no Settings fallback). Health probe
+# still uses shop-default Settings via WooClient.from_settings / settings_woo_creds.
+# Never write override headers into the Settings row.
HEADER_WOO_USERNAME = "X-Woo-Username"
HEADER_WOO_APP_PASSWORD = "X-Woo-App-Password"
@@ -218,10 +221,29 @@ def request_woo_override(headers) -> tuple[str, str] | None:
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.
+def _allow_settings_fallback() -> bool:
+ """Legacy-test escape hatch. Production HTTP Woo routes leave this unset."""
+ import os
- Never mutates Settings. Inference stays on shared Settings (caller responsibility).
+ return os.environ.get("SLAB_WOO_ALLOW_SETTINGS_FALLBACK", "").strip().lower() in (
+ "1",
+ "true",
+ "yes",
+ )
+
+
+def client_for_request(
+ conn,
+ headers=None,
+ request: Callable[..., tuple[int, dict, bytes]] | None = None,
+ *,
+ require_override: bool = True,
+) -> WooClient:
+ """Build a WooClient for one HTTP Woo request.
+
+ Requires X-Woo-* headers by default (no Settings fallback). Set
+ require_override=False or SLAB_WOO_ALLOW_SETTINGS_FALLBACK=1 only for
+ health-adjacent / legacy-test paths. Never mutates Settings.
"""
base, settings_user, settings_enc = settings_woo_creds(conn)
override = request_woo_override(headers)
@@ -230,4 +252,8 @@ def client_for_request(conn, headers=None, request: Callable[..., tuple[int, dic
if request is None:
request = _default_request
return WooClient(user, password, base, request)
+ if require_override and not _allow_settings_fallback():
+ raise WooDeviceCredsRequired(
+ "X-Woo-Username and X-Woo-App-Password are required on this device"
+ )
return WooClient.from_settings(base, settings_user, settings_enc, request=request)
diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte
index 0d10c6a..cc70e5f 100644
--- a/frontend/src/App.svelte
+++ b/frontend/src/App.svelte
@@ -11,12 +11,15 @@
import MaskPanel from './capture/MaskPanel.svelte';
import MeasurePanel from './capture/MeasurePanel.svelte';
import Settings from './create/settings/Settings.svelte';
+ import WooSetupGate from './create/settings/WooSetupGate.svelte';
import Review from './create/review/Review.svelte';
import Publish from './create/publish/Publish.svelte';
import {
flow, startSaving, enterReview, enterPublish, markDone, backToCapture, backToReview, type CalibratedCapture,
} from './create/flow';
- import { createSlab, uploadPhoto, getSettings, ApiError } from './api/slabs';
+ import { createSlab, uploadPhoto, ApiError } from './api/slabs';
+ import { hasDeviceWooCreds } from './api/wooDeviceCreds';
+ import { wooGateForcedOpen, clearWooGateForce } from './api/wooGate';
import { clearDraft } from './draft/idb';
const gate = typeof window !== 'undefined' ? supported() : 'window unavailable';
@@ -26,9 +29,12 @@
let busy = false;
let pngUrl: string | null = null;
let showSettings = false;
+ // Blocking Woo setup until this browser has device creds (or reopen after 401).
+ let showWooGate = typeof window !== 'undefined' ? !hasDeviceWooCreds() : true;
let curFlow: import('./create/flow').Flow = { phase: 'capture' };
$: curFlow = $flow;
+ $: if ($wooGateForcedOpen) showWooGate = true;
let saving = false;
let saveError = '';
@@ -38,7 +44,13 @@
});
const unsubBusy = session.busy.subscribe((b) => (busy = b));
+ function onWooGateReady(): void {
+ clearWooGateForce();
+ showWooGate = false;
+ }
+
onMount(() => {
+ showWooGate = !hasDeviceWooCreds() || $wooGateForcedOpen;
// e2e boot guard: `?noload` skips the resume probe so a test can seed the
// device draft into IndexedDB after the origin exists, then reload without
// ?noload to trigger the Resume prompt deterministically.
@@ -146,7 +158,10 @@
Missing: {gate}
{:else}
-
+ {#if showWooGate}
+
+ {/if}
+
{#if curFlow.phase === 'review'}
();
+
+const fakeLS = {
+ getItem(k: string) {
+ return mem.has(k) ? mem.get(k)! : null;
+ },
+ setItem(k: string, v: string) {
+ mem.set(k, String(v));
+ },
+ removeItem(k: string) {
+ mem.delete(k);
+ },
+};
+
+afterEach(() => {
+ mem.clear();
+});
+
+test('hasDeviceWooCreds is false until both fields are set', () => {
+ (globalThis as any).localStorage = fakeLS;
+ assert.equal(hasDeviceWooCreds(), false);
+ setDeviceWooCreds({ username: 'alice', appPassword: '' });
+ assert.equal(hasDeviceWooCreds(), false);
+ setDeviceWooCreds({ username: 'alice', appPassword: 'pw' });
+ assert.equal(hasDeviceWooCreds(), true);
+ assert.deepEqual(getDeviceWooCreds(), { username: 'alice', appPassword: 'pw' });
+ clearDeviceWooCreds();
+ assert.equal(hasDeviceWooCreds(), false);
+});
diff --git a/frontend/src/api/wooDeviceCreds.ts b/frontend/src/api/wooDeviceCreds.ts
index a883346..c0c8fb0 100644
--- a/frontend/src/api/wooDeviceCreds.ts
+++ b/frontend/src/api/wooDeviceCreds.ts
@@ -76,3 +76,9 @@ export function withWooHeaders(extra?: HeadersInit): HeadersInit {
}
return { ...extra, ...woo };
}
+
+/** True when this browser has both device Woo username and app password. */
+export function hasDeviceWooCreds(): boolean {
+ const { username, appPassword } = getDeviceWooCreds();
+ return Boolean(username && appPassword);
+}
diff --git a/frontend/src/api/wooGate.ts b/frontend/src/api/wooGate.ts
new file mode 100644
index 0000000..d0470cd
--- /dev/null
+++ b/frontend/src/api/wooGate.ts
@@ -0,0 +1,22 @@
+/**
+ * Blocking Woo setup gate visibility.
+ * App shows the overlay when device creds are missing or this store is forced open
+ * (e.g. publish/test-woo returned woo_auth / woo_device_creds_required).
+ */
+import { writable } from 'svelte/store';
+
+export const wooGateForcedOpen = writable(false);
+
+export function reopenWooGate(): void {
+ wooGateForcedOpen.set(true);
+}
+
+export function clearWooGateForce(): void {
+ wooGateForcedOpen.set(false);
+}
+
+/** True for ApiError shapes that mean "device Woo login needed again". */
+export function isWooAuthFailure(status: number, code: string | null): boolean {
+ if (status === 401) return true;
+ return code === 'woo_auth' || code === 'woo_device_creds_required';
+}
diff --git a/frontend/src/create/publish/Publish.svelte b/frontend/src/create/publish/Publish.svelte
index f86e65d..cdded5d 100644
--- a/frontend/src/create/publish/Publish.svelte
+++ b/frontend/src/create/publish/Publish.svelte
@@ -4,6 +4,7 @@
getSlab, getTaxonomy, listPhotos, photoUrl, publishSlab, ApiError,
type Slab, type Taxonomy, type PhotoRow,
} from '../../api/slabs';
+ import { reopenWooGate, isWooAuthFailure } from '../../api/wooGate';
import PublishPreview from './PublishPreview.svelte';
import { buildPublishPreviewModel, type PublishPreviewModel } from './publishPreviewModel';
@@ -58,6 +59,8 @@
.catch((e) => {
if (e instanceof ApiError) {
st = { phase: 'failure', fail: classify(e.code, e.message, e.detail ?? null) };
+ // Device Woo rejected or missing → reopen blocking setup gate.
+ if (isWooAuthFailure(e.status, e.code)) reopenWooGate();
} else {
st = { phase: 'failure', fail: { code: 'network', message: 'The store is unreachable right now. You can retry.', recoverable: true, partialMedia: false, duplicateSku: false } };
}
diff --git a/frontend/src/create/settings/Settings.svelte b/frontend/src/create/settings/Settings.svelte
index 70dcca2..c53e567 100644
--- a/frontend/src/create/settings/Settings.svelte
+++ b/frontend/src/create/settings/Settings.svelte
@@ -8,6 +8,7 @@
clearDeviceWooCreds,
withWooHeaders,
} from '../../api/wooDeviceCreds';
+ import { reopenWooGate, isWooAuthFailure } from '../../api/wooGate';
interface SecretState { configured: boolean }
// Masked mode: object {configured}. Unmasked UAT mode (SLAB_UAT_UNMASK_SECRETS): plain string.
@@ -272,13 +273,13 @@
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.';
+ : 'Cleared — publish / Test Woo need device credentials again.';
}
function clearDeviceWoo() {
clearDeviceWooCreds();
deviceWoo = { username: '', appPassword: '' };
- deviceMsg = 'Cleared — Woo calls will use shop-default Settings credentials.';
+ deviceMsg = 'Cleared — publish / Test Woo need device credentials again.';
}
async function testWoo() {
@@ -288,7 +289,18 @@
method: 'POST',
headers: withWooHeaders(),
});
- const body = await res.json();
+ const body = await res.json().catch(() => ({}));
+ if (isWooAuthFailure(res.status, body?.error?.code ?? null) || body?.ok === false) {
+ const detail =
+ body?.error?.message ||
+ body?.detail ||
+ (body?.ok === false ? 'failed' : 'Device Woo credentials required');
+ wooTest = { ok: false, detail };
+ if (isWooAuthFailure(res.status, body?.error?.code ?? null) || /auth rejected/i.test(detail)) {
+ reopenWooGate();
+ }
+ return;
+ }
wooTest = { ok: !!body.ok, detail: body.detail || (body.ok ? 'Connected & synced' : 'failed') };
if (wooTest.ok) await loadRates();
} catch {
@@ -325,11 +337,17 @@
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.
+ Per-browser WP user + application password (localStorage). Required on
+ publish, taxonomy sync, and Test Woo (sent as
+ X-Woo-Username / X-Woo-App-Password) — never
+ written into shared server Settings. A first-run gate collects these if
+ this browser has none yet.
+ This device has no Woo credentials yet. Save them here or reload to open the setup gate.
+
+ {/if}
@@ -354,9 +372,8 @@
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.
+ Shop-default row (health probe / legacy). Publish and Test Woo use this
+ device’s credentials only — do not put other users’ passwords here.
@@ -415,7 +432,7 @@
WooCommerce
-
Uses this device’s Woo credentials when set; otherwise shop-default Settings.
+
Uses this device’s Woo credentials (required). Shop-default Settings are not used on Test Woo.
+ Each browser uses its own WordPress user and application password. They stay on this
+ device only — not in shared server Settings. Inference stays shared for the shop.
+
+
+ {#if loading}
+
Loading…
+ {:else}
+ {#if loadError}
+
{loadError}
+
+ {:else}
+
+
+
+
From server env — display only.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Inference (shared)
+
+
+
+
+
+
+
+
+
Only filled values are saved to shared Settings. Device Woo is never written there.