Skip to content

Plugins, Java, telemetry and deregister over HTTP (#53) - #94

Merged
CaYatur merged 2 commits into
mainfrom
feat/api-rest
Jul 28, 2026
Merged

Plugins, Java, telemetry and deregister over HTTP (#53)#94
CaYatur merged 2 commits into
mainfrom
feat/api-rest

Conversation

@CaYatur

@CaYatur CaYatur commented Jul 28, 2026

Copy link
Copy Markdown
Owner

Closes the last IPC-only gap in the operations surface (#53, part 3 of 3).

What is now reachable over HTTP

Route Gate
GET/POST/DELETE /api/servers/:id/mods… (list, search, detail, updates, install, update, toggle, delete) files
GET /api/java, POST /api/java/install owner session
GET/POST /api/telemetry owner session
DELETE /api/servers/:id?confirm=true (deregister, files kept) owner session

Decisions worth reviewing

Mods are gated on files, not a new mods scope. Installing a plugin
writes a jar into the server directory; deleting one removes a file from it.
files already permits both outright, by hand. A mods scope would be a strict
subset of a scope the caller must already hold — it would look like a boundary
while being none.

No API key can reach the host-wide routes. principalForKey always builds
role: 'user' — a key carries scopes, never a role — so role !== 'owner' is
session-only by construction. That is easy to read as an oversight, so it is now
stated in the code and in the docs, and the smoke pins it with a key holding
every scope on every server: it still gets 403. Breaking the check
deliberately made that assertion fail, so it is not vacuous.

The telemetry patch is validated, not cast. sanitizeTelemetryPatch in
shared/ops.ts: enabled boolean, three integers in range, unknown keys
refused rather than dropped. This config is persisted, so a bad value does not
fail the request that set it — it fails every metrics prune afterwards, across
restarts, from a config file nobody suspects. { enabled: "false" } is the
sharp case: truthy, so it reads as on while the operator believes they turned
it off.

Server lifecycle is exposed by halves. addServer(path) takes a host
filesystem path chosen by the caller and removeServer(id, true) is a recursive
rmSync — the same class of thing javaPath was refused for in #93. Deregister
keeps every file and is recoverable by a rescan, so it ships; the other half
does not, and docs/api-operations.md says why rather than leaving a gap.

Smaller ones

  • invalid-mod-path answers 400, not 409 — it is the caller naming
    something they may not name, not a conflict with the server's state.
  • DELETE …/mods checks rel before confirm, so a malformed call is not
    audited as a refused delete of "" — a decision nobody made. Asserted.
  • Deregistering a running server is 409: dropping it from the registry
    orphans the process, because every lookup that would reach for its config to
    stop it then 404s.

Verify

All twelve gates exit 0. A new MSMS_SMOKE_WEB block covers the scope split,
the traversal refusal, the audit entries, every telemetry rejection (with the
field named), and a deregister that leaves server.jar on disk. Modrinth is
only exercised through its parameter validation, so the gate does not need the
network.

Closes the last IPC-only gap in the operations surface.

Mods are gated on `files`, not a new `mods` scope: installing a jar writes
into the server directory and deleting one removes a file from it, both of
which `files` already permits outright. A separate scope would be a strict
subset of one the caller must already hold — a boundary in name only.

Java runtimes and telemetry retention are host-wide, so no per-server scope
can express them; they are owner-session only. That has a consequence worth
stating: `principalForKey` always builds `role: 'user'`, so no API key can
reach them however it is scoped. The smoke proves it with a key holding
every scope on every server.

The telemetry patch is validated rather than cast (`shared/ops.ts`). It is
persisted, so a bad value does not fail the request that set it — it fails
every metrics prune afterwards, across restarts. `{ enabled: "false" }` is
the sharp case: truthy, so it reads as on while the operator believes they
turned it off.

Server lifecycle is exposed by halves. `DELETE /api/servers/:id` deregisters
and keeps every file; creating a server (a host path chosen by the caller)
and deleting its files (a recursive rmSync) stay off the surface, for the
same reason `javaPath` did in #93. The reversible half ships, the
irreversible half does not, and the docs say so.

All twelve smoke gates green.
Copilot AI review requested due to automatic review settings July 28, 2026 11:48

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Two findings on the deregister route, one of them a real parity bug.

`ipc/register.ts` pairs `registry.removeServer(id, …)` with
`alerts.dropServer(id)` by hand — `removeServer` itself does not drop rules,
only metrics and events. The new HTTP route made the first call and not the
second, so a server deregistered over the API left every rule aimed at it in
the store, evaluated against a server that no longer exists, until the next
launch swept them in `initAlerts`. The route now makes both calls, with a
comment saying why they are separate so the next route does not repeat it.

Second: `{ filesKept: true }` reads as "nothing was lost", and that is not
what happened. Deregistering deletes this server's metrics folder, its event
timeline and its alert rules; a rescan re-adds the folder under a new id with
no history attached. The response now carries `historyDropped` and a count of
`alertRulesRemoved` — a count rather than a flag, because an integration that
created those rules through the API is the one caller who can be surprised by
losing them.

The smoke seeds a rule on the throwaway fixture and asserts both the store and
the reported count, which is what caught the missing call in the first place.
@CaYatur

CaYatur commented Jul 28, 2026

Copy link
Copy Markdown
Owner Author

Self-review

Two findings, fixed in 9d176dc.

1. The route dropped the server but not its alert rules. removeServer
clears metrics and events; it does not touch alerts. ipc/register.ts pairs
the two calls by hand:

registry.removeServer(id, deleteFiles)
alerts.dropServer(id)

The new HTTP route made the first call and not the second. A server
deregistered over the API therefore left every rule aimed at it sitting in
alerts.json, pointing at a server that no longer exists, until the next launch
swept them in initAlerts. Desktop and API disagreed about what removal means.
Fixed, with a comment on why the two calls are separate so the next route does
not repeat it.

2. { filesKept: true } was a half-truth. It reads as "nothing was lost".
What actually happens is that the server's metrics folder, its event timeline
and its alert rules are all deleted, and a rescan re-adds the folder under a
new id with no history attached. The response now carries historyDropped
and alertRulesRemoved — a count rather than a flag, because an integration
that created those rules through this API is precisely the caller who would be
surprised to lose them. docs/api-operations.md was overselling the same thing
and now says it plainly.

Finding 1 is the one worth noting: the assertion written for finding 2 —
seed a rule, deregister, check the store — is what exposed it. The first run
failed with a rule for the forgotten server survived, so the response is wrong, which was true for a reason I had not suspected.

Gates re-run after the fix: MSMS_SMOKE, MSMS_SMOKE_WEB, MSMS_SMOKE_ALERTS
all exit 0.

@CaYatur
CaYatur merged commit d2918fd into main Jul 28, 2026
1 check passed
@CaYatur
CaYatur deleted the feat/api-rest branch July 28, 2026 11:55
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.

2 participants