EveSynapse.app (EveSynapse) is a free, open-source, all-in-one companion dashboard and toolset for EVE Online. It consolidates fleet management, market analysis, ship fitting, industry planning, corporation tools, and live intel into a single, fast, integrated interface. It is a modern Go rewrite (by natemsz / Burzrujat) of the original 2013-era Python project with it's heritage originating from a hard fork of the original element-43.
  • Go 77.9%
  • JavaScript 9.1%
  • HTML 6.6%
  • CSS 6%
  • Shell 0.3%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Miko 9ffbc6c57d
All checks were successful
github/analysis Analysis gate on GitHub runners
github/core-only Core-only gate on GitHub runners
github/test Test gate on GitHub runners
github/race Race gate on GitHub runners
Bumps the version to 0.7.3.19
2026-11-03 06:54:07 +00:00
.forgejo/workflows Gives setup.sh --build and --build-dev modes, ships it on the releases of all three forges, and points the install docs at it 2026-11-03 04:57:42 +00:00
.github Gives setup.sh --build and --build-dev modes, ships it on the releases of all three forges, and points the install docs at it 2026-11-03 04:57:42 +00:00
android feat: the Android app runs full screen, with the system bars hidden 2026-10-10 00:57:30 -07:00
ci-assets Set dropdown menu items in Rajdhani Regular at 0.03em letter spacing 2026-10-29 03:44:57 -07:00
cmd Restores the contract search sweep note on sections without a subtab row 2026-10-30 18:23:23 -07:00
deploy Provisions the database a dev .env names and unexpires the postgres account 2026-11-03 06:46:09 +00:00
internal Bumps the version to 0.7.3.19 2026-11-03 06:54:07 +00:00
.env.example Gives setup.sh --build and --build-dev modes, ships it on the releases of all three forges, and points the install docs at it 2026-11-03 04:57:42 +00:00
.gitattributes ci: check pull requests, split test from release, add format, vet, race and analysis 2026-10-06 18:00:26 -07:00
.gitignore ci: check pull requests, split test from release, add format, vet, race and analysis 2026-10-06 18:00:26 -07:00
.gitlab-ci.yml Gives setup.sh --build and --build-dev modes, ships it on the releases of all three forges, and points the install docs at it 2026-11-03 04:57:42 +00:00
CHANGELOG.md Release v0.7.0.8 2026-10-30 04:11:47 -07:00
FRONTEND.md Move the release path from GitHub to GitLab 2026-10-29 17:58:10 -07:00
go.mod Merge pull request 'Update module github.com/fergusstrange/embedded-postgres to v2' (#70) from renovate/major-go-modules into main 2026-11-01 23:18:52 +00:00
go.sum Merge pull request 'Update module github.com/fergusstrange/embedded-postgres to v2' (#70) from renovate/major-go-modules into main 2026-11-01 23:18:52 +00:00
LICENSE Update LICENSE to remove old license information 2026-10-10 02:49:37 -07:00
Makefile Bakes the muted backdrop grade into muted twins of the wallpaper tiers 2026-10-28 03:32:22 -07:00
PATENTS Revise PATENTS file with new effective date and state 2026-10-10 02:52:01 -07:00
README.md Merge pull request 'Gives setup.sh --build and --build-dev modes, ships it on the releases of all three forges, and points the install docs at it' (#125) from feat/setup-sh-build-modes into main 2026-11-03 05:10:38 +00:00
renovate.json Migrate config renovate.json 2026-11-01 02:06:12 +00:00
SECURITY.md Gives setup.sh --build and --build-dev modes, ships it on the releases of all three forges, and points the install docs at it 2026-11-03 04:57:42 +00:00
sqlc.yaml refactor: the database and its schema get their own package (store) 2026-10-06 21:45:41 -07:00

EveSynapse (Go rewrite)

EveSynapse is a lightweight and fast, yet comprehensive and powerful all-in-one EVE Online companion (character sheets, market, fitting, intel, industry, etc) rebuilt in Go on CCP/Fenris Creation's ESI API with EVE SSO login.

The modernization is developed by natemsz (Nate / IGN: Burzrujat), rewriting the original Python-based closed-source app module by module in Go. Not all modules have been finished but development is very active. Built in the hopes it might be useful to the players of this game that holds a special place in my heart and other third party developers for their own projects.

EveSynapse is based on the 2013 project originally developed by natemsz, element, and j0ker (Rest in peace Matt. See you on the other side of the Eve Gate). Without them, the original project would not have been possible and this rewrite would not exist. While they have not contributed to the modernization, their work was critically important to both it's development and to my skills as a developer today.

Special thanks to Sahir and ado for helping me fix my own mistakes and answering questions, and to Marti for putting up with me and my constant requests to test out features.

This project is dedicated to EVE Online, the game and community that I have loved for over two decades. To its pilots and its developers, past, present, and future; and to all those who we have lost over the years o7.

While there is a hosted version (https://evesynapse.app) you are welcome to install this on your local machine or as a corporation/alliance service (see below on how to restrict sign-ups). If you have suggestions, concerns, questions or complaints, feel free to shoot us an email.

Contact: [email protected] (the team), [email protected] (Nate, or in-game 'Burzrujat'), [email protected] (Miko). Bugs, issues and security reports go to [email protected] (see SECURITY.md).

AI Use Disclosure and Policy

  • AI is used in this project purely for security auditing, hardening, and performance improvements, writing tests, certain UI elements such as icons and glyphs (I'm no artist), and to assist with simplifying repetitive tasks. AI is a force multiplier, never a primary un-reviewed author. If you wish to contribute code to the project you must:
  1. Understand the code you are submitting. Your name is on it, you are responsible for it.
  2. Reviewed it adequately with your own brain. Mistakes are okay, but give it your best effort.
  3. Never re-make what can be re-used. Familiarize yourself with the existing code-base first.
  4. Keep it consistent. Respect conventions. They are there for a reason. If you think something can be improved and wish to make a large change, please explain why. AI is terrible with this and code submitted this way without explanation will be assumed to be breaking rules 1-3.

This is a human-built project built for other humans, and a labor of love.

Stack

  • Go (module evesynapse; see go.mod)
  • chi (github.com/go-chi/chi/v5) — HTTP router
  • alexedwards/scs v2 — sessions, stored in PostgreSQL via pgxstore
  • PostgreSQL 16 — the app's database, reached through pgx/v5 (sqlc generates database/sql code over the pgx stdlib driver; a pgxpool backs the session store)
  • sqlc — type-safe Go generated from hand-written SQL
  • golang.org/x/oauth2 — EVE SSO authorization-code flow
  • github.com/golang-jwt/jwt/v5 — SSO access-token verification against CCP's JWKS (issuer login.eveonline.com)
  • html/template + embed.FS — server-rendered pages

Layout

  • cmd/evesynapse/ — release entrypoint: thin wiring (config → app → HTTP server). It does not import the devtools package, so the release binary contains no dev-login code; DEV_LOGIN=1 has no effect on it.

  • cmd/evesynapse-dev/ — dev entrypoint: identical wiring plus the dev-only routes (/dev-login). Local testing only — never deploy this binary.

  • cmd/releasesign/ — the maintainer's tool for release signing: makes the release key, signs the update manifests in the release job, checks a signature by hand. Never deployed either

  • cmd/residue-corpus/ — builds labeled training examples for the intel residue reader from production exports (the residue calls and the reports they fed), with labels taken only from verified outcomes. A maintainer tool; never deployed

  • cmd/mkicons/ — draws the raster icons from the logo's SVG files in internal/app/static/: logo.svg (the mark, shown in the page header), icon.svg (the app icon) and favicon.svg (the simplified small one). To change the logo, replace those and run go run ./cmd/mkicons: it rewrites favicon.ico, the four PNG app icons and the notification badge beside them

  • internal/app/ — the application: config + .env loader (core_config.go), EVE SSO auth/sessions/JWT verification (auth.go), account linking and the sign-up policy (auth_links.go, auth_signup.go), token refresh and encryption at rest (auth_refresh.go, auth_tokencrypt.go), cookie, header and cross-site protections (core_httpsec.go), the application struct and router (core_app.go), page handlers and view models (core_pages.go, char_assets.go, char_skills.go, corp_overview.go, market.go, sync.go, char_pages.go, char_fittings.go, char_killmails.go, intel.go), the background worker (worker.go runs the cycle; each *_worker.go file is the fetching behind one group of pages, and worker_name_harvest.go collects the names a cycle has to resolve), what pages and the worker both read (core_snapshots.go, market_book.go), the SDE static-data importer (sde.go), static assets and their caching (core_static.go), the health check (core_health.go), and the two maintenance modes that need the application (core_maintenance.go: -version and -refresh) It is one Go package, and a file's name starts with the part of the app it belongs to, so a directory listing groups them. A test file sits beside the code it tests with the same prefix.

    Prefix What is in it
    core_ the application itself: start-up, config, routing, page rendering, security headers, static files, health, maintenance
    auth EVE sign-in, sign-up policy, linking characters, token refresh and encryption
    ui_ navigation state and the tests that pin layout and styling
    module_ which modules an account has, by the scopes it granted
    home_ the home page: widgets, briefing, net worth
    char_ pages about one character: sheet, skills, assets, fittings, killmails, industry
    economy_ wallet, orders, contracts
    planets_ planetary industry
    comms_ mail, calendar, contacts
    profit_ the profit pages: per-item trading and per-blueprint industry profit and loss, and their charts
    map_ the star map: systems, routes, character and intel layers
    corp_ corporation pages and special-interest groups
    ops_ the op calendar and attendance
    market_ market pages, scanners, price guide, restock
    items_ the item database
    intel_ wars, incursions, pilots, corporations, alliances, places
    structures_ structures
    search_ search
    sde the static-data importer
    sync the Sync page
    notify_ notifications and browser push
    discord_ the Discord bot
    worker_ the background worker: the cycle, tiers, lanes, timing, name warm-up

    A *_worker.go file under another prefix is the fetching behind that group of pages.

  • internal/app/templates/ — embedded html/templates (base.html layout)

  • internal/app/static/ — embedded assets: the 2013 wallpaper (bg.jpg) and the dependency-free stylesheet (style.css), served at /static/

  • internal/store/ — the database: opens Postgres and brings the schema up to date at startup (store.go, schema.go). The schema itself is the numbered steps in internal/store/schema_pg/, which are also sqlc's input

  • internal/fit/ — the fitting simulator's stat engine: the dogma arithmetic that turns a ship, its modules, skills and charges into the fit's statistics. Pure over the static data it loads; the fitting pages in internal/app are its only caller

  • internal/skillplan/ — skill-plan arithmetic: SP per level, training speed, ordering targets with their prerequisites, the remap advisor. Pure; the skill pages supply the skill graph

  • cmd/modbuild/, internal/app/modulecatalog/ — the module build tool and the list of every module it chooses from

  • internal/app/swarm/ — the Swarm Cache module: the signed, spot-checked sharing of public data between instances (see "Swarm Cache" below)

  • internal/app/scanner/, tradefinder/, leaderboard/, restock/, appraise/ — the market page modules, each a page over stored rows read through module.Deps

  • internal/app/intel/ — the Intel module: the killboard, the d-scan and intel parsers' pages, intel channels, the synthesized feed and workbench, pilot dossiers, and the public wars, incursions and faction-warfare pages. The parsers and synthesis live in core and the intelanalyze/intelchat packages; the module is their pages

  • internal/app/tradeideas/ — the Trade Ideas module: the page and the stored model answers the worker refreshes

  • internal/app/piplanner/ — the PI Planner module: colony chain layouts over the SDE, beside the core colonies pages

  • internal/htmlsanitize/ — makes text written by other players safe to show inside a page (mail, pilot bios, descriptions)

  • internal/app/wallet/, mail/, calendar/, contacts/, orders/ (module id market), contracts/ — the pilot economy and comms pages

  • internal/balancechart/ — the balance-over-time chart builder shared by the wallet pages, the home net-worth card and the corporation wallets

  • internal/app/corpdata/, srp/, doctrines/, groups/ — the corporation modules: member, wallet, order, asset and structure pages; ship replacement; doctrines and corporate loadouts; groups

  • internal/fitview/ — the fit foundation: the fit document, EFT, slot families and the simulation view, read by core and the fitting modules

  • internal/app/fitting/ — the Fitting module: workbench, saved loadouts, import/export, save to the game, shopping list

  • internal/app/publicfits/ — the Public Fits module: browser, fit pages, ratings and the jobs that keep them stored-only; the flag and storage stay core

  • internal/app/skillplans/ — the Skill Plans module: plan pages, plan-from-fit and plan editing over module.Deps, built on internal/skillplan

  • internal/app/buildplanner/ — the Build Planner module: its page, JSON payload and recipe source over the SDE, built on module.Deps

  • internal/humanize/, internal/tags/ — small pure wording and tag helpers shared by core and modules

  • internal/buildplan/ — the industry build planner's engine: the tree of everything a product needs, the manufacturing formulae, what is already held, and the price of the rest. Pure

  • internal/markethistory/ — figures and the SVG chart computed from a type's stored daily price history. Pure

  • internal/selfupdate/ — evesynapse -update: the release channel, checking a release's signature, downloading, verifying and swapping in a new build, and handing over to the running server. It is told the running version and knows nothing else about the application

  • internal/releasesig/ — the signature on a release: how an update manifest is signed and how the updater checks one, plus trusted_keys.pem, the public keys compiled into every build ("Release signing" below)

  • internal/pidfile/ — the server's pidfile: written at start-up, read by -update to restart the server and by -refresh to refuse while it is running (pidfile/pidfiletest is test support)

  • internal/dotenv/ — the .env loader the server and the updater both use

  • internal/pgtest/ — test-only embedded-Postgres provisioning (a fresh database per test; go test ./... needs no external database). A package whose tests use it needs a TestMain that calls pgtest.TestMain, which stops the server when the tests finish; without one pgtest refuses to start a server

  • internal/esi/ — the ESI client: HTTP layer, per-character snapshot cache, and the two-tier type/group/place name resolution (network tier + cache-only render tier). Never imports internal/app; access tokens are injected via a callback.

  • internal/devtools/ — dev-only routes (/dev-login), imported solely by cmd/evesynapse-dev

  • internal/db/query/ — hand-written queries (sqlc input)

  • internal/db/sqlc/ — sqlc-generated code (do not edit)

Run

cp .env.example .env   # fill in EVE_CLIENT_ID / EVE_CLIENT_SECRET
make run               # or: go build -o bin/evesynapse ./cmd/evesynapse && ./bin/evesynapse

The release build needs a PostgreSQL 16 database to point DATABASE_URL at; for a fresh local database the setup script below provisions one, or create role+database yourself and put the URL in .env. A fresh database gets the full schema from the embedded baseline on first boot.

make run runs the release build (cmd/evesynapse). If you fork this project, that's the build you get by default — it has no dev-login route at all. make build produces bin/evesynapse, make build-arm64 cross-compiles bin/evesynapse-arm64. For local development there is also a dev build (make build-dev, binary bin/evesynapse-dev, entrypoint cmd/evesynapse-dev) that additionally registers /dev-login when started with DEV_LOGIN=1; see "Dev login" below. Never deploy the dev build.

For local testing there is a simpler option that does all of the above in one command: from a checkout, sudo bash deploy/setup.sh --build-dev installs Go and PostgreSQL when they are missing, provisions the database, writes a dev .env (with DEV_LOGIN=1), builds the dev server and runs it. Local testing only.

The app auto-loads ./.env at startup (keys already set in the real environment win). Then open http://localhost:8080:

  • / — home; signed out it shows the public landing page (what the app does, sign-up, a demo with sample characters to look around with, and the public tools), signed in it shows your home: a customizable overview of widgets over every linked character (the briefing, your pilots at a glance, net worth, market, industry and more), in the layout the account chooses for itself

  • /map/ — the map of New Eden: systems and their connections, jump routes, your characters' locations, and the intel your channels carry laid over it (requires login)

  • /character/ — character live state for one character: online status and login history, location, active ship, jump fatigue, active implants, home/jump clones with their implants (requires login; esi-location.*, esi-clones.*, esi-characters.read_fatigue.v1)

  • /characters/ — every character linked to the account in one list, with the way into each one's pages (requires login)

  • /fittings/ — the Ship Workbench: the fitting simulator. Build a fit on any hull and the dogma engine works out its statistics (requires login)

  • /fittings/saved/ — My Loadouts: your saved fittings, from the game and from the workbench (requires login; esi-fittings.read_fittings.v1)

  • /fittings/public/ — the Public Loadouts Browser: fits other players have published, with ratings

  • /killmails/ — the 50 most recent kills/losses with KILL/LOSS badges, final-blow attacker, involved count and an estimated destroyed+dropped value (requires login; esi-killmails.read_killmails.v1; details warmed by the worker)

  • /mail/ — EVE mail: read and search your characters' mail and compose from the page (sending takes esi-mail.send_mail.v1; requires login)

  • /calendar/ — the calendar: upcoming events for your characters and corporations, and the op calendar (requires login)

  • /contacts/ — contacts and their standings across your characters (requires login)

  • /corporations/ — public corporation overviews for the user's characters' corporations (name, CEO, alliance, tax, home station; public ESI, cached per CCP's Expires header)

  • /corporations/members/ — member roster with, when member tracking is available, join date, last login, current ship and location (roster: no role; tracking requires the Director role in-game)

  • /corporations/wallets/ — the seven wallet divisions with balances, plus the selected division's recent journal and transactions (requires the Accountant or Junior Accountant role in-game)

  • /corporations/orders/ — open corporation orders with item, location and region names, price, volume remaining and expiry (requires the Accountant or Trader role in-game)

  • /corporations/assets/ — corporation assets grouped by location, with player-given singleton names (named ships, renamed containers) resolved by the worker via the assets/names endpoint (requires the Director role in-game)

  • /corporations/structures/ — Upwell structures: type, system, state, fuel expiry, reinforce timers and service states (requires the Station Manager role in-game)

  • /corporations/killmails/ — the corporation's recent kills and losses, sharing the killmail store and page rendering with /killmails/ (a victim in the corp is a LOSS; requires the Director role in-game)

    All corporation subpages follow the selected character's corporation (switch characters with ?character=, like Assets), render from worker-warmed snapshots only, and show a plain "needs the role" state when ESI refused the dataset for want of an in-game role — see the caching section below.

  • /doctrines/ — the corporation's doctrines: the fleet compositions it flies (requires a character in the corporation)

  • /doctrines/fits/ — Corporate Loadouts: the fittings behind the doctrines (requires login)

  • /srp/ — Ship Replacement (SRP): replacement requests and their handling (requires login)

  • /groups/ — Groups: the lists of characters a corporation's or alliance's directors keep by hand, used by the Discord role rules and more (see "Discord"; requires login)

  • /corporations/settings/ — the corporation's own settings: its Discord servers and related housekeeping (requires login)

  • /auth/eve — starts EVE SSO login (also "Link another character")

  • /auth/callback — OAuth2 callback (see SSO flow below)

  • /auth/logout — destroys the session (POST; the sidebar's Sign out button)

  • /admin/ — the admin page: an analytics tab of read-only site statistics and charts, an accounts tab (accounts and the characters linked to them: totals, the newest accounts, and one account looked up by any of its characters, a search box that suggests as you type, with each character's sign-in state and what it granted), and a notices tab. Nothing about data freshness: that is the Sync tab (requires an admin account; see EVE_ADMIN_CHARACTER_IDS)

  • /assets/ — asset browser: every stack grouped by location for the signed-in user's characters (requires login; esi-assets.read_assets.v1)

  • /wallet/ — the character wallet: balance, journal and transactions, with the balance over time as a chart (requires login; esi-wallet.read_character_wallet.v1)

  • /orders/ — your characters' market orders, open and finished (requires login)

  • /contracts/ — your characters' contracts and what is inside them (requires login)

  • /contracts/search/ — search public contracts across New Eden (requires login)

  • /profit/ — profit: per-item trading profit and loss on a pooled FIFO cost basis (sales tax paired, broker fees tied, and typed gaps where a cost is not known), per-blueprint industry profit three ways (realized, at the prices of their time, and rerun at today's prices), LP earnings, and the chart set: running total by character, fees with their share of sales, and cost of goods sold (requires login)

  • /market/ — market browser: item search (local name cache + exact ESI resolution), guide prices, and best/top orders for The Forge, Domain, Sinq Laison, Heimatar and Metropolis (requires login; all data is public ESI, order books capped at 20 pages)

  • /market/appraise/ — appraisal: paste a plain item list, an asset or cargo copy, or an EFT fit (the ship, its fitted modules and charges, and the drones and cargo the fit lists), and every line is priced from EveSynapse's own stored prices with its basis and age shown. Blueprint copies written as Name (BPC, ME n, TE n, n runs) price from the stored median for that copy, never the original, and a copy with too few listings on record is left unpriced. Mutated (abyssal) items appraise too: a line carrying the item's in-game link prices from that item's stored rolls estimate, and a line without the link, or with no estimate on record, prices its base item and says so (no login required; signed-in appraisals are saved and shareable by link)

  • /market/scanner/ — the Spread Scanner: buy and sell spreads from EveSynapse's own swept order books, ranked and stored at sweep time (requires login)

  • /market/trends/ — Market Trends: price and volume movement from the stored daily history (requires login)

  • /market/lp/ — the LP Store browser: every loyalty point store's offers, what each costs in LP, ISK and required items, and what the reward is worth (requires login)

  • /market/baskets/ — Market Baskets: saved groups of items, watched and priced together (requires login)

  • /market/ideas/ — Trade Ideas: trade opportunities written by a model from the spread scanner's rows (needs a model configured: MODEL_API_KEY or the Cloudflare settings under Configuration; otherwise the page shows its empty state)

  • /market/tradefinder/ — the Tradefinder: routes between stations with the profit worked out from stored order books (requires login)

  • /market/leaderboard/ — Top Stations: stations ranked by open orders and the ISK on their books (requires login)

  • /market/restock/ — the Restock Planner: what a market or a list needs bought to be whole again, and what it costs (requires login)

  • /items/ — the Item Browser: the item database, every type with its group, attributes and market summary

  • /skills/ — full skill sheet: total/unallocated SP, level-V count, the complete training queue, and every known skill grouped by category with per-group SP subtotals (requires login; esi-skills.read_skills.v1 + esi-skills.read_skillqueue.v1)

  • /skills/plans — Skill Plans: build a training plan (or take one from a fit) with prerequisites ordered for you and the training time worked out (requires login)

  • /industry/ — Blueprints: your blueprint library, originals and copies with their research (requires login)

  • /industry/jobs/ — My Jobs: your industry jobs, running and delivered (requires login)

  • /industry/mining/ — the Mining Ledger: what your characters mined, by day and by item (requires login)

  • /planner/ — the Build Planner: pick a product and see the tree of everything it needs, what you already hold, and the price of the rest (requires login)

  • /planets/ — My Colonies: your planetary colonies and what they extract and produce (requires login)

  • /industry/pi/ — the PI Planner: lay out a colony chain on paper before committing to it in the game (requires login)

  • /admin/sync/ — the Admin page's Sync tab: the worker and the data: the last cycle in figures (time, fetches against the allowance, characters by tier, ESI's rate-limit budgets), what was warmed ahead and how pages are served, the shared cache's check outcomes, the shared public data, the SDE static-data block (row counts, import state, update check), and one character's snapshot freshness and type-name coverage with a re-warm button. The character is the reader's own until another is looked up by name or id; no page prints every character. It auto-refreshes so an import can be watched as it lands (requires an admin account); /sync/ redirects here

  • /intel/wars/ — current wars from the public war list and worker-warmed war details: aggressor/defender names, state, kill records, open-for-allies/mutual badges, and a flag on wars involving one of your characters' corporations (requires login; all data is public ESI)

  • /intel/incursions/ — active Sansha incursions: constellation, staging system, state, influence bar, boss presence and the affected systems (requires login; public ESI)

  • /intel/fw/ — faction warfare: per-faction summary cards (systems held, pilots, kills and victory points) and the most contested front-line systems with occupier/owner factions (requires login; public ESI)

  • /intel/kills/ — the Killboard: kills and losses across New Eden from the stored killmail data (requires login)

  • /intel/scan/ — the D-Scan Parser: paste a directional scan (or local, probe or overview text) and get the interpreted reading: what is there and on grid, whose ships they likely are, and the composition at a glance (requires login)

  • /intel/channels/ — Intel Channels: the channels you run or belong to. Reports posted to a channel are parsed and synthesized as they land (requires login)

  • /intel/feed/ — the Intel Feed: the synthesized reports from every channel you can read, newest first, each element linking deeper (to the pilot, the system on the map, related scans) (requires login)

  • /intel/workbench/ — the Intel Workbench: one pilot or gang under the microscope: the synthesized headline, their activity, sightings and threads (requires login)

  • /license/ — the project's license and legal notices

  • /healthz — ok (200) when the database answers and the background worker is running; 503 with one line per problem otherwise

  • /dev-login — dev-only fake sign-in, registered only when DEV_LOGIN=1 (see below)

Configuration

Everything comes from the environment (after ./.env fills any gaps; real environment variables win over the file):

Variable Required Default Purpose
EVE_CLIENT_ID yes — EVE SSO application client ID
EVE_CLIENT_SECRET yes — EVE SSO application client secret
EVE_CALLBACK_URL yes http://localhost:8080/auth/callback OAuth2 redirect URI; must match the callback registered at developers.eveonline.com character-for-character
EVE_SSO_REVOKE_URL no CCP's endpoint Token revocation endpoint (RFC 7009); only changed to point the unlink flow's "revoke at EVE" call somewhere else, for example in tests
DATABASE_URL yes postgres://evesynapse@localhost:5432/evesynapse?sslmode=disable PostgreSQL connection URL
ADDR no :8080 HTTP listen address
TOKEN_ENCRYPTION_KEY no — Encrypts the EVE tokens stored in the database (see "Token encryption")
TOKEN_KEY_PROVIDER no derived Where the token key comes from: env (the key above; the default when it is set), http (fetched from a key service at startup), oci (unwrapped by OCI KMS at startup), or none (tokens stored unencrypted; the default when no key is set). See "Keeping the key off the machine"
TOKEN_KEY_WRAPPED, TOKEN_KEY_UNWRAP_URL, TOKEN_KEY_UNWRAP_CREDENTIAL with http — The wrapped token key, the key service's unwrap endpoint, and the credential the app presents to it (http provider)
TOKEN_KEY_OCI_KEY_ID, TOKEN_KEY_OCI_CRYPTO_ENDPOINT with oci — The OCI Vault key that wrapped the token key (in TOKEN_KEY_WRAPPED) and the vault's cryptographic endpoint (oci provider)
EVE_CLIENT_SECRET_OCI_SECRET_ID, DISCORD_CLIENT_SECRET_OCI_SECRET_ID, DISCORD_BOT_TOKEN_OCI_SECRET_ID, MODEL_API_KEY_OCI_SECRET_ID with oci — The OCI Secrets OCID each working credential is read from at startup instead of sitting in .env (oci provider). The plain variable can stay until the fetched value is seen to land, then come out
EVE_CLIENT_SECRET_WRAPPED, DISCORD_CLIENT_SECRET_WRAPPED, DISCORD_BOT_TOKEN_WRAPPED, MODEL_API_KEY_WRAPPED with http — Each working credential wrapped by the key service, unwrapped at startup through the same endpoint as the token key (http provider)
EVE_ADMIN_CHARACTER_IDS no — Comma-separated EVE character IDs whose accounts may open the Admin and Sync pages. Any character linked to an account makes that whole account an admin's. Empty = nobody. The status belongs to whoever owned the character when it was first seen as an admin: if the character is sold or transferred, its new owner does not get it. To give it to a new owner on purpose, remove the id, restart, add it back and restart
EVE_ALLOWED_CHARACTER_IDS, EVE_ALLOWED_CORPORATION_IDS, EVE_ALLOWED_ALLIANCE_IDS no — Limit who may create an account (see "Who can sign up"). All empty = anyone who can sign in with EVE
EVESYNAPSE_UPDATE_PROVIDER no github, gitlab, forgejo, mirror The sources the updater reads releases from: one or more of github, gitlab, forgejo, mirror (the project's static mirror), comma-separated. The updater checks them in the order listed and prefers no source beyond that order: the newest signed release wins, and a tie goes to the earlier-listed source. Unset, it walks the default shown here: the three forges, then the mirror
EVESYNAPSE_UPDATE_PROJECT no natemsz/evesynapse GitLab project (group/project) the updater checks; EVESYNAPSE_UPDATE_REPO, set by older installs, is read as the same project's path
EVESYNAPSE_UPDATE_API_URL no the provider's public API The API address of the chosen provider the updater lists releases from; set it only for a self-hosted GitLab or a GitHub Enterprise host
EVESYNAPSE_UPDATE_MIRROR_URL no https://cdn.synap6.io The mirror provider's root
EVE_SDE_BASE_URL no Fuzzwork's dump Base URL of the SDE CSV dump the importer downloads
ESI_CONTACT no — How CCP can reach whoever runs this instance (an email address, a Discord handle, a character name). Sent in the User-Agent of every ESI request, as CCP asks of third-party apps
STATUS_PAGE_ORIGIN no — The status page's origin (https://status.example.com): the one site whose pages may read /status.json, the public summary of how the database, worker, notifications, ESI, market sweeps and push are doing (a state and an age each, nothing more)
LOG_LEVEL no info Least severe kind of log line written: debug, info, warn or error (see "Logging")
LOG_FORMAT no text text for the classic line, json for one object per line
VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY no — The key pair that turns on browser push notifications (see "Notifications and browser push"). Make one with evesynapse -push-keys. Unset, notifications show in the top bar only
VAPID_SUBJECT no the site's address A contact address (mailto: or https:) the browsers' push services may use to reach the operator
ANDROID_APP_PACKAGE, ANDROID_APP_FINGERPRINTS no — Name the site's Android app (see "The Android app"): its package, and the SHA-256 fingerprints of the certificates it is signed with, comma separated. Unset, the site names no app
OPS_MANAGER_ROLES no Director The in-game corporation roles whose holders may create, change and cancel ops on the calendar, comma separated and spelled as ESI spells them (Director,Personnel_Manager)
NOTIFY_POLL_SECONDS no 30 How often, in seconds, an open page checks whether its notifications icon has changed, so new notifications show without a reload. 0 turns the checks off; other values are kept between 5 and 3600. Takes effect on restart
WORKER_TIERS no on How often a character's data is refreshed follows its account: as often as ESI allows for the character someone has open, every 5 minutes (position every 2) for that account's other characters, every 5 to 15 minutes for an account seen in the last day, every 30 minutes (position every 6 hours) for one not seen for a day, and every 2 hours for one not seen for a week that has no browser subscribed to push and no Discord account linked. off refreshes everything as often as ESI allows for everyone, which uses up the worker's allowance at around fifteen characters
WORKER_FETCHES_PER_CYCLE no 120 per lane (480) How many ESI fetches the worker's character pass may make in one one-minute cycle, for the whole server (kept between 20 and 2000). Raise it when the Sync page shows the allowance used up and characters put off. One lane gets through about 200 fetches in a minute, so more than about 200 per lane makes a cycle run past its minute
FORESIGHT no on Warming on game events: when a character docks at a trade hub, an industry job is about to finish, an order is undercut, a skill queue runs out or a war is declared on a corporation, the data the account is likely to open next is fetched ahead. off switches it off. The Sync page's "Warmed ahead" table shows what it cost and how often it was right
FORESIGHT_FETCHES_PER_CYCLE no 30 The most ESI calls a cycle may spend on that (0 to 200), out of what the rest of the cycle leaves of its allowance
SHARED_CACHE no off Sharing public data with other instances (see "Swarm Cache"): open (or on) shares with every instance holding the secret, allowlist also waits for each instance to be approved on the Admin page. Anything else stays off
SHARED_CACHE_SECRET no — The swarm's membership proof: at least 32 random characters (openssl rand -hex 32). Enabling sharing without one refuses to start. Never shown on any page
SHARED_CACHE_SEEDS no — Bootstrap and fallback directory nodes (comma-separated https URLs; http only on loopback): the instances a new or lonely one asks for the membership list. Afterwards membership spreads peer to peer
SHARED_CACHE_GOSSIP_HOURS no 18 How often, in hours, membership is re-gossiped (12 to 168). Takes effect on restart
SHARED_CACHE_WARM no — Run as a dedicated warming node for these shared kinds (all, or a comma list of corporation_record, alliance_record, type_name, market_history, structure_name): the instance fetches the swarm's misses of them on its own ESI budget, and other instances spend theirs on those kinds only as the last resort. Takes effect on restart
WORKER_LANES no 4 How many characters the worker refreshes at the same time (1 to 16). Each character is still fetched one request after another, so ESI's per-character rate limits are unaffected; 1 is the worker as it was, one character at a time. More lanes mean more database connections in use at once: with more than 8, raise DB_MAX_CONNS to about two and a half per lane (40 for 16 lanes), which still fits PostgreSQL's default of 100. The Sync page's "Fetches a cycle" beside "Characters on average" shows what the characters actually cost, which is what to size this and the allowance by
DB_MAX_CONNS no 20 How many database connections the app may hold open (5 to 500). The session store keeps a few more of its own (at most 4, or one per CPU core if that is more). Raise it together with PostgreSQL's max_connections (100 unless changed), which must stay above the two added together; more than a few dozen is rarely useful on one machine
DISCORD_CLIENT_ID, DISCORD_CLIENT_SECRET no — A Discord application's id and secret: turns on "Connect Discord" (see "Discord")
DISCORD_BOT_TOKEN no — That application's bot: lets it be added to Discord servers, where it gives roles and sends messages. What it does in each server is set on the site by that server's directors, not here
MODEL_API_KEY no — Turns on the Trade ideas page (Market > Trade ideas) and its optional home module with the hosted model (Meta's Muse Wonder): the worker asks it for trade opportunities in The Forge from the spread scanner's rows, at most twice a day. Keep the key secret
CLOUDFLARE_ACCOUNT_ID, CLOUDFLARE_AUTH_TOKEN no — Cloudflare Workers AI credentials: the Trade ideas fallback when no MODEL_API_KEY is set, asking the Gemma model instead, on the same twice-a-day cadence. With neither configured, the page shows its empty state. Keep the token secret
INTEL_TAGGER no on The intel classifier: a small model built into the server that helps the intel reader tell where one pilot's name ends and the next begins. It runs on this machine and sends nothing anywhere. off reads intel without it
INTEL_TAGGER_WEIGHTS no the built-in model A model file to use for the intel classifier instead of the built-in one
DEV_LOGIN no — Dev build only: 1 registers the /dev-login route

Install

You need a Linux machine (ARM64 or x86-64) and an EVE app registration for sign-in ("EVE SSO flow" below). Then:

  1. Get the program. Easiest is the prebuilt binary from the project's releases, published on Forgejo, GitHub and GitLab: evesynapse-arm64 for ARM machines (most ARM cloud instances), evesynapse-amd64 for Intel/AMD ones. Or build it yourself ("Build from source" below).

  2. Run the setup script from a checkout of this repo:

    sudo bash setup.sh
    

    Run on its own like this, it downloads the latest build for your machine, checks the release's signature and verifies the build against the checksum in its signed manifest ("Release signing" below; on a machine whose OpenSSL is older than 3.0 it can check only the checksum, and says so), creates the evesynapse user and /opt/evesynapse, installs and starts PostgreSQL if it's missing and creates the app's database (role evesynapse, database evesynapse, with a generated password written into /opt/evesynapse/.env as DATABASE_URL), installs the systemd service (ordered after postgresql.service), and links evesynapse into /usr/bin so you can run it without typing the full path. It never overwrites an existing .env (it only appends a DATABASE_URL that isn't there yet).

    It also sets PostgreSQL's memory for the machine it is on. PostgreSQL ships with 128 MB of cache whatever the machine has, so on anything with 2 GB of RAM or more the script raises three settings, sized from the RAM it finds:

    Setting Set to Example: 4 GB 8 GB 24 GB
    shared_buffers an eighth of the RAM, at most 2 GB 475 MB 975 MB 2 GB
    effective_cache_size two thirds of the RAM (a planning hint; it reserves nothing) 2.5 GB 5.2 GB 15.9 GB
    shared_preload_libraries pg_prewarm, which reloads the cache after a restart

    A setting is changed only while it is still as PostgreSQL shipped it and nobody has set it with ALTER SYSTEM, so a value you chose is never replaced and running the script again changes nothing. A machine with under 2 GB is left alone, and so is a database on another machine. On a first install PostgreSQL is restarted to apply them; if it does not come back, the script takes them out again and starts it as it was. With EveSynapse already running the script does not restart anything: it prints the command, because the restart drops the site's connections for a few seconds. EVESYNAPSE_TUNE_POSTGRES=0 skips the whole step:

    sudo EVESYNAPSE_TUNE_POSTGRES=0 bash deploy/setup.sh
    

    The program, /opt/evesynapse and .env belong to root; the service account can read them but not change them, and the unit runs it sandboxed with /run/evesynapse as its only writable directory. Updates run as root, so the account the server runs as must not be able to replace what root runs. If you installed before this layout, run the script once more to move to it. Installing from a fork or from a build you made yourself works too:

    Why /usr/bin? Updating always runs under sudo, and sudo searches its own locked-down PATH — which does not include /usr/local/bin on RHEL-family systems. A link in /usr/local/bin works in your shell but is invisible to sudo evesynapse …, so the setup script puts the link in /usr/bin (and retires an older /usr/local/bin link if it finds one).

    sudo EVESYNAPSE_UPDATE_REPO=you/evesynapse bash deploy/setup.sh
    sudo EVESYNAPSE_BINARY=/path/to/evesynapse bash deploy/setup.sh
    

    Run the fork form from a checkout of the fork: the script checks the release against the release key in the checkout it is run from, and a fork's releases are signed with the fork's own key.

    Production from source: in a checkout, sudo bash deploy/setup.sh --build installs the build tools it needs, provisions the same way, but builds the program from the checkout instead of downloading a release, and installs that build.

    Local testing only: in a checkout, sudo bash deploy/setup.sh --build-dev provisions a local database and a dev .env, builds the dev server and runs it (see "Run" above). Never deploy the dev build.

  3. Fill in /opt/evesynapse/.env with your EVE app's client ID, secret, and callback URL, then start it:

    sudo systemctl start evesynapse
    

    Open the address you configured and sign in with EVE.

Updating afterwards is one command — see "Updating" below.

Signing in or linking another character opens a short chooser (/auth/link) instead of asking EVE for every permission at once:

  1. What do you want to do? Activity cards (track my pilots, trade the market, build and produce, run my corporation, fit and plan ships, a bit of everything). Each module and core feature declares which kinds of play it serves, and the registry's own requires closure turns the answers into the features to switch on.
  2. The reveal. Before anything is sent to EVE, the page lists the features that will be turned on, grouped by kind of play, each with the permissions it uses in plain words (the optional ones included, all ticked like the rest), and how many permissions EVE will ask for (it recounts as you tick). Features can be unticked (one another feature needs stays, and says why). Corporation permissions are opt-ins framed by the in-game role EVE needs for them. The only actions are Complete sign up and Go back.

A scope is declared required when a user who chose that feature would be surprised to find the capability missing (it is what the feature is); it stays an enhancement only when it is specialist data a user goes looking for (audit logs, flavor records, niche statistics).

Playstyle answers grant the permissions their features cannot work without and the optional ones that light them up, all shown ticked on the reveal; unticking any of them before completing is the player's choice. The server works the scope list out from the declarations (the page sends feature ids, never scopes), and a link only ever asks for a subset of eveScopes, the list the EVE developer portal allows. The account remembers its last choice, so the next character opens on the reveal, preselected. Linking more onto a character asks for what it already granted too, so a grant never narrows. /auth/eve with no choice is the full link, as before. ESI cannot narrow a granted token, so there is no removal flow; a narrower grant is a fresh link at the narrower set.

Who can sign up

Out of the box, anyone who can reach the site and sign in with EVE gets an account, and the worker then keeps every character they link in sync. To limit an instance to the people it is meant for, list them in .env (comma-separated EVE IDs; any mix):

EVE_ALLOWED_CHARACTER_IDS=90000001,90000002
EVE_ALLOWED_CORPORATION_IDS=98000001
EVE_ALLOWED_ALLIANCE_IDS=99000001

A new account then needs a character that is listed itself or whose corporation or alliance is (checked against EVE's public API at sign-in). Characters in EVE_ADMIN_CHARACTER_IDS are always allowed. Everyone else is turned away at sign-in with a message, and no account is created.

The lists decide who may join:

  • someone who already has an account can still link more characters to it, including alts outside the lists;
  • accounts created before the lists were set keep working.

A list that is set but cannot be read (a typo, a stray separator) stops the app at startup rather than silently allowing everyone.

Notifications and browser push

EveSynapse tells you when something happens to your characters: a skill finishes, new mail, a new in-game calendar event, a new op planned for one of your corporations, a reminder before an op you signed up to starts (10 minutes to 2 hours ahead, your choice), a new killmail, planetary extractors stopping, an industry job finishing, and watch list alerts. They show under the bell in the top bar with no setup at all. Under bell → Settings (/notifications/settings) each kind can be switched off altogether, or for some of your characters: the list beside a kind has a tick box per character and can be searched by name. A character linked later starts with everything on.

Browser push delivers the same notifications while no EveSynapse tab is open, including to a phone with the site installed to its home screen. It is off until the server has a key pair, and each browser has to be switched on by hand. Nothing asks for permission on its own: not opening the site, and not installing it as an app.

Install it as an app

The site can be installed to a phone's home screen or a computer's desktop, where it opens in its own window and gets its own notification icon.

  • Chrome and Edge (Android, Windows, macOS, Linux): an Install app link appears at the foot of the menu, beside Theme, once the browser says the site can be installed. It opens the browser's own install dialog. The link is not there if the app is already installed.
  • Android: install from Chrome. An app installed from Samsung Internet has been seen never to ask for notification permission, so push cannot be turned on in it.
  • iPhone and iPad: Safari gives a page no way to offer this. Use Share → Add to Home Screen; push only works from the installed app there.
  • Firefox: Installs on desktop with working notifications; on Android it is in the browser's menu. Notifications are not properly paired with the apps icon. Uses default Firefox icon.

The Android app

android/ is an Android app that opens the site full screen: a Trusted Web Activity. It has no code of its own, and it runs in the phone's browser (Chrome, or another that supports it), so sign-ins, push and everything else behave as they do there. It exists for people who would rather install an app than "Add to Home screen", and so the site can be put on Google Play.

The pipeline's android job builds it. Run it by hand from the project's pipelines page and download evesynapse-android from the job's artifacts:

  • With no signing key set up it makes a debug build, signed with a throwaway key. That installs and works, but shows the browser's address bar, because the site cannot vouch for a key that changes.
  • With a signing key it makes a release .apk (to install directly) and .aab (for Google Play).

To set up the signing key, once, in Git Bash or any shell with OpenSSL (pick your own password; it is typed twice below). The base64 line's output goes into the project's CI secrets as ANDROID_KEYSTORE_B64, and the password as ANDROID_KEYSTORE_PASSWORD:

MSYS_NO_PATHCONV=1 openssl req -x509 -newkey rsa:2048 -sha256 -days 10000 -nodes -subj "/CN=EveSynapse" -keyout android-key.pem -out android-cert.pem
openssl pkcs12 -export -name evesynapse -inkey android-key.pem -in android-cert.pem -out android.p12
base64 -w0 android.p12
openssl x509 -in android-cert.pem -noout -fingerprint -sha256

Keep android.p12 and its password somewhere safe and out of the repository: an app can only be updated by a build signed with the same key. The last command prints the fingerprint. Put it, and the package name, in the site's .env and restart:

ANDROID_APP_PACKAGE=app.evesynapse.twa
ANDROID_APP_FINGERPRINTS=AB:CD:…

https://<your site>/.well-known/assetlinks.json then names the app, and the app opens without the address bar. If Google Play signs the app for you (Play App Signing), add the fingerprint Play Console shows under "App integrity" as well, separated by a comma.

A site other than evesynapse.app builds its own app: run the workflow with its host and its own package name.

Turn push on for the server (once)

  1. On the server, make a key pair:

    evesynapse -push-keys
    

    It prints three lines starting VAPID_PUBLIC_KEY=, VAPID_PRIVATE_KEY= and VAPID_SUBJECT=. Nothing is saved anywhere; the lines are only printed.

  2. Paste those three lines into the server's .env (/opt/evesynapse/.env on an install made by the setup script), and change VAPID_SUBJECT to an address you can be reached at, such as mailto:[email protected].

  3. Restart:

    sudo systemctl restart evesynapse
    

Keep the private key secret, and do not replace the pair later: every browser that switched push on would have to do it again. The site has to be served over https (see "HTTPS").

Turn push on in a browser (each browser, each device)

  1. Sign in, click the bell, then Settings.
  2. Under Browser notifications, click Turn on in this browser, and choose Allow when the browser asks.
  3. Click Send a test. A notification reading "Browser notifications are working." should appear within a few seconds.

On an iPhone or iPad, first add EveSynapse to the Home Screen (Share → Add to Home Screen) and do these steps in the app that opens from there; Safari does not offer push to an ordinary tab.

If it does not work

What you see What it means
"Browser notifications are not set up on this server." The server did not find a usable key pair. Check the three lines are in the .env the service reads and that it was restarted. A pair that is set but wrong is reported in the log as browser push is off
"This browser cannot receive push notifications." The browser has no push support. On iOS, open the installed app, not a Safari tab
"…the address is not a known browser push service (it is at …)" The browser subscribed with a push service this server does not send to. The server only sends to the services browsers are known to use (Google's, Mozilla's, Microsoft's and Apple's), so that nobody can make it send requests to an address of their choosing. The message names the host: report it, with the browser, so it can be added
"Notifications from this site are blocked in this browser." Permission was refused earlier. Allow notifications for the site in the browser's site settings, then reload the page
"The test could not be sent: no browser took the test message" The push service refused the message. The reason is in the server log (journalctl -u evesynapse), on a line starting push:
The test says sent, but nothing appears The operating system is hiding it: check its notification settings for the browser, and Do Not Disturb / Focus

What does and does not notify

  • Notifications are worked out after each background sync, so one arrives some minutes after the thing happened in-game, not at that instant.
  • How soon depends on whether anyone has the site open. A character somebody is looking at is synced every minute. An account nobody has had open for a day is synced every 30 minutes, so its mail and killmails are announced up to that much later. An account not opened for a week is synced every 2 hours, but only if it has no browser subscribed to push and no Discord account linked: one that is being told things away from the site is never slowed below the 30 minutes. Things that come with the clock and need no sync (a skill or an industry job finishing, an extractor running out) are checked for at least every 5 minutes, and new ops and op reminders every minute, for everyone.
  • The first sync of a character only records what is already there. Mail that was in the inbox before then is not announced.
  • Mail a character sends to itself is not announced, and neither is mail already read.
  • A kind switched off in Settings is switched off for push too.
  • When more than three things happen at once, push sends a single message with a count instead of one each.

Discord

Optional. EveSynapse can connect a user's Discord account to their EveSynapse account, send their notifications to them on Discord, and, through one bot that can be added to any number of servers, give roles there and post new ops in a channel. It only ever sends to Discord; it reads nothing from any channel.

What the bot does in a server is not part of this install's settings. It is set on the site, per server, by the directors of the corporation or alliance the server belongs to.

Set the bot up (once, by whoever runs the site)

  1. At https://discord.com/developers/applications, create an application. If Discord asks what it is for, "Build a Bot" is the one; the Social SDK is not used.

  2. On General Information, copy the Application ID. That is the client id. (The Public Key on that page is not used.)

  3. Under OAuth2, press Reset Secret and copy the Client Secret, and add this redirect, with your site's address: https://your.site/discord/callback

  4. Under Bot, press Reset Token and copy the token. Switch on the Server Members Intent if any server is to use removals (below): it is what lets the bot read a server's member list. No other privileged intent is needed. Leave Public Bot on if other corporations and alliances are to add it to their servers.

  5. Add to the server's .env (/opt/evesynapse/.env) and restart (sudo systemctl restart evesynapse):

    DISCORD_CLIENT_ID=<the Application ID>
    DISCORD_CLIENT_SECRET=...
    DISCORD_BOT_TOKEN=...
    

Treat the secret and the token like passwords. With only the first two set, "Connect Discord" works and there is no bot.

Add the bot to a server (each corporation or alliance)

Who may: an account with a character that holds the Director role in the corporation (a CEO does), for that corporation's servers; and a Director of the alliance's executor corporation, for the alliance's. That character's link to EVE has to be working.

  1. On the site: bell → Settings → Discord → Set up your corporation's or alliance's Discord server, then Add the bot to a server.

  2. Discord asks which server and shows what the bot may do there: give roles, send messages, add members, and kick members (used only if removals are switched on for that server). Discord only lets someone who can manage that server add a bot to it.

  3. In that Discord server's Roles list, drag the bot's own role above the roles it is to give. Discord lets a bot manage only the roles below its own.

  4. Back on the site, write that server's role rules: "give these people this role", as many as there are roles to give. A member gets every role they qualify for. "These people" can be:

    • everyone there who has connected EveSynapse,
    • members of the corporation or alliance,
    • (alliance servers) members of one of its corporations,
    • the CEO,
    • holders of an in-game corporation role (Director, Accountant, Diplomat, ...),
    • a group the directors keep themselves (below).

    Then, if wanted, pick the channel new ops are posted in. Roles and channels are picked from that server's own.

Adding members automatically. Someone with a character in the corporation (or alliance) who has connected Discord is put in its server by the bot, with their roles, without needing an invite: on the worker's next pass after they qualify, whether that came from connecting Discord, linking the character, the character joining the corporation, or the server being set up. Only people who belong are added; a rule that gives a role to "everyone connected" adds nobody. Each person agrees to it on Discord when they connect ("join servers for you"). Untick the box on the server's settings to switch it off. A bot that was added before this existed lacks the permission: add it to the server again from the site (its settings are kept).

Removals (off unless a server's directors switch it on). With it on, the bot removes from the server everyone who is owed no role by the server's rules: someone whose character left, whose account or Discord connection went, and also anyone who never connected EveSynapse. People are kicked, not banned, a few at a time, and can come back with an invite. Never removed: bots, the server's owner, anyone in their first 24 hours in the server, and holders of roles the directors tick as protected (guests, diplomats). A server with no rules removes nobody, and removals cannot be switched on for one. Press Show who would be removed first: it lists them and changes nothing. Each removal is written to EveSynapse's log and to the server's own audit log.

Groups (/groups/ on the site) are lists of characters that a corporation's or alliance's directors keep by hand: a special interest group, the fleet commanders, a logistics wing. A director makes a group, ticks the characters in it, and adds a rule giving the group a role; the bot does the rest. Only characters linked to EveSynapse that are in the corporation (or the alliance) can be put in one. A member who leaves the corporation stays on the list but loses the role until they are back. Deleting a group removes the rules that used it.

Which channels a role can open is set on the same page, under Channels: pick a channel (or a category, or a voice channel) and tick the roles that may open it. The bot then keeps it private to those roles: it lets itself in, lets each ticked role in, and hides the channel from everyone else. Saving a channel again replaces its list. Saving it with nothing ticked stops the bot managing it; the channel stays hidden until someone opens it in Discord. The bot only touches channels listed there, and it has to be able to see a channel to change it. With the role rules this is the whole chain: what someone is in EVE gives them a role, and the role opens the channels.

Ops in an alliance's server. A corporation's ops are posted in its own servers. They are posted in its alliance's server only after a Director of that corporation ticks the box for it on the same page.

Removing a server first takes back the roles the bot gave there; press Remove again a few minutes later to finish, and the bot leaves.

What users do

On bell → Settings, under Discord: Connect Discord and approve on Discord's page, which asks for two things: to see their Discord name, and to add them to servers. Within a few minutes they are in their corporation's server and have their roles in every server that uses EveSynapse; Check my roles now does it at once, for someone who has just joined a server. To get notifications there, tick "Send my notifications to me on Discord" and press Send a test message; direct messages only arrive for someone who shares a server with the bot and allows messages from its members. Disconnect Discord removes the link and takes back the roles EveSynapse gave.

What the roles mean, and what they do not

A role is given to a Discord account when the EveSynapse account connected to it has a character, with a link to EVE that still works, for which the last sync saw what the rule asks: in the corporation, its CEO, holding the in-game role, in the group. It is taken away when that stops being true, and each role on its own: losing Director takes the directors' role and leaves the members' one.

  • Roles are taken back when the account goes. Deleting the EveSynapse account, disconnecting Discord, connecting a different Discord account, a character leaving, or its EVE access being revoked or expiring all remove them. What the bot gave is recorded against the Discord account itself, so this does not depend on the EveSynapse account still existing, and it is retried until Discord confirms it.
  • It follows a change within a sync or two (minutes), not at the instant it happens in-game.
  • In a server the bot gives and takes only the roles named in that server's rules. Every other role is left as it is.
  • A role removed by hand from someone who still qualifies comes back within six hours.
  • It shows that someone controls a character in the corporation. It does not stop a member sharing their Discord account, and it is not a check of in-game roles.
  • If the bot is kicked from a server by hand, the roles it gave there stay until someone removes them: it can no longer act there.
  • To add someone to a server later (when their character joins a corporation, say) EveSynapse keeps the Discord token they granted, sealed with TOKEN_ENCRYPTION_KEY like EVE tokens. That token can read their Discord name and add them to servers the bot is in, and nothing else. Someone who connected before this, or who removes EveSynapse under Discord's Authorized Apps, is asked on the settings page to connect again.

HTTPS

EveSynapse itself speaks plain HTTP. On anything but your own machine, put a reverse proxy that terminates TLS in front of it, so sign-in cookies and character data never cross the network unencrypted. With Caddy, which obtains and renews the certificate itself, the whole configuration is:

eve.example.org {
    reverse_proxy 127.0.0.1:8080
}

Then, in /opt/evesynapse/.env:

ADDR=127.0.0.1:8080                                    # only the proxy can reach the app
EVE_CALLBACK_URL=https://eve.example.org/auth/callback # and the same URL at developers.eveonline.com

The app reads its public address from EVE_CALLBACK_URL. When that is an https:// address it marks the session cookie Secure and sends Strict-Transport-Security; when it is plain http:// on anything other than localhost it logs a warning at startup.

Every response also carries X-Content-Type-Options, X-Frame-Options, Referrer-Policy and a Content-Security-Policy, and state-changing requests coming from another site are refused.

Build from source

Needs Go (the version in go.mod). The fonts and wallpaper travel through the repo in encoded form (under ci-assets/), so decode them into place first, then build:

make assets        # decode the fonts and wallpaper
make build         # bin/evesynapse for this machine
make build-arm64   # bin/evesynapse-arm64
make build-amd64   # bin/evesynapse-amd64
make test          # the test suite (starts its own embedded PostgreSQL)
make check         # what CI checks: gofmt, go vet, tests under the race detector

Choosing the modules in a build

Every optional feature (the build planner, the fitting tool, the market pages, the corporation pages, Discord, the shared cache, ...) is a module, and a build contains exactly the modules internal/app/modules_enabled.go imports. The default file lists them all. To build a smaller server, let the build tool write that file for your selection; whatever a chosen module requires comes along, and the tool prints the ESI scopes the selection will ask players for:

make modules                                          # every module, what it requires and the scopes it declares
go run ./cmd/modbuild -modules fitting,doctrines      # print the file and the scopes; nothing is written
go run ./cmd/modbuild -modules fitting,doctrines -write
go build -o bin/evesynapse ./cmd/evesynapse
go run ./cmd/modbuild -all -write                     # back to the default, everything

-core-only is the empty selection (go build -tags core_only compiles none regardless of the file). CI checks that the default file is the all-modules selection and that each module builds on its own with what it requires.

Install your build with the setup script's --build mode, which does the build above and the install in one go:

sudo bash deploy/setup.sh --build

Or install a build you already made:

sudo EVESYNAPSE_BINARY=$PWD/bin/evesynapse bash deploy/setup.sh

Updating

Builds are published automatically: every push to main runs the test suite in CI and, when internal/app/version.txt names a version that has no release yet, publishes the release on the project's Forgejo, GitHub and GitLab with builds for ARM64 and AMD64 plus, for each build, a small manifest (the version and the build's SHA-256 checksum) and that manifest's signature. Each published release is then mirrored to the project's static mirror (a Cloudflare R2 bucket served from https://cdn.synap6.io): the version's files under evesynapse/v<version>/, the channel's binaries under latest/ (or latest-dev/), and the channel's signed pointer manifests last; the mirror job needs the R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY and R2_BUCKET CI/CD variables, and skips without them.

That mirror is also the archive of versions: every published version's files are kept under https://cdn.synap6.io/evesynapse/, one folder per version (evesynapse/v<version>/, for example evesynapse/v0.7.2.1/).

The updater reads releases from the sources EVESYNAPSE_UPDATE_PROVIDER lists, and checks them in the order listed: no source is preferred beyond that order. The newest signed release across the sources wins, a tie goes to the earlier-listed source, and the manifest, its signature and the build all come from that one source's release, verified by the same signatures and checksums whichever source serves them. An update run reads out in the package manager's idiom: each source it checks, at its endpoint, with the version it found there, the signing key's signer and fingerprint once the signature verifies, then the download's total size, the Get line naming the source it downloads from, and progress as it downloads:

github    https://api.github.com/repos/natemsz/evesynapse          v0.7.0.9
gitlab    https://gitlab.com/api/v4/projects/natemsz%2Fevesynapse  v0.7.0.9
forgejo   https://natems.dev/api/v1/repos/EveSynapse/evesynapse    no usable release
mirror    https://cdn.synap6.io                                    v0.7.0.9
A new version is available: v0.7.0.9.
Signature verified: [email protected] / natemsz, SHA256:KkV2PM3ej2aUVOkjfuCTzhibjMcydkO886VchqyxE+k

Total download size: 54.0 MiB
Get: evesynapse-amd64 from github [54.0 MiB]
 42% [evesynapse-amd64 22.7/54.0 MiB] 6.8 MB/s
Download verified against its checksum.

With the setting unset, the updater walks a built-in fallback list (github, gitlab, forgejo) and asks the mirror only when no forge produced a signed release.

A release is one edit: internal/app/version.txt. Nothing else in the repository repeats the version, and the tests read it from that file, so there are no version numbers in tests to keep in step.

Versions are four plain numbers with no leading zeros, stable.major.feature.fix: 0.4.1.4, then 0.4.1.5 for a fix or a small addition, 0.4.2.0 for a feature, 0.5.0.0 for a major version. The first number stays 0 until there is a feature-complete stable release. A development build adds a suffix (0.4.2.0-dev). Releases up to 0.4.01.003 padded the last two parts with zeros. The updater reads every part as a number and compares part by part, so the padding makes no difference to it: 0.4.01.003 is 0.4.1.3, and installs on it update to 0.4.1.4. A test fails if version.txt is ever set to something those installs would take for an older version.

To update a running install, run the updater. With no flag it automatically picks the build that matches the machine it's running on:

sudo /opt/evesynapse/evesynapse -update          # right build for this machine
sudo /opt/evesynapse/evesynapse -update -arm64   # ARM build explicitly
sudo /opt/evesynapse/evesynapse -update -amd64   # Intel/AMD build (-x86 and -x64 also work)

The updater first checks the latest release's signature, and refuses a release that is not signed with the project's release key ("Release signing" below). Then it asks the release what version it carries and compares it with its own. If they're the same, it just says so and stops. If the release is newer, it downloads that build, verifies it against the checksum in the signed manifest and checks it's built for the right kind of computer, swaps it into place, restarts the running server onto it, and reports the new version number.

There's also a manual form that installs a specific build: from an address, with the checksum published for it, or from a local file:

sudo /opt/evesynapse/evesynapse -update <url> <sha256>
sudo /opt/evesynapse/evesynapse -update <file> [sha256]

The manual form checks what you give it and no signature: it is your own word for that build.

Development builds

Builds from the evesynapse-dev branch carry a version ending in -dev and are published as prereleases, which a plain -update never installs. To follow them on a test box:

sudo /opt/evesynapse/evesynapse -update -dev

A plain -update from a development build goes back to the latest release once that release's number catches up.

Release signing

Every release is signed, and the updater installs nothing that is not.

  • What is signed. Each build's manifest, latest-<arch>.json: the version, the kind of computer, and the build's SHA-256. Its signature is published beside it as latest-<arch>.json.sig. Because the checksum is inside what is signed, the signature covers every byte of the build.
  • With what. An Ed25519 key. Its private half exists only as the project's RELEASE_SIGNING_KEY CI secret, which the release job signs with. Its public half is internal/releasesig/trusted_keys.pem, compiled into every build.
  • What the updater does with it. It fetches the manifest and the signature, checks the signature against the keys it was built with, and only then reads the manifest. A release that is unsigned, signed with another key, or changed after signing is refused and nothing is downloaded. An older release offered again is still genuinely signed, and is turned down by the version comparison instead.
  • What it proves, and what it does not. That the release was published by this repository's release job. Someone who can replace the files of a release, but cannot run that job with the key, can no longer get a build installed. It is no protection against someone who controls the repository itself, since they control what the job signs.

To check a release by hand, with nothing but OpenSSL 3:

base64 -d latest-arm64.json.sig > sig.bin
openssl pkeyutl -verify -pubin -inkey internal/releasesig/trusted_keys.pem \
    -rawin -in latest-arm64.json -sigfile sig.bin
sha256sum evesynapse-arm64   # must be the "sha256" in latest-arm64.json

(go run ./cmd/releasesign verify latest-arm64.json makes the first check exactly as the updater does. OpenSSL reads only the first key in trusted_keys.pem.)

Making the key. Once, from the repository root:

go run ./cmd/releasesign keygen -out ~/evesynapse-release-key.pem

It makes the key pair, writes the private half to that file (the key is never printed), and only when that has worked adds the public half to trusted_keys.pem. Commit that file. The variable store masks single-line values only, so the key goes in encoded:

base64 -w0 ~/evesynapse-release-key.pem

Paste that one line into the project's CI secrets (on the mainline's Forgejo: the repository's Settings > Actions > Secrets) as RELEASE_SIGNING_KEY, and keep the file somewhere offline. releasesign reads the key in either form. That copy is one more place the key can leak from, and without one, losing the variable means the manual update described under "If the key is lost".

The release job fails, and publishes nothing, when the variable is missing or is not the other half of a key in trusted_keys.pem.

Replacing the key. An install only trusts the keys of the build it is running, so the new key has to reach installs in a release signed with the old one:

  1. go run ./cmd/releasesign keygen -add -out ~/new-release-key.pem adds the new public key beside the old, and writes the new private half outside the repository. Commit trusted_keys.pem and publish a release. It is still signed with the old key, and its updater trusts both.
  2. Once installs have had time to take that release, switch the variable: paste the new private half into RELEASE_SIGNING_KEY in the project's CI/CD variables, then delete that file or move it somewhere offline.
  3. Later, delete the old key's block from trusted_keys.pem.

An install that skipped the release from step 1 needs the manual update below. If you kept a copy of the old private half you can avoid even that: put both keys in the variable for a while (one PEM block after the other) and every release carries both signatures, which an install knowing either key accepts. If the private half may have leaked, replace it the same way without waiting.

If the key is lost (the variable deleted, and no copy kept), make a new one (delete the old block from trusted_keys.pem, then keygen as above) and publish a release. Existing installs cannot verify it, because the only key they trust is the lost one. Each needs one manual update, with the address and checksum from the release page, and updates normally from then on:

sudo evesynapse -update https://gitlab.com/api/v4/projects/natemsz%2Fevesynapse/packages/generic/evesynapse/v<version>/evesynapse-arm64 <sha256>

Updating from your own fork

By default the updater checks the mainline project's releases. To have it check your fork instead, set this in /opt/evesynapse/.env (the setup script writes it for you when you install from a fork):

EVESYNAPSE_UPDATE_PROJECT=yourname/evesynapse

For your fork to publish releases the same way, the pipelines are already in the repo: the mainline publishes through the Forgejo release workflow (.forgejo/workflows/release.yml), and .gitlab-ci.yml carries the same release contract for a GitLab-hosted fork: your pushes get tested, built, and released the same way, with the fork's own RELEASE_SIGNING_KEY variable (see "Release signing").

A fork signs its releases with its own key. In the fork, delete the mainline key's block from internal/releasesig/trusted_keys.pem, run the keygen command under "Release signing", and commit the file; until the fork has a key its release job fails rather than publish unsigned builds. It follows that the setting above only moves an install that is already running a build of the fork: a mainline build refuses the fork's releases, since they are not signed with the key it trusts. Install the fork's build first (its setup script does that), and it updates from the fork from then on.

Command-line modes

evesynapse                          run the web app
evesynapse -version                 print the version and exit
evesynapse -update                  update to the latest release (right build for this machine)
evesynapse -update -arm64           update, fetching the ARM build
evesynapse -update -amd64           update, fetching the Intel/AMD build (-x86, -x64 also work)
evesynapse -update -dev             update to the newest development build
evesynapse -update <url> <sha256>   install a specific build from an address (checksum required)
evesynapse -update <file> [sha256]  install a build from a local file
evesynapse -refresh                 mark all cached data stale (run while the app is stopped)
evesynapse -push-keys               print a new key pair for browser push, as lines for .env
evesynapse -h                       show this list

EVE SSO flow

  1. Register the app at https://developers.eveonline.com with the callback URL from EVE_CALLBACK_URL, and put the issued client ID and secret in .env.

  2. Login: GET /auth/eve stores a random state in the session and redirects to login.eveonline.com requesting the scopes the chosen features need (see "Choosing what to link"; a full link requests all 61 scopes in eveScopes, in internal/app/auth.go): the read scopes from the OAuth2 catalog in CCP's ESI OpenAPI document, plus four that are not read-only, each for one feature:

    Scope What the app does with it
    esi-fittings.write_fittings.v1 "Save to EVE" in the fitting editor
    esi-mail.send_mail.v1 sending mail from the compose page
    esi-mail.organize_mail.v1 marking a mail as read
    esi-planets.manage_planets.v1 reading colonies; CCP publishes no read scope for planetary industry, and the app only ever reads

    Everything else that can change something in the game (contacts, fleets, calendar responses, waypoints, opening in-game windows) is not requested. Characters linked before a scope was added keep the scopes they granted until they sign in again; the feature that needs the missing scope says so.

  3. Callback: GET /auth/callback verifies the state (constant-time, single-use), exchanges the authorization code for tokens, then verifies the access-token JWT: RS256 signature against CCP's JWKS (found via the OAuth discovery document), iss = login.eveonline.com. Character ID/name come from the sub/name claims.

  4. Persist: first login creates a row in users; the character is upserted into characters with the access/refresh tokens, expiry, and granted scopes. Re-login (or "Link another character" while signed in) attaches characters to the same account. The session token is rotated at sign-in.

  5. Character pull: the signed-in home page shows the character sheet — identity (name, portrait, corporation, birthday, security status), ISK wallet balance, total/unallocated SP, the currently training skill, and the 25 heaviest skills with names resolved via GET /universe/types/{id}/ (cached in-process and in the type_names table).

Tokens, authorization codes and the client secret are never logged; request logs contain paths only, no query strings.

Token refresh & caching

EVE SSO access tokens live ~20 minutes. validAccessToken (internal/app/auth_refresh.go) returns the stored token while it has more seconds left; otherwise it refreshes against CCP and persists the new access token, the rotated refresh token, and the new expiry. Refreshes are serialized process-wide and the character row is re-read first, so a rotated refresh token is never replayed.

ESI responses for skills, skill queue, wallet and assets are cached as raw JSON in character_snapshots, keyed by (character, kind) with the response's Expires header stored as cached_until (5-minute fallback when ESI sends none). The module sweep caches the character live-state endpoints the same way (location, ship, online, clones, implants, fittings, fatigue and the recent-killmails list). Killmail details are different: they are immutable, so the worker warms them once per killmail into the killmail_details store (at most 10 per character per cycle) and pages read the store only. Corporation datasets (module sweep cluster 2) ride the same snapshot table as corp_* kinds, fetched with each viewing character's token against that character's corporation — so pages follow the selected character's corp, and character/corporation ID collisions can never cross-contaminate. Role-gated corporation endpoints answer 403 when the character lacks the in-game role; the worker records that in snapshot_fetch_state (state role_missing plus the role label), backs the kind off for six hours instead of retrying every minute, and never writes a snapshot for a refused kind — the subpages and the Sync page render the recorded state ("Needs the Director role in-game") until a fetch succeeds. Corp asset singleton names come from POST /corporations/{id}/assets/names/ (bounded to 1,000 items per cycle, stored in item_names; CCP's "None" placeholder for unnamed items is skipped). Assets are paginated: every page is fetched and stored as one merged JSON array. Pages serve fresh snapshots without calling ESI; on fetch failure a stale snapshot is served instead of an error. ESI's error-limit statuses (420/429) are treated as a hard back-off signal.

Refreshes are conditional where they can be. A dataset that comes in one response is stored with the ETag ESI sent, and the next refresh offers it back (If-None-Match). When nothing has changed ESI answers 304 Not Modified with no body: the stored payload stays as it is and only its cache window is renewed. Datasets spread over several pages (assets, contracts, blueprints, …) have an ETag per page and none for the whole, so they are downloaded in full each time. evesynapse -refresh clears the stored ETags, so everything really is downloaded again.

Name resolution is split in two tiers. Page renders resolve type, group and place names from local data only — the in-process maps, the SDE static-data tables (below), and the type_names fallback table — a render never waits on ESI; anything still missing shows as Type #<id> (or an "Ungrouped" skill section) until the worker's warm-up pass fills it in. The interactive Market lookup is the one exception: it may make a single name fetch for an item nobody has cached yet.

The Intel cluster (module sweep cluster 4) is public ESI data — wars, incursions, faction warfare and Tranquility status — so it doesn't ride the per-character snapshot table. The worker keeps it in a small global store instead: global_snapshots (one raw payload per dataset, same Expires/cached_until contract) plus a war_details store for GET /wars/{war_id}/ payloads, warmed newest-first at most 50 per cycle and refreshed while a war is active (finished wars are immutable). War-party corporation and alliance names and incursion constellation names warm into in-process caches through the worker cycle's shared lookup budget; faction names come from the stored factions list itself. The Intel pages and the Home page's Tranquility line render from the store only — and the worker pass runs even with no characters linked, since none of it needs a token.

Static data (SDE)

Item, skill, group, station and system names come primarily from a local copy of CCP's static data export (the SDE), stored in the sde_* tables: types, groups, categories, NPC stations, solar systems and regions. The ESI drip-feed caches remain only as fallback for anything the SDE lacks — notably player-structure names, which aren't in the dump.

  • Source: Fuzzwork's community CSV conversion of the SDE. The six tables are fetched from https://www.fuzzwork.co.uk/dump/latest/csv/ (plain .csv; .csv.bz2 names and bzip2 content are also handled). Override with EVE_SDE_BASE_URL to use a mirror.
  • Import: on first boot with empty SDE tables the worker imports automatically. The import downloads and parses all six files first, then replaces the tables in a single transaction — a failed import leaves the previous data untouched.
  • Cadence: static data changes on patch days, not on ESI's cache clock. The worker checks the remote files' ETag / Last-Modified daily and re-imports only when they changed.
  • Manual update: the Sync page's "Check for SDE update" button runs the same check on demand (and imports when the dump moved); it also shows import state, per-table row counts, and each file's remote last-modified marker.

Background worker

Every 60 seconds the worker walks all linked characters: it ensures each access token is usable (refreshing when needed) and re-fetches any snapshot whose cached_until has passed — a first pass runs at boot. It also warms killmail details behind each character's recent list and contract item lists behind the contracts list (both bounded per cycle) so those pages never wait on ESI. The same cycle refreshes the public Intel store (server status, war list and details, incursions, faction-warfare systems/stats, factions) and the public names behind the Intel pages, spending from the same per-cycle lookup budget. It then warms the name caches from the fresh snapshots (type names and type→group links, group names, station/system names, and character names for killmail victims and final-blow attackers) with a small concurrent pool, capped per cycle; a huge account simply converges over a few cycles. Characters are warmed first when they were just linked via SSO or flagged on the Sync page. On a 420/429 the cycle stops and waits for the next tick. Logs stay quiet: one summary line per cycle only when something was refreshed or failed, plus a heartbeat every 10 minutes. The current status (last run, summary, cumulative names resolved) shows on the Admin and Sync pages. When a Trade ideas model is configured (a MODEL_API_KEY, or the Cloudflare credentials as the fallback), the same worker also refreshes the Trade ideas page (Market > Trade ideas): it feeds the spread scanner's Forge rows (at most 60, same columns as the scanner page) to the model and stores the answer, at most twice a day and spending no ESI budget. The call runs beside the worker cycle, not in it; a failed refresh keeps the previous answer and backs off before asking again.

Swarm Cache

Self-hosted instances can share public ESI data with each other — market history, type names, corporation and alliance records, and player structure names — so the whole swarm asks ESI for each record once instead of once per instance. Nothing private ever leaves the instance: no character data, no tokens, no accounts. The design is section 3 of How EveSynapse syncs.

It is off unless SHARED_CACHE says open (or on) or allowlist (see "Configuration"). open shares with every instance holding the secret; allowlist additionally waits for each instance to be approved on the Admin page. New instances find the swarm through the SHARED_CACHE_SEEDS directory nodes, then membership spreads peer to peer about once a day (SHARED_CACHE_GOSSIP_HOURS). Every round ends with one heartbeat per configured seed, so the directory keeps a fresh record of each instance; every instance also accepts those heartbeats itself (POST /swarm/seed/register), so any instance works as a seed for the rest.

Fetch capacity is spent swarm-first. A dedicated warming node (SHARED_CACHE_WARM) says which kinds it warms (GET /swarm/warms) and takes the pull requests it cannot answer as demand: the key joins its own queue and its worker fetches it on its own ESI budget. An instance that a dedicated node covers leaves a miss of that kind to the swarm for 15 minutes before spending its own call, so its budget is the last resort; a kind no dedicated node covers is fetched as before. Pages never wait on any of this: a miss renders its not-yet-known state and fills in when the record lands.

Trust is cryptographic and reputational. Every shared entry is signed by the instance that fetched it (Ed25519); the signature and shape are verified before anything is stored. Verification against ESI is sampled, not exhaustive: new signers are checked every time, then the check rate falls with trust (half at half trust, five percent near the top), drawn unpredictably per entry. A failed check counts against its signer with weight that doubles per consecutive failure, and five in a row excludes on velocity alone; old bad marks fade a little every gossip round, and passing checks rebuild trust, so a fixed peer earns its way back and the operator's re-admit is the way home. Ten checks under half trust excludes the signer on its own. Every applied entry records the peer that served it, the instance that signed it and the signed fetch time; when a signer is excluded, the worker takes back what it supplied that this instance has not fetched for itself since, and fetches it from ESI instead. The urgent drain asks peers before spending ESI, and a key with a fresh staged entry skips its refetch while the spot decision lands. The operator can always add an instance by address, approve, exclude, re-admit, or remove one on the Admin page. The first key seen for an instance is the one kept: a later sighting under another key is refused and marked on that instance's row, and an address moves only when the instance itself says so under its pinned key. Removing an instance is the only way its key changes (the next sighting pins the new one). Every member signs its own address with its own key and the time it signed; that tuple travels through every member list, relayed unchanged, so an address is always its owner's word, and of two tuples the later-issued wins. A seed is a member like any other, with its own instance ID and key, and there can be several. Only peers a pull has lately staged data from are asked for data; any other peer gets one probe, then is dialed for membership alone until a gossip round lets it be probed again. A seed serves its member list — itself plus every peer it knows — so one address teaches a new instance the whole swarm. The Sync page's "Swarm Cache" card shows entries applied, ESI calls saved, spot checks for and against, and each instance's trust.

The member list itself is private to the swarm. Fetching it needs the shared secret, and the list is sealed under a key derived from that secret on top — a response that leaks anywhere else stays opaque, readable by members alone. Pages show gossip-learned members under stable aliases (adjective.noun.number) instead of their addresses; only instances added by hand and the configured seeds show plainly.

Logging

Every log line carries a level:

  • ERROR: something failed that should not have (a query, a decode, a store, a template, a recovered panic, a request the server answered with a 5xx).
  • WARN: something went wrong in a way the app expects and handles (ESI asking it to back off, stale data served because a refresh failed, a sign-in that did not complete, a setting worth changing).
  • INFO: what the app is doing (starting and stopping, sign-ins, worker cycle summaries, imports, requests).

LOG_LEVEL sets the least severe kind that is written. The default, info, writes everything, as before; warn leaves only the lines that may need attention. LOG_FORMAT=json writes one JSON object per line (time, level, msg) for a log collector; the default is the plain line:

2026/10/07 12:00:00 WARN worker: ESI error limit hit refreshing intel; backing off until next cycle

Under systemd, journalctl -u evesynapse -g ' (WARN|ERROR) ' shows only those lines without changing what is logged.

Performance principles

EveSynapse is built to stay lightweight, lean, fast, and efficient at scale — without compromising features, power, or security. Two rules govern how data moves:

  1. Precompute at ingest, never at render. Expensive work (medians, bands, aggregates, rankings) happens once in the background worker when data arrives, and its results are stored. Page handlers never recompute what a scheduled job already computed.
  2. Bounded reads on every render. A page handler may only read a bounded number of rows per render (on the order of ~100). No full-table scans, no unbounded sorts or filters in Go, no per-row queries in loops. Filtering, sorting, and ranking belong in SQL with a LIMIT; anything the page needs beyond that belongs in a stored table written at ingest time.

Data changes on a known schedule (market sweeps complete every 30 minutes; character snapshots refresh on their own cadence), so a page that re-derives everything per load is doing repeat work for an identical answer. Under one user that is a CPU spike; under a hundred it is an outage. When in doubt, push the work into the worker and store the result.

Look & feel

The UI echoes the 2013 EveSynapse theme — the original planet/nebula wallpaper, its teal-blue accents (#326b8c) changed to the molten ember of EVE's Dominion expansion (2009), a year that holds special meaning to us as it is the year our characters were born, and translucent dark nav and header panels over the art — rebuilt with a single dependency-free stylesheet (internal/app/static/style.css): no Bootstrap, no jQuery, it's not 2013 anymore :), no external fonts, responsive down to phone widths.

Dev login

The dev build (cmd/evesynapse-dev, make build-dev) registers /dev-login when started with DEV_LOGIN=1: it flips the session to signed-in without EVE SSO so the signed-in pages can be exercised locally (it is never an admin: the dev session has no account). The handler lives in internal/devtools and is only wired into the dev entrypoint — the release binary (cmd/evesynapse) doesn't contain the code, and DEV_LOGIN=1 has no effect on it. /dev-login hands a session to anyone who asks: never enable it on a deployment anyone else can reach, and never deploy the dev build. The dev server logs a loud warning at boot when it is on.

Database & sqlc

The app runs on PostgreSQL 16 (the live deployment is 16.15; tests self-provision an embedded Postgres 16, so CI needs no database service). Every query and the hand-rolled importer ride a database/sql handle over the pgx/v5 stdlib driver; a pgxpool exists only to back the scs pgxstore session store (sessions live in the sessions table the baseline creates).

The schema is a series of numbered steps in internal/store/schema_pg/: 001_baseline.sql is the whole schema as of the move to Postgres (BIGINT/DOUBLE PRECISION keep the generated Go models' int64/float64 types), and each later file is one change. At startup store.Open applies whichever steps a database is missing, in order. Each step runs in a single transaction together with its row in the schema_migrations table, so a step lands completely or not at all, and that table is the record of what has been applied. A database from before the table existed is adopted on first start: its existing steps are recorded, not run again.

Times are timestamptz columns and time.Time (or sql.NullTime where "never" is a possible answer) in the code; a connection always hands them back in UTC, whatever zone the machine is in. The baseline kept them as RFC 3339 text, and steps 009–011 convert those columns, carrying every stored value over as the same instant. Those three steps rewrite the tables they touch, so the first start after upgrading past them takes as long as copying those tables once; the snapshot table is by far the largest.

To change the schema, add the next numbered file, then embed it and add one line to schemaSteps, both in internal/store/schema.go. Regenerate query code after editing internal/db/query/ with:

make gen   # sqlc generate

Token encryption

Each linked character's EVE access and refresh tokens are stored in the characters table. A refresh token is a standing credential, so anyone holding a copy of the database could read that character's data and use the write scopes the app requests (sending mail, saving fittings).

Set TOKEN_ENCRYPTION_KEY to any random value of at least 32 characters and both tokens are stored encrypted (AES-256-GCM), each bound to its character and column:

openssl rand -hex 32    # put the output in .env as TOKEN_ENCRYPTION_KEY=...

The setup script generates a key for new installs. add the line and restart: tokens already stored are encrypted in place at that start.

Two things to know before switching it on:

  • Keep the key. It lives in .env, not in the database, so back the two up together. If the key is changed or removed the app refuses to start rather than run with tokens it cannot read; the error explains how to clear the stored tokens if the key is truly lost, after which every character signs in once more.
  • Don't go back to a build from before token encryption. It would present the encrypted values to CCP as tokens, be refused, and mark every character as needing a fresh sign-in.
  • Upgrading the format is one way. Tokens sealed by earlier builds (enc:v1:, a single SHA-256 of the key) still open, and the first start of this build re-seals them as enc:v2: (a proper key derivation, HKDF). A build from before that change cannot read enc:v2: values, so take a database backup before upgrading if you might roll back.

Without a key, tokens are stored unencrypted as before and the app logs a warning at startup.

Keeping the key off the machine

The key in .env protects the tokens against a stolen copy of the database — but it sits on the same machine, so a copy of the machine's files (database and .env together) opens everything. For any instance reachable from the web, the stronger arrangement is a key service: the key is wrapped by a KMS, and the app fetches it, unwrapped, at startup and keeps it in memory only. Set:

TOKEN_KEY_PROVIDER=http
TOKEN_KEY_WRAPPED=...        # the key, wrapped by the key service
TOKEN_KEY_UNWRAP_URL=...     # its unwrap endpoint
TOKEN_KEY_UNWRAP_CREDENTIAL=...   # the credential the app presents

TOKEN_KEY_PROVIDER is env (the TOKEN_ENCRYPTION_KEY above; the default when it is set), http, oci, or none (no key). Any HTTPS endpoint speaking the two-call protocol — POST {"wrapped": …} → {"secret": …} to unwrap, POST {"secret": …} → {"wrapped": …} to wrap — can serve, so nothing ties this to one vendor's KMS. A complete reference service ships as a Cloudflare Worker you deploy to your own account, free, in a few minutes: natemsz/cloudflare-kms.

On Oracle Cloud there is a second shape, TOKEN_KEY_PROVIDER=oci: the key is wrapped by a master key in an OCI Vault — create it with HSM protection, so the key cannot be exported — and the app unwraps it at startup with a KMS Decrypt call authenticated by the instance's own identity (an instance principal). Nothing cloud-shaped is stored on the machine at all: no credential, no key, only the ciphertext and the key's address. OCI authorizes the call through IAM: put the instance in a dynamic group (its matching rule is instance.id = '<the instance OCID>') and give that group this policy statement: Allow dynamic-group <group> to use keys in tenancy. Keep it in that plain form: a statement narrowed with a where target.key.id = ... condition looks tighter but is not honored on the vault's cryptographic endpoint — calls against it are refused — and scoping by compartment only helps if the key itself (not just its vault) lives in that compartment. Set:

TOKEN_KEY_PROVIDER=oci
TOKEN_KEY_WRAPPED=...          # the key, wrapped by the vault key
TOKEN_KEY_OCI_KEY_ID=...       # the vault key's OCID
TOKEN_KEY_OCI_CRYPTO_ENDPOINT=...   # the vault's cryptographic endpoint

Produce TOKEN_KEY_WRAPPED once, on the instance, while TOKEN_ENCRYPTION_KEY is still set: evesynapse -wrap-token-key prints it (the wrap itself is an instance-principal call, so it only works there). Then remove TOKEN_ENCRYPTION_KEY and restart. The oci provider starts and unlocks exactly like http: serving at once, retrying in the background, installing the key only after it opens the stored tokens.

With http the app starts and serves at once; the unwrap is retried in the background until it succeeds, and until then stored tokens report their key missing while everything else — the public pages included — keeps working. A fetched key is only used after it has been shown to open the stored tokens. Losing the key service's wrapping key is the same as losing TOKEN_ENCRYPTION_KEY: the stored tokens cannot be recovered and every character signs in once more — so back the key service's configuration up with the rest.

The four working credentials — EVE_CLIENT_SECRET, DISCORD_CLIENT_SECRET, DISCORD_BOT_TOKEN and MODEL_API_KEY — can come off the machine the same way. With the oci provider, put each in OCI Secrets (in the same vault; the dynamic group needs a read secret-bundles policy statement alongside the key one) and name its OCID in <name>_OCI_SECRET_ID: the app reads it at startup with the instance's identity and holds it in memory only. With the http provider, wrap each once with the key service's wrap call and store the ciphertext in <name>_WRAPPED; it is unwrapped through the same endpoint as the token key. Each credential moves independently: while a fetch is still to land, the plain value (when one is set) serves, so add the external source, watch the startup log name the credential as loaded, and then take the plain value out. A feature whose credential has not landed yet stays off and recovers when it does. SHARED_CACHE_SECRET is the deliberate exception: it stays in .env, because it is a proof shared with other swarm operators by design, and moving it would buy nothing.

Moving an existing install to a key service

An install already running on TOKEN_ENCRYPTION_KEY moves over without touching the stored tokens — the key itself never changes, only how it is delivered:

  1. Back .env up.
  2. Wrap the current key. With oci, run evesynapse -wrap-token-key on the instance (with the oci settings below in place; the command prints only the ciphertext). With http, post the key to your key service's wrap endpoint once.
  3. Put the complete set into .env together: the provider, the wrapped blob, and that provider's settings — while keeping TOKEN_ENCRYPTION_KEY for the moment. Never leave a half-written provider in .env across a restart: an app that is told to use a provider whose settings are incomplete refuses to start, by design.
  4. Restart. The log shows token encryption key loaded via the <provider> provider; stored tokens are unlocked.
  5. Remove TOKEN_ENCRYPTION_KEY from .env and restart again; the same line must appear. The plaintext key now exists nowhere on the machine.
  6. Rollback at any point: restore the backed-up .env and restart.

Backups

Back the database up with pg_dump (and restore with psql):

pg_dump evesynapse > evesynapse-$(date +%F).sql
psql evesynapse < evesynapse-2026-10-05.sql   # into a fresh database

The generated SQL file plus /opt/evesynapse/.env is a complete backup of an install.