Merge pull request #3 from nexa-sol/feat/prod-backend-and-settings-panel

Real settings panel + connect to production backend
This commit is contained in:
2026-09-08 06:08:13 -04:00
committed by GitHub
9 changed files with 346 additions and 44 deletions
+4 -3
View File
@@ -87,12 +87,13 @@ Note the naming mismatch with the wire protocol below: the frontend's internal `
## Backend connection (implemented) ## Backend connection (implemented)
- `src/shared/config.ts` — `BACKEND_HTTP_URL`/`BACKEND_WS_URL`, currently hardcoded to `localhost:8080` (dev only; `host_permissions` in `wxt.config.ts` must stay in sync with whatever host is configured here). Deliberately not `:3000` — that's this extension's own Vite dev server port (`npm run dev`), and running the backend on the same port breaks the dev popup silently: its script tags point at Vite, but the backend answers instead, so nothing ever renders. If you see a blank popup with `http://localhost:3000/...` script tags in "View Page Source" that 404 or return something unexpected, this port collision is the first thing to check. - `src/shared/config.ts` — `BACKEND_HTTP_URL`/`BACKEND_WS_URL`, pointed at the real deployed backend (`api.osias.trade`, TLS). `host_permissions` in `wxt.config.ts` must stay in sync with whatever host is configured here.
- **Firefox-only CSP override** in `wxt.config.ts`: Firefox's *implicit* default extension-pages CSP includes `upgrade-insecure-requests`, which silently rewrites the WS client's `ws://localhost:8080/ws` connection to `wss://` and breaks it against the plaintext local dev backend (no TLS in dev, deliberately — see backend/CLAUDE.md). Symptom: `Content-Security-Policy: Upgrading insecure request 'ws://...' to use 'wss'` in the console, followed by a failed connection, no other error. Fixed by declaring an explicit `content_security_policy.extension_pages` (otherwise identical to Firefox's own default) for the Firefox build only — an explicit CSP replaces the implicit one entirely, dropping the upgrade directive. Chrome doesn't have this behavior, so the override is gated on `browser === 'firefox'` in the manifest function. If the real deployed backend ever moves to plain `ws://` too (vs. `wss://` behind a real domain), this override needs to travel with it; if the backend gets TLS, this whole override becomes unnecessary and should be removed rather than left as dead configuration. - The Firefox-only CSP override that used to live in `wxt.config.ts` (working around Firefox upgrading a plaintext `ws://` dev connection to `wss://`) has been removed now that the backend is real `wss://` behind TLS — `upgrade-insecure-requests` has nothing to upgrade. Re-add it, scoped to `browser === 'firefox'`, only if a plaintext dev backend comes back into the loop.
- `src/background/backend-client.ts` — session storage only (`getSessionToken()`/`getStoredSession()`/`storeSession(token, walletAddress)`/`clearSessionToken()`). Stores the wallet address alongside the token, not just the token — see "Session storage tracks which wallet it belongs to" below for why. Getting a token in the first place is `wallet-auth.ts`'s job. - `src/background/backend-client.ts` — session storage only (`getSessionToken()`/`getStoredSession()`/`storeSession(token, walletAddress)`/`clearSessionToken()`). Stores the wallet address alongside the token, not just the token — see "Session storage tracks which wallet it belongs to" below for why. Getting a token in the first place is `wallet-auth.ts`'s job.
- `src/background/wallet-auth.ts` — `handleWalletConnected()` runs the REST auth flow (`POST /auth/nonce` → Phantom signature → `POST /auth/verify` → session token) once a content script reports a connected wallet. Also `requestWalletReconnect()` (silent reconnect after the backend invalidates a session), `requestWalletDisconnect()` (sign-out), `requestAccountSwitch()` (explicit account switch) — all three just message whichever tabs are on a supported site; `wallet-connect.ts` in the content script does the actual work. - `src/background/wallet-auth.ts` — `handleWalletConnected()` runs the REST auth flow (`POST /auth/nonce` → Phantom signature → `POST /auth/verify` → session token) once a content script reports a connected wallet. Also `requestWalletReconnect()` (silent reconnect after the backend invalidates a session), `requestWalletDisconnect()` (sign-out), `requestAccountSwitch()` (explicit account switch) — all three just message whichever tabs are on a supported site; `wallet-connect.ts` in the content script does the actual work.
- `src/background/ws-client.ts` — the WS client described above: connects to `/ws?token=...`. `connectWsClient()` returns a controller with `reconnectNow()` so the background script can short-circuit the backoff wait right after a fresh token arrives. Auth failures (`4001` close, `auth_expired`/`session_revoked` errors) do **not** auto-retry with backoff — they call `onAuthExpired()` instead, since retrying with a known-bad token can't succeed; only real disconnects (network drop, backgrounded browser) use the protocol's suggested backoff schedule. - `src/background/ws-client.ts` — the WS client described above: connects to `/ws?token=...`. `connectWsClient()` returns a controller with `reconnectNow()` so the background script can short-circuit the backoff wait right after a fresh token arrives. Auth failures (`4001` close, `auth_expired`/`session_revoked` errors) do **not** auto-retry with backoff — they call `onAuthExpired()` instead, since retrying with a known-bad token can't succeed; only real disconnects (network drop, backgrounded browser) use the protocol's suggested backoff schedule.
- `src/background/connection-status.ts` — separate from lock state; the popup surfaces this (connecting/connected/disconnected/auth-error) alongside the lock state so a broken connection isn't silently indistinguishable from "unlocked". - `src/background/connection-status.ts` — separate from lock state; the popup surfaces this (connecting/connected/disconnected/auth-error) alongside the lock state so a broken connection isn't silently indistinguishable from "unlocked".
- `src/background/settings-client.ts` — `GET`/`PATCH /me/settings` (per-user loss-detection thresholds; see backend/CLAUDE.md, "Account settings"), same direct-fetch-with-bearer-token shape as `wallet-auth.ts`. Popup -> background dispatch is `nexa:get-settings`/`nexa:update-settings` in `background.ts`, responses shaped `{ settings } | { error }` (`SettingsResult` in `shared/messaging.ts`). The popup's "Loss thresholds" panel (`entrypoints/popup/App.tsx`) is the only place percent fields (`near_full_exit_fraction` and both loss thresholds) get converted between the backend's fractional wire representation and the whole-number percentages shown in the form — same "convert only at the seam" rule as `LockScope`'s kebab/snake split below.
### Wallet auth (Phantom) — implemented ### Wallet auth (Phantom) — implemented
@@ -183,5 +184,5 @@ Single source of truth for the wire protocol between the Nexa backend (Rust/Axum
- No cost-basis tracking logic (backend concern). - No cost-basis tracking logic (backend concern).
- No full-site-block implementation beyond a stub module. - No full-site-block implementation beyond a stub module.
- No threshold-setting UI (placeholder link only). - ~~No threshold-setting UI (placeholder link only)~~ — implemented, see "Backend connection" above.
- Only the axiom.trade adapter needs to be functional; the architecture just needs to make adding more sites trivial. - Only the axiom.trade adapter needs to be functional; the architecture just needs to make adding more sites trivial.
+31
View File
@@ -0,0 +1,31 @@
import { BACKEND_HTTP_URL } from '@/shared/config';
import type { ThresholdSettings } from '@/shared/types';
import { getSessionToken } from './backend-client';
/**
* `GET`/`PATCH /me/settings` — same direct-fetch-with-bearer-token shape as
* wallet-auth.ts's REST calls. Throws on any failure (network, auth, or a
* validation rejection from the backend); callers decide how to surface that.
*/
export async function getSettings(): Promise<ThresholdSettings> {
const token = await getSessionToken();
const res = await fetch(`${BACKEND_HTTP_URL}/me/settings`, {
headers: { authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error(`get settings failed: ${res.status}`);
return (await res.json()) as ThresholdSettings;
}
export async function updateSettings(patch: Partial<ThresholdSettings>): Promise<ThresholdSettings> {
const token = await getSessionToken();
const res = await fetch(`${BACKEND_HTTP_URL}/me/settings`, {
method: 'PATCH',
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
body: JSON.stringify(patch),
});
if (!res.ok) {
const message = await res.text().catch(() => '');
throw new Error(message || `update settings failed: ${res.status}`);
}
return (await res.json()) as ThresholdSettings;
}
+14 -1
View File
@@ -7,8 +7,9 @@ import {
requestWalletDisconnect, requestWalletDisconnect,
requestWalletReconnect, requestWalletReconnect,
} from '@/background/wallet-auth'; } from '@/background/wallet-auth';
import { getSettings, updateSettings } from '@/background/settings-client';
import { connectWsClient } from '@/background/ws-client'; import { connectWsClient } from '@/background/ws-client';
import type { NexaMessage } from '@/shared/messaging'; import type { NexaMessage, SettingsResult } from '@/shared/messaging';
export default defineBackground(() => { export default defineBackground(() => {
const ws = connectWsClient({ const ws = connectWsClient({
@@ -71,6 +72,18 @@ export default defineBackground(() => {
void requestAccountSwitch(); void requestAccountSwitch();
return false; return false;
case 'nexa:get-settings':
getSettings()
.then((settings) => sendResponse({ settings } satisfies SettingsResult))
.catch((err) => sendResponse({ error: String(err) } satisfies SettingsResult));
return true; // keep the message channel open for the async response
case 'nexa:update-settings':
updateSettings(message.settings)
.then((settings) => sendResponse({ settings } satisfies SettingsResult))
.catch((err) => sendResponse({ error: String(err) } satisfies SettingsResult));
return true;
default: default:
return undefined; return undefined;
} }
+196 -9
View File
@@ -1,7 +1,14 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { browser } from 'wxt/browser'; import { browser } from 'wxt/browser';
import { DEFAULT_CONNECTION_STATUS, DEFAULT_LOCK_STATE, type ConnectionStatus, type LockState } from '@/shared/types'; import {
import type { NexaMessage } from '@/shared/messaging'; DEFAULT_CONNECTION_STATUS,
DEFAULT_LOCK_STATE,
DEFAULT_THRESHOLD_SETTINGS,
type ConnectionStatus,
type LockState,
type ThresholdSettings,
} from '@/shared/types';
import type { NexaMessage, SettingsResult } from '@/shared/messaging';
const CONNECTION_LABELS: Record<ConnectionStatus, string> = { const CONNECTION_LABELS: Record<ConnectionStatus, string> = {
connecting: 'Connecting…', connecting: 'Connecting…',
@@ -78,13 +85,193 @@ export function App() {
</div> </div>
)} )}
<a <ThresholdsPanel />
className="thresholds-link"
href="#"
title="Threshold settings are not part of this milestone"
>
Adjust loss thresholds →
</a>
</> </>
); );
} }
/**
* Percent-shaped fields (`near_full_exit_fraction`, both loss thresholds) are
* edited here as whole numbers (e.g. "30") and converted to/from the
* backend's fractional wire representation only at this boundary — same
* "convert only at the seam" rule already applied to LockScope's kebab/snake
* case split (see ../../CLAUDE.md).
*/
type ThresholdForm = {
exitPercent: string;
windowHours: string;
streakLength: string;
singleLossPercent: string;
streakLossPercent: string;
cooldownMinutes: string;
};
function toForm(settings: ThresholdSettings): ThresholdForm {
return {
exitPercent: String(Math.round(settings.near_full_exit_fraction * 100)),
windowHours: String(settings.loss_window_hours),
streakLength: String(settings.streak_length),
singleLossPercent: String(Math.round(-settings.single_trade_loss_threshold * 100)),
streakLossPercent: String(Math.round(-settings.streak_sum_loss_threshold * 100)),
cooldownMinutes: String(settings.cooldown_minutes),
};
}
function fromForm(form: ThresholdForm): Partial<ThresholdSettings> {
return {
near_full_exit_fraction: Number(form.exitPercent) / 100,
loss_window_hours: Number(form.windowHours),
streak_length: Number(form.streakLength),
single_trade_loss_threshold: -Number(form.singleLossPercent) / 100,
streak_sum_loss_threshold: -Number(form.streakLossPercent) / 100,
cooldown_minutes: Number(form.cooldownMinutes),
};
}
function ThresholdsPanel() {
const [expanded, setExpanded] = useState(false);
const [form, setForm] = useState<ThresholdForm>(() => toForm(DEFAULT_THRESHOLD_SETTINGS));
const [status, setStatus] = useState<'idle' | 'loading' | 'saving'>('idle');
const [error, setError] = useState<string | undefined>(undefined);
function expand(): void {
setExpanded(true);
if (status !== 'idle') return;
setStatus('loading');
setError(undefined);
browser.runtime
.sendMessage({ type: 'nexa:get-settings' } satisfies NexaMessage)
.then((result: SettingsResult | undefined) => {
if (result && 'settings' in result) {
setForm(toForm(result.settings));
setStatus('idle');
} else {
setError(result?.error ?? 'Failed to load settings.');
setStatus('idle');
}
});
}
function field(key: keyof ThresholdForm, value: string): void {
setForm((prev) => ({ ...prev, [key]: value }));
}
function save(): void {
setStatus('saving');
setError(undefined);
browser.runtime
.sendMessage({ type: 'nexa:update-settings', settings: fromForm(form) } satisfies NexaMessage)
.then((result: SettingsResult | undefined) => {
setStatus('idle');
if (result && 'settings' in result) {
setForm(toForm(result.settings));
} else {
setError(result?.error ?? 'Failed to save settings.');
}
});
}
if (!expanded) {
return (
<button type="button" className="thresholds-toggle" onClick={expand}>
Adjust loss thresholds →
</button>
);
}
return (
<section className="thresholds-panel">
<h2>Loss thresholds</h2>
<label>
Exit counts as "full" at
<div className="threshold-input">
<input
type="number"
min={1}
max={100}
value={form.exitPercent}
onChange={(e) => field('exitPercent', e.target.value)}
/>
%
</div>
</label>
<label>
Streak window
<div className="threshold-input">
<input
type="number"
min={1}
max={168}
value={form.windowHours}
onChange={(e) => field('windowHours', e.target.value)}
/>
hours
</div>
</label>
<label>
Streak length
<div className="threshold-input">
<input
type="number"
min={1}
max={20}
value={form.streakLength}
onChange={(e) => field('streakLength', e.target.value)}
/>
trades
</div>
</label>
<label>
Single-trade loss locks at
<div className="threshold-input">
<input
type="number"
min={1}
max={100}
value={form.singleLossPercent}
onChange={(e) => field('singleLossPercent', e.target.value)}
/>
%
</div>
</label>
<label>
Streak cumulative loss locks at
<div className="threshold-input">
<input
type="number"
min={1}
max={100}
value={form.streakLossPercent}
onChange={(e) => field('streakLossPercent', e.target.value)}
/>
%
</div>
</label>
<label>
Auto-unlock after
<div className="threshold-input">
<input
type="number"
min={1}
max={1440}
value={form.cooldownMinutes}
onChange={(e) => field('cooldownMinutes', e.target.value)}
/>
minutes
</div>
</label>
{error && <p className="thresholds-error">{error}</p>}
<button type="button" className="sign-out-button" onClick={save} disabled={status === 'saving'}>
{status === 'saving' ? 'Saving…' : 'Save'}
</button>
</section>
);
}
+58 -4
View File
@@ -100,11 +100,65 @@ header .subtitle {
background: rgba(128, 128, 128, 0.12); background: rgba(128, 128, 128, 0.12);
} }
.thresholds-link { .thresholds-toggle {
display: block; display: block;
width: 100%;
margin-top: 14px; margin-top: 14px;
padding: 0;
border: none;
background: none;
color: inherit; color: inherit;
opacity: 0.5; opacity: 0.75;
text-decoration: none; font: inherit;
pointer-events: none; text-align: left;
cursor: pointer;
}
.thresholds-toggle:hover {
opacity: 1;
}
.thresholds-panel {
margin-top: 14px;
padding-top: 12px;
border-top: 1px solid rgba(128, 128, 128, 0.25);
}
.thresholds-panel h2 {
margin: 0 0 8px;
font-size: 13px;
}
.thresholds-panel label {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
font-size: 12px;
opacity: 0.85;
}
.threshold-input {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.threshold-input input {
width: 48px;
padding: 3px 5px;
border: 1px solid rgba(128, 128, 128, 0.4);
border-radius: 4px;
background: transparent;
color: inherit;
font: inherit;
text-align: right;
}
.thresholds-error {
margin: 4px 0 8px;
color: #dc2626;
font-size: 12px;
} }
+4 -9
View File
@@ -1,11 +1,6 @@
/** /**
* Nexa backend location. Dev-only default — swap for the real deployed * Nexa backend location. `host_permissions` in wxt.config.ts must be kept in
* backend host before shipping (see backend/CLAUDE.md, "Relationship to the * sync with this host.
* frontend"). `host_permissions` in wxt.config.ts must be kept in sync with
* this host.
*/ */
// Port 8080, deliberately not 3000 — that's WXT/Vite's dev server port for export const BACKEND_HTTP_URL = 'https://api.osias.trade';
// this extension, and colliding with it breaks the dev popup silently (its export const BACKEND_WS_URL = 'wss://api.osias.trade/ws';
// script tags point at Vite, but the backend answers instead).
export const BACKEND_HTTP_URL = 'http://localhost:8080';
export const BACKEND_WS_URL = 'ws://localhost:8080/ws';
+9 -2
View File
@@ -1,4 +1,4 @@
import type { ConnectionStatus, LockState } from './types'; import type { ConnectionStatus, LockState, ThresholdSettings } from './types';
/** browser.storage.local key backing the lock state seam in the background script. */ /** browser.storage.local key backing the lock state seam in the background script. */
export const LOCK_STATE_STORAGE_KEY = 'nexa:lockState'; export const LOCK_STATE_STORAGE_KEY = 'nexa:lockState';
@@ -28,7 +28,14 @@ export type NexaMessage =
| { type: 'nexa:switch-account-request' } | { type: 'nexa:switch-account-request' }
// Sign-out / switch account (popup -> background). See entrypoints/popup/App.tsx. // Sign-out / switch account (popup -> background). See entrypoints/popup/App.tsx.
| { type: 'nexa:sign-out' } | { type: 'nexa:sign-out' }
| { type: 'nexa:switch-account' }; | { type: 'nexa:switch-account' }
// Loss-threshold settings (popup -> background -> GET/PATCH /me/settings).
// See background/settings-client.ts and entrypoints/popup/App.tsx.
| { type: 'nexa:get-settings' }
| { type: 'nexa:update-settings'; settings: Partial<ThresholdSettings> };
/** Response shape for 'nexa:get-settings'/'nexa:update-settings', returned via sendResponse. */
export type SettingsResult = { settings: ThresholdSettings } | { error: string };
/** Response shape for 'nexa:wallet-sign-request', returned via sendResponse (not a dispatched NexaMessage). */ /** Response shape for 'nexa:wallet-sign-request', returned via sendResponse (not a dispatched NexaMessage). */
export type WalletSignResult = { signature: string } | { error: string }; export type WalletSignResult = { signature: string } | { error: string };
+25
View File
@@ -17,3 +17,28 @@ export const DEFAULT_LOCK_STATE: LockState = {
export type ConnectionStatus = 'connecting' | 'connected' | 'disconnected' | 'auth-error'; export type ConnectionStatus = 'connecting' | 'connected' | 'disconnected' | 'auth-error';
export const DEFAULT_CONNECTION_STATUS: ConnectionStatus = 'connecting'; export const DEFAULT_CONNECTION_STATUS: ConnectionStatus = 'connecting';
/**
* Per-user loss-detection thresholds — mirrors the backend's
* `settings::Settings` field-for-field (wire is snake_case throughout, see
* backend/CLAUDE.md, "Relationship to the frontend"). Fetched/updated via
* `GET`/`PATCH /me/settings`, see background/settings-client.ts.
*/
export interface ThresholdSettings {
near_full_exit_fraction: number;
loss_window_hours: number;
streak_length: number;
single_trade_loss_threshold: number;
streak_sum_loss_threshold: number;
cooldown_minutes: number;
}
/** Mirrors the backend's `Settings::default()` — shown before the real values load. */
export const DEFAULT_THRESHOLD_SETTINGS: ThresholdSettings = {
near_full_exit_fraction: 0.95,
loss_window_hours: 24,
streak_length: 3,
single_trade_loss_threshold: -0.3,
streak_sum_loss_threshold: -0.5,
cooldown_minutes: 30,
};
+5 -16
View File
@@ -1,3 +1,4 @@
import { BACKEND_HTTP_URL } from './src/shared/config';
import { defineConfig } from 'wxt'; import { defineConfig } from 'wxt';
// See https://wxt.dev/api/config.html // See https://wxt.dev/api/config.html
@@ -6,29 +7,17 @@ export default defineConfig({
modules: ['@wxt-dev/module-react'], modules: ['@wxt-dev/module-react'],
// Target Manifest V3 on both Chromium and Firefox (modern Firefox / Zen support it). // Target Manifest V3 on both Chromium and Firefox (modern Firefox / Zen support it).
manifestVersion: 3, manifestVersion: 3,
manifest: ({ browser }) => ({ manifest: {
name: 'Nexa', name: 'Nexa',
description: 'Your blockchain powered agent to help with your trading emotions.', description: 'Your blockchain powered agent to help with your trading emotions.',
permissions: ['storage'], permissions: ['storage'],
// axiom.trade: the site adapter target. localhost:8080: the Nexa backend // axiom.trade: the site adapter target. The other entry is the Nexa backend.
// (dev only — swap/extend for the real backend host before shipping). host_permissions: ['https://axiom.trade/*', BACKEND_HTTP_URL + "/*"],
// Not 3000 — that's this extension's own Vite dev server port.
host_permissions: ['https://axiom.trade/*', 'http://localhost:8080/*'],
browser_specific_settings: { browser_specific_settings: {
gecko: { gecko: {
// Placeholder id for local/dev builds; replace before publishing to AMO. // Placeholder id for local/dev builds; replace before publishing to AMO.
id: '[email protected]', id: '[email protected]',
}, },
}, },
// Firefox's implicit default extension-pages CSP includes },
// upgrade-insecure-requests, which silently rewrites our ws:// WS client
// connections to wss:// and breaks them against the plaintext local dev
// backend (no TLS in dev — see backend/CLAUDE.md). Declaring our own CSP
// (identical to the standard default otherwise) replaces Firefox's
// implicit one and drops that directive. Chrome doesn't have this
// behavior, so this is Firefox-only.
...(browser === 'firefox'
? { content_security_policy: { extension_pages: "script-src 'self'; object-src 'self'" } }
: {}),
}),
}); });