From ff1366048ce31494c731f299bef1a7afcc1fdca7 Mon Sep 17 00:00:00 2001 From: tr00d Date: Mon, 14 Sep 2026 13:54:06 +0200 Subject: [PATCH] fix(gotrue): classify auth errors by error_code --- .../AuthenticationFailureTests.cs | 7 +- .../Gotrue.Tests/Errors/FailureHintTests.cs | 115 ++++++++++++------ .../TokenRefresh/RefreshContractTests.cs | 8 +- .../Gotrue.Tests/TokenRefresh/RefreshTests.cs | 11 +- packages/Gotrue/Gotrue/Client.cs | 12 +- .../Gotrue/Gotrue/Exceptions/FailureReason.cs | 93 +++++++++----- .../Gotrue/Exceptions/GotrueException.cs | 12 +- .../Gotrue/Gotrue/PublicAPI.Unshipped.txt | 2 + 8 files changed, 180 insertions(+), 80 deletions(-) diff --git a/packages/Gotrue/Gotrue.Tests/Authentication/AuthenticationFailureTests.cs b/packages/Gotrue/Gotrue.Tests/Authentication/AuthenticationFailureTests.cs index c28fa444..fea21fe6 100644 --- a/packages/Gotrue/Gotrue.Tests/Authentication/AuthenticationFailureTests.cs +++ b/packages/Gotrue/Gotrue.Tests/Authentication/AuthenticationFailureTests.cs @@ -32,10 +32,13 @@ public async Task SignUp_ShouldThrowUserBadPassword_GivenWeakPassword() } [TestMethod] - public async Task SignUp_ShouldThrowUserBadEmailAddress_GivenInvalidEmail() + public async Task SignUp_ShouldSurfaceTheServerErrorCode_GivenInvalidEmail() { var signUp = () => this.Client.SignUp("not a real email address", Password); - await this.VerifyRejected(signUp, UserBadEmailAddress); + var exception = await signUp.Should().ThrowAsync(); + exception.Which.ErrorCode.Should().Be("validation_failed", + "GoTrue rejects a malformed email with the generic validation_failed code, which the SDK surfaces verbatim rather than guessing a finer reason from the message text"); + this.StateChanges.Should().BeEmpty(); } [TestMethod] diff --git a/packages/Gotrue/Gotrue.Tests/Errors/FailureHintTests.cs b/packages/Gotrue/Gotrue.Tests/Errors/FailureHintTests.cs index f543792e..91794a8b 100644 --- a/packages/Gotrue/Gotrue.Tests/Errors/FailureHintTests.cs +++ b/packages/Gotrue/Gotrue.Tests/Errors/FailureHintTests.cs @@ -2,6 +2,7 @@ using System.Threading.Tasks; using FluentAssertions; +using FluentAssertions.Execution; using Gotrue.Tests.Support; using Microsoft.VisualStudio.TestTools.UnitTesting; using Supabase.Gotrue.Exceptions; @@ -32,46 +33,90 @@ public class FailureHintTests public void TestCleanup() => this.server.Dispose(); [TestMethod] - [DataRow(400, "Invalid login credentials", UserBadLogin, DisplayName = "400 invalid login")] - [DataRow(400, "Email not confirmed", UserEmailNotConfirmed, DisplayName = "400 email not confirmed")] - [DataRow(400, "Invalid Refresh Token", InvalidRefreshToken, DisplayName = "400 invalid refresh token")] - [DataRow(400, "refresh_token_not_found", InvalidRefreshToken, DisplayName = "400 refresh token not found")] - [DataRow(400, "Refresh token is not valid", InvalidRefreshToken, DisplayName = "400 malformed refresh token")] - [DataRow(400, "Phone number is invalid", UserBadPhoneNumber, DisplayName = "400 bad phone")] - [DataRow(400, "Email address is invalid", UserBadEmailAddress, DisplayName = "400 bad email")] - [DataRow(400, "You must provide a value", UserMissingInformation, DisplayName = "400 missing information")] - [DataRow(401, "This endpoint requires a Bearer token", AdminTokenRequired, DisplayName = "401 bearer required")] - [DataRow(403, "Invalid token", AdminTokenRequired, DisplayName = "403 invalid token")] - [DataRow(403, "invalid JWT", AdminTokenRequired, DisplayName = "403 invalid JWT")] - [DataRow(404, "No SSO provider assigned for this domain", SsoDomainNotFound, DisplayName = "404 sso domain not found")] - [DataRow(404, "No such SSO provider", SsoProviderNotFound, DisplayName = "404 sso provider not found")] - [DataRow(422, "User already registered", UserAlreadyRegistered, DisplayName = "422 already registered")] - [DataRow(422, "Phone and Email are both invalid", UserBadMultiple, DisplayName = "422 bad phone and email")] - [DataRow(422, "Invalid email and password", UserBadMultiple, DisplayName = "422 bad email and password")] - [DataRow(422, "Password is too weak", UserBadPassword, DisplayName = "422 bad password")] - [DataRow(429, "Too many requests", UserTooManyRequests, DisplayName = "429 rate limited")] - [DataRow(500, "boom", Unknown, DisplayName = "unrecognized status")] - [DataRow(502, "boom", NetworkError, DisplayName = "standard server/gateway errors")] - [DataRow(503, "boom", NetworkError, DisplayName = "standard server/gateway errors")] - [DataRow(504, "boom", NetworkError, DisplayName = "standard server/gateway errors")] - [DataRow(520, "boom", CloudflareNetworkError, DisplayName = "cloudflare errors")] - [DataRow(521, "boom", CloudflareNetworkError, DisplayName = "cloudflare errors")] - [DataRow(522, "boom", CloudflareNetworkError, DisplayName = "cloudflare errors")] - [DataRow(523, "boom", CloudflareNetworkError, DisplayName = "cloudflare errors")] - [DataRow(524, "boom", CloudflareNetworkError, DisplayName = "cloudflare errors")] - [DataRow(530, "boom", CloudflareNetworkError, DisplayName = "cloudflare errors")] - public async Task DetectReason_ShouldMapServerErrorToReason( - int statusCode, - string body, - FailureHint.Reason expected - ) + [DataRow(429, UserTooManyRequests, DisplayName = "429 rate limited")] + [DataRow(500, Unknown, DisplayName = "unrecognized status")] + [DataRow(502, NetworkError, DisplayName = "502 gateway error")] + [DataRow(503, NetworkError, DisplayName = "503 gateway error")] + [DataRow(504, NetworkError, DisplayName = "504 gateway error")] + [DataRow(520, CloudflareNetworkError, DisplayName = "520 cloudflare error")] + [DataRow(521, CloudflareNetworkError, DisplayName = "521 cloudflare error")] + [DataRow(522, CloudflareNetworkError, DisplayName = "522 cloudflare error")] + [DataRow(523, CloudflareNetworkError, DisplayName = "523 cloudflare error")] + [DataRow(524, CloudflareNetworkError, DisplayName = "524 cloudflare error")] + [DataRow(530, CloudflareNetworkError, DisplayName = "530 cloudflare error")] + public async Task DetectReason_ShouldFallBackToStatusCode_GivenNoErrorCode(int statusCode, FailureHint.Reason expected) { - this.StubSignUp(statusCode, body); + this.StubSignUp(statusCode, "an upstream gateway page"); var signUp = () => TestClients.Against(this.server).SignUp(RandomEmail(), Password); var exception = await signUp.Should().ThrowAsync(); exception .Which.Reason.Should() - .Be(expected, $"status {statusCode} with body \"{body}\" classifies as {expected}"); + .Be(expected, $"a bodyless status {statusCode} classifies as {expected}"); + } + + [TestMethod] + [DataRow("invalid_credentials", UserBadLogin, DisplayName = "invalid_credentials")] + [DataRow("email_not_confirmed", UserEmailNotConfirmed, DisplayName = "email_not_confirmed")] + [DataRow("email_address_invalid", UserBadEmailAddress, DisplayName = "email_address_invalid")] + [DataRow("refresh_token_not_found", InvalidRefreshToken, DisplayName = "refresh_token_not_found")] + [DataRow("refresh_token_already_used", InvalidRefreshToken, DisplayName = "refresh_token_already_used")] + [DataRow("user_already_exists", UserAlreadyRegistered, DisplayName = "user_already_exists")] + [DataRow("email_exists", UserAlreadyRegistered, DisplayName = "email_exists")] + [DataRow("phone_exists", UserAlreadyRegistered, DisplayName = "phone_exists")] + [DataRow("weak_password", UserBadPassword, DisplayName = "weak_password")] + [DataRow("over_request_rate_limit", UserTooManyRequests, DisplayName = "over_request_rate_limit")] + [DataRow("over_email_send_rate_limit", UserTooManyRequests, DisplayName = "over_email_send_rate_limit")] + [DataRow("over_sms_send_rate_limit", UserTooManyRequests, DisplayName = "over_sms_send_rate_limit")] + [DataRow("bad_jwt", AdminTokenRequired, DisplayName = "bad_jwt")] + [DataRow("no_authorization", AdminTokenRequired, DisplayName = "no_authorization")] + [DataRow("not_admin", AdminTokenRequired, DisplayName = "not_admin")] + [DataRow("sso_provider_not_found", SsoProviderNotFound, DisplayName = "sso_provider_not_found")] + [DataRow("mfa_verification_failed", MfaChallengeUnverified, DisplayName = "mfa_verification_failed")] + [DataRow("mfa_verification_rejected", MfaChallengeUnverified, DisplayName = "mfa_verification_rejected")] + [DataRow("mfa_challenge_expired", MfaChallengeUnverified, DisplayName = "mfa_challenge_expired")] + public async Task DetectReason_ShouldMapErrorCodeToReason(string errorCode, FailureHint.Reason expected) + { + this.StubSignUp(400, $$"""{"code":400,"error_code":"{{errorCode}}","msg":"a server message"}"""); + var signUp = () => TestClients.Against(this.server).SignUp(RandomEmail(), Password); + var exception = await signUp.Should().ThrowAsync(); + exception.Which.Reason.Should().Be(expected, $"error_code \"{errorCode}\" classifies as {expected}"); + exception.Which.ErrorCode.Should().Be(errorCode, "the raw server error_code is exposed for precise handling"); + } + + [TestMethod] + public async Task DetectReason_ShouldClassifyFromErrorCode_GivenConflictingMessageText() + { + this.StubSignUp(400, """{"code":400,"error_code":"invalid_credentials","msg":"Email not confirmed"}"""); + var signUp = () => TestClients.Against(this.server).SignUp(RandomEmail(), Password); + var exception = await signUp.Should().ThrowAsync(); + exception.Which.Reason.Should() + .Be(UserBadLogin, "classification comes from the machine-readable error_code, not the message text"); + } + + [TestMethod] + public async Task DetectReason_ShouldBeUnknown_GivenAnUnmappedErrorCode() + { + this.StubSignUp(400, """{"code":400,"error_code":"validation_failed","msg":"You must provide a value"}"""); + var signUp = () => TestClients.Against(this.server).SignUp(RandomEmail(), Password); + var exception = await signUp.Should().ThrowAsync(); + using (new AssertionScope()) + { + exception.Which.Reason.Should().Be(Unknown, "a generic/unmapped code resolves to Unknown rather than guessing from message text"); + exception.Which.ErrorCode.Should().Be("validation_failed", "the raw code is still surfaced so callers can branch on it"); + } + } + + [TestMethod] + public async Task ErrorCode_ShouldBeNull_GivenANonJsonBody() + { + this.StubSignUp(502, "an upstream gateway page"); + var signUp = () => TestClients.Against(this.server).SignUp(RandomEmail(), Password); + var exception = await signUp.Should().ThrowAsync(); + using (new AssertionScope()) + { + exception.Which.ErrorCode.Should().BeNull("a gateway/Cloudflare page carries no error_code"); + exception.Which.Reason.Should().Be(NetworkError, "status-code classification still applies"); + } } private void StubSignUp(int statusCode, string body) => diff --git a/packages/Gotrue/Gotrue.Tests/TokenRefresh/RefreshContractTests.cs b/packages/Gotrue/Gotrue.Tests/TokenRefresh/RefreshContractTests.cs index a42cd96e..e0493a3a 100644 --- a/packages/Gotrue/Gotrue.Tests/TokenRefresh/RefreshContractTests.cs +++ b/packages/Gotrue/Gotrue.Tests/TokenRefresh/RefreshContractTests.cs @@ -78,13 +78,13 @@ public async Task RefreshToken_ShouldBecomeTheCurrentSession_GivenSuccess() [TestMethod] [DataRow("token_not_found_error.json", DisplayName = "unknown token (refresh_token_not_found)")] [DataRow("malformed_token_error.json", DisplayName = "malformed token (validation_failed)")] - public async Task RefreshToken_ShouldThrowInvalidRefreshTokenAndDestroySession_GivenRejected(string fixture) + public async Task RefreshToken_ShouldThrowAndDestroySession_GivenTheServerRejectsTheToken(string fixture) { this.MockErrorResponse(400, Fixture(fixture)); var refresh = () => this.client.RefreshToken(AccessToken, RefreshTokenValue); - var exception = await refresh.Should().ThrowAsync(); - exception.Which.Reason.Should().Be(InvalidRefreshToken); - this.client.CurrentSession.Should().BeNull(); + await refresh.Should().ThrowAsync(); + this.client.CurrentSession.Should().BeNull( + "a definitive (4xx) refresh rejection destroys the session regardless of the specific error_code — GoTrue returns the generic validation_failed for a malformed token, so classification cannot gate this"); } [TestMethod] diff --git a/packages/Gotrue/Gotrue.Tests/TokenRefresh/RefreshTests.cs b/packages/Gotrue/Gotrue.Tests/TokenRefresh/RefreshTests.cs index fa903a1a..93ab430f 100644 --- a/packages/Gotrue/Gotrue.Tests/TokenRefresh/RefreshTests.cs +++ b/packages/Gotrue/Gotrue.Tests/TokenRefresh/RefreshTests.cs @@ -8,7 +8,6 @@ using Supabase.Gotrue; using Supabase.Gotrue.Exceptions; using static Supabase.Gotrue.Constants.AuthState; -using static Supabase.Gotrue.Exceptions.FailureHint.Reason; #endregion @@ -17,7 +16,7 @@ namespace Gotrue.Tests.TokenRefresh; /// /// End-to-end session refresh against the live stack: refreshing rotates the refresh token and yields an /// access token the server accepts (including for an already-expired session), while a rejected refresh -/// token fails as and destroys the session. +/// token throws a and destroys the session. /// [TestClass] [TestCategory("E2E")] @@ -43,14 +42,14 @@ public async Task RefreshSession_ShouldSucceed_GivenExpiredSession() [TestMethod] [DataRow("bogus-token", DisplayName = "malformed token")] [DataRow("abcdef012345", DisplayName = "well-formed unknown token")] - public async Task RefreshSession_ShouldThrowInvalidRefreshTokenAndDestroySession_GivenRejectedToken(string rejectedToken) + public async Task RefreshSession_ShouldThrowAndDestroySession_GivenRejectedToken(string rejectedToken) { await this.SignUpNewUser(); this.Client.CurrentSession!.RefreshToken = rejectedToken; var refresh = () => this.Client.RefreshSession(); - var exception = await refresh.Should().ThrowAsync(); - exception.Which.Reason.Should().Be(InvalidRefreshToken); - this.Client.CurrentSession.Should().BeNull(); + await refresh.Should().ThrowAsync(); + this.Client.CurrentSession.Should().BeNull( + "a definitive (4xx) refresh rejection destroys the session — a malformed token comes back as the generic validation_failed, so this cannot depend on the specific reason"); } private async Task VerifyRotatedSession(Session original, Session? refreshed) diff --git a/packages/Gotrue/Gotrue/Client.cs b/packages/Gotrue/Gotrue/Client.cs index 39269d58..ac602b56 100644 --- a/packages/Gotrue/Gotrue/Client.cs +++ b/packages/Gotrue/Gotrue/Client.cs @@ -712,7 +712,7 @@ public async Task SetSession(string accessToken, string refreshToken, b await this.RefreshToken(); return this.CurrentSession; } - catch (GotrueException e) when (e.Reason is InvalidRefreshToken) + catch (GotrueException e) when (IsDefinitiveRefreshRejection(e)) { // RefreshToken destroyed the session, unless it was replaced mid-flight. activity.SetFailure(e); @@ -785,7 +785,7 @@ public async Task RefreshToken(string accessToken, string refreshToken) this.SetCurrentSession(result); await this.NotifyAuthStateChangeAsync(TokenRefreshed).ConfigureAwait(false); } - catch (GotrueException ex) when (ex.Reason is InvalidRefreshToken) + catch (GotrueException ex) when (IsDefinitiveRefreshRejection(ex)) { activity.SetFailure(ex); await this.ClearRejectedSessionAsync(refreshToken).ConfigureAwait(false); @@ -861,7 +861,7 @@ private async Task RefreshCurrentSession(string accessToken, string refreshToken } await this.NotifyAuthStateChangeAsync(TokenRefreshed).ConfigureAwait(false); } - catch (GotrueException ex) when (ex.Reason is InvalidRefreshToken) + catch (GotrueException ex) when (IsDefinitiveRefreshRejection(ex)) { activity.SetFailure(ex); await this.ClearRejectedSessionAsync(refreshToken).ConfigureAwait(false); @@ -1149,6 +1149,12 @@ private async Task UpdateSessionAsync(Session? session, CancellationToken cancel } } + // A refresh the server answers with a client error (4xx other than rate limiting) will never succeed + // with that token, so the session it belonged to is definitively rejected. Transient failures - + // offline, network, 5xx, rate limiting - keep the session so the next refresh can retry. + private static bool IsDefinitiveRefreshRejection(GotrueException ex) => + ex.StatusCode is >= 400 and < 500 and not 429; + // Only signs out the session the token belonged to. private async Task ClearRejectedSessionAsync(string refreshToken) { diff --git a/packages/Gotrue/Gotrue/Exceptions/FailureReason.cs b/packages/Gotrue/Gotrue/Exceptions/FailureReason.cs index 1987ccbb..7f8eb89d 100644 --- a/packages/Gotrue/Gotrue/Exceptions/FailureReason.cs +++ b/packages/Gotrue/Gotrue/Exceptions/FailureReason.cs @@ -1,9 +1,12 @@ +using System.Collections.Generic; +using System.Text.Json; using static Supabase.Gotrue.Exceptions.FailureHint.Reason; namespace Supabase.Gotrue.Exceptions; /// -/// Maps Supabase server errors to hints based on the status code and the contents of the error message. +/// Maps Supabase server errors to hints from the machine-readable error_code the GoTrue server +/// returns, falling back to the HTTP status code for responses that carry no classifiable body. /// public static class FailureHint { @@ -125,41 +128,75 @@ public enum Reason } /// - /// Detects the reason for the error based on the status code and the contents of the error message. + /// The GoTrue server has returned a machine-readable error_code in every error body since + /// early 2024. This maps the codes we recognise onto a ; anything absent here + /// (including generic codes such as validation_failed) resolves to , + /// with the raw code still available on . + /// + private static readonly IReadOnlyDictionary ReasonByErrorCode = new Dictionary + { + ["invalid_credentials"] = UserBadLogin, + ["email_not_confirmed"] = UserEmailNotConfirmed, + ["email_address_invalid"] = UserBadEmailAddress, + ["refresh_token_not_found"] = InvalidRefreshToken, + ["refresh_token_already_used"] = InvalidRefreshToken, + ["user_already_exists"] = UserAlreadyRegistered, + ["email_exists"] = UserAlreadyRegistered, + ["phone_exists"] = UserAlreadyRegistered, + ["weak_password"] = UserBadPassword, + ["over_request_rate_limit"] = UserTooManyRequests, + ["over_email_send_rate_limit"] = UserTooManyRequests, + ["over_sms_send_rate_limit"] = UserTooManyRequests, + ["bad_jwt"] = AdminTokenRequired, + ["no_authorization"] = AdminTokenRequired, + ["not_admin"] = AdminTokenRequired, + ["sso_provider_not_found"] = SsoProviderNotFound, + ["mfa_verification_failed"] = MfaChallengeUnverified, + ["mfa_verification_rejected"] = MfaChallengeUnverified, + ["mfa_challenge_expired"] = MfaChallengeUnverified, + }; + + /// + /// Reads the machine-readable error_code from a GoTrue error body. Returns null for bodies + /// that are missing, not JSON (gateway/Cloudflare pages), or that carry no error_code field. + /// + /// The raw error response body. + public static string? ParseErrorCode(string? content) + { + if (string.IsNullOrWhiteSpace(content)) + return null; + + try + { + using var document = JsonDocument.Parse(content); + if (document.RootElement.ValueKind != JsonValueKind.Object) + return null; + + return document.RootElement.TryGetProperty("error_code", out var errorCode) && errorCode.ValueKind == JsonValueKind.String + ? errorCode.GetString() + : null; + } + catch (JsonException) + { + return null; + } + } + + /// + /// Detects the reason for the error from the server's machine-readable error_code, falling back + /// to the HTTP status code for responses that carry no classifiable body (rate limiting, and gateway + /// or Cloudflare errors served as HTML rather than JSON). /// /// /// public static Reason DetectReason(GotrueException gte) { - if (gte.Content == null) - return Unknown; + var errorCode = gte.ErrorCode ?? ParseErrorCode(gte.Content); + if (errorCode != null && ReasonByErrorCode.TryGetValue(errorCode, out var reasonFromCode)) + return reasonFromCode; return gte.StatusCode switch { - 400 when gte.Content.Contains("Invalid login") => UserBadLogin, - 400 when gte.Content.Contains("Email not confirmed") => UserEmailNotConfirmed, - // Gotrue rejects refresh tokens on two paths: malformed tokens fail format validation - // ("Refresh token is not valid", validation_failed) before lookup, while well-formed - // unknown tokens return "Invalid Refresh Token" (refresh_token_not_found). - 400 when gte.Content.Contains("Invalid Refresh Token") => InvalidRefreshToken, - 400 when gte.Content.Contains("refresh_token_not_found") => InvalidRefreshToken, - 400 when gte.Content.Contains("Refresh token is not valid") => InvalidRefreshToken, - 400 when gte.Content.Contains("Phone") => UserBadPhoneNumber, - 400 when gte.Content.Contains("phone") => UserBadPhoneNumber, - 400 when gte.Content.Contains("Email") => UserBadEmailAddress, - 400 when gte.Content.Contains("email") => UserBadEmailAddress, - 400 when gte.Content.Contains("provide") => UserMissingInformation, - 401 when gte.Content.Contains("This endpoint requires a Bearer token") => AdminTokenRequired, - 403 when gte.Content.Contains("Invalid token") => AdminTokenRequired, - 403 when gte.Content.Contains("invalid JWT") => AdminTokenRequired, - 404 when gte.Content.Contains("No SSO provider assigned for this domain") => SsoDomainNotFound, - 404 when gte.Content.Contains("No such SSO provider") => SsoProviderNotFound, - 422 when gte.Content.Contains("User already registered") => UserAlreadyRegistered, - 422 when gte.Content.Contains("A user with this email address has already been registered") => UserAlreadyRegistered, - 422 when gte.Content.Contains("Phone") && gte.Content.Contains("Email") => UserBadMultiple, - 422 when gte.Content.Contains("email") && gte.Content.Contains("password") => UserBadMultiple, - 422 when gte.Content.Contains("Password") => UserBadPassword, - 422 when gte.Content.Contains("password") => UserBadPassword, 429 => UserTooManyRequests, 502 or 503 or 504 => NetworkError, 520 or 521 or 522 or 523 or 524 or 530 => CloudflareNetworkError, diff --git a/packages/Gotrue/Gotrue/Exceptions/GotrueException.cs b/packages/Gotrue/Gotrue/Exceptions/GotrueException.cs index ca8b7021..5eef0476 100644 --- a/packages/Gotrue/Gotrue/Exceptions/GotrueException.cs +++ b/packages/Gotrue/Gotrue/Exceptions/GotrueException.cs @@ -54,12 +54,20 @@ public GotrueException(string message, FailureHint.Reason reason, Exception? inn public int StatusCode { get; internal set; } /// - /// Adds the best-effort reason for the failure + /// The machine-readable error code returned by the GoTrue server (the response body's + /// error_code field), for example invalid_credentials or user_already_exists. + /// Null when the server did not return one. Prefer this over when you need + /// to branch on a specific server error, since it is stable and locale-independent. + /// + public string? ErrorCode { get; internal set; } + + /// + /// Adds the best-effort reason for the failure /// public void AddReason() { + ErrorCode = FailureHint.ParseErrorCode(Content); Reason = FailureHint.DetectReason(this); - //Debug.WriteLine(Content); } /// diff --git a/packages/Gotrue/Gotrue/PublicAPI.Unshipped.txt b/packages/Gotrue/Gotrue/PublicAPI.Unshipped.txt index 7dc5c581..b589bf48 100644 --- a/packages/Gotrue/Gotrue/PublicAPI.Unshipped.txt +++ b/packages/Gotrue/Gotrue/PublicAPI.Unshipped.txt @@ -1 +1,3 @@ #nullable enable +Supabase.Gotrue.Exceptions.GotrueException.ErrorCode.get -> string? +static Supabase.Gotrue.Exceptions.FailureHint.ParseErrorCode(string? content) -> string?