Skip to content

GQuantrill/eve-nexum

Repository files navigation

Nexum

Nexum is a free, open-source, self-hosted wormhole mapping tool for EVE Online — an alternative to Pathfinder and Tripwire. Track systems, signatures, structures, kills, and live activity across your chain; calculate wormhole rolls; and coordinate your corporation in J-space in real time. Logs in with EVE Online SSO and runs anywhere Docker does.

Live demo: eve-nexum.com — a read-only demo map is viewable without logging in.

Compare: Nexum vs Pathfinder vs Tripwire vs Wanderer →


Contents


Quick start

Get a local Nexum running in about ten minutes. You'll need:

Clone and configure:

git clone https://github.com/GQuantrill/eve-nexum.git
cd eve-nexum
cp .env.example .env

Open .env and set, at minimum:

EVE_CLIENT_ID=           # from your EVE developer app
EVE_CLIENT_SECRET=       # from your EVE developer app
EVE_CALLBACK_URL=http://localhost/auth/callback
FRONTEND_URL=http://localhost
PG_PASSWORD=             # any strong password

# Generate each with `openssl rand -hex 32` and paste the result:
SESSION_SECRET=
TOKEN_ENCRYPTION_KEY=

Leave CORP_ID unset for now — that opens login to any EVE character. To restrict to one or more corps or alliances later, see Corp & alliance mode.

Build and start the stack. On first run an importer service downloads the EVE Static Data Export and populates Postgres (a one-off, a few minutes) before the server boots; later runs detect the data is already there and skip straight through:

docker compose build
docker compose up -d

Open http://localhost and click Log in with EVE Online. That's it.

Next steps

  • Restrict logins to your corp or alliance, set up roles, locking, merges, or Discord notifications → Corp & alliance mode.
  • Production deployment (TLS via Traefik, a public URL, periodic SDE refreshes, upgrades) → Installation.
  • Something not coming up → Troubleshooting.

Features

Mapping

  • Interactive map — drag systems, draw connections, set wormhole class/type/status per connection. Snap-to-grid, optional minimap.
  • Seed a map from a region — when creating a map you can optionally pick an EVE region (searchable). The new map is pre-populated with every system in that region, laid out from CCP's 2D star-map projection (a Dotlan-style layout where stargate-connected systems sit adjacent), with all in-region stargate connections pre-drawn. Leave the region blank for an empty map. Requires solar-system coordinates in the SDE tables — see Installation.
  • Wormhole intel — per-connection mass status (stable / destabilized / critical), end-of-life flag with countdown, K162-aware static identification, frig-hole and gas-site auto-tagging from sig type.
  • Rolling calculator — plan and track collapsing a hole from its connection panel. It models the ±10% wormhole mass variance, so every call is made against the worst case: a hatched band marks the uncertain-collapse zone and each pass button is colour-forecast safe / may-collapse / will-collapse. Define a roller ship (cold + prop-on mass, or pull your currently-flown ship from ESI), then click each pass to step through — it tracks which side the roller is on, warns loudly when a risky pass would strand it on the far side, and asks to confirm a pass that would collapse the hole. Shows an "≈ N more passes" estimate with undo/reset, and the cumulative mass syncs live to everyone viewing the hole.
  • Wormhole type picker — searchable popover for assigning the exact wormhole type to a connection; statics quick-info on hover shows destination class, mass, lifetime.
  • Multi-select bulk operations — shift-click to select multiple systems/signatures, then bulk-assign type, delete, or rename.
  • PNG export — render the current map (with sig counts, connections, status) to a PNG for sharing.
  • Map PNG / clipboard — copy or download the current chain as an image for pings.
  • Wormhole sig aging — wormhole signatures tint by their position in the WH type's known lifetime (yellow ≥50%, orange ≥90%, red past expected close). K162s stay neutral since the lifetime isn't knowable from this side.
  • Broken-chain detection — deleting a wormhole signature severs the connection it backed instead of leaving two now-separate chains silently joined. Rather than removing the link (which loses the chain), it's quarantined: kept on the map but rendered severed — red dashed with a ✂ marker, stale labels dropped — and excluded from routing, so a collapsed chain is obvious instead of looking complete to a quick glance. The connection panel offers a one-click restore, and the link auto-un-breaks if a sig backs it again or a pilot jumps it.
  • Orphan cleanup — a right-click canvas action sweeps away orphaned systems (no live connection, or only broken ones) in one go, while always protecting your home system and any locked systems. Shows a live count and confirms before removing; every removal is undoable.
  • Auto-remove aged wormholes — opt-in per map (Connections section of the sidebar). A server-side sweep periodically removes wormhole signatures older than their type's maximum lifetime and quarantines any connection they backed. Because it runs server-side on a fixed cadence (LAZY_WH_SWEEP_MINUTES, default 15), it keeps synced and corp chains current even when nobody has the map open. K162s are aged against the 48h maximum WH lifetime; codes with no known lifetime are left alone.

Personal, Corp & Alliance maps

  • Personal / Corp / Alliance scopes — every user has personal maps that are always private. In corp mode each corp also gets shared corp maps (cross-corp visibility is opt-in via CORP_MAP_SHARED). In alliance mode there's a third scope above corp: alliance maps every member of the alliance can see, managed by the alliance_admin role, with cross-alliance sharing opt-in via ALLIANCE_MAP_SHARED. A corp inside an alliance still keeps its own corp-private maps without listing every member corp in CORP_ID. See Corp & alliance mode.
  • Multi-character accounts — link several characters (alts) to a single account and switch the active character from the in-app switcher with no re-login, since each character's token is already stored. Linking merges that character's maps onto the account; the per-account map cap is enforced only at creation, so nothing is lost on link. You can also follow an alt's live (or last-known) location and route from it while flying your main, and unlink a character at any time — an unlinked character keeps its own data and becomes a standalone account again on its next login. The currently-active character can't be removed (switch away first).
  • Share a personal map with another character or corp — owners can grant edit access to a specific EVE character or an entire corp from the map sidebar. Recipients can edit signatures, structures, notes, and topology like a corp member but can't rename, delete, or re-share the map. Grants target raw EVE IDs, so they take effect on the recipient's very first Nexum login — no pre-registration required. The map appears in their switcher under a distinct "Shared" badge. Personal maps only; corp maps are already shared via membership.
  • Multi-map support — each character (or corp) can maintain multiple independent maps up to configured limits (MAX_USER_MAPS / MAX_CORP_MAPS).
  • Real-time collaboration — edits propagate live to everyone viewing the same map. Adding/moving/removing systems, drawing or retyping connections, renaming or locking the map, signature and structure changes, and merges all appear for other viewers within moments — no refresh. Delivered over a per-map Server-Sent Events stream (access-checked, so you only ever receive events for maps you can see), with your own changes echo-suppressed and an automatic resync on reconnect. Purely additive: if the stream drops, editing still works exactly as before.
  • Merge maps — fold one map's contents into another. The destination is the source of truth: existing systems are kept and only missing systems and connections are added, while signatures, structures, and notes are merged in (each toggleable in the merge dialog). New systems are aligned into the destination's existing layout near the systems they connect to, rather than dumped off to one side. Corp maps must be explicitly opted in — separately as a merge source and/or destination — and any merge touching a corp map is recorded in the audit log. See Merging maps for the roles involved.
  • Cross-map sync — opt-in (Map Controls → Sync system data across my maps). When the same EVE system appears on several of your maps, adding or editing its signatures and anomalies on one map copies them to the same system on your others — personal maps sync with your other personal maps, corp maps with the corp's other maps. Non-destructive: missing entries are inserted and blank fields filled, but data you've already entered is never overwritten and nothing is ever deleted (deletes and overwrite-paste removals stay local). Matches by EVE system id, so custom systems are skipped.
  • Map locking — admins can freeze a corp map's topology. Systems, connections, and the map name lock for non-admins, but signatures, structures, and per-system notes stay editable so ops can continue while the layout is pinned. The toolbar shows an amber 🔒 chip while the lock is active, and passive location tracking won't auto-add new systems on a locked map.
  • Role-based accessadmin / full / edit / readonly. Roles only restrict corp-map actions; every user owns their personal maps regardless of role. See Roles for the full matrix.
  • External API — generate a long-lived API key (🔑 in the toolbar) to read and drive your maps from your own tools and scripts: read maps, subscribe to a live event stream, and (with a write key) push signatures/anomalies/structures. Account-scoped, acts as a chosen character, role-gated, topology stays human-only, revocable. See External API.

System intelligence

  • System panel — per-system cards for signatures, structures, NPC stations, notes, killboard, and activity charts; cards are reorderable via drag-and-drop and persist per-user.
  • Signature management — paste EVE scan results directly; tracks created/updated age per signature; auto-deletes sigs missing from a re-paste; bulk type assignment for multi-select.
  • Structure import — paste EVE overview data to import player-owned structures.
  • Activity charts — 24-hour rolling history of jumps, ship/pod kills, and NPC kills, polled hourly from ESI. The poller persists every k-space system (not just ones a Nexum user has opened), so chart data accumulates cluster-wide and survives server restarts.
  • Sovereignty & station data — live alliance/corp/faction sov info and NPC station services with in-game waypoint/destination actions.
  • Killboard pane — recent zKillboard activity per system; recent kills also bubble up as highlights on the map. NPC-only kills (CONCORD, rats, etc.) are hidden by default with a toggle to include them.
  • Standings overlay — your EVE contact list (personal, corp, and alliance — fetched via ESI on login and re-pullable on demand from the sov header) drives a chain-wide visual layer. Sov holders show inline P/C/A pills with EVE-palette colour tiers; killboard rows tint red when a hostile actor is in the chain or a blue gets killed, blue when a friendly scores or a hostile dies; structures resolved via ESI tint by their owner corp's standing; sov-holder systems on the map gain a coloured halo. Nothing is sent off-instance — all logic runs against your own contacts.
  • Chain effect summary — at-a-glance view of all wormhole effects currently present in the chain.

Live ops

  • Scout connections — Thera and Turnur public Eve-Scout connections surfaced into the sidebar so you can jump straight to known holes.
  • A0 sun detection — auto-flags systems with A0 (yellow) suns visible via ESI for capital-friendly skirmish planning.
  • Ice belt systems — Empire-space systems that spawn ice anomalies get a ❄ icon. Static list in server/data/ice-belt-systems.json (sourced from the EVE University wiki), resolved to eve_system_id at startup against the SDE. Null-sec ice tracking will come later via the scraped respawn feed.
  • Storm tracking — active null-sec storms (Electric / Gamma / Exotic / Plasma) from the community-maintained EveScout Rescue stormtrack feed surface as a colour-coded ⚡ icon on matching system nodes, with a tooltip showing last report and reporter. Refreshed every 30 minutes. (ESI doesn't expose stellar phenomena yet; this scrapes the public community feed and will swap to ESI when CCP ships one.)
  • Proximity alerts — incursions, pirate insurgencies, and hostile-sov-holder systems (any sov-holding system where you've set the corp or alliance to a negative standing) appear as a toolbar chip showing the closest threat in jumps. Configurable threshold with browser notification + audio ping when you cross into the zone.
  • Watchlist — a personal, hand-edited list of holes you're hunting, in a dedicated left-side panel. Watch a specific system (name / J-code), a wormhole type (matched against statics, connections, and scanned signatures map-wide), or a characteristic (shattered, a system effect, frigate holes). Each entry has its own marker icon + colour and an optional note; matches are highlighted on the map and chime once when they appear. A "show on map" button pans/zooms to a match, expanding to a clickable list when an entry matches several. Stored per-user, so it follows you across devices and maps.
  • Inbound K162 alert — when a signature's wormhole type is set to K162 anywhere on the map, a toast + browser notification + distinct audio ping fire immediately. K162 means "the other side opened this hole", so it's a strong intel signal that something just connected into your chain.
  • Configurable notifications — a Notifications sidebar section lets you choose, per event (inbound K162, proximity threats, watchlist matches), whether it fires a desktop notification and/or a sound — each independently toggled. Defaults preserve the behaviour above (K162 and proximity on both channels; the watchlist keeps its chime and can opt into a desktop notification). Settings sync across your devices.
  • Discord notifications — push corp chain intel to a Discord channel so alerts land even when nobody's watching the tab. Fires server-side on an inbound K162 and on a new wormhole connection, scoped to corp maps and configured per corp via a webhook URL (DISCORD_WEBHOOK_URL). Admins can narrow which regions and maps notify from the Discord tab in the admin panel. Best-effort and rate-limit-aware; bulk operations like region seeding never spam the channel. See Discord notifications.
  • Chain exit summary — sidebar widget that counts every K-space exit currently on the map by security class (HS / LS / NS) as coloured chips, and pinpoints the nearest gate route to Jita ("Nearest Jita: 7j via Amarr") so loot runs and logistics planning don't need a manual route check.
  • Route planner — server-side BFS over stargates + your live chain, so a route through a wormhole hop is a single click.
  • Location tracking — opt-in live character location dot in the toolbar plus per-map "you are here" indicator.
  • Pilot presence — see where everyone viewing the same map is right now: a blue dot (pilot name on hover) marks each other viewer's current system, live as they jump. Unlike fleet dots it covers anyone with the map open, not just your fleet. Opt-in via location sharing, scoped to maps you can see, and ephemeral (nothing stored).
  • Online status — toolbar dot shows whether each user is currently logged into EVE Online.

Productivity & UX

  • Command paletteCmd/Ctrl + K opens a fuzzy search across systems, sigs, and actions (jump to system, set waypoint, toggle panes).
  • Home hotkey — jump the viewport back to the home system from any panel.
  • Recent-kill highlights — systems with kills in the last hour get a coloured halo so you can see fresh activity at a glance.
  • User stats modal — per-character totals: jumps, signatures by type, broken down by day/week/month/year/forever, plus a 30-day daily sparkline of scanning activity with hover tooltips.
  • Server status widget — live Tranquility server status, player count, and ESI health in the toolbar.
  • Demo map — the landing page mounts a non-editable demo map so visitors can see what the tool does before logging in.
  • Collapsible sidebar — Map Options, Connections, Proximity Alerts, Stale System Fade, and Shortcuts each expand or collapse independently. Per-section open/closed state persists per browser via localStorage.
  • European date format — DD-MM-YYYY everywhere a date is displayed (chart axes, relative-time fallbacks for events older than a month). ISO timestamps are still used in CSV exports for spreadsheet sortability.
  • Multi-lingual — the entire UI is translated into nine languages: English, Deutsch, Français, Español, Português, 简体中文, 한국어, 日本語, and Русский. The language is auto-detected from the browser (regional locales like es-MX or en-GB map to the base language) and can be changed any time from the in-app language switcher; the choice persists per browser. The public landing and comparison pages are localised too.

For corporations & alliances

These features only matter once CORP_ID or ALLIANCE_ID is set — see Corp & alliance mode for the full configuration.

  • Multi-corp deploymentsCORP_ID accepts a comma-separated list of corporation IDs. One Nexum instance can host several corps; each corp's maps stay scoped to its own members unless CORP_MAP_SHARED=true.
  • Alliance-wide mapsALLIANCE_ID adds a third map scope above corp: alliance maps every member of the alliance can see. Login is admitted if a character's corp is in CORP_ID or their alliance is in ALLIANCE_ID, so a whole alliance is permitted without listing every member corp. Set ALLIANCE_MAP_SHARED=true to share alliance maps across a coalition of listed alliances. A corp inside the alliance still keeps its own corp-private maps (derived from the members' corp at login).
  • Alliance admin role — a dedicated alliance_admin tier sits above admin: it manages alliance maps and every member's role across the whole alliance (a superset of the corp admin), while corp admins stay scoped to their own corp. Only an alliance admin can create/delete/lock alliance maps or grant the alliance_admin role. The bootstrap ADMIN_CHAR_ID is pinned to alliance_admin when ALLIANCE_ID is set.
  • Admin dashboard — a dedicated #/admin page with five tabs: Users, Maps, Reports, Discord, Audit log. Admins reach it from the toolbar's Admin button. The Users tab has a member search and a Roles? explainer.
  • User management — change roles, block / unblock, and force an ESI corp-membership re-check on demand. Self-block / self-demote and changes to ADMIN_CHAR_ID are guarded against. Anyone who has left every listed corp is auto-blocked on the next login or recheck.
  • Map management — admins see every corp map (solo maps are excluded by design) with owner avatar, corp ticker, system / connection counts, lock state, and last-active time. Force-lock, force-unlock, and force-delete are one-click each.
  • Users report — per-character last-login, systems added / deleted, structures added, signatures broken down by type, and last-corp-activity timestamps. Every column sortable, filterable by activity (logins / signatures / structures) and time window (24h / week / month / year / all), exportable as CSV.
  • Systems report — aggregate corp-map signatures with a sig-type donut, daily / monthly activity line chart (bucketing adapts to the window), and a sortable wormhole-type breakdown.
  • Audit log — every admin action (role change, block, force-lock, force-unlock, force-delete, ESI corp change, auto-block on departure, corp-map merge as source/destination) is recorded with actor, target, old → new value, and timestamp. Exportable as CSV.
  • Corp ticker resolution — corp IDs in the Users and Maps reports are resolved to in-game tickers via ESI (/v5/corporations/{id}/), with a 1-hour in-memory cache to keep the report loads cheap.
  • Per-character attribution — sigs, structures, and system add / delete actions are recorded with the user who made them, so reports can answer "who has been scanning what" with no manual logging.

Wormhole bookmarks

Generate a consistent, paste-ready name for a wormhole and drop it straight into your in-game bookmark, so your Locations window reads the same way for everyone in the chain.

How to use it. In the signatures pane, every wormhole signature row has a copy button. Click it and the generated name is copied to your clipboard — alt-tab into EVE, bookmark the hole, and paste the name into the bookmark's name field. The exact text that was copied is shown in a toast.

Choosing the format. The name is built from a token template you can change in the sidebar under Map Options → Signature bookmark (it's saved to your account, so it follows you across devices). Hover the field's label to see the full token list. The default is:

{sig} {dest_type} {size}        →  e.g.  ABC-123 C5 M

Available tokens (any token that can't be filled in just disappears, and extra spaces are collapsed, so partly-scanned holes still come out tidy):

Token Example Meaning
{sig} ABC-123 Full signature ID
{sig_letters} ABC First three characters of the ID
{type} D382 Wormhole type code
{dest_type} C5, HS Destination class
{size} S / M / L / XL Hole size (from max jump mass)
{mass} 3.0 Total mass, in billions of kg
{age} 2h Hours since the sig was first seen
{name} The signature's name field
{notes} The signature's notes field

Tip: {age} reflects when the signature was first scanned, so it reads 0h right after you add a hole — useful for re-copying an older bookmark later, but most people leave it out of the default.


External API

Read and drive your maps programmatically — pull the live chain into a fleet bot, auto-import scan results from an intel tool, run a "is home open to highsec?" checker, or your own scripts. Authenticated with a long-lived API key you generate, instead of a browser session.

Generate a key. Click the 🔑 icon in the toolbar (next to the language switcher). Give it a name, pick which of your characters it acts as (the key can do exactly what that character can — same maps, same role), choose its access (see scopes below), and optionally set an expiry. The key is shown once at creation — copy it then; it's stored only as a hash and can't be retrieved again. Revoke any key from the same panel and any tool using it loses access immediately.

Scopes (each includes the ones above it):

Scope Can
Read only Read maps, systems, signatures, anomalies, structures
Read + live events …plus subscribe to the live event stream
Read + write content …plus add / edit / delete signatures, anomalies, and structures

Writes are limited to per-system content. Map topology (adding/moving systems, drawing connections, rename, lock) is deliberately human-only — no key can change it.

Authenticate. Send the key as a Bearer token:

curl -H "Authorization: Bearer nxm_…" https://yourdomain.com/api/v1/maps

Read endpoints (any scope):

Endpoint Returns
GET /api/v1/maps Maps visible to the key's account
GET /api/v1/maps/:mapId Full map — systems + connections
GET /api/v1/maps/:mapId/systems/:systemId/signatures Scanned signatures in a system
GET /api/v1/maps/:mapId/systems/:systemId/anomalies Cosmic anomalies in a system
GET /api/v1/maps/:mapId/systems/:systemId/structures Player structures in a system
GET /api/v1/maps/:mapId/events Live event stream (SSE) — needs Read + live events

Write endpoints (need Read + write content; gated by the bound character's role just like the app):

Endpoint Does
POST/PATCH/DELETE /api/v1/maps/:mapId/systems/:systemId/signatures[/:sigId] Add / edit / remove a signature
POST/PATCH/DELETE /api/v1/maps/:mapId/systems/:systemId/anomalies[/:anomId] Add / edit / remove an anomaly
POST/PATCH/DELETE /api/v1/maps/:mapId/systems/:systemId/structures[/:structureId] Add / edit / remove a structure

Writes go through the exact same path as the app — so they fan out over the event stream, trigger the inbound-K162 Discord notice, and cross-map sync just like a human edit.

Live event stream

GET /api/v1/maps/:mapId/events is a Server-Sent Events stream of the same live edits the web client receives — so a tool can stay in sync without polling. It needs a key with the Read + live events scope (a plain read key gets 403). On connect it sends a presence.snapshot, then one JSON event per edit; lines beginning : are heartbeat/keep-alive comments.

curl -N -H "Authorization: Bearer nxm_…" \
  https://yourdomain.com/api/v1/maps/<mapId>/events

Each data: line is a JSON object with a type and a type-specific payload. The published event types (the public contract):

Type Meaning
system.add / system.update / system.remove A system was added, changed (status, notes, position, activity), or removed
connection.add / connection.update / connection.remove A connection was drawn, retyped (mass/EOL/type), or deleted
sig.changed / anom.changed / structure.changed A system's signatures / anomalies / structures changed
map.meta / map.resync Map metadata (name, lock) changed / clients should refetch
presence.snapshot / presence.update / presence.leave Who's viewing the map and where (ephemeral)

Single-process delivery: the stream is served in-memory by one instance, the same as the in-app live sync. A multi-replica deployment would need the documented Postgres LISTEN/NOTIFY swap — see the realtime-sync notes.

Scope and safety. Keys are account-scoped — a key can do everything its account can within its scope, so treat it as a secret. Writes are capped at per-system content (never topology) and are still gated by the bound character's role, so a key acting as a readonly corp member can't write a corp map. Keys can be given an expiry, record a last used time so you can spot a stale or leaked key, and are one-click revocable. Share-link tokens are never returned through the API.


Installation

Option 1 — Docker (recommended)

Prerequisites: Docker with Compose, an EVE Online developer application (developers.eveonline.com).

1. Clone and configure

git clone https://github.com/GQuantrill/eve-nexum.git
cd eve-nexum
cp .env.example .env

Edit .env and fill in the required values:

Variable Required Description
PG_PASSWORD Yes PostgreSQL password
NODE_ENV Optional Set to development for local dev (auto-derives TOKEN_ENCRYPTION_KEY from SESSION_SECRET and relaxes session-cookie settings). Defaults to production in Docker, where missing SESSION_SECRET / EVE_CLIENT_ID / EVE_CLIENT_SECRET will fail fast at boot.
SESSION_SECRET Yes Random secret — run openssl rand -hex 32
TOKEN_ENCRYPTION_KEY Yes (production) 64 hex chars used to encrypt stored EVE OAuth tokens at rest — run openssl rand -hex 32. Do not change after first boot — rotating this key makes existing stored tokens unreadable and forces every user to re-login. In development the key is auto-derived from SESSION_SECRET if unset.
EVE_CLIENT_ID Yes From your EVE developer app
EVE_CLIENT_SECRET Yes From your EVE developer app
EVE_CALLBACK_URL Yes Must match the callback registered in your EVE app — e.g. https://yourdomain.com/auth/callback
FRONTEND_URL Yes Public URL of the app — e.g. https://yourdomain.com
DOMAIN Traefik only Bare hostname for the Traefik router rule — e.g. nexum.yourdomain.com
CORP_ID Optional Restricts logins to specific EVE corporations. Comma-separated list of corporation IDs — anyone whose corp is not in the list is rejected at the OAuth callback (unless their alliance is in ALLIANCE_ID). Leave empty/unset to allow any EVE character to log in. Example single corp: 98000001. Example multi-corp: 98000001,98000002.
ALLIANCE_ID Optional Restricts logins to specific EVE alliances, and adds the alliance map scope. Comma-separated list of alliance IDs. A character is admitted if their corp is in CORP_ID or their alliance is in ALLIANCE_ID, so a whole alliance can be permitted without listing every member corp. The list also forms the coalition for ALLIANCE_MAP_SHARED. Example: 99000001 or 99000001,99000002.
ADMIN_CHAR_ID When CORP_ID or ALLIANCE_ID is set EVE character ID of the bootstrap admin. Forced to the top role on first login (alliance_admin when ALLIANCE_ID is set, otherwise admin) and cannot be demoted or blocked by other admins. Not a membership exemption — this character still has to be in a listed corp or alliance to log in. See What happens when a user leaves the corp.
CORP_MAP_SHARED Optional 1 / true to share every corp map across every listed corp. Default (false) scopes corp maps to the corp that created them — Corp A's chain stays invisible to Corp B even when they share a deployment. Only enable when all listed corps explicitly trust each other.
ALLIANCE_MAP_SHARED Optional Alliance counterpart of CORP_MAP_SHARED. 1 / true makes every alliance map visible to every listed alliance (coalition mode). Default (false) scopes each alliance map to its owning alliance.
CORP_MAP_TIME Optional Days an idle corp or alliance map can sit untouched before it's auto-deleted. Default 30.
MAX_USER_MAPS Optional Max number of personal maps per user. Default 5.
MAX_CORP_MAPS Optional Max number of corp maps per corp. Default 5.
MAX_ALLIANCE_MAPS Optional Max number of alliance maps per alliance. Default 5.
LAZY_WH_SWEEP_MINUTES Optional Cadence (minutes) of the lazy wormhole-removal sweep, which removes aged-out WH sigs and quarantines the connections they backed on maps that have opted in. Default 15. Set to 0 to disable the sweep entirely.
DISCORD_WEBHOOK_URL Optional Discord webhook(s) for corp-intel notifications (inbound K162, new connections). One URL fires for every corp map; for multi-corp deployments use corpId=URL pairs (comma-separated) to route each corp to its own channel — e.g. 98000001=https://discord.com/api/webhooks/…,98000002=https://discord.com/api/webhooks/…. Personal maps never notify. Leave unset to disable. Which regions/maps actually notify is then filtered per corp in the admin Discord tab. See Discord notifications.

EVE developer app scopes

When registering your application at developers.eveonline.com, enable the following scopes:

Scope Purpose
esi-location.read_location.v1 Read character's current solar system
esi-location.read_ship_type.v1 Read character's active ship
esi-location.read_online.v1 Read character online status
esi-ui.open_window.v1 Open windows in the EVE client
esi-ui.write_waypoint.v1 Set destinations and add waypoints
esi-universe.read_structures.v1 Read player-owned structure info
esi-corporations.read_corporation_membership.v1 Read corporation member list
esi-characters.read_corporation_roles.v1 Read character's corporation roles
esi-characters.read_contacts.v1 Read the character's personal contact list (standings) — used to colour-tag hostile / friendly entities in the Standings card, Killboard, Sov holder, and map node halos.
esi-corporations.read_contacts.v1 Read the corporation's shared contact list. Only succeeds for characters with the in-game Contact Manager role; the call is gracefully skipped for anyone else. When it does succeed, the entire corp benefits from the pulled standings until the next refresh.
esi-alliances.read_contacts.v1 Read the alliance's shared contact list. Requires the character to be in the alliance executor corp with the right role; almost always denied for normal members, and that's fine — the call no-ops without breaking login.
esi-fleets.read_fleet.v1 Read the character's current fleet composition (members + their solar systems) so fleet-mates show up as purple dots on the map with a hover tooltip listing names. Member-list reads require the character to be the fleet boss; wing/squad commanders see "in a fleet, no member visibility" and the UI degrades silently.

2. Build and start the stack

The server depends on the EVE Static Data Export (SDE) tables (map_stargates, solar_systems, item_types, …) at boot — without them, route-graph initialisation throws and the container crash-loops. A dedicated importer service handles this: it runs once after Postgres is healthy, downloads the SDE (~hundreds of MB from CCP, a few minutes; logs progress per table), populates the static tables, then exits. The server waits for it to finish (service_completed_successfully) before booting, so a single command brings everything up in the right order.

docker compose build
docker compose up -d

The app will be available on port ${WEB_PORT:-80} (defaults to 80).

The import is self-skipping: on every later up or restart the importer sees the static tables are already populated and exits in a second without re-downloading. So the two commands above are also your update flow. To force a re-import (e.g. after a CCP SDE drop), see Updating the SDE below.

The importer also stores each system's universe coordinates and CCP's 2D star-map projection (position / position2D), which power the Seed a map from a region feature.

3. Reverse proxy (optional)

To front the stack with Traefik for TLS and a public URL, add DOMAIN=nexum.yourdomain.com to your .env, then:

docker compose -f docker-compose.yml -f docker-compose.traefik.yml up -d

Traefik will handle TLS termination and HTTP→HTTPS redirects. The docker-compose.traefik.yml overlay assumes a Traefik network named traefik-public and a cert resolver named letsencrypt.

Tip — avoid retyping the overlay. Every docker compose ... command below uses the standard form. If you run with the Traefik overlay, either prefix each command with -f docker-compose.yml -f docker-compose.traefik.yml, or set it once per shell session:

export COMPOSE_FILE=docker-compose.yml:docker-compose.traefik.yml

After that, plain docker compose ... automatically loads both files. Add the export to ~/.bashrc / ~/.zshrc if it's the only deployment on that host.

App-schema migrations (users, maps, map_signatures, etc.) layer on automatically the first time the server boots — no manual step.

Schema ownership. The one-shot importer (server/scripts/setup-db.ts) creates only the static/SDE schema and its indexes (solar_systems, map_stargates, item_types, …) — it must not depend on app columns the server migration adds later, since the importer runs before the server boots. App-table indexes are owned by server/src/migrate.ts, which creates them after the app columns are guaranteed to exist. (Keeping an app-table index in the importer would crash a fresh bootstrap on a missing column.)

Updating the SDE

The static data (systems, stargates, item types, dogma — everything CCP ships in the Static Data Export) is imported into Postgres at first boot and kept current from there. Here's how it stays up to date, how to force it, and how to check which build you're on.

Automatic (the default — you normally do nothing). The server checks once a day (default 11:30 UTC, just after the 11:00 downtime when CCP publishes the new export) — plus a catch-up a few minutes after every restart, so a deploy that missed the daily slot, or a build CCP publishes off-cycle, doesn't leave you stale for ~24h. The check is cheap: a single HEAD request reads the build number from CCP's "latest" redirect (…/eve-online-static-data-<build>-jsonl.zip) and compares it to the build recorded in the sde_meta table. If — and only if — the build changed, the server downloads the new export, re-seeds the static tables, reloads its in-memory route graph in place, then deletes the downloaded zip so it doesn't sit on disk. No new build, no download; a re-seed needs no restart.

The re-seed is upsert-only (ON CONFLICT DO UPDATE on every table), so it's safe — your user data (maps, signatures, structures, sessions, etc.) lives in other tables and is never touched.

Tune it via .env:

Variable Default Effect
SDE_AUTO_UPDATE 1 (on) Set to 0 to disable the daily check entirely.
SDE_CHECK_UTC 11:30 Time of day (HH:MM, UTC) to run the check.

Forcing an update now (e.g. to test, right after a known CCP drop, or if you disabled the daily check). Run the importer with FORCE_SDE_IMPORT=1 — it downloads the latest export and re-seeds regardless of the recorded build:

# Docker
docker compose run --rm -e FORCE_SDE_IMPORT=1 importer
# Local development (Postgres on localhost)
cd server && FORCE_SDE_IMPORT=1 yarn setup-db

Checking which build you're on. GET /api/sde/version is public and browser-callable — it reports the build this instance is running against the latest CCP currently offers, so you can confirm an update landed (or see that one is pending):

// GET /api/sde/version
{
  "installed":   "3365090",                  // build imported into this DB (null if never seeded)
  "installedAt": "2026-05-29T11:29:00.000Z", // when that build was imported
  "latest":      "3368760",                  // latest build CCP offers (null if CCP unreachable)
  "latestCheckedAt": "2026-06-02T11:30:00.000Z", // when THIS endpoint last queried CCP (cached)
  "upToDate":    false,                       // true | false | null (unknown)
  "autoUpdate":  true,                        // is the daily auto-update enabled?
  "autoCheck": { "at": "2026-06-02T11:30:12.000Z", "result": "updated" } // last auto-update run (null until one fires)
}

installed vs latest are directly comparable — both are CCP build numbers from the same source the importer uses. upToDate is null ("unknown") rather than a misleading false when either build can't be determined (CCP unreachable, or a never-seeded DB).

Two timestamps that are easy to confuse: latestCheckedAt is when this endpoint last asked CCP for the latest build (cached — a single HEAD, 1 h on success / 5 min after a failure, so the endpoint is cheap to poll). autoCheck is when the auto-updater itself last ran and what it did (updated / unchanged / error / skipped) — that's the field to watch to confirm the daily check is actually firing. It's null until the first check runs in the current process. For the full picture, the server also logs each run under the sde-update tag (docker compose logs server | grep sde-update).

Refreshing wormhole types

Mostly automatic. Wormhole stats (destination class, mass limits, lifetime, mass-regen) are derived live from the imported SDE dogma, so a CCP rebalance flows in on the next daily re-seed with no action — no extract-wormholes, no rebuild.

The only thing the SDE can't provide is src ("where can this WH appear") — community knowledge curated in data/wormholes.json. When CCP adds a brand-new WH type (rare — usually a major expansion), it auto-appears in /api/wormholes/types with its derived stats and an empty src, and the server logs it as needing curation. To fill in src, add the code to data/wormholes.json and rebuild:

# Optional: scaffold any new codes (preserves existing curation, fills new
# entries with placeholder src). Then edit data/wormholes.json by hand.
docker compose run --rm \
  -v "$PWD/server:/app" -w /app -e NODE_ENV=development \
  --entrypoint sh server -c "yarn install --frozen-lockfile && yarn extract-wormholes"
docker compose build server && docker compose up -d

See Static data files for what extract-wormholes does and the fields involved.

Upgrading an existing deployment

Pulling a new Nexum release into a running instance:

  • App-schema changes apply automatically. New columns and tables (e.g. the map-merge allow_as_merge_source / allow_as_merge_destination flags, the solar-system coordinate columns) are added by the migration that runs on every server boot — just rebuild and restart:
    docker compose build server && docker compose up -d

Backup & restore

Two scripts under scripts/ handle the Postgres database for a docker-compose deployment. Both read PG_USER / PG_DB from your .env, so they stay in sync with the running stack. Paths are configurable via env vars (NEXUM_PROJECT_DIR, NEXUM_BACKUP_DIR, NEXUM_KEEP, NEXUM_FORCE).

Backupscripts/backup-db.sh runs pg_dump, gzips it, writes atomically, and prunes to the last NEXUM_KEEP (default 7). Safe under cron:

# one-off
./scripts/backup-db.sh
# daily at 05:00, as the user that owns the compose project
0 5 * * *  /opt/eve-nexum/scripts/backup-db.sh >> /var/log/nexum-backup.log 2>&1

Restorescripts/restore-db.sh restores a dump (a given file, or the most recent in NEXUM_BACKUP_DIR). It's destructive (the dumps are --clean --if-exists, so it drops & recreates the dumped objects), so it prompts for confirmation and stops the server for the duration:

./scripts/restore-db.sh                                   # newest backup
./scripts/restore-db.sh /var/backups/nexum/nexum-2026-06-02.sql.gz
NEXUM_FORCE=1 ./scripts/restore-db.sh <file>              # skip the prompt (cron/automation)

The dump includes the EVE SDE tables (systems, stargates, dogma, …) alongside your user data — simplest and self-contained. The app schema applies on the next server boot and the SDE re-seeds if missing, so a restore heals itself even against a newer build.


Option 2 — Local development

Prerequisites: Node.js 20+, Yarn, PostgreSQL 16.

1. Clone and configure

git clone https://github.com/GQuantrill/eve-nexum.git
cd eve-nexum
cp .env.example .env

Set NODE_ENV=development and point PG_HOST=localhost in your .env.

2. Install dependencies

cd server && yarn install
cd ../web   && yarn install

3. Start the server

cd server
yarn dev

4. Start the frontend

cd web
yarn dev

The frontend is available at http://localhost:5174. The Vite dev server proxies /api and /auth to http://localhost:3001.

The server needs the EVE SDE imported into Postgres before it will boot — run cd server && yarn setup-db once (downloads the latest SDE and populates the static tables, including system coordinates).


Corp & alliance mode

When CORP_ID and/or ALLIANCE_ID is set, Nexum operates in restricted mode — only members of the listed corporations or alliances can log in. The user roles described below apply only in restricted mode; open deployments (neither set) treat every authenticated character as a normal user with full access to their own maps.

Allowing multiple corporations

CORP_ID accepts a comma-separated list of corporation IDs. Every character logging in is checked against this list via ESI; anyone whose corp is not included (and whose alliance isn't in ALLIANCE_ID) is bounced to the landing page with ?error=not_in_corp.

# Single corp
CORP_ID=98000001

# Two (or more) corps sharing the same deployment
CORP_ID=98000001,98000002,98000003

Alliance mode

Set ALLIANCE_ID to permit whole alliances without enumerating every member corp. A character is admitted if their corp is in CORP_ID or their alliance is in ALLIANCE_ID, so the two lists are additive — you can run alliance-only, corp-only, or a mix.

# Permit an entire alliance (no need to list its corps)
ALLIANCE_ID=99000001

# A coalition of alliances
ALLIANCE_ID=99000001,99000002

Alliance mode adds a third map scope above corp: personal → corp → alliance.

  • Alliance maps are visible to every member of the owning alliance and are created / deleted / locked / renamed only by the alliance_admin role. Set ALLIANCE_MAP_SHARED=true to make every alliance map visible across all listed alliances (coalition mode); the default keeps each alliance's maps to its own members.
  • Corp maps inside an alliance still work without a CORP_ID list — a corp map is derived from the creator's corp at login and stays visible to same-corp members only. This lets each corp keep private chains while the alliance shares its own maps above them.
  • alliance_admin is a superset of admin: it administers users and maps across the entire alliance, whereas a corp admin stays scoped to its own corp. Only an alliance admin can grant the alliance_admin role or block another alliance admin. ADMIN_CHAR_ID is pinned to alliance_admin while ALLIANCE_ID is set.

How corp & alliance map visibility works

By default (CORP_MAP_SHARED=false), corp maps are scoped to the corp that created them. If Corp A creates a corp map, only members of Corp A can see it — Corp B's members never know it exists, even though they share the deployment. This is the safer default for alliance-shared instances where each corp wants to keep its chain intel private.

Set CORP_MAP_SHARED=true to make every corp map visible to every listed corp. Only do this when all listed corps explicitly trust each other.

Alliance maps follow the same pattern one scope up: by default an alliance map is visible only to its owning alliance; ALLIANCE_MAP_SHARED=true shares every alliance map across all listed alliances (coalition mode).

Personal maps are always private to their owning user, regardless of corp, alliance, or the *_MAP_SHARED flags. Leaving a corp or alliance does not transfer ownership.

Roles

Roles are stored per-user in the database. New users default to readonly; an admin must promote them.

Rules differ for personal (solo) maps, corp maps, and alliance maps. Personal maps are scoped to a single user; corp and alliance maps are shared infrastructure and gated more tightly.

Personal maps — every user can create their own personal maps (up to MAX_USER_MAPS) and edit everything inside them, regardless of role. A readonly user is "readonly" only with respect to other people's maps; their own personal map is theirs.

Shared-in personal maps — when another user has shared their personal map with you (or with your corp), you can edit signatures, structures, notes, and topology regardless of your global role — the grant is an explicit invitation by the owner. You cannot rename the map, delete it, generate a public share link, or change who else has access; those stay with the owner. Lock state still applies: if the owner is also an admin and locks the map, your topology edits are frozen the same as a locked corp map.

Corp & alliance maps:

Role View Edit signatures / structures / notes Edit systems / connections / rename Create / delete / lock corp map Manage corp users Create / delete / lock alliance map Manage alliance users
readonly
edit
full
admin ✓ (own corp)
alliance_admin ✓ (whole alliance)

Editing rights on an alliance map follow the same tiers as a corp map (a readonly alliance member can view but not edit; edit/full/admin can edit). Only alliance_admin can create, delete, lock, or rename an alliance map, or grant the alliance_admin role. alliance_admin is a strict superset of admin — the difference is reach (the whole alliance vs a single corp), not editing capability.

The character whose ID matches ADMIN_CHAR_ID is always given the top role on login (alliance_admin when ALLIANCE_ID is set, otherwise admin) and cannot be demoted or blocked by other admins. Leave it set even after you've promoted other admins — it's the recovery path against an admin demoting themselves or being blocked by mistake.

Map locking

Admins can lock any corp map from the Admin → Maps page. A locked map keeps accepting signatures, structures, and per-system notes from any user with normal edit permission, but rejects topology mutations — system add/move/remove, connection add/edit/delete, and map rename are all frozen for non-admins. The toolbar shows an amber 🔒 Locked chip next to the map name while the lock is active.

A locked map also stops auto-growing from passive location tracking: even an admin walking through EVE won't sprout new systems on a locked chain (admins can still add manually via the canvas right-click). This is intentional — locking is what you do when you want the layout pinned, including against your own movements.

force_lock_map and force_unlock_map actions are written to the audit log.

Merging maps

Merging folds a source map's contents into a destination map. The destination is treated as the source of truth — its systems are never overwritten; only missing systems and connections are added, with signatures, structures, and notes merged in (each toggleable in the merge dialog). Open it from the map sidebar's Merge Maps section.

Who can merge what — solo and corp maps are gated differently:

  • Solo maps — you can always merge from or into a personal map you own, or one that's been shared with you. No special role needed; it's your map (or an explicit grant).
  • Corp maps are opt-in and gated by role:
Action Requirement
Use a corp map as a merge source The map has Allow as merge source enabled. Any corp member who can view it may then merge from it.
Use a corp map as a merge destination The map has Allow as merge destination enabled and the user has edit / full / admin (the same write access as any other corp-map edit). Locked corp maps are excluded for non-admins, since a merge changes topology.
Toggle either flag full or admin only.

Both flags default to off — a corp map is neither a merge source nor destination until a full/admin member turns it on. The two toggles live in the same Merge Maps sidebar section and are independent: a map can be a source, a destination, both, or neither. The toggles only appear on corp maps; solo maps ignore them.

Every merge that involves a corp map on either side writes an audit entry (corp_map_merge_source / corp_map_merge_destination) recording who performed it and the source → destination map names. The merge runs in a single transaction, so a failure leaves the destination untouched (no audit entry either).

What happens when a user leaves the corp

The corp membership check runs at login. Existing sessions keep working until the user logs out and back in — at which point ESI is queried again and a corp departure causes the login to fail. Admins can also manually block a user (see below), which prevents login entirely regardless of corp membership.

…including ADMIN_CHAR_ID

The corp gate is fail-closed and does not exempt ADMIN_CHAR_ID. If your bootstrap admin character leaves every corp listed in CORP_ID, their next login is rejected just like anyone else's, and because they can no longer log in there's no in-app path to recover. This is intentional — a former member walking out of corp with admin keys is exactly who you don't want signing back in. To recover you have three options:

  1. Move the character back into one of the listed corps in-game and log in again.
  2. Edit .env to add their new corp to CORP_ID and restart the server.
  3. Edit the users table directly (last resort).

If you want a permanent break-glass, run a second ADMIN_CHAR_ID-eligible character in a corp you control and don't plan to leave.

Discord notifications

Set DISCORD_WEBHOOK_URL to push chain intel into a Discord channel so alerts reach people who aren't watching Nexum. It fires server-side, so it doesn't depend on anyone having the map open.

What fires:

  • Inbound K162 — a signature's wormhole type is set to K162 (something just connected into the chain).
  • New wormhole connection — a connection is drawn between two systems.

Scope and behaviour:

  • Corp maps only. Personal maps never notify — their scanning stays private.
  • Bulk operations are excluded. Seeding a region or merging maps creates many connections at once and deliberately does not post to Discord; only interactive edits do.
  • Best-effort. A webhook failure (Discord down, timeout, rate-limit) is logged and dropped — it never affects the edit that triggered it. Delivery is paced and honours Discord's rate limits.

Configuration — create a webhook in your Discord channel (Channel → Edit → Integrations → Webhooks) and set the URL:

# One channel for every corp map:
DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/123/abc

# Multi-corp: route each corp to its own channel (corpId=URL, comma-separated):
DISCORD_WEBHOOK_URL=98000001=https://discord.com/api/webhooks/1/a,98000002=https://discord.com/api/webhooks/2/b

The webhook URL is a secret: it lives only in the server env, is never sent to the browser, and is masked in logs. Changing it takes effect on the next server restart. Leave it unset to disable notifications entirely.

Filtering (admin). By default every corp map and region notifies. The Discord tab in the admin panel lets an admin narrow this per corp, without touching the webhook:

  • Regions — notify for all regions (default), or only a chosen allowlist. The wormhole's system region is checked at send time; for a new connection, either endpoint's region qualifies.
  • Excluded maps — every map notifies by default; tick maps to exclude them.

These settings only ever subtract from the default, and new maps are always included automatically, so nothing silently goes dark.

Admin operations

Admins reach the dashboard from the Admin button in the toolbar, which lands them at #/admin/users. The page has five tabs:

  • Users — role select, block / unblock, recheck corp via ESI.
  • Maps — every corp map across every listed corp. Force-lock, force-unlock, force-delete. Solo maps are deliberately excluded — they belong to individual users.
  • Reports — placeholder for future ops reports.
  • Discord — per-corp notification filters: a region allowlist and per-map exclusions (see Discord notifications).
  • Audit log — last 200 admin actions, newest first.

Each tab is also reachable at its own hash route (#/admin/maps, #/admin/audit, …). All mutating actions write to the admin_audit table with the actor, target, old value, and new value.

Endpoint Purpose
GET /api/admin/users Full user list with role, corp ID (and resolved corp ticker from ESI), blocked status, last login, and activity counts.
PATCH /api/admin/users/:id/role Change a user's role to one of admin, full, edit, readonly. Cannot demote yourself or the ADMIN_CHAR_ID character.
POST /api/admin/users/:id/block Block a user from logging in. They keep their existing session until next login, then get bounced with ?error=blocked. Cannot block yourself or ADMIN_CHAR_ID.
POST /api/admin/users/:id/unblock Reverse a block.
POST /api/admin/users/:id/recheck-corp Hits ESI for the target user's current corp without waiting for their next login. If they've left every corp in CORP_ID, they're auto-blocked. Useful when a known departure needs to take effect immediately.
GET /api/admin/maps Every corp map with owner, corp ticker, system / connection counts, lock state, last active.
POST /api/admin/maps/:id/lock Freeze a corp map's topology. Signatures/structures/notes remain editable; see Map locking.
POST /api/admin/maps/:id/unlock Reverse a lock.
DELETE /api/admin/maps/:id Force-delete a corp map regardless of who owns it.
GET /api/admin/audit The 200 most recent admin actions (role change, block, unblock, corp change, auto-block-on-departure, force-lock, force-unlock, force-delete).

What admins cannot do

  • Demote themselves (use another admin).
  • Demote or block the character whose ID is in ADMIN_CHAR_ID — that character is the safety hatch.
  • Force-terminate an existing live session. A blocked user stays signed in until they log out or their session cookie expires; the next login then fails. If you need someone offline right now, blocking + revoking their EVE OAuth in CCP's developer panel is the immediate path.

Analytics & telemetry

By default a self-hosted Nexum reports to no one. No analytics, no tracking, no phone-home. Both of the following are off until you turn them on, and neither sends any user, character, or map data.

Frontend analytics (Google Tag Manager) — off by default

The web build only loads Google Tag Manager if you provide a container ID at build time via VITE_GTM_ID. Leave it unset (the default) and the built site contains no analytics whatsoever — no GTM, no Google Analytics, nothing.

To enable it for your own deployment, set your own container ID in .env and rebuild the web image:

VITE_GTM_ID=GTM-XXXXXXX        # in .env
docker compose build web && docker compose up -d

Only ever use a container ID you own. The ID is inlined at build time, so a rebuild is required to change it.

Deployment telemetry (version ping) — opt-in

So the project can gauge how many people self-host and which versions are live, the server can send a tiny opt-in ping. It is off unless you set NEXUM_TELEMETRY=1, and when enabled it sends, once a day:

  • the app version (e.g. 0.1.0)
  • a random per-instance id (generated once, stored locally), so repeat pings count as one install

That's the entire payload. It contains no user data, character names, corp IDs, map data, or settings, and the receiver does not store your IP. To enable it:

NEXUM_TELEMETRY=1                                  # in .env
# optional: send to your own collector instead of the project's
# NEXUM_TELEMETRY_URL=https://your-host/api/telemetry

Unset NEXUM_TELEMETRY (the default) and the server never makes the call. The receiving endpoint (POST /api/telemetry) exists on every deployment but stays empty unless instances are pointed at it.


Static data files

Some pre-computed lookups live in server/data/ as plain JSON, derived once from the EVE Static Data Export (SDE). They're committed to the repo so a fresh install works without an extra step. You only need to regenerate them when CCP releases an SDE drop that adds or changes the underlying data.

The SDE tables themselves (systems, stargates, dogma, …) are imported into Postgres separately and kept current automatically — see Updating the SDE for how that works, how to force it, and the GET /api/sde/version endpoint for checking which build you're on.

A0 sun systems (typeID 3801, "Sun A0 (Blue Small)") used to live here as data/a0-systems.json. They're now flagged on solar_systems.is_a0 directly by the SDE importer (from mapStars.jsonl) and served via GET /api/systems/a0, so they refresh automatically on every SDE re-seed — no committed file, no manual regen. Note this is the in-game A0 classification (typeID 3801), not the SDE's statistics.spectralClass field; the two don't agree.

data/wormholes.json

Per-wormhole-type metadata for every connection signature (T405, R943, K162, …) keyed by the in-game 3-letter code, served via GET /api/wormholes/types (the client uses it for the wormhole type picker, sig aging tints, and the WH-type info popover).

The numeric stats are not read from this file at runtime — the server derives them live from the imported SDE dogma each time it builds the spec list (and rebuilds after every re-seed), so CCP rebalances apply automatically. The deterministic fields and their dogma sources:

Field Source
dest dogma attribute 1381 (wormholeTargetSystemClass)
total_mass dogma attribute 1382 (massWormholeTotal)
max_mass_per_jump dogma attribute 1383 (massWormholeMaxJumpable)
mass_regen dogma attribute 1384 (massWormholeMassRegeneration)
lifetime dogma attribute 1503 (wormholeMaxStableTime, seconds — converted to hours)

What this file does provide is the one thing CCP doesn't encode anywhere: the src array — "where can this wormhole appear" is community/observation knowledge, not data. (static and sibling_groups live here too but aren't currently served.) The schema follows the exodus4d/Pathfinder shape — that project is the original source of the file and is bundled at the repo root for licensing/attribution. A brand-new WH code auto-appears with derived stats and an empty src, logged as needing curation; fill it in here and rebuild.

extract-wormholes (optional maintainer helper). Regenerates this file from the imported SDE — preserving existing src / static / sibling_groups and scaffolding new codes with placeholders — so you have a starting point to curate. The server no longer depends on the stat fields it writes; it's purely for maintaining src. Run it after a fresh yarn setup-db:

cd server
PG_HOST=localhost yarn extract-wormholes

Or inside Docker via a one-off container (matching the SDE refresh recipe in the Troubleshooting section):

docker compose run --rm \
  -v "$PWD/server:/app" \
  -w /app \
  -e NODE_ENV=development \
  --entrypoint sh \
  server -c "yarn install --frozen-lockfile && yarn extract-wormholes"

The script prints three lists at the end:

  • New codes since last run — these have src: [] and static: false filled in as placeholders; review them against CCP's patch notes (or the WH's in-game description) and edit by hand.
  • Orphaned codes — present in old JSON but absent from current SDE. Usually means CCP removed the type (very rare).
  • Unmapped destination classes — printed only if CCP adds a new class enum we don't know about; if you see this, extend CLASS_MAP at the top of scripts/extract-wormholes.ts.

K162 is special-cased: it has no single destination class (it's the "return side" of every other connection), so it isn't in dogma; the server serves it from this file verbatim.

Rebuild and restart (docker compose build server && docker compose up -d) after editing the file so the new src is baked into the image.


Technology overview

Frontend — web/

Technology Role
React 19 + TypeScript UI framework
Vite Build tool and dev server
Zustand Client-side state (maps, selections, undo history)
@xyflow/react Interactive node-graph canvas for the wormhole map
@dnd-kit Drag-and-drop reordering of system panel cards
@uiw/react-md-editor Markdown notes editor

The frontend is a fully static SPA after build. In production it is served by nginx, which also proxies all /api/* and /auth/* requests to the API server.

Backend — server/

Technology Role
Node.js 20 + TypeScript Runtime
Express HTTP server and routing
PostgreSQL 16 via pg Persistent storage for maps, systems, signatures, structures, sessions
express-session + connect-pg-simple Session management with database-backed store
EVE SSO (OAuth2) Character authentication

External APIs

API Data
ESI System info, sovereignty, NPC stations, jumps, kills, server status, character online status
zKillboard Recent kill feed per system
EVE Image Server Character portraits, alliance and corporation logos

Infrastructure

Component Technology
Web server nginx (Alpine)
Container orchestration Docker Compose
Database PostgreSQL 16 (Alpine)

Troubleshooting

The stack has three services — web (nginx), server (Node API), and postgres. Most issues are isolated to one of them; figure out which is failing first, then pull its logs.

1. Which service is failing?

docker compose ps

Look at the STATUS column:

Status Meaning
Up / Up (healthy) running normally
Restarting crashing on boot — see logs
Exit 1 failed to start — see logs
(missing) never started — docker compose up -d

You can also probe each layer directly:

# Web (nginx serving the SPA + proxying /api & /auth)
curl -I http://localhost           # expect HTTP/1.1 200

# Server (Express API)
curl http://localhost:3001/health  # expect {"ok":true}

# Database
docker compose exec postgres pg_isready -U "$PG_USER" -d "$PG_DB"

If curl :3001/health fails but docker compose ps shows the server Up, the container is running but the app crashed inside it — check its logs.

2. Viewing logs

# Live tail for one service (Ctrl-C to exit)
docker compose logs -f server
docker compose logs -f web
docker compose logs -f postgres

# Last 200 lines, then exit
docker compose logs --tail=200 server

# Everything in time order across all services (useful for race conditions)
docker compose logs -f --timestamps

Server logs are tagged by subsystem — grep for the prefix to narrow the noise:

docker compose logs server | grep -E '\[(auth|maps|admin|standings|ghost-sites|storms|activity|incursions)\]'

3. Common failure patterns

Symptom Likely cause Where to look
Migration failed: in server logs, container restarts DB unreachable or wrong creds docker compose logs postgres; verify PG_* in .env
Login redirect loop / Authentication failed EVE_CALLBACK_URL in .env doesn't match the URL registered on the EVE Developer app server logs ([auth] lines)
Login works but cookies don't persist FRONTEND_URL mismatch, or reverse-proxy stripping Cookie header browser DevTools → Application → Cookies; server logs
Blank page on the SPA nginx serving 404 for /index.html, build artifacts missing docker compose logs web; rebuild with docker compose build web
502 Bad Gateway from nginx server container is down check docker compose ps; then server logs
Admin page / Reports tab not visible role isn't admin (or canViewReports), CORP_ID is unset for admin routes DevTools → Network → /auth/me response shows role and canViewReports
ESI features stale (kills, structures, sov) ESI rate-limited or down server logs; https://esi.evetech.net/ui/ for service status
Storm / ghost-site lookups not appearing scraper fetch failed (502 fallback used) server logs ([storms] / [ghost-sites])

4. Local-dev variant

In local-dev mode (npm run dev in server/ and web/ separately) there are no containers — logs stream straight to the terminal you started them from. Browser-side errors appear in DevTools Console; network failures show in DevTools Network. The Vite dev server proxies /api and /auth to the local API, so a 502 in the browser network tab means the API process exited.

5. Health-check shortcuts

# Count the rows the server should have populated at boot
docker compose exec postgres psql -U "$PG_USER" -d "$PG_DB" \
  -c "SELECT 'solar_systems' AS t, COUNT(*) FROM solar_systems
      UNION ALL SELECT 'map_regions',  COUNT(*) FROM map_regions
      UNION ALL SELECT 'item_types',   COUNT(*) FROM item_types;"

# Decode a session cookie's user id (paste cookie value as $SID)
docker compose exec postgres psql -U "$PG_USER" -d "$PG_DB" \
  -c "SELECT sess->>'userId' AS user_id, expire FROM sessions WHERE sid = '$SID';"

If solar_systems is empty the SDE import never ran — check the importer's logs (docker compose logs importer) and re-run it with docker compose run --rm -e FORCE_SDE_IMPORT=1 importer.


License

Nexum is free software licensed under the GNU Affero General Public License v3.0 or later (AGPL-3.0-or-later). You're free to use, study, modify, and redistribute it. The copyleft covers network use: if you run a modified version as a network service, you must also offer that version's source to its users (AGPL section 13), and any redistributed version must likewise be released under the AGPL. See LICENSE for the full text.

EVE Online IP notice

EVE Online and the EVE logo are the registered trademarks of CCP hf. All rights are reserved worldwide. All other trademarks are the property of their respective owners. EVE Online, the EVE logo, EVE and all associated logos and designs are the intellectual property of CCP hf. All artwork, screenshots, characters, vehicles, storylines, world facts or other recognizable features of the intellectual property relating to these trademarks are likewise the intellectual property of CCP hf.

CCP hf. has granted permission to Nexum to use EVE Online and all associated logos and designs for promotional and information purposes on its website but does not endorse, and is not in any way affiliated with, Nexum. CCP is in no way responsible for the content on or functioning of this software, nor can it be liable for any damage arising from the use of this software.

About

Open-source, self-hosted wormhole mapping tool for EVE Online — a Pathfinder/Wanderer/Tripwire alternative. Real-time chain mapping, signatures, rolling calculator, killboard, EVE SSO and corp tools. 9 Languages supported

Topics

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors