diff --git a/client/src/main/kotlin/io/flamenet/client/RelayAccounts.kt b/client/src/main/kotlin/io/flamenet/client/RelayAccounts.kt index f446f49..4f42337 100644 --- a/client/src/main/kotlin/io/flamenet/client/RelayAccounts.kt +++ b/client/src/main/kotlin/io/flamenet/client/RelayAccounts.kt @@ -90,6 +90,17 @@ class RelayAccounts( post("account/reset/request", JSONObject().put("handle", handle)) } + /** + * Spend an email verification code. + * + * ⚠️ Unauthenticated, exactly like reset confirm: the code IS the proof. + * Requiring a signed-in session would mean somebody kept out *because* they + * are unverified could never verify. + */ + fun verifyEmail(token: String) { + post("account/verify", JSONObject().put("token", token)) + } + fun confirmPasswordReset(handle: String, code: String, newPassword: String) { post("account/reset/confirm", JSONObject() .put("handle", handle) diff --git a/client/src/main/kotlin/io/flamenet/client/RelayClient.kt b/client/src/main/kotlin/io/flamenet/client/RelayClient.kt index 61e7ca4..f427e51 100644 --- a/client/src/main/kotlin/io/flamenet/client/RelayClient.kt +++ b/client/src/main/kotlin/io/flamenet/client/RelayClient.kt @@ -796,6 +796,15 @@ class RelayClient( throw when (code) { 401 -> RelayException.Unauthorized(readable) + // ⚠️ The relay's own code, not the status. A 403 is also "that + // device belongs to another account", and telling somebody to check + // their email for that would send them looking for a message that + // was never sent. + 403 -> { + val relayCode = runCatching { JSONObject(body).optString("code") }.getOrNull() + if (relayCode == "email_unverified") RelayException.EmailUnverified(readable) + else RelayException.Http(code, readable) + } 404 -> RelayException.NotFound(path) 429 -> RelayException.RateLimited(response.header("Retry-After")?.toIntOrNull()) // The geo-gate, not the relay. Needs a sentence, not a number. diff --git a/client/src/main/kotlin/io/flamenet/client/RelayException.kt b/client/src/main/kotlin/io/flamenet/client/RelayException.kt index 8bf6712..c7fd098 100644 --- a/client/src/main/kotlin/io/flamenet/client/RelayException.kt +++ b/client/src/main/kotlin/io/flamenet/client/RelayException.kt @@ -42,6 +42,24 @@ sealed class RelayException(message: String, cause: Throwable? = null) : Excepti "This isn't a problem with your account or your connection.") /** Any other non-2xx. The body is kept because relays put the reason in it. */ + /** + * The account exists and the credential is right; the address has not been + * proved reachable yet. + * + * 🔴 **Its own type because everything else about it looks like a + * credential failure.** Without this it arrives as a bare 403 and + * [RelayAuth] logs "minting failed" before falling back to Basic — which + * the e2e routes refuse — so the person sees calls not working and an inbox + * that does nothing, and nothing anywhere says the remedy is a code sitting + * in their email. + * + * ⚠️ Do not write the e2e route glob in a KDoc here: Kotlin NESTS block + * comments, so a slash-star inside one opens a comment that never closes + * and takes the whole module down with ninety unrelated errors. + */ + class EmailUnverified(val body: String) : RelayException( + "this account's email address has not been confirmed yet") + class Http(val status: Int, val body: String) : RelayException("relay returned $status: $body") /** A 2xx whose body was not what the route promised. */ diff --git a/client/src/test/kotlin/io/flamenet/client/EmailUnverifiedTest.kt b/client/src/test/kotlin/io/flamenet/client/EmailUnverifiedTest.kt new file mode 100644 index 0000000..3e3fbfc --- /dev/null +++ b/client/src/test/kotlin/io/flamenet/client/EmailUnverifiedTest.kt @@ -0,0 +1,99 @@ +package io.flamenet.client + +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +/** + * A 403 that means "check your email", told apart from every other 403. + * + * 🔴 **Without this it is indistinguishable from a credential problem.** An + * unverified account arrives as a bare 403; `RelayAuth` logs "minting failed" + * and falls back to Basic, which the e2e routes refuse. The person then sees + * calls that do not work and an inbox that does nothing, and nothing anywhere + * says the remedy is a code sitting in their inbox. + * + * ⚠️ The status alone is not enough: 403 is also "that device belongs to + * another account". Telling somebody to check their email for that would send + * them looking for a message that was never sent — so the relay's own `code` is + * what decides, not the number. + */ +class EmailUnverifiedTest { + + private lateinit var server: MockWebServer + + @BeforeTest fun start() { + server = MockWebServer() + server.start() + } + + @AfterTest fun stop() { + server.shutdown() + } + + private fun relay() = RelayClient( + server.url("/").toString(), username = "ada", appPassword = "credential") + + private fun refuse(code: String?, status: Int = 403) { + val body = if (code == null) """{"message":"no"}""" + else """{"code":"$code","message":"Check your email and enter the code."}""" + server.enqueue( + MockResponse().setResponseCode(status) + .setHeader("Content-Type", "application/json") + .setBody(body) + ) + } + + @Test + fun `an unverified account is its own failure, not a generic 403`() { + refuse("email_unverified") + val failure = assertFailsWith { + relay().listDevices(1) + } + assertTrue( + failure.body.contains("code"), + "the relay's sentence should survive: ${failure.body}" + ) + } + + /** + * 🔴 The distinction that matters. A device conflict is also a 403 and must + * NOT be reported as an unverified address. + */ + @Test + fun `another 403 is not mistaken for an unverified address`() { + refuse("forbidden") + val failure = assertFailsWith { relay().listDevices(1) } + assertEquals(403, failure.status) + } + + /** ⚠️ A 403 with no code at all — an old relay, or a proxy — stays generic. */ + @Test + fun `a 403 with no code stays generic`() { + refuse(null) + assertFailsWith { relay().listDevices(1) } + } + + /** ⚠️ And a body that is not JSON must not throw a parse error. */ + @Test + fun `an unparseable 403 body does not crash`() { + server.enqueue( + MockResponse().setResponseCode(403) + .setHeader("Content-Type", "text/html") + .setBody("a proxy wrote this") + ) + assertFailsWith { relay().listDevices(1) } + } + + /** The code is only meaningful on a 403 — the same string on a 500 is not. */ + @Test + fun `the code does not change the meaning of other statuses`() { + refuse("email_unverified", status = 500) + assertFailsWith { relay().listDevices(1) } + } +}