| ... | @@ -7,15 +7,9 @@ | ... | @@ -7,15 +7,9 @@ |
| 7 | import * as NodeCrypto from "node:crypto"; | 7 | import * as NodeCrypto from "node:crypto"; |
| 8 | import { | 8 | import { |
| 9 | AuthStandardClientScopes, | 9 | AuthStandardClientScopes, |
| 10 | EnvironmentCloudEndpointUnavailableError, | | |
| 11 | type EnvironmentCloudLinkStateResult, | 10 | type EnvironmentCloudLinkStateResult, |
| 12 | type EnvironmentCloudPreferencesRequest, | 11 | type EnvironmentCloudPreferencesRequest, |
| 13 | type EnvironmentCloudRelayConfigResult, | 12 | type EnvironmentCloudRelayConfigResult, |
| 14 | EnvironmentHttpBadRequestError, | | |
| 15 | EnvironmentHttpConflictError, | | |
| 16 | type EnvironmentHttpForbiddenError, | | |
| 17 | EnvironmentHttpInternalServerError, | | |
| 18 | EnvironmentHttpUnauthorizedError, | | |
| 19 | } from "@t3tools/contracts"; | 13 | } from "@t3tools/contracts"; |
| 20 | import { | 14 | import { |
| 21 | RelayCloudEnvironmentHealthProofPayload, | 15 | RelayCloudEnvironmentHealthProofPayload, |
| ... | @@ -106,7 +100,12 @@ import { | ... | @@ -106,7 +100,12 @@ import { |
| 106 | import { getOrCreateEnvironmentKeyPairFromSecretStore } from "./environmentKeys.ts"; | 100 | import { getOrCreateEnvironmentKeyPairFromSecretStore } from "./environmentKeys.ts"; |
| 107 | import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts"; | 101 | import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts"; |
| 108 | import { relayUrlConfig } from "./publicConfig.ts"; | 102 | import { relayUrlConfig } from "./publicConfig.ts"; |
| 109 | import { filterRelayResponse, relayRequestError, shouldRetryCloudLink } from "./relayResponse.ts"; | 103 | import { |
| | 104 | filterRelayResponse, |
| | 105 | relayRequestError, |
| | 106 | type RelayRequestError, |
| | 107 | shouldRetryRelayRequest, |
| | 108 | } from "./relayResponse.ts"; |
| 110 | import { | 109 | import { |
| 111 | CLOUD_HEALTH_JTI_PREFIX, | 110 | CLOUD_HEALTH_JTI_PREFIX, |
| 112 | CLOUD_HEALTH_NONCE_PREFIX, | 111 | CLOUD_HEALTH_NONCE_PREFIX, |
| ... | @@ -126,15 +125,187 @@ import { | ... | @@ -126,15 +125,187 @@ import { |
| 126 | import { desktopUpdateRestartPending, pendingUpdateHandoffExists } from "./updateHandoff.ts"; | 125 | import { desktopUpdateRestartPending, pendingUpdateHandoffExists } from "./updateHandoff.ts"; |
| 127 | | 126 | |
| 128 | const MANAGED_ENDPOINT_PROVISION_REQUEST_TIMEOUT = Duration.minutes(2); | 127 | const MANAGED_ENDPOINT_PROVISION_REQUEST_TIMEOUT = Duration.minutes(2); |
| 129 | const failEnvironmentCloudInternalError = | 128 | const RELAY_CONFIG_FIELD_MESSAGES = { |
| 130 | (message: string) => | 129 | relayUrl: "Relay URL must be a secure absolute HTTPS URL.", |
| 131 | (cause: unknown): Effect.Effect<never, EnvironmentHttpInternalServerError> => | 130 | relayIssuer: "Relay issuer must be a secure absolute HTTPS URL.", |
| 132 | Effect.logError(message, { cause }).pipe( | 131 | environmentCredential: "Relay environment credential is required.", |
| 133 | Effect.flatMap(() => Effect.fail(new EnvironmentHttpInternalServerError({ message }))), | 132 | cloudUserId: "Cloud user id is required.", |
| 134 | ); | 133 | cloudMintPublicKey: "Cloud mint public key must be a valid Ed25519 public key.", |
| | 134 | } as const; |
| | 135 | |
| | 136 | /** The relay sent a link configuration this environment cannot install. */ |
| | 137 | export class CloudLinkRelayConfigInvalidError extends Schema.TaggedError<CloudLinkRelayConfigInvalidError>()( |
| | 138 | "CloudLinkRelayConfigInvalidError", |
| | 139 | { |
| | 140 | field: Schema.Literals([ |
| | 141 | "relayUrl", |
| | 142 | "relayIssuer", |
| | 143 | "environmentCredential", |
| | 144 | "cloudUserId", |
| | 145 | "cloudMintPublicKey", |
| | 146 | ]), |
| | 147 | }, |
| | 148 | ) { |
| | 149 | override get message(): string { |
| | 150 | return RELAY_CONFIG_FIELD_MESSAGES[this.field]; |
| | 151 | } |
| | 152 | } |
| | 153 | |
| | 154 | /** |
| | 155 | * A link was asked to point at an origin it may not serve: a link proof request |
| | 156 | * that did not reach this server directly on loopback (`endpoint`), or a local |
| | 157 | * origin that is not a bare http(s) origin (`local`). |
| | 158 | */ |
| | 159 | export class CloudLinkOriginInvalidError extends Schema.TaggedError<CloudLinkOriginInvalidError>()( |
| | 160 | "CloudLinkOriginInvalidError", |
| | 161 | { origin: Schema.Literals(["endpoint", "local"]) }, |
| | 162 | ) { |
| | 163 | override get message(): string { |
| | 164 | return this.origin === "endpoint" |
| | 165 | ? "Invalid managed endpoint origin." |
| | 166 | : "Could not resolve local environment origin."; |
| | 167 | } |
| | 168 | } |
| | 169 | |
| | 170 | export class CloudLinkNotLinkedError extends Schema.TaggedError<CloudLinkNotLinkedError>()( |
| | 171 | "CloudLinkNotLinkedError", |
| | 172 | {}, |
| | 173 | ) { |
| | 174 | override get message(): string { |
| | 175 | return "Link this environment to T3 Connect first."; |
| | 176 | } |
| | 177 | } |
| | 178 | |
| | 179 | export class CloudLinkAccountMismatchError extends Schema.TaggedError<CloudLinkAccountMismatchError>()( |
| | 180 | "CloudLinkAccountMismatchError", |
| | 181 | {}, |
| | 182 | ) { |
| | 183 | override get message(): string { |
| | 184 | return "This environment is already linked to a different cloud account. Unlink it before switching accounts."; |
| | 185 | } |
| | 186 | } |
| | 187 | |
| | 188 | /** Linking from the CLI needs the authorization `t3 connect link` stores. */ |
| | 189 | export class CloudLinkAuthorizationMissingError extends Schema.TaggedError<CloudLinkAuthorizationMissingError>()( |
| | 190 | "CloudLinkAuthorizationMissingError", |
| | 191 | {}, |
| | 192 | ) { |
| | 193 | override get message(): string { |
| | 194 | return "Run `t3 connect link` to authorize this environment."; |
| | 195 | } |
| | 196 | } |
| | 197 | |
| | 198 | const SignedRelayRequest = Schema.Literals(["health", "mint"]); |
| 135 | | 199 | |
| 136 | const failCloudCliTokenManagerError = (error: CliTokenManager.CloudCliTokenManagerError) => | 200 | /** A signed relay request whose proof does not verify for this environment. */ |
| 137 | failEnvironmentCloudInternalError(error.message)(error); | 201 | export class CloudLinkProofRejectedError extends Schema.TaggedError<CloudLinkProofRejectedError>()( |
| | 202 | "CloudLinkProofRejectedError", |
| | 203 | { request: SignedRelayRequest }, |
| | 204 | ) { |
| | 205 | override get message(): string { |
| | 206 | return `Invalid cloud ${this.request} request.`; |
| | 207 | } |
| | 208 | } |
| | 209 | |
| | 210 | /** A signed relay request whose proof was already used once. */ |
| | 211 | export class CloudLinkProofReplayedError extends Schema.TaggedError<CloudLinkProofReplayedError>()( |
| | 212 | "CloudLinkProofReplayedError", |
| | 213 | { request: SignedRelayRequest }, |
| | 214 | ) { |
| | 215 | override get message(): string { |
| | 216 | return `Cloud ${this.request} request was already consumed.`; |
| | 217 | } |
| | 218 | } |
| | 219 | |
| | 220 | /** The stored tunnel changed while its registration was in flight. */ |
| | 221 | export class CloudLinkTunnelSupersededError extends Schema.TaggedError<CloudLinkTunnelSupersededError>()( |
| | 222 | "CloudLinkTunnelSupersededError", |
| | 223 | {}, |
| | 224 | ) { |
| | 225 | override get message(): string { |
| | 226 | return "The managed tunnel configuration changed during registration."; |
| | 227 | } |
| | 228 | } |
| | 229 | |
| | 230 | /** |
| | 231 | * The managed tunnel is not serving: its connector did not start |
| | 232 | * (`runtime-not-started`, with the runtime's status), or the relay never |
| | 233 | * confirmed this server's origin (`origin-unconfirmed`). |
| | 234 | */ |
| | 235 | export class CloudLinkEndpointUnavailableError extends Schema.TaggedError<CloudLinkEndpointUnavailableError>()( |
| | 236 | "CloudLinkEndpointUnavailableError", |
| | 237 | { |
| | 238 | reason: Schema.Literals(["runtime-not-started", "origin-unconfirmed"]), |
| | 239 | endpointRuntimeStatus: Schema.Unknown, |
| | 240 | }, |
| | 241 | ) { |
| | 242 | override get message(): string { |
| | 243 | return this.reason === "runtime-not-started" |
| | 244 | ? "Managed endpoint runtime could not be started." |
| | 245 | : "Managed endpoint origin could not be confirmed."; |
| | 246 | } |
| | 247 | } |
| | 248 | |
| | 249 | const INTERNAL_OPERATION_MESSAGES = { |
| | 250 | "relay-url-unconfigured": |
| | 251 | "T3CODE_RELAY_URL must be configured as a secure absolute HTTPS origin.", |
| | 252 | "generate-link-proof": "Could not generate environment link proof.", |
| | 253 | "persist-relay-config": "Could not persist environment relay configuration.", |
| | 254 | "register-endpoint-origin": "Could not register the managed endpoint origin.", |
| | 255 | "resolve-server-origin": "Could not resolve the local server origin.", |
| | 256 | "persist-desired-link": "Could not persist desired T3 Connect link state.", |
| | 257 | "sign-recovery-proof": "Could not sign the managed tunnel recovery request.", |
| | 258 | "unsupported-recovered-tunnel": |
| | 259 | "T3 Connect returned an unsupported managed tunnel configuration.", |
| | 260 | "persist-recovered-tunnel": "Could not persist the recovered managed tunnel configuration.", |
| | 261 | "read-relay-config": "Could not read environment relay configuration.", |
| | 262 | "remove-relay-config": "Could not remove environment relay configuration.", |
| | 263 | "update-webhook-settings": "Could not update T3 Connect webhook settings.", |
| | 264 | "read-preferences": "Could not read environment cloud preferences.", |
| | 265 | "persist-preferences": "Could not persist environment cloud preferences.", |
| | 266 | "answer-health": "Could not answer cloud health request.", |
| | 267 | "issue-credential": "Could not issue cloud connection credential.", |
| | 268 | } as const; |
| | 269 | |
| | 270 | /** A link step failed on this machine: storage, signing, or an unexpected relay answer. */ |
| | 271 | export class CloudLinkInternalError extends Schema.TaggedError<CloudLinkInternalError>()( |
| | 272 | "CloudLinkInternalError", |
| | 273 | { |
| | 274 | operation: Schema.Literals( |
| | 275 | Object.keys(INTERNAL_OPERATION_MESSAGES) as [ |
| | 276 | keyof typeof INTERNAL_OPERATION_MESSAGES, |
| | 277 | ...Array<keyof typeof INTERNAL_OPERATION_MESSAGES>, |
| | 278 | ], |
| | 279 | ), |
| | 280 | cause: Schema.optional(Schema.Defect()), |
| | 281 | }, |
| | 282 | ) { |
| | 283 | override get message(): string { |
| | 284 | return INTERNAL_OPERATION_MESSAGES[this.operation]; |
| | 285 | } |
| | 286 | } |
| | 287 | |
| | 288 | const internalError = |
| | 289 | (operation: CloudLinkInternalError["operation"]) => |
| | 290 | (cause: unknown): Effect.Effect<never, CloudLinkInternalError> => |
| | 291 | Effect.fail(new CloudLinkInternalError({ operation, cause })); |
| | 292 | |
| | 293 | const isPermanentLinkError = Schema.is( |
| | 294 | Schema.Union([ |
| | 295 | CloudLinkRelayConfigInvalidError, |
| | 296 | CloudLinkOriginInvalidError, |
| | 297 | CloudLinkNotLinkedError, |
| | 298 | CloudLinkAccountMismatchError, |
| | 299 | CloudLinkAuthorizationMissingError, |
| | 300 | CloudLinkProofRejectedError, |
| | 301 | CloudLinkProofReplayedError, |
| | 302 | CloudLinkTunnelSupersededError, |
| | 303 | ]), |
| | 304 | ); |
| | 305 | |
| | 306 | /** Whether a failed link step may succeed on retry: not when the relay or this server refused it. */ |
| | 307 | export const shouldRetryCloudLink = (error: unknown): boolean => |
| | 308 | shouldRetryRelayRequest(error) && !isPermanentLinkError(error); |
| 138 | | 309 | |
| 139 | /** A failed rollback leaves a setting changed; it is logged, not hidden. */ | 310 | /** A failed rollback leaves a setting changed; it is logged, not hidden. */ |
| 140 | const rollbackFailed = (cause: unknown) => | 311 | const rollbackFailed = (cause: unknown) => |
| ... | @@ -142,10 +313,7 @@ const rollbackFailed = (cause: unknown) => | ... | @@ -142,10 +313,7 @@ const rollbackFailed = (cause: unknown) => |
| 142 | | 313 | |
| 143 | const requireRelayUrl = relayUrlConfig.pipe( | 314 | const requireRelayUrl = relayUrlConfig.pipe( |
| 144 | Effect.mapError( | 315 | Effect.mapError( |
| 145 | () => | 316 | (cause) => new CloudLinkInternalError({ operation: "relay-url-unconfigured", cause }), |
| 146 | new EnvironmentHttpInternalServerError({ | | |
| 147 | message: "T3CODE_RELAY_URL must be configured as a secure absolute HTTPS origin.", | | |
| 148 | }), | | |
| 149 | ), | 317 | ), |
| 150 | ); | 318 | ); |
| 151 | | 319 | |
| ... | @@ -159,58 +327,37 @@ function stringToBytes(value: string): Uint8Array { | ... | @@ -159,58 +327,37 @@ function stringToBytes(value: string): Uint8Array { |
| 159 | | 327 | |
| 160 | function validateCloudMintPublicKey( | 328 | function validateCloudMintPublicKey( |
| 161 | publicKey: string, | 329 | publicKey: string, |
| 162 | ): Effect.Effect<void, EnvironmentHttpBadRequestError> { | 330 | ): Effect.Effect<void, CloudLinkRelayConfigInvalidError> { |
| | 331 | const invalid = new CloudLinkRelayConfigInvalidError({ field: "cloudMintPublicKey" }); |
| 163 | return Effect.try({ | 332 | return Effect.try({ |
| 164 | try: () => NodeCrypto.createPublicKey(publicKey.replace(/\\n/g, "\n")), | 333 | try: () => NodeCrypto.createPublicKey(publicKey.replace(/\\n/g, "\n")), |
| 165 | catch: () => | 334 | catch: () => invalid, |
| 166 | new EnvironmentHttpBadRequestError({ | | |
| 167 | message: "Cloud mint public key must be a valid Ed25519 public key.", | | |
| 168 | }), | | |
| 169 | }).pipe( | 335 | }).pipe( |
| 170 | Effect.flatMap((key) => | 336 | Effect.flatMap((key) => |
| 171 | key.asymmetricKeyType === "ed25519" | 337 | key.asymmetricKeyType === "ed25519" ? Effect.void : Effect.fail(invalid), |
| 172 | ? Effect.void | | |
| 173 | : Effect.fail( | | |
| 174 | new EnvironmentHttpBadRequestError({ | | |
| 175 | message: "Cloud mint public key must be a valid Ed25519 public key.", | | |
| 176 | }), | | |
| 177 | ), | | |
| 178 | ), | 338 | ), |
| 179 | ); | 339 | ); |
| 180 | } | 340 | } |
| 181 | | 341 | |
| 182 | function validateRelayConfigPayload( | 342 | function invalidRelayConfigField( |
| 183 | payload: RelayEnvironmentConfigRequest, | 343 | payload: RelayEnvironmentConfigRequest, |
| 184 | ): Effect.Effect<void, EnvironmentHttpBadRequestError> { | 344 | ): CloudLinkRelayConfigInvalidError["field"] | null { |
| 185 | if (!isSecureRelayUrl(payload.relayUrl)) { | 345 | if (!isSecureRelayUrl(payload.relayUrl)) return "relayUrl"; |
| 186 | return Effect.fail( | | |
| 187 | new EnvironmentHttpBadRequestError({ | | |
| 188 | message: "Relay URL must be a secure absolute HTTPS URL.", | | |
| 189 | }), | | |
| 190 | ); | | |
| 191 | } | | |
| 192 | if (payload.relayIssuer !== undefined && !isSecureRelayUrl(payload.relayIssuer)) { | 346 | if (payload.relayIssuer !== undefined && !isSecureRelayUrl(payload.relayIssuer)) { |
| 193 | return Effect.fail( | 347 | return "relayIssuer"; |
| 194 | new EnvironmentHttpBadRequestError({ | | |
| 195 | message: "Relay issuer must be a secure absolute HTTPS URL.", | | |
| 196 | }), | | |
| 197 | ); | | |
| 198 | } | | |
| 199 | if (payload.environmentCredential.trim().length === 0) { | | |
| 200 | return Effect.fail( | | |
| 201 | new EnvironmentHttpBadRequestError({ | | |
| 202 | message: "Relay environment credential is required.", | | |
| 203 | }), | | |
| 204 | ); | | |
| 205 | } | 348 | } |
| 206 | if (payload.cloudUserId.trim().length === 0) { | 349 | if (payload.environmentCredential.trim().length === 0) return "environmentCredential"; |
| 207 | return Effect.fail( | 350 | if (payload.cloudUserId.trim().length === 0) return "cloudUserId"; |
| 208 | new EnvironmentHttpBadRequestError({ | 351 | return null; |
| 209 | message: "Cloud user id is required.", | 352 | } |
| 210 | }), | 353 | |
| 211 | ); | 354 | function validateRelayConfigPayload( |
| 212 | } | 355 | payload: RelayEnvironmentConfigRequest, |
| 213 | return Effect.void; | 356 | ): Effect.Effect<void, CloudLinkRelayConfigInvalidError> { |
| | 357 | const field = invalidRelayConfigField(payload); |
| | 358 | return field === null |
| | 359 | ? Effect.void |
| | 360 | : Effect.fail(new CloudLinkRelayConfigInvalidError({ field })); |
| 214 | } | 361 | } |
| 215 | | 362 | |
| 216 | function normalizePemForSignedPayload(value: string): string { | 363 | function normalizePemForSignedPayload(value: string): string { |
| ... | @@ -233,13 +380,25 @@ type ManagedTunnelRecoveryProofInput = { | ... | @@ -233,13 +380,25 @@ type ManagedTunnelRecoveryProofInput = { |
| 233 | | { readonly action: "recover"; readonly origin: RelayManagedEndpointOrigin } | 380 | | { readonly action: "recover"; readonly origin: RelayManagedEndpointOrigin } |
| 234 | ); | 381 | ); |
| 235 | | 382 | |
| 236 | /** Failures of the link work that runs outside a request: startup, recovery and shutdown. */ | 383 | /** Every failure CloudLink constructs itself. */ |
| | 384 | export type CloudLinkError = |
| | 385 | | CloudLinkAccountMismatchError |
| | 386 | | CloudLinkAuthorizationMissingError |
| | 387 | | CloudLinkEndpointUnavailableError |
| | 388 | | CloudLinkInternalError |
| | 389 | | CloudLinkNotLinkedError |
| | 390 | | CloudLinkOriginInvalidError |
| | 391 | | CloudLinkProofRejectedError |
| | 392 | | CloudLinkProofReplayedError |
| | 393 | | CloudLinkRelayConfigInvalidError |
| | 394 | | CloudLinkTunnelSupersededError; |
| | 395 | |
| | 396 | /** Failures of the link work that talks to the relay: linking, recovery and shutdown. */ |
| 237 | type CloudLinkBackgroundError = | 397 | type CloudLinkBackgroundError = |
| 238 | | EnvironmentCloudEndpointUnavailableError | 398 | | CloudLinkEndpointUnavailableError |
| 239 | | EnvironmentHttpBadRequestError | 399 | | CloudLinkInternalError |
| 240 | | EnvironmentHttpForbiddenError | 400 | | CloudLinkOriginInvalidError |
| 241 | | EnvironmentHttpInternalServerError | 401 | | RelayRequestError |
| 242 | | EnvironmentHttpUnauthorizedError | | |
| 243 | | ServerSecretStore.SecretStoreError | 402 | | ServerSecretStore.SecretStoreError |
| 244 | | Schema.SchemaError | 403 | | Schema.SchemaError |
| 245 | | PlatformError.PlatformError; | 404 | | PlatformError.PlatformError; |
| ... | @@ -264,29 +423,28 @@ export class CloudLink extends Context.Service< | ... | @@ -264,29 +423,28 @@ export class CloudLink extends Context.Service< |
| 264 | httpRequest: HttpServerRequest.HttpServerRequest, | 423 | httpRequest: HttpServerRequest.HttpServerRequest, |
| 265 | ) => Effect.Effect< | 424 | ) => Effect.Effect< |
| 266 | RelayEnvironmentLinkProof, | 425 | RelayEnvironmentLinkProof, |
| 267 | EnvironmentHttpBadRequestError | EnvironmentHttpInternalServerError | 426 | CloudLinkOriginInvalidError | CloudLinkInternalError | EnvironmentAuth.ServerAuthInternalError |
| 268 | >; | 427 | >; |
| 269 | /** Installs the link the relay returned and, for a managed tunnel, confirms its origin. */ | 428 | /** Installs the link the relay returned and, for a managed tunnel, confirms its origin. */ |
| 270 | readonly applyRelayConfig: ( | 429 | readonly applyRelayConfig: ( |
| 271 | payload: RelayEnvironmentConfigRequest, | 430 | payload: RelayEnvironmentConfigRequest, |
| 272 | ) => Effect.Effect< | 431 | ) => Effect.Effect< |
| 273 | EnvironmentCloudRelayConfigResult, | 432 | EnvironmentCloudRelayConfigResult, |
| 274 | | EnvironmentCloudEndpointUnavailableError | 433 | | CloudLinkAccountMismatchError |
| 275 | | EnvironmentHttpBadRequestError | 434 | | CloudLinkEndpointUnavailableError |
| 276 | | EnvironmentHttpConflictError | 435 | | CloudLinkInternalError |
| 277 | | EnvironmentHttpForbiddenError | 436 | | CloudLinkOriginInvalidError |
| 278 | | EnvironmentHttpInternalServerError | 437 | | CloudLinkRelayConfigInvalidError |
| 279 | | EnvironmentHttpUnauthorizedError | 438 | | CloudLinkTunnelSupersededError |
| | 439 | | EnvironmentAuth.ServerAuthInternalError |
| | 440 | | RelayRequestError |
| 280 | >; | 441 | >; |
| 281 | readonly linkState: () => Effect.Effect< | 442 | readonly linkState: () => Effect.Effect< |
| 282 | EnvironmentCloudLinkStateResult, | 443 | EnvironmentCloudLinkStateResult, |
| 283 | EnvironmentHttpInternalServerError | 444 | CloudLinkInternalError |
| 284 | >; | 445 | >; |
| 285 | /** Stops the tunnel and forgets the link, including the CLI's wish to keep it. */ | 446 | /** Stops the tunnel and forgets the link, including the CLI's wish to keep it. */ |
| 286 | readonly unlink: () => Effect.Effect< | 447 | readonly unlink: () => Effect.Effect<EnvironmentCloudRelayConfigResult, CloudLinkInternalError>; |
| 287 | EnvironmentCloudRelayConfigResult, | | |
| 288 | EnvironmentHttpInternalServerError | | |
| 289 | >; | | |
| 290 | /** | 448 | /** |
| 291 | * Saves this environment's T3 Connect preferences, all or nothing, and | 449 | * Saves this environment's T3 Connect preferences, all or nothing, and |
| 292 | * returns the link state. The activity setting is saved first. Holding | 450 | * returns the link state. The activity setting is saved first. Holding |
| ... | @@ -298,25 +456,27 @@ export class CloudLink extends Context.Service< | ... | @@ -298,25 +456,27 @@ export class CloudLink extends Context.Service< |
| 298 | input: EnvironmentCloudPreferencesRequest, | 456 | input: EnvironmentCloudPreferencesRequest, |
| 299 | ) => Effect.Effect< | 457 | ) => Effect.Effect< |
| 300 | EnvironmentCloudLinkStateResult, | 458 | EnvironmentCloudLinkStateResult, |
| 301 | EnvironmentHttpBadRequestError | EnvironmentHttpInternalServerError | 459 | CloudLinkNotLinkedError | CloudLinkInternalError |
| 302 | >; | 460 | >; |
| 303 | /** Answers the relay's signed health check once per proof. */ | 461 | /** Answers the relay's signed health check once per proof. */ |
| 304 | readonly answerHealthRequest: ( | 462 | readonly answerHealthRequest: ( |
| 305 | request: RelayCloudEnvironmentHealthRequest, | 463 | request: RelayCloudEnvironmentHealthRequest, |
| 306 | ) => Effect.Effect< | 464 | ) => Effect.Effect< |
| 307 | RelayEnvironmentHealthResponse, | 465 | RelayEnvironmentHealthResponse, |
| 308 | | EnvironmentHttpConflictError | 466 | | CloudLinkProofRejectedError |
| 309 | | EnvironmentHttpInternalServerError | 467 | | CloudLinkProofReplayedError |
| 310 | | EnvironmentHttpUnauthorizedError | 468 | | CloudLinkInternalError |
| | 469 | | EnvironmentAuth.ServerAuthInternalError |
| 311 | >; | 470 | >; |
| 312 | /** Issues a short-lived pairing credential for a client the relay vouched for, once per proof. */ | 471 | /** Issues a short-lived pairing credential for a client the relay vouched for, once per proof. */ |
| 313 | readonly mintCredential: ( | 472 | readonly mintCredential: ( |
| 314 | request: RelayCloudMintCredentialRequest, | 473 | request: RelayCloudMintCredentialRequest, |
| 315 | ) => Effect.Effect< | 474 | ) => Effect.Effect< |
| 316 | RelayEnvironmentMintResponse, | 475 | RelayEnvironmentMintResponse, |
| 317 | | EnvironmentHttpConflictError | 476 | | CloudLinkProofRejectedError |
| 318 | | EnvironmentHttpInternalServerError | 477 | | CloudLinkProofReplayedError |
| 319 | | EnvironmentHttpUnauthorizedError | 478 | | CloudLinkInternalError |
| | 479 | | EnvironmentAuth.ServerAuthInternalError |
| 320 | >; | 480 | >; |
| 321 | /** Links this environment with the stored CLI authorization and records the CLI's wish. */ | 481 | /** Links this environment with the stored CLI authorization and records the CLI's wish. */ |
| 322 | readonly reconcileDesiredLink: ( | 482 | readonly reconcileDesiredLink: ( |
| ... | @@ -324,9 +484,11 @@ export class CloudLink extends Context.Service< | ... | @@ -324,9 +484,11 @@ export class CloudLink extends Context.Service< |
| 324 | ) => Effect.Effect< | 484 | ) => Effect.Effect< |
| 325 | CliDesiredLinkMode, | 485 | CliDesiredLinkMode, |
| 326 | | CloudLinkBackgroundError | 486 | | CloudLinkBackgroundError |
| 327 | | EnvironmentHttpConflictError | 487 | | CloudLinkAccountMismatchError |
| | 488 | | CloudLinkAuthorizationMissingError |
| | 489 | | CloudLinkRelayConfigInvalidError |
| 328 | | EnvironmentAuth.ServerAuthInternalError | 490 | | EnvironmentAuth.ServerAuthInternalError |
| 329 | | CliTokenManager.CloudCliAuthorizationDeniedError | 491 | | CliTokenManager.CloudCliTokenManagerError |
| 330 | >; | 492 | >; |
| 331 | /** As `reconcileDesiredLink`, but returns null when the CLI no longer wants a link. */ | 493 | /** As `reconcileDesiredLink`, but returns null when the CLI no longer wants a link. */ |
| 332 | readonly reconcileDesiredLinkIfStillDesired: ( | 494 | readonly reconcileDesiredLinkIfStillDesired: ( |
| ... | @@ -334,9 +496,11 @@ export class CloudLink extends Context.Service< | ... | @@ -334,9 +496,11 @@ export class CloudLink extends Context.Service< |
| 334 | ) => Effect.Effect< | 496 | ) => Effect.Effect< |
| 335 | CliDesiredLinkMode | null, | 497 | CliDesiredLinkMode | null, |
| 336 | | CloudLinkBackgroundError | 498 | | CloudLinkBackgroundError |
| 337 | | EnvironmentHttpConflictError | 499 | | CloudLinkAccountMismatchError |
| | 500 | | CloudLinkAuthorizationMissingError |
| | 501 | | CloudLinkRelayConfigInvalidError |
| 338 | | EnvironmentAuth.ServerAuthInternalError | 502 | | EnvironmentAuth.ServerAuthInternalError |
| 339 | | CliTokenManager.CloudCliAuthorizationDeniedError | 503 | | CliTokenManager.CloudCliTokenManagerError |
| 340 | >; | 504 | >; |
| 341 | /** Tells the relay which local origin the stored tunnel serves, then starts it. */ | 505 | /** Tells the relay which local origin the stored tunnel serves, then starts it. */ |
| 342 | readonly registerManagedTunnelRecovery: ( | 506 | readonly registerManagedTunnelRecovery: ( |
| ... | @@ -359,8 +523,8 @@ export class CloudLink extends Context.Service< | ... | @@ -359,8 +523,8 @@ export class CloudLink extends Context.Service< |
| 359 | options?: { readonly requireConfirmedOrigin?: boolean }, | 523 | options?: { readonly requireConfirmedOrigin?: boolean }, |
| 360 | ) => Effect.Effect< | 524 | ) => Effect.Effect< |
| 361 | boolean, | 525 | boolean, |
| 362 | | EnvironmentCloudEndpointUnavailableError | 526 | | CloudLinkEndpointUnavailableError |
| 363 | | EnvironmentHttpBadRequestError | 527 | | CloudLinkOriginInvalidError |
| 364 | | ServerSecretStore.SecretStoreError | 528 | | ServerSecretStore.SecretStoreError |
| 365 | >; | 529 | >; |
| 366 | /** Deletes a CLI-managed tunnel when this server goes offline for good. */ | 530 | /** Deletes a CLI-managed tunnel when this server goes offline for good. */ |
| ... | @@ -399,7 +563,7 @@ const make = Effect.gen(function* () { | ... | @@ -399,7 +563,7 @@ const make = Effect.gen(function* () { |
| 399 | | 563 | |
| 400 | const validateLinkedCloudUser = ( | 564 | const validateLinkedCloudUser = ( |
| 401 | cloudUserId: string, | 565 | cloudUserId: string, |
| 402 | ): Effect.Effect<void, EnvironmentAuth.ServerAuthInternalError | EnvironmentHttpConflictError> => | 566 | ): Effect.Effect<void, EnvironmentAuth.ServerAuthInternalError | CloudLinkAccountMismatchError> => |
| 403 | secrets.get(CLOUD_LINKED_USER_ID).pipe( | 567 | secrets.get(CLOUD_LINKED_USER_ID).pipe( |
| 404 | Effect.mapError( | 568 | Effect.mapError( |
| 405 | (cause) => | 569 | (cause) => |
| ... | @@ -414,12 +578,7 @@ const make = Effect.gen(function* () { | ... | @@ -414,12 +578,7 @@ const make = Effect.gen(function* () { |
| 414 | const existingCloudUserId = bytesToString(existing.value); | 578 | const existingCloudUserId = bytesToString(existing.value); |
| 415 | return existingCloudUserId === cloudUserId | 579 | return existingCloudUserId === cloudUserId |
| 416 | ? Effect.void | 580 | ? Effect.void |
| 417 | : Effect.fail( | 581 | : Effect.fail(new CloudLinkAccountMismatchError({})); |
| 418 | new EnvironmentHttpConflictError({ | | |
| 419 | message: | | |
| 420 | "This environment is already linked to a different cloud account. Unlink it before switching accounts.", | | |
| 421 | }), | | |
| 422 | ); | | |
| 423 | }), | 582 | }), |
| 424 | ); | 583 | ); |
| 425 | | 584 | |
| ... | @@ -450,9 +609,7 @@ const make = Effect.gen(function* () { | ... | @@ -450,9 +609,7 @@ const make = Effect.gen(function* () { |
| 450 | requestUrl, | 609 | requestUrl, |
| 451 | }) | 610 | }) |
| 452 | ) { | 611 | ) { |
| 453 | return yield* new EnvironmentHttpBadRequestError({ | 612 | return yield* new CloudLinkOriginInvalidError({ origin: "endpoint" }); |
| 454 | message: "Invalid managed endpoint origin.", | | |
| 455 | }); | | |
| 456 | } | 613 | } |
| 457 | const now = yield* DateTime.now; | 614 | const now = yield* DateTime.now; |
| 458 | const expiresAt = DateTime.add(now, { minutes: 5 }); | 615 | const expiresAt = DateTime.add(now, { minutes: 5 }); |
| ... | @@ -491,24 +648,13 @@ const make = Effect.gen(function* () { | ... | @@ -491,24 +648,13 @@ const make = Effect.gen(function* () { |
| 491 | function* (request: RelayLinkProofRequest, httpRequest: HttpServerRequest.HttpServerRequest) { | 648 | function* (request: RelayLinkProofRequest, httpRequest: HttpServerRequest.HttpServerRequest) { |
| 492 | const requestUrl = requestAbsoluteUrl(httpRequest); | 649 | const requestUrl = requestAbsoluteUrl(httpRequest); |
| 493 | if (requestUrl === null || hasForwardedAuthorityHeaders(httpRequest)) { | 650 | if (requestUrl === null || hasForwardedAuthorityHeaders(httpRequest)) { |
| 494 | return yield* new EnvironmentHttpBadRequestError({ | 651 | return yield* new CloudLinkOriginInvalidError({ origin: "endpoint" }); |
| 495 | message: "Invalid managed endpoint origin.", | | |
| 496 | }); | | |
| 497 | } | 652 | } |
| 498 | const proof = yield* makeCloudLinkProof(request, requestUrl); | 653 | const proof = yield* makeCloudLinkProof(request, requestUrl); |
| 499 | return proof satisfies RelayEnvironmentLinkProof; | 654 | return proof satisfies RelayEnvironmentLinkProof; |
| 500 | }, | 655 | }, |
| 501 | Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => | 656 | Effect.catchIf(ServerSecretStore.isSecretStoreError, internalError("generate-link-proof")), |
| 502 | failEnvironmentCloudInternalError(error.message)(error), | 657 | Effect.catchTag("PlatformError", internalError("generate-link-proof")), |
| 503 | ), | | |
| 504 | Effect.catchIf( | | |
| 505 | ServerSecretStore.isSecretStoreError, | | |
| 506 | failEnvironmentCloudInternalError("Could not generate environment link proof."), | | |
| 507 | ), | | |
| 508 | Effect.catchTag( | | |
| 509 | "PlatformError", | | |
| 510 | failEnvironmentCloudInternalError("Could not generate environment link proof."), | | |
| 511 | ), | | |
| 512 | ); | 658 | ); |
| 513 | | 659 | |
| 514 | const activateManagedTunnel = Effect.fn("environment.cloud.activateManagedTunnel")( | 660 | const activateManagedTunnel = Effect.fn("environment.cloud.activateManagedTunnel")( |
| ... | @@ -528,8 +674,8 @@ const make = Effect.gen(function* () { | ... | @@ -528,8 +674,8 @@ const make = Effect.gen(function* () { |
| 528 | } | 674 | } |
| 529 | const status = yield* endpointRuntime.applyConfig(input.config); | 675 | const status = yield* endpointRuntime.applyConfig(input.config); |
| 530 | if (status.status !== "running") { | 676 | if (status.status !== "running") { |
| 531 | return yield* new EnvironmentCloudEndpointUnavailableError({ | 677 | return yield* new CloudLinkEndpointUnavailableError({ |
| 532 | message: "Managed endpoint runtime could not be started.", | 678 | reason: "runtime-not-started", |
| 533 | endpointRuntimeStatus: status, | 679 | endpointRuntimeStatus: status, |
| 534 | }); | 680 | }); |
| 535 | } | 681 | } |
| ... | @@ -557,7 +703,7 @@ const make = Effect.gen(function* () { | ... | @@ -557,7 +703,7 @@ const make = Effect.gen(function* () { |
| 557 | ? activate.pipe( | 703 | ? activate.pipe( |
| 558 | Effect.retry({ | 704 | Effect.retry({ |
| 559 | while: (error) => | 705 | while: (error) => |
| 560 | error._tag === "EnvironmentCloudEndpointUnavailableError" && | 706 | error._tag === "CloudLinkEndpointUnavailableError" && |
| 561 | ManagedEndpointRuntime.isRetryableManagedEndpointRuntimeStatus( | 707 | ManagedEndpointRuntime.isRetryableManagedEndpointRuntimeStatus( |
| 562 | error.endpointRuntimeStatus, | 708 | error.endpointRuntimeStatus, |
| 563 | ), | 709 | ), |
| ... | @@ -578,10 +724,7 @@ const make = Effect.gen(function* () { | ... | @@ -578,10 +724,7 @@ const make = Effect.gen(function* () { |
| 578 | const requireConfirmedOrigin = options?.requireConfirmedOrigin ?? true; | 724 | const requireConfirmedOrigin = options?.requireConfirmedOrigin ?? true; |
| 579 | const parsedOrigin = yield* Effect.try({ | 725 | const parsedOrigin = yield* Effect.try({ |
| 580 | try: () => parseManagedEndpointLocalOrigin(localOrigin), | 726 | try: () => parseManagedEndpointLocalOrigin(localOrigin), |
| 581 | catch: () => | 727 | catch: () => new CloudLinkOriginInvalidError({ origin: "local" }), |
| 582 | new EnvironmentHttpBadRequestError({ | | |
| 583 | message: "Could not resolve local environment origin.", | | |
| 584 | }), | | |
| 585 | }); | 728 | }); |
| 586 | return yield* endpointRuntime.withLinkStateLock( | 729 | return yield* endpointRuntime.withLinkStateLock( |
| 587 | Effect.gen(function* () { | 730 | Effect.gen(function* () { |
| ... | @@ -610,8 +753,8 @@ const make = Effect.gen(function* () { | ... | @@ -610,8 +753,8 @@ const make = Effect.gen(function* () { |
| 610 | } | 753 | } |
| 611 | const status = yield* endpointRuntime.applyConfig(config); | 754 | const status = yield* endpointRuntime.applyConfig(config); |
| 612 | if (status.status !== "running") { | 755 | if (status.status !== "running") { |
| 613 | return yield* new EnvironmentCloudEndpointUnavailableError({ | 756 | return yield* new CloudLinkEndpointUnavailableError({ |
| 614 | message: "Managed endpoint runtime could not be started.", | 757 | reason: "runtime-not-started", |
| 615 | endpointRuntimeStatus: status, | 758 | endpointRuntimeStatus: status, |
| 616 | }); | 759 | }); |
| 617 | } | 760 | } |
| ... | @@ -637,8 +780,8 @@ const make = Effect.gen(function* () { | ... | @@ -637,8 +780,8 @@ const make = Effect.gen(function* () { |
| 637 | payload.endpointRuntime !== null && | 780 | payload.endpointRuntime !== null && |
| 638 | payload.endpointRuntime.providerKind !== "cloudflare_tunnel" | 781 | payload.endpointRuntime.providerKind !== "cloudflare_tunnel" |
| 639 | ) { | 782 | ) { |
| 640 | return yield* new EnvironmentCloudEndpointUnavailableError({ | 783 | return yield* new CloudLinkEndpointUnavailableError({ |
| 641 | message: "Managed endpoint runtime could not be started.", | 784 | reason: "runtime-not-started", |
| 642 | endpointRuntimeStatus: { | 785 | endpointRuntimeStatus: { |
| 643 | status: "unsupported", | 786 | status: "unsupported", |
| 644 | providerKind: payload.endpointRuntime.providerKind, | 787 | providerKind: payload.endpointRuntime.providerKind, |
| ... | @@ -674,8 +817,8 @@ const make = Effect.gen(function* () { | ... | @@ -674,8 +817,8 @@ const make = Effect.gen(function* () { |
| 674 | } | 817 | } |
| 675 | const endpointRuntimeStatus = yield* endpointRuntime.applyConfig(payload.endpointRuntime); | 818 | const endpointRuntimeStatus = yield* endpointRuntime.applyConfig(payload.endpointRuntime); |
| 676 | if (endpointRuntimeStatus.status !== "running") { | 819 | if (endpointRuntimeStatus.status !== "running") { |
| 677 | return yield* new EnvironmentCloudEndpointUnavailableError({ | 820 | return yield* new CloudLinkEndpointUnavailableError({ |
| 678 | message: "Managed endpoint runtime could not be started.", | 821 | reason: "runtime-not-started", |
| 679 | endpointRuntimeStatus, | 822 | endpointRuntimeStatus, |
| 680 | }); | 823 | }); |
| 681 | } | 824 | } |
| ... | @@ -711,20 +854,12 @@ const make = Effect.gen(function* () { | ... | @@ -711,20 +854,12 @@ const make = Effect.gen(function* () { |
| 711 | function* (localOrigin: string) { | 854 | function* (localOrigin: string) { |
| 712 | const parsedOrigin = yield* Effect.try({ | 855 | const parsedOrigin = yield* Effect.try({ |
| 713 | try: () => parseManagedEndpointLocalOrigin(localOrigin), | 856 | try: () => parseManagedEndpointLocalOrigin(localOrigin), |
| 714 | catch: () => | 857 | catch: () => new CloudLinkOriginInvalidError({ origin: "local" }), |
| 715 | new EnvironmentHttpBadRequestError({ | | |
| 716 | message: "Could not resolve local environment origin.", | | |
| 717 | }), | | |
| 718 | }); | 858 | }); |
| 719 | const token = yield* cliTokenManager.getExisting.pipe( | 859 | const token = yield* cliTokenManager.getExisting.pipe( |
| 720 | Effect.flatMap( | 860 | Effect.flatMap( |
| 721 | Option.match({ | 861 | Option.match({ |
| 722 | onNone: () => | 862 | onNone: () => Effect.fail(new CloudLinkAuthorizationMissingError({})), |
| 723 | Effect.fail( | | |
| 724 | new EnvironmentHttpUnauthorizedError({ | | |
| 725 | message: "Run `t3 connect link` to authorize this environment.", | | |
| 726 | }), | | |
| 727 | ), | | |
| 728 | onSome: Effect.succeed, | 863 | onSome: Effect.succeed, |
| 729 | }), | 864 | }), |
| 730 | ), | 865 | ), |
| ... | @@ -786,17 +921,7 @@ const make = Effect.gen(function* () { | ... | @@ -786,17 +921,7 @@ const make = Effect.gen(function* () { |
| 786 | // actually used, not from a value read before the relay round trip. | 921 | // actually used, not from a value read before the relay round trip. |
| 787 | return mode; | 922 | return mode; |
| 788 | }, | 923 | }, |
| 789 | Effect.catchIf( | 924 | Effect.catchIf(ServerSecretStore.isSecretStoreError, internalError("persist-desired-link")), |
| 790 | ServerSecretStore.isSecretStoreError, | | |
| 791 | failEnvironmentCloudInternalError("Could not persist desired T3 Connect link state."), | | |
| 792 | ), | | |
| 793 | Effect.catchTags({ | | |
| 794 | CloudCliCredentialRemovalError: failCloudCliTokenManagerError, | | |
| 795 | CloudCliCredentialRefreshError: failCloudCliTokenManagerError, | | |
| 796 | CloudCliCredentialReadError: failCloudCliTokenManagerError, | | |
| 797 | CloudCliAuthorizationError: failCloudCliTokenManagerError, | | |
| 798 | CloudCliAuthorizationTimeoutError: failCloudCliTokenManagerError, | | |
| 799 | }), | | |
| 800 | ); | 925 | ); |
| 801 | | 926 | |
| 802 | const reconcileDesiredLink = Effect.fn("environment.cloud.reconcileDesiredLink")(function* ( | 927 | const reconcileDesiredLink = Effect.fn("environment.cloud.reconcileDesiredLink")(function* ( |
| ... | @@ -853,10 +978,7 @@ const make = Effect.gen(function* () { | ... | @@ -853,10 +978,7 @@ const make = Effect.gen(function* () { |
| 853 | payload, | 978 | payload, |
| 854 | }).pipe( | 979 | }).pipe( |
| 855 | Effect.mapError( | 980 | Effect.mapError( |
| 856 | () => | 981 | (cause) => new CloudLinkInternalError({ operation: "sign-recovery-proof", cause }), |
| 857 | new EnvironmentHttpInternalServerError({ | | |
| 858 | message: "Could not sign the managed tunnel recovery request.", | | |
| 859 | }), | | |
| 860 | ), | 982 | ), |
| 861 | ); | 983 | ); |
| 862 | }); | 984 | }); |
| ... | @@ -886,10 +1008,7 @@ const make = Effect.gen(function* () { | ... | @@ -886,10 +1008,7 @@ const make = Effect.gen(function* () { |
| 886 | | 1008 | |
| 887 | const parsedOrigin = yield* Effect.try({ | 1009 | const parsedOrigin = yield* Effect.try({ |
| 888 | try: () => parseManagedEndpointLocalOrigin(localOrigin), | 1010 | try: () => parseManagedEndpointLocalOrigin(localOrigin), |
| 889 | catch: () => | 1011 | catch: () => new CloudLinkOriginInvalidError({ origin: "local" }), |
| 890 | new EnvironmentHttpBadRequestError({ | | |
| 891 | message: "Could not resolve local environment origin.", | | |
| 892 | }), | | |
| 893 | }); | 1012 | }); |
| 894 | if (config.tunnelId === undefined) { | 1013 | if (config.tunnelId === undefined) { |
| 895 | return { status: "recovery_required" as const, config }; | 1014 | return { status: "recovery_required" as const, config }; |
| ... | @@ -967,10 +1086,7 @@ const make = Effect.gen(function* () { | ... | @@ -967,10 +1086,7 @@ const make = Effect.gen(function* () { |
| 967 | | 1086 | |
| 968 | const parsedOrigin = yield* Effect.try({ | 1087 | const parsedOrigin = yield* Effect.try({ |
| 969 | try: () => parseManagedEndpointLocalOrigin(localOrigin), | 1088 | try: () => parseManagedEndpointLocalOrigin(localOrigin), |
| 970 | catch: () => | 1089 | catch: () => new CloudLinkOriginInvalidError({ origin: "local" }), |
| 971 | new EnvironmentHttpBadRequestError({ | | |
| 972 | message: "Could not resolve local environment origin.", | | |
| 973 | }), | | |
| 974 | }); | 1090 | }); |
| 975 | | 1091 | |
| 976 | const environmentId = yield* environment.getEnvironmentId; | 1092 | const environmentId = yield* environment.getEnvironmentId; |
| ... | @@ -996,17 +1112,12 @@ const make = Effect.gen(function* () { | ... | @@ -996,17 +1112,12 @@ const make = Effect.gen(function* () { |
| 996 | timeout: MANAGED_ENDPOINT_PROVISION_REQUEST_TIMEOUT, | 1112 | timeout: MANAGED_ENDPOINT_PROVISION_REQUEST_TIMEOUT, |
| 997 | }); | 1113 | }); |
| 998 | if (recovered.endpointRuntime.providerKind !== "cloudflare_tunnel") { | 1114 | if (recovered.endpointRuntime.providerKind !== "cloudflare_tunnel") { |
| 999 | return yield* new EnvironmentHttpInternalServerError({ | 1115 | return yield* new CloudLinkInternalError({ operation: "unsupported-recovered-tunnel" }); |
| 1000 | message: "T3 Connect returned an unsupported managed tunnel configuration.", | | |
| 1001 | }); | | |
| 1002 | } | 1116 | } |
| 1003 | | 1117 | |
| 1004 | const encoded = yield* encodeEndpointRuntimeConfigJson(recovered.endpointRuntime).pipe( | 1118 | const encoded = yield* encodeEndpointRuntimeConfigJson(recovered.endpointRuntime).pipe( |
| 1005 | Effect.mapError( | 1119 | Effect.mapError( |
| 1006 | () => | 1120 | (cause) => new CloudLinkInternalError({ operation: "persist-recovered-tunnel", cause }), |
| 1007 | new EnvironmentHttpInternalServerError({ | | |
| 1008 | message: "Could not persist the recovered managed tunnel configuration.", | | |
| 1009 | }), | | |
| 1010 | ), | 1121 | ), |
| 1011 | ); | 1122 | ); |
| 1012 | const stored = yield* endpointRuntime.withLinkStateLock( | 1123 | const stored = yield* endpointRuntime.withLinkStateLock( |
| ... | @@ -1041,9 +1152,7 @@ const make = Effect.gen(function* () { | ... | @@ -1041,9 +1152,7 @@ const make = Effect.gen(function* () { |
| 1041 | if (payload.endpointRuntime?.providerKind === "cloudflare_tunnel") { | 1152 | if (payload.endpointRuntime?.providerKind === "cloudflare_tunnel") { |
| 1042 | const address = httpServer.address; | 1153 | const address = httpServer.address; |
| 1043 | if (typeof address === "string" || !("port" in address)) { | 1154 | if (typeof address === "string" || !("port" in address)) { |
| 1044 | return yield* new EnvironmentHttpInternalServerError({ | 1155 | return yield* new CloudLinkInternalError({ operation: "resolve-server-origin" }); |
| 1045 | message: "Could not resolve the local server origin.", | | |
| 1046 | }); | | |
| 1047 | } | 1156 | } |
| 1048 | const registration = yield* registerManagedTunnelRecovery( | 1157 | const registration = yield* registerManagedTunnelRecovery( |
| 1049 | `http://127.0.0.1:${address.port}`, | 1158 | `http://127.0.0.1:${address.port}`, |
| ... | @@ -1051,21 +1160,18 @@ const make = Effect.gen(function* () { | ... | @@ -1051,21 +1160,18 @@ const make = Effect.gen(function* () { |
| 1051 | Effect.retry({ | 1160 | Effect.retry({ |
| 1052 | times: 2, | 1161 | times: 2, |
| 1053 | while: (error) => | 1162 | while: (error) => |
| 1054 | shouldRetryCloudLink(error) && | 1163 | shouldRetryCloudLink(error) && error._tag !== "CloudLinkEndpointUnavailableError", |
| 1055 | error._tag !== "EnvironmentCloudEndpointUnavailableError", | | |
| 1056 | }), | 1164 | }), |
| 1057 | ); | 1165 | ); |
| 1058 | if (registration.status === "superseded") { | 1166 | if (registration.status === "superseded") { |
| 1059 | return yield* new EnvironmentHttpConflictError({ | 1167 | return yield* new CloudLinkTunnelSupersededError({}); |
| 1060 | message: "The managed tunnel configuration changed during registration.", | | |
| 1061 | }); | | |
| 1062 | } | 1168 | } |
| 1063 | if (registration.status === "recovery_required") { | 1169 | if (registration.status === "recovery_required") { |
| 1064 | yield* endpointRuntime.requestRecovery(registration.config); | 1170 | yield* endpointRuntime.requestRecovery(registration.config); |
| 1065 | } | 1171 | } |
| 1066 | if (registration.status !== "ready") { | 1172 | if (registration.status !== "ready") { |
| 1067 | return yield* new EnvironmentCloudEndpointUnavailableError({ | 1173 | return yield* new CloudLinkEndpointUnavailableError({ |
| 1068 | message: "Managed endpoint origin could not be confirmed.", | 1174 | reason: "origin-unconfirmed", |
| 1069 | endpointRuntimeStatus: { status: "disabled" }, | 1175 | endpointRuntimeStatus: { status: "disabled" }, |
| 1070 | }); | 1176 | }); |
| 1071 | } | 1177 | } |
| ... | @@ -1076,20 +1182,10 @@ const make = Effect.gen(function* () { | ... | @@ -1076,20 +1182,10 @@ const make = Effect.gen(function* () { |
| 1076 | } | 1182 | } |
| 1077 | return result; | 1183 | return result; |
| 1078 | }, | 1184 | }, |
| 1079 | Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => | 1185 | Effect.catchIf(ServerSecretStore.isSecretStoreError, internalError("persist-relay-config")), |
| 1080 | failEnvironmentCloudInternalError(error.message)(error), | | |
| 1081 | ), | | |
| 1082 | Effect.catchIf( | | |
| 1083 | ServerSecretStore.isSecretStoreError, | | |
| 1084 | failEnvironmentCloudInternalError("Could not persist environment relay configuration."), | | |
| 1085 | ), | | |
| 1086 | Effect.catchTags({ | 1186 | Effect.catchTags({ |
| 1087 | SchemaError: failEnvironmentCloudInternalError( | 1187 | SchemaError: internalError("persist-relay-config"), |
| 1088 | "Could not persist environment relay configuration.", | 1188 | PlatformError: internalError("register-endpoint-origin"), |
| 1089 | ), | | |
| 1090 | PlatformError: failEnvironmentCloudInternalError( | | |
| 1091 | "Could not register the managed endpoint origin.", | | |
| 1092 | ), | | |
| 1093 | }), | 1189 | }), |
| 1094 | ); | 1190 | ); |
| 1095 | | 1191 | |
| ... | @@ -1216,10 +1312,7 @@ const make = Effect.gen(function* () { | ... | @@ -1216,10 +1312,7 @@ const make = Effect.gen(function* () { |
| 1216 | function* () { | 1312 | function* () { |
| 1217 | return yield* readCloudLinkState(); | 1313 | return yield* readCloudLinkState(); |
| 1218 | }, | 1314 | }, |
| 1219 | Effect.catchIf( | 1315 | Effect.catchIf(ServerSecretStore.isSecretStoreError, internalError("read-relay-config")), |
| 1220 | ServerSecretStore.isSecretStoreError, | | |
| 1221 | failEnvironmentCloudInternalError("Could not read environment relay configuration."), | | |
| 1222 | ), | | |
| 1223 | ); | 1316 | ); |
| 1224 | | 1317 | |
| 1225 | const unlink = Effect.fn("environment.cloud.unlink")( | 1318 | const unlink = Effect.fn("environment.cloud.unlink")( |
| ... | @@ -1246,19 +1339,14 @@ const make = Effect.gen(function* () { | ... | @@ -1246,19 +1339,14 @@ const make = Effect.gen(function* () { |
| 1246 | }), | 1339 | }), |
| 1247 | ); | 1340 | ); |
| 1248 | }, | 1341 | }, |
| 1249 | Effect.catchIf( | 1342 | Effect.catchIf(ServerSecretStore.isSecretStoreError, internalError("remove-relay-config")), |
| 1250 | ServerSecretStore.isSecretStoreError, | | |
| 1251 | failEnvironmentCloudInternalError("Could not remove environment relay configuration."), | | |
| 1252 | ), | | |
| 1253 | ); | 1343 | ); |
| 1254 | | 1344 | |
| 1255 | const pushHoldWebhooksWhileOffline = Effect.fn("CloudPreferences.pushHoldWebhooksWhileOffline")( | 1345 | const pushHoldWebhooksWhileOffline = Effect.fn("CloudPreferences.pushHoldWebhooksWhileOffline")( |
| 1256 | function* (holdWebhooksWhileOffline: boolean) { | 1346 | function* (holdWebhooksWhileOffline: boolean) { |
| 1257 | const connection = yield* readRelayConnection.pipe(withSecrets); | 1347 | const connection = yield* readRelayConnection.pipe(withSecrets); |
| 1258 | if (connection === null) { | 1348 | if (connection === null) { |
| 1259 | return yield* new EnvironmentHttpBadRequestError({ | 1349 | return yield* new CloudLinkNotLinkedError({}); |
| 1260 | message: "Link this environment to T3 Connect first.", | | |
| 1261 | }); | | |
| 1262 | } | 1350 | } |
| 1263 | const environmentId = yield* environment.getEnvironmentId; | 1351 | const environmentId = yield* environment.getEnvironmentId; |
| 1264 | const client = yield* makeRelayEnvironmentClient(connection); | 1352 | const client = yield* makeRelayEnvironmentClient(connection); |
| ... | @@ -1267,23 +1355,14 @@ const make = Effect.gen(function* () { | ... | @@ -1267,23 +1355,14 @@ const make = Effect.gen(function* () { |
| 1267 | params: { environmentId }, | 1355 | params: { environmentId }, |
| 1268 | payload: { holdWebhooksWhileOffline }, | 1356 | payload: { holdWebhooksWhileOffline }, |
| 1269 | }) | 1357 | }) |
| 1270 | .pipe( | 1358 | .pipe(Effect.timeout("10 seconds"), Effect.catch(internalError("update-webhook-settings"))); |
| 1271 | Effect.timeout("10 seconds"), | | |
| 1272 | Effect.catch( | | |
| 1273 | failEnvironmentCloudInternalError("Could not update T3 Connect webhook settings."), | | |
| 1274 | ), | | |
| 1275 | ); | | |
| 1276 | }, | 1359 | }, |
| 1277 | ); | 1360 | ); |
| 1278 | | 1361 | |
| 1279 | const savePreference = (name: string, value: boolean) => | 1362 | const savePreference = (name: string, value: boolean) => |
| 1280 | secrets | 1363 | secrets |
| 1281 | .set(name, stringToBytes(String(value))) | 1364 | .set(name, stringToBytes(String(value))) |
| 1282 | .pipe( | 1365 | .pipe(Effect.catch(internalError("persist-preferences"))); |
| 1283 | Effect.catch( | | |
| 1284 | failEnvironmentCloudInternalError("Could not persist environment cloud preferences."), | | |
| 1285 | ), | | |
| 1286 | ); | | |
| 1287 | | 1366 | |
| 1288 | // One update at a time, so two requests can't each leave one setting behind. | 1367 | // One update at a time, so two requests can't each leave one setting behind. |
| 1289 | const preferencesLock = yield* Semaphore.make(1); | 1368 | const preferencesLock = yield* Semaphore.make(1); |
| ... | @@ -1295,13 +1374,7 @@ const make = Effect.gen(function* () { | ... | @@ -1295,13 +1374,7 @@ const make = Effect.gen(function* () { |
| 1295 | // current values are read up front; a failed read stops here, before | 1374 | // current values are read up front; a failed read stops here, before |
| 1296 | // anything changes, because a guessed value would be the rollback target. | 1375 | // anything changes, because a guessed value would be the rollback target. |
| 1297 | const readCurrent = (name: string) => | 1376 | const readCurrent = (name: string) => |
| 1298 | secrets | 1377 | secrets.get(name).pipe(Effect.catch(internalError("read-preferences"))); |
| 1299 | .get(name) | | |
| 1300 | .pipe( | | |
| 1301 | Effect.catch( | | |
| 1302 | failEnvironmentCloudInternalError("Could not read environment cloud preferences."), | | |
| 1303 | ), | | |
| 1304 | ); | | |
| 1305 | const previousActivity = yield* readCurrent(PUBLISH_AGENT_ACTIVITY_SECRET); | 1378 | const previousActivity = yield* readCurrent(PUBLISH_AGENT_ACTIVITY_SECRET); |
| 1306 | const previousHold = yield* readCurrent(HOLD_WEBHOOKS_WHILE_OFFLINE_SECRET); | 1379 | const previousHold = yield* readCurrent(HOLD_WEBHOOKS_WHILE_OFFLINE_SECRET); |
| 1307 | yield* savePreference(PUBLISH_AGENT_ACTIVITY_SECRET, input.publishAgentActivity); | 1380 | yield* savePreference(PUBLISH_AGENT_ACTIVITY_SECRET, input.publishAgentActivity); |
| ... | @@ -1342,10 +1415,7 @@ const make = Effect.gen(function* () { | ... | @@ -1342,10 +1415,7 @@ const make = Effect.gen(function* () { |
| 1342 | yield* savePreferences(input); | 1415 | yield* savePreferences(input); |
| 1343 | return yield* readCloudLinkState(); | 1416 | return yield* readCloudLinkState(); |
| 1344 | }, | 1417 | }, |
| 1345 | Effect.catchIf( | 1418 | Effect.catchIf(ServerSecretStore.isSecretStoreError, internalError("read-preferences")), |
| 1346 | ServerSecretStore.isSecretStoreError, | | |
| 1347 | failEnvironmentCloudInternalError("Could not read environment cloud preferences."), | | |
| 1348 | ), | | |
| 1349 | ); | 1419 | ); |
| 1350 | | 1420 | |
| 1351 | const answerHealthRequest = Effect.fn("environment.cloud.health")( | 1421 | const answerHealthRequest = Effect.fn("environment.cloud.health")( |
| ... | @@ -1397,9 +1467,7 @@ const make = Effect.gen(function* () { | ... | @@ -1397,9 +1467,7 @@ const make = Effect.gen(function* () { |
| 1397 | !hasBoundedCloudProofLifetime({ ...proofOption.value, nowSeconds }) || | 1467 | !hasBoundedCloudProofLifetime({ ...proofOption.value, nowSeconds }) || |
| 1398 | !hasExactScope({ scopes: proofOption.value.scope, expected: "environment:status" }) | 1468 | !hasExactScope({ scopes: proofOption.value.scope, expected: "environment:status" }) |
| 1399 | ) { | 1469 | ) { |
| 1400 | return yield* new EnvironmentHttpUnauthorizedError({ | 1470 | return yield* new CloudLinkProofRejectedError({ request: "health" }); |
| 1401 | message: "Invalid cloud health request.", | | |
| 1402 | }); | | |
| 1403 | } | 1471 | } |
| 1404 | const proof = proofOption.value; | 1472 | const proof = proofOption.value; |
| 1405 | | 1473 | |
| ... | @@ -1411,9 +1479,7 @@ const make = Effect.gen(function* () { | ... | @@ -1411,9 +1479,7 @@ const make = Effect.gen(function* () { |
| 1411 | value: stringToBytes(DateTime.formatIso(now)), | 1479 | value: stringToBytes(DateTime.formatIso(now)), |
| 1412 | }); | 1480 | }); |
| 1413 | if (!consumedReplayGuards) { | 1481 | if (!consumedReplayGuards) { |
| 1414 | return yield* new EnvironmentHttpConflictError({ | 1482 | return yield* new CloudLinkProofReplayedError({ request: "health" }); |
| 1415 | message: "Cloud health request was already consumed.", | | |
| 1416 | }); | | |
| 1417 | } | 1483 | } |
| 1418 | | 1484 | |
| 1419 | const keyPair = yield* getOrCreateEnvironmentKeyPairFromSecretStore(secrets); | 1485 | const keyPair = yield* getOrCreateEnvironmentKeyPairFromSecretStore(secrets); |
| ... | @@ -1452,17 +1518,8 @@ const make = Effect.gen(function* () { | ... | @@ -1452,17 +1518,8 @@ const make = Effect.gen(function* () { |
| 1452 | proof: responseProof, | 1518 | proof: responseProof, |
| 1453 | } satisfies RelayEnvironmentHealthResponse; | 1519 | } satisfies RelayEnvironmentHealthResponse; |
| 1454 | }, | 1520 | }, |
| 1455 | Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => | 1521 | Effect.catchIf(ServerSecretStore.isSecretStoreError, internalError("answer-health")), |
| 1456 | failEnvironmentCloudInternalError(error.message)(error), | 1522 | Effect.catchTag("PlatformError", internalError("answer-health")), |
| 1457 | ), | | |
| 1458 | Effect.catchIf( | | |
| 1459 | ServerSecretStore.isSecretStoreError, | | |
| 1460 | failEnvironmentCloudInternalError("Could not answer cloud health request."), | | |
| 1461 | ), | | |
| 1462 | Effect.catchTag( | | |
| 1463 | "PlatformError", | | |
| 1464 | failEnvironmentCloudInternalError("Could not answer cloud health request."), | | |
| 1465 | ), | | |
| 1466 | ); | 1523 | ); |
| 1467 | | 1524 | |
| 1468 | const mintCredential = Effect.fn("environment.cloud.mintCredential")( | 1525 | const mintCredential = Effect.fn("environment.cloud.mintCredential")( |
| ... | @@ -1515,9 +1572,7 @@ const make = Effect.gen(function* () { | ... | @@ -1515,9 +1572,7 @@ const make = Effect.gen(function* () { |
| 1515 | !hasBoundedCloudProofLifetime({ ...proofOption.value, nowSeconds }) || | 1572 | !hasBoundedCloudProofLifetime({ ...proofOption.value, nowSeconds }) || |
| 1516 | !hasExactScope({ scopes: proofOption.value.scope, expected: "environment:connect" }) | 1573 | !hasExactScope({ scopes: proofOption.value.scope, expected: "environment:connect" }) |
| 1517 | ) { | 1574 | ) { |
| 1518 | return yield* new EnvironmentHttpUnauthorizedError({ | 1575 | return yield* new CloudLinkProofRejectedError({ request: "mint" }); |
| 1519 | message: "Invalid cloud mint request.", | | |
| 1520 | }); | | |
| 1521 | } | 1576 | } |
| 1522 | const proof = proofOption.value; | 1577 | const proof = proofOption.value; |
| 1523 | | 1578 | |
| ... | @@ -1529,9 +1584,7 @@ const make = Effect.gen(function* () { | ... | @@ -1529,9 +1584,7 @@ const make = Effect.gen(function* () { |
| 1529 | value: stringToBytes(DateTime.formatIso(now)), | 1584 | value: stringToBytes(DateTime.formatIso(now)), |
| 1530 | }); | 1585 | }); |
| 1531 | if (!consumedReplayGuards) { | 1586 | if (!consumedReplayGuards) { |
| 1532 | return yield* new EnvironmentHttpConflictError({ | 1587 | return yield* new CloudLinkProofReplayedError({ request: "mint" }); |
| 1533 | message: "Cloud mint request was already consumed.", | | |
| 1534 | }); | | |
| 1535 | } | 1588 | } |
| 1536 | | 1589 | |
| 1537 | const keyPair = yield* getOrCreateEnvironmentKeyPairFromSecretStore(secrets); | 1590 | const keyPair = yield* getOrCreateEnvironmentKeyPairFromSecretStore(secrets); |
| ... | @@ -1572,17 +1625,8 @@ const make = Effect.gen(function* () { | ... | @@ -1572,17 +1625,8 @@ const make = Effect.gen(function* () { |
| 1572 | proof: responseProof, | 1625 | proof: responseProof, |
| 1573 | } satisfies RelayEnvironmentMintResponse; | 1626 | } satisfies RelayEnvironmentMintResponse; |
| 1574 | }, | 1627 | }, |
| 1575 | Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => | 1628 | Effect.catchIf(ServerSecretStore.isSecretStoreError, internalError("issue-credential")), |
| 1576 | failEnvironmentCloudInternalError(error.message)(error), | 1629 | Effect.catchTag("PlatformError", internalError("issue-credential")), |
| 1577 | ), | | |
| 1578 | Effect.catchIf( | | |
| 1579 | ServerSecretStore.isSecretStoreError, | | |
| 1580 | failEnvironmentCloudInternalError("Could not issue cloud connection credential."), | | |
| 1581 | ), | | |
| 1582 | Effect.catchTag( | | |
| 1583 | "PlatformError", | | |
| 1584 | failEnvironmentCloudInternalError("Could not issue cloud connection credential."), | | |
| 1585 | ), | | |
| 1586 | ); | 1630 | ); |
| 1587 | | 1631 | |
| 1588 | return CloudLink.of({ | 1632 | return CloudLink.of({ |