diff --git a/apps/mobile/src/features/cloud/linkEnvironment.test.ts b/apps/mobile/src/features/cloud/linkEnvironment.test.ts index b9ab3aeab05..2e3476cdbf4 100644 --- a/apps/mobile/src/features/cloud/linkEnvironment.test.ts +++ b/apps/mobile/src/features/cloud/linkEnvironment.test.ts @@ -607,7 +607,8 @@ describe("mobile cloud link environment client", () => { Response.json( { _tag: "EnvironmentHttpUnauthorizedError", - message: "Invalid environment bearer session.", + reason: "cloud_cli_authorization_required", + message: "Run `t3 connect link` to authorize this environment.", }, { status: 401 }, ), @@ -623,7 +624,7 @@ describe("mobile cloud link environment client", () => { ).pipe(Effect.flip); expect(error._tag).toBe("CloudEnvironmentLinkError"); expect(error.message).toBe( - "Could not obtain environment link proof: Invalid environment bearer session.", + "Could not obtain environment link proof: Run `t3 connect link` to authorize this environment.", ); expect(fetchMock).toHaveBeenCalledTimes(2); }), diff --git a/apps/server/src/auth/ServerSecretStore.test.ts b/apps/server/src/auth/ServerSecretStore.test.ts index d4411fb9f3b..50fca258a0b 100644 --- a/apps/server/src/auth/ServerSecretStore.test.ts +++ b/apps/server/src/auth/ServerSecretStore.test.ts @@ -45,6 +45,31 @@ const makePermissionDeniedSecretStoreLayer = () => Layer.provideMerge(PermissionDeniedFileSystemLayer), ); +const DirectoryCreateFailureFileSystemLayer = Layer.effect( + FileSystem.FileSystem, + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + return { + ...fileSystem, + makeDirectory: (path) => + Effect.fail( + PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "makeDirectory", + pathOrDescriptor: String(path), + }), + ), + } satisfies FileSystem.FileSystem; + }), +).pipe(Layer.provide(NodeServices.layer)); + +const makeDirectoryCreateFailureSecretStoreLayer = (config: ServerConfig.ServerConfig["Service"]) => + ServerSecretStore.layer.pipe( + Layer.provide(Layer.succeed(ServerConfig.ServerConfig, config)), + Layer.provideMerge(DirectoryCreateFailureFileSystemLayer), + ); + const RenameFailureFileSystemLayer = Layer.effect( FileSystem.FileSystem, Effect.gen(function* () { @@ -146,6 +171,20 @@ const makeConcurrentCreateSecretStoreLayer = () => ); it.layer(NodeServices.layer)("ServerSecretStore.layer", (it) => { + it.effect("preserves directory context when secret-store initialization fails", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig.pipe(Effect.provide(makeServerConfigLayer())); + const error = yield* Layer.build(makeDirectoryCreateFailureSecretStoreLayer(config)).pipe( + Effect.scoped, + Effect.flip, + ); + + assert.instanceOf(error, ServerSecretStore.SecretStoreDirectoryCreateError); + assert.match(error.directoryPath, /secrets$/u); + assert.instanceOf(error.cause, PlatformError.PlatformError); + }), + ); + it.effect("returns Option.none when a secret file does not exist", () => Effect.gen(function* () { const secretStore = yield* ServerSecretStore.ServerSecretStore; @@ -232,7 +271,8 @@ it.layer(NodeServices.layer)("ServerSecretStore.layer", (it) => { const error = yield* Effect.flip(secretStore.getOrCreateRandom("session-signing-key", 32)); assert.instanceOf(error, ServerSecretStore.SecretStoreReadError); - assert.include(error.message, "Failed to read secret session-signing-key."); + assert.equal(error.secretName, "session-signing-key"); + assert.match(error.secretPath, /session-signing-key\.bin$/u); assert.instanceOf(error.cause, PlatformError.PlatformError); assert.equal((error.cause as PlatformError.PlatformError).reason._tag, "PermissionDenied"); }).pipe(Effect.provide(makePermissionDeniedSecretStoreLayer())), @@ -247,7 +287,9 @@ it.layer(NodeServices.layer)("ServerSecretStore.layer", (it) => { ); assert.instanceOf(error, ServerSecretStore.SecretStorePersistError); - assert.include(error.message, "Failed to persist secret session-signing-key."); + assert.equal(error.operation, "set"); + assert.equal(error.secretName, "session-signing-key"); + assert.match(error.secretPath, /session-signing-key\.bin$/u); assert.instanceOf(error.cause, PlatformError.PlatformError); assert.equal((error.cause as PlatformError.PlatformError).reason._tag, "PermissionDenied"); }).pipe(Effect.provide(makeRenameFailureSecretStoreLayer())), @@ -260,7 +302,8 @@ it.layer(NodeServices.layer)("ServerSecretStore.layer", (it) => { const error = yield* Effect.flip(secretStore.remove("session-signing-key")); assert.instanceOf(error, ServerSecretStore.SecretStoreRemoveError); - assert.include(error.message, "Failed to remove secret session-signing-key."); + assert.equal(error.secretName, "session-signing-key"); + assert.match(error.secretPath, /session-signing-key\.bin$/u); assert.instanceOf(error.cause, PlatformError.PlatformError); assert.equal((error.cause as PlatformError.PlatformError).reason._tag, "PermissionDenied"); }).pipe(Effect.provide(makeRemoveFailureSecretStoreLayer())), diff --git a/apps/server/src/auth/ServerSecretStore.ts b/apps/server/src/auth/ServerSecretStore.ts index 5e9890c1ea2..bbd567a9416 100644 --- a/apps/server/src/auth/ServerSecretStore.ts +++ b/apps/server/src/auth/ServerSecretStore.ts @@ -11,114 +11,136 @@ import * as Schema from "effect/Schema"; import * as ServerConfig from "../config.ts"; -const secretStoreErrorContext = { - resource: Schema.String, +const storedSecretErrorContext = { + secretName: Schema.String, + secretPath: Schema.String, cause: Schema.Defect(), }; -export class SecretStoreSecureError extends Schema.TaggedErrorClass()( - "SecretStoreSecureError", +export class SecretStoreDirectoryCreateError extends Schema.TaggedErrorClass()( + "SecretStoreDirectoryCreateError", { - ...secretStoreErrorContext, + directoryPath: Schema.String, + cause: Schema.Defect(), }, ) { override get message(): string { - return `Failed to secure ${this.resource}.`; + return `Failed to create secret store directory ${this.directoryPath}.`; + } +} + +export class SecretStoreDirectorySecureError extends Schema.TaggedErrorClass()( + "SecretStoreDirectorySecureError", + { + directoryPath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to secure secret store directory ${this.directoryPath}.`; } } export class SecretStoreReadError extends Schema.TaggedErrorClass()( "SecretStoreReadError", { - ...secretStoreErrorContext, + ...storedSecretErrorContext, }, ) { override get message(): string { - return `Failed to read ${this.resource}.`; + return `Failed to read secret ${this.secretName} at ${this.secretPath}.`; } } -export class SecretStoreTemporaryPathError extends Schema.TaggedErrorClass()( - "SecretStoreTemporaryPathError", +export class SecretStoreTemporaryPathGenerationError extends Schema.TaggedErrorClass()( + "SecretStoreTemporaryPathGenerationError", { - ...secretStoreErrorContext, + secretName: Schema.String, + secretPath: Schema.String, + cause: Schema.Defect(), }, ) { override get message(): string { - return `Failed to create temporary path for ${this.resource}.`; + return `Failed to generate a temporary path for secret ${this.secretName} at ${this.secretPath}.`; } } export class SecretStorePersistError extends Schema.TaggedErrorClass()( "SecretStorePersistError", { - ...secretStoreErrorContext, + operation: Schema.Literals(["create", "set"]), + ...storedSecretErrorContext, }, ) { override get message(): string { - return `Failed to persist ${this.resource}.`; + return `Failed to ${this.operation} secret ${this.secretName} at ${this.secretPath}.`; } } export class SecretStoreRandomGenerationError extends Schema.TaggedErrorClass()( "SecretStoreRandomGenerationError", { - ...secretStoreErrorContext, + secretName: Schema.String, + byteCount: Schema.Number, + cause: Schema.Defect(), }, ) { override get message(): string { - return `Failed to generate random bytes for ${this.resource}.`; + return `Failed to generate ${this.byteCount} random bytes for secret ${this.secretName}.`; } } export class SecretStoreConcurrentReadError extends Schema.TaggedErrorClass()( "SecretStoreConcurrentReadError", { - resource: Schema.String, + secretName: Schema.String, }, ) { override get message(): string { - return `Failed to read ${this.resource} after concurrent creation.`; + return `Failed to read secret ${this.secretName} after concurrent creation.`; } } export class SecretStoreRemoveError extends Schema.TaggedErrorClass()( "SecretStoreRemoveError", { - ...secretStoreErrorContext, + ...storedSecretErrorContext, }, ) { override get message(): string { - return `Failed to remove ${this.resource}.`; + return `Failed to remove secret ${this.secretName} at ${this.secretPath}.`; } } export class SecretStoreDecodeError extends Schema.TaggedErrorClass()( "SecretStoreDecodeError", { - ...secretStoreErrorContext, + secretName: Schema.String, + cause: Schema.Defect(), }, ) { override get message(): string { - return `Failed to decode ${this.resource}.`; + return `Failed to decode secret ${this.secretName}.`; } } export class SecretStoreEncodeError extends Schema.TaggedErrorClass()( "SecretStoreEncodeError", { - ...secretStoreErrorContext, + secretName: Schema.String, + cause: Schema.Defect(), }, ) { override get message(): string { - return `Failed to encode ${this.resource}.`; + return `Failed to encode secret ${this.secretName}.`; } } export const SecretStoreError = Schema.Union([ - SecretStoreSecureError, + SecretStoreDirectoryCreateError, + SecretStoreDirectorySecureError, SecretStoreReadError, - SecretStoreTemporaryPathError, + SecretStoreTemporaryPathGenerationError, SecretStorePersistError, SecretStoreRandomGenerationError, SecretStoreConcurrentReadError, @@ -138,14 +160,26 @@ export const isSecretAlreadyExistsError = (error: SecretStoreError): boolean => export class ServerSecretStore extends Context.Service< ServerSecretStore, { - readonly get: (name: string) => Effect.Effect, SecretStoreError>; - readonly set: (name: string, value: Uint8Array) => Effect.Effect; - readonly create: (name: string, value: Uint8Array) => Effect.Effect; + readonly get: (name: string) => Effect.Effect, SecretStoreReadError>; + readonly set: ( + name: string, + value: Uint8Array, + ) => Effect.Effect; + readonly create: ( + name: string, + value: Uint8Array, + ) => Effect.Effect; readonly getOrCreateRandom: ( name: string, bytes: number, - ) => Effect.Effect; - readonly remove: (name: string) => Effect.Effect; + ) => Effect.Effect< + Uint8Array, + | SecretStoreReadError + | SecretStoreRandomGenerationError + | SecretStorePersistError + | SecretStoreConcurrentReadError + >; + readonly remove: (name: string) => Effect.Effect; } >()("t3/auth/ServerSecretStore") {} @@ -155,12 +189,20 @@ export const make = Effect.gen(function* () { const path = yield* Path.Path; const serverConfig = yield* ServerConfig.ServerConfig; - yield* fileSystem.makeDirectory(serverConfig.secretsDir, { recursive: true }); + yield* fileSystem.makeDirectory(serverConfig.secretsDir, { recursive: true }).pipe( + Effect.mapError( + (cause) => + new SecretStoreDirectoryCreateError({ + directoryPath: serverConfig.secretsDir, + cause, + }), + ), + ); yield* fileSystem.chmod(serverConfig.secretsDir, 0o700).pipe( Effect.mapError( (cause) => - new SecretStoreSecureError({ - resource: `secrets directory ${serverConfig.secretsDir}`, + new SecretStoreDirectorySecureError({ + directoryPath: serverConfig.secretsDir, cause, }), ), @@ -168,29 +210,33 @@ export const make = Effect.gen(function* () { const resolveSecretPath = (name: string) => path.join(serverConfig.secretsDir, `${name}.bin`); - const get: ServerSecretStore["Service"]["get"] = (name) => - fileSystem.readFile(resolveSecretPath(name)).pipe( + const get: ServerSecretStore["Service"]["get"] = (name) => { + const secretPath = resolveSecretPath(name); + return fileSystem.readFile(secretPath).pipe( Effect.map((bytes) => Option.some(Uint8Array.from(bytes))), Effect.catch((cause) => cause.reason._tag === "NotFound" ? Effect.succeed(Option.none()) : Effect.fail( new SecretStoreReadError({ - resource: `secret ${name}`, + secretName: name, + secretPath, cause, }), ), ), Effect.withSpan("ServerSecretStore.get"), ); + }; const set: ServerSecretStore["Service"]["set"] = (name, value) => { const secretPath = resolveSecretPath(name); return crypto.randomUUIDv4.pipe( Effect.mapError( (cause) => - new SecretStoreTemporaryPathError({ - resource: `secret ${name}`, + new SecretStoreTemporaryPathGenerationError({ + secretName: name, + secretPath, cause, }), ), @@ -208,7 +254,9 @@ export const make = Effect.gen(function* () { Effect.flatMap(() => Effect.fail( new SecretStorePersistError({ - resource: `secret ${name}`, + operation: "set", + secretName: name, + secretPath, cause, }), ), @@ -237,7 +285,9 @@ export const make = Effect.gen(function* () { Effect.mapError( (cause) => new SecretStorePersistError({ - resource: `secret ${name}`, + operation: "create", + secretName: name, + secretPath, cause, }), ), @@ -254,30 +304,32 @@ export const make = Effect.gen(function* () { Effect.mapError( (cause) => new SecretStoreRandomGenerationError({ - resource: `secret ${name}`, + secretName: name, + byteCount: bytes, cause, }), ), Effect.flatMap((generated) => create(name, generated).pipe( Effect.as(Uint8Array.from(generated)), - Effect.catchIf(isSecretStoreError, (error) => - isSecretAlreadyExistsError(error) - ? get(name).pipe( - Effect.flatMap( - Option.match({ - onSome: Effect.succeed, - onNone: () => - Effect.fail( - new SecretStoreConcurrentReadError({ - resource: `secret ${name}`, - }), - ), - }), - ), - ) - : Effect.fail(error), - ), + Effect.catchTags({ + SecretStorePersistError: (error) => + isSecretAlreadyExistsError(error) + ? get(name).pipe( + Effect.flatMap( + Option.match({ + onSome: Effect.succeed, + onNone: () => + Effect.fail( + new SecretStoreConcurrentReadError({ + secretName: name, + }), + ), + }), + ), + ) + : Effect.fail(error), + }), ), ), ), @@ -286,20 +338,23 @@ export const make = Effect.gen(function* () { Effect.withSpan("ServerSecretStore.getOrCreateRandom"), ); - const remove: ServerSecretStore["Service"]["remove"] = (name) => - fileSystem.remove(resolveSecretPath(name)).pipe( + const remove: ServerSecretStore["Service"]["remove"] = (name) => { + const secretPath = resolveSecretPath(name); + return fileSystem.remove(secretPath).pipe( Effect.catch((cause) => cause.reason._tag === "NotFound" ? Effect.void : Effect.fail( new SecretStoreRemoveError({ - resource: `secret ${name}`, + secretName: name, + secretPath, cause, }), ), ), Effect.withSpan("ServerSecretStore.remove"), ); + }; return ServerSecretStore.of({ get, diff --git a/apps/server/src/auth/dpop.test.ts b/apps/server/src/auth/dpop.test.ts index 81610a2201d..d01417cebfc 100644 --- a/apps/server/src/auth/dpop.test.ts +++ b/apps/server/src/auth/dpop.test.ts @@ -6,7 +6,9 @@ import { mapDpopReplayStoreError } from "./dpop.ts"; const storeFailure = (tag: "AlreadyExists" | "PermissionDenied") => new SecretStorePersistError({ - resource: "DPoP proof", + operation: "create", + secretName: "DPoP proof", + secretPath: "dpop-proof.bin", cause: PlatformError.systemError({ _tag: tag, module: "FileSystem", diff --git a/apps/server/src/cloud/environmentKeys.test.ts b/apps/server/src/cloud/environmentKeys.test.ts index 48c44ccc48a..a2fef8c8b0b 100644 --- a/apps/server/src/cloud/environmentKeys.test.ts +++ b/apps/server/src/cloud/environmentKeys.test.ts @@ -66,7 +66,9 @@ it.layer(NodeServices.layer)("getOrCreateEnvironmentKeyPairFromSecretStore", (it Effect.flatMap(() => Effect.fail( new ServerSecretStore.SecretStorePersistError({ - resource: "environment signing key pair", + operation: "create", + secretName: "cloud-link-ed25519-key-pair", + secretPath: "cloud-link-ed25519-key-pair.bin", cause: PlatformError.systemError({ _tag: "AlreadyExists", module: "FileSystem", @@ -87,4 +89,24 @@ it.layer(NodeServices.layer)("getOrCreateEnvironmentKeyPairFromSecretStore", (it }); }), ); + + it.effect("retains the secret name and decode cause for malformed keypairs", () => + Effect.gen(function* () { + const secretStore = { + get: () => Effect.succeed(Option.some(new TextEncoder().encode("not-json"))), + set: unusedSecretStoreOperation, + create: unusedSecretStoreOperation, + getOrCreateRandom: unusedSecretStoreOperation, + remove: unusedSecretStoreOperation, + } satisfies ServerSecretStore.ServerSecretStore["Service"]; + + const error = yield* getOrCreateEnvironmentKeyPairFromSecretStore(secretStore).pipe( + Effect.flip, + ); + + assert.instanceOf(error, ServerSecretStore.SecretStoreDecodeError); + assert.equal(error.secretName, "cloud-link-ed25519-key-pair"); + assert.exists(error.cause); + }), + ); }); diff --git a/apps/server/src/cloud/environmentKeys.ts b/apps/server/src/cloud/environmentKeys.ts index 1d0cde91bf4..3e20983aef1 100644 --- a/apps/server/src/cloud/environmentKeys.ts +++ b/apps/server/src/cloud/environmentKeys.ts @@ -27,17 +27,6 @@ function stringToBytes(value: string): Uint8Array { return new TextEncoder().encode(value); } -const KEY_PAIR_RESOURCE = "environment signing key pair"; - -const keyPairDecodeError = (cause: unknown): ServerSecretStore.SecretStoreDecodeError => - new ServerSecretStore.SecretStoreDecodeError({ resource: KEY_PAIR_RESOURCE, cause }); - -const keyPairEncodeError = (cause: unknown): ServerSecretStore.SecretStoreEncodeError => - new ServerSecretStore.SecretStoreEncodeError({ resource: KEY_PAIR_RESOURCE, cause }); - -const keyPairConcurrentReadError = (): ServerSecretStore.SecretStoreConcurrentReadError => - new ServerSecretStore.SecretStoreConcurrentReadError({ resource: KEY_PAIR_RESOURCE }); - const readEnvironmentKeyPair = Effect.fn("readEnvironmentKeyPair")(function* ( secrets: ServerSecretStore.ServerSecretStore["Service"], ) { @@ -46,7 +35,13 @@ const readEnvironmentKeyPair = Effect.fn("readEnvironmentKeyPair")(function* ( return Option.none(); } const decoded = yield* decodeEnvironmentKeyPair(bytesToString(encoded.value)).pipe( - Effect.mapError(keyPairDecodeError), + Effect.mapError( + (cause) => + new ServerSecretStore.SecretStoreDecodeError({ + secretName: CLOUD_LINK_KEY_PAIR, + cause, + }), + ), ); return Option.some(decoded); }); @@ -56,22 +51,34 @@ const persistEnvironmentKeyPair = Effect.fn("persistEnvironmentKeyPair")(functio keyPair: EnvironmentKeyPair, ) { const encoded = yield* encodeEnvironmentKeyPair(keyPair).pipe( - Effect.mapError(keyPairEncodeError), + Effect.mapError( + (cause) => + new ServerSecretStore.SecretStoreEncodeError({ + secretName: CLOUD_LINK_KEY_PAIR, + cause, + }), + ), ); return yield* secrets.create(CLOUD_LINK_KEY_PAIR, stringToBytes(encoded)).pipe( Effect.as(keyPair), - Effect.catchIf(ServerSecretStore.isSecretStoreError, (error) => - ServerSecretStore.isSecretAlreadyExistsError(error) - ? readEnvironmentKeyPair(secrets).pipe( - Effect.flatMap( - Option.match({ - onSome: Effect.succeed, - onNone: () => Effect.fail(keyPairConcurrentReadError()), - }), - ), - ) - : Effect.fail(error), - ), + Effect.catchTags({ + SecretStorePersistError: (error) => + ServerSecretStore.isSecretAlreadyExistsError(error) + ? readEnvironmentKeyPair(secrets).pipe( + Effect.flatMap( + Option.match({ + onSome: Effect.succeed, + onNone: () => + Effect.fail( + new ServerSecretStore.SecretStoreConcurrentReadError({ + secretName: CLOUD_LINK_KEY_PAIR, + }), + ), + }), + ), + ) + : Effect.fail(error), + }), ); }); diff --git a/apps/server/src/cloud/http.test.ts b/apps/server/src/cloud/http.test.ts index ed2e5a4cf75..c623d79e550 100644 --- a/apps/server/src/cloud/http.test.ts +++ b/apps/server/src/cloud/http.test.ts @@ -1,23 +1,45 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, expect, it } from "@effect/vitest"; +import * as ConfigProvider from "effect/ConfigProvider"; import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; +import * as Schema from "effect/Schema"; import * as Tracer from "effect/Tracer"; -import { HttpClient, HttpServerRequest } from "effect/unstable/http"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientError from "effect/unstable/http/HttpClientError"; +import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; +import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; +import { EnvironmentHttpInternalServerError } from "@t3tools/contracts"; import { RelayClientTracer } from "@t3tools/shared/relayTracing"; import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as CliTokenManager from "./CliTokenManager.ts"; -import { consumeCloudReplayGuards, reconcileDesiredCloudLink } from "./http.ts"; +import { + CloudRelayRequestError, + consumeCloudReplayGuards, + reconcileDesiredCloudLink, +} from "./http.ts"; import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts"; import { traceAuthenticatedRelayRequest, traceRelayRequest } from "./traceRelayRequest.ts"; +const encodeEnvironmentHttpInternalServerError = Schema.encodeUnknownSync( + EnvironmentHttpInternalServerError, +); +const decodeEnvironmentHttpInternalServerError = Schema.decodeUnknownSync( + EnvironmentHttpInternalServerError, +); + const storeFailure = (tag: "AlreadyExists" | "PermissionDenied") => new ServerSecretStore.SecretStorePersistError({ - resource: "cloud replay guard", + operation: "create", + secretName: "cloud replay guard", + secretPath: "cloud-replay-guard.bin", cause: PlatformError.systemError({ _tag: tag, module: "FileSystem", @@ -40,6 +62,55 @@ function makeSecretStore( }; } +function reconcileWith(input: { + readonly getExisting: CliTokenManager.CloudCliTokenManager["Service"]["getExisting"]; + readonly httpClient?: HttpClient.HttpClient; + readonly env?: Readonly>; +}) { + return reconcileDesiredCloudLink("http://127.0.0.1:3774").pipe( + Effect.provideService( + ServerSecretStore.ServerSecretStore, + makeSecretStore(unusedSecretStoreOperation), + ), + Effect.provideService( + ServerEnvironment.ServerEnvironment, + ServerEnvironment.ServerEnvironment.of({ + getEnvironmentId: unusedSecretStoreOperation(), + getDescriptor: unusedSecretStoreOperation(), + }), + ), + Effect.provideService( + ManagedEndpointRuntime.CloudManagedEndpointRuntime, + ManagedEndpointRuntime.CloudManagedEndpointRuntime.of({ + applyConfig: unusedSecretStoreOperation, + } satisfies ManagedEndpointRuntime.CloudManagedEndpointRuntime["Service"]), + ), + Effect.provideService( + EnvironmentAuth.EnvironmentAuth, + EnvironmentAuth.EnvironmentAuth.of({} as EnvironmentAuth.EnvironmentAuth["Service"]), + ), + Effect.provideService( + CliTokenManager.CloudCliTokenManager, + CliTokenManager.CloudCliTokenManager.of({ + get: unusedSecretStoreOperation(), + getExisting: input.getExisting, + hasCredential: unusedSecretStoreOperation(), + clear: unusedSecretStoreOperation(), + }), + ), + Effect.provideService( + HttpClient.HttpClient, + input.httpClient ?? HttpClient.make(() => unusedSecretStoreOperation()), + ), + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + ConfigProvider.layer(ConfigProvider.fromEnv({ env: input.env ?? {} })), + ), + ), + ); +} + it("preserves messages surfaced by cloud 500 responses", () => { const cause = new Error("cloud operation failed"); @@ -93,6 +164,84 @@ describe("consumeCloudReplayGuards", () => { ); }); +describe("CloudRelayRequestError", () => { + it("classifies response failures without deriving its message from the cause", () => { + const requestUrl = + "https://relay-user:relay-password@relay.example.test/private/environment-links?token=relay-secret#relay-fragment"; + const request = HttpClientRequest.post(requestUrl); + const response = HttpClientResponse.fromWeb( + request, + new Response("sensitive upstream response", { status: 502 }), + ); + const upstreamCause = new Error("sensitive upstream response details"); + const cause = new HttpClientError.HttpClientError({ + reason: new HttpClientError.StatusCodeError({ + request, + response, + cause: upstreamCause, + }), + }); + + const error = CloudRelayRequestError.fromClientFailure({ + operation: "create-environment-link", + url: request.url, + cause, + }); + + expect(error).toMatchObject({ + operation: "create-environment-link", + phase: "check-response-status", + method: "POST", + requestUrlInputLength: requestUrl.length, + requestUrlProtocol: "https:", + requestUrlHostname: "relay.example.test", + responseStatus: 502, + cause, + }); + expect(error.cause).toBe(cause); + expect(error).not.toHaveProperty("url"); + expect(error.message).toBe( + "T3 Connect relay create-environment-link failed during check-response-status with response status 502.", + ); + expect(error.message).not.toContain(upstreamCause.message); + for (const secret of [ + "relay-user", + "relay-password", + "/private/environment-links", + "relay-secret", + "relay-fragment", + ]) { + expect(error.message).not.toContain(secret); + expect(Object.values(error).join(" ")).not.toContain(secret); + } + }); +}); + +it("keeps internal causes out of encoded HTTP error bodies", () => { + const cause = new Error("private upstream detail"); + const error = new EnvironmentHttpInternalServerError({ + operation: "generate_link_proof", + cause, + }); + + expect(error.cause).toBe(cause); + expect(encodeEnvironmentHttpInternalServerError(error)).toEqual({ + _tag: "EnvironmentHttpInternalServerError", + operation: "generate_link_proof", + message: "Could not generate environment link proof.", + }); +}); + +it("decodes legacy message-only HTTP errors during rolling deployments", () => { + const error = decodeEnvironmentHttpInternalServerError({ + _tag: "EnvironmentHttpInternalServerError", + message: "Legacy environment server failure.", + }); + + expect(error.operation).toBeUndefined(); + expect(error.message).toBe("Legacy environment server failure."); +}); + describe("relay request tracing", () => { it.effect("does not accept an unauthenticated request trace parent", () => Effect.gen(function* () { @@ -160,48 +309,74 @@ describe("relay request tracing", () => { describe("reconcileDesiredCloudLink", () => { it.effect("requires stored CLI authorization without exposing an HTTP endpoint", () => Effect.gen(function* () { - const error = yield* Effect.flip(reconcileDesiredCloudLink("http://127.0.0.1:3774")); + const error = yield* Effect.flip( + reconcileWith({ getExisting: Effect.succeed(Option.none()) }), + ); expect(error).toMatchObject({ _tag: "EnvironmentHttpUnauthorizedError", + reason: "cloud_cli_authorization_required", message: "Run `t3 connect link` to authorize this environment.", }); - }).pipe( - Effect.provideService( - ServerSecretStore.ServerSecretStore, - makeSecretStore(unusedSecretStoreOperation), - ), - Effect.provideService( - ServerEnvironment.ServerEnvironment, - ServerEnvironment.ServerEnvironment.of({ - getEnvironmentId: unusedSecretStoreOperation(), - getDescriptor: unusedSecretStoreOperation(), - }), - ), - Effect.provideService( - ManagedEndpointRuntime.CloudManagedEndpointRuntime, - ManagedEndpointRuntime.CloudManagedEndpointRuntime.of({ - applyConfig: unusedSecretStoreOperation, - } satisfies ManagedEndpointRuntime.CloudManagedEndpointRuntime["Service"]), - ), - Effect.provideService( - EnvironmentAuth.EnvironmentAuth, - EnvironmentAuth.EnvironmentAuth.of({} as EnvironmentAuth.EnvironmentAuth["Service"]), - ), - Effect.provideService( - CliTokenManager.CloudCliTokenManager, - CliTokenManager.CloudCliTokenManager.of({ - get: unusedSecretStoreOperation(), - getExisting: Effect.succeed(Option.none()), - hasCredential: unusedSecretStoreOperation(), - clear: unusedSecretStoreOperation(), + }), + ); + + it.effect("redacts relay transport failures behind a stable structural message", () => { + const transportCause = new Error("upstream included a sensitive database password"); + const capturedLogs: Array> = []; + const logger = Logger.make(({ message }) => { + capturedLogs.push(Array.isArray(message) ? message : [message]); + }); + const httpClient = HttpClient.make((request) => + Effect.fail( + new HttpClientError.HttpClientError({ + reason: new HttpClientError.TransportError({ request, cause: transportCause }), }), ), - Effect.provideService( - HttpClient.HttpClient, - HttpClient.make(() => unusedSecretStoreOperation()), - ), - Effect.provide(NodeServices.layer), - ), - ); + ); + + return Effect.gen(function* () { + const error = yield* Effect.flip( + reconcileWith({ + getExisting: Effect.succeed( + Option.some({ + accessToken: "access-token", + refreshToken: "refresh-token", + expiresAtEpochMs: Number.MAX_SAFE_INTEGER, + }), + ), + httpClient, + env: { T3CODE_RELAY_URL: "https://relay.example.test" }, + }).pipe(Effect.provide(Logger.layer([logger], { mergeWithExisting: false }))), + ); + + expect(error).toMatchObject({ + _tag: "EnvironmentHttpInternalServerError", + operation: "relay_request", + relayOperation: "create-link-challenge", + relayPhase: "send-request", + message: "T3 Connect relay create-link-challenge failed during send-request.", + cause: { + _tag: "CloudRelayRequestError", + operation: "create-link-challenge", + phase: "send-request", + }, + }); + expect(error.message).not.toContain(transportCause.message); + expect(capturedLogs).toHaveLength(1); + const logFields = capturedLogs[0]?.find( + (value): value is Record => typeof value === "object" && value !== null, + ); + expect(logFields).toMatchObject({ + operation: "relay_request", + relayOperation: "create-link-challenge", + relayPhase: "send-request", + causeTag: "CloudRelayRequestError", + }); + expect(logFields).not.toHaveProperty("cause"); + expect(capturedLogs[0]?.filter((value) => typeof value === "string").join(" ")).not.toContain( + transportCause.message, + ); + }); + }); }); diff --git a/apps/server/src/cloud/http.ts b/apps/server/src/cloud/http.ts index fc2adca9fbc..874ea8e2dc1 100644 --- a/apps/server/src/cloud/http.ts +++ b/apps/server/src/cloud/http.ts @@ -9,7 +9,10 @@ import { EnvironmentHttpApi, EnvironmentHttpBadRequestError, EnvironmentHttpConflictError, + type EnvironmentHttpInternalOperation, EnvironmentHttpInternalServerError, + EnvironmentHttpRelayOperation, + EnvironmentHttpRelayPhase, EnvironmentHttpUnauthorizedError, } from "@t3tools/contracts"; import { @@ -41,6 +44,7 @@ import { verifyRelayJwt, } from "@t3tools/shared/relayJwt"; import { isSecureRelayUrl } from "@t3tools/shared/relayUrl"; +import { getUrlDiagnostics } from "@t3tools/shared/urlDiagnostics"; import * as DateTime from "effect/DateTime"; import * as Crypto from "effect/Crypto"; import * as Duration from "effect/Duration"; @@ -48,8 +52,13 @@ import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as HttpEffect from "effect/unstable/http/HttpEffect"; -import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; -import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; +import * as HttpBody from "effect/unstable/http/HttpBody"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientError from "effect/unstable/http/HttpClientError"; +import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; +import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; +import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; @@ -85,26 +94,145 @@ const CLOUD_CREDENTIAL_RESPONSE_HEADERS = { pragma: "no-cache", } as const; +export class CloudRelayConfigurationError extends Schema.TaggedErrorClass()( + "CloudRelayConfigurationError", + { + configKey: Schema.Literal("T3CODE_RELAY_URL"), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `${this.configKey} must be configured as a secure absolute HTTPS origin.`; + } +} + +export class CloudRelayRequestError extends Schema.TaggedErrorClass()( + "CloudRelayRequestError", + { + operation: EnvironmentHttpRelayOperation, + phase: EnvironmentHttpRelayPhase, + method: Schema.Literal("POST"), + requestUrlInputLength: Schema.Number, + requestUrlProtocol: Schema.optionalKey(Schema.String), + requestUrlHostname: Schema.optionalKey(Schema.String), + responseStatus: Schema.optional(Schema.Number), + cause: Schema.Defect(), + }, +) { + static fromClientFailure(input: { + readonly operation: CloudRelayRequestError["operation"]; + readonly url: string; + readonly cause: HttpBody.HttpBodyError | HttpClientError.HttpClientError | Schema.SchemaError; + readonly responseStatus?: number; + }): CloudRelayRequestError { + const diagnostics = getUrlDiagnostics(input.url); + const context = { + operation: input.operation, + method: "POST" as const, + requestUrlInputLength: diagnostics.inputLength, + ...(diagnostics.protocol === undefined ? {} : { requestUrlProtocol: diagnostics.protocol }), + ...(diagnostics.hostname === undefined ? {} : { requestUrlHostname: diagnostics.hostname }), + }; + if (input.cause._tag === "SchemaError") { + return new CloudRelayRequestError({ + ...context, + phase: "decode-response", + ...(input.responseStatus === undefined ? {} : { responseStatus: input.responseStatus }), + cause: input.cause, + }); + } + + if (!HttpClientError.isHttpClientError(input.cause)) { + return new CloudRelayRequestError({ + ...context, + phase: "encode-request", + cause: input.cause, + }); + } + + const phase: CloudRelayRequestError["phase"] = (() => { + switch (input.cause.reason._tag) { + case "EncodeError": + return "encode-request"; + case "TransportError": + case "InvalidUrlError": + return "send-request"; + case "StatusCodeError": + return "check-response-status"; + case "DecodeError": + case "EmptyBodyError": + return "decode-response"; + } + })(); + + return new CloudRelayRequestError({ + ...context, + phase, + ...(input.cause.response === undefined + ? input.responseStatus === undefined + ? {} + : { responseStatus: input.responseStatus } + : { responseStatus: input.cause.response.status }), + cause: input.cause, + }); + } + + override get message(): string { + const responseStatus = + this.responseStatus === undefined ? "" : ` with response status ${this.responseStatus}`; + return `T3 Connect relay ${this.operation} failed during ${this.phase}${responseStatus}.`; + } +} + const appendCloudCredentialResponseHeaders = HttpEffect.appendPreResponseHandler( (_request, response) => Effect.succeed(HttpServerResponse.setHeaders(response, CLOUD_CREDENTIAL_RESPONSE_HEADERS)), ); +function errorDiagnosticTag(cause: unknown): string { + if ( + typeof cause === "object" && + cause !== null && + "_tag" in cause && + typeof cause._tag === "string" + ) { + return cause._tag; + } + if (cause instanceof Error) return cause.name; + return typeof cause; +} + +type EnvironmentCloudInternalErrorContext = + | Exclude + | { + readonly operation: "relay_request"; + readonly relayOperation: CloudRelayRequestError["operation"]; + readonly relayPhase: CloudRelayRequestError["phase"]; + readonly responseStatus?: number; + }; + const failEnvironmentCloudInternalError = - (message: string) => - (cause: unknown): Effect.Effect => - Effect.logError(message, { cause }).pipe( - Effect.flatMap(() => Effect.fail(new EnvironmentHttpInternalServerError({ message }))), + (context: EnvironmentCloudInternalErrorContext) => + (cause: unknown): Effect.Effect => { + const error = + typeof context === "string" + ? new EnvironmentHttpInternalServerError({ operation: context, cause }) + : new EnvironmentHttpInternalServerError({ ...context, cause }); + const logContext = + typeof context === "string" + ? { operation: context, causeTag: errorDiagnosticTag(cause) } + : { ...context, causeTag: errorDiagnosticTag(cause) }; + return Effect.logError(error.message, logContext).pipe( + Effect.flatMap(() => Effect.fail(error)), ); - -const failCloudCliTokenManagerError = (error: CliTokenManager.CloudCliTokenManagerError) => - failEnvironmentCloudInternalError(error.message)(error); + }; const requireRelayUrl = relayUrlConfig.pipe( Effect.mapError( - () => - new EnvironmentHttpInternalServerError({ - message: "T3CODE_RELAY_URL must be configured as a secure absolute HTTPS origin.", + (cause) => + new CloudRelayConfigurationError({ + configKey: "T3CODE_RELAY_URL", + cause, }), ), ); @@ -126,11 +254,12 @@ export function consumeCloudReplayGuards(input: { input.names.map((name) => input.secrets.create(name, input.value).pipe( Effect.as(true), - Effect.catchIf(ServerSecretStore.isSecretStoreError, (error) => - ServerSecretStore.isSecretAlreadyExistsError(error) - ? Effect.succeed(false) - : Effect.fail(error), - ), + Effect.catchTags({ + SecretStorePersistError: (error) => + ServerSecretStore.isSecretAlreadyExistsError(error) + ? Effect.succeed(false) + : Effect.fail(error), + }), ), ), { concurrency: input.names.length }, @@ -153,9 +282,10 @@ function validateCloudMintPublicKey( ): Effect.Effect { return Effect.try({ try: () => NodeCrypto.createPublicKey(publicKey.replace(/\\n/g, "\n")), - catch: () => + catch: (cause) => new EnvironmentHttpBadRequestError({ - message: "Cloud mint public key must be a valid Ed25519 public key.", + reason: "invalid_cloud_mint_public_key", + cause, }), }).pipe( Effect.flatMap((key) => @@ -163,7 +293,7 @@ function validateCloudMintPublicKey( ? Effect.void : Effect.fail( new EnvironmentHttpBadRequestError({ - message: "Cloud mint public key must be a valid Ed25519 public key.", + reason: "invalid_cloud_mint_public_key", }), ), ), @@ -176,28 +306,28 @@ function validateRelayConfigPayload( if (!isSecureRelayUrl(payload.relayUrl)) { return Effect.fail( new EnvironmentHttpBadRequestError({ - message: "Relay URL must be a secure absolute HTTPS URL.", + reason: "invalid_relay_url", }), ); } if (payload.relayIssuer !== undefined && !isSecureRelayUrl(payload.relayIssuer)) { return Effect.fail( new EnvironmentHttpBadRequestError({ - message: "Relay issuer must be a secure absolute HTTPS URL.", + reason: "invalid_relay_issuer", }), ); } if (payload.environmentCredential.trim().length === 0) { return Effect.fail( new EnvironmentHttpBadRequestError({ - message: "Relay environment credential is required.", + reason: "missing_relay_environment_credential", }), ); } if (payload.cloudUserId.trim().length === 0) { return Effect.fail( new EnvironmentHttpBadRequestError({ - message: "Cloud user id is required.", + reason: "missing_cloud_user_id", }), ); } @@ -207,7 +337,7 @@ function validateRelayConfigPayload( function validateLinkedCloudUser(input: { readonly secrets: ServerSecretStore.ServerSecretStore["Service"]; readonly cloudUserId: string; -}): Effect.Effect { +}) { return input.secrets.get(CLOUD_LINKED_USER_ID).pipe( Effect.mapError( (cause) => @@ -224,17 +354,14 @@ function validateLinkedCloudUser(input: { ? Effect.void : Effect.fail( new EnvironmentHttpConflictError({ - message: - "This environment is already linked to a different cloud account. Unlink it before switching accounts.", + reason: "linked_to_different_cloud_account", }), ); }), ); } -function readInstalledCloudUserId( - secrets: ServerSecretStore.ServerSecretStore["Service"], -): Effect.Effect { +function readInstalledCloudUserId(secrets: ServerSecretStore.ServerSecretStore["Service"]) { return secrets.get(CLOUD_LINKED_USER_ID).pipe( Effect.mapError( (cause) => @@ -359,7 +486,7 @@ const makeCloudLinkProof = Effect.fn("environment.cloud.makeLinkProof")(function }) ) { return yield* new EnvironmentHttpBadRequestError({ - message: "Invalid managed endpoint origin.", + reason: "invalid_managed_endpoint_origin", }); } const now = yield* DateTime.now; @@ -402,24 +529,23 @@ const cloudLinkProofHandler = Effect.fn("environment.cloud.linkProof")( const requestUrl = requestAbsoluteUrl(httpRequest); if (requestUrl === null || hasForwardedAuthorityHeaders(httpRequest)) { return yield* new EnvironmentHttpBadRequestError({ - message: "Invalid managed endpoint origin.", + reason: "invalid_managed_endpoint_origin", }); } const proof = yield* makeCloudLinkProof(dependencies, request, requestUrl); yield* appendCloudCredentialResponseHeaders; return proof satisfies RelayEnvironmentLinkProof; }, - Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => - failEnvironmentCloudInternalError(error.message)(error), - ), - Effect.catchIf( - ServerSecretStore.isSecretStoreError, - failEnvironmentCloudInternalError("Could not generate environment link proof."), - ), - Effect.catchTag( - "PlatformError", - failEnvironmentCloudInternalError("Could not generate environment link proof."), - ), + Effect.catchTags({ + ServerAuthCloudLinkJwtSigningError: (error) => + failEnvironmentCloudInternalError("sign_cloud_link_jwt")(error), + SecretStoreReadError: failEnvironmentCloudInternalError("generate_link_proof"), + SecretStoreDecodeError: failEnvironmentCloudInternalError("generate_link_proof"), + SecretStoreEncodeError: failEnvironmentCloudInternalError("generate_link_proof"), + SecretStorePersistError: failEnvironmentCloudInternalError("generate_link_proof"), + SecretStoreConcurrentReadError: failEnvironmentCloudInternalError("generate_link_proof"), + PlatformError: failEnvironmentCloudInternalError("generate_link_proof"), + }), ); const applyCloudRelayConfig = Effect.fn("environment.cloud.applyRelayConfig")(function* ( @@ -439,7 +565,6 @@ const applyCloudRelayConfig = Effect.fn("environment.cloud.applyRelayConfig")(fu endpointRuntimeStatus.status === "disabled" || endpointRuntimeStatus.status === "running"; if (!ok) { return yield* new EnvironmentCloudEndpointUnavailableError({ - message: "Managed endpoint runtime could not be started.", endpointRuntimeStatus, }); } @@ -472,22 +597,22 @@ const cloudRelayConfigHandler = Effect.fn("environment.cloud.relayConfig")( yield* requireEnvironmentScope(AuthRelayWriteScope); return yield* applyCloudRelayConfig(dependencies, payload); }, - Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => - failEnvironmentCloudInternalError(error.message)(error), - ), - Effect.catchIf( - ServerSecretStore.isSecretStoreError, - failEnvironmentCloudInternalError("Could not persist environment relay configuration."), - ), - Effect.catchTag( - "SchemaError", - failEnvironmentCloudInternalError("Could not persist environment relay configuration."), - ), + Effect.catchTags({ + ServerAuthLinkedCloudAccountVerificationError: (error) => + failEnvironmentCloudInternalError("verify_linked_cloud_account")(error), + SecretStoreTemporaryPathGenerationError: failEnvironmentCloudInternalError( + "persist_relay_configuration", + ), + SecretStorePersistError: failEnvironmentCloudInternalError("persist_relay_configuration"), + SecretStoreRemoveError: failEnvironmentCloudInternalError("persist_relay_configuration"), + SchemaError: failEnvironmentCloudInternalError("persist_relay_configuration"), + }), ); const relayClientRequest = ( dependencies: CloudHttpDependencies, input: { + readonly operation: CloudRelayRequestError["operation"]; readonly url: string; readonly token: string; readonly payload: unknown; @@ -497,14 +622,46 @@ const relayClientRequest = ( HttpClientRequest.post(input.url).pipe( HttpClientRequest.bearerToken(input.token), HttpClientRequest.bodyJson(input.payload), - Effect.flatMap(dependencies.httpClient.execute), - Effect.flatMap(HttpClientResponse.filterStatusOk), - Effect.flatMap(HttpClientResponse.schemaBodyJson(input.schema)), - Effect.mapError( - (cause) => - new EnvironmentHttpInternalServerError({ - message: `T3 Connect relay request failed: ${String(cause)}`, - }), + Effect.mapError((cause) => + CloudRelayRequestError.fromClientFailure({ + operation: input.operation, + url: input.url, + cause, + }), + ), + Effect.flatMap((request) => + dependencies.httpClient.execute(request).pipe( + Effect.mapError((cause) => + CloudRelayRequestError.fromClientFailure({ + operation: input.operation, + url: input.url, + cause, + }), + ), + ), + ), + Effect.flatMap((response) => + HttpClientResponse.filterStatusOk(response).pipe( + Effect.mapError((cause) => + CloudRelayRequestError.fromClientFailure({ + operation: input.operation, + url: input.url, + cause, + }), + ), + ), + ), + Effect.flatMap((response) => + HttpClientResponse.schemaBodyJson(input.schema)(response).pipe( + Effect.mapError((cause) => + CloudRelayRequestError.fromClientFailure({ + operation: input.operation, + url: input.url, + responseStatus: response.status, + cause, + }), + ), + ), ), withRelayClientTracing, ); @@ -513,14 +670,15 @@ const reconcileDesiredCloudLinkWith = Effect.fn("environment.cloud.reconcileDesi function* (dependencies: CloudHttpDependencies, localOrigin: string) { const localUrl = yield* Effect.try({ try: () => new URL(localOrigin), - catch: () => + catch: (cause) => new EnvironmentHttpBadRequestError({ - message: "Could not resolve local environment origin.", + reason: "invalid_local_environment_origin", + cause, }), }); if (localUrl.origin !== localOrigin) { return yield* new EnvironmentHttpBadRequestError({ - message: "Could not resolve local environment origin.", + reason: "invalid_local_environment_origin", }); } const localWsOrigin = localOrigin.replace(/^http/u, "ws"); @@ -530,7 +688,7 @@ const reconcileDesiredCloudLinkWith = Effect.fn("environment.cloud.reconcileDesi onNone: () => Effect.fail( new EnvironmentHttpUnauthorizedError({ - message: "Run `t3 connect link` to authorize this environment.", + reason: "cloud_cli_authorization_required", }), ), onSome: Effect.succeed, @@ -539,6 +697,7 @@ const reconcileDesiredCloudLinkWith = Effect.fn("environment.cloud.reconcileDesi ); const relayUrl = yield* requireRelayUrl; const challenge = yield* relayClientRequest(dependencies, { + operation: "create-link-challenge", url: `${relayUrl}/v1/client/environment-link-challenges`, token: token.accessToken, payload: { @@ -566,6 +725,7 @@ const reconcileDesiredCloudLinkWith = Effect.fn("environment.cloud.reconcileDesi localOrigin, ); const link = yield* relayClientRequest(dependencies, { + operation: "create-environment-link", url: `${relayUrl}/v1/client/environment-links`, token: token.accessToken, payload: { @@ -586,16 +746,41 @@ const reconcileDesiredCloudLinkWith = Effect.fn("environment.cloud.reconcileDesi endpointRuntime: link.endpointRuntime, }); }, - Effect.catchIf( - ServerSecretStore.isSecretStoreError, - failEnvironmentCloudInternalError("Could not persist desired T3 Connect link state."), - ), Effect.catchTags({ - CloudCliCredentialRemovalError: failCloudCliTokenManagerError, - CloudCliCredentialRefreshError: failCloudCliTokenManagerError, - CloudCliCredentialReadError: failCloudCliTokenManagerError, - CloudCliAuthorizationError: failCloudCliTokenManagerError, - CloudCliAuthorizationTimeoutError: failCloudCliTokenManagerError, + ServerAuthLinkedCloudAccountVerificationError: (error) => + failEnvironmentCloudInternalError("verify_linked_cloud_account")(error), + ServerAuthCloudLinkJwtSigningError: (error) => + failEnvironmentCloudInternalError("sign_cloud_link_jwt")(error), + SecretStoreReadError: failEnvironmentCloudInternalError("persist_desired_link_state"), + SecretStoreTemporaryPathGenerationError: failEnvironmentCloudInternalError( + "persist_desired_link_state", + ), + SecretStorePersistError: failEnvironmentCloudInternalError("persist_desired_link_state"), + SecretStoreRemoveError: failEnvironmentCloudInternalError("persist_desired_link_state"), + SecretStoreDecodeError: failEnvironmentCloudInternalError("persist_desired_link_state"), + SecretStoreEncodeError: failEnvironmentCloudInternalError("persist_desired_link_state"), + SecretStoreConcurrentReadError: failEnvironmentCloudInternalError("persist_desired_link_state"), + SchemaError: failEnvironmentCloudInternalError("persist_desired_link_state"), + PlatformError: failEnvironmentCloudInternalError("persist_desired_link_state"), + CloudRelayConfigurationError: (error) => + failEnvironmentCloudInternalError("read_relay_url_configuration")(error), + CloudRelayRequestError: (error) => + failEnvironmentCloudInternalError({ + operation: "relay_request", + relayOperation: error.operation, + relayPhase: error.phase, + ...(error.responseStatus === undefined ? {} : { responseStatus: error.responseStatus }), + })(error), + CloudCliCredentialRemovalError: (error) => + failEnvironmentCloudInternalError("remove_cloud_cli_credential")(error), + CloudCliCredentialRefreshError: (error) => + failEnvironmentCloudInternalError("refresh_cloud_cli_credential")(error), + CloudCliCredentialReadError: (error) => + failEnvironmentCloudInternalError("read_cloud_cli_credential")(error), + CloudCliAuthorizationError: (error) => + failEnvironmentCloudInternalError("authorize_cloud_cli")(error), + CloudCliAuthorizationTimeoutError: (error) => + failEnvironmentCloudInternalError("await_cloud_cli_authorization")(error), }), ); @@ -633,10 +818,9 @@ const cloudLinkStateHandler = Effect.fn("environment.cloud.linkState")( yield* requireEnvironmentScope(AuthRelayReadScope); return yield* readCloudLinkState(dependencies); }, - Effect.catchIf( - ServerSecretStore.isSecretStoreError, - failEnvironmentCloudInternalError("Could not read environment relay configuration."), - ), + Effect.catchTags({ + SecretStoreReadError: failEnvironmentCloudInternalError("read_relay_configuration"), + }), ); const cloudUnlinkHandler = Effect.fn("environment.cloud.unlink")( @@ -658,10 +842,13 @@ const cloudUnlinkHandler = Effect.fn("environment.cloud.unlink")( yield* setCliDesiredCloudLink(false); return { ok: true, endpointRuntimeStatus } satisfies EnvironmentCloudRelayConfigResult; }, - Effect.catchIf( - ServerSecretStore.isSecretStoreError, - failEnvironmentCloudInternalError("Could not remove environment relay configuration."), - ), + Effect.catchTags({ + SecretStoreTemporaryPathGenerationError: failEnvironmentCloudInternalError( + "remove_relay_configuration", + ), + SecretStorePersistError: failEnvironmentCloudInternalError("remove_relay_configuration"), + SecretStoreRemoveError: failEnvironmentCloudInternalError("remove_relay_configuration"), + }), ); const cloudPreferencesHandler = Effect.fn("environment.cloud.preferences")( @@ -676,10 +863,13 @@ const cloudPreferencesHandler = Effect.fn("environment.cloud.preferences")( ); return yield* readCloudLinkState(dependencies); }, - Effect.catchIf( - ServerSecretStore.isSecretStoreError, - failEnvironmentCloudInternalError("Could not persist environment cloud preferences."), - ), + Effect.catchTags({ + SecretStoreReadError: failEnvironmentCloudInternalError("persist_cloud_preferences"), + SecretStoreTemporaryPathGenerationError: failEnvironmentCloudInternalError( + "persist_cloud_preferences", + ), + SecretStorePersistError: failEnvironmentCloudInternalError("persist_cloud_preferences"), + }), ); const cloudEnvironmentHealthHandler = Effect.fn("environment.cloud.health")( @@ -730,7 +920,7 @@ const cloudEnvironmentHealthHandler = Effect.fn("environment.cloud.health")( !hasExactScope({ scopes: proofOption.value.scope, expected: "environment:status" }) ) { return yield* new EnvironmentHttpUnauthorizedError({ - message: "Invalid cloud health request.", + reason: "invalid_cloud_health_request", }); } const proof = proofOption.value; @@ -744,7 +934,7 @@ const cloudEnvironmentHealthHandler = Effect.fn("environment.cloud.health")( }); if (!consumedReplayGuards) { return yield* new EnvironmentHttpConflictError({ - message: "Cloud health request was already consumed.", + reason: "cloud_health_request_replayed", }); } @@ -787,17 +977,26 @@ const cloudEnvironmentHealthHandler = Effect.fn("environment.cloud.health")( yield* appendCloudCredentialResponseHeaders; return response; }, - Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => - failEnvironmentCloudInternalError(error.message)(error), - ), - Effect.catchIf( - ServerSecretStore.isSecretStoreError, - failEnvironmentCloudInternalError("Could not answer cloud health request."), - ), - Effect.catchTag( - "PlatformError", - failEnvironmentCloudInternalError("Could not answer cloud health request."), - ), + Effect.catchTags({ + ServerAuthLinkedCloudAccountReadError: (error) => + failEnvironmentCloudInternalError("read_linked_cloud_account")(error), + ServerAuthLinkedCloudAccountMissingError: (error) => + failEnvironmentCloudInternalError("require_linked_cloud_account")(error), + ServerAuthCloudMintPublicKeyMissingError: (error) => + failEnvironmentCloudInternalError("read_cloud_mint_public_key")(error), + ServerAuthCloudRelayIssuerMissingError: (error) => + failEnvironmentCloudInternalError("read_cloud_relay_issuer")(error), + ServerAuthCloudHealthJwtSigningError: (error) => + failEnvironmentCloudInternalError("sign_cloud_health_jwt")(error), + SecretStoreReadError: failEnvironmentCloudInternalError("answer_cloud_health_request"), + SecretStorePersistError: failEnvironmentCloudInternalError("answer_cloud_health_request"), + SecretStoreDecodeError: failEnvironmentCloudInternalError("answer_cloud_health_request"), + SecretStoreEncodeError: failEnvironmentCloudInternalError("answer_cloud_health_request"), + SecretStoreConcurrentReadError: failEnvironmentCloudInternalError( + "answer_cloud_health_request", + ), + PlatformError: failEnvironmentCloudInternalError("answer_cloud_health_request"), + }), ); const cloudMintCredentialHandler = Effect.fn("environment.cloud.mintCredential")( @@ -849,7 +1048,7 @@ const cloudMintCredentialHandler = Effect.fn("environment.cloud.mintCredential") !hasExactScope({ scopes: proofOption.value.scope, expected: "environment:connect" }) ) { return yield* new EnvironmentHttpUnauthorizedError({ - message: "Invalid cloud mint request.", + reason: "invalid_cloud_mint_request", }); } const proof = proofOption.value; @@ -863,7 +1062,7 @@ const cloudMintCredentialHandler = Effect.fn("environment.cloud.mintCredential") }); if (!consumedReplayGuards) { return yield* new EnvironmentHttpConflictError({ - message: "Cloud mint request was already consumed.", + reason: "cloud_mint_request_replayed", }); } @@ -908,17 +1107,28 @@ const cloudMintCredentialHandler = Effect.fn("environment.cloud.mintCredential") yield* appendCloudCredentialResponseHeaders; return response; }, - Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => - failEnvironmentCloudInternalError(error.message)(error), - ), - Effect.catchIf( - ServerSecretStore.isSecretStoreError, - failEnvironmentCloudInternalError("Could not issue cloud connection credential."), - ), - Effect.catchTag( - "PlatformError", - failEnvironmentCloudInternalError("Could not issue cloud connection credential."), - ), + Effect.catchTags({ + ServerAuthLinkedCloudAccountReadError: (error) => + failEnvironmentCloudInternalError("read_linked_cloud_account")(error), + ServerAuthLinkedCloudAccountMissingError: (error) => + failEnvironmentCloudInternalError("require_linked_cloud_account")(error), + ServerAuthCloudMintPublicKeyMissingError: (error) => + failEnvironmentCloudInternalError("read_cloud_mint_public_key")(error), + ServerAuthCloudRelayIssuerMissingError: (error) => + failEnvironmentCloudInternalError("read_cloud_relay_issuer")(error), + ServerAuthPairingLinkCreationError: (error) => + failEnvironmentCloudInternalError("create_cloud_pairing_link")(error), + ServerAuthCloudMintJwtSigningError: (error) => + failEnvironmentCloudInternalError("sign_cloud_mint_jwt")(error), + SecretStoreReadError: failEnvironmentCloudInternalError("issue_cloud_connection_credential"), + SecretStorePersistError: failEnvironmentCloudInternalError("issue_cloud_connection_credential"), + SecretStoreDecodeError: failEnvironmentCloudInternalError("issue_cloud_connection_credential"), + SecretStoreEncodeError: failEnvironmentCloudInternalError("issue_cloud_connection_credential"), + SecretStoreConcurrentReadError: failEnvironmentCloudInternalError( + "issue_cloud_connection_credential", + ), + PlatformError: failEnvironmentCloudInternalError("issue_cloud_connection_credential"), + }), ); export const connectHttpApiLayer = HttpApiBuilder.group( diff --git a/infra/relay/src/environments/EnvironmentConnector.test.ts b/infra/relay/src/environments/EnvironmentConnector.test.ts index 63f12379870..0fd82a138af 100644 --- a/infra/relay/src/environments/EnvironmentConnector.test.ts +++ b/infra/relay/src/environments/EnvironmentConnector.test.ts @@ -517,6 +517,7 @@ describe("EnvironmentConnector", () => { Response.json( { _tag: "EnvironmentHttpInternalServerError", + operation: "answer_cloud_health_request", message: "Environment is unavailable.", }, { status: 500 }, diff --git a/packages/contracts/src/environmentHttp.ts b/packages/contracts/src/environmentHttp.ts index adc5f149cba..015c0289d93 100644 --- a/packages/contracts/src/environmentHttp.ts +++ b/packages/contracts/src/environmentHttp.ts @@ -169,13 +169,154 @@ const EnvironmentAuthenticationErrors = [ EnvironmentInternalError, ] as const; +export const EnvironmentHttpBadRequestReason = Schema.Literals([ + "invalid_cloud_mint_public_key", + "invalid_relay_url", + "invalid_relay_issuer", + "missing_relay_environment_credential", + "missing_cloud_user_id", + "invalid_managed_endpoint_origin", + "invalid_local_environment_origin", +]); +export type EnvironmentHttpBadRequestReason = typeof EnvironmentHttpBadRequestReason.Type; + +const environmentHttpBadRequestMessages = { + invalid_cloud_mint_public_key: "Cloud mint public key must be a valid Ed25519 public key.", + invalid_relay_url: "Relay URL must be a secure absolute HTTPS URL.", + invalid_relay_issuer: "Relay issuer must be a secure absolute HTTPS URL.", + missing_relay_environment_credential: "Relay environment credential is required.", + missing_cloud_user_id: "Cloud user id is required.", + invalid_managed_endpoint_origin: "Invalid managed endpoint origin.", + invalid_local_environment_origin: "Could not resolve local environment origin.", +} satisfies Record; + +export const EnvironmentHttpUnauthorizedReason = Schema.Literals([ + "cloud_cli_authorization_required", + "invalid_cloud_health_request", + "invalid_cloud_mint_request", +]); +export type EnvironmentHttpUnauthorizedReason = typeof EnvironmentHttpUnauthorizedReason.Type; + +const environmentHttpUnauthorizedMessages = { + cloud_cli_authorization_required: "Run `t3 connect link` to authorize this environment.", + invalid_cloud_health_request: "Invalid cloud health request.", + invalid_cloud_mint_request: "Invalid cloud mint request.", +} satisfies Record; + +export const EnvironmentHttpInternalOperation = Schema.Literals([ + "generate_link_proof", + "verify_linked_cloud_account", + "persist_relay_configuration", + "persist_desired_link_state", + "read_relay_configuration", + "remove_relay_configuration", + "persist_cloud_preferences", + "answer_cloud_health_request", + "issue_cloud_connection_credential", + "read_linked_cloud_account", + "require_linked_cloud_account", + "sign_cloud_link_jwt", + "read_cloud_mint_public_key", + "read_cloud_relay_issuer", + "sign_cloud_health_jwt", + "sign_cloud_mint_jwt", + "create_cloud_pairing_link", + "read_relay_url_configuration", + "relay_request", + "remove_cloud_cli_credential", + "refresh_cloud_cli_credential", + "read_cloud_cli_credential", + "authorize_cloud_cli", + "await_cloud_cli_authorization", +]); +export type EnvironmentHttpInternalOperation = typeof EnvironmentHttpInternalOperation.Type; + +export const EnvironmentHttpRelayOperation = Schema.Literals([ + "create-link-challenge", + "create-environment-link", +]); +export type EnvironmentHttpRelayOperation = typeof EnvironmentHttpRelayOperation.Type; + +export const EnvironmentHttpRelayPhase = Schema.Literals([ + "encode-request", + "send-request", + "check-response-status", + "decode-response", +]); +export type EnvironmentHttpRelayPhase = typeof EnvironmentHttpRelayPhase.Type; + +const environmentHttpInternalMessages = { + generate_link_proof: "Could not generate environment link proof.", + verify_linked_cloud_account: "Could not verify the linked cloud account.", + persist_relay_configuration: "Could not persist environment relay configuration.", + persist_desired_link_state: "Could not persist desired T3 Connect link state.", + read_relay_configuration: "Could not read environment relay configuration.", + remove_relay_configuration: "Could not remove environment relay configuration.", + persist_cloud_preferences: "Could not persist environment cloud preferences.", + answer_cloud_health_request: "Could not answer cloud health request.", + issue_cloud_connection_credential: "Could not issue cloud connection credential.", + read_linked_cloud_account: "Could not read the linked cloud account.", + require_linked_cloud_account: "Cloud linked user is not installed for this environment.", + sign_cloud_link_jwt: "Failed to sign cloud link JWT.", + read_cloud_mint_public_key: "Cloud mint public key is not installed for this environment.", + read_cloud_relay_issuer: "Cloud relay issuer is not installed for this environment.", + sign_cloud_health_jwt: "Failed to sign cloud health JWT.", + sign_cloud_mint_jwt: "Failed to sign cloud mint JWT.", + create_cloud_pairing_link: "Failed to create pairing link.", + read_relay_url_configuration: + "T3CODE_RELAY_URL must be configured as a secure absolute HTTPS origin.", + remove_cloud_cli_credential: "Could not remove the stored T3 Connect CLI credential.", + refresh_cloud_cli_credential: "Could not refresh the T3 Connect CLI credential.", + read_cloud_cli_credential: "Could not read the stored T3 Connect CLI credential.", + authorize_cloud_cli: "Could not authorize the T3 Connect CLI.", + await_cloud_cli_authorization: "Timed out waiting for T3 Connect authorization.", +} satisfies Record, string>; + +export const EnvironmentHttpConflictReason = Schema.Literals([ + "linked_to_different_cloud_account", + "cloud_health_request_replayed", + "cloud_mint_request_replayed", +]); +export type EnvironmentHttpConflictReason = typeof EnvironmentHttpConflictReason.Type; + +const environmentHttpConflictMessages = { + linked_to_different_cloud_account: + "This environment is already linked to a different cloud account. Unlink it before switching accounts.", + cloud_health_request_replayed: "Cloud health request was already consumed.", + cloud_mint_request_replayed: "Cloud mint request was already consumed.", +} satisfies Record; + +// These HTTP errors cross independently deployed clients, relays, and environment servers. Keep +// newly added diagnostics optional while decoding so a newer peer can still consume the legacy +// message-only payload. The constructors below continue to require structured context from new +// application code and only preserve `message` when Schema is decoding an older wire payload. +function decodedEnvironmentHttpErrorMessage(props: object): string | undefined { + if (!("message" in props)) return undefined; + return typeof props.message === "string" ? props.message : undefined; +} + export class EnvironmentHttpBadRequestError extends Schema.TaggedErrorClass()( "EnvironmentHttpBadRequestError", { + reason: Schema.optional(EnvironmentHttpBadRequestReason), message: Schema.String, }, { httpApiStatus: 400 }, ) { + // @effect-diagnostics-next-line overriddenSchemaConstructor:off + constructor(props: { + readonly reason: EnvironmentHttpBadRequestReason; + readonly cause?: unknown; + }) { + super({ + reason: props.reason, + message: + decodedEnvironmentHttpErrorMessage(props) ?? + environmentHttpBadRequestMessages[props.reason], + ...(props.cause === undefined ? {} : { cause: props.cause }), + } as any); + } + [HttpServerRespondable.symbol]() { return HttpServerResponse.schemaJson(EnvironmentHttpBadRequestError)(this, { status: 400 }); } @@ -184,10 +325,25 @@ export class EnvironmentHttpBadRequestError extends Schema.TaggedErrorClass()( "EnvironmentHttpUnauthorizedError", { + reason: Schema.optional(EnvironmentHttpUnauthorizedReason), message: Schema.String, }, { httpApiStatus: 401 }, ) { + // @effect-diagnostics-next-line overriddenSchemaConstructor:off + constructor(props: { + readonly reason: EnvironmentHttpUnauthorizedReason; + readonly cause?: unknown; + }) { + super({ + reason: props.reason, + message: + decodedEnvironmentHttpErrorMessage(props) ?? + environmentHttpUnauthorizedMessages[props.reason], + ...(props.cause === undefined ? {} : { cause: props.cause }), + } as any); + } + [HttpServerRespondable.symbol]() { return HttpServerResponse.schemaJson(EnvironmentHttpUnauthorizedError)(this, { status: 401 }); } @@ -200,6 +356,14 @@ export class EnvironmentHttpForbiddenError extends Schema.TaggedErrorClass()( "EnvironmentHttpInternalServerError", { + operation: Schema.optional(EnvironmentHttpInternalOperation), + relayOperation: Schema.optional(EnvironmentHttpRelayOperation), + relayPhase: Schema.optional(EnvironmentHttpRelayPhase), + responseStatus: Schema.optional(Schema.Number), message: Schema.String, }, { httpApiStatus: 500 }, ) { + // @effect-diagnostics-next-line overriddenSchemaConstructor:off + constructor( + props: + | { + readonly operation: Exclude; + readonly cause?: unknown; + } + | { + readonly operation: "relay_request"; + readonly relayOperation: EnvironmentHttpRelayOperation; + readonly relayPhase: EnvironmentHttpRelayPhase; + readonly responseStatus?: number; + readonly cause?: unknown; + }, + ) { + const message = + decodedEnvironmentHttpErrorMessage(props) ?? + (props.operation === "relay_request" + ? `T3 Connect relay ${props.relayOperation} failed during ${props.relayPhase}${ + props.responseStatus === undefined + ? "" + : ` with response status ${props.responseStatus}` + }.` + : environmentHttpInternalMessages[props.operation]); + super({ + ...props, + message, + } as any); + } + [HttpServerRespondable.symbol]() { return HttpServerResponse.schemaJson(EnvironmentHttpInternalServerError)(this, { status: 500 }); } @@ -220,10 +418,21 @@ export class EnvironmentHttpInternalServerError extends Schema.TaggedErrorClass< export class EnvironmentHttpConflictError extends Schema.TaggedErrorClass()( "EnvironmentHttpConflictError", { + reason: Schema.optional(EnvironmentHttpConflictReason), message: Schema.String, }, { httpApiStatus: 409 }, ) { + // @effect-diagnostics-next-line overriddenSchemaConstructor:off + constructor(props: { readonly reason: EnvironmentHttpConflictReason; readonly cause?: unknown }) { + super({ + reason: props.reason, + message: + decodedEnvironmentHttpErrorMessage(props) ?? environmentHttpConflictMessages[props.reason], + ...(props.cause === undefined ? {} : { cause: props.cause }), + } as any); + } + [HttpServerRespondable.symbol]() { return HttpServerResponse.schemaJson(EnvironmentHttpConflictError)(this, { status: 409 }); } @@ -237,6 +446,16 @@ export class EnvironmentCloudEndpointUnavailableError extends Schema.TaggedError }, { httpApiStatus: 503 }, ) { + // @effect-diagnostics-next-line overriddenSchemaConstructor:off + constructor(props: { readonly endpointRuntimeStatus: unknown; readonly cause?: unknown }) { + super({ + ...props, + message: + decodedEnvironmentHttpErrorMessage(props) ?? + "Managed endpoint runtime could not be started.", + } as any); + } + [HttpServerRespondable.symbol]() { return HttpServerResponse.schemaJson(EnvironmentCloudEndpointUnavailableError)(this, { status: 503,