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",
|
"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",
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
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 });
|
||||||
|
|||||||
@@ -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';
|
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
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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. */
|
/** 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' };
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user