Repository navigation
PSF-HTTP API: an authenticated HTTP admin API for the web portal, plus game-side fixes - #1399
Open
GeekOfWires wants to merge 21 commits into
Open
GeekOfWires wants to merge 21 commits into
GeekOfWires wants to merge 21 commits into
Conversation
Two admin-driven world mutations need real ground height rather than a trusted caller-supplied z: an orbital strike's impact point, and a vehicle placed from a 2D map click. Both only ever know x/y. TerrainHeight reads a per-continent height grid from src/main/resources/terrain/<zone>.json (one file per continent, sanctuary, battle island and cavern -- 26 in total) and samples it by world coordinate. The grids themselves are exported from the client's own terrain mesh by raxicore-editor's ContinentExport tool, so they reflect the real geometry rather than an approximation. Co-Authored-By: Claude <noreply@anthropic.com>
The admin API can find a live Player through its zone, but it has no
handle on the typed AvatarActor that actually owns the session and
performs zoning -- only the player's own PlayerControl actor holds
that reference. This adds the relay path for three admin-initiated
actions that need to reach a live session rather than waiting for the
player's next login:
- SetModePermissions: GM/spectator permission changes take effect
immediately instead of only at next login (the split-brain the
portal's DB-authority migration exists to remove).
- ForceRecall: a forced sanctuary recall, driven through the exact
same SessionActor.Recall() message the player's own /recall
produces, so it is indistinguishable from a voluntary one.
- ForceZone: a forced transfer to a specific zone/position, driven
through SessionActor.SetZone(), the same message a CSR's /zone
command produces.
Player.<Message> -> PlayerControl (bridges to the typed actor) ->
AvatarActor.<Message> -> SessionActor.
Co-Authored-By: Claude <noreply@anthropic.com>
The individual command actors behind the admin HTTP service (wired up
in a following commit). Each wraps exactly the domain logic the game
already performs on itself, reached from outside a session:
- CmdListPlayers/Zones/Lattice, CmdCombatSnapshot: read-only queries
over live world state (who's online, per-zone lock/population,
facility lattice, a one-shot soldier/vehicle/deployable snapshot).
- CmdEntityAction: kill/kick a player, deconstruct/destroy a vehicle
or deployable, or hand a vehicle to another empire.
- CmdZoneAction: continent-wide actions (kill-all, kick-all, an
orbital strike -- hence OrbitalStrike's player becoming optional,
since an admin-called strike has no in-game commander to credit).
- CmdSetZoneFaction/SetBuildingFaction/SetZoneBuildings: force a
continent's or a facility's ownership live.
- CmdSetAccountBan/SetAccountGm: account state changes that also
reach a live session (kick on ban; see the AvatarActor relay).
- CmdSetAvatarPermissions/CmdPlayerTransfer: per-character GM/
spectator permissions and forced recall/zone-transfer, built on
the PlayerControl/AvatarActor relay from the previous commit.
AdminWorldOps holds the handful of world mutations shared across more
than one of these (kill via the ordinary damage path, kick, dismount,
evict occupants, orbital strike). CommandResponse defines the
Good/Error response types every command replies with.
Co-Authored-By: Claude <noreply@anthropic.com>
Places a mobile vehicle, an already-deployed AMS, or an already- deployed Router into a continent from a 2D map point -- the same "which server to talk to" and GUID-registration machinery the existing vehicle spawn pads use, reached from outside a session. Height and attitude are derived, not supplied: the caller only has an x/y from a map click, so ground height comes from the TerrainHeight resource and pitch/roll are computed from the local slope (sampled a few units either side of the point), so a vehicle sits flush with a hillside instead of floating or clipping through it. Yaw is left at zero -- a click implies no facing. An AMS is refused inside a facility's sphere of influence (an AMS belongs on open terrain); a Router is not, since a Router at a facility is a legitimate defensive placement. Placeable vehicles are a deliberate whitelist rather than every VehicleDefinition, since most non-whitelisted definitions are mounted turrets and wreck variants that make no sense to drop on a hillside. Co-Authored-By: Claude <noreply@anthropic.com>
The comment claimed a Router's second click places its telepad. The code only accepts and echoes telepad_x/telepad_y -- nothing constructs a TelepadDeployable or links it via TelepadLike.InitializeTelepadDeployable, so a Router placed this way has no working telepad. Recording that as a known gap rather than letting the comment overclaim what the previous commit shipped. Co-Authored-By: Claude <noreply@anthropic.com>
AdminHttpService owns the HTTP binding for its whole lifetime -- bound on preStart, unbound on stop, and any bind failure terminates the system exactly as the old TCP listener did. Each route reuses the command actors from the previous three commits (list/set/randomize/ transfer/place), so all the domain and database logic is shared, not reimplemented. Responses are the same json4s-serialised `data` maps the TCP protocol returned, so callers on the other side (the portal) only change transport, not shape. The API is server-to-server (the web portal calls it); there is no browser CORS. Wired into Server.scala in place of the old TcpListener/PsAdminActor, which is deleted along with the rest of the raw-TCP command set (CmdInternal, CmdListPlayers, CmdShutdown, PsAdminCommands) it drove. Co-Authored-By: Claude <noreply@anthropic.com>
CaptureFlagManager (LLU picked up) and HackCaptureActor (hack started; LLU delivered, lost, or destroyed; a CTF base's LLU bypassed; a timed hold completed or failed) now report each outcome to AdminHttpService.report, so the portal's interstellar log reflects base-capture activity as it happens rather than only the end state. `player` is only meaningful for acts performed at a control console (starting or clearing a hack) or carrying an LLU; a plain ownership change on a timer has no single responsible player, so it is reported blank rather than attributed to whoever happened to finish the timer. Co-Authored-By: Claude <noreply@anthropic.com>
The interstellar log's PortalDesignation entries reported whatever the request happened to carry: a bare numeric local id for a single facility, the literal string "continent" for a whole-continent designation, and an assignment COUNT for a bulk one. None of those is something a person reading a log can act on. CmdSetBuildingFaction already holds the Building, so it now returns the facility's own name and the audit hook prefers it. Where there genuinely is no single facility the entry says so -- "(whole continent)", or the number of facilities -- rather than reporting a count as if it were a name. Co-Authored-By: Claude <noreply@anthropic.com>
The portal held its own PostgreSQL pool. That made two processes
authoritative over the same data, and they disagreed where it mattered:
the portal read building.faction_id to colour a continent map while this
process held the faction the world was actually enforcing, and the write
behind a capture is asynchronous, so the map could show a base's previous
owner. Login had the mirror problem -- the portal hashed with Node's
bcrypt, which emits $2b$ revisions this server's checker rejects, so a
password could be accepted there and refused in game.
PortalQueries carries every query the portal used to issue, as raw SQL
decoded by Quill. The statements moved across verbatim: they are analytic
-- window functions, GROUP BY over computed columns, chained outer joins
-- and restating them as quotations would be a rewrite of working SQL for
no gain and a real risk of changing results silently.
Three things are deliberately different from what the portal did:
- passhash and password are never selected, and a login's IP address is
cut to its last two octets before it leaves this process. The portal
used to fetch both in full and strip them afterwards.
- sort and filter arrive as plain tokens and are matched against a
whitelist here. Neither is spliced into a statement: the ordering is
written as CASE expressions over a lifted key, which reads oddly but
is the price of not putting an identifier into SQL.
- facility ownership comes from the live zones (CmdListBuildingControl),
not the building table, so it cannot trail a capture.
The portal's session table moved too. It was the last thing requiring a
connection pool over there.
Co-Authored-By: Claude <noreply@anthropic.com>
The name kept drifting back to PSAdmin, which was the raw-TCP protocol this replaced, or to the vaguer "admin API". Both are now the one name. Historical references to the PSAdmin protocol keep it -- naming what was replaced is what makes the rename legible -- and "admin" meaning administration is untouched: the admin panel, the admin (audit) log, admin-only routes and the X-Admin-User header are a different concept that happens to share a word. The config also described a listener the server no longer has. bind applies to the game sockets, and the admin port is HTTP, not TCP framing. The `admin` config key itself is deliberately unchanged: deployments set CONFIG_FORCE_admin_bind, and renaming it would break them for no gain. Identifiers are untouched for the same reason -- AdminHttpService and the config key are load-bearing. Co-Authored-By: Claude <noreply@anthropic.com>
The last rename settled on "PSF-Server HTTP API". The shorter "PSF-HTTP API" is the name the portal, its logs and the docs use, so the server now says the same thing in its comments, log lines and config notes. Text only. Identifiers and the `admin` config key are unchanged, for the same reason as before: AdminHttpService and CONFIG_FORCE_admin_bind are load-bearing. Co-Authored-By: Claude <noreply@anthropic.com>
docker-compose.override.yml is merged by `docker compose` automatically, without being named anywhere. That is what makes it the right place for host-specific addresses, published ports and dev secrets -- and exactly how it gets committed by accident. .env is the same hazard: compose reads it from this directory for variable substitution, so it is where local secrets naturally end up. Co-Authored-By: Claude <noreply@anthropic.com>
A character's first login writes its default certifications and its medkit shortcut. "First login" is inferred from `created` matching `lastLogin`, and `lastLogin` is only written once a login completes. A first login interrupted after those inserts -- a disconnect, a restart, a slow query -- leaves the rows written and the timestamps still equal. The next attempt takes the same branch, the inserts violate certification_pkey and shortcut_avatar_id_slot_key, the whole future fails, and the client is answered with ActionResultMessage.Fail. That state cannot recover by itself: the login has to succeed to update `lastLogin`, and `lastLogin` is what would stop it retrying the failing insert. The character is permanently unloadable. Both inserts now ignore a conflict, so the initialisation can safely run again. Co-Authored-By: Claude <noreply@anthropic.com>
A client that misses a SlottedMetaPacket asks for it again (RelatedA), and the server answers from a history of the packets it sent. Once a subslot falls out of that history the client can never be answered about it: its reliable stream stalls for good. The session stays up and locally predicted actions -- swapping weapons -- still look fine, but anything needing a round trip on that channel stops. Reloading is the one players notice. psforever#1384 made the history configurable (smp-history-length, default 1024) and the lookup null-safe. The cause still remained. set_zone_buildings applied every facility in a zone in one pass, and each one broadcasts several packets to every client; a continent carries around thirty. Changing several continents back to back is a burst that can walk past any fixed window. set_zone_buildings now applies a zone's facilities five at a time, 100 ms apart, and recomputes the zone lock 750 ms after the last batch rather than the first. A forty-facility continent finishes in about 1.5 s, well inside the HTTP bridge's 8 s timeout. The command actor now stops itself, since it outlives its first message. The RelatedA lookup no longer scans the history. Packets are filed at the slot their own subslot dictates and found by masking, so a resend request costs the same at 1024 entries as at 100 -- and resend requests arrive in bursts, when the session can least afford a scan. Keying on the subslot also replaces a separate rolling cursor, which drifted from the subslot count at every 16-bit wrap whenever the length did not divide 65536. Masking needs that divisibility, so smp-history-length must now be a power of two. Config checks it at startup and refuses to run otherwise, the same way it treats any other unreadable setting: a wrong value would not fail loudly, it would quietly answer resends with the wrong packet or none. Co-Authored-By: Claude <noreply@anthropic.com>
PortalQueries arrived as raw SQL moved verbatim from the portal. Most of it was plain single-table reads that the rest of this codebase writes as quotations against persistence.*, and raw SQL has real costs here: a column alias that does not match its field fails at runtime, not compile time, and this driver counts any literal `?` -- even inside a comment, even the jsonb operator -- as a bind placeholder. Those reads are now quotations. Raw SQL remains only where a quotation cannot reasonably express the statement: the leaderboards and outfit summaries (window functions, CTEs, GROUP BY over computed columns), the accounts listing, and the session store's JSON handling. The wire format is unchanged. Timestamps used to be formatted by Postgres via ::text; a quotation decodes a LocalDateTime, whose toString would put a `T` where the browser has always seen a space. pgTimestamp reproduces Postgres's own format. The one loss is precision: Joda carries milliseconds, so `.089649` now reads `.089`. These are only ever displayed as dates. Counts now come back as a Long from `.size`, so the Count row type is gone and the API's pager takes the number directly. Sort orders are composed with sortBy instead of CASE over a lifted key, which removes the injection question rather than defending against it. The account row's projection is part of the query, so passhash and password are never read out of the table at all. Co-Authored-By: Claude <noreply@anthropic.com>
The API trusted whatever called it. X-Admin-User, the only notion of a caller, is a label the caller writes itself. Nothing checked game-master status, and one player's account, characters and loadouts were readable by anyone who could reach the port. The portal now forwards the browser's session id as X-PSF-Session. This process owns the session table, so CallerAuth resolves that id to an account and reads the account's real privileges from the database. Nothing about the caller is taken from a header: the header carries an opaque id, and every fact behind it comes from a row here. An expired session resolves to nobody, the same way the session store's own read treats it. - Every state-changing route goes through auditedRoute, which now requires a game master. A route added later cannot forget the check. The audit entry names the verified account, not the claimed label. - An account's own data -- its record, logins, characters, locker and loadouts -- is served to that account or to a game master. - Listings, the combat snapshot and role data are game-master only. - Leaderboards and other public statistics stay public. A session is bound to an account in exactly one place: the password check, which receives the session id to bind. sessionSet strips account_id from whatever the caller stores, so storing a session cannot name its owner. Without that split, anyone able to store a session could make themselves a game master. The session routes themselves need no caller check: the session id in the path is the credential, just as the cookie carrying it is. Co-Authored-By: Claude <noreply@anthropic.com>
Game masters can pick game or control opcodes, by number, and watch those packets pass in either direction with their raw bytes and the server's own decoding. A packet that fails to decode is kept too, with its bytes and the error -- for an opcode nobody has charted, those bytes are the only evidence of what it carries, and they used to go no further than a log line. Decoded packets carry their real field names from their own case class (productElementNames, not reflection), for the portal's class designer. Capture exists only while somebody is watching. Each viewer holds a lease, renewed while the page is open; when the last one lapses or leaves, capture stops and everything held is dropped. Viewers share one capture rather than each starting their own: a watcher reads forward from its own cursor, and reading does not consume. The hot path is built so players do not pay for this: - idle, it costs one volatile boolean read per packet; - armed, an opcode is one array lookup into a 256-entry mask; - outbound packets reuse the bytes `out` already encoded, never a second encode on the connection's thread; - retention, per-packet bytes and per-second rendering are capped (1000 packets, 2048 bytes, 200/s). Opcodes 0x01-0x03 carry the account password and session tokens. They can never be armed, whoever asks and however. Unimplemented opcodes now fail with a shared NoDecoderMessage, so an uncharted packet can be told apart from a malformed one. Co-Authored-By: Claude <noreply@anthropic.com>
POST /avatars/{id}/progress sets battle and command experience.
Rank decides implant slots and uniform, so changing it should behave as
it does in play. For a character that is online, the change goes through
the avatar's own experience path: a lowered rank removes implants it no
longer supports, and a raised one takes effect now rather than at the
next login. Battle experience is applied as a delta, because that path is
additive; command experience is a total. For a character that is offline,
the row is written directly. Never both.
Co-Authored-By: Claude <noreply@anthropic.com>
GET /portal/leaderboard/top-weapons serves two public boards: kills by gun, and kills by vehicle. The counts come from killactivity rather than weaponstat. Only killactivity is written by a kill; weaponstat is flushed per session and is empty on a server that has never flushed it, which would look like nobody had ever fired a shot. `exp > 0` matches topKills, so team kills and suicides cannot lift a weapon up the board. A kill record names the weapon but not what the killer was riding. The split is made here because it needs the vehicle definitions: each mounted weapon is credited to the vehicle that carries it, by inverting VehicleDefinition.Weapons. Co-Authored-By: Claude <noreply@anthropic.com>
GET /squads lists every squad the world server holds, with its members. Game masters can also disband a squad or remove one character from whichever squad holds them. These run in the squad service's own message loop, reusing DisbandSquad and LeaveSquad. The channel, the faction listing and an emptied squad closing itself all happen exactly as they would in game, and squad state is never touched from outside the actor that owns it. The listing names online players and their positions -- the same class of information as the combat snapshot -- so it is game-master only. Both actions are audited. Co-Authored-By: Claude <noreply@anthropic.com>
Game masters can schedule an action to run at a time of day every N days, when the online population crosses a threshold, or both. The actions are applying a continent layout and giving a continent to an empire. Schedules are evaluated here, not in the portal: a schedule that fires at four in the morning needs a process that is up at four in the morning, and this is the one that is up whenever the game is. Zone numbers are mapped to zones from the live zone list, which this process has had all along. - A condition fires on a crossing, not a state. "Below 10 players" is true all night; the stored `armed` flag clears when it fires and resets only once the population goes back the other way. - A time trigger that was missed -- the server was restarting -- still runs up to an hour late, but not twelve hours late. - Claiming a run is atomic, so two processes can never both perform it: against the due instant for time, and by flipping `armed` for a condition. - A layout covering many continents starts them 1.5 s apart. Each continent already paces its own facilities; starting all of them at once would put the total broadcast rate back where it was. The portal builds its form from an action catalogue served here, so a new action needs no portal change. V020 drops the action CHECK so that adding an action is a row, not a migration. Co-Authored-By: Claude <noreply@anthropic.com>
This branch has not been deployed
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
This replaces the raw-TCP PSAdmin protocol with an HTTP API (the "PSF-HTTP API") and moves the web portal's data access into this process, so the portal no longer needs a database connection. On top of that it adds the server side of several new portal features and fixes two bugs that affect players.
It is a companion to psforever/PSFPortal#55, which is the portal that uses this API. Merge and deploy the two together, this one first — see Deployment below.
It is 21 commits, each of which compiles and stands on its own, so it can be reviewed one commit at a time. The branch is rebased on current
master(cacebf6, #1397) and merges with no conflicts.Fixes that affect players
set_zone_buildingsnow applies facilities five at a time, 100 ms apart, instead of in one burst. The resend lookup becomes a direct index instead of a scan, which requiressmp-history-lengthto be a power of two (see Configuration).The PSF-HTTP API
An Akka HTTP service (
AdminHttpService) on the same port and bind address as the PSAdmin listener it replaces (51002,127.0.0.1by default). Each route reuses the existing command actors, so domain and database logic is shared, not reimplemented.X-PSF-Session.CallerAuthresolves it against the session table and reads the account's real privileges. Nothing about the caller is taken from a header. State-changing routes require a game master; an account's own data is served to that account or a game master; public statistics stay public. A session is bound to an account in one place only: the password check.PortalQueries), mostly as Quill quotations againstpersistence.*. Raw SQL remains only for window functions, the accounts listing and the session store's JSON handling.New features (server side)
portalscheduletable.Deployment
Breaking: the raw-TCP PSAdmin protocol is gone. Port 51002 now speaks HTTP. Anything that talks to PSAdmin stops working, including the current production portal (1.0.1), whose admin actions use it. Deploy psforever/PSFPortal#55 immediately after this; that portal reads everything through this API.
V019__PortalScheduleandV020__PortalScheduleActions(new table only; nothing existing is altered). Flyway applies them on start.src/main/resources/terrain/adds 26 per-continent height grids (8.8 MB), alongside the existing zone maps.CONFIG_FORCE_admin_bind=0.0.0.0is needed — on a network the internet cannot reach.Configuration
admin.port/admin.bindnetwork.middleware.smp-history-lengthTesting
psforever/Test/compile,server/compile).PacketCaptureTest(15) andTopWeaponsTest(6). Existing control-packet specs (59) pass.masteralone gives 1947 tests and 51 failures in the same timing-sensitive actor specs. Those specs are flaky: six that failed onmasterpassed here. One spec that failed only here (ExplosiveDeployableJammerTest, a 200 ms timeout under load) passed five times out of five when run alone.Known gaps
TelepadDeployable.randomize_continentsstays in the portal for now. It has not been moved into this process.🤖 Generated with Claude Code