From cedd410a724553cdf438f6050a84ac5098274864 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 02:03:46 -0700 Subject: [PATCH 1/8] Improve server auth error context Co-authored-by: codex --- apps/server/src/auth/EnvironmentAuth.test.ts | 26 ++ apps/server/src/auth/EnvironmentAuth.ts | 257 ++++++++++++------- apps/server/src/auth/dpop.test.ts | 19 +- apps/server/src/auth/dpop.ts | 19 +- apps/server/src/auth/http.ts | 144 ++++++----- apps/server/src/http.ts | 12 +- apps/server/src/ws.ts | 11 +- 7 files changed, 316 insertions(+), 172 deletions(-) diff --git a/apps/server/src/auth/EnvironmentAuth.test.ts b/apps/server/src/auth/EnvironmentAuth.test.ts index 335e0685197..ae90389d38f 100644 --- a/apps/server/src/auth/EnvironmentAuth.test.ts +++ b/apps/server/src/auth/EnvironmentAuth.test.ts @@ -114,6 +114,16 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { .pipe(Effect.flip); expect(error._tag).toBe("ServerAuthScopeNotGrantedError"); + if (error._tag === "ServerAuthScopeNotGrantedError") { + expect(error.requestedScopes).toEqual(["orchestration:read", "access:write"]); + expect(error.grantedScopes).toEqual([ + "orchestration:read", + "orchestration:operate", + "terminal:operate", + "review:write", + "relay:read", + ]); + } }).pipe(Effect.provide(makeEnvironmentAuthLayer())), ); @@ -252,4 +262,20 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { ), ), ); + + it.effect("retains both session ids when rejecting self-revocation", () => + Effect.gen(function* () { + const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; + const issued = yield* serverAuth.issueSession(); + const error = yield* serverAuth + .revokeClientSession(issued.sessionId, issued.sessionId) + .pipe(Effect.flip); + + expect(error._tag).toBe("ServerAuthForbiddenOperationError"); + if (error._tag === "ServerAuthForbiddenOperationError") { + expect(error.currentSessionId).toBe(issued.sessionId); + expect(error.targetSessionId).toBe(issued.sessionId); + } + }).pipe(Effect.provide(makeEnvironmentAuthLayer())), + ); }); diff --git a/apps/server/src/auth/EnvironmentAuth.ts b/apps/server/src/auth/EnvironmentAuth.ts index dd53a83ca95..fdc228a10a3 100644 --- a/apps/server/src/auth/EnvironmentAuth.ts +++ b/apps/server/src/auth/EnvironmentAuth.ts @@ -85,44 +85,50 @@ export class ServerAuthBootstrapCredentialValidationError extends Schema.TaggedE export class ServerAuthSessionCredentialValidationError extends Schema.TaggedErrorClass()( "ServerAuthSessionCredentialValidationError", { + credentialKind: Schema.Literals(["session", "websocket-ticket"]), ...serverAuthInternalErrorContext, }, ) { override get message(): string { - return "Failed to validate session credential."; + return `Failed to validate ${this.credentialKind} credential.`; } } export class ServerAuthAuthenticatedSessionIssueError extends Schema.TaggedErrorClass()( "ServerAuthAuthenticatedSessionIssueError", { + subject: Schema.String, ...serverAuthInternalErrorContext, }, ) { override get message(): string { - return "Failed to issue authenticated session."; + return `Failed to issue authenticated session for ${this.subject}.`; } } export class ServerAuthAuthenticatedAccessTokenIssueError extends Schema.TaggedErrorClass()( "ServerAuthAuthenticatedAccessTokenIssueError", { + subject: Schema.String, + scopes: Schema.Array(Schema.String), ...serverAuthInternalErrorContext, }, ) { override get message(): string { - return "Failed to issue authenticated access token."; + return `Failed to issue authenticated access token for ${this.subject} with scopes [${this.scopes.join(", ")}].`; } } export class ServerAuthPairingLinkCreationError extends Schema.TaggedErrorClass()( "ServerAuthPairingLinkCreationError", { + subject: Schema.String, + scopes: Schema.Array(Schema.String), ...serverAuthInternalErrorContext, }, ) { override get message(): string { - return "Failed to create pairing link."; + return `Failed to create pairing link for ${this.subject} with scopes [${this.scopes.join(", ")}].`; } } @@ -140,22 +146,25 @@ export class ServerAuthPairingLinksListError extends Schema.TaggedErrorClass()( "ServerAuthPairingLinkRevocationError", { + pairingLinkId: Schema.String, ...serverAuthInternalErrorContext, }, ) { override get message(): string { - return "Failed to revoke pairing link."; + return `Failed to revoke pairing link ${this.pairingLinkId}.`; } } export class ServerAuthSessionTokenIssueError extends Schema.TaggedErrorClass()( "ServerAuthSessionTokenIssueError", { + subject: Schema.String, + scopes: Schema.Array(Schema.String), ...serverAuthInternalErrorContext, }, ) { override get message(): string { - return "Failed to issue session token."; + return `Failed to issue session token for ${this.subject} with scopes [${this.scopes.join(", ")}].`; } } @@ -173,55 +182,63 @@ export class ServerAuthSessionsListError extends Schema.TaggedErrorClass()( "ServerAuthSessionRevocationError", { + sessionId: Schema.String, ...serverAuthInternalErrorContext, }, ) { override get message(): string { - return "Failed to revoke session."; + return `Failed to revoke session ${this.sessionId}.`; } } export class ServerAuthOtherSessionsRevocationError extends Schema.TaggedErrorClass()( "ServerAuthOtherSessionsRevocationError", { + excludedSessionId: Schema.String, ...serverAuthInternalErrorContext, }, ) { override get message(): string { - return "Failed to revoke other sessions."; + return `Failed to revoke sessions other than ${this.excludedSessionId}.`; } } export class ServerAuthWebSocketTokenIssueError extends Schema.TaggedErrorClass()( "ServerAuthWebSocketTokenIssueError", { + sessionId: Schema.String, ...serverAuthInternalErrorContext, }, ) { override get message(): string { - return "Failed to issue websocket token."; + return `Failed to issue websocket token for session ${this.sessionId}.`; } } export class ServerAuthDpopReplayStateRecordError extends Schema.TaggedErrorClass()( "ServerAuthDpopReplayStateRecordError", { + proofKeyThumbprint: Schema.String, + proofId: Schema.String, + replayKey: Schema.String, ...serverAuthInternalErrorContext, }, ) { override get message(): string { - return "Failed to record DPoP proof replay state."; + return `Failed to record replay state for DPoP proof ${this.proofId} (${this.proofKeyThumbprint}).`; } } export class ServerAuthDpopReplayKeyCalculationError extends Schema.TaggedErrorClass()( "ServerAuthDpopReplayKeyCalculationError", { + proofKeyThumbprint: Schema.String, + proofId: Schema.String, ...serverAuthInternalErrorContext, }, ) { override get message(): string { - return "Failed to calculate DPoP replay key."; + return `Failed to calculate replay key for DPoP proof ${this.proofId} (${this.proofKeyThumbprint}).`; } } @@ -360,11 +377,6 @@ export const ServerAuthCredentialError = Schema.Union([ ServerAuthInvalidCredentialError, ]); export type ServerAuthCredentialError = typeof ServerAuthCredentialError.Type; -export const isServerAuthCredentialError = Schema.is(ServerAuthCredentialError); -export const serverAuthCredentialReason = ( - error: ServerAuthCredentialError, -): "missing_credential" | "invalid_credential" => - error._tag === "ServerAuthMissingCredentialError" ? "missing_credential" : "invalid_credential"; export class ServerAuthInvalidScopeError extends Schema.TaggedErrorClass()( "ServerAuthInvalidScopeError", @@ -377,10 +389,13 @@ export class ServerAuthInvalidScopeError extends Schema.TaggedErrorClass()( "ServerAuthScopeNotGrantedError", - {}, + { + requestedScopes: Schema.Array(Schema.String), + grantedScopes: Schema.Array(Schema.String), + }, ) { override get message(): string { - return "The requested authentication scope was not granted."; + return `Requested scopes [${this.requestedScopes.join(", ")}] exceed granted scopes [${this.grantedScopes.join(", ")}].`; } } @@ -389,28 +404,35 @@ export const ServerAuthInvalidRequestError = Schema.Union([ ServerAuthScopeNotGrantedError, ]); export type ServerAuthInvalidRequestError = typeof ServerAuthInvalidRequestError.Type; -export const isServerAuthInvalidRequestError = Schema.is(ServerAuthInvalidRequestError); -export const serverAuthInvalidRequestReason = ( - error: ServerAuthInvalidRequestError, -): "invalid_scope" | "scope_not_granted" => - error._tag === "ServerAuthInvalidScopeError" ? "invalid_scope" : "scope_not_granted"; export class ServerAuthForbiddenOperationError extends Schema.TaggedErrorClass()( "ServerAuthForbiddenOperationError", - {}, + { + currentSessionId: Schema.String, + targetSessionId: Schema.String, + }, ) { override get message(): string { - return "The current authentication session cannot revoke itself."; + return `Authentication session ${this.currentSessionId} cannot revoke itself.`; } } +export type ServerAuthAuthenticationInternalError = + | ServerAuthSessionCredentialValidationError + | ServerAuthDpopReplayStateRecordError + | ServerAuthDpopReplayKeyCalculationError; + +export type ServerAuthAuthenticationError = + | ServerAuthCredentialError + | ServerAuthAuthenticationInternalError; + export class EnvironmentAuth extends Context.Service< EnvironmentAuth, { readonly getDescriptor: () => Effect.Effect; readonly getSessionState: ( request: HttpServerRequest.HttpServerRequest, - ) => Effect.Effect; + ) => Effect.Effect; readonly createBrowserSession: ( credential: string, requestMetadata: AuthClientMetadata, @@ -419,7 +441,9 @@ export class EnvironmentAuth extends Context.Service< readonly response: AuthBrowserSessionResult; readonly sessionToken: string; }, - ServerAuthInvalidCredentialError | ServerAuthInternalError + | ServerAuthInvalidCredentialError + | ServerAuthBootstrapCredentialValidationError + | ServerAuthAuthenticatedSessionIssueError >; readonly exchangeBootstrapCredentialForAccessToken: ( credential: string, @@ -430,7 +454,10 @@ export class EnvironmentAuth extends Context.Service< }, ) => Effect.Effect< AuthAccessTokenResult, - ServerAuthInvalidCredentialError | ServerAuthInvalidRequestError | ServerAuthInternalError + | ServerAuthInvalidCredentialError + | ServerAuthScopeNotGrantedError + | ServerAuthBootstrapCredentialValidationError + | ServerAuthAuthenticatedAccessTokenIssueError >; readonly createPairingLink: (input?: { readonly ttl?: Duration.Duration; @@ -438,56 +465,61 @@ export class EnvironmentAuth extends Context.Service< readonly scopes?: ReadonlyArray; readonly subject?: string; readonly proofKeyThumbprint?: string; - }) => Effect.Effect; + }) => Effect.Effect; readonly issuePairingCredential: ( input?: AuthCreatePairingCredentialInput, - ) => Effect.Effect; + ) => Effect.Effect; readonly issueStartupPairingCredential: () => Effect.Effect< AuthPairingCredentialResult, - ServerAuthInternalError + ServerAuthPairingLinkCreationError >; readonly listPairingLinks: (input?: { readonly excludeSubjects?: ReadonlyArray; - }) => Effect.Effect, ServerAuthInternalError>; - readonly revokePairingLink: (id: string) => Effect.Effect; + }) => Effect.Effect, ServerAuthPairingLinksListError>; + readonly revokePairingLink: ( + id: string, + ) => Effect.Effect; readonly issueSession: (input?: { readonly ttl?: Duration.Duration; readonly subject?: string; readonly scopes?: ReadonlyArray; readonly label?: string; - }) => Effect.Effect; + }) => Effect.Effect; readonly listSessions: () => Effect.Effect< ReadonlyArray, - ServerAuthInternalError + ServerAuthSessionsListError >; readonly revokeSession: ( sessionId: AuthSessionId, - ) => Effect.Effect; + ) => Effect.Effect; readonly revokeOtherSessionsExcept: ( sessionId: AuthSessionId, - ) => Effect.Effect; + ) => Effect.Effect; readonly listClientSessions: ( currentSessionId: AuthSessionId, - ) => Effect.Effect, ServerAuthInternalError>; + ) => Effect.Effect, ServerAuthSessionsListError>; readonly revokeClientSession: ( currentSessionId: AuthSessionId, targetSessionId: AuthSessionId, - ) => Effect.Effect; + ) => Effect.Effect< + boolean, + ServerAuthForbiddenOperationError | ServerAuthSessionRevocationError + >; readonly revokeOtherClientSessions: ( currentSessionId: AuthSessionId, - ) => Effect.Effect; + ) => Effect.Effect; readonly authenticateHttpRequest: ( request: HttpServerRequest.HttpServerRequest, - ) => Effect.Effect; + ) => Effect.Effect; readonly authenticateWebSocketUpgrade: ( request: HttpServerRequest.HttpServerRequest, - ) => Effect.Effect; + ) => Effect.Effect; readonly issueWebSocketTicket: ( session: Pick, - ) => Effect.Effect; + ) => Effect.Effect; readonly issueStartupPairingUrl: ( baseUrl: string, - ) => Effect.Effect; + ) => Effect.Effect; } >()("t3/auth/EnvironmentAuth") {} @@ -514,7 +546,7 @@ const bySessionPriority = (left: AuthClientSession, right: AuthClientSession) => export function toBootstrapExchangeError( cause: PairingGrantStore.BootstrapCredentialError, -): ServerAuthInvalidCredentialError | ServerAuthInternalError { +): ServerAuthInvalidCredentialError | ServerAuthBootstrapCredentialValidationError { if (PairingGrantStore.isBootstrapCredentialInternalError(cause)) { return new ServerAuthBootstrapCredentialValidationError({ cause }); } @@ -526,12 +558,17 @@ export function toBootstrapExchangeError( const mapSessionVerificationErrors = ( effect: Effect.Effect, -): Effect.Effect => + credentialKind: ServerAuthSessionCredentialValidationError["credentialKind"], +): Effect.Effect< + A, + ServerAuthInvalidCredentialError | ServerAuthSessionCredentialValidationError, + R +> => effect.pipe( Effect.mapError((cause) => SessionStore.isSessionCredentialInvalidError(cause) ? new ServerAuthInvalidCredentialError({ cause }) - : new ServerAuthSessionCredentialValidationError({ cause }), + : new ServerAuthSessionCredentialValidationError({ credentialKind, cause }), ), ); @@ -565,7 +602,7 @@ export const make = Effect.gen(function* () { token: string, ): Effect.Effect< AuthenticatedSession, - ServerAuthInvalidCredentialError | ServerAuthInternalError + ServerAuthInvalidCredentialError | ServerAuthSessionCredentialValidationError > => sessions.verify(token).pipe( Effect.tapError((cause) => @@ -585,12 +622,12 @@ export const make = Effect.gen(function* () { ...(session.proofKeyThumbprint ? { proofKeyThumbprint: session.proofKeyThumbprint } : {}), ...(session.expiresAt ? { expiresAt: session.expiresAt } : {}), })), - mapSessionVerificationErrors, + (effect) => mapSessionVerificationErrors(effect, "session"), ); const authenticateRequest = ( request: HttpServerRequest.HttpServerRequest, - ): Effect.Effect => { + ): Effect.Effect => { const cookieToken = request.cookies[sessions.cookieName]; const bearerToken = parseBearerToken(request); const dpopToken = parseDpopToken(request); @@ -642,12 +679,18 @@ export const make = Effect.gen(function* () { ...(session.expiresAt ? { expiresAt: DateTime.toUtc(session.expiresAt) } : {}), }) satisfies AuthSessionState, ), - Effect.catchIf(isServerAuthCredentialError, () => - Effect.succeed({ - authenticated: false, - auth: descriptor, - } satisfies AuthSessionState), - ), + Effect.catchTags({ + ServerAuthMissingCredentialError: () => + Effect.succeed({ + authenticated: false, + auth: descriptor, + } satisfies AuthSessionState), + ServerAuthInvalidCredentialError: () => + Effect.succeed({ + authenticated: false, + auth: descriptor, + } satisfies AuthSessionState), + }), Effect.withSpan("EnvironmentAuth.getSessionState"), ); @@ -669,7 +712,13 @@ export const make = Effect.gen(function* () { }, }) .pipe( - Effect.mapError((cause) => new ServerAuthAuthenticatedSessionIssueError({ cause })), + Effect.mapError( + (cause) => + new ServerAuthAuthenticatedSessionIssueError({ + subject: grant.subject, + cause, + }), + ), ), ), Effect.map( @@ -695,7 +744,10 @@ export const make = Effect.gen(function* () { Effect.gen(function* () { const grantedScopes = requestedScopes ?? grant.scopes; if (!grantedScopes.every((scope) => grant.scopes.includes(scope))) { - return yield* new ServerAuthScopeNotGrantedError({}); + return yield* new ServerAuthScopeNotGrantedError({ + requestedScopes: grantedScopes, + grantedScopes: grant.scopes, + }); } return yield* sessions .issue({ @@ -715,7 +767,12 @@ export const make = Effect.gen(function* () { }) .pipe( Effect.mapError( - (cause) => new ServerAuthAuthenticatedAccessTokenIssueError({ cause }), + (cause) => + new ServerAuthAuthenticatedAccessTokenIssueError({ + subject: grant.subject, + scopes: grantedScopes, + cause, + }), ), ); }), @@ -765,28 +822,33 @@ export const make = Effect.gen(function* () { const createPairingLink: EnvironmentAuth["Service"]["createPairingLink"] = Effect.fn( "EnvironmentAuth.createPairingLink", - )( - function* (input) { - const createdAt = yield* DateTime.now; - const issued = yield* bootstrapCredentials.issueOneTimeToken({ - scopes: input?.scopes ?? AuthStandardClientScopes, - subject: input?.subject ?? "one-time-token", + )(function* (input) { + const scopes = input?.scopes ?? AuthStandardClientScopes; + const subject = input?.subject ?? "one-time-token"; + const createdAt = yield* DateTime.now; + const issued = yield* bootstrapCredentials + .issueOneTimeToken({ + scopes, + subject, ...(input?.ttl ? { ttl: input.ttl } : {}), ...(input?.label ? { label: input.label } : {}), ...(input?.proofKeyThumbprint ? { proofKeyThumbprint: input.proofKeyThumbprint } : {}), - }); - return { - id: issued.id, - credential: issued.credential, - scopes: input?.scopes ?? AuthStandardClientScopes, - subject: input?.subject ?? "one-time-token", - ...(issued.label ? { label: issued.label } : {}), - createdAt: DateTime.toUtc(createdAt), - expiresAt: DateTime.toUtc(issued.expiresAt), - } satisfies IssuedPairingLink; - }, - Effect.mapError((cause) => new ServerAuthPairingLinkCreationError({ cause })), - ); + }) + .pipe( + Effect.mapError( + (cause) => new ServerAuthPairingLinkCreationError({ subject, scopes, cause }), + ), + ); + return { + id: issued.id, + credential: issued.credential, + scopes, + subject, + ...(issued.label ? { label: issued.label } : {}), + createdAt: DateTime.toUtc(createdAt), + expiresAt: DateTime.toUtc(issued.expiresAt), + } satisfies IssuedPairingLink; + }); const listPairingLinks: EnvironmentAuth["Service"]["listPairingLinks"] = (input) => bootstrapCredentials.listActive().pipe( @@ -806,16 +868,20 @@ export const make = Effect.gen(function* () { const revokePairingLink: EnvironmentAuth["Service"]["revokePairingLink"] = (id) => bootstrapCredentials.revoke(id).pipe( - Effect.mapError((cause) => new ServerAuthPairingLinkRevocationError({ cause })), + Effect.mapError( + (cause) => new ServerAuthPairingLinkRevocationError({ pairingLinkId: id, cause }), + ), Effect.withSpan("EnvironmentAuth.revokePairingLink"), ); - const issueSession: EnvironmentAuth["Service"]["issueSession"] = (input) => - sessions + const issueSession: EnvironmentAuth["Service"]["issueSession"] = (input) => { + const subject = input?.subject ?? DEFAULT_SESSION_SUBJECT; + const scopes = input?.scopes ?? AuthAdministrativeScopes; + return sessions .issue({ - subject: input?.subject ?? DEFAULT_SESSION_SUBJECT, + subject, method: "bearer-access-token", - scopes: input?.scopes ?? AuthAdministrativeScopes, + scopes, client: { ...(input?.label ? { label: input.label } : {}), deviceType: "bot", @@ -830,14 +896,17 @@ export const make = Effect.gen(function* () { token: issued.token, method: "bearer-access-token", scopes: issued.scopes, - subject: input?.subject ?? DEFAULT_SESSION_SUBJECT, + subject, client: issued.client, expiresAt: DateTime.toUtc(issued.expiresAt), }) satisfies IssuedBearerSession, ), - Effect.mapError((cause) => new ServerAuthSessionTokenIssueError({ cause })), + Effect.mapError( + (cause) => new ServerAuthSessionTokenIssueError({ subject, scopes, cause }), + ), Effect.withSpan("EnvironmentAuth.issueSession"), ); + }; const listSessions: EnvironmentAuth["Service"]["listSessions"] = () => sessions.listActive().pipe( @@ -848,7 +917,7 @@ export const make = Effect.gen(function* () { const revokeSession: EnvironmentAuth["Service"]["revokeSession"] = (sessionId) => sessions.revoke(sessionId).pipe( - Effect.mapError((cause) => new ServerAuthSessionRevocationError({ cause })), + Effect.mapError((cause) => new ServerAuthSessionRevocationError({ sessionId, cause })), Effect.withSpan("EnvironmentAuth.revokeSession"), ); @@ -856,7 +925,10 @@ export const make = Effect.gen(function* () { sessionId, ) => sessions.revokeAllExcept(sessionId).pipe( - Effect.mapError((cause) => new ServerAuthOtherSessionsRevocationError({ cause })), + Effect.mapError( + (cause) => + new ServerAuthOtherSessionsRevocationError({ excludedSessionId: sessionId, cause }), + ), Effect.withSpan("EnvironmentAuth.revokeOtherSessionsExcept"), ); @@ -891,7 +963,10 @@ export const make = Effect.gen(function* () { "EnvironmentAuth.revokeClientSession", )(function* (currentSessionId, targetSessionId) { if (currentSessionId === targetSessionId) { - return yield* new ServerAuthForbiddenOperationError({}); + return yield* new ServerAuthForbiddenOperationError({ + currentSessionId, + targetSessionId, + }); } return yield* revokeSession(targetSessionId); }); @@ -917,7 +992,9 @@ export const make = Effect.gen(function* () { const issueWebSocketTicket: EnvironmentAuth["Service"]["issueWebSocketTicket"] = (session) => sessions.issueWebSocketToken(session.sessionId).pipe( - Effect.mapError((cause) => new ServerAuthWebSocketTokenIssueError({ cause })), + Effect.mapError( + (cause) => new ServerAuthWebSocketTokenIssueError({ sessionId: session.sessionId, cause }), + ), Effect.map( (issued) => ({ @@ -947,7 +1024,7 @@ export const make = Effect.gen(function* () { scopes: session.scopes, ...(session.expiresAt ? { expiresAt: session.expiresAt } : {}), })), - mapSessionVerificationErrors, + (effect) => mapSessionVerificationErrors(effect, "websocket-ticket"), ); } } diff --git a/apps/server/src/auth/dpop.test.ts b/apps/server/src/auth/dpop.test.ts index fa75c407b0c..81610a2201d 100644 --- a/apps/server/src/auth/dpop.test.ts +++ b/apps/server/src/auth/dpop.test.ts @@ -15,10 +15,16 @@ const storeFailure = (tag: "AlreadyExists" | "PermissionDenied") => }), }); +const replayContext = { + proofKeyThumbprint: "proof-key-thumbprint", + proofId: "proof-id", + replayKey: "replay-key", +}; + describe("mapDpopReplayStoreError", () => { it("reports replay conflicts as invalid credentials", () => { const cause = storeFailure("AlreadyExists"); - const error = mapDpopReplayStoreError(cause); + const error = mapDpopReplayStoreError(cause, replayContext); expect(error._tag).toBe("ServerAuthInvalidCredentialError"); if (error._tag === "ServerAuthInvalidCredentialError") { @@ -27,11 +33,18 @@ describe("mapDpopReplayStoreError", () => { }); it("reports replay-store availability failures as internal errors", () => { - const error = mapDpopReplayStoreError(storeFailure("PermissionDenied")); + const cause = storeFailure("PermissionDenied"); + const error = mapDpopReplayStoreError(cause, replayContext); expect(error._tag).toBe("ServerAuthDpopReplayStateRecordError"); if (error._tag === "ServerAuthDpopReplayStateRecordError") { - expect(error.message).toBe("Failed to record DPoP proof replay state."); + expect(error.message).toBe( + "Failed to record replay state for DPoP proof proof-id (proof-key-thumbprint).", + ); + expect(error.proofKeyThumbprint).toBe(replayContext.proofKeyThumbprint); + expect(error.proofId).toBe(replayContext.proofId); + expect(error.replayKey).toBe(replayContext.replayKey); + expect(error.cause).toBe(cause); } }); }); diff --git a/apps/server/src/auth/dpop.ts b/apps/server/src/auth/dpop.ts index 87dc0c263e2..f5da796e747 100644 --- a/apps/server/src/auth/dpop.ts +++ b/apps/server/src/auth/dpop.ts @@ -9,7 +9,6 @@ import { ServerAuthDpopReplayKeyCalculationError, ServerAuthDpopReplayStateRecordError, ServerAuthInvalidCredentialError, - type ServerAuthInternalError, } from "./EnvironmentAuth.ts"; import * as ServerSecretStore from "./ServerSecretStore.ts"; @@ -31,13 +30,19 @@ export function requestAbsoluteUrl(request: HttpServerRequest.HttpServerRequest) export const mapDpopReplayStoreError = ( error: ServerSecretStore.SecretStoreError, -): ServerAuthInvalidCredentialError | ServerAuthInternalError => + context: { + readonly proofKeyThumbprint: string; + readonly proofId: string; + readonly replayKey: string; + }, +): ServerAuthInvalidCredentialError | ServerAuthDpopReplayStateRecordError => ServerSecretStore.isSecretAlreadyExistsError(error) ? new ServerAuthInvalidCredentialError({ diagnostic: "DPoP proof replayed.", cause: error, }) : new ServerAuthDpopReplayStateRecordError({ + ...context, cause: error, }); @@ -71,6 +76,8 @@ export const verifyRequestDpopProof = (input: { Effect.mapError( (cause) => new ServerAuthDpopReplayKeyCalculationError({ + proofKeyThumbprint: result.thumbprint, + proofId: result.jti, cause, }), ), @@ -88,8 +95,12 @@ export const verifyRequestDpopProof = (input: { ), ) .pipe( - Effect.catchIf(ServerSecretStore.isSecretStoreError, (error) => - Effect.fail(mapDpopReplayStoreError(error)), + Effect.mapError((error) => + mapDpopReplayStoreError(error, { + proofKeyThumbprint: result.thumbprint, + proofId: result.jti, + replayKey, + }), ), ); return result.thumbprint; diff --git a/apps/server/src/auth/http.ts b/apps/server/src/auth/http.ts index 71fb00b970a..03977cf9ffb 100644 --- a/apps/server/src/auth/http.ts +++ b/apps/server/src/auth/http.ts @@ -151,6 +151,22 @@ export function failEnvironmentInternal(reason: EnvironmentInternalErrorReason, }); } +const failAuthenticationInternal = (error: EnvironmentAuth.ServerAuthAuthenticationInternalError) => + failEnvironmentInternal("internal_error", error); + +export const catchEnvironmentAuthenticationErrors = ( + effect: Effect.Effect, +) => + effect.pipe( + Effect.catchTags({ + ServerAuthMissingCredentialError: () => failEnvironmentAuthInvalid("missing_credential"), + ServerAuthInvalidCredentialError: () => failEnvironmentAuthInvalid("invalid_credential"), + ServerAuthSessionCredentialValidationError: failAuthenticationInternal, + ServerAuthDpopReplayStateRecordError: failAuthenticationInternal, + ServerAuthDpopReplayKeyCalculationError: failAuthenticationInternal, + }), + ); + export const requireEnvironmentScope = Effect.fn("environment.auth.requireScope")(function* ( scope: AuthEnvironmentScope, ) { @@ -168,13 +184,8 @@ export const environmentAuthenticatedAuthLayer = Layer.effect( return (httpEffect) => Effect.gen(function* () { const request = yield* HttpServerRequest.HttpServerRequest; - const session = yield* serverAuth.authenticateHttpRequest(request).pipe( - Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) => - failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)), - ), - Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => - failEnvironmentInternal("internal_error", error), - ), + const session = yield* catchEnvironmentAuthenticationErrors( + serverAuth.authenticateHttpRequest(request), ); return yield* httpEffect.pipe( Effect.provideService(EnvironmentAuthenticatedPrincipal, { @@ -183,7 +194,11 @@ export const environmentAuthenticatedAuthLayer = Layer.effect( }), session.subject === "cloud-connect" ? traceAuthenticatedRelayRequest : identity, ); - }).pipe(Effect.catchTag("EnvironmentAuthInvalidError", appendDpopChallengeOnUnauthorized)); + }).pipe( + Effect.catchTags({ + EnvironmentAuthInvalidError: appendDpopChallengeOnUnauthorized, + }), + ); }), ); @@ -203,9 +218,11 @@ export const authHttpApiLayer = HttpApiBuilder.group( const request = yield* HttpServerRequest.HttpServerRequest; return yield* serverAuth.getSessionState(request); }, - Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => - failEnvironmentInternal("internal_error", error), - ), + Effect.catchTags({ + ServerAuthSessionCredentialValidationError: failAuthenticationInternal, + ServerAuthDpopReplayStateRecordError: failAuthenticationInternal, + ServerAuthDpopReplayKeyCalculationError: failAuthenticationInternal, + }), ), ) .handle( @@ -233,12 +250,14 @@ export const authHttpApiLayer = HttpApiBuilder.group( yield* appendCredentialResponseHeaders; return result.response; }, - Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) => - failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)), - ), - Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => - failEnvironmentInternal("browser_session_issuance_failed", error), - ), + Effect.catchTags({ + ServerAuthInvalidCredentialError: () => + failEnvironmentAuthInvalid("invalid_credential"), + ServerAuthBootstrapCredentialValidationError: (error) => + failEnvironmentInternal("browser_session_issuance_failed", error), + ServerAuthAuthenticatedSessionIssueError: (error) => + failEnvironmentInternal("browser_session_issuance_failed", error), + }), ), ) .handle( @@ -268,14 +287,16 @@ export const authHttpApiLayer = HttpApiBuilder.group( } const proofKeyThumbprint = args.headers.dpop ? yield* verifyRequestDpopProof({ request }).pipe( - Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, () => - appendDpopChallengeHeader.pipe( - Effect.andThen(failEnvironmentAuthInvalid("invalid_credential")), - ), - ), - Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => - failEnvironmentInternal("access_token_issuance_failed", error), - ), + Effect.catchTags({ + ServerAuthInvalidCredentialError: () => + appendDpopChallengeHeader.pipe( + Effect.andThen(failEnvironmentAuthInvalid("invalid_credential")), + ), + ServerAuthDpopReplayStateRecordError: (error) => + failEnvironmentInternal("access_token_issuance_failed", error), + ServerAuthDpopReplayKeyCalculationError: (error) => + failEnvironmentInternal("access_token_issuance_failed", error), + }), ) : undefined; yield* appendCredentialResponseHeaders; @@ -296,15 +317,16 @@ export const authHttpApiLayer = HttpApiBuilder.group( ); }, traceRelayRequest, - Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) => - failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)), - ), - Effect.catchIf(EnvironmentAuth.isServerAuthInvalidRequestError, (error) => - failEnvironmentInvalidRequest(EnvironmentAuth.serverAuthInvalidRequestReason(error)), - ), - Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => - failEnvironmentInternal("access_token_issuance_failed", error), - ), + Effect.catchTags({ + ServerAuthInvalidCredentialError: () => + failEnvironmentAuthInvalid("invalid_credential"), + ServerAuthScopeNotGrantedError: () => + failEnvironmentInvalidRequest("scope_not_granted"), + ServerAuthBootstrapCredentialValidationError: (error) => + failEnvironmentInternal("access_token_issuance_failed", error), + ServerAuthAuthenticatedAccessTokenIssueError: (error) => + failEnvironmentInternal("access_token_issuance_failed", error), + }), ), ) .handle( @@ -316,9 +338,10 @@ export const authHttpApiLayer = HttpApiBuilder.group( yield* appendCredentialResponseHeaders; return yield* serverAuth.issueWebSocketTicket(session); }, - Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => - failEnvironmentInternal("websocket_ticket_issuance_failed", error), - ), + Effect.catchTags({ + ServerAuthWebSocketTokenIssueError: (error) => + failEnvironmentInternal("websocket_ticket_issuance_failed", error), + }), ), ) .handle( @@ -341,9 +364,10 @@ export const authHttpApiLayer = HttpApiBuilder.group( } return yield* serverAuth.issuePairingCredential(args.payload); }, - Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => - failEnvironmentInternal("pairing_credential_issuance_failed", error), - ), + Effect.catchTags({ + ServerAuthPairingLinkCreationError: (error) => + failEnvironmentInternal("pairing_credential_issuance_failed", error), + }), ), ) .handle( @@ -354,9 +378,10 @@ export const authHttpApiLayer = HttpApiBuilder.group( yield* requireEnvironmentScope(AuthAccessReadScope); return yield* serverAuth.listPairingLinks(); }, - Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => - failEnvironmentInternal("pairing_links_load_failed", error), - ), + Effect.catchTags({ + ServerAuthPairingLinksListError: (error) => + failEnvironmentInternal("pairing_links_load_failed", error), + }), ), ) .handle( @@ -368,9 +393,10 @@ export const authHttpApiLayer = HttpApiBuilder.group( const revoked = yield* serverAuth.revokePairingLink(args.payload.id); return { revoked }; }, - Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => - failEnvironmentInternal("pairing_link_revoke_failed", error), - ), + Effect.catchTags({ + ServerAuthPairingLinkRevocationError: (error) => + failEnvironmentInternal("pairing_link_revoke_failed", error), + }), ), ) .handle( @@ -381,9 +407,10 @@ export const authHttpApiLayer = HttpApiBuilder.group( const session = yield* requireEnvironmentScope(AuthAccessReadScope); return yield* serverAuth.listClientSessions(session.sessionId); }, - Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => - failEnvironmentInternal("client_sessions_load_failed", error), - ), + Effect.catchTags({ + ServerAuthSessionsListError: (error) => + failEnvironmentInternal("client_sessions_load_failed", error), + }), ), ) .handle( @@ -398,12 +425,12 @@ export const authHttpApiLayer = HttpApiBuilder.group( ); return { revoked }; }, - Effect.catchTag("ServerAuthForbiddenOperationError", () => - failEnvironmentOperationForbidden("current_session_revoke_not_allowed"), - ), - Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => - failEnvironmentInternal("client_session_revoke_failed", error), - ), + Effect.catchTags({ + ServerAuthForbiddenOperationError: () => + failEnvironmentOperationForbidden("current_session_revoke_not_allowed"), + ServerAuthSessionRevocationError: (error) => + failEnvironmentInternal("client_session_revoke_failed", error), + }), ), ) .handle( @@ -415,9 +442,10 @@ export const authHttpApiLayer = HttpApiBuilder.group( const revokedCount = yield* serverAuth.revokeOtherClientSessions(session.sessionId); return { revokedCount }; }, - Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => - failEnvironmentInternal("client_session_revoke_failed", error), - ), + Effect.catchTags({ + ServerAuthOtherSessionsRevocationError: (error) => + failEnvironmentInternal("client_session_revoke_failed", error), + }), ), ); }), diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 90d524f4246..d7e88d62d1e 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -36,9 +36,8 @@ import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import { traceRelayRequest } from "./cloud/traceRelayRequest.ts"; import { annotateEnvironmentRequest, + catchEnvironmentAuthenticationErrors, failEnvironmentScopeRequired, - failEnvironmentAuthInvalid, - failEnvironmentInternal, } from "./auth/http.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; import { browserApiCorsAllowedHeaders, browserApiCorsAllowedMethods } from "./httpCors.ts"; @@ -81,13 +80,8 @@ const authenticateRawRouteWithScope = ( Effect.gen(function* () { const request = yield* HttpServerRequest.HttpServerRequest; const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; - const session = yield* serverAuth.authenticateHttpRequest(request).pipe( - Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) => - failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)), - ), - Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => - failEnvironmentInternal("internal_error", error), - ), + const session = yield* catchEnvironmentAuthenticationErrors( + serverAuth.authenticateHttpRequest(request), ); if (!session.scopes.includes(scope)) { return yield* failEnvironmentScopeRequired(scope); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 7ebc432038c..c00812b03d5 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -109,7 +109,7 @@ import * as VcsProjectConfig from "./vcs/VcsProjectConfig.ts"; import * as VcsProcess from "./vcs/VcsProcess.ts"; import * as PairingGrantStore from "./auth/PairingGrantStore.ts"; import * as SessionStore from "./auth/SessionStore.ts"; -import { failEnvironmentAuthInvalid, failEnvironmentInternal } from "./auth/http.ts"; +import { catchEnvironmentAuthenticationErrors } from "./auth/http.ts"; import * as RelayClient from "@t3tools/shared/relayClient"; const isOrchestrationDispatchCommandError = Schema.is(OrchestrationDispatchCommandError); @@ -1806,13 +1806,8 @@ export const websocketRpcRouteLayer = Layer.unwrap( const request = yield* HttpServerRequest.HttpServerRequest; const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; const sessions = yield* SessionStore.SessionStore; - const session = yield* serverAuth.authenticateWebSocketUpgrade(request).pipe( - Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) => - failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)), - ), - Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => - failEnvironmentInternal("internal_error", error), - ), + const session = yield* catchEnvironmentAuthenticationErrors( + serverAuth.authenticateWebSocketUpgrade(request), ); const rpcWebSocketHttpEffect = yield* RpcServer.toHttpEffectWebsocket(WsRpcGroup, { disableTracing: true, From d5a9862e97447dc4d49f76b83ee5a2e572b35ca0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 07:02:40 -0700 Subject: [PATCH 2/8] Preserve auth access stream failure context Co-authored-by: codex --- apps/server/src/ws.ts | 28 ++++++++++++++------- packages/contracts/src/auth.test.ts | 38 +++++++++++++++++++++++++++++ packages/contracts/src/auth.ts | 18 ++++++++++++-- 3 files changed, 73 insertions(+), 11 deletions(-) create mode 100644 packages/contracts/src/auth.test.ts diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index c00812b03d5..c59c3e6137e 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -509,16 +509,26 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => const loadAuthAccessSnapshot = () => Effect.all({ - pairingLinks: serverAuth.listPairingLinks(), - clientSessions: serverAuth.listClientSessions(currentSessionId), - }).pipe( - Effect.mapError( - (error) => - new AuthAccessStreamError({ - message: error.message, - }), + pairingLinks: serverAuth.listPairingLinks().pipe( + Effect.mapError( + (cause) => + new AuthAccessStreamError({ + operation: "list-pairing-links", + cause, + }), + ), ), - ); + clientSessions: serverAuth.listClientSessions(currentSessionId).pipe( + Effect.mapError( + (cause) => + new AuthAccessStreamError({ + operation: "list-client-sessions", + currentSessionId, + cause, + }), + ), + ), + }); const appendSetupScriptActivity = (input: { readonly threadId: ThreadId; diff --git a/packages/contracts/src/auth.test.ts b/packages/contracts/src/auth.test.ts new file mode 100644 index 00000000000..7052b2debdd --- /dev/null +++ b/packages/contracts/src/auth.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { AuthSessionId } from "./baseSchemas.ts"; +import { AuthAccessStreamError } from "./auth.ts"; + +describe("AuthAccessStreamError", () => { + it("preserves the pairing-link list failure", () => { + const cause = new Error("database unavailable"); + const error = new AuthAccessStreamError({ + operation: "list-pairing-links", + cause, + }); + + expect(error.operation).toBe("list-pairing-links"); + expect(error.currentSessionId).toBeUndefined(); + expect(error.cause).toBe(cause); + expect(error.message).toBe("Authentication access stream operation list-pairing-links failed."); + expect(error.message).not.toContain(cause.message); + }); + + it("preserves the current session for client-session list failures", () => { + const cause = new Error("database unavailable"); + const currentSessionId = AuthSessionId.make("session-current"); + const error = new AuthAccessStreamError({ + operation: "list-client-sessions", + currentSessionId, + cause, + }); + + expect(error.operation).toBe("list-client-sessions"); + expect(error.currentSessionId).toBe(currentSessionId); + expect(error.cause).toBe(cause); + expect(error.message).toBe( + "Authentication access stream operation list-client-sessions failed for session session-current.", + ); + expect(error.message).not.toContain(cause.message); + }); +}); diff --git a/packages/contracts/src/auth.ts b/packages/contracts/src/auth.ts index 70b2899757d..04467729abc 100644 --- a/packages/contracts/src/auth.ts +++ b/packages/contracts/src/auth.ts @@ -276,12 +276,26 @@ export const AuthAccessStreamPairingLinkRemovedEvent = Schema.Struct({ export type AuthAccessStreamPairingLinkRemovedEvent = typeof AuthAccessStreamPairingLinkRemovedEvent.Type; +export const AuthAccessStreamOperation = Schema.Literals([ + "list-pairing-links", + "list-client-sessions", +]); +export type AuthAccessStreamOperation = typeof AuthAccessStreamOperation.Type; + export class AuthAccessStreamError extends Schema.TaggedErrorClass()( "AuthAccessStreamError", { - message: Schema.String, + operation: AuthAccessStreamOperation, + currentSessionId: Schema.optional(AuthSessionId), + cause: Schema.Defect(), }, -) {} +) { + override get message(): string { + const session = + this.currentSessionId === undefined ? "" : ` for session ${this.currentSessionId}`; + return `Authentication access stream operation ${this.operation} failed${session}.`; + } +} export class EnvironmentAuthorizationError extends Schema.TaggedErrorClass()( "EnvironmentAuthorizationError", From f1eae4c85b2b255e14e122639ad1866c11ebd7b4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:48:05 -0700 Subject: [PATCH 3/8] [codex] structure server secret store errors (#3243) Co-authored-by: codex --- .../features/cloud/linkEnvironment.test.ts | 5 +- .../server/src/auth/ServerSecretStore.test.ts | 49 +- apps/server/src/auth/ServerSecretStore.ts | 179 ++++--- apps/server/src/auth/dpop.test.ts | 4 +- apps/server/src/cloud/environmentKeys.test.ts | 24 +- apps/server/src/cloud/environmentKeys.ts | 57 ++- apps/server/src/cloud/http.test.ts | 255 ++++++++-- apps/server/src/cloud/http.ts | 438 +++++++++++++----- .../environments/EnvironmentConnector.test.ts | 1 + packages/contracts/src/environmentHttp.ts | 219 +++++++++ 10 files changed, 983 insertions(+), 248 deletions(-) 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 af8584e32c1..95b4ca9d1e0 100644 --- a/infra/relay/src/environments/EnvironmentConnector.test.ts +++ b/infra/relay/src/environments/EnvironmentConnector.test.ts @@ -603,6 +603,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, From 1fedf643a051449d0f08aabb10d8cb22a39d6ba7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 12:31:13 -0700 Subject: [PATCH 4/8] [codex] Structure cloud CLI token errors (#3249) Co-authored-by: codex --- apps/server/src/cloud/CliTokenManager.test.ts | 184 +++++++++ apps/server/src/cloud/CliTokenManager.ts | 349 +++++++++++++++--- apps/server/src/cloud/http.ts | 8 - 3 files changed, 486 insertions(+), 55 deletions(-) create mode 100644 apps/server/src/cloud/CliTokenManager.test.ts diff --git a/apps/server/src/cloud/CliTokenManager.test.ts b/apps/server/src/cloud/CliTokenManager.test.ts new file mode 100644 index 00000000000..e86dfe8cfed --- /dev/null +++ b/apps/server/src/cloud/CliTokenManager.test.ts @@ -0,0 +1,184 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as PlatformError from "effect/PlatformError"; +import * as HttpClient from "effect/unstable/http/HttpClient"; + +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as CliTokenManager from "./CliTokenManager.ts"; + +const unusedSecretStoreOperation = () => Effect.die("unused secret-store operation"); + +function makeSecretStore( + overrides: Partial, +): ServerSecretStore.ServerSecretStore["Service"] { + return { + get: unusedSecretStoreOperation, + set: unusedSecretStoreOperation, + create: unusedSecretStoreOperation, + getOrCreateRandom: unusedSecretStoreOperation, + remove: unusedSecretStoreOperation, + ...overrides, + }; +} + +function makeTokenManager(secretStore: ServerSecretStore.ServerSecretStore["Service"]) { + return CliTokenManager.make.pipe( + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + Layer.succeed(ServerSecretStore.ServerSecretStore, secretStore), + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("unused HTTP client")), + ), + ), + ), + ); +} + +describe("CloudCliTokenManager", () => { + it("redacts OAuth endpoint credentials while retaining exact causes", () => { + const tokenEndpoint = + "https://user:password@auth.example.test/private/token?client_secret=secret#fragment"; + const redirectUri = + "https://callback-user:callback-password@localhost/private/callback?code=secret#fragment"; + const cause = new Error("exchange failed"); + + const refreshError = CliTokenManager.CloudCliCredentialRefreshError.fromStage({ + stage: "exchange-token", + tokenEndpoint, + cause, + }); + const authorizationError = CliTokenManager.CloudCliAuthorizationError.fromStage({ + stage: "exchange-token", + tokenEndpoint, + redirectUri, + cause, + }); + const timeoutError = CliTokenManager.CloudCliAuthorizationTimeoutError.fromRedirectUri({ + redirectUri, + timeoutMillis: 1000, + cause, + }); + + expect(refreshError).toMatchObject({ + tokenEndpointInputLength: tokenEndpoint.length, + tokenEndpointProtocol: "https:", + tokenEndpointHostname: "auth.example.test", + cause, + }); + expect(authorizationError).toMatchObject({ + tokenEndpointInputLength: tokenEndpoint.length, + tokenEndpointHostname: "auth.example.test", + redirectUriInputLength: redirectUri.length, + redirectUriHostname: "localhost", + cause, + }); + expect(timeoutError).toMatchObject({ + redirectUriInputLength: redirectUri.length, + redirectUriHostname: "localhost", + cause, + }); + expect(refreshError.cause).toBe(cause); + expect(authorizationError.cause).toBe(cause); + expect(timeoutError.cause).toBe(cause); + for (const error of [refreshError, authorizationError, timeoutError]) { + expect(error).not.toHaveProperty("tokenEndpoint"); + expect(error).not.toHaveProperty("redirectUri"); + const serialized = JSON.stringify(error); + for (const secret of [ + "user:password", + "callback-user:callback-password", + "/private/", + "client_secret=secret", + "code=secret", + "#fragment", + ]) { + expect(error.message).not.toContain(secret); + expect(serialized).not.toContain(secret); + } + } + }); + + it.effect("retains secret context and cause when credential removal fails", () => { + const failure = new ServerSecretStore.SecretStoreRemoveError({ + secretName: "cloud-cli-oauth-token", + secretPath: "/tmp/secrets/cloud-cli-oauth-token.bin", + cause: PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "remove", + pathOrDescriptor: "/tmp/secrets/cloud-cli-oauth-token.bin", + }), + }); + + return Effect.gen(function* () { + const tokens = yield* makeTokenManager( + makeSecretStore({ remove: () => Effect.fail(failure) }), + ); + const error = yield* Effect.flip(tokens.clear); + + expect(error).toMatchObject({ + _tag: "CloudCliCredentialRemovalError", + secretName: "cloud-cli-oauth-token", + cause: failure, + }); + expect(error.message).toBe( + "Could not remove the stored T3 Connect CLI credential cloud-cli-oauth-token.", + ); + }); + }); + + it.effect("classifies credential read failures without replacing the cause", () => { + const failure = new ServerSecretStore.SecretStoreReadError({ + secretName: "cloud-cli-oauth-token", + secretPath: "/tmp/secrets/cloud-cli-oauth-token.bin", + cause: PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "readFile", + pathOrDescriptor: "/tmp/secrets/cloud-cli-oauth-token.bin", + }), + }); + + return Effect.gen(function* () { + const tokens = yield* makeTokenManager(makeSecretStore({ get: () => Effect.fail(failure) })); + const error = yield* Effect.flip(tokens.hasCredential); + + expect(error).toMatchObject({ + _tag: "CloudCliCredentialReadError", + stage: "read-credential", + secretName: "cloud-cli-oauth-token", + cause: failure, + }); + expect(error.message).toBe( + "Could not inspect the stored T3 Connect CLI credential cloud-cli-oauth-token during read-credential.", + ); + }); + }); + + it.effect("classifies malformed persisted credentials as refresh decode failures", () => + Effect.gen(function* () { + const tokens = yield* makeTokenManager( + makeSecretStore({ + get: () => + Effect.succeed(Option.some(new TextEncoder().encode("not valid credential JSON"))), + }), + ); + const error = yield* Effect.flip(tokens.getExisting); + + expect(error).toMatchObject({ + _tag: "CloudCliCredentialRefreshError", + stage: "decode-credential", + secretName: "cloud-cli-oauth-token", + cause: { _tag: "SchemaError" }, + }); + expect(error.message).toBe( + "Could not refresh the T3 Connect CLI credential cloud-cli-oauth-token during decode-credential.", + ); + }), + ); +}); diff --git a/apps/server/src/cloud/CliTokenManager.ts b/apps/server/src/cloud/CliTokenManager.ts index 00709370b26..9d869059bcf 100644 --- a/apps/server/src/cloud/CliTokenManager.ts +++ b/apps/server/src/cloud/CliTokenManager.ts @@ -20,6 +20,7 @@ import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; import * as HttpRouter from "effect/unstable/http/HttpRouter"; import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; +import { getUrlDiagnostics } from "@t3tools/shared/urlDiagnostics"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import { cloudCliOAuthConfig, type CloudCliOAuthConfig } from "./publicConfig.ts"; @@ -27,6 +28,8 @@ import { cloudCliOAuthConfig, type CloudCliOAuthConfig } from "./publicConfig.ts const CLOUD_CLI_OAUTH_TOKEN_SECRET = "cloud-cli-oauth-token"; const CLOUD_CLI_OAUTH_CALLBACK_TIMEOUT = Duration.minutes(10); const CLOUD_CLI_OAUTH_REFRESH_EARLY_MS = Duration.toMillis(Duration.minutes(5)); +const CLOUD_CLI_OAUTH_CALLBACK_HOST = "127.0.0.1"; +const CLOUD_CLI_OAUTH_CALLBACK_PORT = 34338; const PersistedToken = Schema.Struct({ accessToken: Schema.String, @@ -46,48 +49,223 @@ const OAuthTokenResponse = Schema.Struct({ token_type: Schema.String, }); +type CredentialReadFailure = ServerSecretStore.SecretStoreReadError | Schema.SchemaError; + +type CredentialPersistFailure = + | Schema.SchemaError + | ServerSecretStore.SecretStoreTemporaryPathGenerationError + | ServerSecretStore.SecretStorePersistError; + +const CloudCliCredentialRefreshStage = Schema.Literals([ + "read-credential", + "decode-credential", + "load-oauth-config", + "exchange-token", + "encode-credential", + "persist-credential", +]); +type CloudCliCredentialRefreshStage = typeof CloudCliCredentialRefreshStage.Type; + +const CloudCliAuthorizationStage = Schema.Literals([ + "load-oauth-config", + "prepare-pkce", + "start-callback-server", + "exchange-token", + "encode-credential", + "persist-credential", +]); +type CloudCliAuthorizationStage = typeof CloudCliAuthorizationStage.Type; + +function tokenEndpointDiagnosticFields(tokenEndpoint: string | undefined) { + if (tokenEndpoint === undefined) return {}; + const diagnostics = getUrlDiagnostics(tokenEndpoint); + return { + tokenEndpointInputLength: diagnostics.inputLength, + ...(diagnostics.protocol === undefined ? {} : { tokenEndpointProtocol: diagnostics.protocol }), + ...(diagnostics.hostname === undefined ? {} : { tokenEndpointHostname: diagnostics.hostname }), + }; +} + +function redirectUriDiagnosticFields(redirectUri: string | undefined) { + if (redirectUri === undefined) return {}; + const diagnostics = getUrlDiagnostics(redirectUri); + return { + redirectUriInputLength: diagnostics.inputLength, + ...(diagnostics.protocol === undefined ? {} : { redirectUriProtocol: diagnostics.protocol }), + ...(diagnostics.hostname === undefined ? {} : { redirectUriHostname: diagnostics.hostname }), + }; +} + export class CloudCliCredentialRemovalError extends Schema.TaggedErrorClass()( "CloudCliCredentialRemovalError", - { cause: Schema.Defect() }, + { + secretName: Schema.Literal(CLOUD_CLI_OAUTH_TOKEN_SECRET), + cause: Schema.Defect(), + }, ) { override get message(): string { - return "Could not remove the stored T3 Connect CLI credential."; + return `Could not remove the stored T3 Connect CLI credential ${this.secretName}.`; } } export class CloudCliCredentialRefreshError extends Schema.TaggedErrorClass()( "CloudCliCredentialRefreshError", - { cause: Schema.Defect() }, + { + stage: CloudCliCredentialRefreshStage, + secretName: Schema.Literal(CLOUD_CLI_OAUTH_TOKEN_SECRET), + tokenEndpointInputLength: Schema.optionalKey(Schema.Number), + tokenEndpointProtocol: Schema.optionalKey(Schema.String), + tokenEndpointHostname: Schema.optionalKey(Schema.String), + cause: Schema.Defect(), + }, ) { + static fromStage(input: { + readonly stage: CloudCliCredentialRefreshStage; + readonly cause: unknown; + readonly tokenEndpoint?: string; + }): CloudCliCredentialRefreshError { + return new CloudCliCredentialRefreshError({ + stage: input.stage, + secretName: CLOUD_CLI_OAUTH_TOKEN_SECRET, + ...tokenEndpointDiagnosticFields(input.tokenEndpoint), + cause: input.cause, + }); + } + + static fromCredentialRead(cause: CredentialReadFailure): CloudCliCredentialRefreshError { + return new CloudCliCredentialRefreshError({ + stage: cause._tag === "SecretStoreReadError" ? "read-credential" : "decode-credential", + secretName: CLOUD_CLI_OAUTH_TOKEN_SECRET, + cause, + }); + } + + static fromCredentialPersist(cause: CredentialPersistFailure): CloudCliCredentialRefreshError { + return new CloudCliCredentialRefreshError({ + stage: cause._tag === "SchemaError" ? "encode-credential" : "persist-credential", + secretName: CLOUD_CLI_OAUTH_TOKEN_SECRET, + cause, + }); + } + override get message(): string { - return "Could not refresh the T3 Connect CLI credential."; + const tokenEndpoint = + this.tokenEndpointInputLength === undefined + ? "" + : ` using the token endpoint${this.tokenEndpointHostname ? ` at ${this.tokenEndpointHostname}` : ""} (input length ${this.tokenEndpointInputLength})`; + return `Could not refresh the T3 Connect CLI credential ${this.secretName} during ${this.stage}${tokenEndpoint}.`; } } export class CloudCliCredentialReadError extends Schema.TaggedErrorClass()( "CloudCliCredentialReadError", - { cause: Schema.Defect() }, + { + stage: Schema.Literals(["read-credential", "decode-credential"]), + secretName: Schema.Literal(CLOUD_CLI_OAUTH_TOKEN_SECRET), + cause: Schema.Defect(), + }, ) { + static fromCredentialRead(cause: CredentialReadFailure): CloudCliCredentialReadError { + return new CloudCliCredentialReadError({ + stage: cause._tag === "SecretStoreReadError" ? "read-credential" : "decode-credential", + secretName: CLOUD_CLI_OAUTH_TOKEN_SECRET, + cause, + }); + } + override get message(): string { - return "Could not read the stored T3 Connect CLI credential."; + return `Could not inspect the stored T3 Connect CLI credential ${this.secretName} during ${this.stage}.`; } } export class CloudCliAuthorizationError extends Schema.TaggedErrorClass()( "CloudCliAuthorizationError", - { cause: Schema.Defect() }, + { + stage: CloudCliAuthorizationStage, + secretName: Schema.Literal(CLOUD_CLI_OAUTH_TOKEN_SECRET), + tokenEndpointInputLength: Schema.optionalKey(Schema.Number), + tokenEndpointProtocol: Schema.optionalKey(Schema.String), + tokenEndpointHostname: Schema.optionalKey(Schema.String), + redirectUriInputLength: Schema.optionalKey(Schema.Number), + redirectUriProtocol: Schema.optionalKey(Schema.String), + redirectUriHostname: Schema.optionalKey(Schema.String), + callbackHost: Schema.optional(Schema.String), + callbackPort: Schema.optional(Schema.Number), + cause: Schema.Defect(), + }, ) { + static fromStage(input: { + readonly stage: CloudCliAuthorizationStage; + readonly cause: unknown; + readonly tokenEndpoint?: string; + readonly redirectUri?: string; + readonly callbackHost?: string; + readonly callbackPort?: number; + }): CloudCliAuthorizationError { + return new CloudCliAuthorizationError({ + stage: input.stage, + secretName: CLOUD_CLI_OAUTH_TOKEN_SECRET, + ...tokenEndpointDiagnosticFields(input.tokenEndpoint), + ...redirectUriDiagnosticFields(input.redirectUri), + ...(input.callbackHost === undefined ? {} : { callbackHost: input.callbackHost }), + ...(input.callbackPort === undefined ? {} : { callbackPort: input.callbackPort }), + cause: input.cause, + }); + } + + static fromCredentialPersist(cause: CredentialPersistFailure): CloudCliAuthorizationError { + return new CloudCliAuthorizationError({ + stage: cause._tag === "SchemaError" ? "encode-credential" : "persist-credential", + secretName: CLOUD_CLI_OAUTH_TOKEN_SECRET, + cause, + }); + } + override get message(): string { - return "Could not authorize the T3 Connect CLI."; + const tokenEndpoint = + this.tokenEndpointInputLength === undefined + ? "" + : ` using the token endpoint${this.tokenEndpointHostname ? ` at ${this.tokenEndpointHostname}` : ""} (input length ${this.tokenEndpointInputLength})`; + const redirectUri = + this.redirectUriInputLength === undefined + ? "" + : ` with a callback URI input of length ${this.redirectUriInputLength}`; + const callbackAddress = + this.callbackHost && this.callbackPort !== undefined + ? ` on ${this.callbackHost}:${this.callbackPort}` + : ""; + return `Could not authorize the T3 Connect CLI credential ${this.secretName} during ${this.stage}${tokenEndpoint}${redirectUri}${callbackAddress}.`; } } export class CloudCliAuthorizationTimeoutError extends Schema.TaggedErrorClass()( "CloudCliAuthorizationTimeoutError", - { cause: Schema.Defect() }, + { + redirectUriInputLength: Schema.Number, + redirectUriProtocol: Schema.optionalKey(Schema.String), + redirectUriHostname: Schema.optionalKey(Schema.String), + timeoutMillis: Schema.Number, + cause: Schema.Defect(), + }, ) { + static fromRedirectUri(input: { + readonly redirectUri: string; + readonly timeoutMillis: number; + readonly cause: unknown; + }): CloudCliAuthorizationTimeoutError { + const diagnostics = getUrlDiagnostics(input.redirectUri); + return new CloudCliAuthorizationTimeoutError({ + redirectUriInputLength: diagnostics.inputLength, + ...(diagnostics.protocol === undefined ? {} : { redirectUriProtocol: diagnostics.protocol }), + ...(diagnostics.hostname === undefined ? {} : { redirectUriHostname: diagnostics.hostname }), + timeoutMillis: input.timeoutMillis, + cause: input.cause, + }); + } + override get message(): string { - return "Timed out waiting for T3 Connect authorization."; + const callback = this.redirectUriHostname ? ` for ${this.redirectUriHostname}` : ""; + return `Timed out after ${this.timeoutMillis}ms waiting for T3 Connect authorization${callback} (callback URI input length ${this.redirectUriInputLength}).`; } } @@ -103,18 +281,21 @@ export type CloudCliTokenManagerError = typeof CloudCliTokenManagerError.Type; export class CloudCliTokenManager extends Context.Service< CloudCliTokenManager, { - readonly get: Effect.Effect; - readonly getExisting: Effect.Effect, CloudCliTokenManagerError>; - readonly hasCredential: Effect.Effect; - readonly clear: Effect.Effect; + readonly get: Effect.Effect< + PersistedToken, + | CloudCliCredentialRefreshError + | CloudCliAuthorizationError + | CloudCliAuthorizationTimeoutError + >; + readonly getExisting: Effect.Effect< + Option.Option, + CloudCliCredentialRefreshError + >; + readonly hasCredential: Effect.Effect; + readonly clear: Effect.Effect; } >()("t3/cloud/CliTokenManager/CloudCliTokenManager") {} -const wrapError = - (makeError: (cause: unknown) => WrappedError) => - (effect: Effect.Effect): Effect.Effect => - effect.pipe(Effect.mapError(makeError)); - function stringToBytes(value: string): Uint8Array { return new TextEncoder().encode(value); } @@ -134,9 +315,15 @@ export const make = Effect.gen(function* () { return token; }); - const clear = secrets - .remove(CLOUD_CLI_OAUTH_TOKEN_SECRET) - .pipe(wrapError((cause) => new CloudCliCredentialRemovalError({ cause }))); + const clear = secrets.remove(CLOUD_CLI_OAUTH_TOKEN_SECRET).pipe( + Effect.mapError( + (cause) => + new CloudCliCredentialRemovalError({ + secretName: CLOUD_CLI_OAUTH_TOKEN_SECRET, + cause, + }), + ), + ); const read = Effect.fn("cloud.cli_token.read")(function* () { const encoded = yield* secrets.get(CLOUD_CLI_OAUTH_TOKEN_SECRET); @@ -162,21 +349,54 @@ export const make = Effect.gen(function* () { }); const refresh = Effect.fn("cloud.cli_token.refresh")(function* (token: PersistedToken) { - const metadata = yield* cloudCliOAuthConfig; + const metadata = yield* cloudCliOAuthConfig.pipe( + Effect.mapError((cause) => + CloudCliCredentialRefreshError.fromStage({ + stage: "load-oauth-config", + cause, + }), + ), + ); return yield* exchangeToken(metadata, { grant_type: "refresh_token", refresh_token: token.refreshToken, client_id: metadata.clientId, - }); + }).pipe( + Effect.mapError((cause) => + CloudCliCredentialRefreshError.fromStage({ + stage: "exchange-token", + tokenEndpoint: metadata.tokenEndpoint, + cause, + }), + ), + ); }); const login = Effect.fn("cloud.cli_token.login")(function* () { - const metadata = yield* cloudCliOAuthConfig; - const verifier = Encoding.encodeBase64Url(yield* crypto.randomBytes(32)); - const challenge = Encoding.encodeBase64Url( - yield* crypto.digest("SHA-256", new TextEncoder().encode(verifier)), + const metadata = yield* cloudCliOAuthConfig.pipe( + Effect.mapError((cause) => + CloudCliAuthorizationError.fromStage({ + stage: "load-oauth-config", + cause, + }), + ), + ); + const { challenge, state, verifier } = yield* Effect.gen(function* () { + const verifier = Encoding.encodeBase64Url(yield* crypto.randomBytes(32)); + const challenge = Encoding.encodeBase64Url( + yield* crypto.digest("SHA-256", new TextEncoder().encode(verifier)), + ); + const state = yield* crypto.randomUUIDv4; + return { challenge, state, verifier }; + }).pipe( + Effect.mapError((cause) => + CloudCliAuthorizationError.fromStage({ + stage: "prepare-pkce", + redirectUri: metadata.redirectUri, + cause, + }), + ), ); - const state = yield* crypto.randomUUIDv4; const callback = yield* Deferred.make(); const callbackRoute = HttpRouter.add( "GET", @@ -207,12 +427,21 @@ export const make = Effect.gen(function* () { }).pipe( Layer.provide( NodeHttpServer.layer(NodeHttp.createServer, { - host: "127.0.0.1", - port: 34338, + host: CLOUD_CLI_OAUTH_CALLBACK_HOST, + port: CLOUD_CLI_OAUTH_CALLBACK_PORT, disablePreemptiveShutdown: true, }), ), Layer.build, + Effect.mapError((cause) => + CloudCliAuthorizationError.fromStage({ + stage: "start-callback-server", + redirectUri: metadata.redirectUri, + callbackHost: CLOUD_CLI_OAUTH_CALLBACK_HOST, + callbackPort: CLOUD_CLI_OAUTH_CALLBACK_PORT, + cause, + }), + ), ); const authorizationUrl = new URL(metadata.authorizationEndpoint); authorizationUrl.searchParams.set("client_id", metadata.clientId); @@ -225,13 +454,16 @@ export const make = Effect.gen(function* () { yield* Console.log(`Open this URL to authorize T3 Connect:\n${authorizationUrl.toString()}\n`); const code = yield* Deferred.await(callback).pipe( Effect.timeout(CLOUD_CLI_OAUTH_CALLBACK_TIMEOUT), - Effect.catchTag("TimeoutError", (cause) => - Effect.fail( - new CloudCliAuthorizationTimeoutError({ - cause, - }), - ), - ), + Effect.catchTags({ + TimeoutError: (cause) => + Effect.fail( + CloudCliAuthorizationTimeoutError.fromRedirectUri({ + redirectUri: metadata.redirectUri, + timeoutMillis: Duration.toMillis(CLOUD_CLI_OAUTH_CALLBACK_TIMEOUT), + cause, + }), + ), + }), ); return yield* exchangeToken(metadata, { grant_type: "authorization_code", @@ -239,26 +471,43 @@ export const make = Effect.gen(function* () { redirect_uri: metadata.redirectUri, client_id: metadata.clientId, code_verifier: verifier, - }); + }).pipe( + Effect.mapError((cause) => + CloudCliAuthorizationError.fromStage({ + stage: "exchange-token", + tokenEndpoint: metadata.tokenEndpoint, + redirectUri: metadata.redirectUri, + cause, + }), + ), + ); }); const getExistingNoLock = Effect.fn("cloud.cli_token.get_existing_no_lock")(function* () { - const token = yield* read(); + const token = yield* read().pipe( + Effect.mapError(CloudCliCredentialRefreshError.fromCredentialRead), + ); if (Option.isNone(token)) return token; const now = yield* Clock.currentTimeMillis; if (token.value.expiresAtEpochMs - CLOUD_CLI_OAUTH_REFRESH_EARLY_MS > now) { return token; } - return Option.some(yield* refresh(token.value).pipe(Effect.flatMap(persist))); + return Option.some( + yield* refresh(token.value).pipe( + Effect.flatMap((refreshed) => + persist(refreshed).pipe( + Effect.mapError(CloudCliCredentialRefreshError.fromCredentialPersist), + ), + ), + ), + ); }); - const getExisting = semaphore.withPermits(1)( - getExistingNoLock().pipe(wrapError((cause) => new CloudCliCredentialRefreshError({ cause }))), - ); + const getExisting = semaphore.withPermits(1)(getExistingNoLock()); const hasCredential = semaphore.withPermits(1)( read().pipe( Effect.map(Option.isSome), - wrapError((cause) => new CloudCliCredentialReadError({ cause })), + Effect.mapError(CloudCliCredentialReadError.fromCredentialRead), ), ); const get = semaphore.withPermits(1)( @@ -266,8 +515,14 @@ export const make = Effect.gen(function* () { const token = yield* getExistingNoLock(); return Option.isSome(token) ? token.value - : yield* Effect.scoped(login()).pipe(Effect.flatMap(persist)); - }).pipe(wrapError((cause) => new CloudCliAuthorizationError({ cause }))), + : yield* Effect.scoped(login()).pipe( + Effect.flatMap((authorized) => + persist(authorized).pipe( + Effect.mapError(CloudCliAuthorizationError.fromCredentialPersist), + ), + ), + ); + }), ); return CloudCliTokenManager.of({ get, getExisting, hasCredential, clear }); diff --git a/apps/server/src/cloud/http.ts b/apps/server/src/cloud/http.ts index 874ea8e2dc1..3241e5e2ed3 100644 --- a/apps/server/src/cloud/http.ts +++ b/apps/server/src/cloud/http.ts @@ -771,16 +771,8 @@ const reconcileDesiredCloudLinkWith = Effect.fn("environment.cloud.reconcileDesi 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), }), ); From eb6d3edce4d597423bd3e76832b255c24a300ad7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 15:45:46 -0700 Subject: [PATCH 5/8] Update server settings error fixture Co-authored-by: codex --- apps/server/src/serverSettings.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 504d99e18de..d9df8fec1b0 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -33,7 +33,7 @@ const makeServerSettingsLayer = () => ), ); -const makeFailingSecretStoreLayer = (cause: ServerSecretStore.SecretStoreError) => +const makeFailingSecretStoreLayer = (cause: ServerSecretStore.SecretStoreReadError) => Layer.succeed( ServerSecretStore.ServerSecretStore, ServerSecretStore.ServerSecretStore.of({ @@ -55,7 +55,8 @@ it.layer(NodeServices.layer)("server settings", (it) => { description: "Secret backend unavailable.", }); const cause = new ServerSecretStore.SecretStoreReadError({ - resource: "provider environment secret", + secretName: "provider environment secret", + secretPath: "/test/secrets/provider-environment-secret.bin", cause: platformCause, }); const configLayer = Layer.fresh( From 13d4ecfd11cfa284d69ae3f0059672ac09290eb5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 16:39:06 -0700 Subject: [PATCH 6/8] Attribute cloud reconcile failures Co-authored-by: codex --- apps/server/src/cloud/http.test.ts | 56 +++++++++++++++++++++++++++++- apps/server/src/cloud/http.ts | 49 +++++++++++++++++--------- 2 files changed, 87 insertions(+), 18 deletions(-) diff --git a/apps/server/src/cloud/http.test.ts b/apps/server/src/cloud/http.test.ts index c623d79e550..26b4646fea6 100644 --- a/apps/server/src/cloud/http.test.ts +++ b/apps/server/src/cloud/http.test.ts @@ -65,12 +65,13 @@ function makeSecretStore( function reconcileWith(input: { readonly getExisting: CliTokenManager.CloudCliTokenManager["Service"]["getExisting"]; readonly httpClient?: HttpClient.HttpClient; + readonly secretStore?: ServerSecretStore.ServerSecretStore["Service"]; readonly env?: Readonly>; }) { return reconcileDesiredCloudLink("http://127.0.0.1:3774").pipe( Effect.provideService( ServerSecretStore.ServerSecretStore, - makeSecretStore(unusedSecretStoreOperation), + input.secretStore ?? makeSecretStore(unusedSecretStoreOperation), ), Effect.provideService( ServerEnvironment.ServerEnvironment, @@ -321,6 +322,59 @@ describe("reconcileDesiredCloudLink", () => { }), ); + it.effect("attributes link-proof secret failures to proof generation", () => { + const cause = new Error("private secret-store detail"); + const secretFailure = new ServerSecretStore.SecretStoreReadError({ + secretName: "environment-key-pair", + secretPath: "environment-key-pair.json", + cause, + }); + const httpClient = HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response( + JSON.stringify({ + challenge: "relay-link-challenge", + expiresAt: "2099-01-01T00:00:00.000Z", + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ), + ), + ); + const secretStore = makeSecretStore(unusedSecretStoreOperation); + + 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, + secretStore: { + ...secretStore, + get: () => Effect.fail(secretFailure), + }, + env: { T3CODE_RELAY_URL: "https://relay.example.test" }, + }), + ); + + expect(error).toMatchObject({ + _tag: "EnvironmentHttpInternalServerError", + operation: "generate_link_proof", + cause: secretFailure, + }); + expect(error.message).toBe("Could not generate environment link proof."); + expect(error.cause).toBe(secretFailure); + expect(error.message).not.toContain(cause.message); + }); + }); + it.effect("redacts relay transport failures behind a stable structural message", () => { const transportCause = new Error("upstream included a sensitive database password"); const capturedLogs: Array> = []; diff --git a/apps/server/src/cloud/http.ts b/apps/server/src/cloud/http.ts index 3241e5e2ed3..5effca5340f 100644 --- a/apps/server/src/cloud/http.ts +++ b/apps/server/src/cloud/http.ts @@ -723,6 +723,17 @@ const reconcileDesiredCloudLinkWith = Effect.fn("environment.cloud.reconcileDesi }, }, localOrigin, + ).pipe( + 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 link = yield* relayClientRequest(dependencies, { operation: "create-environment-link", @@ -736,7 +747,15 @@ const reconcileDesiredCloudLinkWith = Effect.fn("environment.cloud.reconcileDesi }, schema: RelayEnvironmentLinkResponse, }); - yield* setCliDesiredCloudLink(true); + yield* setCliDesiredCloudLink(true).pipe( + Effect.catchTags({ + SecretStoreTemporaryPathGenerationError: failEnvironmentCloudInternalError( + "persist_desired_link_state", + ), + SecretStorePersistError: failEnvironmentCloudInternalError("persist_desired_link_state"), + SecretStoreRemoveError: failEnvironmentCloudInternalError("persist_desired_link_state"), + }), + ); return yield* applyCloudRelayConfig(dependencies, { relayUrl, relayIssuer: link.relayIssuer, @@ -744,24 +763,20 @@ const reconcileDesiredCloudLinkWith = Effect.fn("environment.cloud.reconcileDesi environmentCredential: link.environmentCredential, cloudMintPublicKey: link.cloudMintPublicKey, endpointRuntime: link.endpointRuntime, - }); + }).pipe( + 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"), + }), + ); }, Effect.catchTags({ - 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) => From 62db4485ba752194ec47585c5c668c822d5e590f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 16:46:02 -0700 Subject: [PATCH 7/8] Use catchTags for relay install failures Co-authored-by: codex --- apps/server/src/ws.ts | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index c59c3e6137e..e1ec3310108 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1288,15 +1288,16 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => status, }), ), - Effect.catchTag("RelayClientInstallError", (error) => - Queue.fail( - queue, - new RelayClientInstallFailedError({ - reason: error.reason, - message: error.message, - }), - ), - ), + Effect.catchTags({ + RelayClientInstallError: (error) => + Queue.fail( + queue, + new RelayClientInstallFailedError({ + reason: error.reason, + message: error.message, + }), + ), + }), Effect.andThen(Queue.end(queue)), Effect.forkScoped, ), From 55e9cd596d9ded13d80a6f929f1875bb4e0c6b42 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 17:03:56 -0700 Subject: [PATCH 8/8] Derive environment HTTP error messages Co-authored-by: codex --- .../contracts/src/environmentHttp.test.ts | 54 +++++++++++++++++++ packages/contracts/src/environmentHttp.ts | 20 +++++++ 2 files changed, 74 insertions(+) create mode 100644 packages/contracts/src/environmentHttp.test.ts diff --git a/packages/contracts/src/environmentHttp.test.ts b/packages/contracts/src/environmentHttp.test.ts new file mode 100644 index 00000000000..8aa2b6c8121 --- /dev/null +++ b/packages/contracts/src/environmentHttp.test.ts @@ -0,0 +1,54 @@ +import { assert, describe, it } from "@effect/vitest"; + +import { + EnvironmentAuthInvalidError, + EnvironmentInternalError, + EnvironmentOperationForbiddenError, + EnvironmentRequestInvalidError, + EnvironmentScopeRequiredError, +} from "./environmentHttp.ts"; + +describe("environment HTTP common errors", () => { + it("derives messages from structural diagnostics", () => { + assert.equal( + new EnvironmentRequestInvalidError({ + code: "invalid_request", + reason: "invalid_scope", + traceId: "trace-request", + }).message, + "Environment request rejected with invalid_request (invalid_scope; trace trace-request).", + ); + assert.equal( + new EnvironmentAuthInvalidError({ + code: "auth_invalid", + reason: "invalid_credential", + traceId: "trace-auth", + }).message, + "Environment authentication rejected with auth_invalid (invalid_credential; trace trace-auth).", + ); + assert.equal( + new EnvironmentScopeRequiredError({ + code: "insufficient_scope", + requiredScope: "access:read", + traceId: "trace-scope", + }).message, + "Environment authorization requires scope access:read (insufficient_scope; trace trace-scope).", + ); + assert.equal( + new EnvironmentOperationForbiddenError({ + code: "operation_forbidden", + reason: "current_session_revoke_not_allowed", + traceId: "trace-forbidden", + }).message, + "Environment operation rejected with operation_forbidden (current_session_revoke_not_allowed; trace trace-forbidden).", + ); + assert.equal( + new EnvironmentInternalError({ + code: "internal_error", + reason: "access_token_issuance_failed", + traceId: "trace-internal", + }).message, + "Environment request failed with internal_error (access_token_issuance_failed; trace trace-internal).", + ); + }); +}); diff --git a/packages/contracts/src/environmentHttp.ts b/packages/contracts/src/environmentHttp.ts index 015c0289d93..232cd8012cc 100644 --- a/packages/contracts/src/environmentHttp.ts +++ b/packages/contracts/src/environmentHttp.ts @@ -94,6 +94,10 @@ export class EnvironmentRequestInvalidError extends Schema.TaggedErrorClass