Connect to the real Nexa backend, replacing the mock lock toggle

Adds the wire-protocol WebSocket client (ws-client.ts) and the REST
auth flow (backend-client.ts) against the now-live backend, using a
locally-generated ed25519 keypair (identity.ts) as a stand-in wallet
signer until real wallet-extension integration is built.

lock-state.ts's setLockState is now applyLockState, called only by
the WS client on an incoming lock_state message -- the backend is
the sole source of truth for lock state, so there's no other writer
anymore. The popup's dev mock controls are replaced with a live
connection-status + lock-state display.

Verified end-to-end against the real backend (nonce -> verify -> /me
-> ws lock_state) using the same tweetnacl/bs58 libs shipped in the
extension.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YXiHuScXrjxBh7yFGAPq3B
This commit is contained in:
2026-09-07 09:10:18 +02:00
co-authored by claude
parent 5643eebde7
commit 3b6054f2f0
14 changed files with 371 additions and 98 deletions
+13 -4
View File
@@ -1,16 +1,25 @@
import { getLockState, setLockState } from '@/background/lock-state';
import { getSessionToken } from '@/background/backend-client';
import { getConnectionStatus, setConnectionStatus } from '@/background/connection-status';
import { applyLockState, getLockState } from '@/background/lock-state';
import { connectWsClient } from '@/background/ws-client';
import type { NexaMessage } from '@/shared/messaging';
export default defineBackground(() => {
connectWsClient({
getToken: getSessionToken,
onLockState: (state) => void applyLockState(state),
onStatusChange: (status) => void setConnectionStatus(status),
});
browser.runtime.onMessage.addListener((message: NexaMessage, _sender, sendResponse) => {
switch (message?.type) {
case 'nexa:get-lock-state':
getLockState().then(sendResponse);
return true; // keep the message channel open for the async response
case 'nexa:set-lock-state':
setLockState(message.state).then(() => sendResponse({ ok: true }));
return true;
case 'nexa:get-connection-status':
sendResponse(getConnectionStatus());
return false;
case 'nexa:open-popup':
// Best-effort: not all browsers/contexts allow programmatic popup
+23 -53
View File
@@ -1,5 +1,5 @@
import { browser } from 'wxt/browser';
import { DEFAULT_LOCK_STATE, type LockScope, type LockState } from '@/shared/types';
import { DEFAULT_CONNECTION_STATUS, DEFAULT_LOCK_STATE, type ConnectionStatus, type LockState } from '@/shared/types';
import type { NexaMessage } from '@/shared/messaging';
const app = document.getElementById('app');
@@ -11,40 +11,16 @@ app.innerHTML = `
<p class="subtitle">Trading companion</p>
</header>
<section class="connection" id="connection"></section>
<section class="status" id="status"></section>
<section class="dev-controls">
<h2>Dev mock controls</h2>
<p class="hint">No backend connected yet — use this to simulate a lock signal.</p>
<label class="field">
<span>Locked</span>
<input type="checkbox" id="locked-toggle" />
</label>
<label class="field">
<span>Scope</span>
<select id="scope-select">
<option value="buy-only">buy-only</option>
<option value="full-block">full-block</option>
</select>
</label>
<label class="field">
<span>Reason</span>
<input type="text" id="reason-input" placeholder="e.g. -$62 loss crossed your $50 threshold" />
</label>
</section>
<a class="thresholds-link" id="thresholds-link" href="#" title="Threshold settings are not part of this milestone">
Adjust loss thresholds →
</a>
`;
const connectionEl = document.getElementById('connection') as HTMLElement;
const statusEl = document.getElementById('status') as HTMLElement;
const lockedToggle = document.getElementById('locked-toggle') as HTMLInputElement;
const scopeSelect = document.getElementById('scope-select') as HTMLSelectElement;
const reasonInput = document.getElementById('reason-input') as HTMLInputElement;
function escapeHtml(value: string): string {
const div = document.createElement('div');
@@ -52,6 +28,17 @@ function escapeHtml(value: string): string {
return div.innerHTML;
}
const CONNECTION_LABELS: Record<ConnectionStatus, string> = {
connecting: 'Connecting…',
connected: 'Connected',
disconnected: 'Disconnected — retrying',
'auth-error': 'Sign-in failed — retrying',
};
function renderConnection(status: ConnectionStatus): void {
connectionEl.innerHTML = `<span class="connection-dot connection-${status}"></span> ${CONNECTION_LABELS[status]}`;
}
function renderStatus(state: LockState): void {
statusEl.innerHTML = state.locked
? `<div class="badge badge-locked">🔒 Locked</div>
@@ -60,42 +47,25 @@ function renderStatus(state: LockState): void {
: `<div class="badge badge-unlocked">Unlocked</div>`;
}
function syncFormFromState(state: LockState): void {
lockedToggle.checked = state.locked;
scopeSelect.value = state.scope;
reasonInput.value = state.reason ?? '';
}
async function loadState(): Promise<LockState> {
async function loadLockState(): Promise<LockState> {
const message: NexaMessage = { type: 'nexa:get-lock-state' };
const state = (await browser.runtime.sendMessage(message)) as LockState | undefined;
return state ?? DEFAULT_LOCK_STATE;
}
async function pushState(): Promise<void> {
const next: LockState = {
locked: lockedToggle.checked,
scope: scopeSelect.value as LockScope,
reason: reasonInput.value.trim() || undefined,
lockedAt: lockedToggle.checked ? Date.now() : undefined,
};
const message: NexaMessage = { type: 'nexa:set-lock-state', state: next };
await browser.runtime.sendMessage(message);
renderStatus(next);
async function loadConnectionStatus(): Promise<ConnectionStatus> {
const message: NexaMessage = { type: 'nexa:get-connection-status' };
const status = (await browser.runtime.sendMessage(message)) as ConnectionStatus | undefined;
return status ?? DEFAULT_CONNECTION_STATUS;
}
for (const el of [lockedToggle, scopeSelect, reasonInput]) {
el.addEventListener('change', () => void pushState());
}
loadState().then((state) => {
syncFormFromState(state);
renderStatus(state);
});
loadLockState().then(renderStatus);
loadConnectionStatus().then(renderConnection);
browser.runtime.onMessage.addListener((message: NexaMessage) => {
if (message?.type === 'nexa:lock-state-changed') {
syncFormFromState(message.state);
renderStatus(message.state);
} else if (message?.type === 'nexa:connection-status-changed') {
renderConnection(message.status);
}
});
+29 -31
View File
@@ -19,6 +19,35 @@ header .subtitle {
opacity: 0.65;
}
.connection {
margin-bottom: 10px;
opacity: 0.75;
display: flex;
align-items: center;
gap: 6px;
}
.connection-dot {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
background: currentColor;
}
.connection-connecting {
color: #ca8a04;
}
.connection-connected {
color: #16a34a;
}
.connection-disconnected,
.connection-auth-error {
color: #dc2626;
}
.status {
margin-bottom: 16px;
}
@@ -50,37 +79,6 @@ header .subtitle {
opacity: 0.65;
}
.dev-controls {
border-top: 1px solid rgba(128, 128, 128, 0.25);
padding-top: 12px;
}
.dev-controls h2 {
margin: 0 0 4px;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.04em;
opacity: 0.65;
}
.dev-controls .hint {
margin: 0 0 10px;
opacity: 0.6;
}
.field {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
}
.field input[type='text'] {
flex: 1;
min-width: 0;
}
.thresholds-link {
display: block;
margin-top: 14px;