# Legatus: the client library (plan LG)

How the products join Atrium. The package lives in `packages/legatus` (`atrium/legatus`, namespace `Atrium\Legatus`, Laravel 13, PHP 8.3
or later); its README is the install guide, and this page is its reference. Decisions LG-1 to LG-14 are in
`reji/plans/2026-11-09-LG-legatus-client-library.md`. What each product must change to install it is in `reji/tasks/other-apps.md`.

## The pieces

| Piece | What it does |
|---|---|
| `Legatus` | The product's one door: `organisations()`, `users()`, `figures()` (what the product tells it), `for($org)` (the API about one organisation), `api()` (naming none), `held($org)`, `report()`, `signOut()`, and the test kit `fake()`, `idToken()`, `webhook()` |
| `Api\Client` | Every `/v1` call (LG-10): the key, `X-Organisation`, an `Idempotency-Key` on each POST (the caller's `key:`, or one made, kept across the call's tries), 3 seconds to connect and 10 to answer (120 for a file). Tried again twice on no connection or a 502, 503 or 504, only when safe: a GET, a PUT, a DELETE, a POST given a key or marked safe. Atrium's "still running under this key" (409 with `Retry-After`) is waited for, up to 5 seconds, then `Unavailable`. A success must be JSON with `data` (`Client::data`), or it's `Unavailable`. Answers become `Errors\Unavailable` (no answer, 5xx, not JSON), `Invalid` (422, `errors()`, the first named in the message, or a refused send's reason), `Throttled` (429, `retryAfter`) or `Refused` (any other 4xx, `kind()` from the problem's type). `quick()`: one try, `recheck_timeout` (3) seconds, for a call a page waits on |
| `Api\Accounts`, `Entitlement`, `Notify`, `Vouchers`, `Links`, `Organisation` | One method an endpoint (below). A voucher's refusal is a `VoucherAnswer`, not an error |
| `Entitlements` | The last entitlement view an organisation (`legatus_entitlement`, LG-9), from `get()`, `used()` and the two webhooks, ordered by Atrium's `as_of` (read before the entitlement, to the microsecond) in one conditional update, never by arrival; a 404 from `get()` keeps `['state' => 'none']` |
| `SignIn\SignInController` | `start`: PKCE S256, a 40-character `state` and `nonce`, the verifier and where they were going kept with that sign-in in the session (five at most, so two tabs don't trip each other), the return address this request's own host plus `callback_path`, `acr_values=password` for step-up. `callback`: the same host and organisation as it started, the code exchanged, the ID token checked (and, for step-up, a password given within `password_minutes`), `check` asked once (belongs and active, or `not_member`; no answer or throttled is `unavailable`), `users()` asked (or `unknown`), signed in, the session regenerated, a `legatus_session` row, `SignedIn` raised; the page to return to only on this host and port. A step-up by the same account keeps the person signed in and replaces the row. For signing out the session keeps Atrium's `signout_hint`, which names nobody, never the ID token |
| `SignIn\Provider` | `id`'s discovery and keys, each cached a day (the issuer must be `ATRIUM_ID_URL`; a key set with no keys is never cached); the token exchange with the client's secret; the ID token checked: RS256 keys only, its `kid`, `iss`, `aud` (with `azp` when there are several), `exp` and `iat` (required) with a minute's leeway on the app's clock, `nonce`, `sub`. An unknown `kid` fetches the keys again, once in five minutes |
| `SignIn\Sessions` | `legatus_session`: a row a session signed in through `id` (`sid`, account, organisation, the session's own ID encrypted with the app's key, the user). Ending one marks the row and destroys the session through the session handler; `SessionEnded` raised. Rows ended, or unchecked, for 30 days go each night |
| `SignIn\SignOut` | POST only. Local sign-out, then `id`'s end-session with Atrium's `signout_hint` as `id_token_hint` and this host (or an absolute `ATRIUM_HOME`) as the way back; local only if `id` can't be reached or the session didn't come through `id` |
| `Middleware\Recheck` (`atrium.session`, and pushed onto `atrium.recheck_group`, `web`) | A row ended: signed out at once (`atrium_error=ended`). A session on another organisation's host: ended. Every `recheck_minutes` (15): `check` (one try, three seconds) with the `sid` and the sign-in time; gone, turned off, signed out, or a 403 (the organisation no longer holds the product) ends it; no answer, a 401 or anything else keeps it and asks again a minute later. Follows the session's ID when the product regenerates it |
| `Middleware\RequirePassword` (`atrium.password`) | A session from `id` without `acr=password`, or whose password is older than `password_minutes` (15), goes back to `id` with `acr_values=password` and `max_age`; JSON gets 403 |
| `Webhooks\WebhookController` | `POST /atrium/webhook`: the signature against either secret (5 minutes' tolerance), a delivery's `id` claimed before acting and remembered two days (a repeat answers 200, `repeat`; a failure lets the claim go for Atrium's retry). `account.signed_out` ends the `sid`'s sessions (every session of the account when `everywhere`), `membership.ended` the account's in that organisation, `account.merged` moves its rows, `entitlement.granted`/`changed` keeps the view. Then `WebhookReceived` for the product. A fault answers 500, so Atrium tries again |
| `Report\Resources`, `atrium:report` | CPU, memory, disk, uptime, the default queue's waiting and failed jobs, each null when the host won't let it be read; the release (60 characters at most); up to six figures, an empty one dropped. Scheduled every five minutes while `ATRIUM_REPORT` and a key are set |
| `atrium:check` | The settings, the key (a report sent), the organisation (`--organisation`, a ping), discovery and keys, the clocks within a minute |
| `Testing\Fake`, `Testing\Delivery` | `Http::fake` for `/v1` and `id`, answering in Atrium's shapes (`answer()` changes one), a test key, `signInAs()` and `returnFrom()` for a sign-in, `idToken()`; a signed delivery with `to($this)` |

## The endpoints

| Method | Calls |
|---|---|
| `accounts()->add($person, acting:, key:)`, `find($acc)` (null when not this organisation's), `check($acc, $sid, $since)` | `POST /v1/account/person`, `GET /v1/account/{account}`, `GET …/check` |
| `entitlement()->get()`, `used([...])` | `GET /v1/entitlement`, `PUT /v1/entitlement/usage` |
| `notify()->sms`, `message`, `balance`, `batch`, `findBatch`, `batchMessages`, `cancelBatch`, `email`, `findEmail`, `sender`, `askSender` | `/v1/notify/*` |
| `vouchers()->open`, `campaign`, `close`, `issue` (safe only with `issueRef`), `batch`, `findBatch`, `download` (a stream), `voidBatch`, `check`, `redeem`, `find`, `void` | `/v1/vouchers/*` |
| `links()->make`, `find`, `off` | `/v1/links*` |
| `ping()`, `job($id)`; `Legatus::report()` | `GET /v1/ping`, `GET /v1/job/{job}`, `POST /v1/report` |

## Settings (`config/atrium.php`, from `.env`)

`ATRIUM_URL`, `ATRIUM_KEY`, `ATRIUM_ID_URL`, `ATRIUM_CLIENT_ID`, `ATRIUM_CLIENT_SECRET`, `ATRIUM_WEBHOOK_SECRET` (two, comma-separated, while
one is replaced), `ATRIUM_SCOPES` (`openid profile phone email`), `ATRIUM_CALLBACK_PATH` (`/auth/callback`), `ATRIUM_SIGN_IN_PATH`
(`/auth/atrium`), `ATRIUM_WEBHOOK_PATH` (`/atrium/webhook`), `atrium.middleware` (`['web']`), `ATRIUM_HOME` (`/`), `ATRIUM_SIGN_IN_ROUTE`
(`login`), `ATRIUM_GUARD` (`web`), `ATRIUM_RECHECK_GROUP` (`web`), `ATRIUM_SIGN_IN_PER_MINUTE` (300), `ATRIUM_RECHECK_MINUTES` (15), `ATRIUM_PASSWORD_MINUTES` (15), `ATRIUM_TIMEOUT` (10), `ATRIUM_REPORT` (on), `ATRIUM_RELEASE`,
`ATRIUM_CACHE_STORE` (the default store).

## Keys

A key names one message, one person added, one campaign: `"fee-reminder-{$invoice}-{$date}"`, never `"invoice-{$invoice}"` reused next
week. Nuntius keeps a send's key for good (NS-8), so a reused key answers with the first message and sends nothing. A voucher check is
never tried again, since Atrium counts each miss; checking then redeeming a wrong code counts two.

## What Atrium changed for it

- `POST /v1/report`: `cpu`, `memory` and `disk` may be left out (null), since shared hosting often hides them; the console shows a dash.
  A `null` queue count is taken as none instead of failing.
- The token endpoint's answer adds `signout_hint`: the ID token's signature, audience and account, with none of its details, which
  `OidcSignOut` takes as a hint (the LG4 review).
- `OidcAuthorise` honours `max_age` with `acr_values=password`: a password older than that is asked for again, and a step-up's password
  now moves `auth_time`; the confirm page says "again" then.
- The entitlement view (`GET /v1/entitlement`, `PUT …/usage`, and both webhooks) carries `as_of`.
- A password reset at `id` ends the account's sessions in every product (`account.signed_out`, `everywhere`).
- `HandleInertiaRequests`: an Inertia request answered with a redirect to another host or to `/oauth/*` becomes a full page visit
  (`Inertia::location`). Found by the end-to-end check: a password given at `id` returned through `/oauth/authorize`, whose answer goes to
  the product's own host, which an XHR can't follow, so sign-in stalled on the form.

## How it's proved

`tests/Feature/Legatus/` runs the package inside Atrium's suite against Atrium itself (`tests/Support/legatus.php` installs it and answers
its calls with Atrium's own routes, in the same process): `ContractTest` (every endpoint, the errors, safe retries), `SignInTest` (sign-in
with Atrium's provider, refusals, the recheck, sessions ended by Atrium's own webhooks, step-up, sign-out), `WebhookTest`, `FakeTest`
(a product-style test with the fake alone; the ID token refusals; the fake's shapes against Atrium's). On 9 November 2026 it was also run
live: a throwaway product on port 8103 signed a demo account in through local Atrium in the browser, ran `atrium:check`, reported to the
console, was signed out by turning the membership off (the webhook through Atrium's real queue) and refused sign-in afterwards
(`not_member`).

`ReviewTest` holds a test for each finding of the LG4 review (`reji/docs/SECURITY_REVIEW_LEGATUS.md`).

A test product shares one session with `id` inside the suite, which a real browser doesn't; where that matters (signing out at `id`),
the test checks the row and `WebhookTest` checks the middleware on a session `id` never touched.
