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
+25
View File
@@ -8,6 +8,10 @@
"name": "nexa-extension", "name": "nexa-extension",
"version": "0.1.0", "version": "0.1.0",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": {
"bs58": "^6.0.0",
"tweetnacl": "^1.0.3"
},
"devDependencies": { "devDependencies": {
"typescript": "^7.0.2", "typescript": "^7.0.2",
"wxt": "^0.21.4" "wxt": "^0.21.4"
@@ -1556,6 +1560,12 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1" "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": { "node_modules/boolbase": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-2.0.0.tgz", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-2.0.0.tgz",
@@ -1570,6 +1580,15 @@
"url": "https://github.com/sponsors/fb55" "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": { "node_modules/c12": {
"version": "3.3.4", "version": "3.3.4",
"resolved": "https://registry.npmjs.org/c12/-/c12-3.3.4.tgz", "resolved": "https://registry.npmjs.org/c12/-/c12-3.3.4.tgz",
@@ -2732,6 +2751,12 @@
"url": "https://github.com/sponsors/SuperchupuDev" "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": { "node_modules/typescript": {
"version": "7.0.2", "version": "7.0.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
+4
View File
@@ -17,5 +17,9 @@
"devDependencies": { "devDependencies": {
"typescript": "^7.0.2", "typescript": "^7.0.2",
"wxt": "^0.21.4" "wxt": "^0.21.4"
},
"dependencies": {
"bs58": "^6.0.0",
"tweetnacl": "^1.0.3"
} }
} }
+54
View File
@@ -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();
}
+18
View File
@@ -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);
}
+53
View File
@@ -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;
}
+6 -5
View File
@@ -3,10 +3,11 @@ import { DEFAULT_LOCK_STATE, type LockState } from '@/shared/types';
import { LOCK_STATE_STORAGE_KEY, type NexaMessage } from '@/shared/messaging'; import { LOCK_STATE_STORAGE_KEY, type NexaMessage } from '@/shared/messaging';
/** /**
* Single isolated seam for lock state. Nothing else in the extension reads or * Single isolated seam for lock state. Nothing else in the extension reads
* writes lock state directly — this is where the real backend WebSocket * or writes lock state directly. `applyLockState` is called exclusively by
* client plugs in later (replacing the storage-backed mock below) without * ws-client.ts when the backend sends a `lock_state` message — the backend
* any other module needing to change. * 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; let currentLockState: LockState = DEFAULT_LOCK_STATE;
@@ -29,7 +30,7 @@ export async function getLockState(): Promise<LockState> {
return currentLockState; return currentLockState;
} }
export async function setLockState(next: LockState): Promise<void> { export async function applyLockState(next: LockState): Promise<void> {
await ensureInitialized(); await ensureInitialized();
currentLockState = next; currentLockState = next;
await browser.storage.local.set({ [LOCK_STATE_STORAGE_KEY]: next }); await browser.storage.local.set({ [LOCK_STATE_STORAGE_KEY]: next });
+121
View File
@@ -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();
}
+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'; import type { NexaMessage } from '@/shared/messaging';
export default defineBackground(() => { export default defineBackground(() => {
connectWsClient({
getToken: getSessionToken,
onLockState: (state) => void applyLockState(state),
onStatusChange: (status) => void setConnectionStatus(status),
});
browser.runtime.onMessage.addListener((message: NexaMessage, _sender, sendResponse) => { browser.runtime.onMessage.addListener((message: NexaMessage, _sender, sendResponse) => {
switch (message?.type) { switch (message?.type) {
case 'nexa:get-lock-state': case 'nexa:get-lock-state':
getLockState().then(sendResponse); getLockState().then(sendResponse);
return true; // keep the message channel open for the async response return true; // keep the message channel open for the async response
case 'nexa:set-lock-state': case 'nexa:get-connection-status':
setLockState(message.state).then(() => sendResponse({ ok: true })); sendResponse(getConnectionStatus());
return true; return false;
case 'nexa:open-popup': case 'nexa:open-popup':
// Best-effort: not all browsers/contexts allow programmatic popup // Best-effort: not all browsers/contexts allow programmatic popup
+23 -53
View File
@@ -1,5 +1,5 @@
import { browser } from 'wxt/browser'; 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'; import type { NexaMessage } from '@/shared/messaging';
const app = document.getElementById('app'); const app = document.getElementById('app');
@@ -11,40 +11,16 @@ app.innerHTML = `
<p class="subtitle">Trading companion</p> <p class="subtitle">Trading companion</p>
</header> </header>
<section class="connection" id="connection"></section>
<section class="status" id="status"></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"> <a class="thresholds-link" id="thresholds-link" href="#" title="Threshold settings are not part of this milestone">
Adjust loss thresholds → Adjust loss thresholds →
</a> </a>
`; `;
const connectionEl = document.getElementById('connection') as HTMLElement;
const statusEl = document.getElementById('status') 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 { function escapeHtml(value: string): string {
const div = document.createElement('div'); const div = document.createElement('div');
@@ -52,6 +28,17 @@ function escapeHtml(value: string): string {
return div.innerHTML; 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 { function renderStatus(state: LockState): void {
statusEl.innerHTML = state.locked statusEl.innerHTML = state.locked
? `<div class="badge badge-locked">🔒 Locked</div> ? `<div class="badge badge-locked">🔒 Locked</div>
@@ -60,42 +47,25 @@ function renderStatus(state: LockState): void {
: `<div class="badge badge-unlocked">Unlocked</div>`; : `<div class="badge badge-unlocked">Unlocked</div>`;
} }
function syncFormFromState(state: LockState): void { async function loadLockState(): Promise<LockState> {
lockedToggle.checked = state.locked;
scopeSelect.value = state.scope;
reasonInput.value = state.reason ?? '';
}
async function loadState(): Promise<LockState> {
const message: NexaMessage = { type: 'nexa:get-lock-state' }; const message: NexaMessage = { type: 'nexa:get-lock-state' };
const state = (await browser.runtime.sendMessage(message)) as LockState | undefined; const state = (await browser.runtime.sendMessage(message)) as LockState | undefined;
return state ?? DEFAULT_LOCK_STATE; return state ?? DEFAULT_LOCK_STATE;
} }
async function pushState(): Promise<void> { async function loadConnectionStatus(): Promise<ConnectionStatus> {
const next: LockState = { const message: NexaMessage = { type: 'nexa:get-connection-status' };
locked: lockedToggle.checked, const status = (await browser.runtime.sendMessage(message)) as ConnectionStatus | undefined;
scope: scopeSelect.value as LockScope, return status ?? DEFAULT_CONNECTION_STATUS;
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);
} }
for (const el of [lockedToggle, scopeSelect, reasonInput]) { loadLockState().then(renderStatus);
el.addEventListener('change', () => void pushState()); loadConnectionStatus().then(renderConnection);
}
loadState().then((state) => {
syncFormFromState(state);
renderStatus(state);
});
browser.runtime.onMessage.addListener((message: NexaMessage) => { browser.runtime.onMessage.addListener((message: NexaMessage) => {
if (message?.type === 'nexa:lock-state-changed') { if (message?.type === 'nexa:lock-state-changed') {
syncFormFromState(message.state);
renderStatus(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; 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 { .status {
margin-bottom: 16px; margin-bottom: 16px;
} }
@@ -50,37 +79,6 @@ header .subtitle {
opacity: 0.65; 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 { .thresholds-link {
display: block; display: block;
margin-top: 14px; margin-top: 14px;
+8
View File
@@ -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';
+9 -4
View File
@@ -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. */ /** 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';
/** /**
* Typed message contracts between background <-> content scripts <-> popup. * Typed message contracts between background <-> content scripts <-> popup.
* All lock-state reads/writes go through these so no module reaches into * All lock-state reads go through these so no module reaches into storage
* storage directly (see src/background/lock-state.ts). * 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 = export type NexaMessage =
| { type: 'nexa:get-lock-state' } | { type: 'nexa:get-lock-state' }
| { type: 'nexa:set-lock-state'; state: LockState }
| { type: 'nexa:lock-state-changed'; 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' }; | { type: 'nexa:open-popup' };
+5
View File
@@ -12,3 +12,8 @@ export const DEFAULT_LOCK_STATE: LockState = {
locked: false, locked: false,
scope: 'buy-only', 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
View File
@@ -9,7 +9,9 @@ export default defineConfig({
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'],
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: { 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.