The REST API
The shape of the surface, what authenticates a request, the error contract, and the admin surface that answers 404 to everything it turns away.
The endpoint-by-endpoint reference is REST reference, which is
generated from the Server's own openapi.json and is never written by hand.
This page is the shape around it.
Everything lives under /api/v1
The prefix is applied to the whole application — controllers, the Identity endpoints and the WebSocket alike — rather than repeated on each route.
A request without the prefix is a 404, deliberately
Left alone, the framework would answer at both /health and /api/v1/health.
A Client asking a correct host for the wrong path would then be answered
instead of corrected, which is the misconfiguration the fixed prefix exists to
prevent. The guard is explicit and runs before the prefix is stripped.
What authenticates a request
| Caller | Credential |
|---|---|
| A person, through the Client | the session cookie minted by ASP.NET Core Identity, on sign-in or after an OIDC callback |
The WebSocket at /api/v1/ws | the same cookie. No token in the query string — it would end up in proxy logs |
| A Runner | its own bearer token, from a challenge it signed with its immutable key |
| The admin surface | the loopback interface and a token header. See below |
A Runner is not a user and holds no cookie, which is why its controller is anonymous at the class level and authenticates each call itself. It is then authorized against the job it holds, not against being a Runner — without that, any approved Runner could fetch every test package in the installation.
Authorization for everything else is the permission model, resolved per request and cached for that request's lifetime only. A longer-lived cache would keep answering with rights that had been revoked.
Parts of the Identity surface are closed
MapIdentityApi maps its whole surface unconditionally and offers no way to
omit any of it, so the Server refuses several of them in middleware — in front
of the endpoint, before a body is bound.
- Self-service registration is off unless the installation turns it on.
POST /identity/registeranswers403 registration.closedwhile it is, and the switch is the manager panel's accept local sign-ups. With it off, accounts are created by an organizer or arrive through a provider. - There is no password reset and no confirmation to resend, because there is
no mail sender in v1.
forgotPassword,resetPasswordandresendConfirmationEmailanswer403 mail.unavailable. An endpoint that exists and cannot work is worse than one that refuses: it invites a screen to promise something nothing will deliver. /identity/manage/infois refused too, with403 identity.info.unavailable, and for a different reason than the mail ones: the framework builds that answer with a throw when an account has no address, and this product allows accounts without one — the seeded administrator is one.GET /accountis the product's own answer, and it works for every account this product allows.- Two-factor authentication is not wired up. The endpoints the framework brings are mapped and unused rather than half-built, by decision.
The match is on the whole remaining segment, trailing slash normalized: a suffix
test would let POST /identity/register/ through the guard and refuse
/identity/manage/2fa/register, which this rule has nothing to say about.
Closed unless somebody opened it
Authorization here is opt-out, not opt-in. A fallback policy requires an
authenticated user of every endpoint, so a controller carrying no attribute at
all is refused before it runs. Left to the framework's own default the reverse
is true — an endpoint that says nothing is anonymous — and a route added without
[Authorize] would be a hole nothing reports.
That is a floor, not the rule. What a caller may do is the permission model, and several open endpoints authorize themselves in the handler because a policy cannot express what they check: a file is readable if any reference to it is, a Runner proves a key it registered, a platform arrives with a signed launch and no account yet.
The open surface is named one address at a time
49 addresses answer without a session. Each is a decision somebody wrote down rather than an omission:
| Group | Addresses | Why it is open |
|---|---|---|
| Public | 4 | /health, /instance, and a file plus its metadata. A probe that needs a session is not a probe, the sign-in screen has to draw itself before anybody is signed in, and the terms of service the registration form links are a file |
| Signing in | 10 | the act of signing in is what starts a session, and a federated one leaves and comes back. The provider's deletion back channel is here too, authorized by the shared secret the provider was registered with |
| Runner | 18 | a Runner is a machine with a key, not a person with a session |
| LTI | 5 | a platform calls these before there is anybody to be signed in as, and each is authorized by what the platform signed |
| The operator's surface | 11 | not on the network at all: loopback and the token, as above. Anonymous here means there is no account to sign in as, which is the situation it exists to rescue |
| The event socket | 1 | the handshake refuses an anonymous caller itself, with the 401 the Client is written to expect |
EndpointCensusTests pins that list from both ends: it fails on an address that
became open without being named, and on a name that is no longer open. A second
test refuses any endpoint stating neither attribute, so the census reads what
somebody chose rather than what happened to come out.
/robots.txt is answered by middleware in front of routing — a robots file is
only ever read at the root of a host — so it is not an endpoint and not in that
census.
MapIdentityApi is opened one endpoint at a time
The helper maps manage/* with its own authorization and the rest with none.
Opening the group wholesale would apply after each endpoint's own metadata and
win, and manage/2fa would be reachable by anybody. The attribute is added only
where the framework left none, which is exactly the set that was already
anonymous.
What may be reached at all is the surface rules above; this decides only who has to be signed in first.
An address that matches nothing is a 404, not a 401
The fallback policy is applied to a request with no endpoint as well, so left
alone every mistyped path would answer Unauthorized. Two things that costs:
this Server's error contract says not_found, and the Client reads a 401 as
your session ended and sends the reader to the sign-in screen — so a typo in an
address would look like being signed out.
Switch on 404 `not_found` for an address that is not there
A request that matched no endpoint is answered 404 with code: "not_found", in
front of authorization, because there is nothing there to authorize. A 401 from
this Server means a session, not a spelling.
Errors
Every failure is application/problem+json (RFC 9457), including the ones
nobody raised — a 404 from routing is not an exception, so a status-code page
supplies the body.
| Member | Meaning |
|---|---|
status | the HTTP status |
title | |
detail | |
code | stable within a release line — see below |
errors | field name to messages, on a validation failure |
Switch on `code`, not on the status
A 503 may equally come from an edge proxy that has never heard of this
product. The code is the Server's own word for what happened.
A code does not change inside a release line. Between lines it may: 0.2
is not compatible with 0.1, and a code whose concept was renamed is renamed
with it. The set is committed as error-codes.json in the Server's repository
and held to the source by a test, so a change to it is a decision somebody made
and wrote in a release note rather than a rename that reached some literals and
not others.
0 is the tag an installation asks for, and it moves through every 0.x
release. An integrator switching on a code should read the release note before
that tag carries them across a line.
A page past the end is not an error
A list endpoint asked for a page beyond what it holds answers an empty page,
not a failure. That includes an absurd one: ?page=2147483647 would overflow
the offset it is multiplied into and answer 500 on a negative number, so the
offset is clamped and the answer is the empty page.
The admin surface
/api/v1/admin/** is the operator's surface: the maintenance switch, the
key ring, the storage tools, the pre-configuration endpoint and the
administrator password.
It requires two things, and both of them:
- the caller is on the Server container's own loopback interface;
- the request carries the configured token in
X-AlgoJudge-Admin-Token.
Loopback alone is not enough. Anything that gets a foothold inside the container — including the Server's own process — is already on loopback; the token is what a stolen foothold does not come with.
The address is captured before forwarded headers are applied. Left to the
rewritten value, a request from the far side of the internet carrying
X-Forwarded-For: 127.0.0.1 would look local, and the maintenance switch would
be a switch anybody could throw.
It is deliberately not a permission
This is an operator's act rather than a role somebody can be granted. The moment it is a permission, it is also something a stolen administrator session can do.
Everything it turns away gets a 404
Wrong machine, no token configured, no header, wrong value — one answer, and it
is 404, never 403.
A 403 would confirm that the endpoint is there and that the caller got one of the two halves right, which is exactly the feedback somebody probing for it wants. To anything not entitled to be here, this surface does not exist.
An absent, empty or whitespace token setting closes the whole group. There is no "no token means no check" reading: the failure shuts the door rather than opening it.
So does the development token, everywhere but Development. The value the
development stack ships, admin-token-development-only, is published — it is in
a compose file anybody can read — so outside a Development environment the
Server treats it as no token at all and closes the group. The start-up log says
which of the two it was:
AJ_Admin__Token is the well-known development token, so /admin is closed.An installation that copied the development value has a closed surface and a line naming the reason, rather than an open one nobody noticed.
Because the surface is unreachable from the network, the way an operator uses it
is docker exec into the Server's container — which is what aj-admin does.
The maintenance gate exempts /admin, so an installation that has closed itself
can still be reopened.
The generated reference
REST reference is built from the openapi.json of a pinned
Server release — v0.2.0 — fetched from that tag and checked against a recorded
SHA-256, never from a local build, which would document whatever happened to be
on the machine that ran it.
One page per tag rather than one per operation: that document declares 171 paths and 209 operations over 45 tags, and a page per operation would bury the eight a reader actually wants.
Its own info.version reads 1.0. That is the framework's default for a
document nobody versioned; the Server release this reference describes is the
one named above.
It follows a tag rather than a branch, so between releases it describes the
last one while the prose pages of this section describe what is being built.
Each released version keeps its own copy of the reference under its own address,
generated from that release's document: /en/server/v0.1/rest still answers
with the surface v0.1.0 served, including the addresses this one renamed.
The interactive playground is switched off. Turning it on later is a CORS decision, not a framework one.