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