Skip to content

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
psforever:masterfrom
GeekOfWires:master
Open

GeekOfWires wants to merge 21 commits into
psforever:masterfrom
GeekOfWires:master

Conversation

@GeekOfWires

@GeekOfWires GeekOfWires commented Oct 4, 2026 •

Copy link
Copy Markdown
Contributor

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

  • Clients lock up after continent-wide changes (Pace continent broadcasts and index the resend history). Changing a continent's facilities broadcasts several packets per facility to every client. Several continents back to back pushed packets out of the resend history before a client could ask for them again, and that client's reliable stream stalled for good. Players could still swap weapons, but could no longer reload. Fix client instability / crashes when zoning frequently #1384 already made the history configurable and the lookup null-safe; this adds the missing half: set_zone_buildings now 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 requires smp-history-length to be a power of two (see Configuration).
  • A character can become permanently unloadable (Make first-login initialisation repeatable). If a first login is interrupted after the default certifications are written, every later attempt fails on a primary-key violation, and the character can never load. Both inserts now ignore a conflict.

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.1 by default). Each route reuses the existing command actors, so domain and database logic is shared, not reimplemented.

  • Authorisation comes from the caller's own session. The portal forwards its browser session id as X-PSF-Session. CallerAuth resolves 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.
  • The portal's queries now run here (PortalQueries), mostly as Quill quotations against persistence.*. Raw SQL remains only for window functions, the accounts listing and the session store's JSON handling.
  • Admin world actions: kill/kick players, deconstruct/destroy vehicles, transfer players, set facility and continent ownership, place a vehicle / deployed AMS / deployed Router from a map point, orbital strikes. Placed vehicles and strikes take their ground height from a shipped per-cell terrain resource rather than a caller-supplied z.
  • Interstellar log: capture and LLU events are reported as they happen.

New features (server side)

  • Packet Review — game masters choose opcodes to capture, live, with raw bytes and the server's decoding. Capture only runs while someone is watching. When idle it costs one volatile read per packet, and it is bounded when active. Opcodes carrying passwords or session tokens (0x01–0x03) can never be captured.
  • Scheduled actions — apply a continent layout or give a continent to an empire, at a time of day every N days and/or when the population crosses a threshold. Evaluated in this process, claimed atomically so it never runs twice. Stored in a new portalschedule table.
  • Set a character's experience — battle and command experience; applied through the avatar's own experience path when the character is online, so rank, implants and uniform update immediately.
  • Squad administration — list squads, disband a squad, remove a member; runs inside the squad service's own actor.
  • Top weapons and vehicles by kills — a public leaderboard; mounted weapons are credited to the vehicle that carries them.

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.

  • Migrations: V019__PortalSchedule and V020__PortalScheduleActions (new table only; nothing existing is altered). Flyway applies them on start.
  • Resources: src/main/resources/terrain/ adds 26 per-continent height grids (8.8 MB), alongside the existing zone maps.
  • Keep 51002 private. As before, it binds to loopback by default. If the portal runs in another container, CONFIG_FORCE_admin_bind=0.0.0.0 is needed — on a network the internet cannot reach.

Configuration

Key Change
admin.port / admin.bind Unchanged names and defaults; now serve HTTP.
network.middleware.smp-history-length Must now be a power of two (the default, 1024, is). Startup fails with a clear message otherwise; a non-power-of-two would silently answer resend requests with the wrong packet.

Testing

  • Every commit compiles individually (psforever/Test/compile, server/compile).
  • New specs: PacketCaptureTest (15) and TopWeaponsTest (6). Existing control-packet specs (59) pass.
  • Full suite: 1968 tests, 43 failures. Current master alone gives 1947 tests and 51 failures in the same timing-sensitive actor specs. Those specs are flaky: six that failed on master passed here. One spec that failed only here (ExplosiveDeployableJammerTest, a 200 ms timeout under load) passed five times out of five when run alone.
  • Most of this ran on a local server with game clients and the new portal before the rebase. The broadcast pacing and the rebased build have not yet been played on.

Known gaps

  • A Router placed through the API has no telepad yet; the coordinates are accepted but nothing builds the TelepadDeployable.
  • randomize_continents stays in the portal for now. It has not been moved into this process.

🤖 Generated with Claude Code

GeekOfWires and others added 21 commits October 3, 2026 19:46
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

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant