Skip to content

Commit 7deb0c4

Browse files
juliusmarmingecodex
andcommitted
Use typed Environment HttpApi cloud errors
Co-authored-by: codex <codex@users.noreply.github.com>
1 parent fcfecc7 commit 7deb0c4

12 files changed

Lines changed: 891 additions & 801 deletions

File tree

apps/mobile/src/features/cloud/linkEnvironment.test.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -577,6 +577,38 @@ describe("mobile cloud link environment client", () => {
577577
}),
578578
);
579579

580+
it.effect("preserves typed local environment failures while obtaining a link proof", () =>
581+
Effect.gen(function* () {
582+
const fetchMock = vi.fn((url: string | URL) => {
583+
if (String(url).endsWith("/v1/client/environment-link-challenges")) {
584+
return Promise.resolve(Response.json(validLinkChallengeResponse()));
585+
}
586+
return Promise.resolve(
587+
Response.json(
588+
{
589+
_tag: "EnvironmentHttpUnauthorizedError",
590+
message: "Invalid environment bearer session.",
591+
},
592+
{ status: 401 },
593+
),
594+
);
595+
});
596+
vi.stubGlobal("fetch", fetchMock);
597+
598+
const error = yield* withCloudServices(
599+
linkEnvironmentToCloud({
600+
clerkToken: "clerk-token",
601+
connection: savedConnection,
602+
}),
603+
).pipe(Effect.flip);
604+
expect(error._tag).toBe("CloudEnvironmentLinkError");
605+
expect(error.message).toBe(
606+
"Could not obtain environment link proof: Invalid environment bearer session.",
607+
);
608+
expect(fetchMock).toHaveBeenCalledTimes(2);
609+
}),
610+
);
611+
580612
it.effect("preserves typed relay error bodies while linking environments", () =>
581613
Effect.gen(function* () {
582614
const fetchMock = vi.fn((url: string | URL) => {
@@ -671,7 +703,9 @@ describe("mobile cloud link environment client", () => {
671703
if (String(url).endsWith("/v1/client/environment-links")) {
672704
return Promise.resolve(Response.json(validLinkResponse()));
673705
}
674-
return Promise.resolve(Response.json({ ok: true }));
706+
return Promise.resolve(
707+
Response.json({ ok: true, endpointRuntimeStatus: { status: "configured" } }),
708+
);
675709
});
676710
vi.stubGlobal("fetch", fetchMock);
677711

apps/mobile/src/features/cloud/linkEnvironment.ts

Lines changed: 74 additions & 138 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,18 @@ import Constants from "expo-constants";
22
import * as Data from "effect/Data";
33
import * as Effect from "effect/Effect";
44
import * as Schema from "effect/Schema";
5-
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
5+
import { HttpClient } from "effect/unstable/http";
6+
import {
7+
EnvironmentCloudEndpointUnavailableError,
8+
EnvironmentHttpBadRequestError,
9+
EnvironmentHttpConflictError,
10+
EnvironmentHttpForbiddenError,
11+
EnvironmentHttpInternalServerError,
12+
EnvironmentHttpUnauthorizedError,
13+
} from "@t3tools/contracts";
614
import { stripPairingTokenFromUrl } from "@t3tools/shared/remote";
715
import {
816
type RelayEnvironmentConnectResponse as RelayEnvironmentConnectResponseType,
9-
RelayEnvironmentConfigRequest,
10-
RelayEnvironmentLinkProof,
11-
RelayLinkProofRequest,
1217
type RelayEnvironmentLinkResponse as RelayEnvironmentLinkResponseType,
1318
RelayEnvironmentConnectScope,
1419
RelayEnvironmentStatusScope,
@@ -22,6 +27,7 @@ import {
2227
import {
2328
exchangeRemoteDpopAccessToken,
2429
fetchRemoteEnvironmentDescriptor,
30+
makeEnvironmentHttpApiClient,
2531
ManagedRelayClient,
2632
ManagedRelayDpopSigner,
2733
} from "@t3tools/client-runtime";
@@ -33,12 +39,6 @@ const RELAY_STATUS_AND_CONNECT_SCOPES = [
3339
RelayEnvironmentStatusScope,
3440
RelayEnvironmentConnectScope,
3541
] satisfies ReadonlyArray<RelayDpopAccessTokenScope>;
36-
const encodeRelayLinkProofRequest = Schema.encodeEffect(
37-
Schema.fromJsonString(RelayLinkProofRequest),
38-
);
39-
const encodeRelayEnvironmentConfigRequest = Schema.encodeEffect(
40-
Schema.fromJsonString(RelayEnvironmentConfigRequest),
41-
);
4242

4343
export function normalizeRelayBaseUrl(value: string | null | undefined): string | null {
4444
const trimmed = value?.trim();
@@ -66,12 +66,31 @@ export interface CloudEnvironmentRecordWithStatus {
6666
readonly statusError: string | null;
6767
}
6868

69+
const isRelayProtectedError = Schema.is(RelayProtectedError);
70+
const isEnvironmentCloudApiError = Schema.is(
71+
Schema.Union([
72+
EnvironmentHttpBadRequestError,
73+
EnvironmentHttpUnauthorizedError,
74+
EnvironmentHttpForbiddenError,
75+
EnvironmentHttpConflictError,
76+
EnvironmentHttpInternalServerError,
77+
EnvironmentCloudEndpointUnavailableError,
78+
]),
79+
);
80+
6981
const MANAGED_ENDPOINT_PROVIDER_KIND =
7082
"cloudflare_tunnel" satisfies RelayManagedEndpointProviderKind;
7183

7284
function cloudEnvironmentLinkError(message: string) {
73-
return (cause: unknown) =>
74-
new CloudEnvironmentLinkError({ message: withDevCause(message, cause), cause });
85+
return (cause: unknown) => {
86+
const environmentError = findEnvironmentCloudApiError(cause);
87+
return new CloudEnvironmentLinkError({
88+
message: environmentError
89+
? `${message.replace(/[.:]$/, "")}: ${environmentError.message}`
90+
: withDevCause(message, cause),
91+
cause,
92+
});
93+
};
7594
}
7695

7796
function isDevRuntime(): boolean {
@@ -100,8 +119,6 @@ function withDevCause(message: string, cause: unknown): string {
100119
return detail ? `${message} (${detail})` : message;
101120
}
102121

103-
const decodeRelayProtectedError = Schema.decodeUnknownEffect(RelayProtectedError);
104-
105122
function relayProtectedErrorMessage(error: RelayProtectedErrorType): string {
106123
switch (error._tag) {
107124
case "RelayAuthInvalidError":
@@ -149,101 +166,32 @@ function decodedRelayClientError(message: string) {
149166
}
150167

151168
function findRelayProtectedError(cause: unknown): RelayProtectedErrorType | null {
169+
if (isRelayProtectedError(cause)) {
170+
return cause;
171+
}
152172
if (typeof cause !== "object" || cause === null) {
153173
return null;
154174
}
155-
if ("_tag" in cause && String(cause._tag).startsWith("Relay")) {
156-
return cause as RelayProtectedErrorType;
157-
}
158175
return "cause" in cause ? findRelayProtectedError(cause.cause) : null;
159176
}
160177

178+
function findEnvironmentCloudApiError(cause: unknown): { readonly message: string } | null {
179+
if (isEnvironmentCloudApiError(cause)) {
180+
return cause;
181+
}
182+
if (typeof cause !== "object" || cause === null) {
183+
return null;
184+
}
185+
return "cause" in cause ? findEnvironmentCloudApiError(cause.cause) : null;
186+
}
187+
161188
function requireRelayUrl(): Effect.Effect<string, CloudEnvironmentLinkError> {
162189
const relayUrl = readRelayUrl();
163190
return relayUrl
164191
? Effect.succeed(relayUrl)
165192
: Effect.fail(new CloudEnvironmentLinkError({ message: "Relay URL is not configured." }));
166193
}
167194

168-
function requestFromInit(url: string, init: RequestInit) {
169-
return HttpClientRequest.make((init.method ?? "GET") as "GET" | "POST" | "DELETE")(url, {
170-
headers: init.headers as Record<string, string> | undefined,
171-
}).pipe(
172-
typeof init.body === "string"
173-
? HttpClientRequest.bodyText(
174-
init.body,
175-
(init.headers as Record<string, string> | undefined)?.["content-type"] ??
176-
"application/json",
177-
)
178-
: (request) => request,
179-
);
180-
}
181-
182-
function jsonResponse(
183-
url: string,
184-
init: RequestInit,
185-
): Effect.Effect<
186-
HttpClientResponse.HttpClientResponse,
187-
CloudEnvironmentLinkError,
188-
HttpClient.HttpClient
189-
> {
190-
return Effect.gen(function* () {
191-
const response = yield* HttpClient.execute(requestFromInit(url, init)).pipe(
192-
Effect.mapError(cloudEnvironmentLinkError(`${url} request failed.`)),
193-
);
194-
if (response.status < 200 || response.status >= 300) {
195-
const relayError = yield* response.json.pipe(
196-
Effect.orElseSucceed(() => null),
197-
Effect.flatMap((json) =>
198-
json === null
199-
? Effect.succeed(null)
200-
: decodeRelayProtectedError(json).pipe(Effect.catch(() => Effect.succeed(null))),
201-
),
202-
);
203-
return yield* new CloudEnvironmentLinkError({
204-
message: relayError
205-
? `${url} failed with ${response.status}: ${relayProtectedErrorMessage(relayError)}`
206-
: `${url} failed with ${response.status}`,
207-
});
208-
}
209-
return response;
210-
});
211-
}
212-
213-
function jsonFetch<T>(
214-
url: string,
215-
init: RequestInit,
216-
): Effect.Effect<T, CloudEnvironmentLinkError, HttpClient.HttpClient> {
217-
return Effect.gen(function* () {
218-
const response = yield* jsonResponse(url, init);
219-
return yield* response.json.pipe(
220-
Effect.map((json) => json as T),
221-
Effect.mapError(cloudEnvironmentLinkError(`${url} returned invalid JSON.`)),
222-
);
223-
});
224-
}
225-
226-
function jsonFetchSchema<S extends Schema.Top>(input: {
227-
readonly url: string;
228-
readonly init: RequestInit;
229-
readonly schema: S;
230-
readonly errorMessage: string;
231-
}): Effect.Effect<
232-
S["Type"],
233-
CloudEnvironmentLinkError,
234-
S["DecodingServices"] | HttpClient.HttpClient
235-
> {
236-
return jsonResponse(input.url, input.init).pipe(
237-
Effect.flatMap(HttpClientResponse.schemaJson(Schema.Struct({ body: input.schema }))),
238-
Effect.map((response) => (response as { readonly body: S["Type"] }).body),
239-
Effect.mapError((cause) =>
240-
cause instanceof CloudEnvironmentLinkError
241-
? cause
242-
: new CloudEnvironmentLinkError({ message: input.errorMessage, cause }),
243-
),
244-
);
245-
}
246-
247195
function endpointOrigin(httpBaseUrl: string) {
248196
const url = new URL(httpBaseUrl);
249197
return {
@@ -354,31 +302,22 @@ export function linkEnvironmentToCloud(input: {
354302
decodedRelayClientError(`${relayUrl}/v1/client/environment-link-challenges failed`),
355303
),
356304
);
357-
const proofRequestBody = yield* encodeRelayLinkProofRequest({
358-
challenge: challenge.challenge,
359-
relayIssuer: relayUrl,
360-
endpoint: {
361-
httpBaseUrl: input.connection.httpBaseUrl,
362-
wsBaseUrl: input.connection.wsBaseUrl,
363-
providerKind: MANAGED_ENDPOINT_PROVIDER_KIND,
364-
},
365-
origin: endpointOrigin(input.connection.httpBaseUrl),
366-
}).pipe(
367-
Effect.mapError(cloudEnvironmentLinkError("Could not encode cloud link proof request.")),
368-
);
369-
const proof = yield* jsonFetchSchema({
370-
url: `${input.connection.httpBaseUrl}/api/cloud/link-proof`,
371-
schema: RelayEnvironmentLinkProof,
372-
errorMessage: "Environment returned an invalid cloud link proof.",
373-
init: {
374-
method: "POST",
375-
headers: {
376-
authorization: `Bearer ${localBearerToken}`,
377-
"content-type": "application/json",
305+
const environmentClient = yield* makeEnvironmentHttpApiClient(input.connection.httpBaseUrl);
306+
const proof = yield* environmentClient.cloud
307+
.linkProof({
308+
headers: { authorization: `Bearer ${localBearerToken}` },
309+
payload: {
310+
challenge: challenge.challenge,
311+
relayIssuer: relayUrl,
312+
endpoint: {
313+
httpBaseUrl: input.connection.httpBaseUrl,
314+
wsBaseUrl: input.connection.wsBaseUrl,
315+
providerKind: MANAGED_ENDPOINT_PROVIDER_KIND,
316+
},
317+
origin: endpointOrigin(input.connection.httpBaseUrl),
378318
},
379-
body: proofRequestBody,
380-
},
381-
});
319+
})
320+
.pipe(Effect.mapError(cloudEnvironmentLinkError("Could not obtain environment link proof.")));
382321
const link = yield* relayClient
383322
.linkEnvironment({
384323
clerkToken: input.clerkToken,
@@ -399,24 +338,21 @@ export function linkEnvironmentToCloud(input: {
399338
link,
400339
});
401340

402-
const relayConfigRequestBody = yield* encodeRelayEnvironmentConfigRequest({
403-
relayUrl,
404-
relayIssuer: link.relayIssuer,
405-
cloudUserId: link.cloudUserId,
406-
environmentCredential: link.environmentCredential,
407-
cloudMintPublicKey: link.cloudMintPublicKey,
408-
endpointRuntime: link.endpointRuntime,
409-
}).pipe(
410-
Effect.mapError(cloudEnvironmentLinkError("Could not encode cloud relay config request.")),
411-
);
412-
yield* jsonFetch(`${input.connection.httpBaseUrl}/api/cloud/relay-config`, {
413-
method: "POST",
414-
headers: {
415-
authorization: `Bearer ${localBearerToken}`,
416-
"content-type": "application/json",
417-
},
418-
body: relayConfigRequestBody,
419-
});
341+
yield* environmentClient.cloud
342+
.relayConfig({
343+
headers: { authorization: `Bearer ${localBearerToken}` },
344+
payload: {
345+
relayUrl,
346+
relayIssuer: link.relayIssuer,
347+
cloudUserId: link.cloudUserId,
348+
environmentCredential: link.environmentCredential,
349+
cloudMintPublicKey: link.cloudMintPublicKey,
350+
endpointRuntime: link.endpointRuntime,
351+
},
352+
})
353+
.pipe(
354+
Effect.mapError(cloudEnvironmentLinkError("Could not configure environment relay access.")),
355+
);
420356
});
421357
}
422358

0 commit comments

Comments
 (0)