proto(woo): per-browser Woo creds via X-Woo-* request headers

Prototype routing so 2–3 owners can share one store URL with different WP
application passwords. Device creds live in localStorage; server uses
request override → Settings fallback and never writes overrides into the
Settings row. Inference stays on shared Settings.

See PROTOTYPE.md for UAT with two browsers and known LAN forge risk.
This commit is contained in:
grokbot_ty
2026-09-26 12:26:21 -07:00
parent fc40c66c44
commit 2f6335ec26
8 changed files with 419 additions and 31 deletions
+12 -4
View File
@@ -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<PublishResult> {
return fetch(`/api/v1/slabs/${encodeURIComponent(slabId)}/publish`, {
method: 'POST',
headers: withWooHeaders(),
}).then((r) => parse<PublishResult>(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<TestWooResult> {
return fetch('/api/v1/settings/test-woo', { method: 'POST' }).then((r) =>
parse<TestWooResult>(r),
);
return fetch('/api/v1/settings/test-woo', {
method: 'POST',
headers: withWooHeaders(),
}).then((r) => parse<TestWooResult>(r));
}
export function testInference(): Promise<TestInferenceResult> {
+78
View File
@@ -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<string, string> {
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 };
}
+76 -3
View File
@@ -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 @@
<input type="text" value={settings.woo_base_url} disabled />
</div>
<div class="section" data-testid="device-woo-creds">
<h3>This device’s Woo credentials</h3>
<p class="hint">
Per-browser WP user + application password (localStorage). Sent as
<code>X-Woo-Username</code> / <code>X-Woo-App-Password</code> on publish,
taxonomy sync, and Test Woo — never written into shared server Settings.
Leave blank to use the shop-default below.
</p>
<div>
<label>Device WP Username</label>
<input bind:value={deviceWoo.username} data-testid="device-woo-username" autocomplete="username" />
</div>
<div>
<label>Device App Password</label>
<input
type="password"
bind:value={deviceWoo.appPassword}
data-testid="device-woo-app-password"
autocomplete="current-password"
placeholder="••••••••"
/>
</div>
<div class="row">
<button type="button" on:click={saveDeviceWoo} data-testid="save-device-woo">Save on this device</button>
<button type="button" class="ghost" on:click={clearDeviceWoo} data-testid="clear-device-woo">Clear</button>
</div>
{#if deviceMsg}<p class="msg">{deviceMsg}</p>{/if}
</div>
<div class="section">
<h3>Shop-default Woo (server Settings)</h3>
<p class="hint">
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.
</p>
<div>
<label>WP Username</label>
<label>Shop-default WP Username</label>
<input bind:value={form.woo_wp_username} />
</div>
<div>
<label>Woo App Password {secretConfigured(settings.woo_app_password) ? '(configured)' : '(not set)'}</label>
<label>Shop-default Woo App Password {secretConfigured(settings.woo_app_password) ? '(configured)' : '(not set)'}</label>
<input bind:value={form.woo_app_password} placeholder={isUnmasked(settings.woo_app_password) ? '' : '••••••••'} />
</div>
</div>
<div>
<label>Inference Base URL {secretConfigured(settings.inference_base_url) ? '(configured)' : '(not set)'}</label>
@@ -347,6 +415,7 @@
<div class="section">
<h3>WooCommerce</h3>
<p class="hint">Uses this device’s Woo credentials when set; otherwise shop-default Settings.</p>
<button on:click={testWoo} disabled={saving || wooTest?.busy}>Test WooCommerce connection</button>
{#if wooTest}
<p class="msg" class:ok={wooTest.ok} class:err={!wooTest.ok}>{wooTest.detail}</p>
@@ -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; }
</style>