Zaroz
Zaroz Cloud Kernel Open API

Create Session Application Delegation

POST
/api/v2/application-delegations

Mints (or replaces the scope of) a delegation authorizing the calling application to act as the user named by session_token, which the kernel verifies itself -- it is data, never a trusted assertion from the caller. Authenticated as the application (its own bearer token in Authorization), never by X-Acting-As-User: there is no acting-as user until this call succeeds. capabilities must be non-empty. A second call for the same user replaces the existing delegation's scope rather than creating a duplicate.

Create Session Application Delegation › Request Body

Grants spec `§2.5` (Path A): the calling application already holds a session the kernel issued to a user (typically because that application runs the kernel's own login flow, e.g. FOP). `session_token` is DATA, not authentication -- the request is authenticated by the application's own bearer token, in `Authorization`, exactly like every other application call. The kernel verifies `session_token` itself and mints (or replaces) the delegation for whichever user it names; the calling application never gets to assert an identity the kernel has not independently checked.
​ApiTokenCapability[] · required

Non-empty: a delegation that grants nothing is refused.

session_token
​string · required

Create Session Application Delegation › Responses

The minted or replaced delegation

What the calling application needs back: the id of the delegation it now holds, and the user it was minted for. Nothing else -- this is not the user's own account view (`GET /me/application-delegations`), it is the application's confirmation that its own mint call succeeded.
id
​string · uuid · required
user_id
​string · uuid · required

Look Up Application User Profiles

POST
/api/v2/applications/users/lookup

Public profiles of up to 100 users at once, for an application token holding users.read.public. Same projection as the single read. Ids with no account are absent from the answer rather than an error, and duplicates are collapsed. More than 100 distinct ids is refused.

Look Up Application User Profiles › Request Body

`POST /api/v2/applications/users/lookup`.
ids
​string[] · required

At most [MAX_LOOKUP_IDS]; duplicates are fine.

Look Up Application User Profiles › Responses

The profiles that exist

Ids with no account are absent from `users`, not an error.

Find User By Linked Account

GET
/api/v2/applications/users/by-oauth-account

The kernel user a linked provider account belongs to, for an application token holding users.lookup.oauth (a separate capability from users.read.public). Returns user_id, display_name and language_tag. 404 when no user has linked that account or the user is deleted.

Find User By Linked Account › query Parameters

provider
​string · required · style: form

The provider's id, for example discord.

The provider's id, for example discord.

provider_user_id
​string · required · style: form

The user's id at that provider (a Discord snowflake).

The user's id at that provider (a Discord snowflake).

Find User By Linked Account › Responses

The linked user

Who a linked account belongs to.
display_name
​string · required
language_tag
​string · required

The user's language, for example en-US.

user_id
​string · uuid · required

Get Application User Profile

GET
/api/v2/applications/users/{id}

A user's public profile, for an application token holding users.read.public. Returns id, name, avatar_asset_id and deleted_at and nothing else: never an email. A deleted account is still answered, with deleted_at set. Refused with 403 for a user session, a user API token, an acting-as request, and an application without the capability.

Get Application User Profile › path Parameters

id
​string · uuid · required · style: simple

Get Application User Profile › Responses

The user's public profile

`GET /api/v2/applications/users/{id}` and one element of the batch answer.
id
​string · uuid · required
name
​string · required

The user's display name, as shown to other users.

avatar_asset_id
​string | null · uuid

The profile picture, addressed through /api/v2/assets/{id}. None when they never set one.

deleted_at
​string | null · date-time

Set when the account was deleted. The profile is still answered so a listing can render "deleted user" rather than "unknown user".


Get My Discovery State

GET
/api/v2/me/discovery

The caller's own {key: seen_at} map of discovery-dot keys (nav/tab/product/ui) they have already seen. An account with no stored row answers {"seen": {}}, never 404 -- everything is simply unseen. Kernel does not know which keys currently exist or which of them are "new"; console derives that from its own manifest, joined against this map and the account's creation date.

Get My Discovery State › Responses

The caller's seen discovery keys

`GET /api/v2/me/discovery`'s response: the caller's own `{key: seen_at}` map. An account with no stored `user_discovery` row answers `{"seen": {}}`, never a 404 -- see `DiscoveryLedger::empty`.
​object · required

Mark Discovery Keys Seen

POST
/api/v2/me/discovery/seen

Marks up to 20 of the caller's own discovery-dot keys seen, at the moment of the call. Re-marking an already-seen key is a no-op: its original seen_at is kept, never bumped. Refuses the WHOLE call, naming the offending key, if any key fails the <kind>:<path> format (lowercase, [a-z0-9/_-], at most 96 characters, kind one of nav/tab/product/ui) or if more than 20 keys are submitted -- nothing is written in either case. Rate-limited per caller (RateLimitScope::DiscoverySeen, about 30 requests per minute).

Mark Discovery Keys Seen › Request Body

`POST /api/v2/me/discovery/seen`'s request body. `deny_unknown_fields`, the same convention every `/v2` request type in this crate follows. The 20-key batch cap and the `<kind>:<path>` key format are NOT re-validated here -- `DiscoveryLedger::mark_seen` is the single source of truth for both, so a request that violates either gets exactly one 400, from the domain layer, naming the actual problem.
keys
​string[] · required

Mark Discovery Keys Seen › Responses

The keys are recorded as seen

No data returned

Get Current Identity

GET
/api/v2/me

Gets the caller's own identity, including the platform permissions it holds. system_permissions is empty for an ordinary customer and non-empty for staff, which is the whole of whether the admin app may be opened -- the kernel has no separate staff flag. This operation is not elevation-gated, so an un-elevated staff session can still read what it holds.

Get Current Identity › Responses

The caller's own identity, as seen on the /v2 console surface.

The caller's own identity, as seen on the `/v2` console surface.
created_at
​string · date-time · required
discord_notifications_enabled
​boolean · required

Whether kernel-generated notifications are relayed to this user as Discord DMs, on top of the in-app center they always get. Read back so the toggle PATCH /me writes has somewhere to read its own current state from.

id
​string · uuid · required
is_onboarded
​boolean · required
language
​string · required

The caller's own display language, as an unvalidated BCP-47-ish tag (LanguageCode is a newtype over String and validates nothing).

Published because the onboarding screen and the account page both need to render the value the account already holds as their default. console/app/onboarding/page.tsx records the gap this closes: it had to start the language and billing-country selects empty because nothing on /v2 reported either field, which meant re-answering onboarding silently guessed instead of showing what was already stored.

name
​string · required
system_permissions
​string[] · required

The caller's own platform permissions, as [kernel_core::domain::permissions::SystemPermission]'s wire strings (view_all_orders, manage_users, ...). Empty for an ordinary customer, which is nearly every caller.

Holding one of these IS being staff. The kernel has no separate staff flag, so a non-empty list is the whole of "this caller may open the admin app", and each value decides one gated section inside it.

Field-for-field the same answer /v1's CurrentUserView::system_permissions gave (deleted with /v1 in 88cb24c1; it rendered User::permissions through the same Display). Nothing on /v2 published it, so admin recovered it by intersecting GET /api/v2/admin/users/{id}'s roles with GET /api/v2/admin/ permissions' granted_by_roles: two extra round trips per render, growing with the role count, and both of those routes sit under /admin/ and so behind require_elevated_session -- meaning an un-elevated staff session could not learn what it held at all (ZC-0410, and ZC-0411 which this closes with it). /api/v2/me has no admin path segment, so it is not elevation-gated and does not reintroduce that trap.

Empty for a restricted API token, never the owning user's list. A restricted token's authority is its grants, which domain::policies::api_token_intersect intersects with these permissions once per operation; there is no mapping from a SystemPermission back to the ApiTokenCapability a token would also have to carry, so the owner's list is not an answer to "what may this token do", and publishing it would tell a leaked token only that its owner is staff. /v1 never had to decide this: its current-user route refused a token caller outright.

avatar_asset_id
​string | null · uuid

The caller's own avatar (ZC-1306), addressed through /api/v2/assets/{id}. None when the account has never set one (no OAuth provider avatar and nothing uploaded). An asset id, not a URL -- see OrganizationResponse::icon_asset_id's own doc comment for the /v2 convention this follows: the id itself changes on every replace, uploaded or provider-synced alike, so a consumer never has to append a cache-busting query parameter.

billing_country_code
​string | null

The ISO 3166-1 alpha-2 country the account is billed from, None until onboarding answers it.

Write-once in practice: UserManagementService::update_user_profile refuses to change it once is_onboarded is true and a value is already set, so a client rendering an edit control for it after onboarding is rendering a control that cannot succeed.

email
​string | null

Resolved through UserEmailService (checks oauth_accounts first, then password_credentials). None when neither source has an address for this user -- User itself carries no email field, so this cannot be filled in by a pure User conversion; see get_me_handler.


Update Current Identity

PATCH
/api/v2/me

Updates the caller's own profile. Every field is optional and an omitted field is left unchanged. billing_country_code is refused once onboarding is complete and a country is already stored, because it decides tax treatment.

Update Current Identity › Request Body

The profile fields a caller may change about themselves. Every field is optional and an omitted field is left alone, which is what makes this a `PATCH` rather than a `PUT`: the onboarding screen sends all three at once, while the account page sends whichever single control was touched, and neither has to send back values it never rendered. This is the request half of `/v1`'s deleted `PATCH /users/current-user` (`crates/http/identity/src/controllers/users/admin.rs::update_current_user_handler`), field for field. It carries no `is_onboarded`: completing onboarding is its own operation with its own refusal (`POST /me/complete-onboarding`), so a profile write can never flip the flag that gates the whole signed-in shell as a side effect of setting a name.
billing_country_code
​string | null

The country this account is billed from, ISO 3166-1 alpha-2, case insensitive. Refused with 422 once onboarding is complete and a country is already stored: the value decides tax treatment, so it is deliberately not something a customer can re-answer later.

discord_notifications_enabled
​boolean | null

Whether to relay notifications to Discord.

language
​string | null

A new display language tag.

name
​string | null

A new display name. Moderated by Username::try_new_moderated, so a name that is too short, too long, wrongly shaped or offensive is refused rather than stored.

Update Current Identity › Responses

The saved profile, in the same shape GET /me returns

The caller's own identity, as seen on the `/v2` console surface.
created_at
​string · date-time · required
discord_notifications_enabled
​boolean · required

Whether kernel-generated notifications are relayed to this user as Discord DMs, on top of the in-app center they always get. Read back so the toggle PATCH /me writes has somewhere to read its own current state from.

id
​string · uuid · required
is_onboarded
​boolean · required
language
​string · required

The caller's own display language, as an unvalidated BCP-47-ish tag (LanguageCode is a newtype over String and validates nothing).

Published because the onboarding screen and the account page both need to render the value the account already holds as their default. console/app/onboarding/page.tsx records the gap this closes: it had to start the language and billing-country selects empty because nothing on /v2 reported either field, which meant re-answering onboarding silently guessed instead of showing what was already stored.

name
​string · required
system_permissions
​string[] · required

The caller's own platform permissions, as [kernel_core::domain::permissions::SystemPermission]'s wire strings (view_all_orders, manage_users, ...). Empty for an ordinary customer, which is nearly every caller.

Holding one of these IS being staff. The kernel has no separate staff flag, so a non-empty list is the whole of "this caller may open the admin app", and each value decides one gated section inside it.

Field-for-field the same answer /v1's CurrentUserView::system_permissions gave (deleted with /v1 in 88cb24c1; it rendered User::permissions through the same Display). Nothing on /v2 published it, so admin recovered it by intersecting GET /api/v2/admin/users/{id}'s roles with GET /api/v2/admin/ permissions' granted_by_roles: two extra round trips per render, growing with the role count, and both of those routes sit under /admin/ and so behind require_elevated_session -- meaning an un-elevated staff session could not learn what it held at all (ZC-0410, and ZC-0411 which this closes with it). /api/v2/me has no admin path segment, so it is not elevation-gated and does not reintroduce that trap.

Empty for a restricted API token, never the owning user's list. A restricted token's authority is its grants, which domain::policies::api_token_intersect intersects with these permissions once per operation; there is no mapping from a SystemPermission back to the ApiTokenCapability a token would also have to carry, so the owner's list is not an answer to "what may this token do", and publishing it would tell a leaked token only that its owner is staff. /v1 never had to decide this: its current-user route refused a token caller outright.

avatar_asset_id
​string | null · uuid

The caller's own avatar (ZC-1306), addressed through /api/v2/assets/{id}. None when the account has never set one (no OAuth provider avatar and nothing uploaded). An asset id, not a URL -- see OrganizationResponse::icon_asset_id's own doc comment for the /v2 convention this follows: the id itself changes on every replace, uploaded or provider-synced alike, so a consumer never has to append a cache-busting query parameter.

billing_country_code
​string | null

The ISO 3166-1 alpha-2 country the account is billed from, None until onboarding answers it.

Write-once in practice: UserManagementService::update_user_profile refuses to change it once is_onboarded is true and a value is already set, so a client rendering an edit control for it after onboarding is rendering a control that cannot succeed.

email
​string | null

Resolved through UserEmailService (checks oauth_accounts first, then password_credentials). None when neither source has an address for this user -- User itself carries no email field, so this cannot be filled in by a pure User conversion; see get_me_handler.


Complete Onboarding

POST
/api/v2/me/complete-onboarding

Marks the caller's onboarding as complete, which is what opens every layout behind the signed-in shell. Idempotent only in the sense that it refuses a second call with 422 rather than silently succeeding. Returns the saved identity; no fresh access token is issued, because authentication reloads the user from the database on every request and nothing reads the is_onboarded JWT claim (see crates/http/foundation/src/middleware/authentication.rs), so the caller's existing token already reports the new state. /v1's equivalent minted one, which was a credential nobody needed.

Complete Onboarding › Responses

The identity, now onboarded

The caller's own identity, as seen on the `/v2` console surface.
created_at
​string · date-time · required
discord_notifications_enabled
​boolean · required

Whether kernel-generated notifications are relayed to this user as Discord DMs, on top of the in-app center they always get. Read back so the toggle PATCH /me writes has somewhere to read its own current state from.

id
​string · uuid · required
is_onboarded
​boolean · required
language
​string · required

The caller's own display language, as an unvalidated BCP-47-ish tag (LanguageCode is a newtype over String and validates nothing).

Published because the onboarding screen and the account page both need to render the value the account already holds as their default. console/app/onboarding/page.tsx records the gap this closes: it had to start the language and billing-country selects empty because nothing on /v2 reported either field, which meant re-answering onboarding silently guessed instead of showing what was already stored.

name
​string · required
system_permissions
​string[] · required

The caller's own platform permissions, as [kernel_core::domain::permissions::SystemPermission]'s wire strings (view_all_orders, manage_users, ...). Empty for an ordinary customer, which is nearly every caller.

Holding one of these IS being staff. The kernel has no separate staff flag, so a non-empty list is the whole of "this caller may open the admin app", and each value decides one gated section inside it.

Field-for-field the same answer /v1's CurrentUserView::system_permissions gave (deleted with /v1 in 88cb24c1; it rendered User::permissions through the same Display). Nothing on /v2 published it, so admin recovered it by intersecting GET /api/v2/admin/users/{id}'s roles with GET /api/v2/admin/ permissions' granted_by_roles: two extra round trips per render, growing with the role count, and both of those routes sit under /admin/ and so behind require_elevated_session -- meaning an un-elevated staff session could not learn what it held at all (ZC-0410, and ZC-0411 which this closes with it). /api/v2/me has no admin path segment, so it is not elevation-gated and does not reintroduce that trap.

Empty for a restricted API token, never the owning user's list. A restricted token's authority is its grants, which domain::policies::api_token_intersect intersects with these permissions once per operation; there is no mapping from a SystemPermission back to the ApiTokenCapability a token would also have to carry, so the owner's list is not an answer to "what may this token do", and publishing it would tell a leaked token only that its owner is staff. /v1 never had to decide this: its current-user route refused a token caller outright.

avatar_asset_id
​string | null · uuid

The caller's own avatar (ZC-1306), addressed through /api/v2/assets/{id}. None when the account has never set one (no OAuth provider avatar and nothing uploaded). An asset id, not a URL -- see OrganizationResponse::icon_asset_id's own doc comment for the /v2 convention this follows: the id itself changes on every replace, uploaded or provider-synced alike, so a consumer never has to append a cache-busting query parameter.

billing_country_code
​string | null

The ISO 3166-1 alpha-2 country the account is billed from, None until onboarding answers it.

Write-once in practice: UserManagementService::update_user_profile refuses to change it once is_onboarded is true and a value is already set, so a client rendering an edit control for it after onboarding is rendering a control that cannot succeed.

email
​string | null

Resolved through UserEmailService (checks oauth_accounts first, then password_credentials). None when neither source has an address for this user -- User itself carries no email field, so this cannot be filled in by a pure User conversion; see get_me_handler.


Upload My Avatar

PUT
/api/v2/me/avatar

Uploads the caller's avatar. Send as multipart/form-data with the image in a part named file: JPEG, PNG, WebP or static GIF (first frame), identified by magic bytes; anything else, including SVG, is refused. Re-encoded server-side to a 512x512 lossy WebP with EXIF orientation applied and all other metadata stripped. Every upload -- including a replace -- gets a FRESH asset id, so the returned avatar_asset_id always changes. Marks the avatar's source as uploaded: an automatic OAuth avatar sync on a later login will not overwrite it unless the caller explicitly re-selects a provider's avatar via PUT /me/avatar/source. Rate-limited per caller.

Upload My Avatar › Request Body

file
​string · binary · required

Upload My Avatar › Responses

The caller's identity, with its new avatar_asset_id

The caller's own identity, as seen on the `/v2` console surface.
created_at
​string · date-time · required
discord_notifications_enabled
​boolean · required

Whether kernel-generated notifications are relayed to this user as Discord DMs, on top of the in-app center they always get. Read back so the toggle PATCH /me writes has somewhere to read its own current state from.

id
​string · uuid · required
is_onboarded
​boolean · required
language
​string · required

The caller's own display language, as an unvalidated BCP-47-ish tag (LanguageCode is a newtype over String and validates nothing).

Published because the onboarding screen and the account page both need to render the value the account already holds as their default. console/app/onboarding/page.tsx records the gap this closes: it had to start the language and billing-country selects empty because nothing on /v2 reported either field, which meant re-answering onboarding silently guessed instead of showing what was already stored.

name
​string · required
system_permissions
​string[] · required

The caller's own platform permissions, as [kernel_core::domain::permissions::SystemPermission]'s wire strings (view_all_orders, manage_users, ...). Empty for an ordinary customer, which is nearly every caller.

Holding one of these IS being staff. The kernel has no separate staff flag, so a non-empty list is the whole of "this caller may open the admin app", and each value decides one gated section inside it.

Field-for-field the same answer /v1's CurrentUserView::system_permissions gave (deleted with /v1 in 88cb24c1; it rendered User::permissions through the same Display). Nothing on /v2 published it, so admin recovered it by intersecting GET /api/v2/admin/users/{id}'s roles with GET /api/v2/admin/ permissions' granted_by_roles: two extra round trips per render, growing with the role count, and both of those routes sit under /admin/ and so behind require_elevated_session -- meaning an un-elevated staff session could not learn what it held at all (ZC-0410, and ZC-0411 which this closes with it). /api/v2/me has no admin path segment, so it is not elevation-gated and does not reintroduce that trap.

Empty for a restricted API token, never the owning user's list. A restricted token's authority is its grants, which domain::policies::api_token_intersect intersects with these permissions once per operation; there is no mapping from a SystemPermission back to the ApiTokenCapability a token would also have to carry, so the owner's list is not an answer to "what may this token do", and publishing it would tell a leaked token only that its owner is staff. /v1 never had to decide this: its current-user route refused a token caller outright.

avatar_asset_id
​string | null · uuid

The caller's own avatar (ZC-1306), addressed through /api/v2/assets/{id}. None when the account has never set one (no OAuth provider avatar and nothing uploaded). An asset id, not a URL -- see OrganizationResponse::icon_asset_id's own doc comment for the /v2 convention this follows: the id itself changes on every replace, uploaded or provider-synced alike, so a consumer never has to append a cache-busting query parameter.

billing_country_code
​string | null

The ISO 3166-1 alpha-2 country the account is billed from, None until onboarding answers it.

Write-once in practice: UserManagementService::update_user_profile refuses to change it once is_onboarded is true and a value is already set, so a client rendering an edit control for it after onboarding is rendering a control that cannot succeed.

email
​string | null

Resolved through UserEmailService (checks oauth_accounts first, then password_credentials). None when neither source has an address for this user -- User itself carries no email field, so this cannot be filled in by a pure User conversion; see get_me_handler.


Remove My Avatar

DELETE
/api/v2/me/avatar

Removes the caller's avatar. A no-op success (204) when they have none. Does not fall back to re-syncing a linked provider's avatar -- use PUT /me/avatar/source for that.

Remove My Avatar › Responses

Removed

No data returned

Set My Avatar Source

PUT
/api/v2/me/avatar/source

Re-selects a linked OAuth provider's avatar as the caller's avatar, downloading, normalizing and storing it the same way an automatic login sync would. The one provider must already be linked and must have a stored avatar. Unlike an automatic sync, this always overwrites -- including an uploaded avatar -- because making this call IS the explicit choice that moves the account off an upload.

Set My Avatar Source › Request Body

Body of `PUT /me/avatar/source`.
provider_id
​string · required

The id of an OAuth provider the caller has already linked an account with. Must have a stored avatar (the provider returned one at some login).

Set My Avatar Source › Responses

The caller's identity, with its new avatar_asset_id

The caller's own identity, as seen on the `/v2` console surface.
created_at
​string · date-time · required
discord_notifications_enabled
​boolean · required

Whether kernel-generated notifications are relayed to this user as Discord DMs, on top of the in-app center they always get. Read back so the toggle PATCH /me writes has somewhere to read its own current state from.

id
​string · uuid · required
is_onboarded
​boolean · required
language
​string · required

The caller's own display language, as an unvalidated BCP-47-ish tag (LanguageCode is a newtype over String and validates nothing).

Published because the onboarding screen and the account page both need to render the value the account already holds as their default. console/app/onboarding/page.tsx records the gap this closes: it had to start the language and billing-country selects empty because nothing on /v2 reported either field, which meant re-answering onboarding silently guessed instead of showing what was already stored.

name
​string · required
system_permissions
​string[] · required

The caller's own platform permissions, as [kernel_core::domain::permissions::SystemPermission]'s wire strings (view_all_orders, manage_users, ...). Empty for an ordinary customer, which is nearly every caller.

Holding one of these IS being staff. The kernel has no separate staff flag, so a non-empty list is the whole of "this caller may open the admin app", and each value decides one gated section inside it.

Field-for-field the same answer /v1's CurrentUserView::system_permissions gave (deleted with /v1 in 88cb24c1; it rendered User::permissions through the same Display). Nothing on /v2 published it, so admin recovered it by intersecting GET /api/v2/admin/users/{id}'s roles with GET /api/v2/admin/ permissions' granted_by_roles: two extra round trips per render, growing with the role count, and both of those routes sit under /admin/ and so behind require_elevated_session -- meaning an un-elevated staff session could not learn what it held at all (ZC-0410, and ZC-0411 which this closes with it). /api/v2/me has no admin path segment, so it is not elevation-gated and does not reintroduce that trap.

Empty for a restricted API token, never the owning user's list. A restricted token's authority is its grants, which domain::policies::api_token_intersect intersects with these permissions once per operation; there is no mapping from a SystemPermission back to the ApiTokenCapability a token would also have to carry, so the owner's list is not an answer to "what may this token do", and publishing it would tell a leaked token only that its owner is staff. /v1 never had to decide this: its current-user route refused a token caller outright.

avatar_asset_id
​string | null · uuid

The caller's own avatar (ZC-1306), addressed through /api/v2/assets/{id}. None when the account has never set one (no OAuth provider avatar and nothing uploaded). An asset id, not a URL -- see OrganizationResponse::icon_asset_id's own doc comment for the /v2 convention this follows: the id itself changes on every replace, uploaded or provider-synced alike, so a consumer never has to append a cache-busting query parameter.

billing_country_code
​string | null

The ISO 3166-1 alpha-2 country the account is billed from, None until onboarding answers it.

Write-once in practice: UserManagementService::update_user_profile refuses to change it once is_onboarded is true and a value is already set, so a client rendering an edit control for it after onboarding is rendering a control that cannot succeed.

email
​string | null

Resolved through UserEmailService (checks oauth_accounts first, then password_credentials). None when neither source has an address for this user -- User itself carries no email field, so this cannot be filled in by a pure User conversion; see get_me_handler.


List My API Tokens

GET
/api/v2/me/api-tokens

Lists the caller's own live personal API tokens, newest first, with each token's capability set and last use. Revoked tokens are omitted: this is a credential inventory, not an audit log. No response here ever carries a secret or its digest -- the secret shown once by the create call cannot be recovered from this route or any other. Never returns another account's tokens, and never returns an organization's tokens (see GET /organizations/{id}/api-tokens for those).

List My API Tokens › Responses

The caller's own live personal API tokens

​MyApiTokenResponse[] · required

Create My API Token

POST
/api/v2/me/api-tokens

Mints a personal API token and returns the secret exactly once, in the form zaroz_pat_{tokenId}_{secret}. Only a digest is stored. authorization is the token's grant list; every capability must be one the caller may grant (GET /me/api-token-capabilities). A grant containing an admin-only capability needs admin_authorized: true and an elevated session, otherwise 403 elevation-required. Requires a full signed-in session: no API token may mint another.

Create My API Token › Request Body

Body of `POST /me/api-tokens`.
​object · required

The grants the token holds. Every capability must be one the caller may grant themselves (GET /me/api-token-capabilities).

name
​string · required

1-64 characters after trimming.

admin_authorized
​boolean

Must be true exactly when the authorization contains an admin-only capability, and then requires an elevated session.

Default: false
expires_at
​string | null · date-time

Omit for a token that never expires on its own. Must be in the future.

Default: null

Create My API Token › Responses

The token, and its secret, shown once

The only response that ever carries the secret.
secret
​string · required

The full credential, shown exactly once. Only a digest of its secret half is stored.

​MyApiTokenResponse · required

One of the caller's own personal API tokens (ZC-0512).

Never carries the secret, or even its digest. Only a digest of the secret half is stored on token_hash, and this response omits that field entirely: the full credential is returned exactly once, at creation (POST /me/api-tokens), and no later read -- this one included -- can reproduce it.

Unlike [crate::contracts::organization_api_tokens::OrganizationApiTokenResponse], capabilities here is not filtered to resource-scoped grants: a personal token may hold a global or resource-type-scoped grant as well as a resource-scoped one, so every capability the token's authorization names is reported, regardless of scope shape. A token created before restricted authorization existed carries no authorization at all (a legacy unrestricted token); it reports an empty list rather than "every capability there is", since there is no finite list of everything an unrestricted token can do to render here.


List My API Token Capabilities

GET
/api/v2/me/api-token-capabilities

The capabilities the caller may put in a token they mint, filtered by what they hold themselves, so a token editor renders from the kernel rather than a hardcoded copy. admin_only marks the ones that need an elevated session. Any signed-in session may read it; a restricted API token needs user_api_tokens.list, because the list reflects the account's staff permissions.

List My API Token Capabilities › Responses

The capabilities this caller may grant


Revoke My API Token

DELETE
/api/v2/me/api-tokens/{token_id}

Revokes one of the caller's own personal API tokens immediately. A token id that belongs to another account, to an organization, or to nothing at all all answer the same not found, so this route cannot be used to discover which token ids exist. Revoking an already-revoked token is that same not found. Requires a full signed-in session: no API token, restricted or otherwise, may call this route, even to revoke itself or a sibling token.

Revoke My API Token › path Parameters

token_id
​string · uuid · required · style: simple

Revoke My API Token › Responses

Token revoked

No data returned

List My Application Delegations

GET
/api/v2/me/application-delegations

Lists every application holding a live-or-revoked delegation over the caller's own account, newest first: which application, what scope was approved, when it was last used. Never returns another account's delegations.

List My Application Delegations › Responses

The caller's own application delegations


Create My Application Delegation

POST
/api/v2/me/application-delegations

Approves an application acting as the caller, within the named capability set, using the caller's own signed-in session. A delegation already live for this application is replaced with this scope rather than duplicated -- there is at most one delegation per (application, user) pair. Requires a full session: no API token, restricted or otherwise, may consent on the caller's behalf.

Create My Application Delegation › Request Body

Grants spec `§2.6` (Path B), simplified: this is what a consent page the caller is looking at, after they approve, would submit with their own kernel session. Discovering that there is something to approve (a one-time consent-request URL shown to the caller out of band, grants spec `§2.6` steps 2-3) is `Q78`, unresolved, and not built here -- see the ZC-1321 report.
application_id
​string · uuid · required
​ApiTokenCapability[] · required

Non-empty: a consent event that grants nothing is refused.

Create My Application Delegation › Responses

Delegation created or replaced

No data returned

GET
/api/v2/me/application-delegations/consent-preview/{application_id}

The presentable name of an application the caller is about to approve, for the consent page (consent_url in a delegation-required answer). Requires a full session, like approving. An unknown or revoked application answers 404.

Preview Application Consent › path Parameters

application_id
​string · uuid · required · style: simple

Preview Application Consent › Responses

The application's presentable name

`GET /api/v2/me/application-delegations/consent-preview/{application_id}`.
application_id
​string · uuid · required
application_name
​string · required

Presentable name, the same one MyApplicationDelegationResponse carries.


Revoke My Application Delegation

DELETE
/api/v2/me/application-delegations/{delegation_id}

Revokes one of the caller's own application delegations immediately: the application can no longer act as the caller from the next request onward. Nothing the application already did is undone -- servers it created, tickets it opened, and so on, are untouched. A delegation id belonging to another account, or to nothing at all, answers the same not found, so this route cannot be used to discover which delegation ids exist. Requires a full session, same as revoking a personal API token.

Revoke My Application Delegation › path Parameters

delegation_id
​string · uuid · required · style: simple

Revoke My Application Delegation › Responses

Delegation revoked

No data returned

List My Notifications

GET
/api/v2/me/notifications

The caller's own notifications, newest first, paginated. unread_count is the caller's TOTAL unread count across every page, not only the page returned -- it is what a bell icon's badge renders. A notification whose organization_id names an organization the caller belongs to is included exactly the same as one with no organization at all; delivery is always to the caller regardless of that field.

List My Notifications › query Parameters

page
​integer · uint32 · min: 0 · style: form

Page number (1-indexed). Defaults to 1.

Page number (1-indexed). Defaults to 1.

Default: 1
page_size
​integer · uint32 · min: 0 · style: form

Number of items per page. Defaults to 20, max 100.

Number of items per page. Defaults to 20, max 100.

Default: 20

List My Notifications › Responses

The caller's notifications, newest first

​NotificationResponse[] · required
​PaginationMeta · required

Pagination metadata included in paginated responses

unread_count
​integer · uint64 · min: 0 · required

The caller's TOTAL unread count, across every page -- not merely how many of the notifications in data are unread. The bell icon's badge needs the whole number regardless of which page happens to be open.


Mark All Notifications Read

POST
/api/v2/me/notifications/read-all

Marks every one of the caller's own notifications read.

Mark All Notifications Read › Responses

Every notification visible to the caller is now read

null

Stream My Notifications

GET
/api/v2/me/notifications/stream

Holds a text/event-stream open for the caller's own new notifications, so the bell can push a toast without polling. Every frame is a notification_created event carrying the same fields GET /me/notifications would show for that row except read (always false, since the frame is only ever sent once, at creation) and organization_id (not carried by the underlying event, and never guessed). The server closes the connection after fifteen minutes and the first frame sets the reconnect interval, so a browser EventSource re-opens it by itself.

Stream My Notifications › Responses

An open event stream. The schema describes the data: payload of one notification_created frame, not a single JSON response body.

One frame of `GET /api/v2/me/notifications/stream`'s `notification_created` event. Deliberately its OWN type rather than a reuse of `NotificationResponse`: `DomainEvent::NotificationCreated` (what this is built from) carries no `organization_id` and no persisted `created_at` -- inventing either would either misreport scoping the event never asserted or claim a persistence timestamp the frame does not actually carry, and this surface must never say more, or something DIFFERENT, than a `GET` of the same notification would (the rule `contracts::ticket_events`'s own module doc states for the identical reason). `read` is also absent: every notification this event announces was, by construction, just created unread, so a client does not need to be told.
title
​string · required
body
​string · required
created_at
​string · date-time · required

The moment this frame was assembled, not a persisted column -- see this type's own doc comment.

id
​string · uuid · required
kind
​NotificationKindResponse · enum · required

The wire spelling of NotificationKind, converted explicitly at the transport boundary rather than deriving JsonSchema on the domain type directly (domain crates do not take a schemars dependency -- see crates/http/CLAUDE.md's "Transport boundary").

Subscription stays spelled order on the wire. See NotificationKind::Subscription's own doc comment: it was renamed from Order in the domain (ZC-0261 phase 9f), but notifications.kind is a text column written since /v1 and dashboard's notification feed (lib/notifications.ts) already reads the literal string "order" -- changing the wire value would be a breaking, silent rename of every existing row's meaning.

Enum values:
order
invoice
server
system
href
​string | null

Mark a Notification Read

POST
/api/v2/me/notifications/{id}/read

Marks exactly one of the caller's own notifications read and returns it. Idempotent: marking an already-read notification read again still answers 200 with read: true. Answers 404, identically, both for an id that does not exist at all and for a real notification belonging to somebody else -- never 403, which would confirm the id names a real row.

Mark a Notification Read › path Parameters

id
​string · uuid · required · style: simple

Mark a Notification Read › Responses

The notification, now read

One notification, in the shape both the list read and the single mark-read write answer with -- matching `dashboard`'s own `Notification` interface (`lib/notifications.ts`) field for field where the domain carries the same information: `id`, `kind`, `title`, `body`, `read`, `href`, `created_at`. `organization_id` is additional, carried because the entity already has it and a future multi-organization UI can use it to group the feed; it is never `null` when the notification is genuinely organization-scoped and never fabricated when it is not (see `Notification::organization_id`'s own doc comment on the domain type).
title
​string · required
body
​string · required
created_at
​string · date-time · required
id
​string · uuid · required
kind
​NotificationKindResponse · enum · required

The wire spelling of NotificationKind, converted explicitly at the transport boundary rather than deriving JsonSchema on the domain type directly (domain crates do not take a schemars dependency -- see crates/http/CLAUDE.md's "Transport boundary").

Subscription stays spelled order on the wire. See NotificationKind::Subscription's own doc comment: it was renamed from Order in the domain (ZC-0261 phase 9f), but notifications.kind is a text column written since /v1 and dashboard's notification feed (lib/notifications.ts) already reads the literal string "order" -- changing the wire value would be a breaking, silent rename of every existing row's meaning.

Enum values:
order
invoice
server
system
read
​boolean · required
href
​string | null
organization_id
​string | null · uuid

List My Sessions

GET
/api/v2/me/sessions

Lists the caller's own live sessions (refresh-token families), newest activity first is not guaranteed -- see the response schema for what each row means and why there is no current flag. Only ever the caller's own rows: scoped by user_id at the repository query, not by a check on an already-loaded list.

List My Sessions › Responses

The caller's own live sessions

​MySessionResponse[] · required

Revoke My Session

DELETE
/api/v2/me/sessions/{session_id}

Revokes one of the caller's own sessions immediately, ending every access/refresh token pair issued from that family. A session id that belongs to another account or to nothing at all is silently a no-op, exactly like RefreshTokenService::revoke_session: this route never reveals whether a given id exists at all, only whether the caller's own session list changed. Revoking the session the caller is currently making this request with is allowed, and signs them out: there is no current protection, because nothing on the request lets this route know which session that is (see the response schema's own note on why there is no current flag).

Revoke My Session › path Parameters

session_id
​string · uuid · required · style: simple

Revoke My Session › Responses

no content

No data returned

Log In With Password

POST
/api/v2/auth/login

Starts a password login. A correct email and password earn a one-time code emailed to the address, not a session -- submit it to POST /auth/login/verify to open one.

Log In With Password › Request Body

Credentials for a password login.
email
​string · required
password
​string · required

Log In With Password › Responses

What a password login resolved to.

Today this only ever resolves to MfaRequired: PasswordAuthService::log_in always emails a one-time code and never returns a session directly, and there is no configuration flag that skips the challenge. This stays an enum (rather than a bare challenge_id field) so a login path that can one day skip the challenge does not have to break the response shape.

oneOf
Exactly one variant must match.

Decision Table

VariantMatching Criteria
type = object · requires: status, challenge_id
Properties for Variant 1:
The password was correct. Submit `challenge_id` and the emailed code to `POST /auth/login/verify` to open a session.
challenge_id
​string · uuid · required
status
​string · const · required
Const value: mfa_required

Verify Login Code

POST
/api/v2/auth/login/verify

Completes a password login by submitting the emailed code, opening a new session. The code expires shortly and the challenge is burned after a handful of wrong attempts.

Verify Login Code › Request Body

Completes a password login by submitting the code emailed after [`LoginRequest`].
challenge_id
​string · uuid · required
code
​string · required

Verify Login Code › Responses

A new session

A freshly opened session. `token` is the bare access token: the caller adds the `Bearer ` scheme itself when sending it back as `Authorization`. (The V1 issuer used to return `"Bearer {token}"` here; that defect is fixed and V2 must not reproduce it.)
refresh_token
​string · required
session_id
​string · uuid · required
token
​string · required
​AuthUserResponse · required

Minimal caller identity carried on a session payload.

Deliberately not the full /console/me shape (kernel_http_v2_identity::contracts::me::MeResponse): /v2 feature crates do not depend on one another (see crates/http/CLAUDE.md), so this is a small, independent projection sized for what a session response needs, not a shared type.


Refresh Session

POST
/api/v2/auth/refresh

Exchanges a refresh token for a new access token and a rotated refresh token.

Refresh Session › Request Body

refresh_token
​string · required

Refresh Session › Responses

A rotated access/refresh token pair. token is bare, same as [SessionResponse::token].

A rotated access/refresh token pair. `token` is bare, same as [`SessionResponse::token`].
refresh_token
​string · required
session_id
​string · uuid · required
token
​string · required

Logout

POST
/api/v2/auth/logout

Revokes the current session's refresh token, ending the session.

Logout › Request Body

refresh_token
​string · required

Logout › Responses

no content

No data returned

Get Current Session

GET
/api/v2/auth/session

Reports the caller's own identity when the bearer token names a valid, current session. Fails with 401 otherwise, so this doubles as a lightweight session validity check for the console shell.

Get Current Session › Responses

The caller's own identity, returned only when the bearer token names a valid, current session -- so a successful call doubles as "am I signed in".

The caller's own identity, returned only when the bearer token names a valid, current session -- so a successful call doubles as "am I signed in".
​AuthUserResponse · required

Minimal caller identity carried on a session payload.

Deliberately not the full /console/me shape (kernel_http_v2_identity::contracts::me::MeResponse): /v2 feature crates do not depend on one another (see crates/http/CLAUDE.md), so this is a small, independent projection sized for what a session response needs, not a shared type.


Sign Up With Password

POST
/api/v2/auth/password/signup

Starts an email and password registration. Always reports the same acceptance whether or not the address already has an account: unlike /v1's equivalent, this response cannot be used to test whether an address is registered. referral_code, if given, is attributed atomically when the account is actually created (at POST /auth/password/verify, not here) -- an unknown, expired, or self-owned code never blocks or slows down signup, and never changes this response, so this route cannot be used to probe which referral codes are real. origin, if given, must be on the exact host of a registered OAuth redirect URL or an active reseller's own hostname, with at most one app mount path (/gaming, /console or /admin; V-0664), e.g. https://zaroz.cloud/gaming (ZC-1425) -- a disallowed or malformed value refuses the whole request with a 4xx, never a silent fallback to the platform dashboard.

Sign Up With Password › Request Body

Starts an email and password registration.
email
​string · required
password
​string · required
origin
​string | null

The app base the emailed verification link should point back at (ZC-1425, V-0664): https plus a host, optionally followed by exactly one app mount path -- https://zaroz.cloud/gaming, https://zaroz.cloud/console, https://zaroz.cloud/admin, or a reseller's https://<host>/gaming. A bare origin keeps working and links to the host root. No other path, no query, no fragment. The link is <origin><path>/verify-email?token=.... Checked against the same allowlist OAuth's own redirect_to uses; a disallowed or malformed origin refuses the whole request with a 4xx, never a silent fallback. Omit to keep the default: the dashboard.

referral_code
​string | null

A referral link's slug, if the visitor arrived through one (ZC-1032, owner ruling 2026-09-17: attribution happens at signup). Never validated here and never blocks or slows down signup, nor changes this route's response, whether the code is real, expired, self-owned, or absent -- see signup_handler's own doc comment for why.

Sign Up With Password › Responses

The outcome of POST /auth/password/signup, deliberately identical whether or not the address already has an account.

/v1's equivalent (PasswordSignupResponse in crates/http/identity/src/contracts/password_auth.rs) is a tagged union with an AlreadyRegistered { has_password } variant, returned verbatim whenever PasswordAuthService::sign_up resolves to SignupOutcome::AlreadyRegistered. That makes a signup attempt an account-existence oracle: posting any address and reading the response alone tells the caller whether it is registered. V2 does not carry that forward -- controllers::password::signup_handler collapses both SignupOutcome variants into this one response, so there is no second variant here to return by mistake.

The outcome of `POST /auth/password/signup`, deliberately identical whether or not the address already has an account. `/v1`'s equivalent (`PasswordSignupResponse` in `crates/http/identity/src/contracts/password_auth.rs`) is a tagged union with an `AlreadyRegistered { has_password }` variant, returned verbatim whenever `PasswordAuthService::sign_up` resolves to `SignupOutcome::AlreadyRegistered`. That makes a signup attempt an account-existence oracle: posting any address and reading the response alone tells the caller whether it is registered. V2 does not carry that forward -- `controllers::password::signup_handler` collapses both `SignupOutcome` variants into this one response, so there is no second variant here to return by mistake.
accepted
​boolean · required

Always true. Kept as a real typed field, rather than an empty Success(()), so a generated client gets an actual success body; there is nothing more to report here without reopening the oracle described above.


Verify Email Address

POST
/api/v2/auth/password/verify

Consumes the token emailed after signing up, creating the account (or attaching the password to whoever already owns the address), then signs the caller in.

Verify Email Address › Request Body

Completes a registration by submitting the token emailed after [`PasswordSignupRequest`].
token
​string · required

Verify Email Address › Responses

A new session

A freshly opened session. `token` is the bare access token: the caller adds the `Bearer ` scheme itself when sending it back as `Authorization`. (The V1 issuer used to return `"Bearer {token}"` here; that defect is fixed and V2 must not reproduce it.)
refresh_token
​string · required
session_id
​string · uuid · required
token
​string · required
​AuthUserResponse · required

Minimal caller identity carried on a session payload.

Deliberately not the full /console/me shape (kernel_http_v2_identity::contracts::me::MeResponse): /v2 feature crates do not depend on one another (see crates/http/CLAUDE.md), so this is a small, independent projection sized for what a session response needs, not a shared type.


Get Password Status

GET
/api/v2/auth/password/status

Reports the email and password state of the caller's OWN account: whether it has a password, whether its address is verified, and the provider address a first password would attach to. Answers only about the caller, so it is not a lookup.

Get Password Status › Responses

The email and password state of the signed-in caller's own account, for the security settings screen.

The email and password state of the signed-in caller's own account, for the security settings screen.
email_verified
​boolean · required
has_password
​boolean · required
email
​string | null

The address password login uses, or the provider address a first password would be attached to. None when neither exists.

linkable_email
​string | null

The provider-proven address POST /auth/password/set would attach a first password to. None once the account already has one.


Change Password

POST
/api/v2/auth/password/change

Changes the caller's password after confirming the current one, then signs every other session out. The caller's own session survives when current_session_id names it; omit that field to be signed out everywhere.

Change Password › Request Body

Changes the password of a caller who already has one.
current_password
​string · required
new_password
​string · required
current_session_id
​string | null · uuid

The caller's own session, spared when the change signs every other session out. Omit to be signed out everywhere, including here.

Change Password › Responses

The answer to every request that was accepted for processing and has nothing further to report.

Shared by forgot, reset, change, set and forgot-current rather than given a per-operation shape, because a per-operation shape is how an oracle gets built by accident: the moment one of these carries a field that varies with the account, the response starts describing an account the caller may not own. Modelled on [PasswordSignupResponse], which exists for the same reason.

The answer to every request that was accepted for processing and has nothing further to report. Shared by forgot, reset, change, set and forgot-current rather than given a per-operation shape, because a per-operation shape is how an oracle gets built by accident: the moment one of these carries a field that varies with the account, the response starts describing an account the caller may not own. Modelled on [`PasswordSignupResponse`], which exists for the same reason.
accepted
​boolean · required

Always true. A real typed field rather than an empty body so a generated client has a success type to name.


Set Initial Password

POST
/api/v2/auth/password/set

Sets a first password for a caller who has only ever signed in through a provider. The address is taken from the linked provider, which already proved it, so no verification email is sent and none is accepted from the request. Refused when the account already has a password: changing one requires the current password.

Set Initial Password › Request Body

Sets a first password for a caller who has only ever signed in through a provider. The address comes from that provider, never from the request.
new_password
​string · required

Set Initial Password › Responses

The answer to every request that was accepted for processing and has nothing further to report.

Shared by forgot, reset, change, set and forgot-current rather than given a per-operation shape, because a per-operation shape is how an oracle gets built by accident: the moment one of these carries a field that varies with the account, the response starts describing an account the caller may not own. Modelled on [PasswordSignupResponse], which exists for the same reason.

The answer to every request that was accepted for processing and has nothing further to report. Shared by forgot, reset, change, set and forgot-current rather than given a per-operation shape, because a per-operation shape is how an oracle gets built by accident: the moment one of these carries a field that varies with the account, the response starts describing an account the caller may not own. Modelled on [`PasswordSignupResponse`], which exists for the same reason.
accepted
​boolean · required

Always true. A real typed field rather than an empty body so a generated client has a success type to name.


Request Own Password Reset

POST
/api/v2/auth/password/forgot-current

Emails a password reset link to the caller's own address, for the "I forgot my current password" path on the security settings page. Takes no address: the account's own is used, so a signed-in caller cannot aim the mail at somebody else. The optional origin names the app URL the link should point back at (V-0853), under the same allowlist as requestPasswordReset's.

Request Own Password Reset › Request Body

Optional body of `POST /auth/password/forgot-current` (V-0853).
origin
​string | null

The app URL the emailed reset link should point back at, e.g. https://zaroz.cloud/console. Same shape and validation as [ForgotPasswordRequest::origin]; a disallowed or malformed value is a real 4xx refusal. Omitted, the link points at the platform dashboard.

Request Own Password Reset › Responses

The answer to every request that was accepted for processing and has nothing further to report.

Shared by forgot, reset, change, set and forgot-current rather than given a per-operation shape, because a per-operation shape is how an oracle gets built by accident: the moment one of these carries a field that varies with the account, the response starts describing an account the caller may not own. Modelled on [PasswordSignupResponse], which exists for the same reason.

The answer to every request that was accepted for processing and has nothing further to report. Shared by forgot, reset, change, set and forgot-current rather than given a per-operation shape, because a per-operation shape is how an oracle gets built by accident: the moment one of these carries a field that varies with the account, the response starts describing an account the caller may not own. Modelled on [`PasswordSignupResponse`], which exists for the same reason.
accepted
​boolean · required

Always true. A real typed field rather than an empty body so a generated client has a success type to name.


Request Password Reset

POST
/api/v2/auth/password/forgot

Emails a password reset link. Answers identically -- same status, same body -- whether or not the address has an account, and whether or not it is even a valid address, so this operation cannot be used to discover which addresses are registered. Rate limited per address and per source IP; tripping either limit is also indistinguishable from success. origin, if given, must be the exact origin of a registered OAuth redirect URL or an active reseller's own hostname (ZC-1425), with at most one app mount path (/gaming, /console or /admin; V-0853), so the link lands on the app the request came from; a disallowed or malformed value IS a real 4xx refusal -- unlike the address itself, it is not account-existence information.

Request Password Reset › Request Body

Requests a password reset link for an address the caller claims to own. See `controllers::password_recovery::forgot_handler` for the refusal rule: this operation answers identically for a registered and an unregistered address, which is the whole reason it takes an address in a body and returns no information about it.
email
​string · required
origin
​string | null

The origin the emailed reset link should point back at (ZC-1425). Same shape and validation as [PasswordSignupRequest::origin], including the optional app mount path (https://zaroz.cloud/gaming). Unlike the address above, a disallowed origin here is NOT hidden behind the identical-response refusal: it is not account-existence information, so forgot_handler refuses it with a real 4xx.

Request Password Reset › Responses

Always this, for every address

The answer to every request that was accepted for processing and has nothing further to report. Shared by forgot, reset, change, set and forgot-current rather than given a per-operation shape, because a per-operation shape is how an oracle gets built by accident: the moment one of these carries a field that varies with the account, the response starts describing an account the caller may not own. Modelled on [`PasswordSignupResponse`], which exists for the same reason.
accepted
​boolean · required

Always true. A real typed field rather than an empty body so a generated client has a success type to name.


Complete Password Reset

POST
/api/v2/auth/password/reset

Sets a new password from an emailed reset token, or a first password when the account has none. The token is single use, expiring, and matched by hash; an unknown, expired and already-spent token are one indistinguishable refusal. Succeeding revokes every one of the account's sessions, including the caller's, because whoever holds them may be exactly who the reset is meant to lock out.

Complete Password Reset › Request Body

Sets a password from an emailed reset token. Carries no email address on purpose. The token alone decides which account and which address are affected (see `PasswordManagementService::reset`), so an address in the body would be either ignored or, worse, trusted.
password
​string · required
token
​string · required

Complete Password Reset › Responses

The answer to every request that was accepted for processing and has nothing further to report.

Shared by forgot, reset, change, set and forgot-current rather than given a per-operation shape, because a per-operation shape is how an oracle gets built by accident: the moment one of these carries a field that varies with the account, the response starts describing an account the caller may not own. Modelled on [PasswordSignupResponse], which exists for the same reason.

The answer to every request that was accepted for processing and has nothing further to report. Shared by forgot, reset, change, set and forgot-current rather than given a per-operation shape, because a per-operation shape is how an oracle gets built by accident: the moment one of these carries a field that varies with the account, the response starts describing an account the caller may not own. Modelled on [`PasswordSignupResponse`], which exists for the same reason.
accepted
​boolean · required

Always true. A real typed field rather than an empty body so a generated client has a success type to name.


List OAuth Providers

GET
/api/v2/auth/oauth/providers

Lists the OAuth providers the sign-in page should offer, resolved from the host the request arrived on. A reseller host gets the platform providers it has not hidden plus its own; any other host gets the platform providers only. The same answer for every visitor on a host: it describes the site, not any account.

List OAuth Providers › Responses

One provider a visitor may sign in with, as the sign-in page renders it. Carries the id and a display name and nothing else. It deliberately does not say which providers any particular account uses -- that is `GET /auth/oauth/accounts`, which answers only about the caller's own account and needs a session to do it.
id
​string · required
display_name
​string | null

A reseller-chosen label. None means the client should render the provider's own conventional name from the id.


Complete OAuth Login

POST
/api/v2/auth/oauth/complete

Redeems the single-use completion key the callback handed the client, opening a session. The same SessionResponse a password login's second leg returns, so a client stores one shape of session however it was obtained. An unknown, expired or already-redeemed key is one indistinguishable refusal.

Complete OAuth Login › Request Body

Redeems the single-use completion key the callback handed the client. A body rather than `/v1`'s `?key=` query string, and the operation is a POST rather than a GET, because the key is a bearer credential for a whole session: a query string is written into browser history, into `Referer` on the next navigation, and into every proxy and access log on the path. The same reasoning `CUSTOMER_FACING_V2_WRITES` records for the console family's ticket-minting POSTs applies here, and more sharply -- this one mints the session itself.
key
​string · required

Complete OAuth Login › Responses

A new session

A freshly opened session. `token` is the bare access token: the caller adds the `Bearer ` scheme itself when sending it back as `Authorization`. (The V1 issuer used to return `"Bearer {token}"` here; that defect is fixed and V2 must not reproduce it.)
refresh_token
​string · required
session_id
​string · uuid · required
token
​string · required
​AuthUserResponse · required

Minimal caller identity carried on a session payload.

Deliberately not the full /console/me shape (kernel_http_v2_identity::contracts::me::MeResponse): /v2 feature crates do not depend on one another (see crates/http/CLAUDE.md), so this is a small, independent projection sized for what a session response needs, not a shared type.


List Own OAuth Accounts

GET
/api/v2/auth/oauth/accounts

Lists the providers linked to the CALLER'S OWN account. Takes no user id and accepts none: there is no shape of this request that asks about somebody else. Provider access and refresh tokens are stored but never published.

List Own OAuth Accounts › Responses

A provider account linked to the caller's own user. Carries no token. `OAuthAccount` stores the provider access and refresh tokens it obtained, and neither is a thing a client has any use for; a response that included them would hand every reader of one page a credential for the customer's Discord or Google account.
has_avatar
​boolean · required

Whether this provider has a stored avatar that could be activated. The URL itself is not published: it is a provider-side resource the kernel imports through AvatarImportService, not something a client should be fetching directly.

provider_id
​string · required
email
​string | null

The address the provider reports for this account, when it reports one. Only ever the caller's own.

last_used_at
​string | null · date-time

DELETE
/api/v2/auth/oauth/accounts/{provider}

Unlinks a provider from the caller's own account. Refused when it would be the last linked account, because that would leave an account nobody can sign in to.

provider
​string · required · style: simple

Unlinked

No data returned

GET
/api/v2/auth/oauth/link/{provider}/start

Starts a provider link for the signed-in caller, so the callback attaches the provider to the account they are already using instead of creating a second one. The user is taken from the session, never from the request; reseller_id is ignored here because an existing account already has its attribution.

Start OAuth Link › path Parameters

provider
​string · required · style: simple

Start OAuth Link › query Parameters

redirect_to
​string · required · style: form

Where the provider callback should send the browser once it is done. Validated against the registered redirect-URL allowlist by OAuthLoginService, so an arbitrary value is refused rather than honoured; that check is what stops this being an open redirect and a token-exfiltration path.

Where the provider callback should send the browser once it is done. Validated against the registered redirect-URL allowlist by OAuthLoginService, so an arbitrary value is refused rather than honoured; that check is what stops this being an open redirect and a token-exfiltration path.

referral_code
​string | null · style: form

A referral link's slug, if the visitor arrived through one (ZC-1032, owner ruling 2026-09-17: attribution happens at signup). Only meaningful on login, for the identical reason reseller_id is: link_start_handler destructures this struct with .. and never reads it. Never validated here, and never changes this route's response -- attribution is resolved by callback_login only if and when it creates a brand new account.

A referral link's slug, if the visitor arrived through one (ZC-1032, owner ruling 2026-09-17: attribution happens at signup). Only meaningful on login, for the identical reason reseller_id is: link_start_handler destructures this struct with .. and never reads it. Never validated here, and never changes this route's response -- attribution is resolved by callback_login only if and when it creates a brand new account.

reseller_id
​string | null · uuid · style: form

The reseller whose storefront the visitor started from, when it is not the platform's own. Only meaningful on login: a link flow acts on an account that already exists and already has its attribution.

The reseller whose storefront the visitor started from, when it is not the platform's own. Only meaningful on login: a link flow acts on an account that already exists and already has its attribution.

Start OAuth Link › Responses

Where to send the visitor next.

Where to send the visitor next.
redirect_to
​string · required

Start OAuth Login

GET
/api/v2/auth/oauth/{provider}/start

Starts an OAuth login and returns the provider's authorize URL for the client to send the visitor to. redirect_to must be one of the registered redirect URLs; an unregistered value is refused rather than honoured, which is what keeps this from being an open redirect. referral_code, if given, is attributed atomically only if and when the provider's callback actually creates a new account -- an unknown, expired, or self-owned code never blocks or slows down login, and never changes this response.

Start OAuth Login › path Parameters

provider
​string · required · style: simple

Start OAuth Login › query Parameters

redirect_to
​string · required · style: form

Where the provider callback should send the browser once it is done. Validated against the registered redirect-URL allowlist by OAuthLoginService, so an arbitrary value is refused rather than honoured; that check is what stops this being an open redirect and a token-exfiltration path.

Where the provider callback should send the browser once it is done. Validated against the registered redirect-URL allowlist by OAuthLoginService, so an arbitrary value is refused rather than honoured; that check is what stops this being an open redirect and a token-exfiltration path.

referral_code
​string | null · style: form

A referral link's slug, if the visitor arrived through one (ZC-1032, owner ruling 2026-09-17: attribution happens at signup). Only meaningful on login, for the identical reason reseller_id is: link_start_handler destructures this struct with .. and never reads it. Never validated here, and never changes this route's response -- attribution is resolved by callback_login only if and when it creates a brand new account.

A referral link's slug, if the visitor arrived through one (ZC-1032, owner ruling 2026-09-17: attribution happens at signup). Only meaningful on login, for the identical reason reseller_id is: link_start_handler destructures this struct with .. and never reads it. Never validated here, and never changes this route's response -- attribution is resolved by callback_login only if and when it creates a brand new account.

reseller_id
​string | null · uuid · style: form

The reseller whose storefront the visitor started from, when it is not the platform's own. Only meaningful on login: a link flow acts on an account that already exists and already has its attribution.

The reseller whose storefront the visitor started from, when it is not the platform's own. Only meaningful on login: a link flow acts on an account that already exists and already has its attribution.

Start OAuth Login › Responses

Where to send the visitor next.

Where to send the visitor next.
redirect_to
​string · required

OAuth Login Callback

GET
/api/v2/auth/oauth/{provider}/callback

The URL the provider returns the visitor to. Exchanges the authorization code, resolves or creates the account, and sends the browser back to the client URL the flow started with, carrying a single-use completion key to redeem at POST /auth/oauth/complete. A recoverable failure redirects to that same already validated URL with an error code (email_conflict, email_required, reseller_paused, login_failed) instead of stranding the visitor on an API host. Answers 307 to a browser; a caller that asks for application/json and does not accept HTML gets the same target as a 200 body instead, which is how a non-browser client can observe the outcome without following it.

OAuth Login Callback › path Parameters

provider
​string · required · style: simple

OAuth Login Callback › query Parameters

code
​string · required · style: form
state
​string · required · style: form

The CSRF value minted when the flow started. Checked against the stored authorization session before anything is exchanged.

The CSRF value minted when the flow started. Checked against the stored authorization session before anything is exchanged.

OAuth Login Callback › Responses

Where the browser would have been sent, for a JSON caller

Where to send the visitor next.
redirect_to
​string · required

Get Sudo Status

GET
/api/v2/auth/sudo/status

Reports whether the caller's own session is currently elevated and until when, which elevation methods this API can verify, and which the caller has enrolled. Never elevation-gated: this is how a caller finds out whether it needs to elevate. Answers only about the bearer token on this request.

Get Sudo Status › Responses

Whether the caller's own session is elevated, and what it could elevate with (ZC-0412).

This route existing at all is the fix for a real defect: admin read the missing route's 404 as "not elevated", so its gate rendered over sessions that had been elevated the whole time.

Whether the caller's own session is elevated, and what it could elevate with (ZC-0412). This route existing at all is the fix for a real defect: `admin` read the missing route's 404 as "not elevated", so its gate rendered over sessions that had been elevated the whole time.
available_methods
​ElevationMethod[] · required

The elevation methods this surface can actually verify.

Was PIN-only before ZC-0716: /v2 ported no challenge-issuing route, so a client offered webauthn or totp here would have had no way to produce a secret for either. Now equal to AdminElevationService::available_methods() (every module registered in the deployment), because ZC-0716 added the missing enroll/verify challenge routes for both. See controllers::sudo::V2_ELEVATION_METHODS.

Enum values:
pin
webauthn
totp
enrolled_methods
​ElevationMethod[] · required

Every method the caller has actually enrolled, unfiltered, which can include a method available_methods does not list.

That asymmetry is intentional rather than an oversight: a user who enrolled TOTP while /v1 existed still has that credential, and it is what makes POST /auth/sudo/setup demand an already-elevated session before adding a second method (AdminElevationService::enroll). A client that saw only the filtered list would have no way to explain that refusal.

Enum values:
pin
webauthn
totp
is_elevated
​boolean · required

Whether the bearer token on THIS request is elevated right now. Equal to elevated_until.is_some(), and published as its own field so a client never has to compare a timestamp against its own clock to answer the only question it usually asks. elevated_until is already filtered against server time by utils::elevation::elevated_until, so an expired window reads as false with a null deadline rather than as a past date.

elevated_until
​string | null · date-time

When the current elevation lapses, or null when the caller is not elevated.


Set Up Sudo PIN

POST
/api/v2/auth/sudo/setup

Enrolls the caller in PIN-based sudo elevation and immediately elevates their session. The caller must already be signed in as a real user (not a restricted API token). Adding a second method to an account that already has one requires an already-elevated session. V2 ships PIN only; see this module's doc comment for what /v1's elevation surface is not ported.

Set Up Sudo PIN › Request Body

Enrolls the caller in PIN-based sudo elevation. `/v1`'s equivalent (`crates/http/identity/src/controllers/auth/sudo.rs`) takes `{method, secret}` so one route can also serve WebAuthn and TOTP enrollment. This contract stays PIN-only and keeps its own shape rather than growing a `method` selector: WebAuthn and TOTP now have their own dedicated `/v2` enroll routes (ZC-0716, see `controllers::sudo`'s doc comment), each with a request type shaped for that method's actual enrollment payload (a ceremony response, a confirmation code) rather than an untyped `secret: String` both would have to share here.
pin
​string · required

Six ASCII digits. Format is validated by AdminElevationService.

Set Up Sudo PIN › Responses

The caller's newly elevated session

A freshly elevated session, minted immediately on successful enrollment. `token` is bare: the caller adds the `Bearer ` scheme itself, matching `contracts::session::SessionResponse::token` (see its doc comment on the `/v1` issuer defect that used to prepend the scheme; that is fixed at the source now and V2 does not need to work around it).
elevated_until
​string · date-time · required
token
​string · required

Elevate Sudo Session

POST
/api/v2/auth/sudo/elevate

Verifies the caller's enrolled PIN and returns a newly signed access token carrying a fresh elevation window. The presented token is not revoked and stays valid, un-elevated, until its own expiry; the refresh token is not rotated. A wrong PIN and an account with no PIN enrolled are answered identically, so this cannot be used to discover whether an account has a second factor. Attempts are counted per user and shared with the setup route: five per fifteen minutes, cleared on success.

Elevate Sudo Session › Request Body

Re-elevates a session whose PIN is already enrolled (ZC-0412). Field-identical to [`SudoSetupRequest`] and deliberately a separate type rather than a reuse of it: the two operations mean different things to a generated client (one enrolls a new secret, the other proves a stored one), and sharing the schema would make a later divergence -- a `method` selector once `/v2` supports more than PIN, a recovery code on enrolment -- a breaking rename of the other operation's request body.
pin
​string · required

The six ASCII digits the caller enrolled. A wrong PIN and an account with no PIN enrolled are answered identically; see controllers::sudo::elevate_error_response.

Elevate Sudo Session › Responses

The caller's newly elevated session

A freshly elevated session, minted on a successful PIN verification. **`token` is a newly signed access token, not a mutation of the one the caller sent.** It carries a fresh one-hour `expires_at` and an `elevated_until` eight hours out (`ElevationMethod::Pin`'s `elevation_window`). The caller must replace its stored access token with this one; nothing else in the session changes, and in particular the refresh token is **not** rotated. **The token the caller presented stays valid until its own `expires_at`.** There is no denylist, so this operation cannot revoke it. That is not a downgrade risk on its own -- the old token is the *un*-elevated one, so a caller that keeps using it simply keeps failing the elevation gate -- but a client that stores both will look intermittently un-elevated. Keep one. One consequence worth stating because it has already bitten this codebase: elevation lives in the JWT claim, so `POST /auth/refresh` carries it forward only when the caller also sends the elevated access token as an `Authorization` header (`utils::elevation::elevated_until_for_refresh`, which tolerates an already-expired one). A refresh that omits it returns a plain token and silently drops the elevation minted here. `token` is bare, for the same reason [`SudoSetupResponse::token`] is.
elevated_until
​string · date-time · required
token
​string · required

Disable Sudo Method

POST
/api/v2/auth/sudo/disable

Removes the caller's own enrollment in an elevation method. Requires the caller's session to already be elevated; unlike enrolling or verifying, this is never reachable from an un-elevated session.

Disable Sudo Method › Request Body

Removes one of the caller's own enrolled elevation methods (ZC-0716, `/v1` parity). Requires the caller's session to already be elevated -- see `controllers::sudo::disable_handler`.
method
​ElevationMethod · enum · required

Which admin-elevation module a credential belongs to, or a verification attempt is checked against. AdminElevationModule gives each method (PIN, WebAuthn, TOTP) a home behind this same enum without touching every caller that only cares about routing by name. Serializable so callers of /auth/sudo/* can pick a method by name instead of the HTTP layer assuming PIN.

Enum values:
pin
webauthn
totp

Disable Sudo Method › Responses

Method disabled

No data returned

Exit Sudo Session

POST
/api/v2/auth/sudo/exit

Drops the caller's own sudo elevation early, before it naturally expires.

Exit Sudo Session › Responses

The caller's newly de-elevated token

Reissues a plain, non-elevated token so the caller can drop sudo early (ZC-0716, `/v1` parity). See `AdminElevationService::exit`'s doc comment on why the previously elevated token is not itself revoked.
token
​string · required

Begin TOTP Enrollment

POST
/api/v2/auth/sudo/totp/enroll-options

Generates a TOTP shared secret (QR code URI + manual-entry string) for the caller to confirm with a code from their authenticator app via POST /auth/sudo/totp/enroll. Adding a second method to an account that already has one requires an already-elevated session, the same gate POST /auth/sudo/setup enforces.

Begin TOTP Enrollment › Responses

A pending TOTP enrollment, valid for 5 minutes

A freshly generated TOTP shared secret, ready to show as a QR code or a manual-entry string (ZC-0716). Round-trips `AdminElevationModule::begin_enroll`'s JSON via `serde_json::from_value` into a named shape instead of forwarding the `serde_json::Value` opaquely the way the WebAuthn challenge responses do below: unlike a WebAuthn ceremony object (an external library's wire format, `/v1` deliberately left untyped, see `controllers::sudo`'s `webauthn_register_options_docs`), this JSON is produced entirely by `TotpElevationModule::begin_enroll` in this codebase, so there is no third-party shape to leave opaque.
account
​string · required
issuer
​string · required
otpauth_url
​string · required

otpauth:// URI encoding the same secret, issuer and account name, for a QR code.

secret
​string · required

Base32-encoded shared secret, for manual entry.


Confirm TOTP Enrollment

POST
/api/v2/auth/sudo/totp/enroll

Confirms a pending TOTP enrollment with a code from the authenticator app and immediately elevates the caller's session. The pending secret from /auth/sudo/totp/enroll-options must still be within its 5-minute window; an expired or already-consumed-then-abandoned enrollment, or a wrong code, are both answered as an invalid-field-value with a human-readable reason, never as success.

Confirm TOTP Enrollment › Request Body

Confirms a pending TOTP enrollment with a code from the authenticator app (ZC-0716). A separate, dedicated route (`POST /auth/sudo/totp/enroll`) rather than a `method` branch on [`SudoSetupRequest`], for the same contract-typing reason that request's own doc comment gives.
code
​string · required

The current 6-digit code from the authenticator app. Verified against the secret POST /auth/sudo/totp/enroll-options generated for this caller, which must still be pending (ENROLL_TTL_SECONDS, 5 minutes) -- see TotpElevationModule::read_pending_secret.

Confirm TOTP Enrollment › Responses

The caller's newly elevated session

A freshly elevated session, minted immediately on successful enrollment. `token` is bare: the caller adds the `Bearer ` scheme itself, matching `contracts::session::SessionResponse::token` (see its doc comment on the `/v1` issuer defect that used to prepend the scheme; that is fixed at the source now and V2 does not need to work around it).
elevated_until
​string · date-time · required
token
​string · required

Verify TOTP Code

POST
/api/v2/auth/sudo/totp/verify

Verifies a code from the caller's enrolled authenticator app and returns a newly signed access token carrying a fresh elevation window. The presented token is not revoked; the refresh token is not rotated. A wrong code, an already-used code and an account with no TOTP enrolled are answered identically, so this cannot be used to discover whether an account has TOTP enrolled. Attempts are counted per user and shared with the PIN and passkey routes: five per fifteen minutes, cleared on success.

Verify TOTP Code › Request Body

Re-elevates a session with an already-enrolled TOTP authenticator (ZC-0778). The TOTP counterpart of [`SudoElevateRequest`] and [`WebauthnAuthenticateConfirmRequest`], and field-identical to [`TotpEnrollConfirmRequest`] while deliberately a separate type, for the reason [`SudoElevateRequest`]'s own doc comment gives: one confirms an enrollment, the other proves one.
code
​string · required

The current 6-digit code from the enrolled authenticator app. A wrong code, an already-used code and an account with no TOTP enrolled are answered identically; see controllers::sudo::elevate_error_response.

Verify TOTP Code › Responses

The caller's newly elevated session

A freshly elevated session, minted on a successful PIN verification. **`token` is a newly signed access token, not a mutation of the one the caller sent.** It carries a fresh one-hour `expires_at` and an `elevated_until` eight hours out (`ElevationMethod::Pin`'s `elevation_window`). The caller must replace its stored access token with this one; nothing else in the session changes, and in particular the refresh token is **not** rotated. **The token the caller presented stays valid until its own `expires_at`.** There is no denylist, so this operation cannot revoke it. That is not a downgrade risk on its own -- the old token is the *un*-elevated one, so a caller that keeps using it simply keeps failing the elevation gate -- but a client that stores both will look intermittently un-elevated. Keep one. One consequence worth stating because it has already bitten this codebase: elevation lives in the JWT claim, so `POST /auth/refresh` carries it forward only when the caller also sends the elevated access token as an `Authorization` header (`utils::elevation::elevated_until_for_refresh`, which tolerates an already-expired one). A refresh that omits it returns a plain token and silently drops the elevation minted here. `token` is bare, for the same reason [`SudoSetupResponse::token`] is.
elevated_until
​string · date-time · required
token
​string · required

Begin Passkey Registration

POST
/api/v2/auth/sudo/webauthn/register-options

Generates a WebAuthn passkey registration challenge for the caller to pass to navigator.credentials.create, then confirm via POST /auth/sudo/webauthn/register. Adding a second method to an account that already has one requires an already-elevated session.

Begin Passkey Registration › Responses

WebAuthn creation options

No data returned

Confirm Passkey Registration

POST
/api/v2/auth/sudo/webauthn/register

Confirms a passkey registration ceremony against the challenge /auth/sudo/webauthn/register-options issued, and immediately elevates the caller's session. The challenge is single-use (consumed on this call, whether it succeeds or not) and expires after 5 minutes; a stale, replayed or malformed ceremony response is answered as an invalid-field-value, never as success.

Confirm Passkey Registration › Request Body

Confirms a passkey registration ceremony (ZC-0716). `credential` is the browser's `RegisterPublicKeyCredential` response (from `navigator.credentials.create`), serialized to a string before being passed to `AdminElevationService::enroll` -- see `WebauthnElevationModule::enroll`'s own doc comment on why that module takes a JSON string rather than a typed struct (it parses and verifies the ceremony itself, against the challenge state `POST /auth/sudo/webauthn/register-options` stashed in Valkey). Carried here as `serde_json::Value` rather than `String` so an OpenAPI client sends the ceremony object as-is instead of double-encoding it.
credential
​required

Arbitrary JSON.

Confirm Passkey Registration › Responses

The caller's newly elevated session

A freshly elevated session, minted immediately on successful enrollment. `token` is bare: the caller adds the `Bearer ` scheme itself, matching `contracts::session::SessionResponse::token` (see its doc comment on the `/v1` issuer defect that used to prepend the scheme; that is fixed at the source now and V2 does not need to work around it).
elevated_until
​string · date-time · required
token
​string · required

Begin Passkey Authentication

POST
/api/v2/auth/sudo/webauthn/authenticate-options

Generates a WebAuthn passkey authentication challenge for the caller to pass to navigator.credentials.get, then confirm via POST /auth/sudo/webauthn/authenticate. An account with no passkey enrolled is answered with an invalid-field-value naming that, which is safe here (unlike the collapsed PIN/elevate error) because this route only ever answers about the caller's own account.

Begin Passkey Authentication › Responses

WebAuthn request options

No data returned

Confirm Passkey Authentication

POST
/api/v2/auth/sudo/webauthn/authenticate

Verifies a passkey authentication ceremony against the challenge /auth/sudo/webauthn/authenticate-options issued, and elevates the caller's session on success. The challenge is single-use and expires after 5 minutes. No enrollment, a failed assertion, and a stale or malformed response are all answered identically, so this cannot be used to discover whether an account has a passkey enrolled.

Confirm Passkey Authentication › Request Body

Confirms a passkey authentication ceremony (ZC-0716). Field-identical to [`WebauthnRegisterConfirmRequest`] and deliberately a separate type: one confirms an enrollment, the other proves one, matching [`SudoElevateRequest`]'s own reasoning for not sharing [`SudoSetupRequest`]'s schema.
credential
​required

The browser's PublicKeyCredential assertion response (from navigator.credentials.get).

Confirm Passkey Authentication › Responses

The caller's newly elevated session

A freshly elevated session, minted on a successful PIN verification. **`token` is a newly signed access token, not a mutation of the one the caller sent.** It carries a fresh one-hour `expires_at` and an `elevated_until` eight hours out (`ElevationMethod::Pin`'s `elevation_window`). The caller must replace its stored access token with this one; nothing else in the session changes, and in particular the refresh token is **not** rotated. **The token the caller presented stays valid until its own `expires_at`.** There is no denylist, so this operation cannot revoke it. That is not a downgrade risk on its own -- the old token is the *un*-elevated one, so a caller that keeps using it simply keeps failing the elevation gate -- but a client that stores both will look intermittently un-elevated. Keep one. One consequence worth stating because it has already bitten this codebase: elevation lives in the JWT claim, so `POST /auth/refresh` carries it forward only when the caller also sends the elevated access token as an `Authorization` header (`utils::elevation::elevated_until_for_refresh`, which tolerates an already-expired one). A refresh that omits it returns a plain token and silently drops the elevation minted here. `token` is bare, for the same reason [`SudoSetupResponse::token`] is.
elevated_until
​string · date-time · required
token
​string · required