This describes the 0.5.0-alpha.1 website API. Use it when building or redesigning a frontend.
site/public/assets/js/api.js (the window.ForgeGuard client) and console.js are complete working
references; site/netlify/functions/{auth,servers,link,purchases,capture}.mjs and site/netlify/lib/routes.mjs are the
source of truth.
Architecture
Minecraft server: ForgeGuard plugin
-> outgoing HTTPS polling -> /api/link/device/* (device credential, one workspace)
Browser: same-origin session requests
-> /api/auth/* accounts and sessions
-> /api/purchases/* purchase ownership verification (provider exchange stays server-side)
-> /api/servers/* workspaces, members, pairing, live control, audit
-> /api/capture/* player-enabled screenshots, scoped recorder and staff controls
Netlify Functions + persistent Netlify Blobs
One deployment serves any number of accounts and servers. Each account can belong to many server workspaces; each workspace links to at most one Minecraft server. The browser never connects to a Minecraft port and never receives the plugin credential.
Real screenshots use the separate player-enabled browser flow documented in Client screen capture. Staff controls live under ForgeGuard.capture(serverId, playerId); the player page uses a short-lived recorder bearer, never a staff cookie. The in-game GUI can send an invitation or request a screenshot; the web console displays the resulting image. This does not give the server access to unconsenting clients or provide tamper-proof video.
Request conventions
Send JSON with Content-Type: application/json, Accept: application/json, credentials: "same-origin",
cache: "no-store", redirect: "error". Errors are { "error": "machine_code", "message": "…" } with a
non-2xx status: show message. The server sets an HttpOnly __Host-fg_session cookie; never
read or store it. Session responses include csrf: keep it in memory and send it as
X-ForgeGuard-CSRF on every mutation. Cross-origin requests are refused. On 401, end the signed-in UI.
Accounts — /api/auth
| Endpoint | Body | Result |
|---|---|---|
GET session |
— | Session payload, or {authenticated:false, reason, registration, setupRequired, setupTokenConfigured, passwordMinimum} |
POST signup |
{email,name,password,inviteCode?} |
201 session payload. Refused when registration is closed; invite mode requires a matching invitation |
POST login |
{email,password,code?} |
Session payload, or {authenticated:false,twoFactorRequired:true} |
POST logout |
{} |
{authenticated:false} |
POST bootstrap |
{setupToken,email,name,password} |
201; only for invite/closed deployments with no accounts |
PATCH account |
{name} |
{user} |
POST account/delete |
{password} |
Deletes the account; 409 owns_shared_server if it is the only owner of a workspace with other members |
POST password |
{currentPassword,newPassword} |
Replacement session; other sessions end |
GET sessions / POST sessions/revoke |
— / {} |
List / sign out other devices |
POST totp/start · totp/enable · totp/disable |
{} · {code} · {password} |
Two-factor enrolment; recovery codes are shown once |
GET audit?limit= |
— | This account's own security log |
Session payload: {authenticated:true, user:{id,email,name,status,createdAt,lastLoginAt,twoFactor,recoveryRemaining,mustChangePassword}, csrf, expiresInSeconds, absoluteLifetimeSeconds, idleTimeoutSeconds}.
Accounts have no site-wide role.
Purchase verification — /api/purchases
GET /api/purchases returns the authenticated account's
{required,providers,entitlement,canCreateServers}. Start BuiltByBit verification with
POST /api/purchases/builtbybit/start {} using the normal session and CSRF header; the response is
{authorizationUrl,expiresAt}. The OAuth callback is handled by the backend. Send customers to
purchase.html for the complete flow, including its same-origin continuation step. A callback query
parameter or a claimed receipt is not proof of ownership; retrieve the saved entitlement from the API.
When the purchase gate is enabled, server creation and pairing require a verified purchase. Connected
control uses the workspace purchaser's entitlement; invited staff do not each need a separate purchase.
This does not grant membership or additional capabilities. Show a link to purchase.html on
purchase_required, and explain provider configuration or verification failures instead of granting
access. Seller credentials and the real product resource ID are still deployment requirements.
See Purchase ownership verification (../site/PURCHASE-VERIFICATION.md in the release documentation) for the exact provider response
shapes, setup, license rechecks and remaining live storefront acceptance checks.
Workspaces — /api/servers
| Endpoint | Capability | Result |
|---|---|---|
GET /api/servers |
signed in | {servers:[summary], roles:[{role,label,capabilities}]} |
POST /api/servers {name} |
signed in | 201 {server:summary} (max 25 owned) |
GET invites/probe?code= |
none | {serverName,email,role,expiresAt} |
POST invites/accept {code} |
signed in, e-mail must match | {server,message} |
GET <id> |
member | {server:summary} |
PATCH <id> {name} · DELETE <id> |
link | Rename · delete workspace (revokes the link) |
GET <id>/members |
members | {members:[{userId,name,email,role,twoFactor,lastLoginAt,you}], invites:[…], roles} |
POST <id>/members {email,role} |
members | 201 {invite, inviteCode, accountExists}: the link is /login.html?invite=<code>, shown once |
PATCH <id>/members/<userId> {role} |
members | Last owner protected |
DELETE <id>/members/<userId> |
members, or yourself | Remove or leave |
DELETE <id>/invites/<inviteId> |
members | Revoke |
GET <id>/link |
member | Link view (below) |
POST <id>/link/claim {code} · POST <id>/link/unlink {} |
link | Pair / unpair |
GET|POST <id>/control/<route> |
per route | Live control (below) |
GET <id>/operations/<requestId> |
the requester, or an owner | Poll a queued operation |
GET <id>/audit?limit= |
audit | Workspace audit log |
A server summary is {id,name,createdAt,role,capabilities,link:{state,online,server,lastSeenAt},players,cases}.
A non-member receives the same 404 as a missing workspace.
Roles and capabilities (per workspace): owner = read write moderate inventory.view inventory.edit members audit link;
admin = read write moderate inventory.view inventory.edit audit; moderator = read moderate inventory.view; viewer = read.
Render controls from the returned capabilities; the API enforces them independently.
Pairing
- The member runs
/fg link site <this origin>in Minecraft, then generates a code in Link web console. - An owner
POST <id>/link/claim {code}→ 202 withstate:"awaiting_approval". That is not linked yet. PollGET <id>/linkabout every five seconds. - The Minecraft administrator approves the claimant in-game. The website cannot approve itself.
state:"active", online:true→ loadGET <id>/control/status. Allow a short “starting” period (503 link_starting) until the first status upload.
Link view: {state, server:{id,name,serverVersion,pluginVersion}, siteOrigin, lastSeenAt, statusAt, online, claimedAt, expiresAt, claimant:{email,name}}.
unlinked returns {state:"unlinked", server:null, siteOrigin, lastSeenAt:null, online:false}.
Live control and queued operations
Everything except status is queued for the plugin. The first response is
202 {requestId, status:"queued", expiresAt, pollAfterMs}. Poll GET <id>/operations/<requestId> until it
is no longer 202; the final response carries the plugin's own status and body (for example 409
slot_changed). Never show success on 202, never resubmit a write automatically, and after a 504
operation_expired refresh before offering a retry. There are at most four pending operations per workspace.
| Route | Method | Capability | Body → result |
|---|---|---|---|
status |
GET | read | Latest snapshot (below), served from the heartbeat |
cases/<uuid> |
GET | read | Full case record |
cases/<uuid>/replay |
GET | read | {caseId,check,replayable,reproduced,tracePackets,message} from the real replay engine |
cases/<uuid>/recording |
GET | read | Saved visual gameplay clip for this case, or 404 when none exists |
recordings/<uuid> |
GET | read | Saved visual gameplay clip or capture-in-progress summary |
players/<uuid>/recordings |
GET | read | {recordings:[summary]}; retained manual and automatic clips |
players/<uuid>/recordings |
POST | moderate | {recordingId:<stable UUID>,reason:<plain text 1–120 chars>} → capture receipt with id and status |
catalogue |
GET | read | {minecraft, items:[{id,maxStack,maxDurability,edible,block,blocked?}], enchantments:[{key,max}]} |
bans |
GET | read | {bans:[{playerId,playerName,reason,createdAt,expiresAt,byConsole}], total} |
players/<uuid> |
GET | read | Player details: online, world, position, health, food, ping, gameMode, frozen, protected, effects, ban, cases… |
players/list |
GET | read | {players:[{id,name,online,lastSeen,firstPlayed}],total,onlineCount,offlineCount,truncated,limit} |
players/<uuid>/deliveries |
GET | inventory.view | {deliveries,total,truncated}, latest 20 receipts |
players/<uuid>/deliveries |
POST | inventory.edit | {deliveryId,items:[{id,count,name?,lore?,enchantments?,damage?,unbreakable?}],note?} → individual delivery receipt |
players/<uuid>/travel |
POST | moderate | {action:'teleport',destination:<online-player-uuid>,reason} or {action:'return',reason} |
players/<uuid>/inventory |
GET | inventory.view | {player, loadedAt, editable, inventory:{slots[41], crafting[4]}, enderChest:{slots[27]}} |
players/<uuid>/inventory |
POST | inventory.edit | Edit (below) → the fresh inventory plus applied |
players/<uuid>/moderate |
POST | moderate | {action, reason, seconds?} → {message, player} |
config |
POST | write | {revision, config} → {revision, mode, config} |
reload |
POST | write | {revision} → same shape |
Slots follow Bukkit: 0–8 hotbar, 9–35 storage, 36 boots, 37 leggings, 38 chestplate, 39 helmet, 40
offhand; the Ender Chest has 0–26. Each slot is {slot, fingerprint, id?, count?, maxStack?, name?, lore?, enchantments?, damage?, maxDamage?, unbreakable?, custom?, display?}.
custom:true means the stack carries data the web cannot express; moving or re-counting it preserves it.
display is optional, read-only Minecraft tooltip metadata from the modern core. It contains the
effective name component, styled lore components, rarity, and optional hideTooltip,
hideEnchantments, hideUnbreakable, hideLore and truncated flags. Components permit text,
translate, fallback, with, extra, RGB color and boolean text decorations; explicit false
must override inherited styling. Click/hover events, arbitrary NBT and custom fonts are not sent.
The display is bounded to 32 lore lines and 24 KiB per item, with bounded component depth/nodes/text.
Keep these components separate from the clipped, plain editable name/lore fields, and never
include display in an edit or delivery: the server rejects it. Older plugins without this field
still receive a readable plain-text preview. The renderer and asset limits are documented in
site/MINECRAFT-TEXT.md.
Directory and feature availability
players/list reads the Minecraft server's known player registry, including offline players who have
joined before. Results put online players first, then sort by name and UUID. The response contains at
most 1,000 players; total, onlineCount and offlineCount describe the complete registry, while
truncated:true means the returned list is incomplete. Do not turn an absent entry into a claim that
the player does not exist. The console retains direct UUID lookup. lastSeen and firstPlayed are
milliseconds; a zero timestamp means no recorded value. Offline inventory editing is not supported:
offer an item delivery rather than attempting to edit offline player files.
The status snapshot advertises web.playerDirectory, web.deliveries, web.travel and
web.caseReplay. Require the relevant value to be explicitly true, as well as the workspace
capability, before offering the new operation. A missing flag means the plugin has not reported
support. deliveries follows the server's inventory-edit policy, and travel follows its moderation
policy. The plugin rechecks those settings and player protections when executing the request.
Direct item deliveries
The POST delivery payload describes new stacks to add, not slot edits or console commands. Supply
1–36 stacks, each with an explicit item ID and count, and an optional plain-text note of at most
200 characters. copyOf, raw NBT and arbitrary item fields are refused. The inventory metadata
limits below also apply; actual items and stack sizes must be valid on the linked Minecraft server.
The plugin preserves existing items and does not drop overflow on the ground. An offline player can
receive a queued delivery when they next join.
An individual receipt is
{deliveryId,playerId,status,createdAt,updatedAt,items,note,actor,detail}; POST also includes a
human-readable message. History returns the latest 20 receipts, with total and truncated.
Receipt timestamps are milliseconds. The plugin permits at most 100 pending deliveries per
player and 10,000 retained receipts overall; delivery_capacity requires waiting for pending
work or an operator-reviewed capacity migration. Do not delete receipts to bypass these limits,
because they preserve duplicate protection.
| Receipt status | UI meaning |
|---|---|
queued |
Accepted and waiting for the player or another safe attempt; not delivered yet |
applying |
Minecraft is processing the transfer; check status without issuing a replacement |
delivered |
Minecraft confirmed the item transfer and player-data save |
blocked_full |
No sufficient inventory space; existing items were preserved |
blocked_protected |
Player protection currently prevents the transfer |
blocked_policy |
Server policy or item support currently prevents the transfer |
needs_review |
The outcome cannot be proved after an interrupted or failed transfer; staff must inspect it |
The plugin retries pending work on join and about every 30 seconds while the recipient is online.
Blocked states remain pending; delivered and needs_review are terminal. Never present queued,
applying, a relay 202, or an HTTP success alone as an in-game delivery confirmation. Display the
receipt status and detail. In particular, needs_review must not trigger automatic redelivery or a
new delivery ID.
Generate a fresh UUID once per intentional delivery, before submitting it. Preserve that UUID
and the exact payload when a response is lost, the relay expires or the browser navigates away.
Check history first; if a retry is necessary, send the same UUID, player UUID and contents. Minecraft
returns the existing receipt for an identical request. Reusing an ID for another player or changed
contents returns 409 delivery_conflict. The relay's requestId and the delivery's deliveryId
serve different purposes: poll a known relay request without posting again; the delivery ID protects
an explicit retry when the transport outcome is uncertain.
The reference console stores pending delivery IDs and payloads in session storage scoped to account, workspace and recipient, and locks submitted contents. It allows editing after a definite HTTP 400 input rejection, but retains uncertain requests for status checks or exact retries. It also waits for room in the four-operation relay queue before posting a delivery, so opening a profile and loading its catalogue, inventory and history cannot immediately crowd out the write.
Visual gameplay recordings
These routes are separate from cases/<uuid>/replay, which reruns a deterministic check.
Only expose the recording controls when status.web.recordings === true. Older builds and the
portable staff adapter do not acquire a recorder through a website update. See
Gameplay recordings for the capture and runtime limits.
A manual capture is accepted only for an online, unprotected player, with the moderate web
capability and the plugin's policy enabled. The browser creates recordingId once and keeps the
exact UUID and reason for any retry. Reusing that ID with a different payload is refused. Do not
invent a second ID after an uncertain response. A queued relay response still requires the usual
operation polling; a final plugin response with status:"recording" means capture/persistence is
still underway, not that a playable clip has been committed.
Poll recordings/<uuid> at a bounded interval until its status is ready or partial, or let staff
return to the recording list. Handle capacity, unavailable recorder, expired records, server
disconnects and failed persistence as errors. The reference console automatically checks pending
clips for up to a minute and then offers a manual refresh. Never turn a missing clip into synthetic
footage. Old case JSON remains usable even when no recording exists.
Version-1 clips contain schema, id, optional caseId, playerId, playerName, source, status,
reason, createdMillis, startMillis, triggerMillis, durationMillis, frames, scenes, events,
limits and warnings. Wall-clock fields are milliseconds since epoch. Frame/scene/event t values
are milliseconds relative to startMillis; the trigger marker is triggerMillis - startMillis.
Frames carry the observed player pose and nearby entity snapshots. Scenes carry a world UUID,
integer origin:[x,y,z], size:[width,height,depth], block-material palette, and sparse
blocks:[[relativeX,relativeY,relativeZ,paletteIndex],…]. Recorded air is explicit: an omitted cell
means unknown, not air. Never interpolate across worlds, discontinuities or missing scene data.
The browser viewer draws simplified geometry from those observations. It is not screen capture,
an exact client render, an MP4, or proof of cheating. JSON export preserves the original clip data.
Public demo clips have source:"synthetic-demo" and explicit demo warnings; live clips come only
from the authenticated linked server. Inputs remain within 32 KiB and result bodies within the
existing 2 MiB relay limit. Recording access is scoped to the workspace with the same membership,
session and purchase checks as other control operations.
Player travel and evidence replay
POST players/<subject-uuid>/travel moves the explicitly selected Minecraft subject. A web
account has no implicit Minecraft character. For action:"teleport", require a different online
destination player's UUID and clearly name both players before submitting. action:"return" uses
the location saved by that web actor's last successful teleport of that subject; no destination is
sent. The return location is session state, not a durable waypoint. Both operations require a
plain-text reason of up to 120 characters, online players, the moderate capability and the
server's travel policy. Protected subjects are refused, and other plugins may cancel movement.
The response is {message,player} with fresh subject details. After an uncertain response, inspect
the actual location before another travel request; travel does not use delivery UUID semantics.
GET cases/<case-uuid>/replay runs the recorded case through the server's replay engine and returns
{caseId,check,replayable,reproduced,tracePackets,message}. reproduced may be null when no replay
result is available. Show unsupported/non-replayable outcomes explicitly. A reproduced finding
confirms the implementation's result on that recorded trace; it is not proof that a player cheated.
Existing inventory edits
Inventory edit:
{ "container": "inventory",
"changes": [
{ "slot": 5, "expect": "<fingerprint the member saw>", "item": null },
{ "slot": 7, "expect": "0000000000000000", "item": { "copyOf": 5, "count": 12 } },
{ "slot": 9, "expect": "0000000000000000", "item": { "id": "minecraft:diamond_sword", "count": 1,
"name": "Blade", "lore": ["Line"], "enchantments": { "minecraft:sharpness": 5 }, "damage": 0, "unbreakable": true } },
{ "slot": 12, "expect": "<fingerprint>", "keep": true } ] }
Every listed slot must still have its expect fingerprint, or nothing changes and the plugin
answers 409 slot_changed. copyOf clones the stack that was in that slot (the source must be
listed too, as a change or as keep) and then applies any other fields; null clears a field. New
items must exist on the server, must not be in web.blocked-items (403 item_blocked), and counts
must fit the item's stack size. Limits: 41/27 changes, 8 lore lines of 100 characters, 24
enchantments of level 1–255, plain text only.
Unchanged plain name/lore values preserve the source item's full rich components, even when the
editable snapshot is clipped. Changing those fields replaces only that field with the submitted
plain text; intentional blank lore lines are retained. Count/damage changes do not flatten lore.
Moderation actions are freeze (seconds), unfreeze, kick, tempban (seconds), ban and
unban, with a plain reason of up to 120 characters. The server's admin: limits apply; protected
players are refused. Travel uses its separate route above. Bans work for offline players by UUID.
Status snapshot fields: product, version, mode, revision, statusAt, onlineCount, maxPlayers, tps, health{…}, players[{id,name,coverage,coverageReason,packets,findings,dropped,clientVersion,world,health,food,gameMode,ping,frozen}], cases[{id,playerId,playerName,createdAtMillis,check,confidence,severity}], config{…}, checks[{key,name,enabled,confidence,enforcement,coverage}], guards{…}, behaviour{enabled,mode,checked,findings,cancelled,lastAttack}, administration{enabled,activeFreezes,activeBans,maxFreezeSeconds,defaultFreezeSeconds,maxTempBanSeconds,maxReasonLength}, web{config,moderation,inventoryView,inventoryEdit,playerDirectory,deliveries,travel,caseReplay}, link{…}. Missing values mean “not reported”, not zero.
Check keys: packet.invalid-number, packet.movement-rate, movement.horizontal-envelope, movement.air-trajectory,
timing.window, combat.reach, combat.hit-angle, combat.attack-rate, combat.multi-target,
world.fast-break, world.fast-place, world.scaffold, movement.ground-spoof, movement.hover.
A full case's finding.check uses the Java enum name (COMBAT_REACH, …).
The configuration editor manages only the flat movement settings (mode, the four movement checks and six
thresholds, as in 0.4). behaviour:, guards:, admin: and web: are edited in config.yml and reloaded.
Error codes to handle
| Code | Meaning |
|---|---|
validation, code_invalid, weak_password |
Fix the input |
unauthenticated, session_ended, credentials, totp_invalid |
Sign in again / wrong credentials |
forbidden, csrf, registration_closed, invite_required, invite_email |
Not allowed; refresh the session for csrf |
not_found |
Workspace missing or not yours |
already_linked, code_used, link_pending, link_changed, last_owner, conflict, owns_shared_server |
State conflict; explain and refresh |
revision_conflict, disk_revision_conflict |
Configuration changed; fetch latest |
slot_changed |
Inventory changed in-game; refresh and review |
delivery_conflict |
Delivery ID already belongs to different contents or another player; inspect its receipt |
delivery_capacity |
Pending or retained delivery capacity reached; preserve receipts and inspect capacity |
invalid_delivery, invalid_item |
Delivery input was rejected; correct the request |
purchase_required |
Verify the purchaser's ownership through purchase.html |
configuration_unavailable, verification_unavailable |
Purchase provider is unconfigured or cannot currently verify access; do not invent an entitlement |
item_blocked, unknown_item, invalid_count, protected, web_disabled, player_offline, moderation_refused |
Refused by the plugin; show the message |
locked, rate_limited, queue_full |
Wait (honour Retry-After) |
link_missing, link_starting, link_offline, link_revoked |
Server not linked, starting or offline |
operation_expired, operation_missing |
Outcome uncertain; refresh before retrying |
no_functions, timeout, offline (client-side) |
Static upload or network problem |
Device protocol (plugin only; never call from a browser)
POST /api/link/device/start {deviceId,secret,serverName,serverVersion,pluginVersion} → {deviceId,code,expiresAt,siteOrigin,pollAfterSeconds}.
Afterwards the plugin sends X-ForgeGuard-Device and Authorization: Bearer <secret> to poll
({status?, results?:[{id,status,body}]} → {state, commands[≤1], pollAfterSeconds, …}), approve {claimId}
and revoke {}. pollAfterSeconds is 1 while work is pending or the console is in use, otherwise 5.
The protocol is unchanged from 0.4 apart from that hint and the new command routes.