diff --git a/package-lock.json b/package-lock.json index 9dd3283..7665b6c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 86a211f..225d05d 100644 --- a/package.json +++ b/package.json @@ -17,5 +17,9 @@ "devDependencies": { "typescript": "^7.0.2", "wxt": "^0.21.4" + }, + "dependencies": { + "bs58": "^6.0.0", + "tweetnacl": "^1.0.3" } } diff --git a/src/background/backend-client.ts b/src/background/backend-client.ts new file mode 100644 index 0000000..aaa9f49 --- /dev/null +++ b/src/background/backend-client.ts @@ -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 { + 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 { + 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(); +} diff --git a/src/background/connection-status.ts b/src/background/connection-status.ts new file mode 100644 index 0000000..acb6f40 --- /dev/null +++ b/src/background/connection-status.ts @@ -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 { + if (next === currentStatus) return; + currentStatus = next; + + const message: NexaMessage = { type: 'nexa:connection-status-changed', status: next }; + await browser.runtime.sendMessage(message).catch(() => undefined); +} diff --git a/src/background/identity.ts b/src/background/identity.ts new file mode 100644 index 0000000..9f16aee --- /dev/null +++ b/src/background/identity.ts @@ -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 { + 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; +} diff --git a/src/background/lock-state.ts b/src/background/lock-state.ts index 668bc42..c0f24e6 100644 --- a/src/background/lock-state.ts +++ b/src/background/lock-state.ts @@ -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 { return currentLockState; } -export async function setLockState(next: LockState): Promise { +export async function applyLockState(next: LockState): Promise { await ensureInitialized(); currentLockState = next; await browser.storage.local.set({ [LOCK_STATE_STORAGE_KEY]: next }); diff --git a/src/background/ws-client.ts b/src/background/ws-client.ts new file mode 100644 index 0000000..c2866eb --- /dev/null +++ b/src/background/ws-client.ts @@ -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 = { + 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; + 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 { + 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(); +} diff --git a/src/entrypoints/background.ts b/src/entrypoints/background.ts index 3cd5259..00b63ee 100644 --- a/src/entrypoints/background.ts +++ b/src/entrypoints/background.ts @@ -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 diff --git a/src/entrypoints/popup/main.ts b/src/entrypoints/popup/main.ts index 7832b83..46e15bf 100644 --- a/src/entrypoints/popup/main.ts +++ b/src/entrypoints/popup/main.ts @@ -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 = `

Trading companion

+
-
-

Dev mock controls

-

No backend connected yet — use this to simulate a lock signal.

- - - - - - -
- Adjust loss thresholds → `; +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 = { + connecting: 'Connecting…', + connected: 'Connected', + disconnected: 'Disconnected — retrying', + 'auth-error': 'Sign-in failed — retrying', +}; + +function renderConnection(status: ConnectionStatus): void { + connectionEl.innerHTML = ` ${CONNECTION_LABELS[status]}`; +} + function renderStatus(state: LockState): void { statusEl.innerHTML = state.locked ? `
🔒 Locked
@@ -60,42 +47,25 @@ function renderStatus(state: LockState): void { : `
Unlocked
`; } -function syncFormFromState(state: LockState): void { - lockedToggle.checked = state.locked; - scopeSelect.value = state.scope; - reasonInput.value = state.reason ?? ''; -} - -async function loadState(): Promise { +async function loadLockState(): Promise { 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 { - 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 { + 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); } }); diff --git a/src/entrypoints/popup/style.css b/src/entrypoints/popup/style.css index d91d45f..ea92922 100644 --- a/src/entrypoints/popup/style.css +++ b/src/entrypoints/popup/style.css @@ -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; diff --git a/src/shared/config.ts b/src/shared/config.ts new file mode 100644 index 0000000..21688bc --- /dev/null +++ b/src/shared/config.ts @@ -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'; diff --git a/src/shared/messaging.ts b/src/shared/messaging.ts index 7032d9c..bc468d9 100644 --- a/src/shared/messaging.ts +++ b/src/shared/messaging.ts @@ -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' }; diff --git a/src/shared/types.ts b/src/shared/types.ts index 46480ba..6f6ca52 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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'; diff --git a/wxt.config.ts b/wxt.config.ts index 62fd816..6f71ffd 100644 --- a/wxt.config.ts +++ b/wxt.config.ts @@ -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.