From 6204d8f9486082e70b2cdb00f920757e8a8b69e6 Mon Sep 17 00:00:00 2001
From: extoci
Date: Thu, 27 Aug 2026 03:21:03 +0000
Subject: [PATCH 1/8] fix(connect): explain DPoP connection failures
---
.../features/cloud/linkEnvironment.test.ts | 79 ++++++++++++++++++-
.../src/features/cloud/linkEnvironment.ts | 79 +++++++------------
.../connection/CloudEnvironmentRows.tsx | 3 +-
.../connection/ConnectionEnvironmentRow.tsx | 3 +-
.../features/projects/AddProjectScreen.tsx | 6 +-
apps/server/src/auth/EnvironmentAuth.ts | 10 +++
apps/server/src/auth/dpop.test.ts | 23 +++++-
apps/server/src/auth/dpop.ts | 42 +++++++++-
apps/server/src/auth/http.ts | 40 ++++++++--
apps/server/src/http.ts | 5 +-
apps/server/src/server.test.ts | 4 +
apps/server/src/ws.ts | 5 +-
apps/web/src/cloud/linkEnvironment.ts | 47 +----------
.../src/components/CommandPaletteResults.tsx | 4 +-
.../clerk/T3ConnectUserProfilePage.tsx | 4 +-
.../cloud/CloudEnvironmentConnectList.tsx | 8 +-
.../settings/ConnectionsSettings.tsx | 11 ++-
.../settings/ProviderSettingsPanel.tsx | 8 +-
docs/internals/environment-auth.md | 4 +-
docs/operations/observability.md | 6 ++
docs/operations/relay-observability.md | 6 ++
docs/user/remote-access.md | 2 +
infra/relay/src/auth/DpopProofs.ts | 35 ++++++--
.../auth/DpopProofs.verifyAndConsume.test.ts | 39 ++++++++-
infra/relay/src/http/Api.test.ts | 22 ++++++
infra/relay/src/http/Api.ts | 51 +++++++++++-
.../src/authorization/layer.test.ts | 24 ++++++
.../src/authorization/service.ts | 10 ++-
.../src/connection/errors.test.ts | 75 ++++++++++++++++++
.../client-runtime/src/connection/errors.ts | 34 ++++++--
.../src/relay/errorPresentation.test.ts | 52 ++++++++++++
.../src/relay/errorPresentation.ts | 61 ++++++++++++++
packages/client-runtime/src/relay/index.ts | 1 +
.../src/relay/managedRelay.test.ts | 37 +++++++++
.../src/relay/managedRelayState.test.ts | 36 ++++++++-
.../src/relay/managedRelayState.ts | 8 +-
packages/contracts/src/baseSchemas.ts | 14 ++++
packages/contracts/src/environmentHttp.ts | 9 ++-
packages/contracts/src/relay.ts | 12 ++-
packages/shared/src/dpop.test.ts | 37 +++++++++
packages/shared/src/dpop.ts | 74 +++++++++++------
41 files changed, 857 insertions(+), 173 deletions(-)
create mode 100644 packages/client-runtime/src/connection/errors.test.ts
create mode 100644 packages/client-runtime/src/relay/errorPresentation.test.ts
create mode 100644 packages/client-runtime/src/relay/errorPresentation.ts
diff --git a/apps/mobile/src/features/cloud/linkEnvironment.test.ts b/apps/mobile/src/features/cloud/linkEnvironment.test.ts
index 4d7b0864184b..d812b71cef92 100644
--- a/apps/mobile/src/features/cloud/linkEnvironment.test.ts
+++ b/apps/mobile/src/features/cloud/linkEnvironment.test.ts
@@ -4,7 +4,7 @@ import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import { EnvironmentId } from "@t3tools/contracts";
import { RelayMobileClientId } from "@t3tools/contracts/relay";
-import { ManagedRelay } from "@t3tools/client-runtime/relay";
+import { DPOP_CLOCK_HINT, ManagedRelay } from "@t3tools/client-runtime/relay";
import { remoteHttpClientLayer } from "@t3tools/client-runtime/rpc";
import { HttpClient } from "effect/unstable/http";
import { MobilePreferencesStore } from "../../persistence/mobile-preferences";
@@ -1082,12 +1082,87 @@ describe("mobile cloud link environment client", () => {
expect(error).toMatchObject({
_tag: "CloudEnvironmentLinkError",
message:
- "https://relay.example.test/v1/environments/env-1/connect failed: Relay rejected the DPoP proof.",
+ `https://relay.example.test/v1/environments/env-1/connect failed: Relay rejected the DPoP proof.\n\n` +
+ DPOP_CLOCK_HINT,
traceId: "trace-connect",
});
}),
);
+ it.effect("shows the clock hint when an older environment rejects DPoP", () =>
+ Effect.gen(function* () {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn((url: string | URL) => {
+ const value = String(url);
+ if (value.endsWith("/v1/client/dpop-token")) {
+ return Promise.resolve(
+ Response.json(validDpopAccessTokenResponse("environment:connect")),
+ );
+ }
+ if (value.endsWith("/v1/environments/env-1/connect")) {
+ return Promise.resolve(
+ Response.json({
+ environmentId: "env-1",
+ endpoint: {
+ httpBaseUrl: "https://desktop.example.test/",
+ wsBaseUrl: "wss://desktop.example.test/ws",
+ providerKind: "cloudflare_tunnel",
+ },
+ credential: "one-time-cloud-credential",
+ expiresAt: "2026-05-25T00:05:00.000Z",
+ }),
+ );
+ }
+ if (value.endsWith("/.well-known/t3/environment")) {
+ return Promise.resolve(
+ Response.json({
+ environmentId: "env-1",
+ label: "Desktop",
+ platform: { os: "darwin", arch: "arm64" },
+ serverVersion: "0.0.0-test",
+ capabilities: { repositoryIdentity: true },
+ }),
+ );
+ }
+ return Promise.resolve(
+ Response.json(
+ {
+ _tag: "EnvironmentAuthInvalidError",
+ code: "auth_invalid",
+ reason: "invalid_credential",
+ traceId: "trace-environment",
+ },
+ { status: 401 },
+ ),
+ );
+ }),
+ );
+
+ const error = yield* withCloudServices(
+ connectCloudEnvironment({
+ clerkToken: "clerk-token",
+ environment: {
+ environmentId: EnvironmentId.make("env-1"),
+ label: "Desktop",
+ endpoint: {
+ httpBaseUrl: "https://desktop.example.test/",
+ wsBaseUrl: "wss://desktop.example.test/ws",
+ providerKind: "cloudflare_tunnel",
+ },
+ linkedAt: "2026-05-25T00:00:00.000Z",
+ },
+ }),
+ ).pipe(Effect.flip);
+
+ expect(error).toMatchObject({
+ _tag: "CloudEnvironmentLinkError",
+ message: `Could not exchange a managed endpoint DPoP access token.\n\n${DPOP_CLOCK_HINT}`,
+ traceId: "trace-environment",
+ });
+ }),
+ );
+
it.effect("rejects relay connect responses for a different endpoint", () =>
Effect.gen(function* () {
vi.stubGlobal(
diff --git a/apps/mobile/src/features/cloud/linkEnvironment.ts b/apps/mobile/src/features/cloud/linkEnvironment.ts
index 958827ee492b..c91bbe17c20a 100644
--- a/apps/mobile/src/features/cloud/linkEnvironment.ts
+++ b/apps/mobile/src/features/cloud/linkEnvironment.ts
@@ -4,6 +4,7 @@ import * as Schema from "effect/Schema";
import { HttpClient } from "effect/unstable/http";
import {
EnvironmentCloudEndpointUnavailableError,
+ EnvironmentAuthInvalidError,
EnvironmentHttpBadRequestError,
EnvironmentHttpConflictError,
EnvironmentHttpForbiddenError,
@@ -17,7 +18,6 @@ import {
RelayEnvironmentConnectScope,
RelayEnvironmentStatusScope,
type RelayDpopAccessTokenScope,
- type RelayProtectedError as RelayProtectedErrorType,
type RelayClientEnvironmentRecord,
type RelayEnvironmentStatusResponse as RelayEnvironmentStatusResponseType,
type RelayManagedEndpointProviderKind,
@@ -25,7 +25,11 @@ import {
import { exchangeRemoteDpopAccessToken } from "@t3tools/client-runtime/authorization";
import { fetchRemoteEnvironmentDescriptor } from "@t3tools/client-runtime/environment";
import { findErrorTraceId } from "@t3tools/client-runtime/errors";
-import { ManagedRelay } from "@t3tools/client-runtime/relay";
+import {
+ dpopFailureHint,
+ ManagedRelay,
+ relayProtectedErrorMessage,
+} from "@t3tools/client-runtime/relay";
import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime/rpc";
import { authClientMetadata } from "../../lib/authClientMetadata";
@@ -77,14 +81,19 @@ const isEnvironmentCloudApiError = Schema.is(
const MANAGED_ENDPOINT_PROVIDER_KIND =
"cloudflare_tunnel" satisfies RelayManagedEndpointProviderKind;
-function cloudEnvironmentLinkError(message: string) {
+function cloudEnvironmentLinkError(message: string, options?: { readonly dpop?: boolean }) {
return (cause: unknown) => {
const environmentError = findEnvironmentCloudApiError(cause);
const traceId = findErrorTraceId(cause);
+ const dpopAuthError = options?.dpop ? findEnvironmentAuthInvalidError(cause) : null;
+ const detail = environmentError
+ ? `${message.replace(/[.:]$/, "")}: ${environmentError.message}`
+ : withDevCause(message, cause);
return new CloudEnvironmentLinkError({
- message: environmentError
- ? `${message.replace(/[.:]$/, "")}: ${environmentError.message}`
- : withDevCause(message, cause),
+ message:
+ dpopAuthError?.reason === "invalid_credential"
+ ? `${detail}\n\n${dpopFailureHint(dpopAuthError.dpopFailureReason)}`
+ : detail,
cause,
...(traceId === null ? {} : { traceId }),
});
@@ -117,50 +126,6 @@ function withDevCause(message: string, cause: unknown): string {
return detail ? `${message} (${detail})` : message;
}
-function relayProtectedErrorMessage(error: RelayProtectedErrorType): string {
- switch (error._tag) {
- case "RelayAuthInvalidError":
- switch (error.reason) {
- case "missing_bearer":
- case "invalid_bearer":
- return "Relay rejected the cloud session token.";
- case "invalid_dpop":
- return "Relay rejected the DPoP proof.";
- case "not_authorized":
- return "Relay rejected the authenticated request.";
- }
- case "RelayEnvironmentLinkProofExpiredError":
- return "Relay rejected an expired environment link proof.";
- case "RelayEnvironmentLinkProofInvalidError":
- return `Relay rejected the environment link proof (${error.reason}).`;
- case "RelayEnvironmentConnectNotAuthorizedError":
- // "Not authorized" covers non-auth causes too; surface the reason so a
- // missing link doesn't read as a credential problem.
- if (error.reason === "environment_link_not_found") {
- return "Relay has no active link for this environment. The environment server may not have re-established its link yet.";
- }
- return error.reason
- ? `Relay rejected the environment connection request (${error.reason}).`
- : "Relay rejected the environment connection request.";
- case "RelayEnvironmentEndpointUnavailableError":
- return `Relay could not reach the environment endpoint (${error.reason}).`;
- case "RelayEnvironmentEndpointTimedOutError":
- return "Relay timed out while contacting the environment endpoint.";
- case "RelayEnvironmentLinkFailedError":
- return `Relay could not link the environment (${error.reason}).`;
- case "RelayEnvironmentLinkUnavailableError":
- return `Relay cannot provision the managed endpoint (${error.reason}).`;
- case "RelayEnvironmentLinkLimitExceededError":
- return `Relay refused the link: this account already has its maximum of ${error.maxTunnels} managed tunnels. Unlink an environment to free one up.`;
- case "RelayAgentActivityPublishProofExpiredError":
- return "Relay rejected an expired agent activity publish proof.";
- case "RelayAgentActivityPublishProofInvalidError":
- return `Relay rejected the agent activity publish proof (${error.reason}).`;
- case "RelayInternalError":
- return `Relay encountered an internal error (${error.reason}).`;
- }
-}
-
function decodedRelayClientError(message: string) {
return (cause: ManagedRelay.ManagedRelayClientError) => {
const relayError =
@@ -185,6 +150,16 @@ function findEnvironmentCloudApiError(cause: unknown): { readonly message: strin
return "cause" in cause ? findEnvironmentCloudApiError(cause.cause) : null;
}
+function findEnvironmentAuthInvalidError(cause: unknown): EnvironmentAuthInvalidError | null {
+ if (Schema.is(EnvironmentAuthInvalidError)(cause)) {
+ return cause;
+ }
+ if (typeof cause !== "object" || cause === null) {
+ return null;
+ }
+ return "cause" in cause ? findEnvironmentAuthInvalidError(cause.cause) : null;
+}
+
function requireRelayUrl(): Effect.Effect {
const relayUrl = readRelayUrl();
return relayUrl
@@ -560,7 +535,9 @@ const connectRelayManagedEnvironment = Effect.fn("mobile.cloud.connectRelayManag
clientMetadata: authClientMetadata(),
}).pipe(
Effect.mapError(
- cloudEnvironmentLinkError("Could not exchange a managed endpoint DPoP access token."),
+ cloudEnvironmentLinkError("Could not exchange a managed endpoint DPoP access token.", {
+ dpop: true,
+ }),
),
);
const pairingUrl = new URL(connect.endpoint.httpBaseUrl);
diff --git a/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx b/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx
index 6da73eaeb1fa..8b0e506bb090 100644
--- a/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx
+++ b/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx
@@ -286,6 +286,7 @@ function CloudEnvironmentRowShell(props: {
error: props.connectionError,
traceId: props.connectionErrorTraceId,
});
+ const statusHasHint = statusText.includes("\n");
const statusClassName = props.connectionError
? "text-rose-500 dark:text-rose-400"
: "text-foreground-muted";
@@ -350,7 +351,7 @@ function CloudEnvironmentRowShell(props: {
>
{statusText}
{errorTraceId ? (
diff --git a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx
index 03a0eb5025f6..64f444ac9f25 100644
--- a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx
+++ b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx
@@ -43,6 +43,7 @@ export function ConnectionEnvironmentRow(props: {
const statusLabel = connectionStatusLabel(props.environment);
const statusTraceId = props.environment.connectionErrorTraceId;
const hasConnectionFailure = props.environment.connectionError !== null;
+ const statusHasHint = statusLabel?.includes("\n") ?? false;
const isRetrying =
props.environment.connectionState === "connecting" ||
props.environment.connectionState === "reconnecting";
@@ -87,7 +88,7 @@ export function ConnectionEnvironmentRow(props: {
"text-xs",
hasConnectionFailure ? "text-rose-500 dark:text-rose-400" : "text-foreground-muted",
)}
- numberOfLines={props.expanded ? undefined : 1}
+ numberOfLines={props.expanded || statusHasHint ? undefined : 1}
selectable={props.expanded}
>
{statusLabel}
diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx
index b48c7a0bdd94..80f423b57a43 100644
--- a/apps/mobile/src/features/projects/AddProjectScreen.tsx
+++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx
@@ -160,6 +160,7 @@ function ListRow(props: {
readonly onPress?: () => void;
}) {
const chevronColor = useThemeColor("--color-chevron");
+ const subtitleHasHint = props.subtitle?.includes("\n") ?? false;
return (
{props.title}
{props.subtitle ? (
-
+
{props.subtitle}
) : null}
diff --git a/apps/server/src/auth/EnvironmentAuth.ts b/apps/server/src/auth/EnvironmentAuth.ts
index eb0563421408..f5d244dd9667 100644
--- a/apps/server/src/auth/EnvironmentAuth.ts
+++ b/apps/server/src/auth/EnvironmentAuth.ts
@@ -16,6 +16,8 @@ import {
type ServerAuthDescriptor,
type ServerAuthSessionMethod,
type AuthWebSocketTicketResult,
+ DpopFailureReason,
+ type DpopFailureReason as DpopFailureReasonType,
} from "@t3tools/contracts";
import { encodeOAuthScope } from "@t3tools/shared/oauthScope";
import * as Context from "effect/Context";
@@ -347,6 +349,7 @@ export class ServerAuthInvalidCredentialError extends Schema.TaggedErrorClass
error._tag === "ServerAuthMissingCredentialError" ? "missing_credential" : "invalid_credential";
+export const serverAuthDpopFailureReason = (
+ error: ServerAuthCredentialError,
+): DpopFailureReasonType | undefined =>
+ error._tag === "ServerAuthInvalidCredentialError" ? error.dpopFailureReason : undefined;
+
export class ServerAuthInvalidScopeError extends Schema.TaggedErrorClass()(
"ServerAuthInvalidScopeError",
{},
@@ -606,6 +614,7 @@ export const make = Effect.gen(function* () {
return Effect.fail(
new ServerAuthInvalidCredentialError({
diagnostic: "DPoP-bound access token requires DPoP authorization.",
+ dpopFailureReason: "invalid_proof",
}),
);
}
@@ -623,6 +632,7 @@ export const make = Effect.gen(function* () {
return Effect.fail(
new ServerAuthInvalidCredentialError({
diagnostic: "DPoP authorization requires a proof-bound access token.",
+ dpopFailureReason: "invalid_proof",
}),
);
}
diff --git a/apps/server/src/auth/dpop.test.ts b/apps/server/src/auth/dpop.test.ts
index fa75c407b0c6..ea8d1cd99db7 100644
--- a/apps/server/src/auth/dpop.test.ts
+++ b/apps/server/src/auth/dpop.test.ts
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vite-plus/test";
import * as PlatformError from "effect/PlatformError";
import { SecretStorePersistError } from "./ServerSecretStore.ts";
-import { mapDpopReplayStoreError } from "./dpop.ts";
+import { mapDpopFailureReason, mapDpopReplayStoreError } from "./dpop.ts";
const storeFailure = (tag: "AlreadyExists" | "PermissionDenied") =>
new SecretStorePersistError({
@@ -23,6 +23,7 @@ describe("mapDpopReplayStoreError", () => {
expect(error._tag).toBe("ServerAuthInvalidCredentialError");
if (error._tag === "ServerAuthInvalidCredentialError") {
expect(error.cause).toBe(cause);
+ expect(error.dpopFailureReason).toBe("replay");
}
});
@@ -35,3 +36,23 @@ describe("mapDpopReplayStoreError", () => {
}
});
});
+
+describe("mapDpopFailureReason", () => {
+ it("maps verifier failures to safe client-facing categories", () => {
+ const mappings = [
+ ["time_window", "time_window"],
+ ["key_mismatch", "key_mismatch"],
+ ["method_mismatch", "request_mismatch"],
+ ["url_mismatch", "request_mismatch"],
+ ["access_token_hash_mismatch", "token_mismatch"],
+ ["missing_proof", "invalid_proof"],
+ ["malformed_proof", "invalid_proof"],
+ ["invalid_signature", "invalid_proof"],
+ ["invalid_proof", "invalid_proof"],
+ ] as const;
+
+ for (const [code, expected] of mappings) {
+ expect(mapDpopFailureReason(code)).toBe(expected);
+ }
+ });
+});
diff --git a/apps/server/src/auth/dpop.ts b/apps/server/src/auth/dpop.ts
index f19984eb3690..28e9864a3619 100644
--- a/apps/server/src/auth/dpop.ts
+++ b/apps/server/src/auth/dpop.ts
@@ -1,4 +1,8 @@
-import { verifyDpopProof } from "@t3tools/shared/dpop";
+import {
+ type DpopVerificationFailureCode as DpopVerificationFailureCodeType,
+ verifyDpopProof,
+} from "@t3tools/shared/dpop";
+import type { DpopFailureReason } from "@t3tools/contracts";
import * as Crypto from "effect/Crypto";
import * as DateTime from "effect/DateTime";
import * as Effect from "effect/Effect";
@@ -14,12 +18,34 @@ import {
} from "./EnvironmentAuth.ts";
import * as ServerSecretStore from "./ServerSecretStore.ts";
+export const mapDpopFailureReason = (
+ code: DpopVerificationFailureCodeType,
+): DpopFailureReason => {
+ switch (code) {
+ case "time_window":
+ return "time_window";
+ case "key_mismatch":
+ return "key_mismatch";
+ case "method_mismatch":
+ case "url_mismatch":
+ return "request_mismatch";
+ case "access_token_hash_mismatch":
+ return "token_mismatch";
+ case "missing_proof":
+ case "malformed_proof":
+ case "invalid_signature":
+ case "invalid_proof":
+ return "invalid_proof";
+ }
+};
+
export const mapDpopReplayStoreError = (
error: ServerSecretStore.SecretStoreError,
): ServerAuthInvalidCredentialError | ServerAuthInternalError =>
ServerSecretStore.isSecretAlreadyExistsError(error)
? new ServerAuthInvalidCredentialError({
diagnostic: "DPoP proof replayed.",
+ dpopFailureReason: "replay",
cause: error,
})
: new ServerAuthDpopReplayStateRecordError({
@@ -49,8 +75,12 @@ export const verifyRequestDpopProof = (input: {
...(input.expectedAccessToken ? { expectedAccessToken: input.expectedAccessToken } : {}),
});
if (!result.ok) {
+ yield* Effect.annotateCurrentSpan({
+ "environment.dpop.failure_code": result.code,
+ });
return yield* new ServerAuthInvalidCredentialError({
diagnostic: result.reason,
+ dpopFailureReason: mapDpopFailureReason(result.code),
});
}
const secretStore = yield* ServerSecretStore.ServerSecretStore;
@@ -80,7 +110,15 @@ export const verifyRequestDpopProof = (input: {
)
.pipe(
Effect.catchIf(ServerSecretStore.isSecretStoreError, (error) =>
- Effect.fail(mapDpopReplayStoreError(error)),
+ Effect.gen(function* () {
+ const mapped = mapDpopReplayStoreError(error);
+ if (mapped._tag === "ServerAuthInvalidCredentialError") {
+ yield* Effect.annotateCurrentSpan({
+ "environment.dpop.failure_code": mapped.dpopFailureReason,
+ });
+ }
+ return yield* Effect.fail(mapped);
+ }),
),
);
return result.thumbprint;
diff --git a/apps/server/src/auth/http.ts b/apps/server/src/auth/http.ts
index 780aaabde251..58277141a946 100644
--- a/apps/server/src/auth/http.ts
+++ b/apps/server/src/auth/http.ts
@@ -22,7 +22,7 @@ import {
EnvironmentAuthenticatedAuth,
EnvironmentAuthenticatedPrincipal,
} from "@t3tools/contracts";
-import type { AuthEnvironmentScope } from "@t3tools/contracts";
+import type { AuthEnvironmentScope, DpopFailureReason } from "@t3tools/contracts";
import { parseAllowedOAuthScope } from "@t3tools/shared/oauthScope";
import { causeErrorTag } from "@t3tools/shared/observability";
import * as DateTime from "effect/DateTime";
@@ -95,10 +95,20 @@ export function annotateEnvironmentRequest(endpoint: string) {
});
}
-export function failEnvironmentAuthInvalid(reason: EnvironmentAuthInvalidReason) {
+export function failEnvironmentAuthInvalid(
+ reason: EnvironmentAuthInvalidReason,
+ dpopFailureReason?: DpopFailureReason,
+) {
return currentEnvironmentTraceId.pipe(
Effect.flatMap((traceId) =>
- Effect.fail(new EnvironmentAuthInvalidError({ code: "auth_invalid", reason, traceId })),
+ Effect.fail(
+ new EnvironmentAuthInvalidError({
+ code: "auth_invalid",
+ reason,
+ ...(dpopFailureReason === undefined ? {} : { dpopFailureReason }),
+ traceId,
+ }),
+ ),
),
);
}
@@ -180,7 +190,10 @@ export const environmentAuthenticatedAuthLayer = Layer.effect(
const request = yield* HttpServerRequest.HttpServerRequest;
const session = yield* serverAuth.authenticateHttpRequest(request).pipe(
Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) =>
- failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)),
+ failEnvironmentAuthInvalid(
+ EnvironmentAuth.serverAuthCredentialReason(error),
+ EnvironmentAuth.serverAuthDpopFailureReason(error),
+ ),
),
Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) =>
failEnvironmentInternal("internal_error", error),
@@ -244,7 +257,10 @@ export const authHttpApiLayer = HttpApiBuilder.group(
return result.response;
},
Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) =>
- failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)),
+ failEnvironmentAuthInvalid(
+ EnvironmentAuth.serverAuthCredentialReason(error),
+ EnvironmentAuth.serverAuthDpopFailureReason(error),
+ ),
),
Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) =>
failEnvironmentInternal("browser_session_issuance_failed", error),
@@ -278,9 +294,14 @@ export const authHttpApiLayer = HttpApiBuilder.group(
}
const proofKeyThumbprint = args.headers.dpop
? yield* verifyRequestDpopProof({ request }).pipe(
- Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, () =>
+ Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) =>
appendDpopChallengeHeader.pipe(
- Effect.andThen(failEnvironmentAuthInvalid("invalid_credential")),
+ Effect.andThen(
+ failEnvironmentAuthInvalid(
+ "invalid_credential",
+ EnvironmentAuth.serverAuthDpopFailureReason(error),
+ ),
+ ),
),
),
Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) =>
@@ -307,7 +328,10 @@ export const authHttpApiLayer = HttpApiBuilder.group(
},
traceRelayRequest,
Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) =>
- failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)),
+ failEnvironmentAuthInvalid(
+ EnvironmentAuth.serverAuthCredentialReason(error),
+ EnvironmentAuth.serverAuthDpopFailureReason(error),
+ ),
),
Effect.catchIf(EnvironmentAuth.isServerAuthInvalidRequestError, (error) =>
failEnvironmentInvalidRequest(EnvironmentAuth.serverAuthInvalidRequestReason(error)),
diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts
index c3104e7bc420..33b1698acebb 100644
--- a/apps/server/src/http.ts
+++ b/apps/server/src/http.ts
@@ -117,7 +117,10 @@ const authenticateRawRouteWithScope = (
const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
const session = yield* serverAuth.authenticateHttpRequest(request).pipe(
Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) =>
- failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)),
+ failEnvironmentAuthInvalid(
+ EnvironmentAuth.serverAuthCredentialReason(error),
+ EnvironmentAuth.serverAuthDpopFailureReason(error),
+ ),
),
Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) =>
failEnvironmentInternal("internal_error", error),
diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts
index a9a2c3fa10d6..7f3552626980 100644
--- a/apps/server/src/server.test.ts
+++ b/apps/server/src/server.test.ts
@@ -10,6 +10,7 @@ import {
AuthTokenExchangeGrantType,
CommandId,
DEFAULT_SERVER_SETTINGS,
+ type DpopFailureReason,
EnvironmentId,
EventId,
GitCommandError,
@@ -1115,6 +1116,7 @@ const exchangeAccessToken = (
readonly _tag?: string;
readonly code?: string;
readonly reason?: string;
+ readonly dpopFailureReason?: DpopFailureReason;
readonly traceId?: string;
}>(response);
return {
@@ -1846,6 +1848,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
assert.equal(replayBootstrap.body._tag, "EnvironmentAuthInvalidError");
assert.equal(replayBootstrap.body.code, "auth_invalid");
assert.equal(replayBootstrap.body.reason, "invalid_credential");
+ assert.equal(replayBootstrap.body.dpopFailureReason, "replay");
assert.equal(typeof replayBootstrap.body.traceId, "string");
}).pipe(Effect.provide(NodeHttpServer.layerTest)),
);
@@ -1921,6 +1924,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
assert.equal(bootstrap.body._tag, "EnvironmentAuthInvalidError");
assert.equal(bootstrap.body.code, "auth_invalid");
assert.equal(bootstrap.body.reason, "invalid_credential");
+ assert.equal(bootstrap.body.dpopFailureReason, "request_mismatch");
assert.equal(typeof bootstrap.body.traceId, "string");
}).pipe(Effect.provide(NodeHttpServer.layerTest)),
);
diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts
index 226c82cdb1ac..dd69fe7ec287 100644
--- a/apps/server/src/ws.ts
+++ b/apps/server/src/ws.ts
@@ -2472,7 +2472,10 @@ export const websocketRpcRouteLayer = Layer.unwrap(
const analytics = yield* AnalyticsService.AnalyticsService;
const session = yield* serverAuth.authenticateWebSocketUpgrade(request).pipe(
Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) =>
- failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)),
+ failEnvironmentAuthInvalid(
+ EnvironmentAuth.serverAuthCredentialReason(error),
+ EnvironmentAuth.serverAuthDpopFailureReason(error),
+ ),
),
Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) =>
failEnvironmentInternal("internal_error", error),
diff --git a/apps/web/src/cloud/linkEnvironment.ts b/apps/web/src/cloud/linkEnvironment.ts
index a245cbc54db2..a267a6e07572 100644
--- a/apps/web/src/cloud/linkEnvironment.ts
+++ b/apps/web/src/cloud/linkEnvironment.ts
@@ -19,13 +19,12 @@ import {
type RelayClientDeviceRecord,
type RelayClientEnvironmentRecord,
type RelayEnvironmentLinkResponse,
- type RelayProtectedError as RelayProtectedErrorType,
type RelayManagedEndpointProviderKind,
} from "@t3tools/contracts/relay";
import { EnvironmentRegistry } from "@t3tools/client-runtime/connection";
import { request, runStream } from "@t3tools/client-runtime/rpc";
import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime/rpc";
-import { ManagedRelay } from "@t3tools/client-runtime/relay";
+import { ManagedRelay, relayProtectedErrorMessage } from "@t3tools/client-runtime/relay";
import {
readPrimaryEnvironmentDescriptor,
@@ -128,50 +127,6 @@ const isEnvironmentCloudApiError = Schema.is(
]),
);
-function relayProtectedErrorMessage(error: RelayProtectedErrorType): string {
- switch (error._tag) {
- case "RelayAuthInvalidError":
- switch (error.reason) {
- case "missing_bearer":
- case "invalid_bearer":
- return "Relay rejected the cloud session token.";
- case "invalid_dpop":
- return "Relay rejected the DPoP proof.";
- case "not_authorized":
- return "Relay rejected the authenticated request.";
- }
- case "RelayEnvironmentLinkProofExpiredError":
- return "Relay rejected an expired environment link proof.";
- case "RelayEnvironmentLinkProofInvalidError":
- return `Relay rejected the environment link proof (${error.reason}).`;
- case "RelayEnvironmentConnectNotAuthorizedError":
- // "Not authorized" covers non-auth causes too; surface the reason so a
- // missing link doesn't read as a credential problem.
- if (error.reason === "environment_link_not_found") {
- return "Relay has no active link for this environment. The environment server may not have re-established its link yet.";
- }
- return error.reason
- ? `Relay rejected the environment connection request (${error.reason}).`
- : "Relay rejected the environment connection request.";
- case "RelayEnvironmentEndpointUnavailableError":
- return `Relay could not reach the environment endpoint (${error.reason}).`;
- case "RelayEnvironmentEndpointTimedOutError":
- return "Relay timed out while contacting the environment endpoint.";
- case "RelayEnvironmentLinkFailedError":
- return `Relay could not link the environment (${error.reason}).`;
- case "RelayEnvironmentLinkUnavailableError":
- return `Relay cannot provision the managed endpoint (${error.reason}).`;
- case "RelayEnvironmentLinkLimitExceededError":
- return `Relay refused the link: this account already has its maximum of ${error.maxTunnels} managed tunnels. Unlink an environment to free one up.`;
- case "RelayAgentActivityPublishProofExpiredError":
- return "Relay rejected an expired agent activity publish proof.";
- case "RelayAgentActivityPublishProofInvalidError":
- return `Relay rejected the agent activity publish proof (${error.reason}).`;
- case "RelayInternalError":
- return `Relay encountered an internal error (${error.reason}).`;
- }
-}
-
function decodedRelayClientError(message: string) {
return (cause: ManagedRelay.ManagedRelayClientError) => {
const relayError =
diff --git a/apps/web/src/components/CommandPaletteResults.tsx b/apps/web/src/components/CommandPaletteResults.tsx
index bbdbc28b0609..151ec1be50c9 100644
--- a/apps/web/src/components/CommandPaletteResults.tsx
+++ b/apps/web/src/components/CommandPaletteResults.tsx
@@ -142,7 +142,7 @@ function DisabledCommandPaletteResultRow(props: {
) : null}
{props.item.description ? (
-
+
{props.item.description}
) : null}
@@ -193,7 +193,7 @@ function CommandPaletteResultRow(props: {
) : null}
{props.item.description ? (
-
+
{props.item.description}
) : null}
diff --git a/apps/web/src/components/clerk/T3ConnectUserProfilePage.tsx b/apps/web/src/components/clerk/T3ConnectUserProfilePage.tsx
index 15ed569052be..0e56dd65c405 100644
--- a/apps/web/src/components/clerk/T3ConnectUserProfilePage.tsx
+++ b/apps/web/src/components/clerk/T3ConnectUserProfilePage.tsx
@@ -216,7 +216,9 @@ export function T3ConnectUserProfilePage() {
Could not load T3 Connect environments
- {environmentsState.error}
+
+ {environmentsState.error}
+
) : null}
diff --git a/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx b/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx
index 460a253812a0..1fb67db1bc60 100644
--- a/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx
+++ b/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx
@@ -156,7 +156,9 @@ export function CloudEnvironmentConnectRows({
Could not load T3 Connect environments
- {discoveryProblem}
+
+ {discoveryProblem}
+
@@ -229,7 +232,8 @@ export function CloudEnvironmentConnectRows({
- {connectionStatusText(environment.connection)}
+
+ {connectionStatusText(environment.connection)}
+
{errorTraceId ? (
-
+
{providerEnvironmentDetail(environment)} · {statusText}
diff --git a/docs/internals/environment-auth.md b/docs/internals/environment-auth.md
index 5f4f5b6e9607..302a1bfea8fc 100644
--- a/docs/internals/environment-auth.md
+++ b/docs/internals/environment-auth.md
@@ -84,7 +84,9 @@ that sends a `DPoP` header has its proof verified by `verifyRequestDpopProof`;
the resulting JWK thumbprint is stored on the session, which is then issued with
method `dpop-access-token` and a one-hour TTL instead of the bearer default. An
invalid proof gets a DPoP challenge header and a credential error rather than a
-bearer token.
+bearer token. Newer servers include a safe `dpopFailureReason` category in that
+error. Clients keep a generic date-and-time hint when an older server omits the
+category.
`dpop-access-token` is advertised alongside `browser-session-cookie` and
`bearer-access-token` in the descriptor's `sessionMethods`
diff --git a/docs/operations/observability.md b/docs/operations/observability.md
index 7341bfb5edac..966eab112c6b 100644
--- a/docs/operations/observability.md
+++ b/docs/operations/observability.md
@@ -49,6 +49,12 @@ records instead carry OTLP resource, scope, and optional status fields.
The `TraceRecord`, `EffectTraceRecord`, and `OtlpTraceRecord` schemas live in
`packages/shared/src/observability.ts`.
+DPoP proof failures include the safe `environment.dpop.failure_code` span
+attribute. A `time_window` failure means that a signed proof was too old or too
+far in the future for the environment server's allowed window. It can point to
+a date or time problem on either device, but it can also result from a delayed
+request.
+
### Metrics
Metrics are not written to a local file.
diff --git a/docs/operations/relay-observability.md b/docs/operations/relay-observability.md
index 2bc697b2ef1f..7e48a13ebd29 100644
--- a/docs/operations/relay-observability.md
+++ b/docs/operations/relay-observability.md
@@ -51,3 +51,9 @@ Agents should prefer the provisioned view or APL queries for completed incidents
tailing the Cloudflare Worker. The stack does not provision a separate query token. Responders who
need scripted query access use the authorized account-level `AXIOM_TOKEN` together with
`AXIOM_ORG_ID`; scoped ingest tokens remain write-only credentials for their producers.
+
+DPoP proof failures include the stable `relay.dpop.failure_code` span attribute. A `time_window`
+failure means that a signed proof was too old or too far in the future for the relay's allowed
+window. It can point to a date or time problem on either device, but it can also result from a
+delayed request. The client uses this category, and the absence of a category from an older relay,
+to show the date-and-time troubleshooting hint.
diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md
index 5993fca5b352..40473930c601 100644
--- a/docs/user/remote-access.md
+++ b/docs/user/remote-access.md
@@ -174,6 +174,8 @@ the conversation and in **Settings** → **Connections**. Follow the action show
be able to update and reconnect the server for you, or it may ask you to update the desktop app or
run a copied command on the server machine.
+If T3 Connect cannot connect, check the date and time on both devices, then try again.
+
Finish active work before updating because the server restarts briefly. For step-by-step guidance,
see [Keeping T3 Code in Sync](./updating.md).
diff --git a/infra/relay/src/auth/DpopProofs.ts b/infra/relay/src/auth/DpopProofs.ts
index fa784eb639b6..94f31d8600ef 100644
--- a/infra/relay/src/auth/DpopProofs.ts
+++ b/infra/relay/src/auth/DpopProofs.ts
@@ -3,10 +3,9 @@ import * as DateTime from "effect/DateTime";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Schema from "effect/Schema";
-import * as HttpApiError from "effect/unstable/httpapi/HttpApiError";
import { lt } from "drizzle-orm";
-import { verifyDpopProof } from "@t3tools/shared/dpop";
+import { DpopVerificationFailureCode, verifyDpopProof } from "@t3tools/shared/dpop";
import * as RelayDb from "../db.ts";
import { relayDpopProofs } from "../persistence/schema.ts";
@@ -26,6 +25,23 @@ export class DpopProofReplayPersistenceError extends Schema.TaggedErrorClass()(
+ "DpopProofRejected",
+ {
+ code: DpopProofFailureCode,
+ },
+) {
+ override get message(): string {
+ return `DPoP proof rejected: ${this.code}`;
+ }
+}
+
export class DpopProofReplay extends Context.Service<
DpopProofReplay,
{
@@ -36,7 +52,11 @@ export class DpopProofReplay extends Context.Service<
readonly expectedThumbprint?: string;
readonly expectedAccessToken?: string;
readonly now: DateTime.DateTime;
- }) => Effect.Effect;
+ }) =>
+ Effect.Effect<
+ string,
+ DpopProofRejected | DpopProofReplayPersistenceError
+ >;
readonly consume: (input: {
readonly thumbprint: string;
readonly jti: string;
@@ -98,13 +118,16 @@ const make = Effect.gen(function* () {
});
if (!result.ok) {
yield* Effect.logWarning("relay dpop proof rejected", {
+ code: result.code,
reason: result.reason,
method: input.method,
url: input.url,
expectedThumbprintPresent: input.expectedThumbprint !== undefined,
expectedAccessTokenPresent: input.expectedAccessToken !== undefined,
});
- return yield* new HttpApiError.Unauthorized({});
+ return yield* new DpopProofRejected({
+ code: result.code,
+ });
}
const consumed = yield* consume({
thumbprint: result.thumbprint,
@@ -118,7 +141,9 @@ const make = Effect.gen(function* () {
jti: result.jti,
iat: result.iat,
});
- return yield* new HttpApiError.Unauthorized({});
+ return yield* new DpopProofRejected({
+ code: "replayed",
+ });
}
yield* Effect.annotateCurrentSpan({
"relay.dpop.thumbprint": result.thumbprint,
diff --git a/infra/relay/src/auth/DpopProofs.verifyAndConsume.test.ts b/infra/relay/src/auth/DpopProofs.verifyAndConsume.test.ts
index 7663e874879b..fc83df40691e 100644
--- a/infra/relay/src/auth/DpopProofs.verifyAndConsume.test.ts
+++ b/infra/relay/src/auth/DpopProofs.verifyAndConsume.test.ts
@@ -96,6 +96,34 @@ function consumeEachProofOnce() {
}
describe("DpopProofReplay.verifyAndConsume", () => {
+ it.effect("reports a signed proof outside the time window", () => {
+ const now = DateTime.makeUnsafe("2026-05-25T12:00:00.000Z");
+ const proof = makeDpopProof({
+ method: "POST",
+ url: "https://relay.example.com/v1/environments/env/connect",
+ iat: Math.floor(now.epochMilliseconds / 1_000) - 301,
+ jti: "proof-old",
+ });
+
+ return Effect.gen(function* () {
+ const replay = yield* DpopProofs.DpopProofReplay;
+ const error = yield* Effect.flip(
+ replay.verifyAndConsume({
+ proof: proof.proof,
+ method: "POST",
+ url: "https://relay.example.com/v1/environments/env/connect",
+ expectedThumbprint: proof.thumbprint,
+ now,
+ }),
+ );
+
+ expect(error).toMatchObject({
+ _tag: "DpopProofRejected",
+ code: "time_window",
+ });
+ }).pipe(Effect.provide(layer(() => Effect.die("unexpected replay persistence"))));
+ });
+
it.effect("rejects replayed proofs after persistence consumes the jti once", () => {
const now = DateTime.makeUnsafe("2026-05-25T12:00:00.000Z");
const proof = makeDpopProof({
@@ -114,7 +142,7 @@ describe("DpopProofReplay.verifyAndConsume", () => {
expectedThumbprint: proof.thumbprint,
now,
});
- const second = yield* Effect.exit(
+ const second = yield* Effect.flip(
replay.verifyAndConsume({
proof: proof.proof,
method: "POST",
@@ -125,7 +153,7 @@ describe("DpopProofReplay.verifyAndConsume", () => {
);
expect(first).toBe(proof.thumbprint);
- expect(second._tag).toBe("Failure");
+ expect(second).toMatchObject({ _tag: "DpopProofRejected", code: "replayed" });
}).pipe(Effect.provide(layer(consumeEachProofOnce())));
});
@@ -140,7 +168,7 @@ describe("DpopProofReplay.verifyAndConsume", () => {
return Effect.gen(function* () {
const replay = yield* DpopProofs.DpopProofReplay;
- const result = yield* Effect.exit(
+ const error = yield* Effect.flip(
replay.verifyAndConsume({
proof: proof.proof,
method: "POST",
@@ -151,7 +179,10 @@ describe("DpopProofReplay.verifyAndConsume", () => {
}),
);
- expect(result._tag).toBe("Failure");
+ expect(error).toMatchObject({
+ _tag: "DpopProofRejected",
+ code: "access_token_hash_mismatch",
+ });
}).pipe(Effect.provide(layer(() => Effect.die("unexpected DPoP replay persistence"))));
});
diff --git a/infra/relay/src/http/Api.test.ts b/infra/relay/src/http/Api.test.ts
index daf756a2b7cc..222deafd621b 100644
--- a/infra/relay/src/http/Api.test.ts
+++ b/infra/relay/src/http/Api.test.ts
@@ -23,6 +23,7 @@ import {
relayDocsRedirectRoute,
relayEnvironmentAuthLayer,
relayNotFoundRoute,
+ relayDpopFailureReason,
revokeEnvironmentLinkRecord,
traceRelayHttpRequestWith,
unlinkEnvironmentRecord,
@@ -115,6 +116,27 @@ describe("relay client authentication", () => {
);
});
+describe("relay DPoP failure mapping", () => {
+ it("maps verifier failures to safe client-facing categories", () => {
+ const mappings = [
+ ["time_window", "time_window"],
+ ["key_mismatch", "key_mismatch"],
+ ["method_mismatch", "request_mismatch"],
+ ["url_mismatch", "request_mismatch"],
+ ["access_token_hash_mismatch", "token_mismatch"],
+ ["replayed", "replay"],
+ ["missing_proof", "invalid_proof"],
+ ["malformed_proof", "invalid_proof"],
+ ["invalid_signature", "invalid_proof"],
+ ["invalid_proof", "invalid_proof"],
+ ] as const;
+
+ for (const [code, expected] of mappings) {
+ expect(relayDpopFailureReason(code)).toBe(expected);
+ }
+ });
+});
+
describe("relay environment authentication", () => {
it.effect("preserves credential lookup persistence failures as internal errors", () => {
const failure = new EnvironmentCredentials.EnvironmentCredentialAuthenticatePersistenceError({
diff --git a/infra/relay/src/http/Api.ts b/infra/relay/src/http/Api.ts
index 50bcff665a9b..40744b054fd3 100644
--- a/infra/relay/src/http/Api.ts
+++ b/infra/relay/src/http/Api.ts
@@ -35,6 +35,7 @@ import {
RelayMobileRegistrationScope,
RelayAuthInvalidError,
type RelayAuthInvalidReason,
+ type RelayDpopFailureReason,
RelayEnvironmentAuth,
RelayEnvironmentConnectNotAuthorizedError,
RelayEnvironmentEndpointTimedOutError,
@@ -1030,10 +1031,37 @@ type RelayCommonPersistenceError = typeof RelayCommonPersistenceError.Type;
const isRelayCommonPersistenceError = Schema.is(RelayCommonPersistenceError);
type MapRelayCommonApiError =
- | Exclude
+ | Exclude<
+ E,
+ HttpApiError.Unauthorized | DpopProofs.DpopProofRejected | RelayCommonPersistenceError
+ >
| (Extract extends never ? never : RelayAuthInvalidError)
+ | (Extract extends never ? never : RelayAuthInvalidError)
| (Extract extends never ? never : RelayInternalError);
+export function relayDpopFailureReason(
+ code: DpopProofs.DpopProofFailureCode,
+): RelayDpopFailureReason {
+ switch (code) {
+ case "time_window":
+ return "time_window";
+ case "key_mismatch":
+ return "key_mismatch";
+ case "method_mismatch":
+ case "url_mismatch":
+ return "request_mismatch";
+ case "access_token_hash_mismatch":
+ return "token_mismatch";
+ case "replayed":
+ return "replay";
+ case "missing_proof":
+ case "malformed_proof":
+ case "invalid_signature":
+ case "invalid_proof":
+ return "invalid_proof";
+ }
+}
+
function relayInternalErrorResponse(reason: RelayInternalError["reason"]) {
return currentTraceId.pipe(
Effect.flatMap((traceId) =>
@@ -1045,11 +1073,32 @@ function relayInternalErrorResponse(reason: RelayInternalError["reason"]) {
function mapRelayCommonApiErrors(authReason: RelayAuthInvalidReason) {
const mapError = Effect.fnUntraced(function* (error: E) {
const traceId = yield* currentTraceId;
+ if (Schema.is(DpopProofs.DpopProofRejected)(error)) {
+ yield* Effect.annotateCurrentSpan({
+ "relay.dpop.failure_code": error.code,
+ });
+ return yield* Effect.fail(
+ new RelayAuthInvalidError({
+ code: "auth_invalid",
+ reason: authReason,
+ ...(authReason === "invalid_dpop"
+ ? { dpopFailureReason: relayDpopFailureReason(error.code) }
+ : {}),
+ traceId,
+ }) as MapRelayCommonApiError,
+ );
+ }
if (isHttpUnauthorized(error)) {
+ if (authReason === "invalid_dpop") {
+ yield* Effect.annotateCurrentSpan({
+ "relay.dpop.failure_code": "invalid_proof",
+ });
+ }
return yield* Effect.fail(
new RelayAuthInvalidError({
code: "auth_invalid",
reason: authReason,
+ ...(authReason === "invalid_dpop" ? { dpopFailureReason: "invalid_proof" } : {}),
traceId,
}) as MapRelayCommonApiError,
);
diff --git a/packages/client-runtime/src/authorization/layer.test.ts b/packages/client-runtime/src/authorization/layer.test.ts
index 466a5c2dd4ea..838c8d7d1104 100644
--- a/packages/client-runtime/src/authorization/layer.test.ts
+++ b/packages/client-runtime/src/authorization/layer.test.ts
@@ -6,6 +6,7 @@ import * as Option from "effect/Option";
import * as Ref from "effect/Ref";
import * as TestClock from "effect/testing/TestClock";
+import { DPOP_CLOCK_HINT } from "../relay/errorPresentation.ts";
import * as ManagedRelay from "../relay/managedRelay.ts";
import { remoteHttpClientLayer } from "../rpc/http.ts";
import * as ClientCapabilities from "../platform/capabilities.ts";
@@ -341,6 +342,29 @@ describe("RemoteEnvironmentAuthorization", () => {
}),
);
+ it.effect("shows the clock hint when an environment rejects a DPoP bootstrap proof", () =>
+ Effect.gen(function* () {
+ const harness = yield* makeHarness({
+ responses: [Response.json(DESCRIPTOR), authInvalid()],
+ });
+
+ const failure = yield* Effect.gen(function* () {
+ const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization;
+ return yield* remote.authorizeDpop({
+ expectedEnvironmentId: ENVIRONMENT_ID,
+ obtainBootstrap: harness.obtainBootstrap,
+ });
+ }).pipe(Effect.provide(harness.layer), Effect.flip);
+
+ expect(failure).toMatchObject({
+ _tag: "ConnectionBlockedError",
+ reason: "authentication",
+ detail: `The environment credential is invalid.\n\n${DPOP_CLOCK_HINT}`,
+ traceId: "trace-auth-invalid",
+ });
+ }),
+ );
+
it.effect("refreshes a cached endpoint after its first transient failure", () =>
Effect.gen(function* () {
const cached = new TokenStore.RemoteDpopAccessToken({
diff --git a/packages/client-runtime/src/authorization/service.ts b/packages/client-runtime/src/authorization/service.ts
index fef8db274b3a..0d3849878860 100644
--- a/packages/client-runtime/src/authorization/service.ts
+++ b/packages/client-runtime/src/authorization/service.ts
@@ -6,7 +6,11 @@ import {
resolveRemoteDpopWebSocketConnectionUrl,
resolveRemoteWebSocketConnectionUrl,
} from "./remote.ts";
-import { environmentMismatchError, mapRemoteEnvironmentError } from "../connection/errors.ts";
+import {
+ environmentMismatchError,
+ mapRemoteDpopEnvironmentError,
+ mapRemoteEnvironmentError,
+} from "../connection/errors.ts";
import { ConnectionBlockedError, type ConnectionAttemptError } from "../connection/model.ts";
import { fetchRemoteEnvironmentDescriptor } from "../environment/descriptor.ts";
import { environmentEndpointUrl } from "../environment/endpoint.ts";
@@ -64,7 +68,7 @@ const BEARER_DESCRIPTOR_CACHE_TTL_MS = 10_000;
function mapDpopSocketError(error: RemoteEnvironmentAuthError | ConnectionAttemptError) {
return error._tag === "ConnectionTransientError" || error._tag === "ConnectionBlockedError"
? error
- : mapRemoteEnvironmentError(error);
+ : mapRemoteDpopEnvironmentError(error);
}
const fetchDescriptor = Effect.fn("clientRuntime.connection.remote.fetchDescriptor")(function* (
@@ -268,7 +272,7 @@ export const make = Effect.gen(function* () {
scopes: presentation.scopes,
clientMetadata: presentation.metadata,
}).pipe(
- Effect.mapError(mapRemoteEnvironmentError),
+ Effect.mapError(mapRemoteDpopEnvironmentError),
Effect.provideService(HttpClient.HttpClient, httpClient),
Effect.withSpan("environment.authorization.accessToken.exchange"),
);
diff --git a/packages/client-runtime/src/connection/errors.test.ts b/packages/client-runtime/src/connection/errors.test.ts
new file mode 100644
index 000000000000..225941aa093e
--- /dev/null
+++ b/packages/client-runtime/src/connection/errors.test.ts
@@ -0,0 +1,75 @@
+import { EnvironmentAuthInvalidError } from "@t3tools/contracts";
+import { RelayAuthInvalidError } from "@t3tools/contracts/relay";
+import { describe, expect, it } from "@effect/vitest";
+
+import { mapManagedRelayError, mapRemoteDpopEnvironmentError } from "./errors.ts";
+import { DPOP_CLOCK_HINT, DPOP_RETRY_HINT } from "../relay/errorPresentation.ts";
+import { ManagedRelayRequestFailedError } from "../relay/managedRelay.ts";
+
+describe("mapManagedRelayError", () => {
+ it("shows the clock hint when an older relay returns a generic DPoP error", () => {
+ const mapped = mapManagedRelayError(
+ new ManagedRelayRequestFailedError({
+ action: "connect relay environment",
+ cause: new Error("request failed"),
+ relayError: new RelayAuthInvalidError({
+ code: "auth_invalid",
+ reason: "invalid_dpop",
+ traceId: "trace-1",
+ }),
+ traceId: "trace-1",
+ }),
+ );
+
+ expect(mapped).toMatchObject({
+ _tag: "ConnectionBlockedError",
+ reason: "authentication",
+ detail: `Relay rejected the DPoP proof.\n\n${DPOP_CLOCK_HINT}`,
+ traceId: "trace-1",
+ });
+ });
+
+ it("uses a neutral hint when the relay identifies a non-clock DPoP error", () => {
+ const mapped = mapManagedRelayError(
+ new ManagedRelayRequestFailedError({
+ action: "connect relay environment",
+ cause: new Error("request failed"),
+ relayError: new RelayAuthInvalidError({
+ code: "auth_invalid",
+ reason: "invalid_dpop",
+ dpopFailureReason: "key_mismatch",
+ traceId: "trace-1",
+ }),
+ }),
+ );
+
+ expect(mapped.message).toBe(`Relay rejected the DPoP proof.\n\n${DPOP_RETRY_HINT}`);
+ });
+});
+
+describe("mapRemoteDpopEnvironmentError", () => {
+ it("uses the clock hint when an older environment server returns a generic auth error", () => {
+ const mapped = mapRemoteDpopEnvironmentError(
+ new EnvironmentAuthInvalidError({
+ code: "auth_invalid",
+ reason: "invalid_credential",
+ traceId: "trace-1",
+ }),
+ );
+
+ expect(mapped.message).toBe(`The environment credential is invalid.\n\n${DPOP_CLOCK_HINT}`);
+ });
+
+ it("uses a neutral hint for a non-clock DPoP error from a new server", () => {
+ const mapped = mapRemoteDpopEnvironmentError(
+ new EnvironmentAuthInvalidError({
+ code: "auth_invalid",
+ reason: "invalid_credential",
+ dpopFailureReason: "key_mismatch",
+ traceId: "trace-1",
+ }),
+ );
+
+ expect(mapped.message).toBe(`The environment credential is invalid.\n\n${DPOP_RETRY_HINT}`);
+ });
+});
diff --git a/packages/client-runtime/src/connection/errors.ts b/packages/client-runtime/src/connection/errors.ts
index 66c10333d6a9..545db2eec17f 100644
--- a/packages/client-runtime/src/connection/errors.ts
+++ b/packages/client-runtime/src/connection/errors.ts
@@ -1,6 +1,10 @@
import type { EnvironmentId } from "@t3tools/contracts";
import type { RelayProtectedError } from "@t3tools/contracts/relay";
import type { ManagedRelayClientError } from "../relay/managedRelay.ts";
+import {
+ dpopFailureHint,
+ relayProtectedErrorMessage,
+} from "../relay/errorPresentation.ts";
import type { RemoteEnvironmentAuthError } from "../authorization/remote.ts";
import {
ConnectionBlockedError,
@@ -40,7 +44,7 @@ function relayProtectedError(error: RelayProtectedError): ConnectionAttemptError
case "RelayAgentActivityPublishProofInvalidError":
return new ConnectionBlockedError({
reason: "authentication",
- detail: error.message,
+ detail: relayProtectedErrorMessage(error),
traceId: error.traceId,
});
case "RelayEnvironmentConnectNotAuthorizedError":
@@ -48,27 +52,27 @@ function relayProtectedError(error: RelayProtectedError): ConnectionAttemptError
case "RelayEnvironmentLinkLimitExceededError":
return new ConnectionBlockedError({
reason: "permission",
- detail: error.message,
+ detail: relayProtectedErrorMessage(error),
traceId: error.traceId,
});
case "RelayEnvironmentEndpointTimedOutError":
return new ConnectionTransientError({
reason: "timeout",
- detail: error.message,
+ detail: relayProtectedErrorMessage(error),
traceId: error.traceId,
});
case "RelayEnvironmentEndpointUnavailableError":
case "RelayEnvironmentLinkUnavailableError":
return new ConnectionTransientError({
reason: "endpoint-unavailable",
- detail: error.message,
+ detail: relayProtectedErrorMessage(error),
traceId: error.traceId,
});
case "RelayEnvironmentLinkFailedError":
case "RelayInternalError":
return new ConnectionTransientError({
reason: "relay-unavailable",
- detail: error.message,
+ detail: relayProtectedErrorMessage(error),
traceId: error.traceId,
});
}
@@ -166,3 +170,23 @@ export function mapRemoteEnvironmentError(
});
}
}
+
+/**
+ * Map an environment error from a request that used DPoP authentication. An
+ * older environment server reports a DPoP clock failure as the same generic
+ * invalid-credential response as other DPoP failures, so keep the hint at the
+ * client boundary where the request type is known. Newer servers can identify
+ * non-clock failures and receive a neutral retry hint instead.
+ */
+export function mapRemoteDpopEnvironmentError(
+ error: RemoteEnvironmentAuthError,
+): ConnectionAttemptError {
+ if (error._tag === "EnvironmentAuthInvalidError" && error.reason === "invalid_credential") {
+ return new ConnectionBlockedError({
+ reason: "authentication",
+ detail: `The environment credential is invalid.\n\n${dpopFailureHint(error.dpopFailureReason)}`,
+ traceId: error.traceId,
+ });
+ }
+ return mapRemoteEnvironmentError(error);
+}
diff --git a/packages/client-runtime/src/relay/errorPresentation.test.ts b/packages/client-runtime/src/relay/errorPresentation.test.ts
new file mode 100644
index 000000000000..82ec7946d260
--- /dev/null
+++ b/packages/client-runtime/src/relay/errorPresentation.test.ts
@@ -0,0 +1,52 @@
+import { RelayAuthInvalidError } from "@t3tools/contracts/relay";
+import { describe, expect, it } from "@effect/vitest";
+
+import { DPOP_CLOCK_HINT, relayProtectedErrorMessage } from "./errorPresentation.ts";
+
+describe("relayProtectedErrorMessage", () => {
+ it("uses the clock hint when an older relay omits the DPoP failure reason", () => {
+ const error = new RelayAuthInvalidError({
+ code: "auth_invalid",
+ reason: "invalid_dpop",
+ traceId: "trace-1",
+ });
+
+ expect(relayProtectedErrorMessage(error)).toBe(
+ `Relay rejected the DPoP proof.\n\n${DPOP_CLOCK_HINT}`,
+ );
+ });
+
+ it("keeps the clock hint for a relay that confirms a time-window failure", () => {
+ const error = new RelayAuthInvalidError({
+ code: "auth_invalid",
+ reason: "invalid_dpop",
+ dpopFailureReason: "time_window",
+ traceId: "trace-1",
+ });
+
+ expect(relayProtectedErrorMessage(error)).toContain(DPOP_CLOCK_HINT);
+ });
+
+ it("does not blame the clock when the relay identifies another proof failure", () => {
+ const error = new RelayAuthInvalidError({
+ code: "auth_invalid",
+ reason: "invalid_dpop",
+ dpopFailureReason: "key_mismatch",
+ traceId: "trace-1",
+ });
+
+ expect(relayProtectedErrorMessage(error)).toBe(
+ "Relay rejected the DPoP proof.\n\nHint: Try again. If the problem continues, copy the trace ID.",
+ );
+ });
+
+ it("preserves the existing message for other authentication failures", () => {
+ const error = new RelayAuthInvalidError({
+ code: "auth_invalid",
+ reason: "invalid_bearer",
+ traceId: "trace-1",
+ });
+
+ expect(relayProtectedErrorMessage(error)).toBe("Relay rejected the cloud session token.");
+ });
+});
diff --git a/packages/client-runtime/src/relay/errorPresentation.ts b/packages/client-runtime/src/relay/errorPresentation.ts
new file mode 100644
index 000000000000..c9a4d4b623e2
--- /dev/null
+++ b/packages/client-runtime/src/relay/errorPresentation.ts
@@ -0,0 +1,61 @@
+import type { DpopFailureReason } from "@t3tools/contracts";
+import type { RelayProtectedError } from "@t3tools/contracts/relay";
+
+/**
+ * A DPoP proof is checked against the clock on the receiving server. Older
+ * servers do not report why they rejected a proof, so this hint is also the
+ * compatibility fallback for those responses.
+ */
+export const DPOP_CLOCK_HINT =
+ "Hint: Check the date and time on both devices, then try again.";
+
+export const DPOP_RETRY_HINT =
+ "Hint: Try again. If the problem continues, copy the trace ID.";
+
+export function dpopFailureHint(reason: DpopFailureReason | undefined): string {
+ return reason === undefined || reason === "time_window" ? DPOP_CLOCK_HINT : DPOP_RETRY_HINT;
+}
+
+export function relayProtectedErrorMessage(error: RelayProtectedError): string {
+ switch (error._tag) {
+ case "RelayAuthInvalidError":
+ switch (error.reason) {
+ case "missing_bearer":
+ case "invalid_bearer":
+ return "Relay rejected the cloud session token.";
+ case "invalid_dpop":
+ return `Relay rejected the DPoP proof.\n\n${dpopFailureHint(error.dpopFailureReason)}`;
+ case "not_authorized":
+ return "Relay rejected the authenticated request.";
+ }
+ case "RelayEnvironmentLinkProofExpiredError":
+ return "Relay rejected an expired environment link proof.";
+ case "RelayEnvironmentLinkProofInvalidError":
+ return `Relay rejected the environment link proof (${error.reason}).`;
+ case "RelayEnvironmentConnectNotAuthorizedError":
+ // "Not authorized" covers non-auth causes too; surface the reason so a
+ // missing link does not read as a credential problem.
+ if (error.reason === "environment_link_not_found") {
+ return "Relay has no active link for this environment. The environment server may not have re-established its link yet.";
+ }
+ return error.reason
+ ? `Relay rejected the environment connection request (${error.reason}).`
+ : "Relay rejected the environment connection request.";
+ case "RelayEnvironmentEndpointUnavailableError":
+ return `Relay could not reach the environment endpoint (${error.reason}).`;
+ case "RelayEnvironmentEndpointTimedOutError":
+ return "Relay timed out while contacting the environment endpoint.";
+ case "RelayEnvironmentLinkFailedError":
+ return `Relay could not link the environment (${error.reason}).`;
+ case "RelayEnvironmentLinkUnavailableError":
+ return `Relay cannot provision the managed endpoint (${error.reason}).`;
+ case "RelayEnvironmentLinkLimitExceededError":
+ return `Relay refused the link: this account already has its maximum of ${error.maxTunnels} managed tunnels. Unlink an environment to free one up.`;
+ case "RelayAgentActivityPublishProofExpiredError":
+ return "Relay rejected an expired agent activity publish proof.";
+ case "RelayAgentActivityPublishProofInvalidError":
+ return `Relay rejected the agent activity publish proof (${error.reason}).`;
+ case "RelayInternalError":
+ return `Relay encountered an internal error (${error.reason}).`;
+ }
+}
diff --git a/packages/client-runtime/src/relay/index.ts b/packages/client-runtime/src/relay/index.ts
index 76f755353044..4c8104eb44bc 100644
--- a/packages/client-runtime/src/relay/index.ts
+++ b/packages/client-runtime/src/relay/index.ts
@@ -1,3 +1,4 @@
export * as Discovery from "./discovery.ts";
+export * from "./errorPresentation.ts";
export * as ManagedRelay from "./managedRelay.ts";
export * from "./managedRelayState.ts";
diff --git a/packages/client-runtime/src/relay/managedRelay.test.ts b/packages/client-runtime/src/relay/managedRelay.test.ts
index 278c205883f5..fb2feaa61772 100644
--- a/packages/client-runtime/src/relay/managedRelay.test.ts
+++ b/packages/client-runtime/src/relay/managedRelay.test.ts
@@ -462,6 +462,43 @@ describe("ManagedRelayClient", () => {
}).pipe(Effect.provide(managedRelayTestLayer(fetchFn)));
});
+ it.effect("accepts generic DPoP errors from relays without the optional reason", () => {
+ const fetchFn = (() =>
+ Promise.resolve(
+ Response.json(
+ {
+ _tag: "RelayAuthInvalidError",
+ code: "auth_invalid",
+ reason: "invalid_dpop",
+ traceId: "trace-old-relay",
+ },
+ { status: 401 },
+ ),
+ )) satisfies typeof globalThis.fetch;
+
+ return Effect.gen(function* () {
+ const relayClient = yield* ManagedRelay.ManagedRelayClient;
+ const error = yield* relayClient
+ .listEnvironments({ clerkToken: "clerk-token" })
+ .pipe(Effect.flip);
+
+ expect(error).toMatchObject({
+ _tag: "ManagedRelayRequestFailedError",
+ traceId: "trace-old-relay",
+ relayError: {
+ _tag: "RelayAuthInvalidError",
+ reason: "invalid_dpop",
+ },
+ });
+ if (
+ error._tag === "ManagedRelayRequestFailedError" &&
+ error.relayError?._tag === "RelayAuthInvalidError"
+ ) {
+ expect(error.relayError.dpopFailureReason).toBeUndefined();
+ }
+ }).pipe(Effect.provide(managedRelayTestLayer(fetchFn)));
+ });
+
it.effect("lists account devices through the Clerk bearer client endpoint", () => {
const fetchFn = ((input, init) => {
expect(String(input)).toBe("https://relay.example.test/v1/client/devices");
diff --git a/packages/client-runtime/src/relay/managedRelayState.test.ts b/packages/client-runtime/src/relay/managedRelayState.test.ts
index 00ac733762fe..5996ababad6a 100644
--- a/packages/client-runtime/src/relay/managedRelayState.test.ts
+++ b/packages/client-runtime/src/relay/managedRelayState.test.ts
@@ -1,8 +1,9 @@
import { EnvironmentId } from "@t3tools/contracts";
-import type {
- RelayClientDeviceRecord,
- RelayClientEnvironmentRecord,
- RelayEnvironmentStatusResponse,
+import {
+ RelayAuthInvalidError,
+ type RelayClientDeviceRecord,
+ type RelayClientEnvironmentRecord,
+ type RelayEnvironmentStatusResponse,
} from "@t3tools/contracts/relay";
import { describe, expect, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
@@ -421,4 +422,31 @@ describe("createManagedRelayQueryManager", () => {
});
});
});
+
+ it("shows the DPoP clock hint for snapshot requests from older relays", async () => {
+ const manager = createManager({
+ getEnvironmentStatus: () =>
+ Effect.fail(
+ new ManagedRelay.ManagedRelayRequestFailedError({
+ action: "get relay environment status",
+ cause: new Error("Relay request failed."),
+ relayError: new RelayAuthInvalidError({
+ code: "auth_invalid",
+ reason: "invalid_dpop",
+ traceId: "trace-status",
+ }),
+ traceId: "trace-status",
+ }),
+ ),
+ });
+ setSession();
+ const atom = manager.environmentStatusAtom({ accountId: "account-1", environment });
+
+ registry.get(atom);
+ await vi.waitFor(() => {
+ expect(readManagedRelaySnapshotState(registry.get(atom)).error).toBe(
+ "Relay rejected the DPoP proof.\n\nHint: Check the date and time on both devices, then try again.",
+ );
+ });
+ });
});
diff --git a/packages/client-runtime/src/relay/managedRelayState.ts b/packages/client-runtime/src/relay/managedRelayState.ts
index 1a3a22efb204..7294fd6c61ad 100644
--- a/packages/client-runtime/src/relay/managedRelayState.ts
+++ b/packages/client-runtime/src/relay/managedRelayState.ts
@@ -18,6 +18,7 @@ import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity";
import { findErrorTraceId } from "../errors/errorTrace.ts";
import * as ManagedRelay from "./managedRelay.ts";
+import { relayProtectedErrorMessage } from "./errorPresentation.ts";
const DEFAULT_STALE_TIME_MS = 15_000;
const DEFAULT_IDLE_TTL_MS = 5 * 60_000;
@@ -315,7 +316,12 @@ export function readManagedRelaySnapshotState(
let errorTraceId: string | null = null;
if (result._tag === "Failure") {
const cause = Cause.squash(result.cause);
- error = cause instanceof Error ? cause.message : "Could not load T3 Connect data.";
+ error =
+ cause instanceof ManagedRelay.ManagedRelayRequestFailedError && cause.relayError
+ ? relayProtectedErrorMessage(cause.relayError)
+ : cause instanceof Error
+ ? cause.message
+ : "Could not load T3 Connect data.";
errorTraceId = findErrorTraceId(cause);
}
return {
diff --git a/packages/contracts/src/baseSchemas.ts b/packages/contracts/src/baseSchemas.ts
index e12bf3e975de..088463fae49e 100644
--- a/packages/contracts/src/baseSchemas.ts
+++ b/packages/contracts/src/baseSchemas.ts
@@ -18,6 +18,20 @@ export const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))
export const PositiveInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(1));
export const PortSchema = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 }));
+/**
+ * Safe categories for a failed DPoP proof. These describe the class of failure
+ * without exposing proof contents or server-side authentication details.
+ */
+export const DpopFailureReason = Schema.Literals([
+ "time_window",
+ "key_mismatch",
+ "request_mismatch",
+ "token_mismatch",
+ "replay",
+ "invalid_proof",
+]);
+export type DpopFailureReason = typeof DpopFailureReason.Type;
+
export const IsoDateTime = Schema.String;
export type IsoDateTime = typeof IsoDateTime.Type;
diff --git a/packages/contracts/src/environmentHttp.ts b/packages/contracts/src/environmentHttp.ts
index e7494862251e..a895697e36b0 100644
--- a/packages/contracts/src/environmentHttp.ts
+++ b/packages/contracts/src/environmentHttp.ts
@@ -24,7 +24,12 @@ import {
AuthWebSocketTicketResult,
ServerAuthSessionMethod,
} from "./auth.ts";
-import { AuthSessionId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts";
+import {
+ DpopFailureReason,
+ AuthSessionId,
+ ThreadId,
+ TrimmedNonEmptyString,
+} from "./baseSchemas.ts";
import { ExecutionEnvironmentDescriptor } from "./environment.ts";
import {
ClientOrchestrationCommand,
@@ -117,6 +122,8 @@ export class EnvironmentAuthInvalidError extends Schema.TaggedErrorClass {
);
});
+ it("reports a time-window failure only after the proof signature is valid", () => {
+ const thumbprint = computeDpopJwkThumbprint(publicJwk);
+ const outsideWindow = verifyDpopProof({
+ proof,
+ method: "POST",
+ url: "https://example.com/oauth/token",
+ nowEpochSeconds: 1_000,
+ expectedThumbprint: thumbprint,
+ });
+ if (outsideWindow.ok) {
+ assert.fail("Expected an old DPoP proof to fail.");
+ }
+ assert.equal(outsideWindow.code, "time_window");
+
+ const { privateKey: otherPrivateKey } = NodeCrypto.generateKeyPairSync("ec", {
+ namedCurve: "P-256",
+ });
+ const invalidSignatureProof = signDpopProof({
+ method: "POST",
+ url: "https://example.com/oauth/token",
+ iat: 100,
+ privateKey: otherPrivateKey,
+ publicJwk,
+ });
+ const invalidSignature = verifyDpopProof({
+ proof: invalidSignatureProof,
+ method: "POST",
+ url: "https://example.com/oauth/token",
+ nowEpochSeconds: 1_000,
+ expectedThumbprint: thumbprint,
+ });
+ if (invalidSignature.ok) {
+ assert.fail("Expected a proof signed by a different key to fail.");
+ }
+ assert.equal(invalidSignature.code, "invalid_signature");
+ });
+
it("requires the RFC 9449 access token hash when an access token is expected", () => {
const thumbprint = computeDpopJwkThumbprint(publicJwk);
const accessTokenProof = signDpopProof({
diff --git a/packages/shared/src/dpop.ts b/packages/shared/src/dpop.ts
index dabfaffa4cdd..46f2e8f8fa1d 100644
--- a/packages/shared/src/dpop.ts
+++ b/packages/shared/src/dpop.ts
@@ -17,6 +17,19 @@ export const DpopPublicJwk = DpopPublicJwkSchema;
export type DpopPublicJwk = DpopPublicJwkType;
export { normalizeDpopHtu };
+export const DpopVerificationFailureCode = Schema.Literals([
+ "missing_proof",
+ "malformed_proof",
+ "key_mismatch",
+ "method_mismatch",
+ "url_mismatch",
+ "access_token_hash_mismatch",
+ "time_window",
+ "invalid_signature",
+ "invalid_proof",
+]);
+export type DpopVerificationFailureCode = typeof DpopVerificationFailureCode.Type;
+
const DpopJwtHeaderPublicJwk = Schema.Struct({
...DpopPublicJwkSchema.fields,
d: Schema.optionalKey(Schema.Never),
@@ -51,6 +64,7 @@ export type DpopVerificationResult =
}
| {
readonly ok: false;
+ readonly code: DpopVerificationFailureCode;
readonly reason: string;
};
@@ -106,50 +120,46 @@ export function verifyDpopProof(input: {
readonly maxAgeSeconds?: number;
}): DpopVerificationResult {
if (!input.proof?.trim()) {
- return { ok: false, reason: "Missing DPoP proof." };
+ return { ok: false, code: "missing_proof", reason: "Missing DPoP proof." };
}
const parts = input.proof.split(".");
if (parts.length !== 3 || !parts[0] || !parts[1] || !parts[2]) {
- return { ok: false, reason: "Invalid DPoP compact JWT." };
+ return { ok: false, code: "malformed_proof", reason: "Invalid DPoP compact JWT." };
}
try {
const header = decodeBase64UrlDpopJwtHeader(parts[0]);
const payload = decodeBase64UrlDpopJwtPayload(parts[1]);
if (Option.isNone(header)) {
- return { ok: false, reason: "Invalid DPoP JWT header." };
+ return { ok: false, code: "malformed_proof", reason: "Invalid DPoP JWT header." };
}
if (Option.isNone(payload)) {
- return { ok: false, reason: "Invalid DPoP JWT payload." };
+ return { ok: false, code: "malformed_proof", reason: "Invalid DPoP JWT payload." };
}
const thumbprint = computeDpopJwkThumbprint(header.value.jwk);
if (input.expectedThumbprint && thumbprint !== input.expectedThumbprint) {
- return { ok: false, reason: "DPoP key thumbprint mismatch." };
+ return { ok: false, code: "key_mismatch", reason: "DPoP key thumbprint mismatch." };
}
if (payload.value.htm.toUpperCase() !== input.method.toUpperCase()) {
- return { ok: false, reason: "DPoP method mismatch." };
+ return { ok: false, code: "method_mismatch", reason: "DPoP method mismatch." };
}
const normalizedHtu = normalizeDpopHtu(input.url);
if (normalizedHtu === null || payload.value.htu !== normalizedHtu) {
- return { ok: false, reason: "DPoP URL mismatch." };
+ return { ok: false, code: "url_mismatch", reason: "DPoP URL mismatch." };
}
if (input.expectedAccessToken) {
const expectedAth = computeDpopAccessTokenHash(input.expectedAccessToken);
if (payload.value.ath !== expectedAth) {
- return { ok: false, reason: "DPoP access token hash mismatch." };
+ return {
+ ok: false,
+ code: "access_token_hash_mismatch",
+ reason: "DPoP access token hash mismatch.",
+ };
}
}
- const maxAgeSeconds = input.maxAgeSeconds ?? DEFAULT_MAX_AGE_SECONDS;
- if (
- payload.value.iat > input.nowEpochSeconds + 5 ||
- input.nowEpochSeconds - payload.value.iat > maxAgeSeconds
- ) {
- return { ok: false, reason: "DPoP proof is outside the allowed time window." };
- }
-
const signature = base64UrlToBytes(parts[2]);
const signatureInputHash = sha256(new TextEncoder().encode(`${parts[0]}.${parts[1]}`));
const verified = p256.verify(
@@ -161,15 +171,29 @@ export function verifyDpopProof(input: {
format: "compact",
},
);
- return verified
- ? {
- ok: true,
- thumbprint,
- jti: payload.value.jti,
- iat: payload.value.iat,
- }
- : { ok: false, reason: "Invalid DPoP signature." };
+ if (!verified) {
+ return { ok: false, code: "invalid_signature", reason: "Invalid DPoP signature." };
+ }
+
+ const maxAgeSeconds = input.maxAgeSeconds ?? DEFAULT_MAX_AGE_SECONDS;
+ if (
+ payload.value.iat > input.nowEpochSeconds + 5 ||
+ input.nowEpochSeconds - payload.value.iat > maxAgeSeconds
+ ) {
+ return {
+ ok: false,
+ code: "time_window",
+ reason: "DPoP proof is outside the allowed time window.",
+ };
+ }
+
+ return {
+ ok: true,
+ thumbprint,
+ jti: payload.value.jti,
+ iat: payload.value.iat,
+ };
} catch {
- return { ok: false, reason: "Invalid DPoP proof." };
+ return { ok: false, code: "invalid_proof", reason: "Invalid DPoP proof." };
}
}
From 65a8c74da44e85d3f7616b76dcf8f98c5d706444 Mon Sep 17 00:00:00 2001
From: extoci
Date: Thu, 27 Aug 2026 03:23:55 +0000
Subject: [PATCH 2/8] style(connect): format DPoP diagnostics
---
apps/server/src/auth/dpop.ts | 4 +---
infra/relay/src/auth/DpopProofs.ts | 6 +-----
packages/client-runtime/src/connection/errors.ts | 5 +----
packages/client-runtime/src/relay/errorPresentation.ts | 6 ++----
4 files changed, 5 insertions(+), 16 deletions(-)
diff --git a/apps/server/src/auth/dpop.ts b/apps/server/src/auth/dpop.ts
index 28e9864a3619..43f90e440915 100644
--- a/apps/server/src/auth/dpop.ts
+++ b/apps/server/src/auth/dpop.ts
@@ -18,9 +18,7 @@ import {
} from "./EnvironmentAuth.ts";
import * as ServerSecretStore from "./ServerSecretStore.ts";
-export const mapDpopFailureReason = (
- code: DpopVerificationFailureCodeType,
-): DpopFailureReason => {
+export const mapDpopFailureReason = (code: DpopVerificationFailureCodeType): DpopFailureReason => {
switch (code) {
case "time_window":
return "time_window";
diff --git a/infra/relay/src/auth/DpopProofs.ts b/infra/relay/src/auth/DpopProofs.ts
index 94f31d8600ef..d83d413c3734 100644
--- a/infra/relay/src/auth/DpopProofs.ts
+++ b/infra/relay/src/auth/DpopProofs.ts
@@ -52,11 +52,7 @@ export class DpopProofReplay extends Context.Service<
readonly expectedThumbprint?: string;
readonly expectedAccessToken?: string;
readonly now: DateTime.DateTime;
- }) =>
- Effect.Effect<
- string,
- DpopProofRejected | DpopProofReplayPersistenceError
- >;
+ }) => Effect.Effect;
readonly consume: (input: {
readonly thumbprint: string;
readonly jti: string;
diff --git a/packages/client-runtime/src/connection/errors.ts b/packages/client-runtime/src/connection/errors.ts
index 545db2eec17f..879f14fc07de 100644
--- a/packages/client-runtime/src/connection/errors.ts
+++ b/packages/client-runtime/src/connection/errors.ts
@@ -1,10 +1,7 @@
import type { EnvironmentId } from "@t3tools/contracts";
import type { RelayProtectedError } from "@t3tools/contracts/relay";
import type { ManagedRelayClientError } from "../relay/managedRelay.ts";
-import {
- dpopFailureHint,
- relayProtectedErrorMessage,
-} from "../relay/errorPresentation.ts";
+import { dpopFailureHint, relayProtectedErrorMessage } from "../relay/errorPresentation.ts";
import type { RemoteEnvironmentAuthError } from "../authorization/remote.ts";
import {
ConnectionBlockedError,
diff --git a/packages/client-runtime/src/relay/errorPresentation.ts b/packages/client-runtime/src/relay/errorPresentation.ts
index c9a4d4b623e2..1150a570d7c6 100644
--- a/packages/client-runtime/src/relay/errorPresentation.ts
+++ b/packages/client-runtime/src/relay/errorPresentation.ts
@@ -6,11 +6,9 @@ import type { RelayProtectedError } from "@t3tools/contracts/relay";
* servers do not report why they rejected a proof, so this hint is also the
* compatibility fallback for those responses.
*/
-export const DPOP_CLOCK_HINT =
- "Hint: Check the date and time on both devices, then try again.";
+export const DPOP_CLOCK_HINT = "Hint: Check the date and time on both devices, then try again.";
-export const DPOP_RETRY_HINT =
- "Hint: Try again. If the problem continues, copy the trace ID.";
+export const DPOP_RETRY_HINT = "Hint: Try again. If the problem continues, copy the trace ID.";
export function dpopFailureHint(reason: DpopFailureReason | undefined): string {
return reason === undefined || reason === "time_window" ? DPOP_CLOCK_HINT : DPOP_RETRY_HINT;
From 59a3e94e15967eabf5afcf079fee2331bb5bedd8 Mon Sep 17 00:00:00 2001
From: extoci
Date: Thu, 27 Aug 2026 03:25:27 +0000
Subject: [PATCH 3/8] fix(connect): satisfy DPoP diagnostics checks
---
apps/mobile/src/features/cloud/linkEnvironment.ts | 3 ++-
infra/relay/src/http/Api.ts | 3 ++-
packages/client-runtime/src/relay/managedRelayState.ts | 4 +++-
3 files changed, 7 insertions(+), 3 deletions(-)
diff --git a/apps/mobile/src/features/cloud/linkEnvironment.ts b/apps/mobile/src/features/cloud/linkEnvironment.ts
index c91bbe17c20a..de38d1d2c052 100644
--- a/apps/mobile/src/features/cloud/linkEnvironment.ts
+++ b/apps/mobile/src/features/cloud/linkEnvironment.ts
@@ -77,6 +77,7 @@ const isEnvironmentCloudApiError = Schema.is(
EnvironmentCloudEndpointUnavailableError,
]),
);
+const isEnvironmentAuthInvalidError = Schema.is(EnvironmentAuthInvalidError);
const MANAGED_ENDPOINT_PROVIDER_KIND =
"cloudflare_tunnel" satisfies RelayManagedEndpointProviderKind;
@@ -151,7 +152,7 @@ function findEnvironmentCloudApiError(cause: unknown): { readonly message: strin
}
function findEnvironmentAuthInvalidError(cause: unknown): EnvironmentAuthInvalidError | null {
- if (Schema.is(EnvironmentAuthInvalidError)(cause)) {
+ if (isEnvironmentAuthInvalidError(cause)) {
return cause;
}
if (typeof cause !== "object" || cause === null) {
diff --git a/infra/relay/src/http/Api.ts b/infra/relay/src/http/Api.ts
index 40744b054fd3..05197666c80d 100644
--- a/infra/relay/src/http/Api.ts
+++ b/infra/relay/src/http/Api.ts
@@ -1000,6 +1000,7 @@ class ClerkTokenVerificationFailed extends Schema.TaggedErrorClass span.traceId),
@@ -1073,7 +1074,7 @@ function relayInternalErrorResponse(reason: RelayInternalError["reason"]) {
function mapRelayCommonApiErrors(authReason: RelayAuthInvalidReason) {
const mapError = Effect.fnUntraced(function* (error: E) {
const traceId = yield* currentTraceId;
- if (Schema.is(DpopProofs.DpopProofRejected)(error)) {
+ if (isDpopProofRejected(error)) {
yield* Effect.annotateCurrentSpan({
"relay.dpop.failure_code": error.code,
});
diff --git a/packages/client-runtime/src/relay/managedRelayState.ts b/packages/client-runtime/src/relay/managedRelayState.ts
index 7294fd6c61ad..6eb1fb6c6760 100644
--- a/packages/client-runtime/src/relay/managedRelayState.ts
+++ b/packages/client-runtime/src/relay/managedRelayState.ts
@@ -13,6 +13,7 @@ import * as Clock from "effect/Clock";
import * as Data from "effect/Data";
import * as Effect from "effect/Effect";
import * as Option from "effect/Option";
+import * as Schema from "effect/Schema";
import * as Stream from "effect/Stream";
import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity";
@@ -23,6 +24,7 @@ import { relayProtectedErrorMessage } from "./errorPresentation.ts";
const DEFAULT_STALE_TIME_MS = 15_000;
const DEFAULT_IDLE_TTL_MS = 5 * 60_000;
const CLERK_TOKEN_EXPIRY_SKEW_MS = 5_000;
+const isManagedRelayRequestFailedError = Schema.is(ManagedRelay.ManagedRelayRequestFailedError);
export interface ManagedRelaySession {
readonly accountId: string;
@@ -317,7 +319,7 @@ export function readManagedRelaySnapshotState(
if (result._tag === "Failure") {
const cause = Cause.squash(result.cause);
error =
- cause instanceof ManagedRelay.ManagedRelayRequestFailedError && cause.relayError
+ isManagedRelayRequestFailedError(cause) && cause.relayError
? relayProtectedErrorMessage(cause.relayError)
: cause instanceof Error
? cause.message
From 050c7228447a298962175e83463bd7829b6f4798 Mon Sep 17 00:00:00 2001
From: extoci
Date: Thu, 27 Aug 2026 11:23:41 +0000
Subject: [PATCH 4/8] fix(web): preserve DPoP hint line breaks
---
.../src/components/clerk/MobileClientsUserProfilePage.tsx | 4 +++-
apps/web/src/components/settings/ProviderSettingsPanel.tsx | 7 ++++++-
2 files changed, 9 insertions(+), 2 deletions(-)
diff --git a/apps/web/src/components/clerk/MobileClientsUserProfilePage.tsx b/apps/web/src/components/clerk/MobileClientsUserProfilePage.tsx
index 22449c336742..11034f99ef4e 100644
--- a/apps/web/src/components/clerk/MobileClientsUserProfilePage.tsx
+++ b/apps/web/src/components/clerk/MobileClientsUserProfilePage.tsx
@@ -130,7 +130,9 @@ export function MobileClientsUserProfilePage() {
Could not load mobile clients
- {devicesState.error}
+
+ {devicesState.error}
+
Try again
diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.tsx
index 2aac55cf07c7..8d8b8d8e18df 100644
--- a/apps/web/src/components/settings/ProviderSettingsPanel.tsx
+++ b/apps/web/src/components/settings/ProviderSettingsPanel.tsx
@@ -180,11 +180,16 @@ function EnvironmentUnavailableRow({
? "Checking what this session is allowed to change."
: `Waiting for ${environment.label}'s configuration.`
: connectionStatusText(environment.connection);
+ const renderedDescription = description.includes("\n") ? (
+ {description}
+ ) : (
+ description
+ );
// No spinner: this state can persist indefinitely for a wedged device, and a
// continuously repainting animation would run the whole time.
return (
-
+
);
}
From 41779f289fc11db8f3244705b16a8d65d5d00bcd Mon Sep 17 00:00:00 2001
From: extoci
Date: Thu, 27 Aug 2026 11:29:59 +0000
Subject: [PATCH 5/8] fix(web): preserve multiline status messages
---
apps/web/src/components/settings/settingsLayout.tsx | 13 ++++++++++++-
apps/web/src/components/ui/toast.tsx | 8 ++++++++
2 files changed, 20 insertions(+), 1 deletion(-)
diff --git a/apps/web/src/components/settings/settingsLayout.tsx b/apps/web/src/components/settings/settingsLayout.tsx
index 15540ff5932f..d5b727bc34cc 100644
--- a/apps/web/src/components/settings/settingsLayout.tsx
+++ b/apps/web/src/components/settings/settingsLayout.tsx
@@ -186,7 +186,18 @@ export function SettingsRow({
{description}
) : null}
- {status ? {status}
: null}
+ {status ? (
+
+ {status}
+
+ ) : null}
{control ? (
diff --git a/apps/web/src/components/ui/toast.tsx b/apps/web/src/components/ui/toast.tsx
index 69fd0ebf3664..1cff02d6ecc9 100644
--- a/apps/web/src/components/ui/toast.tsx
+++ b/apps/web/src/components/ui/toast.tsx
@@ -100,6 +100,12 @@ function errorDescriptionClampClass(type: unknown, description: unknown): string
return "line-clamp-4";
}
+function toastDescriptionWhitespaceClass(description: unknown): string | undefined {
+ return typeof description === "string" && description.includes("\n")
+ ? "whitespace-pre-line"
+ : undefined;
+}
+
/** Dismiss-only: circular control overlapping the card corner (iOS notification–style). */
const toastCornerDismissClass = "absolute z-20 -top-1.5 -right-1.5";
const toastCornerOrbClass = cn(
@@ -190,6 +196,7 @@ function ToastDescriptionAndExpandable({
const descriptionTrigger = toastData?.expandableDescriptionTrigger ?? false;
const descriptionClassName = cn(
"min-w-0 select-text wrap-break-word text-muted-foreground",
+ toastDescriptionWhitespaceClass(toastDescription),
errorDescriptionClampClass(toastType, toastDescription),
);
const [open, setOpen] = useState(false);
@@ -242,6 +249,7 @@ function ToastDescriptionAndExpandable({
Date: Thu, 27 Aug 2026 11:40:44 +0000
Subject: [PATCH 6/8] fix(web): preserve multiline connection banners
---
apps/web/src/components/chat/ComposerBannerStack.tsx | 12 +++++++++++-
.../src/components/cloud/ConnectOnboardingDialog.tsx | 4 +++-
2 files changed, 14 insertions(+), 2 deletions(-)
diff --git a/apps/web/src/components/chat/ComposerBannerStack.tsx b/apps/web/src/components/chat/ComposerBannerStack.tsx
index d8b8761447cb..600596051f86 100644
--- a/apps/web/src/components/chat/ComposerBannerStack.tsx
+++ b/apps/web/src/components/chat/ComposerBannerStack.tsx
@@ -207,7 +207,17 @@ function ComposerBannerStackAlert({
>
{item.icon}
{item.title}
- {item.description ? {item.description} : null}
+ {item.description ? (
+
+ {item.description}
+
+ ) : null}
{item.actions || item.onDismiss ? (
- {operationError ? {operationError}
: null}
+ {operationError ? (
+ {operationError}
+ ) : null}
);
}
From 91b2f66fd50f085cca6228aa1d084f78a4872a9d Mon Sep 17 00:00:00 2001
From: Exotic <118054752+extoci@users.noreply.github.com>
Date: Thu, 27 Aug 2026 16:58:26 +0300
Subject: [PATCH 7/8] fix(mobile): hide no-op error expansion affordance
---
apps/mobile/src/features/connection/CloudEnvironmentRows.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx b/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx
index 8b0e506bb090..c1472c0981d7 100644
--- a/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx
+++ b/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx
@@ -298,7 +298,7 @@ function CloudEnvironmentRowShell(props: {
const measuredErrorText = errorTraceId ? `${statusText} Trace ID: ${errorTraceId}` : statusText;
const errorLineCount =
errorMeasurement?.text === measuredErrorText ? errorMeasurement.lineCount : 0;
- const errorCanExpand = props.connectionError !== null && errorLineCount > 1;
+ const errorCanExpand = props.connectionError !== null && errorLineCount > 1 && !statusHasHint;
const isErrorExpanded = errorCanExpand && props.errorExpanded;
const StatusContainer = errorCanExpand ? Pressable : View;
const onMeasuredErrorTextLayout = useCallback(
From 2017ce2f22a07b8880ed7c48a622ea6e5f9ca468 Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Thu, 27 Aug 2026 19:44:03 -0700
Subject: [PATCH 8/8] fix(connect): refine DPoP error guidance
---
.../features/cloud/linkEnvironment.test.ts | 140 +++++++++---------
.../src/features/cloud/linkEnvironment.ts | 4 +-
.../connection/CloudEnvironmentRows.tsx | 5 +-
.../connection/ConnectionEnvironmentRow.tsx | 3 +-
.../features/projects/AddProjectScreen.tsx | 6 +-
apps/server/src/server.test.ts | 32 ++++
.../src/components/CommandPaletteResults.tsx | 4 +-
.../components/chat/ComposerBannerStack.tsx | 12 +-
.../clerk/MobileClientsUserProfilePage.tsx | 4 +-
.../clerk/T3ConnectUserProfilePage.tsx | 4 +-
.../cloud/CloudEnvironmentConnectList.tsx | 8 +-
.../cloud/ConnectOnboardingDialog.tsx | 4 +-
.../settings/ConnectionsSettings.tsx | 11 +-
.../components/settings/settingsLayout.tsx | 13 +-
apps/web/src/components/ui/toast.tsx | 8 -
docs/internals/environment-auth.md | 4 +-
docs/operations/relay-observability.md | 2 +-
.../src/authorization/layer.test.ts | 6 +-
.../src/connection/errors.test.ts | 14 +-
.../client-runtime/src/connection/errors.ts | 10 +-
.../src/relay/errorPresentation.test.ts | 13 +-
.../src/relay/errorPresentation.ts | 23 ++-
.../src/relay/managedRelayState.test.ts | 5 +-
23 files changed, 163 insertions(+), 172 deletions(-)
diff --git a/apps/mobile/src/features/cloud/linkEnvironment.test.ts b/apps/mobile/src/features/cloud/linkEnvironment.test.ts
index d812b71cef92..f8218e3ffd56 100644
--- a/apps/mobile/src/features/cloud/linkEnvironment.test.ts
+++ b/apps/mobile/src/features/cloud/linkEnvironment.test.ts
@@ -4,7 +4,7 @@ import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import { EnvironmentId } from "@t3tools/contracts";
import { RelayMobileClientId } from "@t3tools/contracts/relay";
-import { DPOP_CLOCK_HINT, ManagedRelay } from "@t3tools/client-runtime/relay";
+import { DPOP_UNKNOWN_HINT, ManagedRelay } from "@t3tools/client-runtime/relay";
import { remoteHttpClientLayer } from "@t3tools/client-runtime/rpc";
import { HttpClient } from "effect/unstable/http";
import { MobilePreferencesStore } from "../../persistence/mobile-preferences";
@@ -1081,86 +1081,86 @@ describe("mobile cloud link environment client", () => {
).pipe(Effect.flip);
expect(error).toMatchObject({
_tag: "CloudEnvironmentLinkError",
- message:
- `https://relay.example.test/v1/environments/env-1/connect failed: Relay rejected the DPoP proof.\n\n` +
- DPOP_CLOCK_HINT,
+ message: `https://relay.example.test/v1/environments/env-1/connect failed: Relay rejected the DPoP proof. ${DPOP_UNKNOWN_HINT}`,
traceId: "trace-connect",
});
}),
);
- it.effect("shows the clock hint when an older environment rejects DPoP", () =>
- Effect.gen(function* () {
- vi.stubGlobal(
- "fetch",
- vi.fn((url: string | URL) => {
- const value = String(url);
- if (value.endsWith("/v1/client/dpop-token")) {
- return Promise.resolve(
- Response.json(validDpopAccessTokenResponse("environment:connect")),
- );
- }
- if (value.endsWith("/v1/environments/env-1/connect")) {
+ it.effect(
+ "presents clock skew as one possible cause when an older environment rejects DPoP",
+ () =>
+ Effect.gen(function* () {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn((url: string | URL) => {
+ const value = String(url);
+ if (value.endsWith("/v1/client/dpop-token")) {
+ return Promise.resolve(
+ Response.json(validDpopAccessTokenResponse("environment:connect")),
+ );
+ }
+ if (value.endsWith("/v1/environments/env-1/connect")) {
+ return Promise.resolve(
+ Response.json({
+ environmentId: "env-1",
+ endpoint: {
+ httpBaseUrl: "https://desktop.example.test/",
+ wsBaseUrl: "wss://desktop.example.test/ws",
+ providerKind: "cloudflare_tunnel",
+ },
+ credential: "one-time-cloud-credential",
+ expiresAt: "2026-05-25T00:05:00.000Z",
+ }),
+ );
+ }
+ if (value.endsWith("/.well-known/t3/environment")) {
+ return Promise.resolve(
+ Response.json({
+ environmentId: "env-1",
+ label: "Desktop",
+ platform: { os: "darwin", arch: "arm64" },
+ serverVersion: "0.0.0-test",
+ capabilities: { repositoryIdentity: true },
+ }),
+ );
+ }
return Promise.resolve(
- Response.json({
- environmentId: "env-1",
- endpoint: {
- httpBaseUrl: "https://desktop.example.test/",
- wsBaseUrl: "wss://desktop.example.test/ws",
- providerKind: "cloudflare_tunnel",
+ Response.json(
+ {
+ _tag: "EnvironmentAuthInvalidError",
+ code: "auth_invalid",
+ reason: "invalid_credential",
+ traceId: "trace-environment",
},
- credential: "one-time-cloud-credential",
- expiresAt: "2026-05-25T00:05:00.000Z",
- }),
+ { status: 401 },
+ ),
);
- }
- if (value.endsWith("/.well-known/t3/environment")) {
- return Promise.resolve(
- Response.json({
- environmentId: "env-1",
- label: "Desktop",
- platform: { os: "darwin", arch: "arm64" },
- serverVersion: "0.0.0-test",
- capabilities: { repositoryIdentity: true },
- }),
- );
- }
- return Promise.resolve(
- Response.json(
- {
- _tag: "EnvironmentAuthInvalidError",
- code: "auth_invalid",
- reason: "invalid_credential",
- traceId: "trace-environment",
- },
- { status: 401 },
- ),
- );
- }),
- );
+ }),
+ );
- const error = yield* withCloudServices(
- connectCloudEnvironment({
- clerkToken: "clerk-token",
- environment: {
- environmentId: EnvironmentId.make("env-1"),
- label: "Desktop",
- endpoint: {
- httpBaseUrl: "https://desktop.example.test/",
- wsBaseUrl: "wss://desktop.example.test/ws",
- providerKind: "cloudflare_tunnel",
+ const error = yield* withCloudServices(
+ connectCloudEnvironment({
+ clerkToken: "clerk-token",
+ environment: {
+ environmentId: EnvironmentId.make("env-1"),
+ label: "Desktop",
+ endpoint: {
+ httpBaseUrl: "https://desktop.example.test/",
+ wsBaseUrl: "wss://desktop.example.test/ws",
+ providerKind: "cloudflare_tunnel",
+ },
+ linkedAt: "2026-05-25T00:00:00.000Z",
},
- linkedAt: "2026-05-25T00:00:00.000Z",
- },
- }),
- ).pipe(Effect.flip);
+ }),
+ ).pipe(Effect.flip);
- expect(error).toMatchObject({
- _tag: "CloudEnvironmentLinkError",
- message: `Could not exchange a managed endpoint DPoP access token.\n\n${DPOP_CLOCK_HINT}`,
- traceId: "trace-environment",
- });
- }),
+ expect(error).toMatchObject({
+ _tag: "CloudEnvironmentLinkError",
+ message: `Could not exchange a managed endpoint DPoP access token. ${DPOP_UNKNOWN_HINT}`,
+ traceId: "trace-environment",
+ });
+ }),
);
it.effect("rejects relay connect responses for a different endpoint", () =>
diff --git a/apps/mobile/src/features/cloud/linkEnvironment.ts b/apps/mobile/src/features/cloud/linkEnvironment.ts
index de38d1d2c052..c2033117f69d 100644
--- a/apps/mobile/src/features/cloud/linkEnvironment.ts
+++ b/apps/mobile/src/features/cloud/linkEnvironment.ts
@@ -26,7 +26,7 @@ import { exchangeRemoteDpopAccessToken } from "@t3tools/client-runtime/authoriza
import { fetchRemoteEnvironmentDescriptor } from "@t3tools/client-runtime/environment";
import { findErrorTraceId } from "@t3tools/client-runtime/errors";
import {
- dpopFailureHint,
+ dpopFailureMessage,
ManagedRelay,
relayProtectedErrorMessage,
} from "@t3tools/client-runtime/relay";
@@ -93,7 +93,7 @@ function cloudEnvironmentLinkError(message: string, options?: { readonly dpop?:
return new CloudEnvironmentLinkError({
message:
dpopAuthError?.reason === "invalid_credential"
- ? `${detail}\n\n${dpopFailureHint(dpopAuthError.dpopFailureReason)}`
+ ? dpopFailureMessage(detail, dpopAuthError.dpopFailureReason)
: detail,
cause,
...(traceId === null ? {} : { traceId }),
diff --git a/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx b/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx
index c1472c0981d7..6da73eaeb1fa 100644
--- a/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx
+++ b/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx
@@ -286,7 +286,6 @@ function CloudEnvironmentRowShell(props: {
error: props.connectionError,
traceId: props.connectionErrorTraceId,
});
- const statusHasHint = statusText.includes("\n");
const statusClassName = props.connectionError
? "text-rose-500 dark:text-rose-400"
: "text-foreground-muted";
@@ -298,7 +297,7 @@ function CloudEnvironmentRowShell(props: {
const measuredErrorText = errorTraceId ? `${statusText} Trace ID: ${errorTraceId}` : statusText;
const errorLineCount =
errorMeasurement?.text === measuredErrorText ? errorMeasurement.lineCount : 0;
- const errorCanExpand = props.connectionError !== null && errorLineCount > 1 && !statusHasHint;
+ const errorCanExpand = props.connectionError !== null && errorLineCount > 1;
const isErrorExpanded = errorCanExpand && props.errorExpanded;
const StatusContainer = errorCanExpand ? Pressable : View;
const onMeasuredErrorTextLayout = useCallback(
@@ -351,7 +350,7 @@ function CloudEnvironmentRowShell(props: {
>
{statusText}
{errorTraceId ? (
diff --git a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx
index 64f444ac9f25..03a0eb5025f6 100644
--- a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx
+++ b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx
@@ -43,7 +43,6 @@ export function ConnectionEnvironmentRow(props: {
const statusLabel = connectionStatusLabel(props.environment);
const statusTraceId = props.environment.connectionErrorTraceId;
const hasConnectionFailure = props.environment.connectionError !== null;
- const statusHasHint = statusLabel?.includes("\n") ?? false;
const isRetrying =
props.environment.connectionState === "connecting" ||
props.environment.connectionState === "reconnecting";
@@ -88,7 +87,7 @@ export function ConnectionEnvironmentRow(props: {
"text-xs",
hasConnectionFailure ? "text-rose-500 dark:text-rose-400" : "text-foreground-muted",
)}
- numberOfLines={props.expanded || statusHasHint ? undefined : 1}
+ numberOfLines={props.expanded ? undefined : 1}
selectable={props.expanded}
>
{statusLabel}
diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx
index 80f423b57a43..b48c7a0bdd94 100644
--- a/apps/mobile/src/features/projects/AddProjectScreen.tsx
+++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx
@@ -160,7 +160,6 @@ function ListRow(props: {
readonly onPress?: () => void;
}) {
const chevronColor = useThemeColor("--color-chevron");
- const subtitleHasHint = props.subtitle?.includes("\n") ?? false;
return (
{props.title}
{props.subtitle ? (
-
+
{props.subtitle}
) : null}
diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts
index 7f3552626980..a392d686106c 100644
--- a/apps/server/src/server.test.ts
+++ b/apps/server/src/server.test.ts
@@ -1799,6 +1799,38 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
}).pipe(Effect.provide(NodeHttpServer.layerTest)),
);
+ it.effect("reports clock skew for a future-dated DPoP token exchange proof", () =>
+ Effect.gen(function* () {
+ yield* buildAppUnderTest();
+
+ const ownerCookie = yield* getAuthenticatedSessionCookieHeader();
+ const credentialResponse = yield* HttpClient.post("/api/auth/pairing-token", {
+ headers: { cookie: ownerCookie },
+ body: yield* HttpBody.json({}),
+ });
+ const credential = (yield* credentialResponse.json) as { readonly credential: string };
+ const tokenUrl = yield* getHttpServerUrl("/oauth/token");
+ const now = yield* DateTime.now;
+ const dpop = makeDpopProof({
+ method: "POST",
+ url: tokenUrl,
+ iat: Math.floor(now.epochMilliseconds / 1_000) + 25,
+ });
+
+ const exchange = yield* exchangeAccessToken(credential.credential, {
+ headers: { dpop: dpop.proof },
+ scope: "orchestration:read orchestration:operate terminal:operate review:write",
+ });
+
+ assert.equal(exchange.response.status, 401);
+ assert.equal(exchange.body._tag, "EnvironmentAuthInvalidError");
+ assert.equal(exchange.body.code, "auth_invalid");
+ assert.equal(exchange.body.reason, "invalid_credential");
+ assert.equal(exchange.body.dpopFailureReason, "time_window");
+ assert.equal(typeof exchange.body.traceId, "string");
+ }).pipe(Effect.provide(NodeHttpServer.layerTest)),
+ );
+
it.effect("rejects replayed DPoP proofs across token exchanges", () =>
Effect.gen(function* () {
yield* buildAppUnderTest();
diff --git a/apps/web/src/components/CommandPaletteResults.tsx b/apps/web/src/components/CommandPaletteResults.tsx
index 151ec1be50c9..bbdbc28b0609 100644
--- a/apps/web/src/components/CommandPaletteResults.tsx
+++ b/apps/web/src/components/CommandPaletteResults.tsx
@@ -142,7 +142,7 @@ function DisabledCommandPaletteResultRow(props: {
) : null}
{props.item.description ? (
-
+
{props.item.description}
) : null}
@@ -193,7 +193,7 @@ function CommandPaletteResultRow(props: {
) : null}
{props.item.description ? (
-
+
{props.item.description}
) : null}
diff --git a/apps/web/src/components/chat/ComposerBannerStack.tsx b/apps/web/src/components/chat/ComposerBannerStack.tsx
index 600596051f86..d8b8761447cb 100644
--- a/apps/web/src/components/chat/ComposerBannerStack.tsx
+++ b/apps/web/src/components/chat/ComposerBannerStack.tsx
@@ -207,17 +207,7 @@ function ComposerBannerStackAlert({
>
{item.icon}
{item.title}
- {item.description ? (
-
- {item.description}
-
- ) : null}
+ {item.description ? {item.description} : null}
{item.actions || item.onDismiss ? (
Could not load mobile clients
-
- {devicesState.error}
-
+ {devicesState.error}
Try again
diff --git a/apps/web/src/components/clerk/T3ConnectUserProfilePage.tsx b/apps/web/src/components/clerk/T3ConnectUserProfilePage.tsx
index 0e56dd65c405..15ed569052be 100644
--- a/apps/web/src/components/clerk/T3ConnectUserProfilePage.tsx
+++ b/apps/web/src/components/clerk/T3ConnectUserProfilePage.tsx
@@ -216,9 +216,7 @@ export function T3ConnectUserProfilePage() {
Could not load T3 Connect environments
-
- {environmentsState.error}
-
+ {environmentsState.error}
) : null}
diff --git a/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx b/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx
index 1fb67db1bc60..460a253812a0 100644
--- a/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx
+++ b/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx
@@ -156,9 +156,7 @@ export function CloudEnvironmentConnectRows({
Could not load T3 Connect environments
-
- {discoveryProblem}
-
+ {discoveryProblem}
@@ -232,8 +229,7 @@ export function CloudEnvironmentConnectRows({
- {operationError ? (
- {operationError}
- ) : null}
+ {operationError ? {operationError}
: null}
);
}
diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx
index 299342758cfb..18d1b0f1c924 100644
--- a/apps/web/src/components/settings/ConnectionsSettings.tsx
+++ b/apps/web/src/components/settings/ConnectionsSettings.tsx
@@ -1464,16 +1464,7 @@ function SavedBackendListRow({
) : null}
{environment.connection.error && !resumingServerUpdate ? (
-
- {connectionStatusText(environment.connection)}
-
+ {connectionStatusText(environment.connection)}
{errorTraceId ? (
) : null}
- {status ? (
-
- {status}
-
- ) : null}
+ {status ? {status}
: null}
{control ? (
diff --git a/apps/web/src/components/ui/toast.tsx b/apps/web/src/components/ui/toast.tsx
index 1cff02d6ecc9..69fd0ebf3664 100644
--- a/apps/web/src/components/ui/toast.tsx
+++ b/apps/web/src/components/ui/toast.tsx
@@ -100,12 +100,6 @@ function errorDescriptionClampClass(type: unknown, description: unknown): string
return "line-clamp-4";
}
-function toastDescriptionWhitespaceClass(description: unknown): string | undefined {
- return typeof description === "string" && description.includes("\n")
- ? "whitespace-pre-line"
- : undefined;
-}
-
/** Dismiss-only: circular control overlapping the card corner (iOS notification–style). */
const toastCornerDismissClass = "absolute z-20 -top-1.5 -right-1.5";
const toastCornerOrbClass = cn(
@@ -196,7 +190,6 @@ function ToastDescriptionAndExpandable({
const descriptionTrigger = toastData?.expandableDescriptionTrigger ?? false;
const descriptionClassName = cn(
"min-w-0 select-text wrap-break-word text-muted-foreground",
- toastDescriptionWhitespaceClass(toastDescription),
errorDescriptionClampClass(toastType, toastDescription),
);
const [open, setOpen] = useState(false);
@@ -249,7 +242,6 @@ function ToastDescriptionAndExpandable({
{
}),
);
- it.effect("shows the clock hint when an environment rejects a DPoP bootstrap proof", () =>
+ it.effect("presents clock skew as one possible cause for a generic DPoP rejection", () =>
Effect.gen(function* () {
const harness = yield* makeHarness({
responses: [Response.json(DESCRIPTOR), authInvalid()],
@@ -359,7 +359,7 @@ describe("RemoteEnvironmentAuthorization", () => {
expect(failure).toMatchObject({
_tag: "ConnectionBlockedError",
reason: "authentication",
- detail: `The environment credential is invalid.\n\n${DPOP_CLOCK_HINT}`,
+ detail: `The environment credential is invalid. ${DPOP_UNKNOWN_HINT}`,
traceId: "trace-auth-invalid",
});
}),
diff --git a/packages/client-runtime/src/connection/errors.test.ts b/packages/client-runtime/src/connection/errors.test.ts
index 225941aa093e..771c9490ca3e 100644
--- a/packages/client-runtime/src/connection/errors.test.ts
+++ b/packages/client-runtime/src/connection/errors.test.ts
@@ -3,11 +3,11 @@ import { RelayAuthInvalidError } from "@t3tools/contracts/relay";
import { describe, expect, it } from "@effect/vitest";
import { mapManagedRelayError, mapRemoteDpopEnvironmentError } from "./errors.ts";
-import { DPOP_CLOCK_HINT, DPOP_RETRY_HINT } from "../relay/errorPresentation.ts";
+import { DPOP_RETRY_HINT, DPOP_UNKNOWN_HINT } from "../relay/errorPresentation.ts";
import { ManagedRelayRequestFailedError } from "../relay/managedRelay.ts";
describe("mapManagedRelayError", () => {
- it("shows the clock hint when an older relay returns a generic DPoP error", () => {
+ it("presents clock skew as one possible cause for a generic DPoP error", () => {
const mapped = mapManagedRelayError(
new ManagedRelayRequestFailedError({
action: "connect relay environment",
@@ -24,7 +24,7 @@ describe("mapManagedRelayError", () => {
expect(mapped).toMatchObject({
_tag: "ConnectionBlockedError",
reason: "authentication",
- detail: `Relay rejected the DPoP proof.\n\n${DPOP_CLOCK_HINT}`,
+ detail: `Relay rejected the DPoP proof. ${DPOP_UNKNOWN_HINT}`,
traceId: "trace-1",
});
});
@@ -43,12 +43,12 @@ describe("mapManagedRelayError", () => {
}),
);
- expect(mapped.message).toBe(`Relay rejected the DPoP proof.\n\n${DPOP_RETRY_HINT}`);
+ expect(mapped.message).toBe(`Relay rejected the DPoP proof. ${DPOP_RETRY_HINT}`);
});
});
describe("mapRemoteDpopEnvironmentError", () => {
- it("uses the clock hint when an older environment server returns a generic auth error", () => {
+ it("does not present a generic environment auth error as confirmed clock skew", () => {
const mapped = mapRemoteDpopEnvironmentError(
new EnvironmentAuthInvalidError({
code: "auth_invalid",
@@ -57,7 +57,7 @@ describe("mapRemoteDpopEnvironmentError", () => {
}),
);
- expect(mapped.message).toBe(`The environment credential is invalid.\n\n${DPOP_CLOCK_HINT}`);
+ expect(mapped.message).toBe(`The environment credential is invalid. ${DPOP_UNKNOWN_HINT}`);
});
it("uses a neutral hint for a non-clock DPoP error from a new server", () => {
@@ -70,6 +70,6 @@ describe("mapRemoteDpopEnvironmentError", () => {
}),
);
- expect(mapped.message).toBe(`The environment credential is invalid.\n\n${DPOP_RETRY_HINT}`);
+ expect(mapped.message).toBe(`The environment credential is invalid. ${DPOP_RETRY_HINT}`);
});
});
diff --git a/packages/client-runtime/src/connection/errors.ts b/packages/client-runtime/src/connection/errors.ts
index 879f14fc07de..ed8a117a6f5c 100644
--- a/packages/client-runtime/src/connection/errors.ts
+++ b/packages/client-runtime/src/connection/errors.ts
@@ -1,7 +1,7 @@
import type { EnvironmentId } from "@t3tools/contracts";
import type { RelayProtectedError } from "@t3tools/contracts/relay";
import type { ManagedRelayClientError } from "../relay/managedRelay.ts";
-import { dpopFailureHint, relayProtectedErrorMessage } from "../relay/errorPresentation.ts";
+import { dpopFailureMessage, relayProtectedErrorMessage } from "../relay/errorPresentation.ts";
import type { RemoteEnvironmentAuthError } from "../authorization/remote.ts";
import {
ConnectionBlockedError,
@@ -171,9 +171,9 @@ export function mapRemoteEnvironmentError(
/**
* Map an environment error from a request that used DPoP authentication. An
* older environment server reports a DPoP clock failure as the same generic
- * invalid-credential response as other DPoP failures, so keep the hint at the
- * client boundary where the request type is known. Newer servers can identify
- * non-clock failures and receive a neutral retry hint instead.
+ * invalid-credential response as other failures, so keep the compatibility
+ * hint cautious when the server omits the category. Newer servers can identify
+ * clock and non-clock proof failures precisely.
*/
export function mapRemoteDpopEnvironmentError(
error: RemoteEnvironmentAuthError,
@@ -181,7 +181,7 @@ export function mapRemoteDpopEnvironmentError(
if (error._tag === "EnvironmentAuthInvalidError" && error.reason === "invalid_credential") {
return new ConnectionBlockedError({
reason: "authentication",
- detail: `The environment credential is invalid.\n\n${dpopFailureHint(error.dpopFailureReason)}`,
+ detail: dpopFailureMessage("The environment credential is invalid.", error.dpopFailureReason),
traceId: error.traceId,
});
}
diff --git a/packages/client-runtime/src/relay/errorPresentation.test.ts b/packages/client-runtime/src/relay/errorPresentation.test.ts
index 82ec7946d260..a27810c4366e 100644
--- a/packages/client-runtime/src/relay/errorPresentation.test.ts
+++ b/packages/client-runtime/src/relay/errorPresentation.test.ts
@@ -1,10 +1,15 @@
import { RelayAuthInvalidError } from "@t3tools/contracts/relay";
import { describe, expect, it } from "@effect/vitest";
-import { DPOP_CLOCK_HINT, relayProtectedErrorMessage } from "./errorPresentation.ts";
+import {
+ DPOP_CLOCK_HINT,
+ DPOP_RETRY_HINT,
+ DPOP_UNKNOWN_HINT,
+ relayProtectedErrorMessage,
+} from "./errorPresentation.ts";
describe("relayProtectedErrorMessage", () => {
- it("uses the clock hint when an older relay omits the DPoP failure reason", () => {
+ it("presents clock skew as one possible cause when the relay omits the reason", () => {
const error = new RelayAuthInvalidError({
code: "auth_invalid",
reason: "invalid_dpop",
@@ -12,7 +17,7 @@ describe("relayProtectedErrorMessage", () => {
});
expect(relayProtectedErrorMessage(error)).toBe(
- `Relay rejected the DPoP proof.\n\n${DPOP_CLOCK_HINT}`,
+ `Relay rejected the DPoP proof. ${DPOP_UNKNOWN_HINT}`,
);
});
@@ -36,7 +41,7 @@ describe("relayProtectedErrorMessage", () => {
});
expect(relayProtectedErrorMessage(error)).toBe(
- "Relay rejected the DPoP proof.\n\nHint: Try again. If the problem continues, copy the trace ID.",
+ `Relay rejected the DPoP proof. ${DPOP_RETRY_HINT}`,
);
});
diff --git a/packages/client-runtime/src/relay/errorPresentation.ts b/packages/client-runtime/src/relay/errorPresentation.ts
index 1150a570d7c6..a9364752103d 100644
--- a/packages/client-runtime/src/relay/errorPresentation.ts
+++ b/packages/client-runtime/src/relay/errorPresentation.ts
@@ -1,17 +1,24 @@
import type { DpopFailureReason } from "@t3tools/contracts";
import type { RelayProtectedError } from "@t3tools/contracts/relay";
-/**
- * A DPoP proof is checked against the clock on the receiving server. Older
- * servers do not report why they rejected a proof, so this hint is also the
- * compatibility fallback for those responses.
- */
-export const DPOP_CLOCK_HINT = "Hint: Check the date and time on both devices, then try again.";
+export const DPOP_CLOCK_HINT =
+ "Hint: Check that automatic date and time is enabled on both devices, then try again.";
+
+/** Older servers omit the DPoP category, but newer servers can also omit it for
+ * a credential failure that happens after proof verification. */
+export const DPOP_UNKNOWN_HINT =
+ "Hint: Try again. If it still fails, clock skew may be the cause; check that automatic date and time is enabled on both devices.";
export const DPOP_RETRY_HINT = "Hint: Try again. If the problem continues, copy the trace ID.";
export function dpopFailureHint(reason: DpopFailureReason | undefined): string {
- return reason === undefined || reason === "time_window" ? DPOP_CLOCK_HINT : DPOP_RETRY_HINT;
+ if (reason === "time_window") return DPOP_CLOCK_HINT;
+ if (reason === undefined) return DPOP_UNKNOWN_HINT;
+ return DPOP_RETRY_HINT;
+}
+
+export function dpopFailureMessage(message: string, reason: DpopFailureReason | undefined): string {
+ return `${message} ${dpopFailureHint(reason)}`;
}
export function relayProtectedErrorMessage(error: RelayProtectedError): string {
@@ -22,7 +29,7 @@ export function relayProtectedErrorMessage(error: RelayProtectedError): string {
case "invalid_bearer":
return "Relay rejected the cloud session token.";
case "invalid_dpop":
- return `Relay rejected the DPoP proof.\n\n${dpopFailureHint(error.dpopFailureReason)}`;
+ return dpopFailureMessage("Relay rejected the DPoP proof.", error.dpopFailureReason);
case "not_authorized":
return "Relay rejected the authenticated request.";
}
diff --git a/packages/client-runtime/src/relay/managedRelayState.test.ts b/packages/client-runtime/src/relay/managedRelayState.test.ts
index 5996ababad6a..0588da342066 100644
--- a/packages/client-runtime/src/relay/managedRelayState.test.ts
+++ b/packages/client-runtime/src/relay/managedRelayState.test.ts
@@ -13,6 +13,7 @@ import * as Stream from "effect/Stream";
import { Atom, AtomRegistry } from "effect/unstable/reactivity";
import { afterEach, vi } from "vite-plus/test";
+import { DPOP_UNKNOWN_HINT } from "./errorPresentation.ts";
import * as ManagedRelay from "./managedRelay.ts";
import {
createManagedRelayQueryManager,
@@ -423,7 +424,7 @@ describe("createManagedRelayQueryManager", () => {
});
});
- it("shows the DPoP clock hint for snapshot requests from older relays", async () => {
+ it("presents clock skew as one possible cause for snapshot requests from older relays", async () => {
const manager = createManager({
getEnvironmentStatus: () =>
Effect.fail(
@@ -445,7 +446,7 @@ describe("createManagedRelayQueryManager", () => {
registry.get(atom);
await vi.waitFor(() => {
expect(readManagedRelaySnapshotState(registry.get(atom)).error).toBe(
- "Relay rejected the DPoP proof.\n\nHint: Check the date and time on both devices, then try again.",
+ `Relay rejected the DPoP proof. ${DPOP_UNKNOWN_HINT}`,
);
});
});