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:
Generated
+25
@@ -8,6 +8,10 @@
|
||||
"name": "nexa-extension",
|
||||
"version": "0.1.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"bs58": "^6.0.0",
|
||||
"tweetnacl": "^1.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^7.0.2",
|
||||
"wxt": "^0.21.4"
|
||||
@@ -1556,6 +1560,12 @@
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/base-x": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/base-x/-/base-x-5.0.1.tgz",
|
||||
"integrity": "sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/boolbase": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-2.0.0.tgz",
|
||||
@@ -1570,6 +1580,15 @@
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
},
|
||||
"node_modules/bs58": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/bs58/-/bs58-6.0.0.tgz",
|
||||
"integrity": "sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base-x": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/c12": {
|
||||
"version": "3.3.4",
|
||||
"resolved": "https://registry.npmjs.org/c12/-/c12-3.3.4.tgz",
|
||||
@@ -2732,6 +2751,12 @@
|
||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/tweetnacl": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz",
|
||||
"integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==",
|
||||
"license": "Unlicense"
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
|
||||
|
||||
@@ -17,5 +17,9 @@
|
||||
"devDependencies": {
|
||||
"typescript": "^7.0.2",
|
||||
"wxt": "^0.21.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"bs58": "^6.0.0",
|
||||
"tweetnacl": "^1.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import bs58 from 'bs58';
|
||||
import { browser } from 'wxt/browser';
|
||||
import { BACKEND_HTTP_URL } from '@/shared/config';
|
||||
import { getIdentity } from './identity';
|
||||
|
||||
const SESSION_TOKEN_STORAGE_KEY = 'nexa:sessionToken';
|
||||
|
||||
/**
|
||||
* Wallet-signature auth against the backend's REST flow (see
|
||||
* backend/CLAUDE.md, "Current milestone: authentication + user data"):
|
||||
* request a nonce for our wallet_address, sign it, exchange for a session
|
||||
* token. That token is what the WS client (ws-client.ts) authenticates with.
|
||||
*/
|
||||
export async function authenticate(): Promise<string> {
|
||||
const identity = await getIdentity();
|
||||
|
||||
const nonceRes = await fetch(`${BACKEND_HTTP_URL}/auth/nonce`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ wallet_address: identity.walletAddress }),
|
||||
});
|
||||
if (!nonceRes.ok) throw new Error(`nonce request failed: ${nonceRes.status}`);
|
||||
const { nonce } = (await nonceRes.json()) as { nonce: string };
|
||||
|
||||
const signatureBytes = identity.sign(new TextEncoder().encode(nonce));
|
||||
|
||||
const verifyRes = await fetch(`${BACKEND_HTTP_URL}/auth/verify`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
wallet_address: identity.walletAddress,
|
||||
signature: bs58.encode(signatureBytes),
|
||||
}),
|
||||
});
|
||||
if (!verifyRes.ok) throw new Error(`verify request failed: ${verifyRes.status}`);
|
||||
const { session_token: sessionToken } = (await verifyRes.json()) as { session_token: string };
|
||||
|
||||
await browser.storage.local.set({ [SESSION_TOKEN_STORAGE_KEY]: sessionToken });
|
||||
return sessionToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a usable session token, authenticating from scratch if none is
|
||||
* stored yet (or `forceRefresh` is set, e.g. after the backend told us via
|
||||
* `auth_expired`/close-4001 that the old one is no longer valid).
|
||||
*/
|
||||
export async function getSessionToken(forceRefresh = false): Promise<string> {
|
||||
if (!forceRefresh) {
|
||||
const stored = await browser.storage.local.get(SESSION_TOKEN_STORAGE_KEY);
|
||||
const token = stored[SESSION_TOKEN_STORAGE_KEY] as string | undefined;
|
||||
if (token) return token;
|
||||
}
|
||||
return authenticate();
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { browser } from 'wxt/browser';
|
||||
import { DEFAULT_CONNECTION_STATUS, type ConnectionStatus } from '@/shared/types';
|
||||
import type { NexaMessage } from '@/shared/messaging';
|
||||
|
||||
/** In-memory only (not persisted) — always reflects the live WS connection, not a stale guess. */
|
||||
let currentStatus: ConnectionStatus = DEFAULT_CONNECTION_STATUS;
|
||||
|
||||
export function getConnectionStatus(): ConnectionStatus {
|
||||
return currentStatus;
|
||||
}
|
||||
|
||||
export async function setConnectionStatus(next: ConnectionStatus): Promise<void> {
|
||||
if (next === currentStatus) return;
|
||||
currentStatus = next;
|
||||
|
||||
const message: NexaMessage = { type: 'nexa:connection-status-changed', status: next };
|
||||
await browser.runtime.sendMessage(message).catch(() => undefined);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import bs58 from 'bs58';
|
||||
import nacl from 'tweetnacl';
|
||||
import { browser } from 'wxt/browser';
|
||||
|
||||
const IDENTITY_STORAGE_KEY = 'nexa:identity';
|
||||
|
||||
/**
|
||||
* Stand-in "wallet" identity for the backend's Sign-In-with-Solana-style
|
||||
* auth (nonce + ed25519 signature) — a real Solana wallet keypair, just not
|
||||
* one connected to an actual wallet extension yet. Generated once and
|
||||
* persisted so the same wallet_address/session survives extension restarts.
|
||||
*
|
||||
* Deliberately temporary: real wallet integration (bridging into the page's
|
||||
* injected window.solana provider on axiom.trade) is a follow-up iteration,
|
||||
* not this one. Swap this module out for that without touching callers —
|
||||
* `getIdentity()`/`sign()` is the seam.
|
||||
*/
|
||||
export interface Identity {
|
||||
walletAddress: string;
|
||||
sign(message: Uint8Array): Uint8Array;
|
||||
}
|
||||
|
||||
interface StoredKeypair {
|
||||
publicKey: string; // base58
|
||||
secretKey: string; // base58
|
||||
}
|
||||
|
||||
let cached: Identity | null = null;
|
||||
|
||||
export async function getIdentity(): Promise<Identity> {
|
||||
if (cached) return cached;
|
||||
|
||||
const stored = await browser.storage.local.get(IDENTITY_STORAGE_KEY);
|
||||
const persisted = stored[IDENTITY_STORAGE_KEY] as StoredKeypair | undefined;
|
||||
|
||||
const keyPair = persisted
|
||||
? { publicKey: bs58.decode(persisted.publicKey), secretKey: bs58.decode(persisted.secretKey) }
|
||||
: nacl.sign.keyPair();
|
||||
|
||||
if (!persisted) {
|
||||
const toStore: StoredKeypair = {
|
||||
publicKey: bs58.encode(keyPair.publicKey),
|
||||
secretKey: bs58.encode(keyPair.secretKey),
|
||||
};
|
||||
await browser.storage.local.set({ [IDENTITY_STORAGE_KEY]: toStore });
|
||||
}
|
||||
|
||||
cached = {
|
||||
walletAddress: bs58.encode(keyPair.publicKey),
|
||||
sign: (message) => nacl.sign.detached(message, keyPair.secretKey),
|
||||
};
|
||||
return cached;
|
||||
}
|
||||
@@ -3,10 +3,11 @@ import { DEFAULT_LOCK_STATE, type LockState } from '@/shared/types';
|
||||
import { LOCK_STATE_STORAGE_KEY, type NexaMessage } from '@/shared/messaging';
|
||||
|
||||
/**
|
||||
* Single isolated seam for lock state. Nothing else in the extension reads or
|
||||
* writes lock state directly — this is where the real backend WebSocket
|
||||
* client plugs in later (replacing the storage-backed mock below) without
|
||||
* any other module needing to change.
|
||||
* Single isolated seam for lock state. Nothing else in the extension reads
|
||||
* or writes lock state directly. `applyLockState` is called exclusively by
|
||||
* ws-client.ts when the backend sends a `lock_state` message — the backend
|
||||
* is the sole source of truth (see the wire protocol's "Ownership" section),
|
||||
* so there is no other writer.
|
||||
*/
|
||||
|
||||
let currentLockState: LockState = DEFAULT_LOCK_STATE;
|
||||
@@ -29,7 +30,7 @@ export async function getLockState(): Promise<LockState> {
|
||||
return currentLockState;
|
||||
}
|
||||
|
||||
export async function setLockState(next: LockState): Promise<void> {
|
||||
export async function applyLockState(next: LockState): Promise<void> {
|
||||
await ensureInitialized();
|
||||
currentLockState = next;
|
||||
await browser.storage.local.set({ [LOCK_STATE_STORAGE_KEY]: next });
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { BACKEND_WS_URL } from '@/shared/config';
|
||||
import type { ConnectionStatus, LockScope, LockState } from '@/shared/types';
|
||||
|
||||
/**
|
||||
* Client for the backend wire protocol. See ../../CLAUDE.md's "WebSocket
|
||||
* protocol" section (source of truth, shared with backend/CLAUDE.md) for
|
||||
* the full spec — this is the one place that protocol is translated into
|
||||
* this extension's internal `LockState`/`LockScope` (kebab-case), per the
|
||||
* note there that this translation belongs at the WS client, nowhere else.
|
||||
*/
|
||||
|
||||
type WireScope = 'buy_only' | 'full_block';
|
||||
|
||||
interface WireLockState {
|
||||
type: 'lock_state';
|
||||
locked: boolean;
|
||||
scope: WireScope;
|
||||
reason?: string | null;
|
||||
triggered_at?: string | null;
|
||||
}
|
||||
|
||||
interface WireAck {
|
||||
type: 'ack';
|
||||
for: string;
|
||||
success: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
interface WireError {
|
||||
type: 'error';
|
||||
code: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
type WireMessage = WireLockState | WireAck | WireError;
|
||||
|
||||
const WIRE_TO_INTERNAL_SCOPE: Record<WireScope, LockScope> = {
|
||||
buy_only: 'buy-only',
|
||||
full_block: 'full-block',
|
||||
};
|
||||
|
||||
// Suggested backoff schedule from the protocol's connection-lifecycle section.
|
||||
const RECONNECT_DELAYS_MS = [1000, 2000, 5000, 10000, 30000];
|
||||
|
||||
export interface WsClientOptions {
|
||||
/** Resolve a session token; pass `forceRefresh: true` to get a fresh one. */
|
||||
getToken(forceRefresh?: boolean): Promise<string>;
|
||||
onLockState(state: LockState): void;
|
||||
onStatusChange(status: ConnectionStatus): void;
|
||||
}
|
||||
|
||||
/** Starts the connection and keeps it alive; never resolves. */
|
||||
export function connectWsClient(options: WsClientOptions): void {
|
||||
let reconnectAttempt = 0;
|
||||
|
||||
function scheduleReconnect(forceRefresh: boolean) {
|
||||
const delay = RECONNECT_DELAYS_MS[Math.min(reconnectAttempt, RECONNECT_DELAYS_MS.length - 1)];
|
||||
reconnectAttempt += 1;
|
||||
setTimeout(() => void connect(forceRefresh), delay);
|
||||
}
|
||||
|
||||
async function connect(forceRefresh = false): Promise<void> {
|
||||
options.onStatusChange('connecting');
|
||||
|
||||
let token: string;
|
||||
try {
|
||||
token = await options.getToken(forceRefresh);
|
||||
} catch {
|
||||
options.onStatusChange('auth-error');
|
||||
scheduleReconnect(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const socket = new WebSocket(`${BACKEND_WS_URL}?token=${encodeURIComponent(token)}`);
|
||||
|
||||
socket.addEventListener('open', () => {
|
||||
reconnectAttempt = 0;
|
||||
options.onStatusChange('connected');
|
||||
});
|
||||
|
||||
socket.addEventListener('message', (event) => {
|
||||
let message: WireMessage;
|
||||
try {
|
||||
message = JSON.parse(event.data as string);
|
||||
} catch {
|
||||
return; // malformed frame — ignore rather than crash the background script
|
||||
}
|
||||
|
||||
if (message.type === 'lock_state') {
|
||||
options.onLockState({
|
||||
locked: message.locked,
|
||||
scope: WIRE_TO_INTERNAL_SCOPE[message.scope],
|
||||
reason: message.reason ?? undefined,
|
||||
lockedAt: message.triggered_at ? Date.parse(message.triggered_at) : undefined,
|
||||
});
|
||||
} else if (message.type === 'error' && message.code === 'auth_expired') {
|
||||
// Server already closes the socket for us on auth_expired; the
|
||||
// 'close' handler below does the reconnect-with-fresh-token.
|
||||
}
|
||||
// 'ack' has nothing to react to yet — the client doesn't send
|
||||
// unlock_request/escalate_request until lock-decision logic exists
|
||||
// server-side (see backend/CLAUDE.md non-goals for this milestone).
|
||||
});
|
||||
|
||||
socket.addEventListener('close', (event) => {
|
||||
options.onStatusChange(event.code === 4001 ? 'auth-error' : 'disconnected');
|
||||
// 4001 (auth failed) and session_revoked both mean the current token
|
||||
// is bad — get a fresh one before retrying, per the protocol's
|
||||
// connection-lifecycle notes on both cases.
|
||||
const forceRefreshNext = event.code === 4001;
|
||||
scheduleReconnect(forceRefreshNext);
|
||||
});
|
||||
|
||||
socket.addEventListener('error', () => {
|
||||
// The 'close' event still fires after 'error', so reconnect logic
|
||||
// lives there only — this listener just prevents an unhandled error.
|
||||
});
|
||||
}
|
||||
|
||||
void connect();
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Nexa backend location. Dev-only default — swap for the real deployed
|
||||
* backend host before shipping (see backend/CLAUDE.md, "Relationship to the
|
||||
* frontend"). `host_permissions` in wxt.config.ts must be kept in sync with
|
||||
* this host.
|
||||
*/
|
||||
export const BACKEND_HTTP_URL = 'http://localhost:3000';
|
||||
export const BACKEND_WS_URL = 'ws://localhost:3000/ws';
|
||||
@@ -1,15 +1,20 @@
|
||||
import type { LockState } from './types';
|
||||
import type { ConnectionStatus, LockState } from './types';
|
||||
|
||||
/** browser.storage.local key backing the lock state seam in the background script. */
|
||||
export const LOCK_STATE_STORAGE_KEY = 'nexa:lockState';
|
||||
|
||||
/**
|
||||
* Typed message contracts between background <-> content scripts <-> popup.
|
||||
* All lock-state reads/writes go through these so no module reaches into
|
||||
* storage directly (see src/background/lock-state.ts).
|
||||
* All lock-state reads go through these so no module reaches into storage
|
||||
* directly (see src/background/lock-state.ts). There is deliberately no
|
||||
* client-writable "set lock state" message: the backend is the sole source
|
||||
* of truth for lock state (see the WebSocket protocol's "Ownership" section
|
||||
* in ../../CLAUDE.md) — only the WS client (ws-client.ts) may apply a new
|
||||
* lock state, and only because the server told it to.
|
||||
*/
|
||||
export type NexaMessage =
|
||||
| { type: 'nexa:get-lock-state' }
|
||||
| { type: 'nexa:set-lock-state'; state: LockState }
|
||||
| { type: 'nexa:lock-state-changed'; state: LockState }
|
||||
| { type: 'nexa:get-connection-status' }
|
||||
| { type: 'nexa:connection-status-changed'; status: ConnectionStatus }
|
||||
| { type: 'nexa:open-popup' };
|
||||
|
||||
@@ -12,3 +12,8 @@ export const DEFAULT_LOCK_STATE: LockState = {
|
||||
locked: false,
|
||||
scope: 'buy-only',
|
||||
};
|
||||
|
||||
/** Status of the background script's WS connection to the backend — surfaced in the popup. */
|
||||
export type ConnectionStatus = 'connecting' | 'connected' | 'disconnected' | 'auth-error';
|
||||
|
||||
export const DEFAULT_CONNECTION_STATUS: ConnectionStatus = 'connecting';
|
||||
|
||||
+3
-1
@@ -9,7 +9,9 @@ export default defineConfig({
|
||||
name: 'Nexa',
|
||||
description: 'Your blockchain powered agent to help with your trading emotions.',
|
||||
permissions: ['storage'],
|
||||
host_permissions: ['https://axiom.trade/*'],
|
||||
// axiom.trade: the site adapter target. localhost:3000: the Nexa backend
|
||||
// (dev only — swap/extend for the real backend host before shipping).
|
||||
host_permissions: ['https://axiom.trade/*', 'http://localhost:3000/*'],
|
||||
browser_specific_settings: {
|
||||
gecko: {
|
||||
// Placeholder id for local/dev builds; replace before publishing to AMO.
|
||||
|
||||
Reference in New Issue
Block a user