Skip to content
7 changes: 2 additions & 5 deletions apps/desktop/src/ipc/methods/sshEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import {
bootstrapRemoteBearerSession,
fetchRemoteSessionState,
issueRemoteWebSocketTicket,
RemoteEnvironmentAuthUndeclaredStatusError,
isRemoteEnvironmentAuthUndeclaredStatusError,
type RemoteEnvironmentAuthError,
} from "@t3tools/client-runtime/authorization";
import { fetchRemoteEnvironmentDescriptor } from "@t3tools/client-runtime/environment";
Expand Down Expand Up @@ -52,10 +52,7 @@ const isEnvironmentRequestInvalidError = Schema.is(EnvironmentRequestInvalidErro
const isEnvironmentScopeRequiredError = Schema.is(EnvironmentScopeRequiredError);

function readSshHttpStatus(cause: DesktopSshEnvironmentRequestCause): number | null {
if (
cause instanceof RemoteEnvironmentAuthUndeclaredStatusError ||
cause instanceof SshHttpBridgeError
) {
if (isRemoteEnvironmentAuthUndeclaredStatusError(cause) || cause instanceof SshHttpBridgeError) {
return cause.status ?? null;
}
if (isEnvironmentRequestInvalidError(cause)) {
Expand Down
27 changes: 13 additions & 14 deletions apps/mobile/src/features/cloud/managedRelayLayer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,25 +28,24 @@ const relayDpopSignerLayer = Layer.effect(
),
createProof: Effect.fn("mobile.managedRelayDpopSigner.createProof")(function* (input) {
const proofKey = yield* loadProofKey.pipe(
Effect.mapError(
(error) =>
new ManagedRelay.ManagedRelayDpopProofCreationError({
method: input.method,
url: input.url,
cause: error,
}),
Effect.mapError((error) =>
ManagedRelay.ManagedRelayDpopKeyLoadError.fromTarget({
keyStore: "expo-secure-store",
method: input.method,
url: input.url,
cause: error,
}),
),
);
return yield* createDpopProof({ ...input, proofKey }).pipe(
Effect.provideService(Crypto.Crypto, crypto),
Effect.map((proof) => proof.proof),
Effect.mapError(
(error) =>
new ManagedRelay.ManagedRelayDpopProofCreationError({
method: input.method,
url: input.url,
cause: error,
}),
Effect.mapError((error) =>
ManagedRelay.ManagedRelayDpopProofCreationError.fromTarget({
method: input.method,
url: input.url,
cause: error,
}),
),
);
}),
Expand Down
27 changes: 13 additions & 14 deletions apps/web/src/cloud/managedRelayLayer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,25 +50,24 @@ export const relayDpopSignerLayer = Layer.effect(
),
createProof: Effect.fn("web.managedRelayDpopSigner.createProof")(function* (input) {
const proofKey = yield* loadOrCreateBrowserDpopKey.pipe(
Effect.mapError(
(error) =>
new ManagedRelay.ManagedRelayDpopProofCreationError({
method: input.method,
url: input.url,
cause: error,
}),
Effect.mapError((error) =>
ManagedRelay.ManagedRelayDpopKeyLoadError.fromTarget({
keyStore: "indexed-db",
method: input.method,
url: input.url,
cause: error,
}),
),
);
return yield* createBrowserDpopProof({ ...input, proofKey }).pipe(
Effect.provideService(Crypto.Crypto, crypto),
Effect.map((proof) => proof.proof),
Effect.mapError(
(error) =>
new ManagedRelay.ManagedRelayDpopProofCreationError({
method: input.method,
url: input.url,
cause: error,
}),
Effect.mapError((error) =>
ManagedRelay.ManagedRelayDpopProofCreationError.fromTarget({
method: input.method,
url: input.url,
cause: error,
}),
),
);
}),
Expand Down
8 changes: 6 additions & 2 deletions packages/client-runtime/src/authorization/remote.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -391,7 +391,9 @@ describe("remote environment authorization", () => {

expect(error).toBeInstanceOf(RemoteEnvironmentAuthTimeoutError);
expect(error.message).toBe(
"Remote environment endpoint http://remote.example.com/.well-known/t3/environment timed out after 25ms.",
`Remote environment endpoint at host remote.example.com (${
"http://remote.example.com/.well-known/t3/environment".length
} URL characters) timed out after 25ms.`,
);
}).pipe(Effect.provide(TestClock.layer())),
);
Expand Down Expand Up @@ -446,7 +448,9 @@ describe("remote environment authorization", () => {

expect(error).toBeInstanceOf(RemoteEnvironmentAuthInvalidJsonError);
expect(error.message).toBe(
"Remote environment endpoint returned an invalid response from https://remote.example.com/oauth/token.",
`Remote environment endpoint at host remote.example.com (${
"https://remote.example.com/oauth/token".length
} URL characters) returned an invalid response.`,
);
}),
);
Expand Down
1 change: 1 addition & 0 deletions packages/client-runtime/src/authorization/remote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
} from "../rpc/http.ts";

export {
isRemoteEnvironmentAuthUndeclaredStatusError,
RemoteEnvironmentAuthFetchError,
RemoteEnvironmentAuthInvalidJsonError,
RemoteEnvironmentAuthTimeoutError,
Expand Down
27 changes: 17 additions & 10 deletions packages/client-runtime/src/authorization/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
resolveRemoteDpopWebSocketConnectionUrl,
resolveRemoteWebSocketConnectionUrl,
} from "./remote.ts";
import { environmentMismatchError, mapRemoteEnvironmentError } from "../connection/errors.ts";
import { 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";
Expand Down Expand Up @@ -112,9 +112,11 @@ export const make = Effect.gen(function* () {
Effect.provideService(HttpClient.HttpClient, httpClient),
);
if (descriptor.environmentId !== input.expectedEnvironmentId) {
return yield* environmentMismatchError({
expected: input.expectedEnvironmentId,
actual: descriptor.environmentId,
return yield* new ConnectionBlockedError({
reason: "configuration",
detail: `Connected environment ${descriptor.environmentId} does not match ${input.expectedEnvironmentId}.`,
expectedEnvironmentId: input.expectedEnvironmentId,
actualEnvironmentId: descriptor.environmentId,
});
}
const socketUrl = yield* resolveRemoteWebSocketConnectionUrl({
Expand Down Expand Up @@ -148,10 +150,11 @@ export const make = Effect.gen(function* () {
})
.pipe(
Effect.mapError(
() =>
(cause) =>
new ConnectionBlockedError({
reason: "configuration",
detail: "Could not create the websocket authorization proof.",
cause,
}),
),
);
Expand All @@ -175,10 +178,11 @@ export const make = Effect.gen(function* () {
}) {
const thumbprint = yield* signer.thumbprint.pipe(
Effect.mapError(
() =>
(cause) =>
new ConnectionBlockedError({
reason: "configuration",
detail: "Could not load the environment authorization key.",
cause,
}),
),
Effect.withSpan("environment.authorization.dpopKey.resolve"),
Expand Down Expand Up @@ -236,9 +240,11 @@ export const make = Effect.gen(function* () {
Effect.withSpan("environment.authorization.descriptor"),
);
if (descriptor.environmentId !== input.expectedEnvironmentId) {
return yield* environmentMismatchError({
expected: input.expectedEnvironmentId,
actual: descriptor.environmentId,
return yield* new ConnectionBlockedError({
reason: "configuration",
detail: `Connected environment ${descriptor.environmentId} does not match ${input.expectedEnvironmentId}.`,
expectedEnvironmentId: input.expectedEnvironmentId,
actualEnvironmentId: descriptor.environmentId,
});
}
const bootstrapProof = yield* signer
Expand All @@ -248,10 +254,11 @@ export const make = Effect.gen(function* () {
})
.pipe(
Effect.mapError(
() =>
(cause) =>
new ConnectionBlockedError({
reason: "configuration",
detail: "Could not create the environment authorization proof.",
cause,
}),
),
);
Expand Down
120 changes: 120 additions & 0 deletions packages/client-runtime/src/connection/errors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { EnvironmentAuthInvalidError } from "@t3tools/contracts";
import { RelayAuthInvalidError } from "@t3tools/contracts/relay";
import { describe, expect, it } from "@effect/vitest";

import { mapManagedRelayError, mapRemoteEnvironmentError } from "./errors.ts";
import * as ManagedRelay from "../relay/managedRelay.ts";
import {
RemoteEnvironmentAuthFetchError,
RemoteEnvironmentAuthUndeclaredStatusError,
} from "../rpc/http.ts";

describe("connection error mapping", () => {
it("retains the managed relay request as the cause when classifying a protected error", () => {
const relayError = new RelayAuthInvalidError({
code: "auth_invalid",
reason: "invalid_bearer",
traceId: "relay-trace-id",
});
const source = new ManagedRelay.ManagedRelayRequestFailedError({
action: "connect relay environment",
cause: relayError,
relayError,
traceId: relayError.traceId,
});

const error = mapManagedRelayError(source);

expect(error).toMatchObject({
_tag: "ConnectionBlockedError",
reason: "authentication",
traceId: "relay-trace-id",
});
expect(error.cause).toBe(source);
});

it("retains a managed relay timeout and its structured activity", () => {
const source = new ManagedRelay.ManagedRelayRequestTimeoutError({
activity: "Relay environment connection",
timeoutMs: 10_000,
});

const error = mapManagedRelayError(source);

expect(error).toMatchObject({
_tag: "ConnectionTransientError",
reason: "timeout",
});
expect(error.cause).toBe(source);
expect(source.activity).toBe("Relay environment connection");
});

it("retains structured remote authorization failures", () => {
const source = new EnvironmentAuthInvalidError({
code: "auth_invalid",
reason: "invalid_credential",
traceId: "environment-trace-id",
});

const error = mapRemoteEnvironmentError(source);

expect(error).toMatchObject({
_tag: "ConnectionBlockedError",
reason: "authentication",
traceId: "environment-trace-id",
});
expect(error.cause).toBe(source);
});

it("retains local transport failures without deriving their message from the cause", () => {
const transportCause = new Error("sensitive transport implementation detail");
const requestUrl =
"https://environment-user:environment-password@environment.example.test/private/session?access_token=environment-secret#environment-fragment";
const source = RemoteEnvironmentAuthFetchError.fromRequestUrl(requestUrl, transportCause);

const error = mapRemoteEnvironmentError(source);

expect(source.message).toBe(
`Failed to fetch remote environment endpoint at host environment.example.test (${requestUrl.length} URL characters).`,
);
expect(source.message).not.toContain(transportCause.message);
expect(error).toMatchObject({
_tag: "ConnectionTransientError",
reason: "network",
});
expect(error.cause).toBe(source);
expect(source.cause).toBe(transportCause);
expect(source).toMatchObject({
requestUrlInputLength: requestUrl.length,
requestUrlProtocol: "https:",
requestUrlHostname: "environment.example.test",
});
const diagnostics = JSON.stringify(source);
for (const secret of [
"environment-user",
"environment-password",
"/private/session",
"environment-secret",
"environment-fragment",
]) {
expect(diagnostics).not.toContain(secret);
expect(source.message).not.toContain(secret);
}
});

it("retains the HTTP client cause for undeclared statuses", () => {
const cause = new Error("upstream response metadata");
const requestUrl =
"https://environment-user:environment-password@environment.example.test/private/session?access_token=environment-secret#environment-fragment";
const error = RemoteEnvironmentAuthUndeclaredStatusError.fromRequestUrl(requestUrl, 502, cause);

expect(error).toMatchObject({
status: 502,
requestUrlInputLength: requestUrl.length,
requestUrlProtocol: "https:",
requestUrlHostname: "environment.example.test",
});
expect(error.cause).toBe(cause);
expect(error).not.toHaveProperty("requestUrl");
});
});
Loading
Loading