api_keys_from is a typed setting, lan (default, the historical rule) or
anywhere, checked in admin_api._auth against the first X-Forwarded-For hop
that NPM sets. It governs API keys only: a session is never restricted, the
console is a session from anywhere; and the break-glass token is ALWAYS
LAN-only, which is not a choice and so is not a setting. LAN ranges are
facts about the network and live in code; the docker range is included
because NPM, the routines and sibling containers reach the app from
arrstack_arr_net. An unparseable address is not LAN - the rule fails closed.
With this in place the location /api/admin/ block on NPM host 51 can come
out; the app enforces what it enforced, and the toggle never touches NPM.
tests/smoke_admin.py 58 -> 76. Proven on a throwaway site: key LAN 200,
key WAN 403 naming the setting, session WAN 200, token LAN 200, token WAN
403; set anywhere, key WAN 200 and token WAN still 403; bogus value 422.
A key is the person who minted it, narrowed to the scopes they chose. Only
the sha256 is stored; the full key is returned once. Scopes must be a subset
of the owner's capabilities at mint time and are enforced again at use time
inside identity.can(), the one place that decides, so a key never outlives
its owner's demotion and disabling a person disables their keys with no
separate flag. A key cannot carry apikeys:own or the owner powers, so it
cannot mint keys. Revoked rows stay; a foreign key id is 404, never 403.
Bearer keys are honoured ONLY on /api/admin. The rest of the site reads
sessions alone, so a scoped key never widens into a browser identity.
X-Admin-Token remains break glass and, having no person, cannot own a key.
/api/docs is generated from the router on every request: path, methods and
docstring from the route objects, and the capability read out of each
handler's own _auth() call so it cannot drift from the check. Gated on a new
api:docs capability (leader and above). GET /api/admin/whoami answers who the
API thinks you are and what you can do.
Tests: smoke_identity 66 -> 92, smoke_admin 53 -> 58 (registry has a
capability for every route, docs page renders every route). Driven end to
end on a throwaway site with a DB copy: mint, whoami via key, scoped 200s
and a 403 that names the narrowing, key-mints-key 403, garbage key 401,
admin token on /keys 403, key on /account is not a session, revoke then
401, second revoke 409.
Nearby rows bump verified_at on every save and deactivate rather than delete.
Unit edits are meeting fields only, gated by unit:write_own scoped to the unit
in the URL. Settings are a typed key registry falling back to code defaults;
find-a-unit now reads its source name and URL from it. link_url and contact
reject attribute-breakout characters and the nearby renderer escapes quotes.
Covered by tests/smoke_admin.py, 53 checks in-process.