proto(woo): blocking setup gate; require X-Woo-* on Woo HTTP routes

Full-screen WooSetupGate on App mount when this browser lacks device WP
user + app password. Save + Test Woo dismisses only after a successful
test. Publish/test-woo auth failures reopen the gate.

Server: client_for_request requires X-Woo-* on publish, taxonomy sync,
and test-woo (401 woo_device_creds_required); health probe keeps
Settings. Optional SLAB_WOO_ALLOW_SETTINGS_FALLBACK for legacy tests.

See PROTOTYPE.md.
This commit is contained in:
grokbot_ty
2026-09-26 19:20:16 -07:00
parent 2f6335ec26
commit 2f9b8ebd02
16 changed files with 600 additions and 58 deletions
+24 -17
View File
@@ -13,44 +13,51 @@ No full auth, ACL, or publish mutex in this prototype.
| Layer | What | | Layer | What |
| --- | --- | | --- | --- |
| Browser | `localStorage` keys `slabuploader.woo.device.username` / `…appPassword` | | Browser | `localStorage` keys `slabuploader.woo.device.username` / `…appPassword` |
| HTTP | Headers `X-Woo-Username` + `X-Woo-App-Password` on Woo-touching calls | | Gate | Full-screen `WooSetupGate` on App mount when this browser lacks device creds |
| Server | `client_for_request(conn, headers)` → override if both headers non-blank, else Settings singleton | | HTTP | Headers `X-Woo-Username` + `X-Woo-App-Password` on Woo-touching calls (**required**) |
| Settings | Shop-default `woo_wp_username` / encrypted `woo_app_password` — **never** updated by the device-creds path | | 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/slabs/{id}/publish`
- `POST /api/v1/admin/taxonomy/sync` - `POST /api/v1/admin/taxonomy/sync`
- `POST /api/v1/settings/test-woo` - `POST /api/v1/settings/test-woo`
Inference / Call 2 / Settings PUT for inference keys are unchanged (shared Settings). 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) ## Flow (request → WooClient)
1. Frontend reads device creds from localStorage (`wooDeviceCreds.ts`). 1. Frontend reads device creds from localStorage (`wooDeviceCreds.ts`).
2. `withWooHeaders()` merges `X-Woo-*` onto the fetch. 2. `withWooHeaders()` merges `X-Woo-*` onto the fetch.
3. Backend `client_for_request` builds a **new** `WooClient` for that request only 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 4. Publish / taxonomy / media use that client instance — no process-global
“current user”. “current user”.
## UAT with two browsers ## 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. 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 3. Browser A (or profile A): setup gate → enter user A + app password →
user A + app password → **Save on this device** → **Test WooCommerce connection**. **Save device creds & Test Woo** → gate dismisses.
4. Browser B: same for user B (separate localStorage). 4. Browser B: same for user B (separate localStorage).
5. Confirm Test Woo succeeds for both, and that **Save Changes** on shop-default 5. Confirm Test Woo succeeds for both, and that device passwords were not written
fields was not used to store the other user’s password. into shop-default Settings.
6. Publish different slabs from each browser (do not work the same slab concurrently). 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. 7. Optional: clear device creds in Settings → reload → gate reappears.
## 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) ## Risks left (prototype)
+14 -3
View File
@@ -211,6 +211,7 @@ from .woo import (
WooAuthError, WooAuthError,
WooConnectionError, WooConnectionError,
WooCredentialsMissing, WooCredentialsMissing,
WooDeviceCredsRequired,
WooError, WooError,
WooForbiddenError, WooForbiddenError,
client_for_request, client_for_request,
@@ -225,11 +226,16 @@ def _woo_credentials() -> tuple[str, str | None, str | None]:
@app.post("/api/v1/settings/test-woo") @app.post("/api/v1/settings/test-woo")
async def test_woo(request: Request) -> dict[str, Any]: async def test_woo(request: Request) -> dict[str, Any]:
"""Probe Woo + sync taxonomy. Optional X-Woo-Username / X-Woo-App-Password """Probe Woo + sync taxonomy. Requires X-Woo-Username / X-Woo-App-Password
override shop-default Settings for this request only (never persisted). (device creds); never reads shop-default Settings for this route.
""" """
try: try:
client = client_for_request(get_connection(), request.headers) 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: except WooCredentialsMissing as e:
return { return {
"ok": False, "ok": False,
@@ -262,9 +268,14 @@ async def get_taxonomy() -> dict[str, Any]:
@app.post("/api/v1/admin/taxonomy/sync") @app.post("/api/v1/admin/taxonomy/sync")
async def post_taxonomy_sync(request: Request) -> dict[str, Any]: 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: try:
client = client_for_request(get_connection(), request.headers) 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: except WooCredentialsMissing as e:
return JSONResponse(status_code=502, content=_error("woo_unavailable", str(e))) return JSONResponse(status_code=502, content=_error("woo_unavailable", str(e)))
try: try:
+16 -1
View File
@@ -21,6 +21,7 @@ from .woo import (
WooClient, WooClient,
WooConnectionError, WooConnectionError,
WooCredentialsMissing, WooCredentialsMissing,
WooDeviceCredsRequired,
WooError, WooError,
WooForbiddenError, WooForbiddenError,
client_for_request, 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]: def _classify(e: Exception) -> tuple[str, str, bool]:
"""Return (code, plain message, recoverable). PUBLISH-36/37/38.""" """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): if isinstance(e, WooCredentialsMissing):
return "woo_unconfigured", "Woo is not configured. Set the store credentials in Settings.", False return "woo_unconfigured", "Woo is not configured. Set the store credentials in Settings.", False
if isinstance(e, WooAuthError): if isinstance(e, WooAuthError):
@@ -152,9 +159,17 @@ async def publish(slab_id: str, request: Request) -> dict:
if not ready: if not ready:
return JSONResponse(status_code=422, content=_error("unpublishable", "Slab is not ready", {"missing": missing})) 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. # 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: try:
client = client_for_request(conn, request.headers) 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: except WooCredentialsMissing as e:
return JSONResponse(status_code=422, content=_error("woo_unconfigured", str(e))) return JSONResponse(status_code=422, content=_error("woo_unconfigured", str(e)))
try: try:
+1
View File
@@ -21,6 +21,7 @@ def env(monkeypatch):
os.environ["SLAB_IMAGES_DIR"] = tmp os.environ["SLAB_IMAGES_DIR"] = tmp
os.environ["WOO_BASE_URL"] = "https://store.example.com" os.environ["WOO_BASE_URL"] = "https://store.example.com"
os.environ["SLAB_AES_KEY"] = base64.b64encode(b"0123456789abcdef0123456789abcdef").decode() 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() db_mod._reset_for_tests()
from backend.crypto import encrypt from backend.crypto import encrypt
@@ -59,6 +59,7 @@ def env(monkeypatch):
os.environ["SLAB_IMAGES_DIR"] = tmp os.environ["SLAB_IMAGES_DIR"] = tmp
os.environ["WOO_BASE_URL"] = "https://store.example.com" os.environ["WOO_BASE_URL"] = "https://store.example.com"
os.environ["SLAB_AES_KEY"] = base64.b64encode(b"0123456789abcdef0123456789abcdef").decode() 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() db_mod._reset_for_tests()
from backend.crypto import encrypt from backend.crypto import encrypt
+76 -11
View File
@@ -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 base64
import os import os
@@ -12,6 +12,7 @@ import backend.db as db_mod
from backend.crypto import encrypt, decrypt from backend.crypto import encrypt, decrypt
from backend.woo import ( from backend.woo import (
WooClient, WooClient,
WooDeviceCredsRequired,
client_for_request, client_for_request,
request_woo_override, request_woo_override,
settings_woo_creds, settings_woo_creds,
@@ -27,6 +28,7 @@ def db_dir():
os.environ["SLAB_IMAGES_DIR"] = tmp os.environ["SLAB_IMAGES_DIR"] = tmp
os.environ["WOO_BASE_URL"] = "https://store.example.com" os.environ["WOO_BASE_URL"] = "https://store.example.com"
os.environ["SLAB_AES_KEY"] = base64.b64encode(b"0123456789abcdef0123456789abcdef").decode() os.environ["SLAB_AES_KEY"] = base64.b64encode(b"0123456789abcdef0123456789abcdef").decode()
os.environ.pop("SLAB_WOO_ALLOW_SETTINGS_FALLBACK", None)
db_mod._reset_for_tests() db_mod._reset_for_tests()
conn = db_mod.init_db() conn = db_mod.init_db()
conn.execute( 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_a = client_for_request(conn, h_a)
client_b = client_for_request(conn, h_b) 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_a) == ("alice", "alice-app-pw")
assert _auth_pair(client_b) == ("bob", "bob-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 assert client_a._auth != client_b._auth
after = settings_woo_creds(conn) after = settings_woo_creds(conn)
assert after == before assert after == before
assert decrypt(after[2]) == "shop-secret" 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_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] row_p = conn.execute("SELECT value_enc FROM settings WHERE key='woo_app_password'").fetchone()[0]
assert row_u == "shop-default" assert row_u == "shop-default"
assert decrypt(row_p) == "shop-secret" 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 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 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") assert _auth_pair(client) == ("shop-default", "shop-secret")
def test_test_woo_endpoint_uses_request_override(db_dir, monkeypatch): def test_test_woo_endpoint_requires_headers(db_dir, monkeypatch):
"""API: POST /settings/test-woo with X-Woo-* builds client for that user.""" """API: POST /settings/test-woo with X-Woo-* builds client; missing → 401."""
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from backend import app as app_mod 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") r3 = tc.post("/api/v1/settings/test-woo")
assert r1.status_code == 200 and r1.json()["ok"] is True assert r1.status_code == 200 and r1.json()["ok"] is True
assert r2.status_code == 200 and r2.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 r3.status_code == 401
assert seen == ["alice", "bob", "shop-default"] assert r3.json()["error"]["code"] == "woo_device_creds_required"
assert seen == ["alice", "bob"]
assert settings_woo_creds(db_dir)[1] == "shop-default" 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"
+1
View File
@@ -36,6 +36,7 @@ def env(monkeypatch):
os.environ["SLAB_IMAGES_DIR"] = tmp os.environ["SLAB_IMAGES_DIR"] = tmp
os.environ["WOO_BASE_URL"] = "https://store.example.com" os.environ["WOO_BASE_URL"] = "https://store.example.com"
os.environ["SLAB_AES_KEY"] = base64.b64encode(b"0123456789abcdef0123456789abcdef").decode() 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() db_mod._reset_for_tests()
from backend.crypto import encrypt from backend.crypto import encrypt
+38 -12
View File
@@ -1,12 +1,11 @@
"""WooCommerce REST v3 client (stdlib urllib). Basic-auth, HTTPS-only, paginated. """WooCommerce REST v3 client (stdlib urllib). Basic-auth, HTTPS-only, paginated.
Default creds come from Settings (wp_username plain, app_password AES-GCM). Shop-default creds live in Settings (wp_username plain, app_password AES-GCM) and
Missing creds raise WooCredentialsMissing (fail-closed), distinct from are used by the health probe only. HTTP Woo routes (publish / taxonomy sync /
WooAuthError (401 from the store). 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 Optional escape hatch for legacy tests: SLAB_WOO_ALLOW_SETTINGS_FALLBACK=1.
request headers into client_for_request(); those build a per-request WooClient
and are never written into Settings. See PROTOTYPE.md.
""" """
import base64 import base64
@@ -30,6 +29,10 @@ class WooCredentialsMissing(WooError):
"""Woo base URL or credentials are not configured (fail-closed).""" """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): class WooAuthError(WooError):
"""The store rejected the credentials (401).""" """The store rejected the credentials (401)."""
@@ -188,9 +191,9 @@ class WooClient:
# Woo-touching API calls via: # Woo-touching API calls via:
# X-Woo-Username # X-Woo-Username
# X-Woo-App-Password # X-Woo-App-Password
# Server uses those for THIS request's WooClient only; never writes them into # HTTP Woo routes REQUIRE both headers (no Settings fallback). Health probe
# the Settings row. If either header is missing/blank, fall back to the # still uses shop-default Settings via WooClient.from_settings / settings_woo_creds.
# shop-default singleton (woo_wp_username + encrypted woo_app_password). # Never write override headers into the Settings row.
HEADER_WOO_USERNAME = "X-Woo-Username" HEADER_WOO_USERNAME = "X-Woo-Username"
HEADER_WOO_APP_PASSWORD = "X-Woo-App-Password" HEADER_WOO_APP_PASSWORD = "X-Woo-App-Password"
@@ -218,10 +221,29 @@ def request_woo_override(headers) -> tuple[str, str] | None:
return None return None
def client_for_request(conn, headers=None, request: Callable[..., tuple[int, dict, bytes]] | None = None) -> WooClient: def _allow_settings_fallback() -> bool:
"""Build a WooClient for one HTTP request: override headers → Settings fallback. """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) base, settings_user, settings_enc = settings_woo_creds(conn)
override = request_woo_override(headers) 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: if request is None:
request = _default_request request = _default_request
return WooClient(user, password, base, 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) return WooClient.from_settings(base, settings_user, settings_enc, request=request)
+18 -2
View File
@@ -11,12 +11,15 @@
import MaskPanel from './capture/MaskPanel.svelte'; import MaskPanel from './capture/MaskPanel.svelte';
import MeasurePanel from './capture/MeasurePanel.svelte'; import MeasurePanel from './capture/MeasurePanel.svelte';
import Settings from './create/settings/Settings.svelte'; import Settings from './create/settings/Settings.svelte';
import WooSetupGate from './create/settings/WooSetupGate.svelte';
import Review from './create/review/Review.svelte'; import Review from './create/review/Review.svelte';
import Publish from './create/publish/Publish.svelte'; import Publish from './create/publish/Publish.svelte';
import { import {
flow, startSaving, enterReview, enterPublish, markDone, backToCapture, backToReview, type CalibratedCapture, flow, startSaving, enterReview, enterPublish, markDone, backToCapture, backToReview, type CalibratedCapture,
} from './create/flow'; } 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'; import { clearDraft } from './draft/idb';
const gate = typeof window !== 'undefined' ? supported() : 'window unavailable'; const gate = typeof window !== 'undefined' ? supported() : 'window unavailable';
@@ -26,9 +29,12 @@
let busy = false; let busy = false;
let pngUrl: string | null = null; let pngUrl: string | null = null;
let showSettings = false; 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' }; let curFlow: import('./create/flow').Flow = { phase: 'capture' };
$: curFlow = $flow; $: curFlow = $flow;
$: if ($wooGateForcedOpen) showWooGate = true;
let saving = false; let saving = false;
let saveError = ''; let saveError = '';
@@ -38,7 +44,13 @@
}); });
const unsubBusy = session.busy.subscribe((b) => (busy = b)); const unsubBusy = session.busy.subscribe((b) => (busy = b));
function onWooGateReady(): void {
clearWooGateForce();
showWooGate = false;
}
onMount(() => { onMount(() => {
showWooGate = !hasDeviceWooCreds() || $wooGateForcedOpen;
// e2e boot guard: `?noload` skips the resume probe so a test can seed the // 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 // device draft into IndexedDB after the origin exists, then reload without
// ?noload to trigger the Resume prompt deterministically. // ?noload to trigger the Resume prompt deterministically.
@@ -146,7 +158,10 @@
<p class="detail">Missing: {gate}</p> <p class="detail">Missing: {gate}</p>
</main> </main>
{:else} {:else}
<main class="app"> {#if showWooGate}
<WooSetupGate onReady={onWooGateReady} />
{/if}
<main class="app" class:gated={showWooGate} aria-hidden={showWooGate ? 'true' : undefined}>
{#if curFlow.phase === 'review'} {#if curFlow.phase === 'review'}
<Review <Review
slabId={curFlow.slabId} slabId={curFlow.slabId}
@@ -256,6 +271,7 @@
} }
.app, .hardstop { max-width: 720px; margin: 0 auto; min-height: 100vh; box-sizing: border-box; } .app, .hardstop { max-width: 720px; margin: 0 auto; min-height: 100vh; box-sizing: border-box; }
.app { padding: 12px 16px 24px; } .app { padding: 12px 16px 24px; }
.app.gated { visibility: hidden; pointer-events: none; }
.hardstop { padding: 48px 24px; color: #eee; text-align: center; } .hardstop { padding: 48px 24px; color: #eee; text-align: center; }
.hardstop h1 { font-size: 1.6rem; } .hardstop h1 { font-size: 1.6rem; }
.detail { color: #888; font-size: 0.85rem; } .detail { color: #888; font-size: 0.85rem; }
+1 -1
View File
@@ -7,7 +7,7 @@
// publish.py POST /api/v1/slabs/{id}/publish // publish.py POST /api/v1/slabs/{id}/publish
// inference.py POST /api/v1/slabs/{id}/inference/call1 (DISABLED 410) | call2 // 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 // 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. // Woo-backed calls attach X-Woo-Username / X-Woo-App-Password from localStorage (required on those routes).
// taxonomy.py GET/POST /api/v1/admin/taxonomy[.../sync] // taxonomy.py GET/POST /api/v1/admin/taxonomy[.../sync]
// pricing.py CRUD /api/v1/admin/pricing[/{id}] // pricing.py CRUD /api/v1/admin/pricing[/{id}]
// //
+38
View File
@@ -0,0 +1,38 @@
import assert from 'node:assert/strict';
import { afterEach, test } from 'node:test';
import {
clearDeviceWooCreds,
getDeviceWooCreds,
hasDeviceWooCreds,
setDeviceWooCreds,
} from './wooDeviceCreds';
const mem = new Map<string, string>();
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);
});
+6
View File
@@ -76,3 +76,9 @@ export function withWooHeaders(extra?: HeadersInit): HeadersInit {
} }
return { ...extra, ...woo }; 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);
}
+22
View File
@@ -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';
}
@@ -4,6 +4,7 @@
getSlab, getTaxonomy, listPhotos, photoUrl, publishSlab, ApiError, getSlab, getTaxonomy, listPhotos, photoUrl, publishSlab, ApiError,
type Slab, type Taxonomy, type PhotoRow, type Slab, type Taxonomy, type PhotoRow,
} from '../../api/slabs'; } from '../../api/slabs';
import { reopenWooGate, isWooAuthFailure } from '../../api/wooGate';
import PublishPreview from './PublishPreview.svelte'; import PublishPreview from './PublishPreview.svelte';
import { buildPublishPreviewModel, type PublishPreviewModel } from './publishPreviewModel'; import { buildPublishPreviewModel, type PublishPreviewModel } from './publishPreviewModel';
@@ -58,6 +59,8 @@
.catch((e) => { .catch((e) => {
if (e instanceof ApiError) { if (e instanceof ApiError) {
st = { phase: 'failure', fail: classify(e.code, e.message, e.detail ?? null) }; 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 { } else {
st = { phase: 'failure', fail: { code: 'network', message: 'The store is unreachable right now. You can retry.', recoverable: true, partialMedia: false, duplicateSku: false } }; st = { phase: 'failure', fail: { code: 'network', message: 'The store is unreachable right now. You can retry.', recoverable: true, partialMedia: false, duplicateSku: false } };
} }
+28 -11
View File
@@ -8,6 +8,7 @@
clearDeviceWooCreds, clearDeviceWooCreds,
withWooHeaders, withWooHeaders,
} from '../../api/wooDeviceCreds'; } from '../../api/wooDeviceCreds';
import { reopenWooGate, isWooAuthFailure } from '../../api/wooGate';
interface SecretState { configured: boolean } interface SecretState { configured: boolean }
// Masked mode: object {configured}. Unmasked UAT mode (SLAB_UAT_UNMASK_SECRETS): plain string. // Masked mode: object {configured}. Unmasked UAT mode (SLAB_UAT_UNMASK_SECRETS): plain string.
@@ -272,13 +273,13 @@
deviceWoo = getDeviceWooCreds(); deviceWoo = getDeviceWooCreds();
deviceMsg = deviceWoo.username && deviceWoo.appPassword deviceMsg = deviceWoo.username && deviceWoo.appPassword
? 'Saved on this device (localStorage). Not written to server Settings.' ? '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() { function clearDeviceWoo() {
clearDeviceWooCreds(); clearDeviceWooCreds();
deviceWoo = { username: '', appPassword: '' }; 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() { async function testWoo() {
@@ -288,7 +289,18 @@
method: 'POST', method: 'POST',
headers: withWooHeaders(), 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') }; wooTest = { ok: !!body.ok, detail: body.detail || (body.ok ? 'Connected & synced' : 'failed') };
if (wooTest.ok) await loadRates(); if (wooTest.ok) await loadRates();
} catch { } catch {
@@ -325,11 +337,17 @@
<div class="section" data-testid="device-woo-creds"> <div class="section" data-testid="device-woo-creds">
<h3>This device’s Woo credentials</h3> <h3>This device’s Woo credentials</h3>
<p class="hint"> <p class="hint">
Per-browser WP user + application password (localStorage). Sent as Per-browser WP user + application password (localStorage). Required on
<code>X-Woo-Username</code> / <code>X-Woo-App-Password</code> on publish, publish, taxonomy sync, and Test Woo (sent as
taxonomy sync, and Test Woo — never written into shared server Settings. <code>X-Woo-Username</code> / <code>X-Woo-App-Password</code>) — never
Leave blank to use the shop-default below. written into shared server Settings. A first-run gate collects these if
this browser has none yet.
</p> </p>
{#if !(deviceWoo.username && deviceWoo.appPassword)}
<p class="msg err" data-testid="device-woo-reminder">
This device has no Woo credentials yet. Save them here or reload to open the setup gate.
</p>
{/if}
<div> <div>
<label>Device WP Username</label> <label>Device WP Username</label>
<input bind:value={deviceWoo.username} data-testid="device-woo-username" autocomplete="username" /> <input bind:value={deviceWoo.username} data-testid="device-woo-username" autocomplete="username" />
@@ -354,9 +372,8 @@
<div class="section"> <div class="section">
<h3>Shop-default Woo (server Settings)</h3> <h3>Shop-default Woo (server Settings)</h3>
<p class="hint"> <p class="hint">
Shared fallback for this install when this device has no local credentials. Shop-default row (health probe / legacy). Publish and Test Woo use this
Saving here updates the encrypted Settings row — do not put other users’ device’s credentials only — do not put other users’ passwords here.
passwords here.
</p> </p>
<div> <div>
<label>Shop-default WP Username</label> <label>Shop-default WP Username</label>
@@ -415,7 +432,7 @@
<div class="section"> <div class="section">
<h3>WooCommerce</h3> <h3>WooCommerce</h3>
<p class="hint">Uses this device’s Woo credentials when set; otherwise shop-default Settings.</p> <p class="hint">Uses this device’s Woo credentials (required). Shop-default Settings are not used on Test Woo.</p>
<button on:click={testWoo} disabled={saving || wooTest?.busy}>Test WooCommerce connection</button> <button on:click={testWoo} disabled={saving || wooTest?.busy}>Test WooCommerce connection</button>
{#if wooTest} {#if wooTest}
<p class="msg" class:ok={wooTest.ok} class:err={!wooTest.ok}>{wooTest.detail}</p> <p class="msg" class:ok={wooTest.ok} class:err={!wooTest.ok}>{wooTest.detail}</p>
@@ -0,0 +1,313 @@
<script lang="ts">
import { onMount } from 'svelte';
import {
getDeviceWooCreds,
setDeviceWooCreds,
withWooHeaders,
} from '../../api/wooDeviceCreds';
import { getSettings, updateSettings, type SettingsView } from '../../api/slabs';
/** Called only after Save + Test Woo succeeds. */
export let onReady: () => void;
type Secret = { configured: boolean } | string;
const isUnmasked = (s: Secret) => typeof s === 'string';
const secretValue = (s: Secret): string => (isUnmasked(s) ? (s as string) : '');
const secretConfigured = (s: Secret | undefined | null): boolean => {
if (s == null) return false;
return isUnmasked(s) ? (s as string).length > 0 : !!(s as { configured: boolean }).configured;
};
const inferenceUrlConfigured = (): boolean =>
settings ? secretConfigured(settings.inference_base_url as Secret) : false;
const inferenceKeyConfigured = (): boolean =>
settings ? secretConfigured(settings.inference_api_key as Secret) : false;
const inferenceUrlMaskedKeep = (): boolean =>
!!(settings && !isUnmasked(settings.inference_base_url as Secret) && inferenceUrlConfigured());
const inferenceKeyMaskedKeep = (): boolean =>
!!(settings && !isUnmasked(settings.inference_api_key as Secret) && inferenceKeyConfigured());
let loading = true;
let loadError = '';
let settings: SettingsView | null = null;
let username = '';
let appPassword = '';
let inferenceUrl = '';
let inferenceKey = '';
let busy = false;
let status = '';
let statusOk = false;
onMount(() => {
const existing = getDeviceWooCreds();
username = existing.username;
appPassword = existing.appPassword;
void loadSettings();
});
async function loadSettings() {
loading = true;
loadError = '';
try {
settings = await getSettings();
// Prefill inference only when unmasked (UAT); otherwise leave blank write-only.
inferenceUrl = secretValue(settings.inference_base_url as Secret);
inferenceKey = secretValue(settings.inference_api_key as Secret);
} catch {
loadError = 'Could not load settings. Check the connection and try again.';
} finally {
loading = false;
}
}
async function saveAndTest() {
const user = username.trim();
const pass = appPassword.trim();
if (!user || !pass) {
status = 'Enter this device’s WordPress user and application password.';
statusOk = false;
return;
}
busy = true;
status = 'Saving…';
statusOk = false;
try {
// Inference only — never write device Woo into Settings.
const inferPatch: Record<string, string> = {};
if (inferenceUrl.trim()) inferPatch.inference_base_url = inferenceUrl.trim();
if (inferenceKey.trim()) inferPatch.inference_api_key = inferenceKey.trim();
if (Object.keys(inferPatch).length) {
settings = await updateSettings(inferPatch);
inferenceUrl = secretValue(settings.inference_base_url as Secret);
inferenceKey = secretValue(settings.inference_api_key as Secret);
}
setDeviceWooCreds({ username: user, appPassword: pass });
username = user;
appPassword = pass;
status = 'Testing WooCommerce…';
const res = await fetch('/api/v1/settings/test-woo', {
method: 'POST',
headers: withWooHeaders(),
});
let body: any = null;
try {
body = await res.json();
} catch {
body = null;
}
if (res.status === 401 || (body && body.error?.code === 'woo_device_creds_required')) {
status = body?.error?.message || 'Device Woo credentials required.';
statusOk = false;
return;
}
if (!res.ok) {
status = body?.error?.message || body?.detail || `Test failed (${res.status})`;
statusOk = false;
return;
}
if (body && body.ok) {
status = body.detail || 'Connected & synced';
statusOk = true;
onReady();
return;
}
status = (body && body.detail) || 'Store rejected the credentials. Check user and app password.';
statusOk = false;
} catch {
status = 'Network error — try again.';
statusOk = false;
} finally {
busy = false;
}
}
</script>
<div class="gate" role="dialog" aria-modal="true" aria-labelledby="woo-gate-title" data-testid="woo-setup-gate">
<div class="card">
<h1 id="woo-gate-title">Set up WooCommerce for this device</h1>
<p class="lede">
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.
</p>
{#if loading}
<p class="busy">Loading…</p>
{:else}
{#if loadError}
<p class="err" role="alert">{loadError}</p>
<button type="button" class="ghost" on:click={() => void loadSettings()}>Retry</button>
{:else}
<div class="field">
<label for="woo-base">Woo store URL</label>
<input id="woo-base" type="text" value={settings?.woo_base_url || ''} disabled data-testid="woo-gate-base-url" />
<p class="hint">From server env — display only.</p>
</div>
<div class="field">
<label for="woo-user">Device WordPress username</label>
<input
id="woo-user"
type="text"
bind:value={username}
autocomplete="username"
data-testid="woo-gate-username"
required
/>
</div>
<div class="field">
<label for="woo-pass">Device application password</label>
<input
id="woo-pass"
type="password"
bind:value={appPassword}
autocomplete="current-password"
placeholder="••••••••"
data-testid="woo-gate-app-password"
required
/>
</div>
<div class="section">
<h2>Inference (shared)</h2>
<div class="field">
<label for="inf-url">
Inference base URL
{#if inferenceUrlConfigured()}
<span class="tag">configured</span>
{:else}
<span class="tag miss">not set</span>
{/if}
</label>
<input
id="inf-url"
type="text"
bind:value={inferenceUrl}
placeholder={inferenceUrlMaskedKeep() ? '•••••••• (leave blank to keep)' : 'https://…'}
data-testid="woo-gate-inference-url"
/>
</div>
<div class="field">
<label for="inf-key">
Inference API key
{#if inferenceKeyConfigured()}
<span class="tag">configured</span>
{:else}
<span class="tag miss">not set</span>
{/if}
</label>
<input
id="inf-key"
type="password"
bind:value={inferenceKey}
placeholder={inferenceKeyMaskedKeep() ? '•••••••• (leave blank to keep)' : ''}
data-testid="woo-gate-inference-key"
/>
</div>
<p class="hint">Only filled values are saved to shared Settings. Device Woo is never written there.</p>
</div>
<button
type="button"
class="primary"
disabled={busy || !username.trim() || !appPassword.trim()}
data-testid="woo-gate-save-test"
on:click={() => void saveAndTest()}
>
{busy ? 'Working…' : 'Save device creds & Test Woo'}
</button>
{#if status}
<p class="msg" class:ok={statusOk} class:err={!statusOk} role="status" data-testid="woo-gate-status">{status}</p>
{/if}
{/if}
{/if}
</div>
</div>
<style>
.gate {
position: fixed;
inset: 0;
z-index: 1000;
background: rgba(8, 8, 8, 0.92);
display: flex;
align-items: flex-start;
justify-content: center;
overflow-y: auto;
padding: 24px 16px 48px;
box-sizing: border-box;
}
.card {
width: 100%;
max-width: 520px;
background: #1a1a1a;
border: 1px solid #333;
border-radius: 4px;
padding: 20px 18px 24px;
color: #eee;
box-sizing: border-box;
}
h1 { font-size: 1.35rem; margin: 0 0 8px; }
h2 { font-size: 1.05rem; margin: 0 0 10px; color: #ddd; }
.lede { color: #aaa; font-size: 0.9rem; margin: 0 0 18px; line-height: 1.4; }
.section {
margin: 18px 0 8px;
padding-top: 14px;
border-top: 1px solid #333;
}
.field { margin-bottom: 12px; }
label { display: block; margin: 0 0 4px; font-size: 0.9rem; color: #ccc; }
input {
width: 100%;
box-sizing: border-box;
padding: 10px 10px;
border-radius: 3px;
border: 1px solid #444;
background: #121212;
color: #eee;
font-size: 1rem;
}
input:disabled { color: #888; background: #1c1c1c; }
.hint { margin: 4px 0 0; color: #888; font-size: 0.8rem; }
.tag {
display: inline-block;
margin-left: 6px;
font-size: 0.7rem;
padding: 1px 6px;
border-radius: 2px;
background: #2a3a2a;
color: #7fd37f;
font-weight: 600;
}
.tag.miss { background: #3a2a2a; color: #e8a; }
.primary {
width: 100%;
margin-top: 12px;
padding: 14px;
font-size: 1rem;
border-radius: 3px;
border: 0;
background: #00e5ff;
color: #002;
font-weight: 700;
cursor: pointer;
}
.primary:disabled { background: #333; color: #777; cursor: not-allowed; }
.ghost {
margin-top: 8px;
padding: 10px 14px;
border-radius: 3px;
border: 1px solid #444;
background: transparent;
color: #ccc;
font-weight: 600;
cursor: pointer;
}
.msg { margin-top: 12px; font-weight: 600; }
.msg.ok { color: #7fd37f; }
.msg.err, .err { color: #ffb4a2; }
.busy { color: #aaa; }
</style>