Skip to main content

nexus-ui

The Nexus Portfolio console manages portfolio value and performance, wallets, strategy, on-chain tools, AI cycles, market data and execution health. See the portfolio console guide for the current navigation and data semantics.

It is deliberately not part of nexus-platform: the Rust monorepo stays Rust-only, and the UI ships on its own (Node) release cadence.

Revisions (2026-09-06)

Deployed: sha-ca1d75e04649, rolled 10:19 UTC and pinned in nexus-gitops environments/prod/values.yaml. That image carries f8ca034 (LP positions), 4dca5c0 (25 s exec GET proxy budget), 80432d3 (Treasury truthfulness pass), 068ef0f (the two crash fixes) and the canonical-host chain ad31a86 → 1b078c6 → ca1d75e. Live-verified the same minute: www.nexusapp.dev/treasury 308s to the apex with path and query preserved, /sign-in included, the apex unchanged and docs.nexusapp.dev unaffected.

Portfolio navigation​

The console now has 11 primary destinations and eight supporting screens, organized around portfolio management. The generic agent/project-management pages have been removed. Historical URLs redirect to Portfolio; runtime data and backend services are preserved.

Portfolio console documents the current page inventory, execution boundaries and movement ledger. The 24 September visual review describes the design foundation before this navigation refocus.

Stack​

ConcernLibrary
FrameworkNext.js 16 (App Router), React 19
StylingTailwind CSS v4 (token-based, dark-first)
DataTanStack Query + TanStack Table
PrimitivesRadix UI, lucide-react, sonner, cmdk, nuqs
Validationzod

How it talks to Core​

The browser never calls Nexus Core directly. Every request goes through a same-origin server route that injects the API key server-side:

Browser ──► /api/nexus/[...path] (Next.js route handler, nodejs runtime)
│ injects X-Nexus-Api-Key (server-only secret)
▼
Nexus Core REST API (NEXUS_CORE_URL)

This keeps the API key out of the client bundle, gives one place to re-check the operator session and log admin actions, and avoids CORS entirely. Core evaluates X-Nexus-Api-Key on every /v1 route and runs NEXUS_AUTH_MODE=enforce since 2026-09-05 22:37 UTC with NEXUS_AUTH_READ=open (audit S3 closed): reads stay open, every mutation needs a key. Until 63a1083 the proxy neither stripped inbound X-Nexus-Operator-Sub/Email nor set them from the verified session (audit 2026-09-05 S4). Deployed 2026-09-05 (a2561d9, 15:14 UTC): a dedicated ui-proxy credential NEXUS_UI_API_KEY, session-derived operator headers, every inbound X-Nexus-* stripped, X-Nexus-Proxy-Origin on origin-checked mutations, 401 without a session; Core classifies the key as the ui_operator principal since eb1c464 (16:02 UTC). Contract under Security → Identity propagation.

The CEX portfolio views go through a second proxy, /api/cex/[...path], and the Solana venue service through a third, /api/solana/[...path]. The CEX manager has required X-Api-Key since the C6 cutover; the proxy sends a server-side, read-scoped CEX_MANAGER_API_KEY with a read-endpoint allowlist and renders an explicit unavailable state instead of a silent zero — deployed 2026-09-05 15:14 UTC (a2561d9, audit F7, re-verified 19:3x UTC: Binance and Kucoin /Balances/exchange answer 200).

Proxy timeout budgets (src/app/api/solana/[...path]/route.ts): GET_TIMEOUT_MS is 25 000 since 4dca5c0 (2026-09-06) — it was 8 000, and the exec's LP position scan took ≈ 9.5 s, so the proxy aborted it and /trading/liquidity rendered "positions unavailable" over a live Orca position. POST_TIMEOUT_MS is 600 000 (catalog refreshes walk the chain) and is unchanged. The budget is per method, not per route, and covers the oracle as well as the exec. The exec side of the same incident is the position cache, which took that scan to ≈ 2 ms.

Layout​

src/
app/
(admin)/ gated portfolio, on-chain tools, cycles,
market data, execution and settings routes
api/
nexus/[...path]/ server reverse proxy to Nexus Core
auth/ Keycloak OIDC login / callback / sign-out
sign-in/ operator sign-in page
components/
layout/ AdminShell, AppSidebar, Breadcrumbs, header bits
ui/ button, card, badge, input, table, … (shadcn-style)
kpi/ dashboard KPI card
providers/ React Query + theme + toaster
lib/
api.ts typed browser client (hits the proxy)
queries.ts React Query hooks + cache keys
types.ts Core API type shapes (mirror nexus-domain)
auth.ts verified OIDC identity + signed JWT session cookie
env.ts zod-validated server env
format.ts, cn.ts formatting + class helpers
middleware.ts edge gate (cookie presence) + canonical-host redirect

The trading and treasury pages live in the same gated group: /portfolio, /treasury, /trading/{harvest,goals,analysis,lending,liquidity,oracle,oracle-health,cycles,inspector,scope,ceremony} — HARVEST §9.

Treasury and liquidity pages (2026-09-06)​

/treasury — the truthfulness pass​

nexus-ui 7adb15d → 80432d3 (merged 2026-09-06 ≈ 09:2x UTC), built on 80432d3, on /v1/desk/nav and /v1/desk/pnl. Five things it refuses to render as a comfortable number:

  • "What is earning" — a panel directly under the NAV header (components/treasury/EarningPanel.tsx) over the API's window-independent earning[] block: position, venue, held, venue rate, earned in the window, earned since inception, realised APR and a plain status (earning / idle / out_of_range / contaminated). A position earns since it was opened, not since the chart's left edge, so this block is built over the whole series as well as the window (Measuring success → Earning).
  • Covered windows. The page opens on the largest window the series can answer (pickDefaultWindow, preference inception, 30d, 7d, 1d, all); an uncovered window tab is dimmed and marked, and an uncovered attribution cell renders n/c, never 0, with the sentence "the series does not reach back over that window (it starts …) — that is missing history, not a flat period."
  • figure disputed on a round trip whose fee is negative, whose fee is more than 10 % of the proceeds, or where |profit − (proceeds − cost − fees)| > 0.01.
  • sign disputed on a cost above +0.005: costs render red, labelled (a drag), never green — a cost cannot add value.
  • Per-venue lending reconciliation — start balance, our own supply / withdraw, interest, unattributed, end balance, with a does not tie badge and a residual row when the legs the API reported do not add up, and a contaminated badge from the API's contaminated_periods.
  • An "Unclassified" exposure slice (shown above 1 USDT and 0.1 % of NAV) so a component can never silently vanish from the exposure chart.

/trading/liquidity — "Our LP positions"​

nexus-ui f8ca034 (2026-09-06), components/lp/OurPositions.tsx with the pure helpers in lib/lp.ts. Per position: a range bar with the live price marker (and an "(off axis)" label when the price is outside the drawn axis), in range / out of range and an earning fees / earning nothing / range unknown headline, the composition now, uncollected fees valued at the pool price for the coin leg and 1.00 for the quote (labelled as such — they are the pools' own marks, not an independent valuation), a "Pool context — the pool's numbers, not ours" block joined from the catalog with a verified on-chain / unverified badge, fee income and realised fee APR from /v1/desk/pnl?window=all, the desk's plan for the pool from the last decision, Solscan links, and a "vs simply holding the coins" block that appears only when both deposited quantities are known — otherwise it says the entry data is not available rather than inventing a baseline.

Fee income, not "fees collected" (2d91151, 41dff12, 0fc6392). Three page-side bugs, all found from one owner observation — the page showed $1.00 of uncollected fees while Treasury showed the position earning nothing:

  • The pool id was compared exactly. The exec reports a pool in its true base58 case (Czfq3xZZ…), the desk P&L answered with it upper-cased (CZFQ3XZZ…), so the page found neither the lp[] row nor the lp_fees:<pool> source and said "not available yet — the desk P&L carries no LP row for this pool" while a row was there. samePool() and isLpFeesSource() now fold case at all four comparison sites: the catalog join, fee income, the entry deposit, and the decision officer's plan.
  • The label was wrong. "Collected to date" and "Fees collected" named total fee income, which is not a cash sweep — the desk counts fees that accrued into the position beside fees already collected out of it, because collecting only moves the same dollar. The page now reads the desk's split (fees_uncollected_usdt / fees_collected_usdt) and says "X USDT — Y already collected, Z still in the position".
  • The APR floor was an hour. realizedFeeApr refused only under one hour, so four hours of fees would have been annualised into a headline rate. The floor is now MIN_APR_HOURS = 24, matching the desk.

The zero itself was a platform defect, not a page one — The LP fee defect.

Two live crashes, both fixed in 068ef0f (2026-09-06 ≈ 10:0x UTC)​

  • /treasury threw e.note?.trim is not a function: the platform sends earning[].note as an array of sentences on a contaminated row. noteText() (lib/earning.ts) now normalises string | string[] | object | null — the declared type is string | string[] | null (API reference).
  • /trading/harvest threw Cannot read properties of null (reading 'sol'): the reconciliation report carries a prices map and a null reserves beside the wallets, and the page mapped every key. It now renders only wallet-shaped entries (a non-null object with a sol or address key).

Both are the same class of bug — a nullable or polymorphic field the UI iterates — and the open follow-up is a pass over the API doc comments for every such field (TODO).

Canonical host​

www.nexusapp.dev could never hold a session: the session cookie is set without a domain, so it is host-only, and an operator who signed in on the canonical host arrived at the alias without one and looped through /sign-in. The fix is a 308 redirect from any alias to the canonical host, and it took three attempts worth recording:

  1. An ingress permanent-redirect annotation pointed at backend port 3000 while the Service listens on 80 — the backend was unresolvable and nginx answered 404 (gitops 329bab4, fixed in d41d779).
  2. ingress-nginx then rejected the annotation value because it contained $request_uri (variables are not allowed in that annotation).
  3. Moving it into the app as redirects() in next.config did not work either: redirects() is baked into the routes manifest at build time, and so is process.env inside Edge middleware — a runtime CANONICAL_HOST was invisible to both (ad31a86 → 1b078c6).

As built (ca1d75e, src/middleware.ts): canonicalRedirect() derives the target from the request's own Host header (trimmed, lowercased, port stripped) by dropping a leading www., and honours process.env.CANONICAL_HOST when the runtime exposes it. It runs first in the middleware, before the sign-in bypass, and the matcher deliberately includes /sign-in; the redirect is 308 over https, keeping path and query. next.config.mjs no longer has a redirects() at all. The host-derived rule is the load-bearing path precisely because the env var may be inlined away.

Auth (v1)​

An operator signs in at /sign-in with NEXUS_ADMIN_TOKEN. On success the server sets an httpOnly cookie whose value is an HMAC of a fixed marker keyed by AUTH_SECRET — the token itself never lands in a cookie. The gated (admin) layout verifies the cookie (server-side, node:crypto) and the proxy re-checks it. Swap in OIDC/Keycloak or Core-issued JWTs later without changing call sites.

Environment​

VarScopePurpose
NEXUS_CORE_URLserverBase URL of the Nexus Core REST API
NEXUS_UI_API_KEYserverSent upstream as X-Nexus-Api-Key (ui-proxy principal, audit S4 — deployed 2026-09-05); falls back to NEXUS_API_KEY when unset
CEX_MANAGER_API_KEYserverRequired by the CEX proxy since the C6 cutover; sent as X-Api-Key since a2561d9 (audit F7 — deployed 2026-09-05); a 401/5xx renders an explicit unavailable state, never zeros
NEXUS_ADMIN_TOKENserverOperator sign-in token
AUTH_SECRETserverDerives the session cookie
APP_BASE_URLserverCanonical base URL; the sign-in callback and CANONICAL_HOST are derived from it
CANONICAL_HOSTserverHost an alias 308-redirects to (ca1d75e). Set in the nexus-ui Secret by bootstrap-secrets.sh from APP_BASE_URL; may be inlined away in the Edge middleware bundle, which is why the rule falls back to stripping a leading www. from the request's own Host
NEXT_PUBLIC_APP_NAMEclientDisplay name