1/**
2 * The T3 Connect link lifecycle of this environment: linking it to the relay,
3 * applying and reading the link, unlinking, answering the relay's signed health
4 * and mint requests, and keeping the managed tunnel registered, recovered and
5 * released. HTTP handlers, server startup and shutdown all go through it.
6 */
7// @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's Crypto has no createPublicKey.
8import * as NodeCrypto from "node:crypto";
9import {
10 AuthStandardClientScopes,
11 type EnvironmentCloudLinkStateResult,
12 type EnvironmentCloudPreferencesRequest,
13 type EnvironmentCloudRelayConfigResult,
14} from "@t3tools/contracts";
15import {
16 RelayCloudEnvironmentHealthProofPayload,
17 type RelayCloudEnvironmentHealthRequest,
18 RelayCloudMintCredentialProofPayload,
19 type RelayCloudMintCredentialRequest,
20 type RelayEnvironmentConfigRequest,
21 type RelayEnvironmentHealthResponse,
22 type RelayEnvironmentHealthResponseProofPayload,
23 type RelayEnvironmentLinkProof,
24 type RelayEnvironmentLinkProofPayload,
25 RelayEnvironmentLinkChallengeResponse,
26 RelayEnvironmentLinkResponse,
27 type RelayEnvironmentMintResponse,
28 type RelayEnvironmentMintResponseProofPayload,
29 type RelayLinkProofRequest,
30 type RelayManagedEndpointOrigin,
31 type RelayManagedEndpointRecoveryProofPayload,
32 RelayManagedEndpointRecoveryRegistrationResponse,
33 RelayManagedEndpointRecoveryResponse,
34 type RelayManagedEndpointRuntimeConfig,
35 RelayOkResponse,
36} from "@t3tools/contracts/relay";
37import {
38 normalizeRelayIssuer,
39 RELAY_HEALTH_REQUEST_TYP,
40 RELAY_HEALTH_RESPONSE_TYP,
41 RELAY_LINK_PROOF_TYP,
42 RELAY_MANAGED_TUNNEL_RECOVERY_TYP,
43 RELAY_MINT_REQUEST_TYP,
44 RELAY_MINT_RESPONSE_TYP,
45 signRelayJwt,
46 verifyRelayJwt,
47} from "@t3tools/shared/relayJwt";
48import { isSecureRelayUrl } from "@t3tools/shared/relayUrl";
49import * as Context from "effect/Context";
50import * as Crypto from "effect/Crypto";
51import * as DateTime from "effect/DateTime";
52import * as Duration from "effect/Duration";
53import * as Effect from "effect/Effect";
54import * as FileSystem from "effect/FileSystem";
55import * as Layer from "effect/Layer";
56import * as Option from "effect/Option";
57import * as Path from "effect/Path";
58import type * as PlatformError from "effect/PlatformError";
59import * as Schedule from "effect/Schedule";
60import * as Schema from "effect/Schema";
61import * as Semaphore from "effect/Semaphore";
62import {
63 HttpClient,
64 HttpClientRequest,
65 HttpClientResponse,
66 type HttpServerRequest,
67} from "effect/http";
68import type * as HttpClientError from "effect/http/HttpClientError";
69import * as HttpServer from "effect/http/HttpServer";
70
71import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";
72import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
73import * as ServerConfig from "../config.ts";
74import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
75import * as AgentAwarenessRelay from "../relay/AgentAwarenessRelay.ts";
76import { makeRelayEnvironmentClient } from "../relay/relayEnvironmentClient.ts";
77import {
78 type CliDesiredLinkMode,
79 readCliDesiredCloudLink,
80 readCliDesiredLinkMode,
81 setCliDesiredCloudLink,
82} from "./CliState.ts";
83import * as CliTokenManager from "./CliTokenManager.ts";
84import {
85 CLOUD_ENDPOINT_CONFIRMED_ORIGIN,
86 CLOUD_ENDPOINT_RUNTIME_CONFIG,
87 CLOUD_LINK_REVOKED,
88 CLOUD_LINKED_USER_ID,
89 CLOUD_MINT_PUBLIC_KEY,
90 decodeConfirmedOrigin,
91 decodeRuntimeConfig,
92 encodeConfirmedOriginJson,
93 encodeEndpointRuntimeConfigJson,
94 HOLD_WEBHOOKS_WHILE_OFFLINE_SECRET,
95 PUBLISH_AGENT_ACTIVITY_SECRET,
96 readRelayConnection,
97 RELAY_ENVIRONMENT_CREDENTIAL_SECRET,
98 RELAY_ISSUER_SECRET,
99 RELAY_URL_SECRET,
100} from "./config.ts";
101import { getOrCreateEnvironmentKeyPairFromSecretStore } from "./environmentKeys.ts";
102import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts";
103import { relayUrlConfig } from "./publicConfig.ts";
104import {
105 filterRelayResponse,
106 relayRequestError,
107 type RelayRequestError,
108 shouldRetryRelayRequest,
109} from "./relayResponse.ts";
110import {
111 hasBoundedCloudProofLifetime,
112 hasExactScope,
113 hasForwardedAuthorityHeaders,
114 isAllowedEndpointOrigin,
115 isSupportedLinkProviderKind,
116 linkProofScopes,
117 managedEndpointRuntimeConfigsMatch,
118 parseManagedEndpointLocalOrigin,
119 requestAbsoluteUrl,
120} from "./linkChecks.ts";
121import { desktopUpdateRestartPending, pendingUpdateHandoffExists } from "./updateHandoff.ts";
122
123const CLOUD_MINT_NONCE_PREFIX = "cloud-mint-nonce-";
124const CLOUD_MINT_JTI_PREFIX = "cloud-mint-jti-";
125const CLOUD_HEALTH_NONCE_PREFIX = "cloud-health-nonce-";
126const CLOUD_HEALTH_JTI_PREFIX = "cloud-health-jti-";
127/** Secret store name prefixes of cloud replay markers. The server prunes expired ones. */
128export const CLOUD_REPLAY_MARKER_PREFIXES = [
129 CLOUD_MINT_NONCE_PREFIX,
130 CLOUD_MINT_JTI_PREFIX,
131 CLOUD_HEALTH_NONCE_PREFIX,
132 CLOUD_HEALTH_JTI_PREFIX,
133] as const;
134const MANAGED_ENDPOINT_PROVISION_REQUEST_TIMEOUT = Duration.minutes(2);
135const RELAY_CONFIG_FIELD_MESSAGES = {
136 relayUrl: "Relay URL must be a secure absolute HTTPS URL.",
137 relayIssuer: "Relay issuer must be a secure absolute HTTPS URL.",
138 environmentCredential: "Relay environment credential is required.",
139 cloudUserId: "Cloud user id is required.",
140 cloudMintPublicKey: "Cloud mint public key must be a valid Ed25519 public key.",
141} as const;
142
143/** The relay sent a link configuration this environment cannot install. */
144export class CloudLinkRelayConfigInvalidError extends Schema.TaggedError<CloudLinkRelayConfigInvalidError>()(
145 "CloudLinkRelayConfigInvalidError",
146 {
147 field: Schema.Literals([
148 "relayUrl",
149 "relayIssuer",
150 "environmentCredential",
151 "cloudUserId",
152 "cloudMintPublicKey",
153 ]),
154 },
155) {
156 override get message(): string {
157 return RELAY_CONFIG_FIELD_MESSAGES[this.field];
158 }
159}
160
161/**
162 * A link was asked to point at an origin it may not serve: a link proof request
163 * that did not reach this server directly on loopback (`endpoint`), or a local
164 * origin that is not a bare http(s) origin (`local`).
165 */
166export class CloudLinkOriginInvalidError extends Schema.TaggedError<CloudLinkOriginInvalidError>()(
167 "CloudLinkOriginInvalidError",
168 { origin: Schema.Literals(["endpoint", "local"]) },
169) {
170 override get message(): string {
171 return this.origin === "endpoint"
172 ? "Invalid managed endpoint origin."
173 : "Could not resolve local environment origin.";
174 }
175}
176
177export class CloudLinkNotLinkedError extends Schema.TaggedError<CloudLinkNotLinkedError>()(
178 "CloudLinkNotLinkedError",
179 {},
180) {
181 override get message(): string {
182 return "Link this environment to T3 Connect first.";
183 }
184}
185
186export class CloudLinkAccountMismatchError extends Schema.TaggedError<CloudLinkAccountMismatchError>()(
187 "CloudLinkAccountMismatchError",
188 {},
189) {
190 override get message(): string {
191 return "This environment is already linked to a different cloud account. Unlink it before switching accounts.";
192 }
193}
194
195/** Linking from the CLI needs the authorization `t3 connect link` stores. */
196export class CloudLinkAuthorizationMissingError extends Schema.TaggedError<CloudLinkAuthorizationMissingError>()(
197 "CloudLinkAuthorizationMissingError",
198 {},
199) {
200 override get message(): string {
201 return "Run `t3 connect link` to authorize this environment.";
202 }
203}
204
205const SignedRelayRequest = Schema.Literals(["health", "mint"]);
206
207/** A signed relay request whose proof does not verify for this environment. */
208export class CloudLinkProofRejectedError extends Schema.TaggedError<CloudLinkProofRejectedError>()(
209 "CloudLinkProofRejectedError",
210 { request: SignedRelayRequest },
211) {
212 override get message(): string {
213 return `Invalid cloud ${this.request} request.`;
214 }
215}
216
217/** A signed relay request whose proof was already used once. */
218export class CloudLinkProofReplayedError extends Schema.TaggedError<CloudLinkProofReplayedError>()(
219 "CloudLinkProofReplayedError",
220 { request: SignedRelayRequest },
221) {
222 override get message(): string {
223 return `Cloud ${this.request} request was already consumed.`;
224 }
225}
226
227/** The stored tunnel changed while its registration was in flight. */
228export class CloudLinkTunnelSupersededError extends Schema.TaggedError<CloudLinkTunnelSupersededError>()(
229 "CloudLinkTunnelSupersededError",
230 {},
231) {
232 override get message(): string {
233 return "The managed tunnel configuration changed during registration.";
234 }
235}
236
237/**
238 * The managed tunnel is not serving: its connector did not start
239 * (`runtime-not-started`, with the runtime's status), or the relay never
240 * confirmed this server's origin (`origin-unconfirmed`).
241 */
242export class CloudLinkEndpointUnavailableError extends Schema.TaggedError<CloudLinkEndpointUnavailableError>()(
243 "CloudLinkEndpointUnavailableError",
244 {
245 reason: Schema.Literals(["runtime-not-started", "origin-unconfirmed"]),
246 endpointRuntimeStatus: Schema.Unknown,
247 },
248) {
249 override get message(): string {
250 return this.reason === "runtime-not-started"
251 ? "Managed endpoint runtime could not be started."
252 : "Managed endpoint origin could not be confirmed.";
253 }
254}
255
256const INTERNAL_OPERATION_MESSAGES = {
257 "relay-url-unconfigured":
258 "T3CODE_RELAY_URL must be configured as a secure absolute HTTPS origin.",
259 "generate-link-proof": "Could not generate environment link proof.",
260 "persist-relay-config": "Could not persist environment relay configuration.",
261 "register-endpoint-origin": "Could not register the managed endpoint origin.",
262 "resolve-server-origin": "Could not resolve the local server origin.",
263 "persist-desired-link": "Could not persist desired T3 Connect link state.",
264 "sign-recovery-proof": "Could not sign the managed tunnel recovery request.",
265 "unsupported-recovered-tunnel":
266 "T3 Connect returned an unsupported managed tunnel configuration.",
267 "persist-recovered-tunnel": "Could not persist the recovered managed tunnel configuration.",
268 "read-relay-config": "Could not read environment relay configuration.",
269 "remove-relay-config": "Could not remove environment relay configuration.",
270 "update-webhook-settings": "Could not update T3 Connect webhook settings.",
271 "read-preferences": "Could not read environment cloud preferences.",
272 "persist-preferences": "Could not persist environment cloud preferences.",
273 "answer-health": "Could not answer cloud health request.",
274 "issue-credential": "Could not issue cloud connection credential.",
275} as const;
276
277/** A link step failed on this machine: storage, signing, or an unexpected relay answer. */
278export class CloudLinkInternalError extends Schema.TaggedError<CloudLinkInternalError>()(
279 "CloudLinkInternalError",
280 {
281 operation: Schema.Literals(
282 Object.keys(INTERNAL_OPERATION_MESSAGES) as [
283 keyof typeof INTERNAL_OPERATION_MESSAGES,
284 ...Array<keyof typeof INTERNAL_OPERATION_MESSAGES>,
285 ],
286 ),
287 cause: Schema.optional(Schema.Defect()),
288 },
289) {
290 override get message(): string {
291 return INTERNAL_OPERATION_MESSAGES[this.operation];
292 }
293}
294
295const internalError =
296 (operation: CloudLinkInternalError["operation"]) =>
297 (cause: unknown): Effect.Effect<never, CloudLinkInternalError> =>
298 Effect.fail(new CloudLinkInternalError({ operation, cause }));
299
300const isPermanentLinkError = Schema.is(
301 Schema.Union([
302 CloudLinkRelayConfigInvalidError,
303 CloudLinkOriginInvalidError,
304 CloudLinkNotLinkedError,
305 CloudLinkAccountMismatchError,
306 CloudLinkAuthorizationMissingError,
307 CloudLinkProofRejectedError,
308 CloudLinkProofReplayedError,
309 CloudLinkTunnelSupersededError,
310 ]),
311);
312
313/** Whether a failed link step may succeed on retry: not when the relay or this server refused it. */
314export const shouldRetryCloudLink = (error: unknown): boolean =>
315 shouldRetryRelayRequest(error) && !isPermanentLinkError(error);
316
317/** A failed rollback leaves a setting changed; it is logged, not hidden. */
318const rollbackFailed = (cause: unknown) =>
319 Effect.logWarning("Could not roll back a T3 Connect preference", { cause });
320
321const requireRelayUrl = relayUrlConfig.pipe(
322 Effect.mapError(
323 (cause) => new CloudLinkInternalError({ operation: "relay-url-unconfigured", cause }),
324 ),
325);
326
327function bytesToString(bytes: Uint8Array): string {
328 return new TextDecoder().decode(bytes);
329}
330
331function stringToBytes(value: string): Uint8Array {
332 return new TextEncoder().encode(value);
333}
334
335/** Records each one-shot marker; false when any already existed, so the request is a replay. */
336export function consumeCloudReplayGuards(input: {
337 readonly secrets: ServerSecretStore.ServerSecretStore["Service"];
338 readonly names: ReadonlyArray<string>;
339 readonly value: Uint8Array;
340}) {
341 return Effect.forEach(
342 input.names,
343 (name) =>
344 input.secrets.create(name, input.value).pipe(
345 Effect.as(true),
346 Effect.catchIf(ServerSecretStore.isSecretStoreError, (error) =>
347 ServerSecretStore.isSecretAlreadyExistsError(error)
348 ? Effect.succeed(false)
349 : Effect.fail(error),
350 ),
351 ),
352 { concurrency: input.names.length },
353 ).pipe(Effect.map((created) => created.every(Boolean)));
354}
355
356function validateCloudMintPublicKey(
357 publicKey: string,
358): Effect.Effect<void, CloudLinkRelayConfigInvalidError> {
359 const invalid = new CloudLinkRelayConfigInvalidError({ field: "cloudMintPublicKey" });
360 return Effect.try({
361 try: () => NodeCrypto.createPublicKey(publicKey.replace(/\\n/g, "\n")),
362 catch: () => invalid,
363 }).pipe(
364 Effect.flatMap((key) =>
365 key.asymmetricKeyType === "ed25519" ? Effect.void : Effect.fail(invalid),
366 ),
367 );
368}
369
370function invalidRelayConfigField(
371 payload: RelayEnvironmentConfigRequest,
372): CloudLinkRelayConfigInvalidError["field"] | null {
373 if (!isSecureRelayUrl(payload.relayUrl)) return "relayUrl";
374 if (payload.relayIssuer !== undefined && !isSecureRelayUrl(payload.relayIssuer)) {
375 return "relayIssuer";
376 }
377 if (payload.environmentCredential.trim().length === 0) return "environmentCredential";
378 if (payload.cloudUserId.trim().length === 0) return "cloudUserId";
379 return null;
380}
381
382function validateRelayConfigPayload(
383 payload: RelayEnvironmentConfigRequest,
384): Effect.Effect<void, CloudLinkRelayConfigInvalidError> {
385 const field = invalidRelayConfigField(payload);
386 return field === null
387 ? Effect.void
388 : Effect.fail(new CloudLinkRelayConfigInvalidError({ field }));
389}
390
391function normalizePemForSignedPayload(value: string): string {
392 return value.trim();
393}
394
395const decodeCloudHealthProof = Schema.decodeUnknownEffect(RelayCloudEnvironmentHealthProofPayload);
396const decodeCloudMintProof = Schema.decodeUnknownEffect(RelayCloudMintCredentialProofPayload);
397
398type ManagedTunnelRecoveryProofInput = {
399 readonly environmentId: RelayManagedEndpointRecoveryProofPayload["environmentId"];
400 readonly cloudUserId: string;
401 readonly relayUrl: string;
402} & (
403 | {
404 readonly action: "register";
405 readonly tunnelId: string;
406 readonly origin: RelayManagedEndpointOrigin;
407 }
408 | { readonly action: "recover"; readonly origin: RelayManagedEndpointOrigin }
409);
410
411/** Every failure CloudLink constructs itself. */
412export type CloudLinkError =
413 | CloudLinkAccountMismatchError
414 | CloudLinkAuthorizationMissingError
415 | CloudLinkEndpointUnavailableError
416 | CloudLinkInternalError
417 | CloudLinkNotLinkedError
418 | CloudLinkOriginInvalidError
419 | CloudLinkProofRejectedError
420 | CloudLinkProofReplayedError
421 | CloudLinkRelayConfigInvalidError
422 | CloudLinkTunnelSupersededError;
423
424/** Failures of the link work that talks to the relay: linking, recovery and shutdown. */
425type CloudLinkBackgroundError =
426 | CloudLinkEndpointUnavailableError
427 | CloudLinkInternalError
428 | CloudLinkOriginInvalidError
429 | RelayRequestError
430 | ServerSecretStore.SecretStoreError
431 | Schema.SchemaError
432 | PlatformError.PlatformError;
433
434// "revoked" means the relay rejected this environment's credential, as after
435// the environment is deregistered from T3 Connect.
436type ManagedTunnelRegistration =
437 | { readonly status: "not_linked" | "superseded" | "revoked" }
438 | { readonly status: "recovery_required"; readonly config: RelayManagedEndpointRuntimeConfig }
439 | {
440 readonly status: "ready";
441 readonly endpointRuntimeStatus: ManagedEndpointRuntime.CloudManagedEndpointRuntimeStatus;
442 };
443
444export class CloudLink extends Context.Service<
445 CloudLink,
446 {
447 /**
448 * Signs the proof the relay asks for to link this environment. The request
449 * must reach this server directly on the loopback origin it names.
450 */
451 readonly linkProof: (
452 request: RelayLinkProofRequest,
453 httpRequest: HttpServerRequest.HttpServerRequest,
454 ) => Effect.Effect<
455 RelayEnvironmentLinkProof,
456 CloudLinkOriginInvalidError | CloudLinkInternalError | EnvironmentAuth.ServerAuthInternalError
457 >;
458 /** Installs the link the relay returned and, for a managed tunnel, confirms its origin. */
459 readonly applyRelayConfig: (
460 payload: RelayEnvironmentConfigRequest,
461 ) => Effect.Effect<
462 EnvironmentCloudRelayConfigResult,
463 | CloudLinkAccountMismatchError
464 | CloudLinkEndpointUnavailableError
465 | CloudLinkInternalError
466 | CloudLinkOriginInvalidError
467 | CloudLinkRelayConfigInvalidError
468 | CloudLinkTunnelSupersededError
469 | EnvironmentAuth.ServerAuthInternalError
470 | RelayRequestError
471 >;
472 readonly linkState: () => Effect.Effect<
473 EnvironmentCloudLinkStateResult,
474 CloudLinkInternalError
475 >;
476 /** Stops the tunnel and forgets the link, including the CLI's wish to keep it. */
477 readonly unlink: () => Effect.Effect<EnvironmentCloudRelayConfigResult, CloudLinkInternalError>;
478 /**
479 * Saves this environment's T3 Connect preferences, all or nothing, and
480 * returns the link state. The activity setting is saved first. Holding
481 * webhooks while offline is decided by the relay, so the relay is told
482 * before the local copy is saved. If either step fails, the activity
483 * setting is put back, and so is the relay when only the local save failed.
484 */
485 readonly updatePreferences: (
486 input: EnvironmentCloudPreferencesRequest,
487 ) => Effect.Effect<
488 EnvironmentCloudLinkStateResult,
489 CloudLinkNotLinkedError | CloudLinkInternalError
490 >;
491 /** Answers the relay's signed health check once per proof. */
492 readonly answerHealthRequest: (
493 request: RelayCloudEnvironmentHealthRequest,
494 ) => Effect.Effect<
495 RelayEnvironmentHealthResponse,
496 | CloudLinkProofRejectedError
497 | CloudLinkProofReplayedError
498 | CloudLinkInternalError
499 | EnvironmentAuth.ServerAuthInternalError
500 >;
501 /** Issues a short-lived pairing credential for a client the relay vouched for, once per proof. */
502 readonly mintCredential: (
503 request: RelayCloudMintCredentialRequest,
504 ) => Effect.Effect<
505 RelayEnvironmentMintResponse,
506 | CloudLinkProofRejectedError
507 | CloudLinkProofReplayedError
508 | CloudLinkInternalError
509 | EnvironmentAuth.ServerAuthInternalError
510 >;
511 /** Links this environment with the stored CLI authorization and records the CLI's wish. */
512 readonly reconcileDesiredLink: (
513 localOrigin: string,
514 ) => Effect.Effect<
515 CliDesiredLinkMode,
516 | CloudLinkBackgroundError
517 | CloudLinkAccountMismatchError
518 | CloudLinkAuthorizationMissingError
519 | CloudLinkRelayConfigInvalidError
520 | EnvironmentAuth.ServerAuthInternalError
521 | CliTokenManager.CloudCliTokenManagerError
522 >;
523 /** As `reconcileDesiredLink`, but returns null when the CLI no longer wants a link. */
524 readonly reconcileDesiredLinkIfStillDesired: (
525 localOrigin: string,
526 ) => Effect.Effect<
527 CliDesiredLinkMode | null,
528 | CloudLinkBackgroundError
529 | CloudLinkAccountMismatchError
530 | CloudLinkAuthorizationMissingError
531 | CloudLinkRelayConfigInvalidError
532 | EnvironmentAuth.ServerAuthInternalError
533 | CliTokenManager.CloudCliTokenManagerError
534 >;
535 /** Tells the relay which local origin the stored tunnel serves, then starts it. */
536 readonly registerManagedTunnelRecovery: (
537 localOrigin: string,
538 options?: { readonly retryRuntimeFailures?: boolean },
539 ) => Effect.Effect<ManagedTunnelRegistration, CloudLinkBackgroundError>;
540 /** Asks the relay for a replacement tunnel, unless the stored one changed meanwhile. */
541 readonly recoverManagedTunnel: (
542 localOrigin: string,
543 expectedConfig?: RelayManagedEndpointRuntimeConfig,
544 options?: { readonly retryRuntimeFailures?: boolean },
545 ) => Effect.Effect<boolean, CloudLinkBackgroundError>;
546 /**
547 * Starts the stored tunnel. By default only one the relay already confirmed
548 * on this origin; startup falls back to any stored tunnel when the relay
549 * stays unreachable.
550 */
551 readonly startManagedTunnelIfOriginConfirmed: (
552 localOrigin: string,
553 options?: { readonly requireConfirmedOrigin?: boolean },
554 ) => Effect.Effect<
555 boolean,
556 | CloudLinkEndpointUnavailableError
557 | CloudLinkOriginInvalidError
558 | ServerSecretStore.SecretStoreError
559 >;
560 /** Deletes a CLI-managed tunnel when this server goes offline for good. */
561 readonly releaseManagedTunnelOnShutdown: () => Effect.Effect<
562 boolean,
563 | CloudLinkBackgroundError
564 | CliTokenManager.CloudCliTokenManagerError
565 | HttpClientError.HttpClientError
566 >;
567 }
568>()("t3/cloud/CloudLink") {}
569
570const make = Effect.gen(function* () {
571 const secrets = yield* ServerSecretStore.ServerSecretStore;
572 const environment = yield* ServerEnvironment.ServerEnvironment;
573 const endpointRuntime = yield* ManagedEndpointRuntime.CloudManagedEndpointRuntime;
574 const environmentAuth = yield* EnvironmentAuth.EnvironmentAuth;
575 const cliTokenManager = yield* CliTokenManager.CloudCliTokenManager;
576 const httpClient = yield* HttpClient.HttpClient;
577 const httpServer = yield* HttpServer.HttpServer;
578 const awarenessRelay = yield* AgentAwarenessRelay.AgentAwarenessRelay;
579 const crypto = yield* Crypto.Crypto;
580 const config = yield* ServerConfig.ServerConfig;
581 const fileSystem = yield* FileSystem.FileSystem;
582 const path = yield* Path.Path;
583
584 const withSecrets = Effect.provideService(ServerSecretStore.ServerSecretStore, secrets);
585 const withRuntimeFiles = <A, E>(
586 effect: Effect.Effect<A, E, ServerConfig.ServerConfig | FileSystem.FileSystem | Path.Path>,
587 ) =>
588 effect.pipe(
589 Effect.provideService(ServerConfig.ServerConfig, config),
590 Effect.provideService(FileSystem.FileSystem, fileSystem),
591 Effect.provideService(Path.Path, path),
592 );
593
594 const validateLinkedCloudUser = (
595 cloudUserId: string,
596 ): Effect.Effect<void, EnvironmentAuth.ServerAuthInternalError | CloudLinkAccountMismatchError> =>
597 secrets.get(CLOUD_LINKED_USER_ID).pipe(
598 Effect.mapError(
599 (cause) =>
600 new EnvironmentAuth.ServerAuthLinkedCloudAccountVerificationError({
601 cause,
602 }),
603 ),
604 Effect.flatMap((existing) => {
605 if (Option.isNone(existing)) {
606 return Effect.void;
607 }
608 const existingCloudUserId = bytesToString(existing.value);
609 return existingCloudUserId === cloudUserId
610 ? Effect.void
611 : Effect.fail(new CloudLinkAccountMismatchError({}));
612 }),
613 );
614
615 const readInstalledCloudUserId: Effect.Effect<string, EnvironmentAuth.ServerAuthInternalError> =
616 secrets.get(CLOUD_LINKED_USER_ID).pipe(
617 Effect.mapError(
618 (cause) =>
619 new EnvironmentAuth.ServerAuthLinkedCloudAccountReadError({
620 cause,
621 }),
622 ),
623 Effect.flatMap((bytes) =>
624 Option.isSome(bytes)
625 ? Effect.succeed(bytesToString(bytes.value))
626 : Effect.fail(new EnvironmentAuth.ServerAuthLinkedCloudAccountMissingError({})),
627 ),
628 );
629
630 const makeCloudLinkProof = Effect.fn("environment.cloud.makeLinkProof")(function* (
631 request: RelayLinkProofRequest,
632 requestUrl: string,
633 ) {
634 const keyPair = yield* getOrCreateEnvironmentKeyPairFromSecretStore(secrets);
635 if (
636 !isSupportedLinkProviderKind(request) ||
637 !isAllowedEndpointOrigin({
638 origin: request.origin,
639 requestUrl,
640 })
641 ) {
642 return yield* new CloudLinkOriginInvalidError({ origin: "endpoint" });
643 }
644 const now = yield* DateTime.now;
645 const expiresAt = DateTime.add(now, { minutes: 5 });
646 const nowSeconds = Math.floor(now.epochMilliseconds / 1_000);
647 const descriptor = yield* environment.getDescriptor;
648 const payload = {
649 iss: `t3-env:${descriptor.environmentId}`,
650 aud: normalizeRelayIssuer(request.relayIssuer),
651 sub: descriptor.environmentId,
652 jti: yield* crypto.randomUUIDv4(),
653 iat: nowSeconds,
654 exp: Math.floor(expiresAt.epochMilliseconds / 1_000),
655 challenge: request.challenge,
656 descriptor,
657 environmentId: descriptor.environmentId,
658 environmentPublicKey: normalizePemForSignedPayload(keyPair.publicKey),
659 endpoint: request.endpoint,
660 origin: request.origin,
661 scopes: linkProofScopes(request),
662 } satisfies RelayEnvironmentLinkProofPayload;
663 return yield* signRelayJwt({
664 privateKey: keyPair.privateKey,
665 typ: RELAY_LINK_PROOF_TYP,
666 payload,
667 }).pipe(
668 Effect.mapError(
669 (cause) =>
670 new EnvironmentAuth.ServerAuthCloudLinkJwtSigningError({
671 cause,
672 }),
673 ),
674 );
675 });
676
677 const linkProof = Effect.fn("environment.cloud.linkProof")(
678 function* (request: RelayLinkProofRequest, httpRequest: HttpServerRequest.HttpServerRequest) {
679 const requestUrl = requestAbsoluteUrl(httpRequest);
680 if (requestUrl === null || hasForwardedAuthorityHeaders(httpRequest)) {
681 return yield* new CloudLinkOriginInvalidError({ origin: "endpoint" });
682 }
683 const proof = yield* makeCloudLinkProof(request, requestUrl);
684 return proof satisfies RelayEnvironmentLinkProof;
685 },
686 Effect.catchIf(ServerSecretStore.isSecretStoreError, internalError("generate-link-proof")),
687 Effect.catchTags({ PlatformError: internalError("generate-link-proof") }),
688 );
689
690 const activateManagedTunnel = Effect.fn("environment.cloud.activateManagedTunnel")(
691 function* (input: {
692 readonly config: RelayManagedEndpointRuntimeConfig;
693 readonly configJson: string;
694 readonly origin: RelayManagedEndpointOrigin;
695 }) {
696 return yield* endpointRuntime.withLinkStateLock(
697 Effect.gen(function* () {
698 const currentConfig = yield* secrets.get(CLOUD_ENDPOINT_RUNTIME_CONFIG);
699 if (
700 Option.isNone(currentConfig) ||
701 bytesToString(currentConfig.value) !== input.configJson
702 ) {
703 return null;
704 }
705 const status = yield* endpointRuntime.applyConfig(input.config);
706 if (status.status !== "running") {
707 return yield* new CloudLinkEndpointUnavailableError({
708 reason: "runtime-not-started",
709 endpointRuntimeStatus: status,
710 });
711 }
712 const marker = yield* encodeConfirmedOriginJson({
713 config: input.config,
714 origin: input.origin,
715 });
716 yield* secrets.set(CLOUD_ENDPOINT_CONFIRMED_ORIGIN, stringToBytes(marker));
717 return status;
718 }),
719 );
720 },
721 );
722
723 const activateManagedTunnelWithRetry = (
724 input: {
725 readonly config: RelayManagedEndpointRuntimeConfig;
726 readonly configJson: string;
727 readonly origin: RelayManagedEndpointOrigin;
728 },
729 retryRuntimeFailures: boolean,
730 ) => {
731 const activate = activateManagedTunnel(input);
732 return retryRuntimeFailures
733 ? activate.pipe(
734 Effect.retry({
735 while: (error) =>
736 error._tag === "CloudLinkEndpointUnavailableError" &&
737 ManagedEndpointRuntime.isRetryableManagedEndpointRuntimeStatus(
738 error.endpointRuntimeStatus,
739 ),
740 schedule: Schedule.exponential("1 second").pipe(
741 Schedule.modifyDelay(({ duration }) =>
742 Effect.succeed(Duration.min(duration, Duration.seconds(30))),
743 ),
744 Schedule.jittered,
745 ),
746 }),
747 )
748 : activate;
749 };
750
751 const startManagedTunnelIfOriginConfirmed = Effect.fn(
752 "environment.cloud.startManagedCloudTunnelIfOriginConfirmed",
753 )(function* (localOrigin: string, options?: { readonly requireConfirmedOrigin?: boolean }) {
754 const requireConfirmedOrigin = options?.requireConfirmedOrigin ?? true;
755 const parsedOrigin = yield* Effect.try({
756 try: () => parseManagedEndpointLocalOrigin(localOrigin),
757 catch: () => new CloudLinkOriginInvalidError({ origin: "local" }),
758 });
759 return yield* endpointRuntime.withLinkStateLock(
760 Effect.gen(function* () {
761 const [runtimeBytes, markerBytes] = yield* Effect.all([
762 secrets.get(CLOUD_ENDPOINT_RUNTIME_CONFIG),
763 secrets.get(CLOUD_ENDPOINT_CONFIRMED_ORIGIN),
764 ]);
765 if (Option.isNone(runtimeBytes)) return false;
766 const config = Option.getOrNull(decodeRuntimeConfig(bytesToString(runtimeBytes.value)));
767 if (config === null || config.providerKind !== "cloudflare_tunnel") return false;
768 // With the marker required, only a config the relay already confirmed on
769 // this port may start. Without it, startup is falling back after the
770 // relay stayed unreachable: an unconfirmed origin may send traffic to a
771 // stale port, but that beats no remote access at all.
772 if (requireConfirmedOrigin) {
773 if (Option.isNone(markerBytes)) return false;
774 const marker = Option.getOrNull(decodeConfirmedOrigin(bytesToString(markerBytes.value)));
775 if (
776 marker === null ||
777 !managedEndpointRuntimeConfigsMatch(marker.config, config) ||
778 marker.origin.localHttpHost !== parsedOrigin.origin.localHttpHost ||
779 marker.origin.localHttpPort !== parsedOrigin.origin.localHttpPort
780 ) {
781 return false;
782 }
783 }
784 const status = yield* endpointRuntime.applyConfig(config);
785 if (status.status !== "running") {
786 return yield* new CloudLinkEndpointUnavailableError({
787 reason: "runtime-not-started",
788 endpointRuntimeStatus: status,
789 });
790 }
791 return true;
792 }),
793 );
794 });
795
796 const applyCloudRelayConfig = Effect.fn("environment.cloud.applyRelayConfig")(function* (
797 payload: RelayEnvironmentConfigRequest,
798 options?: {
799 readonly lockHeld?: boolean;
800 readonly confirmedOrigin?: RelayManagedEndpointOrigin;
801 },
802 ) {
803 const apply = Effect.gen(function* () {
804 yield* validateRelayConfigPayload(payload);
805 yield* validateLinkedCloudUser(payload.cloudUserId);
806 yield* validateCloudMintPublicKey(payload.cloudMintPublicKey);
807 // Reject unsupported runtimes before touching the connector so a bad
808 // payload cannot stop a healthy tunnel on its way to a 503.
809 if (
810 payload.endpointRuntime !== null &&
811 payload.endpointRuntime.providerKind !== "cloudflare_tunnel"
812 ) {
813 return yield* new CloudLinkEndpointUnavailableError({
814 reason: "runtime-not-started",
815 endpointRuntimeStatus: {
816 status: "unsupported",
817 providerKind: payload.endpointRuntime.providerKind,
818 },
819 });
820 }
821 const endpointRuntimeJson = payload.endpointRuntime
822 ? yield* encodeEndpointRuntimeConfigJson(payload.endpointRuntime)
823 : null;
824 yield* endpointRuntime.applyConfig(null);
825 yield* secrets.remove(CLOUD_ENDPOINT_CONFIRMED_ORIGIN);
826 // The relay has already committed this link. The stored runtime config
827 // is what reports the link as managed and what startup respawns, so drop
828 // it first and store it last. A write that fails part-way then leaves a
829 // link that reads as not managed, and the next relink repairs it instead
830 // of trusting a stale tunnel config.
831 yield* secrets.remove(CLOUD_ENDPOINT_RUNTIME_CONFIG);
832
833 yield* secrets.set(RELAY_URL_SECRET, stringToBytes(payload.relayUrl));
834 yield* secrets.set(
835 RELAY_ISSUER_SECRET,
836 stringToBytes(payload.relayIssuer ?? payload.relayUrl),
837 );
838 yield* secrets.set(CLOUD_LINKED_USER_ID, stringToBytes(payload.cloudUserId));
839 yield* secrets.set(
840 RELAY_ENVIRONMENT_CREDENTIAL_SECRET,
841 stringToBytes(payload.environmentCredential),
842 );
843 yield* secrets.remove(CLOUD_LINK_REVOKED);
844 yield* secrets.set(CLOUD_MINT_PUBLIC_KEY, stringToBytes(payload.cloudMintPublicKey));
845 yield* awarenessRelay.requestCatchUp();
846 if (endpointRuntimeJson !== null) {
847 yield* secrets.set(CLOUD_ENDPOINT_RUNTIME_CONFIG, stringToBytes(endpointRuntimeJson));
848 }
849 if (payload.endpointRuntime === null || options?.confirmedOrigin === undefined) {
850 return {
851 ok: true,
852 endpointRuntimeStatus: { status: "disabled" },
853 } satisfies EnvironmentCloudRelayConfigResult;
854 }
855 const endpointRuntimeStatus = yield* endpointRuntime.applyConfig(payload.endpointRuntime);
856 if (endpointRuntimeStatus.status !== "running") {
857 return yield* new CloudLinkEndpointUnavailableError({
858 reason: "runtime-not-started",
859 endpointRuntimeStatus,
860 });
861 }
862 const marker = yield* encodeConfirmedOriginJson({
863 config: payload.endpointRuntime,
864 origin: options.confirmedOrigin,
865 });
866 yield* secrets.set(CLOUD_ENDPOINT_CONFIRMED_ORIGIN, stringToBytes(marker));
867 return { ok: true, endpointRuntimeStatus } satisfies EnvironmentCloudRelayConfigResult;
868 });
869 return yield* options?.lockHeld ? apply : endpointRuntime.withLinkStateLock(apply);
870 });
871
872 const relayClientRequest = <A>(input: {
873 readonly url: string;
874 readonly token: string;
875 readonly payload: unknown;
876 readonly schema: Schema.Decoder<A>;
877 readonly timeout?: Duration.Input;
878 }) =>
879 HttpClientRequest.post(input.url).pipe(
880 HttpClientRequest.bearerToken(input.token),
881 HttpClientRequest.bodyJson(input.payload),
882 Effect.flatMap(httpClient.execute),
883 Effect.flatMap(filterRelayResponse),
884 Effect.flatMap(HttpClientResponse.schemaBodyJson(input.schema)),
885 Effect.timeout(input.timeout ?? "10 seconds"),
886 Effect.mapError(relayRequestError),
887 );
888
889 const reconcileDesiredCloudLinkWith = Effect.fn("environment.cloud.reconcileDesiredLinkWith")(
890 function* (localOrigin: string) {
891 const parsedOrigin = yield* Effect.try({
892 try: () => parseManagedEndpointLocalOrigin(localOrigin),
893 catch: () => new CloudLinkOriginInvalidError({ origin: "local" }),
894 });
895 const token = yield* cliTokenManager.getExisting.pipe(
896 Effect.flatMap(
897 Option.match({
898 onNone: () => Effect.fail(new CloudLinkAuthorizationMissingError({})),
899 onSome: Effect.succeed,
900 }),
901 ),
902 );
903 const mode = yield* readCliDesiredLinkMode.pipe(withSecrets);
904 const managedTunnelsEnabled = mode !== "publish_only";
905 const relayUrl = yield* requireRelayUrl;
906 const challenge = yield* relayClientRequest({
907 url: `${relayUrl}/v1/client/environment-link-challenges`,
908 token: token.accessToken,
909 payload: {
910 notificationsEnabled: true,
911 liveActivitiesEnabled: true,
912 managedTunnelsEnabled,
913 },
914 schema: RelayEnvironmentLinkChallengeResponse,
915 });
916 const proof = yield* makeCloudLinkProof(
917 {
918 challenge: challenge.challenge,
919 relayIssuer: relayUrl,
920 endpoint: {
921 httpBaseUrl: parsedOrigin.httpBaseUrl,
922 wsBaseUrl: parsedOrigin.wsBaseUrl,
923 providerKind: managedTunnelsEnabled ? "cloudflare_tunnel" : "manual",
924 },
925 origin: parsedOrigin.origin,
926 },
927 parsedOrigin.httpBaseUrl,
928 );
929 const link = yield* relayClientRequest({
930 url: `${relayUrl}/v1/client/environment-links`,
931 token: token.accessToken,
932 payload: {
933 proof,
934 notificationsEnabled: true,
935 liveActivitiesEnabled: true,
936 managedTunnelsEnabled,
937 },
938 schema: RelayEnvironmentLinkResponse,
939 timeout: MANAGED_ENDPOINT_PROVISION_REQUEST_TIMEOUT,
940 });
941 yield* setCliDesiredCloudLink(true, mode).pipe(withSecrets);
942 yield* applyCloudRelayConfig(
943 {
944 relayUrl,
945 relayIssuer: link.relayIssuer,
946 cloudUserId: link.cloudUserId,
947 environmentCredential: link.environmentCredential,
948 cloudMintPublicKey: link.cloudMintPublicKey,
949 endpointRuntime: link.endpointRuntime,
950 },
951 {
952 lockHeld: true,
953 confirmedOrigin: parsedOrigin.origin,
954 },
955 );
956 // Callers decide on managed tunnel recovery from the mode this link
957 // actually used, not from a value read before the relay round trip.
958 return mode;
959 },
960 Effect.catchIf(ServerSecretStore.isSecretStoreError, internalError("persist-desired-link")),
961 );
962
963 const reconcileDesiredLink = Effect.fn("environment.cloud.reconcileDesiredLink")(function* (
964 localOrigin: string,
965 ) {
966 return yield* endpointRuntime.withLinkStateLock(reconcileDesiredCloudLinkWith(localOrigin));
967 });
968
969 const reconcileDesiredLinkIfStillDesired = Effect.fn(
970 "environment.cloud.reconcileDesiredLinkIfStillDesired",
971 )(function* (localOrigin: string) {
972 return yield* endpointRuntime.withLinkStateLock(
973 Effect.gen(function* () {
974 if (!(yield* readCliDesiredCloudLink.pipe(withSecrets))) {
975 return null;
976 }
977 return yield* reconcileDesiredCloudLinkWith(localOrigin);
978 }),
979 );
980 });
981
982 const makeManagedTunnelRecoveryProof = Effect.fn(
983 "environment.cloud.makeManagedTunnelRecoveryProof",
984 )(function* (input: ManagedTunnelRecoveryProofInput) {
985 const keyPair = yield* getOrCreateEnvironmentKeyPairFromSecretStore(secrets);
986 const configuredIssuer = yield* secrets.get(RELAY_ISSUER_SECRET);
987 const now = yield* DateTime.now;
988 const issuedAt = Math.floor(now.epochMilliseconds / 1_000);
989 const claims = {
990 iss: `t3-env:${input.environmentId}`,
991 aud: normalizeRelayIssuer(
992 Option.isSome(configuredIssuer) ? bytesToString(configuredIssuer.value) : input.relayUrl,
993 ),
994 sub: input.environmentId,
995 jti: yield* crypto.randomUUIDv4(),
996 iat: issuedAt,
997 exp: issuedAt + 60,
998 environmentId: input.environmentId,
999 cloudUserId: input.cloudUserId,
1000 };
1001 const payload =
1002 input.action === "register"
1003 ? {
1004 ...claims,
1005 action: "register" as const,
1006 tunnelId: input.tunnelId,
1007 origin: input.origin,
1008 }
1009 : { ...claims, action: "recover" as const, origin: input.origin };
1010
1011 return yield* signRelayJwt({
1012 privateKey: keyPair.privateKey,
1013 typ: RELAY_MANAGED_TUNNEL_RECOVERY_TYP,
1014 payload,
1015 }).pipe(
1016 Effect.mapError(
1017 (cause) => new CloudLinkInternalError({ operation: "sign-recovery-proof", cause }),
1018 ),
1019 );
1020 });
1021
1022 /**
1023 * Marks the link revoked after the relay answered 401 to `credential`, usually
1024 * because the environment was deregistered. Retrying cannot fix that, and
1025 * relinking is the user's call, so only mark it. Nothing stored is removed:
1026 * the relay also answers 401 for a skewed clock, and the next accepted
1027 * registration clears the mark. Returns false, without marking, when a relink
1028 * replaced the credential while the rejected request was in flight.
1029 */
1030 const markLinkRevoked = (credential: string, cause: RelayRequestError) =>
1031 endpointRuntime.withLinkStateLock(
1032 Effect.gen(function* () {
1033 const current = yield* secrets.get(RELAY_ENVIRONMENT_CREDENTIAL_SECRET);
1034 if (Option.isNone(current) || bytesToString(current.value) !== credential) {
1035 return false;
1036 }
1037 yield* Effect.logWarning(
1038 "T3 Connect no longer accepts this environment's link. Link it again to restore remote access.",
1039 { cause },
1040 );
1041 yield* secrets.set(CLOUD_LINK_REVOKED, stringToBytes("true"));
1042 return true;
1043 }),
1044 );
1045
1046 const registerManagedTunnelRecovery = Effect.fn(
1047 "environment.cloud.registerManagedCloudTunnelRecovery",
1048 )(function* (localOrigin: string, options?: { readonly retryRuntimeFailures?: boolean }) {
1049 const [runtimeConfig, relayUrl, cloudUserId, environmentCredential] = yield* Effect.all([
1050 secrets.get(CLOUD_ENDPOINT_RUNTIME_CONFIG),
1051 secrets.get(RELAY_URL_SECRET),
1052 secrets.get(CLOUD_LINKED_USER_ID),
1053 secrets.get(RELAY_ENVIRONMENT_CREDENTIAL_SECRET),
1054 ]);
1055 if (
1056 Option.isNone(runtimeConfig) ||
1057 Option.isNone(relayUrl) ||
1058 Option.isNone(cloudUserId) ||
1059 Option.isNone(environmentCredential)
1060 ) {
1061 return { status: "not_linked" as const };
1062 }
1063
1064 const config = Option.getOrNull(decodeRuntimeConfig(bytesToString(runtimeConfig.value)));
1065 if (config?.providerKind !== "cloudflare_tunnel") {
1066 return { status: "not_linked" as const };
1067 }
1068
1069 const parsedOrigin = yield* Effect.try({
1070 try: () => parseManagedEndpointLocalOrigin(localOrigin),
1071 catch: () => new CloudLinkOriginInvalidError({ origin: "local" }),
1072 });
1073 if (config.tunnelId === undefined) {
1074 return { status: "recovery_required" as const, config };
1075 }
1076 const origin = parsedOrigin.origin;
1077 const environmentId = yield* environment.getEnvironmentId;
1078 const relayUrlValue = bytesToString(relayUrl.value);
1079 const cloudUserIdValue = bytesToString(cloudUserId.value);
1080 const proof = yield* makeManagedTunnelRecoveryProof({
1081 action: "register",
1082 environmentId,
1083 cloudUserId: cloudUserIdValue,
1084 relayUrl: relayUrlValue,
1085 tunnelId: config.tunnelId,
1086 origin,
1087 });
1088 const credential = bytesToString(environmentCredential.value);
1089 const registered = yield* relayClientRequest({
1090 url: `${relayUrlValue}/v1/environments/${encodeURIComponent(environmentId)}/tunnel/recovery`,
1091 token: credential,
1092 payload: {
1093 cloudUserId: cloudUserIdValue,
1094 tunnelId: config.tunnelId,
1095 origin,
1096 proof,
1097 },
1098 schema: RelayManagedEndpointRecoveryRegistrationResponse,
1099 }).pipe(
1100 Effect.catchTags({
1101 RelayRequestError: (error) =>
1102 error.rejection === "unauthorized"
1103 ? markLinkRevoked(credential, error).pipe(
1104 Effect.map((marked) => (marked ? ("revoked" as const) : ("superseded" as const))),
1105 )
1106 : Effect.fail(error),
1107 }),
1108 );
1109 if (registered === "revoked" || registered === "superseded") {
1110 return { status: registered };
1111 }
1112 yield* secrets.remove(CLOUD_LINK_REVOKED);
1113 if (registered.status === "recovery_required") {
1114 return { status: registered.status, config };
1115 }
1116 const endpointRuntimeStatus = yield* activateManagedTunnelWithRetry(
1117 {
1118 config,
1119 configJson: bytesToString(runtimeConfig.value),
1120 origin,
1121 },
1122 options?.retryRuntimeFailures === true,
1123 );
1124 return endpointRuntimeStatus === null
1125 ? { status: "superseded" as const }
1126 : { status: "ready" as const, endpointRuntimeStatus };
1127 });
1128
1129 const recoverManagedTunnel = Effect.fn("environment.cloud.recoverManagedCloudTunnel")(function* (
1130 localOrigin: string,
1131 expectedConfig?: RelayManagedEndpointRuntimeConfig,
1132 options?: { readonly retryRuntimeFailures?: boolean },
1133 ) {
1134 const [runtimeConfig, relayUrl, cloudUserId, environmentCredential] = yield* Effect.all([
1135 secrets.get(CLOUD_ENDPOINT_RUNTIME_CONFIG),
1136 secrets.get(RELAY_URL_SECRET),
1137 secrets.get(CLOUD_LINKED_USER_ID),
1138 secrets.get(RELAY_ENVIRONMENT_CREDENTIAL_SECRET),
1139 ]);
1140 if (
1141 Option.isNone(runtimeConfig) ||
1142 Option.isNone(relayUrl) ||
1143 Option.isNone(cloudUserId) ||
1144 Option.isNone(environmentCredential)
1145 ) {
1146 return false;
1147 }
1148 if (expectedConfig !== undefined) {
1149 const current = Option.getOrNull(decodeRuntimeConfig(bytesToString(runtimeConfig.value)));
1150 if (
1151 current === null ||
1152 current.providerKind !== expectedConfig.providerKind ||
1153 current.connectorToken !== expectedConfig.connectorToken ||
1154 current.tunnelId !== expectedConfig.tunnelId ||
1155 current.tunnelName !== expectedConfig.tunnelName
1156 ) {
1157 return false;
1158 }
1159 }
1160
1161 const parsedOrigin = yield* Effect.try({
1162 try: () => parseManagedEndpointLocalOrigin(localOrigin),
1163 catch: () => new CloudLinkOriginInvalidError({ origin: "local" }),
1164 });
1165
1166 const environmentId = yield* environment.getEnvironmentId;
1167 const relayUrlValue = bytesToString(relayUrl.value);
1168 const cloudUserIdValue = bytesToString(cloudUserId.value);
1169 const origin = parsedOrigin.origin;
1170 const proof = yield* makeManagedTunnelRecoveryProof({
1171 action: "recover",
1172 environmentId,
1173 cloudUserId: cloudUserIdValue,
1174 relayUrl: relayUrlValue,
1175 origin,
1176 });
1177 const credential = bytesToString(environmentCredential.value);
1178 const recovered = yield* relayClientRequest({
1179 url: `${relayUrlValue}/v1/environments/${encodeURIComponent(environmentId)}/tunnel`,
1180 token: credential,
1181 payload: {
1182 cloudUserId: cloudUserIdValue,
1183 origin,
1184 proof,
1185 },
1186 schema: RelayManagedEndpointRecoveryResponse,
1187 timeout: MANAGED_ENDPOINT_PROVISION_REQUEST_TIMEOUT,
1188 }).pipe(
1189 // A running host learns of a Deregister here, when its dead tunnel asks
1190 // for a replacement. The rejection still fails the recovery.
1191 Effect.tapError((error) =>
1192 error.rejection === "unauthorized" ? markLinkRevoked(credential, error) : Effect.void,
1193 ),
1194 );
1195 if (recovered.endpointRuntime.providerKind !== "cloudflare_tunnel") {
1196 return yield* new CloudLinkInternalError({ operation: "unsupported-recovered-tunnel" });
1197 }
1198
1199 const encoded = yield* encodeEndpointRuntimeConfigJson(recovered.endpointRuntime).pipe(
1200 Effect.mapError(
1201 (cause) => new CloudLinkInternalError({ operation: "persist-recovered-tunnel", cause }),
1202 ),
1203 );
1204 const stored = yield* endpointRuntime.withLinkStateLock(
1205 Effect.gen(function* () {
1206 const currentConfig = yield* secrets.get(CLOUD_ENDPOINT_RUNTIME_CONFIG);
1207 if (
1208 Option.isNone(currentConfig) ||
1209 bytesToString(currentConfig.value) !== bytesToString(runtimeConfig.value)
1210 ) {
1211 return false;
1212 }
1213 yield* secrets.set(CLOUD_ENDPOINT_RUNTIME_CONFIG, stringToBytes(encoded));
1214 yield* secrets.remove(CLOUD_ENDPOINT_CONFIRMED_ORIGIN);
1215 // The relay accepted this link, so an earlier rejection no longer holds.
1216 yield* secrets.remove(CLOUD_LINK_REVOKED);
1217 return true;
1218 }),
1219 );
1220 if (!stored) return false;
1221 const status = yield* activateManagedTunnelWithRetry(
1222 {
1223 config: recovered.endpointRuntime,
1224 configJson: encoded,
1225 origin,
1226 },
1227 options?.retryRuntimeFailures === true,
1228 );
1229 return status !== null;
1230 });
1231
1232 const applyRelayConfig = Effect.fn("environment.cloud.relayConfig")(
1233 function* (payload: RelayEnvironmentConfigRequest) {
1234 const result = yield* applyCloudRelayConfig(payload);
1235 if (payload.endpointRuntime?.providerKind === "cloudflare_tunnel") {
1236 const address = httpServer.address;
1237 if (typeof address === "string" || !("port" in address)) {
1238 return yield* new CloudLinkInternalError({ operation: "resolve-server-origin" });
1239 }
1240 const registration = yield* registerManagedTunnelRecovery(
1241 `http://127.0.0.1:${address.port}`,
1242 ).pipe(
1243 Effect.retry({
1244 times: 2,
1245 while: (error) =>
1246 shouldRetryCloudLink(error) && error._tag !== "CloudLinkEndpointUnavailableError",
1247 }),
1248 );
1249 if (registration.status === "superseded") {
1250 return yield* new CloudLinkTunnelSupersededError({});
1251 }
1252 if (registration.status === "recovery_required") {
1253 yield* endpointRuntime.requestRecovery(registration.config);
1254 }
1255 if (registration.status !== "ready") {
1256 return yield* new CloudLinkEndpointUnavailableError({
1257 reason: "origin-unconfirmed",
1258 endpointRuntimeStatus: { status: "disabled" },
1259 });
1260 }
1261 return {
1262 ok: true,
1263 endpointRuntimeStatus: registration.endpointRuntimeStatus,
1264 } satisfies EnvironmentCloudRelayConfigResult;
1265 }
1266 return result;
1267 },
1268 Effect.catchIf(ServerSecretStore.isSecretStoreError, internalError("persist-relay-config")),
1269 Effect.catchTags({
1270 SchemaError: internalError("persist-relay-config"),
1271 PlatformError: internalError("register-endpoint-origin"),
1272 }),
1273 );
1274
1275 // Cloudflare bills per provisioned tunnel, so an environment that goes offline
1276 // must not leave its tunnel behind. Releasing deletes only the tunnel — the
1277 // relay keeps the link and its hostname reservation, and the next startup's
1278 // link reconcile provisions a replacement tunnel under the same URL.
1279 const releaseManagedTunnelOnShutdown = Effect.fn(
1280 "environment.cloud.releaseManagedTunnelOnShutdown",
1281 )(function* () {
1282 // Only a managed link stores a runtime config; publish-only links have no
1283 // tunnel to release.
1284 const runtimeConfig = yield* secrets.get(CLOUD_ENDPOINT_RUNTIME_CONFIG);
1285 if (Option.isNone(runtimeConfig)) {
1286 return false;
1287 }
1288 // Only CLI-desired managed links release eagerly because this request uses
1289 // CLI authorization. Web/mobile links register startup recovery with their
1290 // environment credential, and the relay reaper removes them after they are
1291 // down for the configured grace period. Unlink still deletes either kind.
1292 if (
1293 !(yield* readCliDesiredCloudLink.pipe(withSecrets)) ||
1294 (yield* readCliDesiredLinkMode.pipe(withSecrets)) !== "managed"
1295 ) {
1296 return false;
1297 }
1298 // A shutdown that hands off to a pending update is not the environment
1299 // going offline: the service launcher or the desktop app immediately brings
1300 // a server back (the new version, or the old one after a rollback). Deleting
1301 // the tunnel here forces that server to provision a replacement UUID, and the
1302 // public hostname's route to the new tunnel takes 1-2 minutes to propagate —
1303 // the dominant cost of an update restart. Keep the tunnel instead: the next
1304 // boot respawns the connector from the stored config and is reachable as
1305 // soon as it connects, and the reconcile confirms the still-live tunnel
1306 // without replacing it.
1307 if (
1308 (yield* withRuntimeFiles(desktopUpdateRestartPending)) ||
1309 (yield* withRuntimeFiles(pendingUpdateHandoffExists))
1310 ) {
1311 yield* Effect.logInfo("Keeping the managed tunnel across the update restart");
1312 return false;
1313 }
1314 const token = yield* cliTokenManager.getExisting;
1315 if (Option.isNone(token)) {
1316 return false;
1317 }
1318 // The link belongs to the relay it was installed against, so target the
1319 // persisted URL: T3CODE_RELAY_URL may have changed since the link was made.
1320 const relayUrl = yield* secrets.get(RELAY_URL_SECRET);
1321 if (Option.isNone(relayUrl)) {
1322 return false;
1323 }
1324 const environmentId = yield* environment.getEnvironmentId;
1325 // Stop the local connector before the relay deletes the tunnel it serves.
1326 yield* endpointRuntime.applyConfig(null);
1327 const response = yield* HttpClientRequest.delete(
1328 `${bytesToString(relayUrl.value)}/v1/client/environment-links/${encodeURIComponent(environmentId)}/tunnel`,
1329 ).pipe(
1330 HttpClientRequest.bearerToken(token.value.accessToken),
1331 httpClient.execute,
1332 Effect.flatMap(filterRelayResponse),
1333 Effect.flatMap(HttpClientResponse.schemaBodyJson(RelayOkResponse)),
1334 );
1335 // ok:false means the relay skipped deletion because a concurrent provision
1336 // owns the recorded tunnel now — leave the stored config alone.
1337 if (!response.ok) {
1338 return false;
1339 }
1340 // The connector token died with the tunnel. Drop the stored config so the
1341 // next start waits for the link reconcile instead of respawning the relay
1342 // client with a dead token. Kept when the release request fails: the tunnel
1343 // still exists, so the stored token keeps working across the restart.
1344 // Only dropped while it is still the config this shutdown released — a fast
1345 // restart may already have reconciled and stored a fresh config for its
1346 // replacement tunnel, and that one must survive this finalizer.
1347 const storedConfig = yield* secrets.get(CLOUD_ENDPOINT_RUNTIME_CONFIG);
1348 if (
1349 Option.isSome(storedConfig) &&
1350 bytesToString(storedConfig.value) === bytesToString(runtimeConfig.value)
1351 ) {
1352 yield* secrets.remove(CLOUD_ENDPOINT_RUNTIME_CONFIG);
1353 yield* secrets.remove(CLOUD_ENDPOINT_CONFIRMED_ORIGIN);
1354 }
1355 return true;
1356 });
1357
1358 const readCloudLinkState = Effect.fn("environment.cloud.readLinkState")(function* () {
1359 const [
1360 cloudUserId,
1361 relayUrl,
1362 relayIssuer,
1363 endpointRuntimeConfig,
1364 publishAgentActivity,
1365 holdWebhooks,
1366 revoked,
1367 ] = yield* Effect.all(
1368 [
1369 secrets.get(CLOUD_LINKED_USER_ID),
1370 secrets.get(RELAY_URL_SECRET),
1371 secrets.get(RELAY_ISSUER_SECRET),
1372 secrets.get(CLOUD_ENDPOINT_RUNTIME_CONFIG),
1373 secrets.get(PUBLISH_AGENT_ACTIVITY_SECRET),
1374 secrets.get(HOLD_WEBHOOKS_WHILE_OFFLINE_SECRET),
1375 secrets.get(CLOUD_LINK_REVOKED),
1376 ],
1377 { concurrency: 7 },
1378 );
1379 return {
1380 linked: Option.isSome(cloudUserId),
1381 linkRevoked: Option.isSome(cloudUserId) && Option.isSome(revoked),
1382 cloudUserId: Option.isSome(cloudUserId) ? bytesToString(cloudUserId.value) : null,
1383 relayUrl: Option.isSome(relayUrl) ? bytesToString(relayUrl.value) : null,
1384 relayIssuer: Option.isSome(relayIssuer) ? bytesToString(relayIssuer.value) : null,
1385 // The managed tunnel runtime config is only stored for managed links; a
1386 // publish-only link leaves it absent.
1387 managedTunnelActive: Option.isSome(endpointRuntimeConfig),
1388 publishAgentActivity: Option.isSome(publishAgentActivity)
1389 ? bytesToString(publishAgentActivity.value) === "true"
1390 : false,
1391 holdWebhooksWhileOffline:
1392 Option.isSome(holdWebhooks) && bytesToString(holdWebhooks.value) === "true",
1393 } satisfies EnvironmentCloudLinkStateResult;
1394 });
1395
1396 const linkState = Effect.fn("environment.cloud.linkState")(
1397 function* () {
1398 return yield* readCloudLinkState();
1399 },
1400 Effect.catchIf(ServerSecretStore.isSecretStoreError, internalError("read-relay-config")),
1401 );
1402
1403 const unlink = Effect.fn("environment.cloud.unlink")(
1404 function* () {
1405 return yield* endpointRuntime.withLinkStateLock(
1406 Effect.gen(function* () {
1407 const endpointRuntimeStatus = yield* endpointRuntime.applyConfig(null);
1408 yield* Effect.all(
1409 [
1410 secrets.remove(CLOUD_LINKED_USER_ID),
1411 secrets.remove(RELAY_URL_SECRET),
1412 secrets.remove(RELAY_ISSUER_SECRET),
1413 secrets.remove(RELAY_ENVIRONMENT_CREDENTIAL_SECRET),
1414 secrets.remove(CLOUD_MINT_PUBLIC_KEY),
1415 secrets.remove(CLOUD_ENDPOINT_RUNTIME_CONFIG),
1416 secrets.remove(CLOUD_ENDPOINT_CONFIRMED_ORIGIN),
1417 secrets.remove(PUBLISH_AGENT_ACTIVITY_SECRET),
1418 secrets.remove(HOLD_WEBHOOKS_WHILE_OFFLINE_SECRET),
1419 secrets.remove(CLOUD_LINK_REVOKED),
1420 ],
1421 { concurrency: 10 },
1422 );
1423 yield* setCliDesiredCloudLink(false).pipe(withSecrets);
1424 return { ok: true, endpointRuntimeStatus } satisfies EnvironmentCloudRelayConfigResult;
1425 }),
1426 );
1427 },
1428 Effect.catchIf(ServerSecretStore.isSecretStoreError, internalError("remove-relay-config")),
1429 );
1430
1431 const pushHoldWebhooksWhileOffline = Effect.fn("CloudPreferences.pushHoldWebhooksWhileOffline")(
1432 function* (holdWebhooksWhileOffline: boolean) {
1433 const connection = yield* readRelayConnection.pipe(withSecrets);
1434 if (connection === null) {
1435 return yield* new CloudLinkNotLinkedError({});
1436 }
1437 const environmentId = yield* environment.getEnvironmentId;
1438 const client = yield* makeRelayEnvironmentClient(connection);
1439 yield* client.server
1440 .updateLinkPreferences({
1441 params: { environmentId },
1442 payload: { holdWebhooksWhileOffline },
1443 })
1444 .pipe(Effect.timeout("10 seconds"), Effect.catch(internalError("update-webhook-settings")));
1445 },
1446 );
1447
1448 const savePreference = (name: string, value: boolean) =>
1449 secrets
1450 .set(name, stringToBytes(String(value)))
1451 .pipe(Effect.catch(internalError("persist-preferences")));
1452
1453 // One update at a time, so two requests can't each leave one setting behind.
1454 const preferencesLock = yield* Semaphore.make(1);
1455
1456 const savePreferences = Effect.fn("CloudPreferences.update")(
1457 function* (input: EnvironmentCloudPreferencesRequest) {
1458 // All or nothing: the activity setting is saved first, before the relay
1459 // is told anything, and put back if the hold change then fails. Both
1460 // current values are read up front; a failed read stops here, before
1461 // anything changes, because a guessed value would be the rollback target.
1462 const readCurrent = (name: string) =>
1463 secrets.get(name).pipe(Effect.catch(internalError("read-preferences")));
1464 const previousActivity = yield* readCurrent(PUBLISH_AGENT_ACTIVITY_SECRET);
1465 const previousHold = yield* readCurrent(HOLD_WEBHOOKS_WHILE_OFFLINE_SECRET);
1466 yield* savePreference(PUBLISH_AGENT_ACTIVITY_SECRET, input.publishAgentActivity);
1467 if (input.holdWebhooksWhileOffline !== undefined) {
1468 const next = input.holdWebhooksWhileOffline;
1469 const previous = Option.match(previousHold, {
1470 onNone: () => false,
1471 onSome: (bytes) => bytesToString(bytes) === "true",
1472 });
1473 yield* pushHoldWebhooksWhileOffline(next).pipe(
1474 Effect.andThen(
1475 savePreference(HOLD_WEBHOOKS_WHILE_OFFLINE_SECRET, next).pipe(
1476 Effect.tapError(() =>
1477 previous === next
1478 ? Effect.void
1479 : pushHoldWebhooksWhileOffline(previous).pipe(Effect.catch(rollbackFailed)),
1480 ),
1481 ),
1482 ),
1483 Effect.tapError(() =>
1484 Option.match(previousActivity, {
1485 onNone: () => secrets.remove(PUBLISH_AGENT_ACTIVITY_SECRET),
1486 onSome: (bytes) => secrets.set(PUBLISH_AGENT_ACTIVITY_SECRET, bytes),
1487 }).pipe(Effect.catch(rollbackFailed)),
1488 ),
1489 );
1490 }
1491 yield* awarenessRelay.requestCatchUp();
1492 },
1493 // A client that disconnects mid-update must not stop it between a save and
1494 // its rollback, so an update always finishes or undoes itself.
1495 Effect.uninterruptible,
1496 preferencesLock.withPermits(1),
1497 );
1498
1499 const updatePreferences = Effect.fn("environment.cloud.preferences")(
1500 function* (input: EnvironmentCloudPreferencesRequest) {
1501 yield* savePreferences(input);
1502 return yield* readCloudLinkState();
1503 },
1504 Effect.catchIf(ServerSecretStore.isSecretStoreError, internalError("read-preferences")),
1505 );
1506
1507 const answerHealthRequest = Effect.fn("environment.cloud.health")(
1508 function* (request: RelayCloudEnvironmentHealthRequest) {
1509 const cloudMintPublicKey = yield* secrets
1510 .get(CLOUD_MINT_PUBLIC_KEY)
1511 .pipe(
1512 Effect.flatMap((bytes) =>
1513 Option.isSome(bytes)
1514 ? Effect.succeed(bytesToString(bytes.value))
1515 : Effect.fail(new EnvironmentAuth.ServerAuthCloudMintPublicKeyMissingError({})),
1516 ),
1517 );
1518 const relayIssuer = yield* secrets
1519 .get(RELAY_ISSUER_SECRET)
1520 .pipe(
1521 Effect.flatMap((bytes) =>
1522 Option.isSome(bytes)
1523 ? Effect.succeed(bytesToString(bytes.value))
1524 : secrets
1525 .get(RELAY_URL_SECRET)
1526 .pipe(
1527 Effect.flatMap((fallbackBytes) =>
1528 Option.isSome(fallbackBytes)
1529 ? Effect.succeed(bytesToString(fallbackBytes.value))
1530 : Effect.fail(
1531 new EnvironmentAuth.ServerAuthCloudRelayIssuerMissingError({}),
1532 ),
1533 ),
1534 ),
1535 ),
1536 );
1537 const environmentId = yield* environment.getEnvironmentId;
1538 const linkedCloudUserId = yield* readInstalledCloudUserId;
1539 const now = yield* DateTime.now;
1540 const nowSeconds = Math.floor(now.epochMilliseconds / 1_000);
1541 const proofOption = yield* verifyRelayJwt({
1542 publicKey: cloudMintPublicKey,
1543 token: request.proof,
1544 typ: RELAY_HEALTH_REQUEST_TYP,
1545 issuer: normalizeRelayIssuer(relayIssuer),
1546 audience: `t3-env:${environmentId}`,
1547 nowEpochSeconds: nowSeconds,
1548 }).pipe(Effect.flatMap(decodeCloudHealthProof), Effect.option);
1549 if (
1550 Option.isNone(proofOption) ||
1551 proofOption.value.environmentId !== environmentId ||
1552 proofOption.value.sub !== linkedCloudUserId ||
1553 !hasBoundedCloudProofLifetime({ ...proofOption.value, nowSeconds }) ||
1554 !hasExactScope({ scopes: proofOption.value.scope, expected: "environment:status" })
1555 ) {
1556 return yield* new CloudLinkProofRejectedError({ request: "health" });
1557 }
1558 const proof = proofOption.value;
1559
1560 const jtiSecretName = `${CLOUD_HEALTH_JTI_PREFIX}${proof.jti}`;
1561 const nonceSecretName = `${CLOUD_HEALTH_NONCE_PREFIX}${proof.nonce}`;
1562 const consumedReplayGuards = yield* consumeCloudReplayGuards({
1563 secrets,
1564 names: [jtiSecretName, nonceSecretName],
1565 value: stringToBytes(DateTime.formatIso(now)),
1566 });
1567 if (!consumedReplayGuards) {
1568 return yield* new CloudLinkProofReplayedError({ request: "health" });
1569 }
1570
1571 const keyPair = yield* getOrCreateEnvironmentKeyPairFromSecretStore(secrets);
1572 const descriptor = yield* environment.getDescriptor;
1573 const responseExpiresAt = DateTime.add(now, { minutes: 5 });
1574 const responsePayload = {
1575 iss: `t3-env:${environmentId}`,
1576 aud: normalizeRelayIssuer(relayIssuer),
1577 sub: environmentId,
1578 jti: yield* crypto.randomUUIDv4(),
1579 iat: nowSeconds,
1580 exp: Math.floor(responseExpiresAt.epochMilliseconds / 1_000),
1581 environmentId,
1582 requestNonce: proof.nonce,
1583 status: "online",
1584 descriptor,
1585 checkedAt: DateTime.formatIso(now),
1586 } satisfies RelayEnvironmentHealthResponseProofPayload;
1587 const responseProof = yield* signRelayJwt({
1588 privateKey: keyPair.privateKey,
1589 typ: RELAY_HEALTH_RESPONSE_TYP,
1590 payload: responsePayload,
1591 }).pipe(
1592 Effect.mapError(
1593 (cause) =>
1594 new EnvironmentAuth.ServerAuthCloudHealthJwtSigningError({
1595 cause,
1596 }),
1597 ),
1598 );
1599 return {
1600 environmentId,
1601 status: "online",
1602 descriptor,
1603 checkedAt: responsePayload.checkedAt,
1604 proof: responseProof,
1605 } satisfies RelayEnvironmentHealthResponse;
1606 },
1607 Effect.catchIf(ServerSecretStore.isSecretStoreError, internalError("answer-health")),
1608 Effect.catchTags({ PlatformError: internalError("answer-health") }),
1609 );
1610
1611 const mintCredential = Effect.fn("environment.cloud.mintCredential")(
1612 function* (request: RelayCloudMintCredentialRequest) {
1613 const cloudMintPublicKey = yield* secrets
1614 .get(CLOUD_MINT_PUBLIC_KEY)
1615 .pipe(
1616 Effect.flatMap((bytes) =>
1617 Option.isSome(bytes)
1618 ? Effect.succeed(bytesToString(bytes.value))
1619 : Effect.fail(new EnvironmentAuth.ServerAuthCloudMintPublicKeyMissingError({})),
1620 ),
1621 );
1622 const relayIssuer = yield* secrets
1623 .get(RELAY_ISSUER_SECRET)
1624 .pipe(
1625 Effect.flatMap((bytes) =>
1626 Option.isSome(bytes)
1627 ? Effect.succeed(bytesToString(bytes.value))
1628 : secrets
1629 .get(RELAY_URL_SECRET)
1630 .pipe(
1631 Effect.flatMap((fallbackBytes) =>
1632 Option.isSome(fallbackBytes)
1633 ? Effect.succeed(bytesToString(fallbackBytes.value))
1634 : Effect.fail(
1635 new EnvironmentAuth.ServerAuthCloudRelayIssuerMissingError({}),
1636 ),
1637 ),
1638 ),
1639 ),
1640 );
1641 const environmentId = yield* environment.getEnvironmentId;
1642 const linkedCloudUserId = yield* readInstalledCloudUserId;
1643 const now = yield* DateTime.now;
1644 const nowSeconds = Math.floor(now.epochMilliseconds / 1_000);
1645 const proofOption = yield* verifyRelayJwt({
1646 publicKey: cloudMintPublicKey,
1647 token: request.proof,
1648 typ: RELAY_MINT_REQUEST_TYP,
1649 issuer: normalizeRelayIssuer(relayIssuer),
1650 audience: `t3-env:${environmentId}`,
1651 nowEpochSeconds: nowSeconds,
1652 }).pipe(Effect.flatMap(decodeCloudMintProof), Effect.option);
1653 if (
1654 Option.isNone(proofOption) ||
1655 proofOption.value.environmentId !== environmentId ||
1656 proofOption.value.sub !== linkedCloudUserId ||
1657 proofOption.value.cnf.jkt !== proofOption.value.clientProofKeyThumbprint ||
1658 !hasBoundedCloudProofLifetime({ ...proofOption.value, nowSeconds }) ||
1659 !hasExactScope({ scopes: proofOption.value.scope, expected: "environment:connect" })
1660 ) {
1661 return yield* new CloudLinkProofRejectedError({ request: "mint" });
1662 }
1663 const proof = proofOption.value;
1664
1665 const jtiSecretName = `${CLOUD_MINT_JTI_PREFIX}${proof.jti}`;
1666 const nonceSecretName = `${CLOUD_MINT_NONCE_PREFIX}${proof.nonce}`;
1667 const consumedReplayGuards = yield* consumeCloudReplayGuards({
1668 secrets,
1669 names: [jtiSecretName, nonceSecretName],
1670 value: stringToBytes(DateTime.formatIso(now)),
1671 });
1672 if (!consumedReplayGuards) {
1673 return yield* new CloudLinkProofReplayedError({ request: "mint" });
1674 }
1675
1676 const keyPair = yield* getOrCreateEnvironmentKeyPairFromSecretStore(secrets);
1677 const issued = yield* environmentAuth.createPairingLink({
1678 scopes: AuthStandardClientScopes,
1679 subject: "cloud-connect",
1680 ttl: Duration.minutes(2),
1681 label: "T3 Connect connect",
1682 proofKeyThumbprint: proof.clientProofKeyThumbprint,
1683 });
1684 const responsePayload = {
1685 iss: `t3-env:${environmentId}`,
1686 aud: normalizeRelayIssuer(relayIssuer),
1687 sub: environmentId,
1688 jti: yield* crypto.randomUUIDv4(),
1689 iat: nowSeconds,
1690 exp: Math.floor(issued.expiresAt.epochMilliseconds / 1_000),
1691 environmentId,
1692 clientProofKeyThumbprint: proof.clientProofKeyThumbprint,
1693 requestNonce: proof.nonce,
1694 credential: issued.credential,
1695 } satisfies RelayEnvironmentMintResponseProofPayload;
1696 const responseProof = yield* signRelayJwt({
1697 privateKey: keyPair.privateKey,
1698 typ: RELAY_MINT_RESPONSE_TYP,
1699 payload: responsePayload,
1700 }).pipe(
1701 Effect.mapError(
1702 (cause) =>
1703 new EnvironmentAuth.ServerAuthCloudMintJwtSigningError({
1704 cause,
1705 }),
1706 ),
1707 );
1708 return {
1709 credential: issued.credential,
1710 expiresAt: DateTime.formatIso(issued.expiresAt),
1711 proof: responseProof,
1712 } satisfies RelayEnvironmentMintResponse;
1713 },
1714 Effect.catchIf(ServerSecretStore.isSecretStoreError, internalError("issue-credential")),
1715 Effect.catchTags({ PlatformError: internalError("issue-credential") }),
1716 );
1717
1718 return CloudLink.of({
1719 linkProof,
1720 applyRelayConfig,
1721 linkState,
1722 unlink,
1723 updatePreferences,
1724 answerHealthRequest,
1725 mintCredential,
1726 reconcileDesiredLink,
1727 reconcileDesiredLinkIfStillDesired,
1728 registerManagedTunnelRecovery,
1729 recoverManagedTunnel,
1730 startManagedTunnelIfOriginConfirmed,
1731 releaseManagedTunnelOnShutdown,
1732 });
1733});
1734
1735export const layer = Layer.effect(CloudLink, make);