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.
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
| Concern | Library |
|---|---|
| Framework | Next.js 16 (App Router), React 19 |
| Styling | Tailwind CSS v4 (token-based, dark-first) |
| Data | TanStack Query + TanStack Table |
| Primitives | Radix UI, lucide-react, sonner, cmdk, nuqs |
| Validation | zod |
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-independentearning[]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, preferenceinception, 30d, 7d, 1d, all); an uncovered window tab is dimmed and marked, and an uncovered attribution cell rendersn/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 disputedon 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 disputedon 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 tiebadge and a residual row when the legs the API reported do not add up, and acontaminatedbadge from the API'scontaminated_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 thelp[]row nor thelp_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()andisLpFeesSource()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.
realizedFeeAprrefused only under one hour, so four hours of fees would have been annualised into a headline rate. The floor is nowMIN_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)
/treasurythrewe.note?.trim is not a function: the platform sendsearning[].noteas an array of sentences on a contaminated row.noteText()(lib/earning.ts) now normalisesstring | string[] | object | null— the declared type isstring | string[] | null(API reference)./trading/harvestthrewCannot read properties of null (reading 'sol'): the reconciliation report carries apricesmap and a nullreservesbeside the wallets, and the page mapped every key. It now renders only wallet-shaped entries (a non-null object with asoloraddresskey).
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:
- An ingress
permanent-redirectannotation pointed at backend port 3000 while the Service listens on 80 — the backend was unresolvable and nginx answered 404 (gitops329bab4, fixed ind41d779). - ingress-nginx then rejected the annotation value because it
contained
$request_uri(variables are not allowed in that annotation). - Moving it into the app as
redirects()innext.configdid not work either:redirects()is baked into the routes manifest at build time, and so isprocess.envinside Edge middleware — a runtimeCANONICAL_HOSTwas 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
| Var | Scope | Purpose |
|---|---|---|
NEXUS_CORE_URL | server | Base URL of the Nexus Core REST API |
NEXUS_UI_API_KEY | server | Sent 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_KEY | server | Required 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_TOKEN | server | Operator sign-in token |
AUTH_SECRET | server | Derives the session cookie |
APP_BASE_URL | server | Canonical base URL; the sign-in callback and CANONICAL_HOST are derived from it |
CANONICAL_HOST | server | Host 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_NAME | client | Display name |
Related
- nexus-platform — the Core API this UI drives
- APIs — the REST surface
- Concepts — what the pages manage