Repository navigation
Portal 2.0: rebuild on React and TypeScript, served entirely through the PSF-HTTP API - #55
Open
GeekOfWires wants to merge 37 commits into
Open
GeekOfWires wants to merge 37 commits into
GeekOfWires wants to merge 37 commits into
Conversation
…d v4 Replaces the Svelte 3 / webpack 4 / Bootstrap 4 / jQuery frontend with a modern stack, and introduces a PSForever theme built on the SCIFICN/UI design system (Radix + Tailwind v4, shadcn-CLI compatible). Toolchain - React 19 + TypeScript (strict) + Vite 6, replacing Svelte and webpack 4. - Tailwind CSS v4 via @tailwindcss/vite, replacing Bootstrap/SCSS. - react-router-dom replaces page.js; the route table is carried over unchanged (/, /login, /register, /leaderboard, /avatar/:id, /admin, /profile, /user/:id, /outfit/:id, catch-all). - UserState's Svelte stores become a UserContext with the same contract, including the "hold render until the initial /api/user probe resolves" behaviour that prevented view pop-in. - axios is dropped in favour of fetch; the API contract is unchanged, and the 403 = "not logged in" convention is preserved. Project layout The Express server is untouched: it still serves the SPA out of public/ and mounts the API at /api. To keep that seam, Vite builds into public/ and its own static assets live in static/. The 116 tracked images and the favicon move from public/ to static/ (Vite copies them verbatim), and public/ becomes entirely generated output, so it is now gitignored in full rather than just public/bundle.*. Theming src/styles/globals.css vendors the SCIFICN/UI token sheet -- the components are copy-paste, so we own the code and carry no runtime UI dependency. A [data-theme="psforever"] block remaps its primitives to the authentic empire colours carried over from scss/planetside.scss (TR #c82f1c, NC #003e80, VS #460e62, background rgb(1,13,29)), and drives all interactive elements -- active borders, primary accent, focus glow -- from the Vanu Sovereignty secondary teal. bcrypt is bumped 3.0.7 -> 6.0.0: the old release predates Node 24 and has no prebuilt binary for it, so a fresh install failed trying to compile via node-gyp. Only hash() and compare() are used and bcrypt hashes remain algorithm-compatible, so stored passwords are unaffected. The Svelte app/ and scss/ trees are retained for now as a porting reference; they are no longer part of the build. Only Home is ported so far -- the remaining views render a placeholder. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Empire secondaries
Each empire now carries its secondary alongside its primary:
TR red + gunmetal grey (#9aa5ad / #c3ced6)
NC blue + gold/yellow (#f0c419 / #ffd851)
VS purple + teal (#34e0c8 / #6cf5e2)
The VS secondary is the same teal that drives global interactive
elements. Exposed as tokens plus .faction-*-secondary helpers, and used
on the home page so empire names render in the primary and their figures
in the secondary.
Express 4.17 -> 5.2
Also upgrades express-session 1.17 -> 1.19, connect-pg-simple 6 -> 10,
connect-history-api-fallback 1 -> 2, morgan 1.9 -> 1.11 and dotenv 8 -> 17.
One genuine breaking change had to be fixed: Express 5 uses
path-to-regexp v8, where a bare '*' wildcard is no longer a valid path and
throws "Missing parameter name" at registration. The API's catch-all
becomes '/*splat'. Every other route uses plain named parameters
(:avatar, :user, :outfit, :batch) and needed no change, and the codebase
uses none of the other removed v5 APIs (req.param, res.send(status),
redirect('back'), app.del, mutable req.query).
body-parser is also promoted to an explicit dependency. It was imported by
api/index.js but never declared, resolving only transitively through
Express 4; Express 5 still bundles it, but relying on that hoisting was
fragile.
Verified: the API router registers cleanly under Express 5.2.1 (11 layers,
no path-to-regexp errors), the client typechecks and builds, and the three
empire secondaries resolve to the intended computed colours in the browser.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ports the remaining views from the Svelte app and wires them to the real
Express backend, replacing the placeholders: Login, Register, Leaderboard,
Avatar, Outfit, Profile (serving both /profile and /user/:id, as before)
and AdminPanel. Adds shared UI primitives (Button, Field, Alert, Spinner,
DataTable) and a Panel, so views code against our own component API rather
than a third-party one, keeping the design system replaceable.
Two shared modules replace values that were previously duplicated across
views: lib/factions.ts (the canonical faction map, mirroring FACTION_MAP
in api/db.js) and lib/types.ts (the API response shapes).
Running the pages against a live Express instance found three defects that
static porting would have shipped:
- The home page queried /api/info, which does not exist. The real endpoint
is /api/stats, returning { status, players, accounts, characters, last,
empires }.
- The faction ids were inverted. FACTION_MAP is 0=TR, 1=NC, 2=VS, not the
reverse; every faction-coloured name and population figure was wrong.
- /api/avatar/:id returns `faction`, not `faction_id`, and bep/cep as
strings.
api/psadmin.js also had a real bug. get_player_list() only settled its
promise on 'data' or 'error', so when a peer accepted the connection and
closed it without replying the promise never settled. Because
start_server_polling() awaits it during boot, Express hung before listen()
and then exited as soon as the event loop drained -- the server would not
start at all. It now settles on close and on a 5s timeout, which also makes
the pre-existing "initial PSAdmin poll FAILED" warning reachable for the
first time.
Verified against the live stack: Home renders real database contents
(2 accounts, 2 characters), /avatar/1 resolves faction_id 0 to Terran
Republic, Leaderboard reads /top_kills and /top_outfits, every route
serves, and the client typechecks and builds clean.
Not ported: app/views/components.svelte (a 1,444-line dev-only style
guide) and UserList/CharacterList/RoleList, whose routes were already
commented out in the previous App.svelte.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… stack A multi-stage build (Debian/glibc base, so bcrypt uses its prebuilt binary rather than compiling): the build stage installs dependencies and runs the Vite build, and the runtime stage serves the result with the Express API. node_modules is installed inside the image, never copied from the host, so the native bcrypt binary matches the container platform. Also sets createTableIfMissing on the session store. The PSForever database is provisioned by the server's Flyway migrations, which do not include the portal's session table, so on a fresh database the portal now creates it on first run instead of failing on the first session write. The compose service that builds this lives in PSF-LoginServer's local-only docker-compose.override.yml, since its build context (../PSFPortal) does not exist upstream. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replaces the plain population list on the home page with the bar-graph widget from the previous portal (EmpireStats.svelte): a 220x200 black box titled "Empire %", three faction-gradient bars at 10%/40%/70% whose heights are percentage * 165px, a min-height stub so a 0% empire still shows a label, and the logo loop behind at low opacity. Layout constants are carried over verbatim. The bars animate up via a CSS height transition rather than the original's JS tween. On the home page the component renders first with zero data and then re-renders when /api/stats resolves, so the bars grow on load with no timer -- and the rendered height stays deterministic (unlike a requestAnimationFrame tween, which browsers pause for a backgrounded tab). Verified in the browser with a 2:1 NC:VS split: NC renders at 67% / 110px, VS at 33% / 55px, TR at 0% as a stub, positioned 10%/40%/70%, matching the reference. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The EmpireStats graph looked like the original PlanetSide widget -- a stark black box with white borders and a white header -- which clashed with the rest of the UI. Re-themed to match: the navy --background surface, a --border outline with a notched corner (clip-corner-md), a teal baseline and teal-edged faction bars, dropping the redundant internal "Empire %" header (the panel is already titled). The raw population counts now sit in a legend to the left of the chart, empire names in their faction primary colour and counts in the secondary, with a total. The bars keep their abbreviation and percentage. Verified in the browser with a 3/8/4 split: the legend renders left of the graph with the correct counts and total, the graph uses the theme background/border and a notched corner, and the bars read 20/53/27% at proportional heights with teal top edges. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The bars still used the original horizontal PlanetSide banner gradients (faction-*-bg), which read oddly on a vertical bar. Each bar is now a vertical gradient from the dim empire colour at the base to its bright colour near the top (as if charged upward), with the two top corners notched to match the panel geometry and a faction-coloured glow. The glow uses filter: drop-shadow rather than box-shadow so it follows the clip-path instead of being clipped away; per-empire --glow-tr/nc/vs tokens are added for it. Verified in the browser: each bar resolves to its authentic empire colours (TR 200,47,28 -> 232,71,47; NC 0,62,128 -> 47,127,232; VS 70,14,98 -> 139,92,246) with a matching drop-shadow, notched top corners, and proportional heights (20/53/27% for a 3/8/4 split). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The /api/stats population figures -- server status, the online player list, and the per-empire breakdown -- require a database lookup per online player, recomputed on every home-page load. Compute them at most once every POP_CACHE_MINUTES (default 5, env-overridable) and serve the cached snapshot in between. The cheap account/character counts stay live. Verified against the running Express instance: five rapid /api/stats requests trigger exactly one population recompute. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Clicking a continent name on the home page opens a modal with a custom <ContinentMap> SVG control: each facility's circular sphere-of-influence, coloured by its current controlling empire, over a facility legend. Data flow: - static/continents/mapNN.json (shipped assets) hold the per-continent facility layout (position, type, SOI radius) plus the extracted terrain grid, produced by scripts/build-continents.mjs from PSF's zonemaps and the raxicore-editor ContinentExport output. - GET /api/continents/:zone/control returns live base ownership from the DB building table, keyed by local_id. - The facility id is the zonemap MapID (== building.local_id), not the zonemap Id, so ownership matches. This is Phase 1 of the continent view: facilities + SOI + control state over a plain themed field. The continent terrain (water/land/lava) is extracted and shipped in the same assets but not yet rendered -- the MPO grid and PSF facility coordinates still need calibration (best naive fit is ~24/56 facilities on land) before terrain can sit correctly under the facilities. Verified end to end against the running stack: the Cyssor modal renders 56 SOI discs + 56 markers, 50 coloured TR and 6 neutral (towers/warpgates, which are not capturable), matching the DB. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Kimi <noreply@moonshot.cn>
The frontend moved to TypeScript when the portal was rebuilt on React; the server half stayed JavaScript. This finishes the job so both halves typecheck against the same interfaces -- notably the API response shapes the pages consume. Adds tsconfig.server.json (the server compiles under its own config, separate from the Vite/React one) and types/express.d.ts for the ambient request augmentations the session-based auth relies on (req.session_account, req.user). api/db.ts and api/stats.ts are not here: they were carried in independently by an earlier commit. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two independent gates guard a credential check, because they stop different attacks: a per-IP gate caps how fast one source can try anything at all, blunting credential stuffing and username enumeration from a single host; a per-username gate caps failures against one account regardless of source, so a distributed attempt spread over many IPs still cannot grind a single password. State is in memory, deliberately: the counters are worthless after a restart, and persisting them would add a database round trip to every login. Both maps are pruned on a timer and hard-capped, so a rotating-key attack cannot turn the limiter itself into a memory-exhaustion vector. Per-IP limiting is only as trustworthy as req.ip, so index.ts sets `trust proxy` to `loopback` -- a same-host reverse proxy is honoured, arbitrary X-Forwarded-For from the internet is not. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds the sanctuaries, battle islands and caverns alongside the ten continents already present (map11-13, map96-99, ugd01-06), plus footprints.json -- per-facility-type top-down outlines, so a facility is drawn at its true world size and orientation rather than as a circle at a nominal radius. The existing continents are regenerated in the same pass, picking up the finer masks the exporter now produces. Data comes from raxicore-editor's ContinentExport tool, which reads the client's own per-tile terrain mesh; build-continents.mjs turns its water/lava/floor masks into coastline geometry here (ocean-vs-lake classification, then marching-squares contouring, in scripts/lib). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
AdminPanel was a single component holding every administrative view. It becomes a shell -- a sidebar of subsections beside the active one, as nested routes under /admin so each is deep-linkable -- with the content split into src/pages/admin: accounts, avatars, continents, combat, the interstellar log and the admin log. Sections are grouped in the sidebar by which server they administer, since "change a login-server account" and "change live world state" are different enough acts to be worth separating visually. Also adds the supporting pieces these views need: a toast context for transient feedback, BarChart/EmpireIcons UI components, and two API modules -- randomize.ts (bulk facility reassignment) and gate-empires.ts (which empire sits behind each warp gate, for the lattice colouring rule). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signing in used to be worth nothing unless you were an admin. An ordinary player can now sign in and inspect their own characters, locker and loadouts. Nothing here changes state -- it is strictly a read-only view of what the world server already holds. Less new machinery than it looks: /api/login never checked GM, and NEED_SESSION already existed alongside NEED_ADMIN. Non-GM auth already worked; such a user simply had nowhere to go. The endpoints are gated by NEED_SESSION plus a per-request ownership check rather than trusting a caller-supplied character id. A character that does not exist and one owned by somebody else both answer 404, so the route cannot be used to probe which ids are real. GMs may read anyone, matching how /user/:user/profile already behaves. Locker contents are treated as private -- in a live game they are competitive intel -- so this is not a public build-sharing feature. Lockers and loadouts are stored as a single TEXT column, not rows: `/`-separated segments of ClassName,slot,objectId,ammoInfo written by the world server's AvatarActor. inventory.ts decodes that and resolves object ids to names. Only the first three commas are structural, so the remainder is rejoined -- a stray comma in the ammo field would otherwise shift the whole parse. Everything is laid out on the real equipment grid: exosuits.ts mirrors the server's own InventoryTile/GlobalDefinitionsExoSuit dimensions (Standard 9x6, Agile 9x9, Reinforced 12x9, Infiltration 6x6, MAX 16x12) and vehicleTrunks.json its per-vehicle trunk sizes. The slot offsets differ per container and are easy to get wrong: an exosuit's grid starts at slot 6, a vehicle trunk at 30, and a locker at 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Items in the locker and loadout grids were labelled boxes. They now carry artwork traced from the client's own inventory icons into flat vector shapes -- geometry, not pixels, so it stays crisp at any cell size and costs a few hundred coordinates per item. Everything is drawn as a silhouette: a solid outline with a faint fill, in currentColor so it follows the theme. At the size of an inventory cell a shaded miniature of a rifle turns to mush, whereas its outline stays readable. Interior line work -- the boundaries between shaded regions -- is stroked over that, which is what stops every weapon reading as one featureless blob. Ammunition additionally keeps the marking from its face, drawn more strongly than the box. Those glyphs are the only thing distinguishing one box of rounds from another, so a bare outline would make every box identical. data/items-english.json carries the names, types and grid footprints these grids need. Its description prose is deliberately stripped: that is verbatim client copy, whereas names and footprints are the factual identifiers the UI cannot render anything without. Regenerating with the export tool re-adds the prose, so it must be stripped again before committing a refresh -- see docs/client-assets.md. static/items/ stays gitignored: that is for locally-generated bitmaps, which are copied artwork rather than derived data. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two new administrative capabilities over live world state, both reaching the world server's HTTP admin API. Move Player, on a selected soldier: recall them to sanctuary, or send them to another continent. Targeted by name rather than GUID -- a GUID only means something inside one zone, and the player is about to leave it. Recall derives the destination from the player's own faction, so one action is correct for a mixed group; it is refused for someone already in their sanctuary, since the session treats that as a fault and disconnects them. Construction, a menu beside Enact: place a vehicle, a deployed AMS, or a deployed Router by clicking the map. Kept as its own menu because Enact's entries all act on something already in the world, and mixing a creation into a list of kills and captures invites the wrong click. An empire is always asked rather than inferred from the continent's owner, which would silently produce the wrong thing on a contested map. Only x/y are sent -- the world server derives ground height and the vehicle's attitude from the terrain. Failure modes are distinguished rather than collapsed: 400 for a bad argument, 409 when the world refuses a well-formed request (an AMS inside a facility, a player not logged in), and 503 only when the world server is unreachable. ActionMenu gains separator support for the menu's grouping. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four fixes to things that made the admin and player views awkward to use rather than wrong. Map labels no longer intercept clicks. A facility's name sits inside its clickable group, so without pointer-events:none the text swallowed clicks meant for the map underneath -- and the label carries a 12px stroke halo for legibility, so the dead zone was far wider than the glyphs looked. That is what blocked placing an orbital strike anywhere near a facility name. The loadout dialog no longer moves while you read it. Its item detail panel is docked to the side at a fixed height, so hovering an item cannot grow the dialog and re-centre it -- which used to shift the very grid the pointer was over. Descriptions longer than the panel marquee inside it (MarqueeText) instead of enlarging it, drifting up and back down so the overflow stays readable rather than clipped. Modals size to their content instead of scrolling. An inventory grid has a fixed, known size; clipping it behind a scrollbar hides the thing being shown. The Dockerfile copies data/ into the runtime image, so a container build has the item table rather than silently falling back to internal object names with only a log warning. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
app/, scss/ and webpack.config.cjs are the original portal; .eslintrc.cjs is its lint config, which targets ecmaVersion 2018 and could not parse a .tsx file even if eslint were still a dependency, which it is not. Nothing in the Vite build referenced any of it -- checked across every .ts/.tsx/.json/.html/.mjs/.css source plus index.html, vite.config.ts, both tsconfigs, the Dockerfile and .dockerignore. src/styles/globals.css already documents itself as the successor to planetside.scss. .dockerignore loses the two exclusions that existed only to keep these directories out of the image. Co-Authored-By: Claude <noreply@anthropic.com>
The log had one "Base" column carrying the facility name with the zone id squeezed in beside it, and the id was raw -- z1 rather than Solsar. Split into Continent and Facility. The world server reports a zone by its internal id, which is the vocabulary the rest of the API speaks, so the id is resolved in the browser against static/continents/index.json -- the file ContinentList already loads. All 23 zone ids it can report have a name there, including sanctuaries and caverns; an id the index does not know still renders as itself rather than disappearing. Co-Authored-By: Claude <noreply@anthropic.com>
The portal held its own PostgreSQL pool, which made two processes authoritative over the same data. They disagreed where it mattered: the portal read building.faction_id to colour a continent map while the login server held the faction the world was enforcing, and that write is asynchronous, so a map could show a base's previous owner. Login had the mirror problem -- Node's bcrypt emits $2b$ revisions the server's own checker rejects, so a password could pass here and fail in game. api/db.ts keeps its name and every exported signature, but issues no SQL: each function is now a call to a /portal/* endpoint. That kept the route modules unchanged. Facility ownership comes from /zones/control, which reads the live zones rather than the building table. api/session-store.ts replaces connect-pg-simple. The session table was the last thing requiring a pool. Reads fail soft -- an unreachable API means "signed out", not an error page -- while writes do not, because silently losing a session someone just created looks like a login that did nothing. get_sort and get_filter no longer build SQL fragments from Symbol column names; they emit plain tokens the login server whitelists again. pg, pg-error-constants, connect-pg-simple and bcrypt are gone, along with their @types. Nothing native remains, so the Dockerfile's Debian-base rationale no longer holds -- noted there rather than changing the base in passing. Two defects the typecheck caught on the way: get_locker_items was returning a row list where callers expect the blob, and stats.ts had three dead get_stats() calls that had quietly become network round-trips. api/continents.ts carries its share of the API rename in this commit rather than the next one. It holds a raw NUL as a map-key delimiter, so git treats it as binary and it cannot be split by hunk. One consequence worth stating plainly: the login server is now a hard dependency. Without it the portal serves static pages and nothing else. Co-Authored-By: Claude <noreply@anthropic.com>
It described a Svelte and webpack app on Node 13 that bootstrapped its own PostgreSQL schema from db/schema.sql -- none of which has been true for some time, and none of which is true at all after the data layer moved to the login server. Covers the Express 5 + React 19 architecture and the single data source, the /me player panel and its own-account-only boundary, the admin panel including Combat and Construction, the full interstellar event table, current environment variables, that Flyway owns the schema, the two generators, and troubleshooting for the failure modes this setup actually has. Co-Authored-By: Claude <noreply@anthropic.com>
Comments, log lines and two user-facing strings called it PSAdmin -- the raw-TCP protocol it replaced -- or the vaguer "admin API". "admin" meaning administration is untouched: the admin panel, the admin log, admin-only routes and the X-Admin-User header are a different concept that happens to share a word. Identifiers are untouched too: api/psadmin.ts, the admin_* helpers and the PSADMIN environment variable are set by existing deployments, and renaming them is a breaking change rather than a comment fix. Co-Authored-By: Claude <noreply@anthropic.com>
computeGateEmpires keys each lattice link as `${a}<NUL>${b}`, with the
NUL written into the source as a raw 0x00 byte. It works -- a NUL cannot
occur in either id, so it is a collision-free separator -- but a NUL is
how git recognises a binary file. Every diff of this file has read
"Bin 15977 -> 16566 bytes", so no change to it could be reviewed.
It is now the escape `\u0000`, which is the same string at runtime.
The binary detection had a second effect: git never applied this
repository's line-ending normalisation to the file, so it was the only
one stored with CRLF. Now that it is text it is stored with LF like
every other file, so this diff touches every line. To see the one
change that is not a line ending, use `git show -w --text` -- the
`--text` because the old side still reads as binary.
Co-Authored-By: Claude <noreply@anthropic.com>
Follows the login server: "PSF-HTTP API" replaces "PSF-Server HTTP API" in comments, log lines, UI text and the README. Text only. The PSADMIN variable and psadmin.ts keep their names -- deployments set the one, and the other is imported everywhere. Co-Authored-By: Claude <noreply@anthropic.com>
- Security headers via helmet: a strict content security policy (only Google Fonts allowed off-site), frame-ancestors none, no referrer leakage. upgrade-insecure-requests is sent only when the deployment is HTTPS -- sent over plain HTTP it makes the browser fetch every asset over a scheme the server does not speak, which shows as a white page on any address but localhost. - One HTTPS flag drives the secure-cookie flag and HSTS together; getting those two out of step is how a site locks itself out. - The session cookie is SameSite=Lax, which is what covers CSRF here: every state-changing route is a non-GET JSON call. - COOKIE_SECRET is required, and in production it must not be short or an obvious placeholder. - Request bodies are capped at 64 KB, and the bad-route telemetry endpoint strips newlines and truncates what it logs, so an unauthenticated caller cannot forge log lines. - Morgan logs in the combined format in production, which an operator can correlate with a reverse proxy's log. - Source maps are built only outside production. NODE_ENV is now set in the Docker build stage as well, so that check can see it. Co-Authored-By: Claude <noreply@anthropic.com>
Every public page reaches into the running game or its database, so the portal was an amplifier: request volume became world-server and database load one for one. Responses are now cached and coalesced. A miss is fetched once, however many requests are waiting on it, and served stale if the refresh fails. Upstream load becomes a function of the clock rather than of traffic: world state at 5 s, leaderboards at 60 s, per-character pages at 30 s. Measured locally, 60 concurrent cold requests cost one upstream round trip. A per-address rate limit (600 a minute, with a short lockout) is the backstop for what the cache cannot stop: a caller walking an id space to get a distinct, uncached answer every time. Co-Authored-By: Claude <noreply@anthropic.com>
A session can end while the page is open -- it expires, the server restarts, someone signs out in another tab. The page used to carry on as if still signed in, and every action failed with a bare error. The API now says why it refused: 401 with `session_required` when there is no session, and 403 with `admin_required` when there is one that may not do this. A 401 anywhere signs the app out and returns to the login page, which explains that the session ended. A 403 does not: that user is still signed in, just not permitted. The login, logout and user probes expect a 401 and are exempt. Co-Authored-By: Claude <noreply@anthropic.com>
The login server now authorises every call by the session that made it, so the portal has to say whose request it is serving. The session id is captured per request in an AsyncLocalStorage context, and every API call made while serving that request sends it as X-PSF-Session. Startup and the polling loop act on nobody's behalf and send none, so they reach only public endpoints. Login sends the session id with the credentials. The login server binds the session to the account in the same call that checks the password, and that is the only place a binding is ever written. The session is now regenerated BEFORE the password check rather than after. Regenerating after would destroy the session that was just bound and hand the browser a fresh, unbound one: the response would say the login succeeded, and the next request would be anonymous. It also closes session fixation, since an id known before login is never the one that becomes authenticated. X-Admin-User is still sent, but the audit log no longer believes it -- the API attributes an action to the account it resolved itself. Co-Authored-By: Claude <noreply@anthropic.com>
Three layout primitives, each used by more than one page: - DataTable can render a detail row beneath any row (renderExpanded) and mark rows active (isRowActive), so a selection opens in place rather than at the bottom of a long list. - Panel is a column whose body grows. Panels side by side in a grid are already stretched to the tallest one; without this the shorter one's content sat crowded at the top of an empty box. - A wide Modal takes the room it is given, up to 1600px, instead of shrinking to its content. The continent dialog had collapsed to a fraction of the screen. Co-Authored-By: Claude <noreply@anthropic.com>
It is where the live work happens -- continents, players, combat -- and Login Server Management is visited far less. Co-Authored-By: Claude <noreply@anthropic.com>
An Audit page for game masters to capture and study chosen packets. Opcodes are listed as "Name (ID)" for both the game and control tables, and colour-coded by layer: control, plaintext, or encrypted. Opcodes that carry a password or a session token are shown but can never be selected. Capture runs only while the page is collecting: leaving the page or pressing stop releases it, and the world server keeps nothing once the last viewer goes. Several admins share one capture. Selecting a packet opens it in place, under its row: the raw bytes, the server's decoding with its real field names, and a field designer for packets nobody has charted. A layout can be exported as a Scala case class for the login server, or opened as a Mermaid packet diagram in a dialog. Mermaid is loaded only when a chart is opened, with strict security, and the dialog is portalled to the page body so it covers the whole window rather than the content pane. The session's captures, selection and designs survive a page refresh; only Clear wipes them. A capture can be saved as RFC 4180 CSV. Co-Authored-By: Claude <noreply@anthropic.com>
Scheduling creates, enables, disables and deletes the login server's scheduled actions, each with a time trigger (HH:MM every N days), a population trigger, or both. The form is built from the action catalogue the login server serves, so an action added there appears here with no change to the portal. Services is its own sidebar section, below Login Server Management, because scheduling is not specific to accounts or to any one action. Co-Authored-By: Claude <noreply@anthropic.com>
A World Server Management page listing every squad the world server holds, with its leader, members and their faction, zone and health. A game master can disband a squad or remove one member. Both go through the squad service's own lifecycle on the server, so the game sees the same thing it would if a player had done it. Size is shown against the positions the squad has open, at most ten. Co-Authored-By: Claude <noreply@anthropic.com>
The Character panel gains an Edit button for game masters. It opens a dialog to set battle rank and command rank, or raw BEP and CEP. Setting a rank writes the experience that rank begins at, from the same table the rank is derived from, so the two cannot disagree. The change is applied by the world server, so an online character's rank, implant slots and uniform update immediately. Co-Authored-By: Claude <noreply@anthropic.com>
A public page beside Leaderboard: the weapons and vehicles with the most kills. The world server splits the counts, since only it knows which weapons belong to which vehicle; gun ids are named here from the client's own item tables. The two boards stay the same height side by side. Co-Authored-By: Claude <noreply@anthropic.com>
- Item shapes regenerate from the client's own art and now cover all 33 vehicles plus the mounted weapons and turrets (227 to 288 shapes). Vehicle icons were missing because a case-sensitive archive lookup failed silently and produced blank images. - The sanctuary maps now draw their spawn buildings (VT_building_*), which were missing from the list of sanctuary structures. - The facility list says "Facility" and "Gate" rather than the map data's own "major" and "minor". Co-Authored-By: Claude <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
A rebuild of the portal: React 19, TypeScript, Vite and Tailwind v4 in the browser, and Express 5 running TypeScript directly on Node 24 on the server. It replaces the Svelte/webpack project, which is removed in this PR.
The portal no longer connects to Postgres. Everything — public pages, admin actions, and its own session storage — goes through the login server's PSF-HTTP API, so the login server owns the data outright. That ends two real disagreements: the continent map coloured bases from
building.faction_idwhile the game enforced a different owner, and Node's bcrypt ($2b$) produced hashes the game server rejects.Depends on psforever/PSF-LoginServer#1399, which provides the API this portal uses. Merge and deploy that first — see Deployment.
37 commits, 297 files. Most of the size is the rewrite itself; the commits are ordered so the history reads as a sequence of reviewable steps.
For players
/me: players can view their own characters, locker and loadouts, drawn from the client's own item artwork.For game masters
The admin panel is split by area, with World Server Management first:
Security
HTTPSsetting.COOKIE_SECRETis required, and in production it must not be short or a placeholder.Load on the game server
Every public page used to turn request volume directly into game-server and database load. Responses are now cached and coalesced: one upstream read serves every waiting request, and stale data is served if a refresh fails. World state refreshes every 5 s, leaderboards every 60 s and per-character pages every 30 s. Locally, 60 concurrent cold requests cost one upstream round trip. A per-address rate limit backstops what the cache cannot cover.
Deployment
PG*) are no longer used.localhostthe page renders blank. That header is only sent whenHTTPS=true, so set it only when the site is actually served over HTTPS.PSADMINhost:portof the PSF-HTTP API — the portal's only data sourceCOOKIE_SECRETHTTPStruewhen served over HTTPS: enables secure cookies and HSTSPORT8080)TRUST_PROXYPOP_CACHE_MINUTESTesting
npm run typecheck(client and server) andnpm run buildpass on the tip and on each of the 14 most recent commits individually.Notes
api/continents.tsheld a raw NUL byte (a map-key separator), which made git treat it as binary and store it with CRLF. One commit escapes it and normalises the line endings. To see its single real change, usegit show -w --text..claude/launch.jsonis a local dev-server config and can be dropped.🤖 Generated with Claude Code