AlgoJudge documentation0.2

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

CallerCredential
A person, through the Clientthe session cookie minted by ASP.NET Core Identity, on sign-in or after an OIDC callback
The WebSocket at /api/v1/wsthe same cookie. No token in the query string — it would end up in proxy logs
A Runnerits own bearer token, from a challenge it signed with its immutable key
The admin surfacethe 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/register answers 403 registration.closed while 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, resetPassword and resendConfirmationEmail answer 403 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/info is refused too, with 403 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 /account is 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:

GroupAddressesWhy it is open
Public4/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 in10the 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
Runner18a Runner is a machine with a key, not a person with a session
LTI5a platform calls these before there is anybody to be signed in as, and each is authorized by what the platform signed
The operator's surface11not 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 socket1the 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.

MemberMeaning
statusthe HTTP status
title
detail
codestable within a release line — see below
errorsfield 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:

  1. the caller is on the Server container's own loopback interface;
  2. 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.

On this page