// -- RFC 8628 Device Authorization Grant --------------------------------------
//
// Wire types shared between the relay (which hosts the endpoints) and the
// server (which consumes them during first-run pairing). See
// `docs/plans/2026-04-10-device-flow-typed-proto-design.md` for context.
// -- DeviceAuthorizationRequest -----------------------------------------------
///|
pub(all) struct DeviceAuthorizationRequest {
hostname : String
}
///|
pub impl ToJson for DeviceAuthorizationRequest with fn to_json(self) {
{ "hostname": self.hostname }
}
///|
pub impl @json.FromJson for DeviceAuthorizationRequest with fn from_json(
json,
path,
) {
guard json is Object(fields) else {
raise JsonDecodeError((path, "expected JSON object"))
}
guard fields.get("hostname") is Some(String(hostname)) else {
raise JsonDecodeError((path, "missing field 'hostname'"))
}
{ hostname, }
}
// -- DeviceAuthorizationResponse ----------------------------------------------
///|
pub(all) struct DeviceAuthorizationResponse {
device_code : String
user_code : String
verification_uri : String
verification_uri_complete : String?
expires_in : Int
interval : Int
}
///|
pub impl ToJson for DeviceAuthorizationResponse with fn to_json(self) {
// Emit `verification_uri_complete` only when present so the wire format is
// byte-identical to the pre-refactor hand-rolled relay response when the
// caller passes None.
match self.verification_uri_complete {
Some(complete) =>
{
"device_code": self.device_code,
"user_code": self.user_code,
"verification_uri": self.verification_uri,
"verification_uri_complete": complete,
"expires_in": self.expires_in,
"interval": self.interval,
}
None =>
{
"device_code": self.device_code,
"user_code": self.user_code,
"verification_uri": self.verification_uri,
"expires_in": self.expires_in,
"interval": self.interval,
}
}
}
///|
pub impl @json.FromJson for DeviceAuthorizationResponse with fn from_json(
json,
path,
) {
guard json is Object(fields) else {
raise JsonDecodeError((path, "expected JSON object"))
}
guard fields.get("device_code") is Some(String(device_code)) else {
raise JsonDecodeError((path, "missing field 'device_code'"))
}
guard fields.get("user_code") is Some(String(user_code)) else {
raise JsonDecodeError((path, "missing field 'user_code'"))
}
guard fields.get("verification_uri") is Some(String(verification_uri)) else {
raise JsonDecodeError((path, "missing field 'verification_uri'"))
}
let verification_uri_complete : String? = match
fields.get("verification_uri_complete") {
Some(String(s)) => Some(s)
_ => None
}
guard fields.get("expires_in") is Some(Number(expires_in, ..)) else {
raise JsonDecodeError((path, "missing field 'expires_in'"))
}
guard fields.get("interval") is Some(Number(interval, ..)) else {
raise JsonDecodeError((path, "missing field 'interval'"))
}
{
device_code,
user_code,
verification_uri,
verification_uri_complete,
expires_in: expires_in.to_int(),
interval: interval.to_int(),
}
}
// -- DeviceTokenRequest -------------------------------------------------------
///|
pub(all) struct DeviceTokenRequest {
grant_type : String
device_code : String
}
///|
pub impl ToJson for DeviceTokenRequest with fn to_json(self) {
{ "grant_type": self.grant_type, "device_code": self.device_code }
}
///|
pub impl @json.FromJson for DeviceTokenRequest with fn from_json(json, path) {
guard json is Object(fields) else {
raise JsonDecodeError((path, "expected JSON object"))
}
guard fields.get("grant_type") is Some(String(grant_type)) else {
raise JsonDecodeError((path, "missing field 'grant_type'"))
}
guard fields.get("device_code") is Some(String(device_code)) else {
raise JsonDecodeError((path, "missing field 'device_code'"))
}
{ grant_type, device_code }
}
// -- DeviceTokenError ---------------------------------------------------------
///|
/// RFC 8628 §3.5 token-endpoint error codes, plus `Unknown(String)` as a
/// forward-compatibility hatch for codes added by a future relay against
/// an older server. Wire format is a bare JSON string (see
/// `DeviceTokenResponse::Error`).
pub(all) enum DeviceTokenError {
AuthorizationPending
SlowDown
ExpiredToken
AccessDenied
InvalidRequest
UnsupportedGrantType
Unknown(String)
}
///|
pub impl ToJson for DeviceTokenError with fn to_json(self) {
let s = match self {
AuthorizationPending => "authorization_pending"
SlowDown => "slow_down"
ExpiredToken => "expired_token"
AccessDenied => "access_denied"
InvalidRequest => "invalid_request"
UnsupportedGrantType => "unsupported_grant_type"
Unknown(s) => s
}
Json::string(s)
}
///|
pub impl @json.FromJson for DeviceTokenError with fn from_json(json, path) {
guard json is String(s) else {
raise JsonDecodeError((path, "expected JSON string for error code"))
}
match s {
"authorization_pending" => AuthorizationPending
"slow_down" => SlowDown
"expired_token" => ExpiredToken
"access_denied" => AccessDenied
"invalid_request" => InvalidRequest
"unsupported_grant_type" => UnsupportedGrantType
other => Unknown(other)
}
}
// -- DeviceTokenResponse ------------------------------------------------------
///|
/// RFC 8628 §3.5 token-endpoint response. Discriminated on the wire by
/// which field is present: `access_token` → Success, `error` → Error.
pub(all) enum DeviceTokenResponse {
Success(access_token~ : String, token_type~ : String)
Error(error~ : DeviceTokenError)
}
///|
pub impl ToJson for DeviceTokenResponse with fn to_json(self) {
match self {
Success(access_token~, token_type~) =>
{ "access_token": access_token, "token_type": token_type }
Error(error~) => { "error": error.to_json() }
}
}
///|
pub impl @json.FromJson for DeviceTokenResponse with fn from_json(json, path) {
guard json is Object(fields) else {
raise JsonDecodeError((path, "expected JSON object"))
}
// Presence of `access_token` wins over `error` if both are somehow set —
// matches the server's previous ordering in main.mbt (checked access_token
// first, error second).
match fields.get("access_token") {
Some(String(access_token)) => {
let token_type = match fields.get("token_type") {
Some(String(s)) => s
_ => "bearer"
}
return Success(access_token~, token_type~)
}
_ => ()
}
match fields.get("error") {
Some(error_json) => {
let error : DeviceTokenError = @json.from_json(error_json)
return Error(error~)
}
_ => ()
}
raise JsonDecodeError(
(path, "device token response missing both 'access_token' and 'error'"),
)
}