Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 77 additions & 2 deletions apps/mobile/src/features/cloud/linkEnvironment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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(
Expand Down
80 changes: 29 additions & 51 deletions apps/mobile/src/features/cloud/linkEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import * as Schema from "effect/Schema";
import { HttpClient } from "effect/unstable/http";
import {
EnvironmentCloudEndpointUnavailableError,
EnvironmentAuthInvalidError,
EnvironmentHttpBadRequestError,
EnvironmentHttpConflictError,
EnvironmentHttpForbiddenError,
Expand All @@ -17,15 +18,18 @@ import {
RelayEnvironmentConnectScope,
RelayEnvironmentStatusScope,
type RelayDpopAccessTokenScope,
type RelayProtectedError as RelayProtectedErrorType,
type RelayClientEnvironmentRecord,
type RelayEnvironmentStatusResponse as RelayEnvironmentStatusResponseType,
type RelayManagedEndpointProviderKind,
} from "@t3tools/contracts/relay";
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";
Expand Down Expand Up @@ -73,18 +77,24 @@ const isEnvironmentCloudApiError = Schema.is(
EnvironmentCloudEndpointUnavailableError,
]),
);
const isEnvironmentAuthInvalidError = Schema.is(EnvironmentAuthInvalidError);

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 }),
});
Expand Down Expand Up @@ -117,50 +127,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 =
Expand All @@ -185,6 +151,16 @@ function findEnvironmentCloudApiError(cause: unknown): { readonly message: strin
return "cause" in cause ? findEnvironmentCloudApiError(cause.cause) : null;
}

function findEnvironmentAuthInvalidError(cause: unknown): EnvironmentAuthInvalidError | null {
if (isEnvironmentAuthInvalidError(cause)) {
return cause;
}
if (typeof cause !== "object" || cause === null) {
return null;
}
return "cause" in cause ? findEnvironmentAuthInvalidError(cause.cause) : null;
}

function requireRelayUrl(): Effect.Effect<string, CloudEnvironmentLinkError> {
const relayUrl = readRelayUrl();
return relayUrl
Expand Down Expand Up @@ -560,7 +536,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);
Expand Down
5 changes: 3 additions & 2 deletions apps/mobile/src/features/connection/CloudEnvironmentRows.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -297,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(
Expand Down Expand Up @@ -350,7 +351,7 @@ function CloudEnvironmentRowShell(props: {
>
<Text
className={cn("min-w-0 flex-1 text-xs", statusClassName)}
numberOfLines={isErrorExpanded ? undefined : 1}
numberOfLines={isErrorExpanded || statusHasHint ? undefined : 1}
Comment thread
extoci marked this conversation as resolved.
>
{statusText}
{errorTraceId ? (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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}
Expand Down
6 changes: 5 additions & 1 deletion apps/mobile/src/features/projects/AddProjectScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ function ListRow(props: {
readonly onPress?: () => void;
}) {
const chevronColor = useThemeColor("--color-chevron");
const subtitleHasHint = props.subtitle?.includes("\n") ?? false;

return (
<Pressable
Expand All @@ -184,7 +185,10 @@ function ListRow(props: {
<View className="flex-1 gap-0.5">
<Text className="text-base leading-snug font-t3-bold">{props.title}</Text>
{props.subtitle ? (
<Text className="text-sm leading-snug text-foreground-muted" numberOfLines={2}>
<Text
className="text-sm leading-snug text-foreground-muted"
numberOfLines={subtitleHasHint ? undefined : 2}
>
{props.subtitle}
</Text>
) : null}
Expand Down
10 changes: 10 additions & 0 deletions apps/server/src/auth/EnvironmentAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -347,6 +349,7 @@ export class ServerAuthInvalidCredentialError extends Schema.TaggedErrorClass<Se
"ServerAuthInvalidCredentialError",
{
diagnostic: Schema.optional(Schema.String),
dpopFailureReason: Schema.optionalKey(DpopFailureReason),
cause: Schema.optional(Schema.Defect()),
},
) {
Expand All @@ -366,6 +369,11 @@ export const serverAuthCredentialReason = (
): "missing_credential" | "invalid_credential" =>
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>()(
"ServerAuthInvalidScopeError",
{},
Expand Down Expand Up @@ -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",
}),
);
}
Expand All @@ -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",
}),
);
}
Expand Down
23 changes: 22 additions & 1 deletion apps/server/src/auth/dpop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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");
}
});

Expand All @@ -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);
}
});
});
Loading
Loading