1import {
2 EnvironmentHttpBadRequestError,
3 EnvironmentHttpConflictError,
4 EnvironmentHttpForbiddenError,
5 EnvironmentHttpInternalServerError,
6 EnvironmentHttpUnauthorizedError,
7} from "@t3tools/contracts";
8import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime/rpc";
9import {
10 RelayCloudEnvironmentHealthProofPayload,
11 RelayEnvironmentHealthResponse,
12 RelayEnvironmentHealthResponseProofPayload,
13 RelayEnvironmentMintResponse,
14 RelayEnvironmentMintResponseProofPayload,
15 RelayCloudMintCredentialProofPayload,
16 RelayEnvironmentConnectNotAuthorizedReason,
17 RelayEnvironmentEndpointFailure,
18 type RelayEnvironmentConnectResponse,
19 type RelayEnvironmentStatusResponse,
20 type RelayManagedEndpoint,
21} from "@t3tools/contracts/relay";
22import {
23 normalizeRelayIssuer,
24 RELAY_HEALTH_REQUEST_TYP,
25 RELAY_HEALTH_RESPONSE_TYP,
26 RELAY_MINT_REQUEST_TYP,
27 RELAY_MINT_RESPONSE_TYP,
28 verifyRelayJwt,
29} from "@t3tools/shared/relayJwt";
30import { stableStringify } from "@t3tools/shared/relaySigning";
31import * as Context from "effect/Context";
32import * as SecureRandom from "effect/crypto/SecureRandom";
33import * as Signature from "effect/crypto/Signature";
34import * as DateTime from "effect/DateTime";
35import * as Duration from "effect/Duration";
36import * as Effect from "effect/Effect";
37import * as Layer from "effect/Layer";
38import * as Option from "effect/Option";
39import * as Result from "effect/Result";
40import * as Schema from "effect/Schema";
41import * as FetchHttpClient from "effect/http/FetchHttpClient";
42import * as HttpClient from "effect/http/HttpClient";
43import * as HttpClientError from "effect/http/HttpClientError";
44
45import { diagnoseEndpointFailure } from "./endpointFailure.ts";
46import * as EnvironmentLinks from "./EnvironmentLinks.ts";
47import * as ManagedEndpointAllocations from "./ManagedEndpointAllocations.ts";
48import * as RelayConfiguration from "../Config.ts";
49import * as CloudMintSigner from "../auth/CloudMintSigner.ts";
50import { isManagedEndpointHostname } from "../deploymentConfig.ts";
51
52function environmentConnectNotAuthorizedReasonMessage(
53 reason: RelayEnvironmentConnectNotAuthorizedReason,
54): string {
55 switch (reason) {
56 case "client_proof_key_thumbprint_missing":
57 return "the client proof key thumbprint is missing";
58 case "environment_link_not_found":
59 return "no active environment link was found";
60 case "endpoint_provider_not_managed":
61 return "the linked endpoint is not relay-managed";
62 case "managed_endpoint_allocation_not_found":
63 return "no managed endpoint allocation was found";
64 case "managed_endpoint_base_domain_not_configured":
65 return "the managed endpoint base domain is not configured";
66 case "managed_endpoint_allocation_not_ready":
67 return "the managed endpoint allocation is incomplete";
68 case "managed_endpoint_hostname_invalid":
69 return "the managed endpoint hostname is invalid";
70 case "managed_endpoint_mismatch":
71 return "the linked endpoint does not match its managed allocation";
72 }
73}
74
75export class EnvironmentConnectNotAuthorized extends Schema.TaggedError<EnvironmentConnectNotAuthorized>()(
76 "EnvironmentConnectNotAuthorized",
77 {
78 environmentId: Schema.String,
79 operation: Schema.Literals(["connect", "status"]),
80 reason: RelayEnvironmentConnectNotAuthorizedReason,
81 },
82) {
83 override get message(): string {
84 return `Environment '${this.environmentId}' is not authorized for ${this.operation}: ${environmentConnectNotAuthorizedReasonMessage(this.reason)}`;
85 }
86}
87
88export class EnvironmentMintRequestFailed extends Schema.TaggedError<EnvironmentMintRequestFailed>()(
89 "EnvironmentMintRequestFailed",
90 {
91 environmentId: Schema.String,
92 operation: Schema.Literals(["connect", "status"]),
93 cause: Schema.Defect(),
94 endpointFailure: Schema.optionalKey(RelayEnvironmentEndpointFailure),
95 endpointStatus: Schema.optionalKey(Schema.Number),
96 },
97) {
98 override get message(): string {
99 return `Environment '${this.environmentId}' ${this.operation} request failed`;
100 }
101}
102
103export class EnvironmentMintRequestTimedOut extends Schema.TaggedError<EnvironmentMintRequestTimedOut>()(
104 "EnvironmentMintRequestTimedOut",
105 {
106 environmentId: Schema.String,
107 timeoutMs: Schema.Number,
108 },
109) {
110 override get message(): string {
111 return `Environment '${this.environmentId}' mint request timed out after ${this.timeoutMs}ms`;
112 }
113}
114
115export class EnvironmentMintResponseInvalid extends Schema.TaggedError<EnvironmentMintResponseInvalid>()(
116 "EnvironmentMintResponseInvalid",
117 {
118 environmentId: Schema.String,
119 operation: Schema.Literals(["connect", "status"]),
120 },
121) {
122 override get message(): string {
123 return `Environment '${this.environmentId}' returned an invalid ${this.operation} response`;
124 }
125}
126
127export type EnvironmentConnectorError =
128 | EnvironmentConnectNotAuthorized
129 | EnvironmentMintRequestFailed
130 | EnvironmentMintRequestTimedOut
131 | EnvironmentMintResponseInvalid
132 | EnvironmentLinks.EnvironmentLinkLookupPersistenceError
133 | ManagedEndpointAllocations.ManagedEndpointAllocationPersistenceError;
134
135const ENVIRONMENT_MINT_REQUEST_TIMEOUT_MS = 10_000;
136const ENVIRONMENT_HEALTH_CLOCK_SKEW_MILLIS = 60 * 1_000;
137
138export class EnvironmentConnector extends Context.Service<
139 EnvironmentConnector,
140 {
141 readonly connect: (input: {
142 readonly userId: string;
143 readonly environmentId: string;
144 readonly clientProofKeyThumbprint: string;
145 readonly deviceId?: string;
146 }) => Effect.Effect<RelayEnvironmentConnectResponse, EnvironmentConnectorError>;
147 readonly status: (input: {
148 readonly userId: string;
149 readonly environmentId: string;
150 }) => Effect.Effect<RelayEnvironmentStatusResponse, EnvironmentConnectorError>;
151 }
152>()("t3code-relay/environments/EnvironmentConnector") {}
153
154const isEnvironmentHealthError = Schema.is(
155 Schema.Union([
156 EnvironmentHttpBadRequestError,
157 EnvironmentHttpUnauthorizedError,
158 EnvironmentHttpForbiddenError,
159 EnvironmentHttpConflictError,
160 EnvironmentHttpInternalServerError,
161 ]),
162);
163
164function offlineReasonFor(
165 allocation: ManagedEndpointAllocations.ManagedEndpointAllocation | null,
166): { readonly offlineReason?: "tunnel_released" } {
167 return allocation?.tunnelReleasedAt ? { offlineReason: "tunnel_released" } : {};
168}
169
170function environmentHealthRequestFailureMessage(cause: unknown): string {
171 return isEnvironmentHealthError(cause)
172 ? `Managed endpoint health request failed: ${cause.message}`
173 : "Managed endpoint health request failed.";
174}
175
176function environmentHealthRequestFailureReason(cause: unknown): string {
177 if (isEnvironmentHealthError(cause)) {
178 return cause._tag;
179 }
180 if (HttpClientError.isHttpClientError(cause)) {
181 return cause.reason._tag;
182 }
183 if (Schema.isSchemaError(cause)) {
184 return "SchemaError";
185 }
186 return cause instanceof Error && cause.name ? cause.name : "Unknown";
187}
188
189const currentTraceId = Effect.currentSpan.pipe(
190 Effect.map((span) => span.traceId),
191 Effect.orElseSucceed(() => "unavailable"),
192);
193
194export const withoutRedirects = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
195 effect.pipe(Effect.provideService(FetchHttpClient.RequestInit, { redirect: "manual" }));
196
197const verifyWithEnvironmentKeys = Effect.fnUntraced(function* <A>(input: {
198 readonly token: string;
199 readonly typ: string;
200 readonly issuer: string;
201 readonly audience: string;
202 readonly environmentPublicKeys: ReadonlyArray<string>;
203 readonly schema: Schema.ConstraintDecoder<A>;
204}) {
205 const { schema, environmentPublicKeys: _keys, ...rest } = input;
206 for (const publicKey of input.environmentPublicKeys) {
207 const proof = yield* verifyRelayJwt(schema, { ...rest, publicKey }).pipe(Effect.option);
208 if (Option.isSome(proof)) {
209 return proof.value;
210 }
211 // A linked environment can have rotated keys; try the remaining active keys.
212 }
213 return null;
214});
215
216function verifyEnvironmentResponse(input: {
217 readonly response: RelayEnvironmentMintResponse;
218 readonly environmentId: string;
219 readonly requestNonce: string;
220 readonly clientProofKeyThumbprint: string;
221 readonly environmentPublicKeys: ReadonlyArray<string>;
222 readonly relayIssuer: string;
223}) {
224 return verifyWithEnvironmentKeys({
225 token: input.response.proof,
226 typ: RELAY_MINT_RESPONSE_TYP,
227 issuer: `t3-env:${input.environmentId}`,
228 audience: normalizeRelayIssuer(input.relayIssuer),
229 environmentPublicKeys: input.environmentPublicKeys,
230 schema: RelayEnvironmentMintResponseProofPayload,
231 }).pipe(
232 Effect.map(
233 (proof) =>
234 proof !== null &&
235 proof.environmentId === input.environmentId &&
236 proof.requestNonce === input.requestNonce &&
237 proof.clientProofKeyThumbprint === input.clientProofKeyThumbprint &&
238 proof.credential === input.response.credential &&
239 Option.match(DateTime.make(input.response.expiresAt), {
240 onNone: () => false,
241 onSome: (expiresAt) => Math.floor(expiresAt.epochMilliseconds / 1_000) === proof.exp,
242 }),
243 ),
244 );
245}
246
247function verifyEnvironmentHealthResponse(input: {
248 readonly response: RelayEnvironmentHealthResponse;
249 readonly environmentId: string;
250 readonly requestNonce: string;
251 readonly requestIssuedAt: DateTime.DateTime;
252 readonly environmentPublicKeys: ReadonlyArray<string>;
253 readonly relayIssuer: string;
254 readonly now: DateTime.DateTime;
255}) {
256 return verifyWithEnvironmentKeys({
257 token: input.response.proof,
258 typ: RELAY_HEALTH_RESPONSE_TYP,
259 issuer: `t3-env:${input.environmentId}`,
260 audience: normalizeRelayIssuer(input.relayIssuer),
261 environmentPublicKeys: input.environmentPublicKeys,
262 schema: RelayEnvironmentHealthResponseProofPayload,
263 }).pipe(
264 Effect.map((proof) => {
265 if (
266 proof === null ||
267 input.response.environmentId !== input.environmentId ||
268 proof.environmentId !== input.environmentId ||
269 proof.requestNonce !== input.requestNonce ||
270 proof.status !== input.response.status ||
271 proof.checkedAt !== input.response.checkedAt ||
272 stableStringify(proof.descriptor) !== stableStringify(input.response.descriptor)
273 ) {
274 return false;
275 }
276 const checkedAt = DateTime.make(input.response.checkedAt);
277 if (Option.isNone(checkedAt)) {
278 return false;
279 }
280 return (
281 checkedAt.value.epochMilliseconds >=
282 input.requestIssuedAt.epochMilliseconds - ENVIRONMENT_HEALTH_CLOCK_SKEW_MILLIS &&
283 checkedAt.value.epochMilliseconds <=
284 input.now.epochMilliseconds + ENVIRONMENT_HEALTH_CLOCK_SKEW_MILLIS
285 );
286 }),
287 );
288}
289
290export interface ManagedEndpointValidationFailure {
291 readonly reason: Exclude<
292 RelayEnvironmentConnectNotAuthorizedReason,
293 "client_proof_key_thumbprint_missing" | "environment_link_not_found"
294 >;
295 readonly attributes?: Record<string, string | boolean>;
296}
297
298export function validateManagedEndpoint(input: {
299 readonly link: EnvironmentLinks.RelayLinkedEnvironmentRecord;
300 readonly allocation: ManagedEndpointAllocations.ManagedEndpointAllocation | null;
301 readonly baseDomain: string | undefined;
302}): Result.Result<RelayManagedEndpoint, ManagedEndpointValidationFailure> {
303 const { link, allocation, baseDomain } = input;
304 if (link.endpoint.providerKind !== "cloudflare_tunnel") {
305 return Result.fail({
306 reason: "endpoint_provider_not_managed",
307 attributes: { "relay.authorization.endpoint_provider_kind": link.endpoint.providerKind },
308 });
309 }
310 if (!allocation) {
311 return Result.fail({ reason: "managed_endpoint_allocation_not_found" });
312 }
313 const allocationAttributes = {
314 "relay.authorization.allocation_hostname": allocation.hostname,
315 "relay.authorization.allocation_has_ready_at": allocation.readyAt !== null,
316 "relay.authorization.allocation_has_tunnel_id": allocation.tunnelId !== null,
317 "relay.authorization.allocation_has_dns_record_id": allocation.dnsRecordId !== null,
318 };
319 if (!baseDomain) {
320 return Result.fail({
321 reason: "managed_endpoint_base_domain_not_configured",
322 attributes: allocationAttributes,
323 });
324 }
325 if (
326 allocation.readyAt === null ||
327 allocation.tunnelId === null ||
328 allocation.dnsRecordId === null
329 ) {
330 return Result.fail({
331 reason: "managed_endpoint_allocation_not_ready",
332 attributes: allocationAttributes,
333 });
334 }
335 if (!isManagedEndpointHostname(allocation.hostname, baseDomain)) {
336 return Result.fail({
337 reason: "managed_endpoint_hostname_invalid",
338 attributes: {
339 ...allocationAttributes,
340 "relay.authorization.managed_endpoint_base_domain": baseDomain,
341 },
342 });
343 }
344 const endpoint = ManagedEndpointAllocations.resolveReadyManagedEndpoint({
345 allocation,
346 baseDomain,
347 });
348 if (
349 endpoint === null ||
350 endpoint.httpBaseUrl !== link.endpoint.httpBaseUrl ||
351 endpoint.wsBaseUrl !== link.endpoint.wsBaseUrl
352 ) {
353 return Result.fail({
354 reason: "managed_endpoint_mismatch",
355 attributes: {
356 ...allocationAttributes,
357 "relay.authorization.linked_http_base_url": link.endpoint.httpBaseUrl,
358 "relay.authorization.linked_ws_base_url": link.endpoint.wsBaseUrl,
359 ...(endpoint
360 ? {
361 "relay.authorization.resolved_http_base_url": endpoint.httpBaseUrl,
362 "relay.authorization.resolved_ws_base_url": endpoint.wsBaseUrl,
363 }
364 : {}),
365 },
366 });
367 }
368 return Result.succeed(endpoint);
369}
370
371const make = Effect.gen(function* () {
372 const links = yield* EnvironmentLinks.EnvironmentLinks;
373 const allocations = yield* ManagedEndpointAllocations.ManagedEndpointAllocations;
374 const settings = yield* RelayConfiguration.RelayConfiguration;
375 const httpClient = yield* HttpClient.HttpClient;
376 const random = yield* SecureRandom.SecureRandom;
377 const signature = yield* Signature.Signature;
378 const mint = yield* CloudMintSigner.CloudMintSigner;
379 const relayIssuer = normalizeRelayIssuer(settings.relayIssuer);
380 const makeEnvironmentClient = (httpBaseUrl: string) =>
381 makeEnvironmentHttpApiClient(httpBaseUrl).pipe(
382 Effect.provideService(HttpClient.HttpClient, httpClient),
383 );
384 const resolveManagedEndpoint = Effect.fn("relay.environment_connector.resolve_managed_endpoint")(
385 function* (input: {
386 readonly operation: "connect" | "status";
387 readonly link: EnvironmentLinks.RelayLinkedEnvironmentRecord;
388 readonly allocation: ManagedEndpointAllocations.ManagedEndpointAllocation | null;
389 }) {
390 const result = validateManagedEndpoint({
391 link: input.link,
392 allocation: input.allocation,
393 baseDomain: settings.managedEndpointBaseDomain,
394 });
395 if (Result.isSuccess(result)) {
396 return result.success;
397 }
398 if (result.failure.attributes) {
399 yield* Effect.annotateCurrentSpan(result.failure.attributes);
400 }
401 return yield* new EnvironmentConnectNotAuthorized({
402 environmentId: input.link.environmentId,
403 operation: input.operation,
404 reason: result.failure.reason,
405 });
406 },
407 );
408
409 return EnvironmentConnector.of({
410 status: Effect.fn("relay.environment_connector.status")(function* (input) {
411 yield* Effect.annotateCurrentSpan({
412 "relay.environment_id": input.environmentId,
413 "relay.operation": "status",
414 });
415 const { link, allocation } = yield* Effect.all(
416 {
417 link: links.getForUser(input),
418 allocation: allocations.get(input),
419 },
420 { concurrency: 2 },
421 );
422 if (!link) {
423 return yield* new EnvironmentConnectNotAuthorized({
424 environmentId: input.environmentId,
425 operation: "status",
426 reason: "environment_link_not_found",
427 });
428 }
429 const endpoint = yield* resolveManagedEndpoint({
430 operation: "status",
431 link,
432 allocation,
433 });
434 const now = yield* DateTime.now;
435 const expiresAt = DateTime.add(now, { minutes: 2 });
436 const nonce = yield* random.uuidV4().pipe(
437 Effect.mapError(
438 (cause) =>
439 new EnvironmentMintRequestFailed({
440 environmentId: input.environmentId,
441 operation: "status",
442 cause,
443 }),
444 ),
445 );
446 const payload = {
447 iss: relayIssuer,
448 aud: `t3-env:${link.environmentId}`,
449 sub: input.userId,
450 jti: yield* random.uuidV4().pipe(
451 Effect.mapError(
452 (cause) =>
453 new EnvironmentMintRequestFailed({
454 environmentId: input.environmentId,
455 operation: "status",
456 cause,
457 }),
458 ),
459 ),
460 iat: Math.floor(now.epochMilliseconds / 1_000),
461 exp: Math.floor(expiresAt.epochMilliseconds / 1_000),
462 environmentId: link.environmentId,
463 nonce,
464 scope: ["environment:status"],
465 } satisfies RelayCloudEnvironmentHealthProofPayload;
466 const proof = yield* mint
467 .sign({
468 typ: RELAY_HEALTH_REQUEST_TYP,
469 payload,
470 })
471 .pipe(
472 Effect.mapError(
473 (cause) =>
474 new EnvironmentMintRequestFailed({
475 environmentId: input.environmentId,
476 operation: "status",
477 cause,
478 }),
479 ),
480 );
481 const checkedAt = DateTime.formatIso(now);
482 const traceId = yield* currentTraceId;
483 const environmentClient = yield* makeEnvironmentClient(endpoint.httpBaseUrl);
484 const responseOption = yield* environmentClient.connect.health({ payload: { proof } }).pipe(
485 withoutRedirects,
486 Effect.match({
487 onFailure: (cause) => ({ _tag: "Failure" as const, cause }),
488 onSuccess: (response) => ({ _tag: "Success" as const, response }),
489 }),
490 Effect.timeoutOption(Duration.millis(ENVIRONMENT_MINT_REQUEST_TIMEOUT_MS)),
491 );
492 if (Option.isNone(responseOption)) {
493 yield* Effect.annotateCurrentSpan({
494 "relay.environment_health.outcome": "timeout",
495 "relay.environment_health.trace_id": traceId,
496 });
497 yield* Effect.logWarning("Managed endpoint health request timed out", {
498 environmentId: link.environmentId,
499 endpoint: endpoint.httpBaseUrl,
500 traceId,
501 });
502 return {
503 environmentId: link.environmentId,
504 endpoint,
505 status: "offline" as const,
506 checkedAt,
507 error: "Managed endpoint health request timed out.",
508 endpointFailure: "timeout" as const,
509 ...offlineReasonFor(allocation),
510 traceId,
511 };
512 }
513 if (responseOption.value._tag === "Failure") {
514 const failureReason = environmentHealthRequestFailureReason(responseOption.value.cause);
515 const diagnosis = yield* diagnoseEndpointFailure(responseOption.value.cause);
516 yield* Effect.annotateCurrentSpan({
517 "relay.environment_health.outcome": "failure",
518 "relay.environment_health.failure_reason": failureReason,
519 "relay.environment_health.endpoint_failure": diagnosis.endpointFailure,
520 "relay.environment_health.trace_id": traceId,
521 });
522 yield* Effect.logWarning("Managed endpoint health request failed", {
523 environmentId: link.environmentId,
524 endpoint: endpoint.httpBaseUrl,
525 failureReason,
526 endpointFailure: diagnosis.endpointFailure,
527 traceId,
528 });
529 return {
530 environmentId: link.environmentId,
531 endpoint,
532 status: "offline" as const,
533 checkedAt,
534 error: environmentHealthRequestFailureMessage(responseOption.value.cause),
535 ...diagnosis,
536 ...offlineReasonFor(allocation),
537 traceId,
538 };
539 }
540 const decoded = responseOption.value.response;
541 const verified = yield* verifyEnvironmentHealthResponse({
542 response: decoded,
543 environmentId: input.environmentId,
544 requestNonce: nonce,
545 requestIssuedAt: now,
546 environmentPublicKeys: [link.environmentPublicKey],
547 relayIssuer,
548 now: yield* DateTime.now,
549 }).pipe(Effect.provideService(Signature.Signature, signature));
550 if (!verified) {
551 return yield* new EnvironmentMintResponseInvalid({
552 environmentId: input.environmentId,
553 operation: "status",
554 });
555 }
556 return {
557 environmentId: link.environmentId,
558 endpoint,
559 status: "online" as const,
560 checkedAt: decoded.checkedAt,
561 descriptor: decoded.descriptor,
562 };
563 }),
564 connect: Effect.fn("relay.environment_connector.connect")(function* (input) {
565 yield* Effect.annotateCurrentSpan({
566 "relay.environment_id": input.environmentId,
567 "relay.operation": "connect",
568 "relay.connect.has_device_id": input.deviceId !== undefined,
569 ...(input.deviceId ? { "relay.mobile.device_id": input.deviceId } : {}),
570 });
571 if (input.clientProofKeyThumbprint.trim().length === 0) {
572 return yield* new EnvironmentConnectNotAuthorized({
573 environmentId: input.environmentId,
574 operation: "connect",
575 reason: "client_proof_key_thumbprint_missing",
576 });
577 }
578 const { link, allocation } = yield* Effect.all(
579 {
580 link: links.getForUser(input),
581 allocation: allocations.get(input),
582 },
583 { concurrency: 2 },
584 );
585 if (!link) {
586 return yield* new EnvironmentConnectNotAuthorized({
587 environmentId: input.environmentId,
588 operation: "connect",
589 reason: "environment_link_not_found",
590 });
591 }
592 const endpoint = yield* resolveManagedEndpoint({
593 operation: "connect",
594 link,
595 allocation,
596 });
597 const now = yield* DateTime.now;
598 const expiresAt = DateTime.add(now, { minutes: 2 });
599 const nonce = yield* random.uuidV4().pipe(
600 Effect.mapError(
601 (cause) =>
602 new EnvironmentMintRequestFailed({
603 environmentId: input.environmentId,
604 operation: "connect",
605 cause,
606 }),
607 ),
608 );
609 const payload = {
610 iss: relayIssuer,
611 aud: `t3-env:${link.environmentId}`,
612 sub: input.userId,
613 jti: yield* random.uuidV4().pipe(
614 Effect.mapError(
615 (cause) =>
616 new EnvironmentMintRequestFailed({
617 environmentId: input.environmentId,
618 operation: "connect",
619 cause,
620 }),
621 ),
622 ),
623 iat: Math.floor(now.epochMilliseconds / 1_000),
624 exp: Math.floor(expiresAt.epochMilliseconds / 1_000),
625 environmentId: link.environmentId,
626 clientProofKeyThumbprint: input.clientProofKeyThumbprint,
627 cnf: { jkt: input.clientProofKeyThumbprint },
628 ...(input.deviceId ? { deviceId: input.deviceId } : {}),
629 nonce,
630 scope: ["environment:connect"],
631 } satisfies RelayCloudMintCredentialProofPayload;
632 const proof = yield* mint
633 .sign({
634 typ: RELAY_MINT_REQUEST_TYP,
635 payload,
636 })
637 .pipe(
638 Effect.mapError(
639 (cause) =>
640 new EnvironmentMintRequestFailed({
641 environmentId: input.environmentId,
642 operation: "connect",
643 cause,
644 }),
645 ),
646 );
647 const environmentClient = yield* makeEnvironmentClient(endpoint.httpBaseUrl);
648 const decoded = yield* environmentClient.connect
649 .t3MintCredential({ payload: { proof } })
650 .pipe(
651 withoutRedirects,
652 Effect.catch((cause) =>
653 diagnoseEndpointFailure(cause).pipe(
654 Effect.flatMap((diagnosis) =>
655 Effect.fail(
656 new EnvironmentMintRequestFailed({
657 environmentId: input.environmentId,
658 operation: "connect",
659 cause,
660 ...diagnosis,
661 }),
662 ),
663 ),
664 ),
665 ),
666 Effect.timeoutOption(Duration.millis(ENVIRONMENT_MINT_REQUEST_TIMEOUT_MS)),
667 Effect.flatMap(
668 Option.match({
669 onNone: () =>
670 Effect.fail(
671 new EnvironmentMintRequestTimedOut({
672 environmentId: input.environmentId,
673 timeoutMs: ENVIRONMENT_MINT_REQUEST_TIMEOUT_MS,
674 }),
675 ),
676 onSome: Effect.succeed,
677 }),
678 ),
679 );
680 const verified = yield* verifyEnvironmentResponse({
681 response: decoded,
682 environmentId: input.environmentId,
683 requestNonce: nonce,
684 clientProofKeyThumbprint: input.clientProofKeyThumbprint,
685 environmentPublicKeys: [link.environmentPublicKey],
686 relayIssuer,
687 }).pipe(Effect.provideService(Signature.Signature, signature));
688 if (!verified) {
689 return yield* new EnvironmentMintResponseInvalid({
690 environmentId: input.environmentId,
691 operation: "connect",
692 });
693 }
694 return {
695 environmentId: link.environmentId,
696 endpoint,
697 credential: decoded.credential,
698 expiresAt: decoded.expiresAt,
699 };
700 }),
701 });
702});
703
704export const layer = Layer.effect(EnvironmentConnector, make);