Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
065ad8e
[PLA-2184] Android - Add the tap-to-pay config route to the device se…
alex-arguello Aug 14, 2026
807a90a
[PLA-2184] Android - Add the card-present session state machine and s…
alex-arguello Aug 14, 2026
01ea15b
[PLA-2184] Android - Hold a session claim per kind, so a build joins …
alex-arguello Aug 14, 2026
d0cfc85
[PLA-2184] Android - Name both repair failures on the entry point tha…
alex-arguello Aug 14, 2026
97faa71
[PLA-2184] Android - Keep one list of the session states for the test…
alex-arguello Aug 14, 2026
795065e
[PLA-2184] Android - Say which tests hold each half of the serialization
alex-arguello Aug 14, 2026
ea3a111
[PLA-2184] Android - Comment pass over the session package and the co…
alex-arguello Aug 14, 2026
ba9457f
[PLA-2184] Android - Name the state a refused move was decided against
alex-arguello Aug 14, 2026
41d6f17
[PLA-2184] Android - Say what a warm start does to the attestation state
alex-arguello Aug 14, 2026
caa0d69
[PLA-2184] Android - Join the coroutines these tests cancel, before a…
alex-arguello Aug 14, 2026
c0d3ab5
[PLA-2184] Android - Cover the failure classifier, which decides ever…
alex-arguello Aug 14, 2026
7c685e5
[PLA-2184] Android - Fail fast when starting over is refused
alex-arguello Aug 15, 2026
c3a09bf
[PLA-2184] Android - Name the shape an entry point must have when one…
alex-arguello Aug 15, 2026
6e05ec0
[PLA-2184] Android - Separate the representative failure from the fai…
alex-arguello Aug 15, 2026
8f5e620
[PLA-2184] Android - Say which side of the wire an internal failure i…
alex-arguello Aug 15, 2026
db2081d
[PLA-2184] Android - Land a lost device key on attesting again, not o…
alex-arguello Aug 15, 2026
33139ef
[PLA-2184] Android - Tell a joiner the run failed when the owner died…
alex-arguello Aug 15, 2026
8d83ef4
[PLA-2184] Android - Say path where the script means path
alex-arguello Aug 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,14 @@ internal object LoggableFieldNames {
// Non-secret claim vocabulary.
"aal",
"scope",
// Lifecycle and state.
// Lifecycle and state. `fromstate` and `tostate` are the same fixed vocabulary as `state`, and
// both are needed together: a record of a refused transition that names only one end of it says
// nothing about why the move was refused.
"event",
"phase",
"state",
"fromstate",
"tostate",
"category",
// Transport metadata. `route` is the route template, never a resolved path.
"route",
Expand All @@ -54,9 +58,9 @@ internal object LoggableFieldNames {
"retryable",
"durationms",
"elapsedms",
// Three distinct durations, deliberately not collapsed into one name: `timeoutms` is the
// backoff wait before the next attempt, `totaltimeoutms` the retry budget, `calltimeoutms`
// the ceiling on one whole call. An incident reads differently depending on which ran out.
// Three distinct durations. `timeoutms` is the backoff wait before the next attempt,
// `totaltimeoutms` the retry budget, `calltimeoutms` the ceiling on one whole call. An
// incident reads differently depending on which ran out.
"timeoutms",
"totaltimeoutms",
"calltimeoutms",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,18 @@ import com.payabli.sdk.core.network.PayabliEnvelope
import com.payabli.sdk.core.network.PayabliHttpErrors
import com.payabli.sdk.core.network.PayabliJson
import com.payabli.sdk.core.network.PayabliRequest
import com.payabli.sdk.core.network.PayabliResponse
import com.payabli.sdk.core.network.PayabliTransport
import com.payabli.sdk.taptopay.attestation.AttestationToken
import kotlinx.serialization.KSerializer
import kotlinx.serialization.SerializationException
import java.net.HttpURLConnection.HTTP_FORBIDDEN

/** Unreserved characters, per RFC 3986 Section 2.3. What an entry point may hold to be one path segment. */
private val PATH_SEGMENT = Regex("^[A-Za-z0-9._~-]+$")

/**
* The four device-lifecycle calls of `/api/v2/device/taptopay`.
* The five device-lifecycle calls of `/api/v2/device/taptopay`.
*
* **Stateless and orchestration-free by design.** It holds no device identity, persists nothing, keeps no
* state machine, and does not know that `/challenge` precedes `/attest`. Every value a call needs is a
Expand All @@ -27,8 +32,7 @@ import kotlinx.serialization.SerializationException
*
* `/activate/challenge` is absent and stays absent. It is the merchant-side call that mints the six-digit
* code; the code reaches the device out of band, and an SDK that could mint its own would be an SDK that
* could activate itself. `/config/{entry}` is absent for a duller reason: its credentials have no consumer
* until the card-reader work.
* could activate itself.
*
* **Nothing here is wrapped in `Retry`, and that is per route rather than an oversight.** `/attest` consumes
* the challenge with a delete-on-read, so a second attempt attests against a value the server has already
Expand All @@ -37,6 +41,10 @@ import kotlinx.serialization.SerializationException
* per-call-site primitive precisely so a call site like this one can decline it. The duplicate-safe unit here
* is the whole cold sequence, not any single call in it, so retrying belongs to whoever owns the sequence.
*
* `/config` is the first route here that would qualify, since it reads and mutates nothing, and it is still
* unwrapped: the assertion it carries is valid for two minutes, so a policy for it is a policy about minting
* a fresh one, which belongs to the same owner.
*
* **The server pins the credential, so every request here refuses credential recovery.** The attestation row
* written at `/attest` records the exact bearer token that made the call, and `/activate` and `/config` require
* that same one, so a refresh between them fails activation as [DeviceServiceException.NotAttested]. Requests
Expand All @@ -49,8 +57,9 @@ import kotlinx.serialization.SerializationException
* status inside the envelope, and it holds for the day they stop.
*
* **A rotation started by some other capability still breaks the binding**, because one session serves them
* all. Nothing this client does can prevent that, and it resolves with the facade, which binds a device by its
* own key rather than by the token that attested it.
* all. Nothing this client does can prevent that. It costs more than enrollment now that `/config` is here:
* a rotation between attesting and fetching the credentials leaves a reader that cannot be prepared, and the
* remedy is to attest again, which the sequence owner drives.
*/
internal class DeviceServiceClient(
private val transport: PayabliTransport,
Expand Down Expand Up @@ -194,27 +203,68 @@ internal class DeviceServiceClient(
)

/**
* One POST, and the whole of this class's care.
* The reader credentials for [entry], which only an active device is given.
*
* The order of the three checks is the contract, not a style:
* Takes all four assertion headers where `/activate` takes three: there is no body to carry the device,
* so the service reads it from `X-Device-Id`. That is the header [DeviceAssertion.asHeaders] already
* sends on both routes.
*
* 1. `PayabliHttpErrors` first, because a transport failure means the envelope below is not this service
* speaking. It is called without a `statusOverride`: these routes put their meaning in the envelope, so
* there is no shared status here to give a component reading. It also catches the failures that never
* reach a controller: DTO validation answers with a real 400 and `problem+json`, carrying no envelope.
* A missing `platform` is one of those.
* 2. Then the envelope decline, because these routes report a refusal as HTTP 200 and skipping this step
* is exactly how a refusal reads as a success.
* 3. Only then the payload.
* **A device that still owes activation is refused, and the refusal arrives two ways.** A device the
* service does not hold as active is declined with a 403 inside a 200. A caller whose token is not
* scoped for this route is refused with a real 403, by the gateway, before any controller runs. Both
* become [DeviceServiceException.Forbidden], so a caller branches once.
*
* Whether an absent `responseData` is usable is the route's business, and it is settled **here** rather
* than by the caller: [emptyPayload] is what a route substitutes when reaching the response at all is the
* answer, and a route that leaves it null is saying it cannot proceed without fields. `/attest` and
* `/activate` supply one; `/challenge` and `/register` do not.
* They are not the same condition and the shared classification is imprecise: a scope problem presents
* as a device that owes a code. It is what the sibling client does, and separating them is a change both
* platforms make together or not at all.
*
* That policy has to live inside this function, not above it, because the success record is written here.
* A caller rejecting a null payload afterwards would throw with `device_call_succeeded` already in the log
* and no failure record beside it, and an incident would read as a success the caller never received.
* A refusal here is never retried in place. The attestation row pins the bearer, so a rejection under
* [DeviceServiceException.NotAttested] means the credential moved and the binding is gone; attesting
* again from inside a failing call would spend a challenge and hide the rotation that caused it.
*/
suspend fun config(
entry: String,
assertion: DeviceAssertion,
failureMapper: DeviceFailureMapper = DeviceFailureMapper.None,
): ConfigResponse =
get(
route = ROUTE_CONFIG,
path = "$BASE/config/${pathSegment(entry)}",
payloadSerializer = ConfigResponse.serializer(),
failureMapper = failureMapper,
headers = assertion.asHeaders(),
statusOverride = { statusCode ->
if (statusCode == HTTP_FORBIDDEN) {
// The gateway's refusal carries no service text, and inventing one would put words in
// its mouth that a caller could display.
DeviceServiceException.Forbidden(statusCode, "")
} else {
null
}
},
)

/**
* [entry] as one path segment, or a refusal.
*
* A value that is not a single segment is a caller defect, and encoding it sends a request for a
* paypoint nobody named. `URLEncoder` writes a space as `+`, which is a query-string rule.
*
* The message names the field and the shape, never the value, because an entry point identifies a
* merchant.
*/
private fun pathSegment(entry: String): String {
require(PATH_SEGMENT.matches(entry)) {
"entry must be one path segment of unreserved characters: A-Z a-z 0-9 and . _ ~ -"
}
return entry
}

/**
* The four POSTs. Every one of them carries a body and resolves to its own template.
*
* The pin is set here and in [get], since the two assemblers build different request shapes. A sixth
* route inherits it from whichever of them it uses.
*/
private suspend fun <B, T> post(
route: String,
Expand All @@ -225,9 +275,9 @@ internal class DeviceServiceClient(
headers: Map<String, String> = emptyMap(),
emptyPayload: T? = null,
): T {
// route and path are the same string for all four: none of them embeds an identifier. Passed anyway,
// because `route` is the only form the transport may log and defaulting it to null would cost every
// record in this family the name of the endpoint it came from.
// The four POSTs resolve to their own template, because none of them embeds an identifier. Passed
// anyway, because `route` is the only form the transport may log and defaulting it to null would cost
// every record in this family the name of the endpoint it came from.
val request =
PayabliRequest.json(
method = HttpMethod.POST,
Expand All @@ -236,11 +286,85 @@ internal class DeviceServiceClient(
bodySerializer = bodySerializer,
route = route,
headers = headers,
// One place for all four, so a fifth route added to this class inherits it.
isCredentialPinned = true,
)
val response = transport.execute(request)
PayabliHttpErrors.from(response)?.let { transportFailure ->
return read(
route = route,
response = transport.execute(request),
payloadSerializer = payloadSerializer,
failureMapper = failureMapper,
emptyPayload = emptyPayload,
)
}

/**
* The GET half, whose [path] is **not** its [route].
*
* `/config` is the one route here that embeds an identifier, so the two are separate parameters for the
* first time: [route] is the template the transport records and [path] is the resolved string it sends.
*
* No [emptyPayload]. Reaching the response is the answer on `/attest` and `/activate`; here the fields
* are, so a success carrying none of them is a failure.
*/
private suspend fun <T> get(
route: String,
path: String,
payloadSerializer: KSerializer<T>,
failureMapper: DeviceFailureMapper,
headers: Map<String, String>,
statusOverride: (Int) -> Throwable?,
): T {
val request =
PayabliRequest(
method = HttpMethod.GET,
path = path,
route = route,
headers = headers,
isCredentialPinned = true,
)
return read(
route = route,
response = transport.execute(request),
payloadSerializer = payloadSerializer,
failureMapper = failureMapper,
statusOverride = statusOverride,
)
}

/**
* The whole of this class's care, once a response exists.
*
* The order of the checks is the contract, not a style:
*
* 0. [statusOverride] first, so a route can name what a status means to it before the shared table does.
* It answers only for statuses the route already treats as failures.
* 1. `PayabliHttpErrors` next, because a transport failure means the envelope below is not this service
* speaking. It is called without a `statusOverride`: these routes put their meaning in the envelope, so
* there is no shared status here to give a component reading. It also catches the failures that never
* reach a controller: DTO validation answers with a real 400 and `problem+json`, carrying no envelope.
* A missing `platform` is one of those.
* 2. Then the envelope decline, because these routes report a refusal as HTTP 200 and skipping this step
* is exactly how a refusal reads as a success.
* 3. Only then the payload.
*
* Whether an absent `responseData` is usable is the route's business, and it is settled **here** rather
* than by the caller: [emptyPayload] is what a route substitutes when reaching the response at all is the
* answer, and a route that leaves it null is saying it cannot proceed without fields. `/attest` and
* `/activate` supply one; `/challenge`, `/register` and `/config` do not.
*
* That policy has to live inside this function, not above it, because the success record is written here.
* A caller rejecting a null payload afterwards would throw with `device_call_succeeded` already in the log
* and no failure record beside it, and an incident would read as a success the caller never received.
*/
private fun <T> read(
route: String,
response: PayabliResponse,
payloadSerializer: KSerializer<T>,
failureMapper: DeviceFailureMapper,
emptyPayload: T? = null,
statusOverride: (Int) -> Throwable? = { null },
): T {
(statusOverride(response.statusCode) ?: PayabliHttpErrors.from(response))?.let { transportFailure ->
logger.warn(
LogField.safe("event", "device_call_failed"),
LogField.safe("route", route),
Expand Down Expand Up @@ -324,7 +448,11 @@ internal class DeviceServiceClient(
private const val BASE = "/api/v2/device/taptopay"

/**
* Route templates, which for these four are also the paths: none embeds an identifier.
* Route templates.
*
* The first four are also their own paths, because none of them embeds an identifier. [ROUTE_CONFIG]
* is a template and nothing else: its `{entry}` names a merchant, so the resolved path is not a form
* anything may record.
*
* Visible to the module's tests, which assert the exact string each call goes to. A path is the one
* part of a request no reviewer can verify by reading the client alone.
Expand All @@ -333,5 +461,6 @@ internal class DeviceServiceClient(
const val ROUTE_REGISTER: String = "$BASE/register"
const val ROUTE_ATTEST: String = "$BASE/attest"
const val ROUTE_ACTIVATE: String = "$BASE/activate"
const val ROUTE_CONFIG: String = "$BASE/config/{entry}"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import java.net.HttpURLConnection.HTTP_UNAUTHORIZED
* like an HTTP status and is not one. So a caller that checks the status and stops sees every one of these as
* a success. `PayabliHttpErrors` still runs first at the call site, for the genuine transport failures — a
* rejected credential, a rate limit, a proxy — and those arrive as `PayabliException`, not as this type. The
* two are disjoint on purpose: which one a caller catches says which layer failed.
* two are disjoint: which one a caller catches says which layer failed.
*
* **The family has two failure shapes, and this type covers only the second.** A request the service's DTO
* validation refuses never reaches a controller: it answers with a real HTTP 400 carrying RFC 9457
Expand Down Expand Up @@ -60,7 +60,7 @@ internal sealed class DeviceServiceException(
/**
* The request or the device's state was refused.
*
* The widest case, and deliberately so. Everything the activation window can go wrong with lands here —
* The widest case. Everything the activation window can go wrong with lands here —
* wrong code, five attempts spent, expired code, rejected assertion, a device that was not pending —
* along with plain malformed input. Splitting them needs `reason`, which is why it is a
* [DeviceFailureMapper]'s job rather than this class's.
Expand All @@ -85,9 +85,16 @@ internal sealed class DeviceServiceException(
/**
* The device or the application is not permitted this call.
*
* Two distinct conditions the service reports identically: a device that is not yet active, which is the
* ordinary pending-activation signal, and an application absent from the paypoint's allowlist, which is
* configuration. Neither is retryable.
* Three distinct conditions reported identically. Two come from a controller as an envelope decline: a
* device that is not yet active, which is the ordinary pending-activation signal, and an application
* absent from the paypoint's allowlist, which is configuration. The third is a real HTTP 403 from the
* gateway on `/config`, raised when the caller's token is not scoped for the route, and it carries no
* [reason] because the gateway sends no service text.
*
* The third is the imprecise one: a scope problem presents to a caller as a device owing activation.
* That matches the sibling client, and separating them is a change both platforms make together.
*
* None of the three is retryable.
*/
class Forbidden(
resultCode: Int?,
Expand Down Expand Up @@ -132,9 +139,9 @@ internal sealed class DeviceServiceException(
* The response said success and its body could not be read as one.
*
* A missing required field, a payload that is not the shape this route documents, or no payload where one
* is needed. Not a refusal by the service, which is why it carries no [resultCode]: something between the
* two of us is wrong about the contract, and treating it as a decline would file it under the service's
* fault and lose the cause.
* is needed. Not a refusal by the service, which is why it carries no [resultCode]: the SDK and the
* service disagree about the contract, and a decline would file that under the service's fault and lose
* the cause.
*/
class Undecodable(
// No default. Nullable because a response can be unusable without anything having thrown — an envelope
Expand Down
Loading