diff --git a/apps/desktop/src/electron/ElectronShell.test.ts b/apps/desktop/src/electron/ElectronShell.test.ts index f5c85769cee..7ab53af0b47 100644 --- a/apps/desktop/src/electron/ElectronShell.test.ts +++ b/apps/desktop/src/electron/ElectronShell.test.ts @@ -46,14 +46,49 @@ describe("ElectronShell", () => { }).pipe(Effect.provide(ElectronShell.layer)), ); - it.effect("returns false when Electron rejects openExternal", () => + it.effect("preserves safe URL context and cause when Electron rejects openExternal", () => Effect.gen(function* () { - openExternalMock.mockRejectedValue(new Error("open failed")); + const cause = new Error("open failed"); + openExternalMock.mockRejectedValue(cause); + const externalUrl = + "HTTPS://user:password@example.com:443/signed-secret-token/path?access_token=secret#fragment"; const electronShell = yield* ElectronShell.ElectronShell; - const result = yield* electronShell.openExternal("https://example.com/path"); + const error = yield* Effect.flip(electronShell.openExternal(externalUrl)); - assert.equal(result, false); + assert.instanceOf(error, ElectronShell.ElectronShellOpenExternalError); + assert.isTrue(ElectronShell.isElectronShellError(error)); + assert.strictEqual(error.urlHostname, "example.com"); + assert.strictEqual(error.urlLength, externalUrl.length); + assert.strictEqual(error.urlProtocol, "https:"); + assert.strictEqual(error.cause, cause); + assert.notProperty(error, "externalUrl"); + assert.notProperty(error, "requestTarget"); + assert.notMatch( + error.message, + /user|password|signed-secret-token|path|access_token|secret|fragment/, + ); + assert.notInclude(error.message, cause.message); + }).pipe(Effect.provide(ElectronShell.layer)), + ); + + it.effect("preserves non-sensitive clipboard context and cause", () => + Effect.gen(function* () { + const cause = new Error("clipboard failed"); + writeTextMock.mockImplementation(() => { + throw cause; + }); + + const electronShell = yield* ElectronShell.ElectronShell; + const error = yield* Effect.flip(electronShell.copyText("secret text")); + + assert.instanceOf(error, ElectronShell.ElectronShellCopyTextError); + assert.isTrue(ElectronShell.isElectronShellError(error)); + assert.strictEqual(error.textLength, 11); + assert.strictEqual(error.cause, cause); + assert.include(error.message, "11 characters"); + assert.notInclude(error.message, "secret text"); + assert.notInclude(error.message, cause.message); }).pipe(Effect.provide(ElectronShell.layer)), ); }); diff --git a/apps/desktop/src/electron/ElectronShell.ts b/apps/desktop/src/electron/ElectronShell.ts index 316d3138bfa..5fb48df1021 100644 --- a/apps/desktop/src/electron/ElectronShell.ts +++ b/apps/desktop/src/electron/ElectronShell.ts @@ -1,12 +1,47 @@ +import { getUrlDiagnostics } from "@t3tools/shared/urlDiagnostics"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; import * as Electron from "electron"; const SAFE_EXTERNAL_PROTOCOLS = new Set(["http:", "https:"]); +export class ElectronShellOpenExternalError extends Schema.TaggedErrorClass()( + "ElectronShellOpenExternalError", + { + urlHostname: Schema.String, + urlLength: Schema.Number, + urlProtocol: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to open external URL for ${this.urlHostname} (${this.urlProtocol}, input length ${this.urlLength}).`; + } +} + +export class ElectronShellCopyTextError extends Schema.TaggedErrorClass()( + "ElectronShellCopyTextError", + { + textLength: Schema.Number, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to copy ${this.textLength} characters to the clipboard.`; + } +} + +export const ElectronShellError = Schema.Union([ + ElectronShellOpenExternalError, + ElectronShellCopyTextError, +]); +export type ElectronShellError = typeof ElectronShellError.Type; +export const isElectronShellError = Schema.is(ElectronShellError); + export function parseSafeExternalUrl(rawUrl: unknown): Option.Option { if (typeof rawUrl !== "string") { return Option.none(); @@ -20,29 +55,46 @@ export function parseSafeExternalUrl(rawUrl: unknown): Option.Option { } } +function describeExternalUrl(externalUrl: string, inputLength: number) { + const diagnostics = getUrlDiagnostics(externalUrl); + return { + urlHostname: diagnostics.hostname ?? "", + urlLength: inputLength, + urlProtocol: diagnostics.protocol ?? "", + }; +} + export class ElectronShell extends Context.Service< ElectronShell, { - readonly openExternal: (rawUrl: unknown) => Effect.Effect; - readonly copyText: (text: string) => Effect.Effect; + readonly openExternal: ( + rawUrl: unknown, + ) => Effect.Effect; + readonly copyText: (text: string) => Effect.Effect; } >()("@t3tools/desktop/electron/ElectronShell") {} export const make = ElectronShell.of({ - openExternal: (rawUrl) => - Option.match(parseSafeExternalUrl(rawUrl), { + openExternal: (rawUrl) => { + const inputLength = typeof rawUrl === "string" ? rawUrl.length : 0; + + return Option.match(parseSafeExternalUrl(rawUrl), { onNone: () => Effect.succeed(false), onSome: (externalUrl) => - Effect.promise(() => - Electron.shell.openExternal(externalUrl).then( - () => true, - () => false, - ), - ), - }), + Effect.tryPromise({ + try: () => Electron.shell.openExternal(externalUrl), + catch: (cause) => + new ElectronShellOpenExternalError({ + ...describeExternalUrl(externalUrl, inputLength), + cause, + }), + }).pipe(Effect.as(true)), + }); + }, copyText: (text) => - Effect.sync(() => { - Electron.clipboard.writeText(text); + Effect.try({ + try: () => Electron.clipboard.writeText(text), + catch: (cause) => new ElectronShellCopyTextError({ textLength: text.length, cause }), }), }); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index cc2a92ca8fd..477d7500fe4 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -32,7 +32,6 @@ export const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-st export const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode"; export const SET_TAILSCALE_SERVE_ENABLED_CHANNEL = "desktop:set-tailscale-serve-enabled"; export const GET_ADVERTISED_ENDPOINTS_CHANNEL = "desktop:get-advertised-endpoints"; -export const SSH_PASSWORD_PROMPT_CANCELLED_RESULT = "ssh-password-prompt-cancelled"; export const PREVIEW_CREATE_TAB_CHANNEL = "desktop:preview-create-tab"; export const PREVIEW_CLOSE_TAB_CHANNEL = "desktop:preview-close-tab"; export const PREVIEW_REGISTER_WEBVIEW_CHANNEL = "desktop:preview-register-webview"; diff --git a/apps/desktop/src/ipc/methods/sshEnvironment.test.ts b/apps/desktop/src/ipc/methods/sshEnvironment.test.ts index fa53486d5e2..88eb7c42782 100644 --- a/apps/desktop/src/ipc/methods/sshEnvironment.test.ts +++ b/apps/desktop/src/ipc/methods/sshEnvironment.test.ts @@ -1,16 +1,28 @@ import { assert, describe, it } from "@effect/vitest"; -import { SshHttpBridgeError } from "@t3tools/ssh/errors"; +import { + DesktopSshEnvironmentEnsureResultSchema, + DesktopSshPasswordPromptCancellationError, +} from "@t3tools/contracts"; +import { SshHttpBridgeError, SshPasswordPromptError } from "@t3tools/ssh/errors"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; import { DesktopSshEnvironmentRequestError, + ensureSshEnvironment, fetchSshEnvironmentDescriptor, } from "./sshEnvironment.ts"; +import * as DesktopSshEnvironment from "../../ssh/DesktopSshEnvironment.ts"; +import * as DesktopSshPasswordPrompts from "../../ssh/DesktopSshPasswordPrompts.ts"; + +const decodeDesktopSshEnvironmentEnsureResult = Schema.decodeUnknownEffect( + DesktopSshEnvironmentEnsureResultSchema, +); function jsonResponse(request: HttpClientRequest.HttpClientRequest, body: unknown, status = 200) { return HttpClientResponse.fromWeb( @@ -34,6 +46,44 @@ function makeHttpClientLayer( } describe("SSH environment IPC", () => { + it.effect("encodes password prompt cancellations with structured context and their cause", () => { + const promptCause = new DesktopSshPasswordPrompts.DesktopSshPromptWindowClosedError({ + requestId: "prompt-1", + destination: "developer@devbox.example.test", + }); + const cause = new SshPasswordPromptError({ + message: promptCause.message, + cause: promptCause, + }); + const layer = Layer.succeed( + DesktopSshEnvironment.DesktopSshEnvironment, + DesktopSshEnvironment.DesktopSshEnvironment.of({ + discoverHosts: () => Effect.die("unexpected host discovery"), + ensureEnvironment: () => Effect.fail(cause), + disconnectEnvironment: () => Effect.die("unexpected disconnect"), + }), + ); + + return Effect.gen(function* () { + const encoded = yield* ensureSshEnvironment.handler({ + target: { + alias: "devbox", + hostname: "devbox.example.test", + username: "developer", + port: 22, + }, + }); + const error = yield* decodeDesktopSshEnvironmentEnsureResult(encoded); + + assert.instanceOf(error, DesktopSshPasswordPromptCancellationError); + assert.equal(error.reason, "window-closed"); + assert.equal(error.requestId, "prompt-1"); + assert.equal(error.destination, "developer@devbox.example.test"); + assert.instanceOf(error.cause, Error); + assert.instanceOf(error.cause.cause, Error); + }).pipe(Effect.provide(layer)); + }); + it.effect("fetches and decodes the remote environment descriptor", () => { const requestUrls: string[] = []; const layer = makeHttpClientLayer((request) => diff --git a/apps/desktop/src/ipc/methods/sshEnvironment.ts b/apps/desktop/src/ipc/methods/sshEnvironment.ts index 9c9af2a4e2b..69a3c32b1b5 100644 --- a/apps/desktop/src/ipc/methods/sshEnvironment.ts +++ b/apps/desktop/src/ipc/methods/sshEnvironment.ts @@ -2,7 +2,7 @@ import { bootstrapRemoteBearerSession, fetchRemoteSessionState, issueRemoteWebSocketTicket, - RemoteEnvironmentAuthUndeclaredStatusError, + isRemoteEnvironmentAuthUndeclaredStatusError, type RemoteEnvironmentAuthError, } from "@t3tools/client-runtime/authorization"; import { fetchRemoteEnvironmentDescriptor } from "@t3tools/client-runtime/environment"; @@ -15,7 +15,7 @@ import { DesktopSshEnvironmentEnsureResultSchema, DesktopSshEnvironmentTargetSchema, DesktopSshHttpBaseUrlInputSchema, - DesktopSshPasswordPromptCancelledType, + DesktopSshPasswordPromptCancellationError, DesktopSshPasswordPromptResolutionInputSchema, ExecutionEnvironmentDescriptor, EnvironmentInternalError, @@ -45,6 +45,13 @@ type DesktopSshEnvironmentRequestOperation = type DesktopSshEnvironmentRequestCause = RemoteEnvironmentAuthError | SshHttpBridgeError; +const desktopSshPasswordPromptCancellationReasons = { + DesktopSshPromptCancelledError: "user-cancelled", + DesktopSshPromptWindowClosedError: "window-closed", + DesktopSshPromptServiceStoppedError: "service-stopped", + DesktopSshPromptTimedOutError: "timed-out", +} as const; + const isEnvironmentAuthInvalidError = Schema.is(EnvironmentAuthInvalidError); const isEnvironmentInternalError = Schema.is(EnvironmentInternalError); const isEnvironmentOperationForbiddenError = Schema.is(EnvironmentOperationForbiddenError); @@ -52,10 +59,7 @@ const isEnvironmentRequestInvalidError = Schema.is(EnvironmentRequestInvalidErro const isEnvironmentScopeRequiredError = Schema.is(EnvironmentScopeRequiredError); function readSshHttpStatus(cause: DesktopSshEnvironmentRequestCause): number | null { - if ( - cause instanceof RemoteEnvironmentAuthUndeclaredStatusError || - cause instanceof SshHttpBridgeError - ) { + if (isRemoteEnvironmentAuthUndeclaredStatusError(cause) || cause instanceof SshHttpBridgeError) { return cause.status ?? null; } if (isEnvironmentRequestInvalidError(cause)) { @@ -127,14 +131,19 @@ export const ensureSshEnvironment = DesktopIpc.makeIpcMethod({ }) { const sshEnvironment = yield* DesktopSshEnvironment.DesktopSshEnvironment; return yield* sshEnvironment.ensureEnvironment(target, options).pipe( - Effect.catch((error) => - DesktopSshEnvironment.isDesktopSshPasswordPromptCancellation(error) - ? Effect.succeed({ - type: DesktopSshPasswordPromptCancelledType, - message: error.message, - }) - : Effect.fail(error), - ), + Effect.catchTags({ + SshPasswordPromptError: (error) => + DesktopSshEnvironment.isDesktopSshPasswordPromptCancellation(error) + ? Effect.succeed( + new DesktopSshPasswordPromptCancellationError({ + reason: desktopSshPasswordPromptCancellationReasons[error.cause._tag], + requestId: error.cause.requestId, + destination: error.cause.destination, + cause: error, + }), + ) + : Effect.fail(error), + }), ); }), }); diff --git a/apps/desktop/src/ipc/methods/window.test.ts b/apps/desktop/src/ipc/methods/window.test.ts new file mode 100644 index 00000000000..609d3e30a13 --- /dev/null +++ b/apps/desktop/src/ipc/methods/window.test.ts @@ -0,0 +1,34 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; + +import { openExternal } from "./window.ts"; +import * as ElectronShell from "../../electron/ElectronShell.ts"; + +describe("window IPC", () => { + it.effect("returns false when Electron rejects an external URL", () => { + const url = "https://example.com/path"; + const error = new ElectronShell.ElectronShellOpenExternalError({ + urlHostname: "example.com", + urlLength: url.length, + urlProtocol: "https:", + cause: new Error("open failed"), + }); + const layer = Layer.mergeAll( + Layer.succeed( + ElectronShell.ElectronShell, + ElectronShell.ElectronShell.of({ + openExternal: () => Effect.fail(error), + copyText: () => Effect.void, + }), + ), + Logger.layer([], { mergeWithExisting: false }), + ); + + return Effect.gen(function* () { + const opened = yield* openExternal.handler(url); + assert.equal(opened, false); + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index 3cb705d0361..b638b77e6a3 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -7,6 +7,7 @@ import { } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; +import * as Redacted from "effect/Redacted"; import * as Schema from "effect/Schema"; import * as DesktopBackendManager from "../../backend/DesktopBackendManager.ts"; @@ -141,6 +142,19 @@ export const openExternal = DesktopIpc.makeIpcMethod({ result: Schema.Boolean, handler: Effect.fn("desktop.ipc.window.openExternal")(function* (url) { const shell = yield* ElectronShell.ElectronShell; - return yield* shell.openExternal(url); + return yield* shell.openExternal(url).pipe( + Effect.catchTag("ElectronShellOpenExternalError", (error) => + Effect.logWarning(error.message).pipe( + Effect.annotateLogs({ + error: Redacted.make(error, { label: error._tag }), + "error.type": error._tag, + "desktop.external_url.hostname": error.urlHostname, + "desktop.external_url.input_length": error.urlLength, + "desktop.external_url.protocol": error.urlProtocol, + }), + Effect.as(false), + ), + ), + ); }), }); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 6f126f41334..5e99b92f0f6 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -1,28 +1,31 @@ -import type { - DesktopBridge, - DesktopPreviewPointerEvent, - DesktopPreviewRecordingFrame, - DesktopPreviewTabState, +import { + DesktopSshPasswordPromptCancellationError, + DesktopSshPasswordPromptCancellationErrorTag, + type DesktopBridge, + type DesktopPreviewPointerEvent, + type DesktopPreviewRecordingFrame, + type DesktopPreviewTabState, } from "@t3tools/contracts"; import { exposeClerkBridge } from "@clerk/electron/preload"; import { contextBridge, ipcRenderer } from "electron"; +import * as Schema from "effect/Schema"; import * as IpcChannels from "./ipc/channels.ts"; exposeClerkBridge({ passkeys: true }); +const decodeDesktopSshPasswordPromptCancellationError = Schema.decodeUnknownSync( + DesktopSshPasswordPromptCancellationError, +); + function unwrapEnsureSshEnvironmentResult(result: unknown) { if ( typeof result === "object" && result !== null && - "type" in result && - result.type === IpcChannels.SSH_PASSWORD_PROMPT_CANCELLED_RESULT + "_tag" in result && + result._tag === DesktopSshPasswordPromptCancellationErrorTag ) { - const message = - "message" in result && typeof result.message === "string" - ? result.message - : "SSH authentication cancelled."; - throw new Error(message); + throw decodeDesktopSshPasswordPromptCancellationError(result); } return result as Awaited>; } diff --git a/apps/desktop/src/ssh/DesktopSshEnvironment.ts b/apps/desktop/src/ssh/DesktopSshEnvironment.ts index 31e84ae995e..495d5ea9c1a 100644 --- a/apps/desktop/src/ssh/DesktopSshEnvironment.ts +++ b/apps/desktop/src/ssh/DesktopSshEnvironment.ts @@ -75,7 +75,9 @@ function discoverDesktopSshHostsEffect(input?: { readonly homeDir?: string }) { export function isDesktopSshPasswordPromptCancellation( error: unknown, -): error is SshPasswordPromptError { +): error is SshPasswordPromptError & { + readonly cause: DesktopSshPasswordPrompts.DesktopSshPasswordPromptCancellation; +} { return ( error instanceof SshPasswordPromptError && DesktopSshPasswordPrompts.isDesktopSshPasswordPromptCancellation(error.cause) diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index e6cfce3c54f..cf4f2b4e556 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -226,7 +226,9 @@ export const make = Effect.gen(function* () { { label: "Copy Link", click: () => { - void runPromise(electronShell.copyText(params.linkURL)); + void runPromise( + electronShell.copyText(params.linkURL).pipe(Effect.ignore({ log: true })), + ); }, }, { type: "separator" }, @@ -253,7 +255,7 @@ export const make = Effect.gen(function* () { window.webContents.setWindowOpenHandler(({ url }) => { if (Option.isSome(ElectronShell.parseSafeExternalUrl(url))) { - void runPromise(electronShell.openExternal(url)); + void runPromise(electronShell.openExternal(url).pipe(Effect.ignore({ log: true }))); } return { action: "deny" }; }); @@ -269,7 +271,7 @@ export const make = Effect.gen(function* () { event.preventDefault(); if (Option.isSome(ElectronShell.parseSafeExternalUrl(url))) { - void runPromise(electronShell.openExternal(url)); + void runPromise(electronShell.openExternal(url).pipe(Effect.ignore({ log: true }))); } }); diff --git a/apps/mobile/src/connection/catalog-store.ts b/apps/mobile/src/connection/catalog-store.ts index b5bda400670..737538e4530 100644 --- a/apps/mobile/src/connection/catalog-store.ts +++ b/apps/mobile/src/connection/catalog-store.ts @@ -3,7 +3,10 @@ import { type ConnectionCatalogDocument as ConnectionCatalogDocumentType, EMPTY_CONNECTION_CATALOG_DOCUMENT, } from "@t3tools/client-runtime/platform"; -import { ConnectionTransientError } from "@t3tools/client-runtime/connection"; +import { + ConnectionStorageOperationError, + ConnectionTransientError, +} from "@t3tools/client-runtime/connection"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; @@ -15,30 +18,38 @@ import { migrateLegacyConnectionCatalog } from "./migration"; export const CONNECTION_CATALOG_KEY = "t3code.connection-catalog.v1"; export const LEGACY_CONNECTIONS_KEY = "t3code.connections"; -function catalogError(operation: string, cause: unknown) { - return new ConnectionTransientError({ - reason: "remote-unavailable", - detail: `Could not ${operation} the local connection catalog: ${String(cause)}`, - }); -} +const ConnectionCatalogDocumentJson = Schema.fromJsonString(ConnectionCatalogDocument); +const decodeConnectionCatalogDocument = Schema.decodeUnknownEffect(ConnectionCatalogDocumentJson); +const encodeConnectionCatalogDocument = Schema.encodeEffect(ConnectionCatalogDocumentJson); const decodeCatalog = Effect.fn("mobile.connectionStorage.decodeCatalog")(function* (raw: string) { - const parsed = yield* Effect.try({ - try: () => JSON.parse(raw) as unknown, - catch: (cause) => catalogError("decode", cause), - }); - return yield* Effect.fromResult( - Schema.decodeUnknownResult(ConnectionCatalogDocument)(parsed), - ).pipe(Effect.mapError((cause) => catalogError("decode", cause))); + return yield* decodeConnectionCatalogDocument(raw).pipe( + Effect.mapError((cause) => + ConnectionTransientError.fromStorageFailure( + new ConnectionStorageOperationError({ + operation: "decode", + backend: "schema", + cause, + }), + ), + ), + ); }); const encodeCatalog = Effect.fn("mobile.connectionStorage.encodeCatalog")(function* ( catalog: ConnectionCatalogDocumentType, ) { - const encoded = yield* Effect.fromResult( - Schema.encodeUnknownResult(ConnectionCatalogDocument)(catalog), - ).pipe(Effect.mapError((cause) => catalogError("encode", cause))); - return JSON.stringify(encoded); + return yield* encodeConnectionCatalogDocument(catalog).pipe( + Effect.mapError((cause) => + ConnectionTransientError.fromStorageFailure( + new ConnectionStorageOperationError({ + operation: "encode", + backend: "schema", + cause, + }), + ), + ), + ); }); interface CatalogStore { @@ -66,12 +77,21 @@ export const makeCatalogStore = Effect.fn("mobile.connectionStorage.makeCatalogS legacyRaw === null || legacyRaw.trim() === "" ? EMPTY_CONNECTION_CATALOG_DOCUMENT : yield* migrateLegacyConnectionCatalog(legacyRaw).pipe( - Effect.mapError((cause) => catalogError("migrate", cause)), - Effect.catch((error) => - Effect.logWarning("Discarding corrupt legacy mobile connections", error).pipe( - Effect.as(EMPTY_CONNECTION_CATALOG_DOCUMENT), + Effect.mapError((cause) => + ConnectionTransientError.fromStorageFailure( + new ConnectionStorageOperationError({ + operation: "migrate", + backend: "legacy-migration", + cause, + }), ), ), + Effect.catchTags({ + ConnectionTransientError: (error) => + Effect.logWarning("Discarding corrupt legacy mobile connections", error).pipe( + Effect.as(EMPTY_CONNECTION_CATALOG_DOCUMENT), + ), + }), ); if (legacyRaw !== null && legacyRaw.trim() !== "") { const encoded = yield* encodeCatalog(catalog); @@ -90,12 +110,13 @@ export const makeCatalogStore = Effect.fn("mobile.connectionStorage.makeCatalogS let catalog: ConnectionCatalogDocumentType; if (raw !== null && raw.trim() !== "") { catalog = yield* decodeCatalog(raw).pipe( - Effect.catch((error) => - Effect.logWarning("Discarding corrupt mobile connection catalog", error).pipe( - Effect.andThen(storage.deleteItem(CONNECTION_CATALOG_KEY)), - Effect.andThen(loadLegacyCatalog()), - ), - ), + Effect.catchTags({ + ConnectionTransientError: (error) => + Effect.logWarning("Discarding corrupt mobile connection catalog", error).pipe( + Effect.andThen(storage.deleteItem(CONNECTION_CATALOG_KEY)), + Effect.andThen(loadLegacyCatalog()), + ), + }), ); } else { catalog = yield* loadLegacyCatalog(); diff --git a/apps/mobile/src/connection/migration.test.ts b/apps/mobile/src/connection/migration.test.ts index 5cb17bd5bf7..a3bd6359315 100644 --- a/apps/mobile/src/connection/migration.test.ts +++ b/apps/mobile/src/connection/migration.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "@effect/vitest"; import { EnvironmentId } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; -import { migrateLegacyConnectionCatalog } from "./migration"; +import { LegacyConnectionMigrationError, migrateLegacyConnectionCatalog } from "./migration"; describe("migrateLegacyConnectionCatalog", () => { it.effect("migrates bearer and relay-managed connections into the new catalog", () => @@ -75,4 +75,28 @@ describe("migrateLegacyConnectionCatalog", () => { expect(catalog.targets).toEqual([]); }), ); + + it.effect("preserves parse failures with a stable structural error", () => + Effect.gen(function* () { + const error = yield* migrateLegacyConnectionCatalog("{not-json").pipe(Effect.flip); + + expect(error).toBeInstanceOf(LegacyConnectionMigrationError); + expect(error.stage).toBe("parse"); + expect(error.cause).toBeInstanceOf(SyntaxError); + expect(error.message).toBe("Could not parse the legacy mobile connection catalog."); + }), + ); + + it.effect("distinguishes catalog decoding failures", () => + Effect.gen(function* () { + const error = yield* migrateLegacyConnectionCatalog('{"connections":"invalid"}').pipe( + Effect.flip, + ); + + expect(error).toBeInstanceOf(LegacyConnectionMigrationError); + expect(error.stage).toBe("decode"); + expect(error.cause).toBeDefined(); + expect(error.message).toBe("Could not decode the legacy mobile connection catalog."); + }), + ); }); diff --git a/apps/mobile/src/connection/migration.ts b/apps/mobile/src/connection/migration.ts index 6f324c9ff15..65f2fccd1d3 100644 --- a/apps/mobile/src/connection/migration.ts +++ b/apps/mobile/src/connection/migration.ts @@ -36,9 +36,14 @@ const decodeLegacyConnectionDocument = Schema.decodeUnknownEffect(LegacyConnecti export class LegacyConnectionMigrationError extends Schema.TaggedErrorClass()( "LegacyConnectionMigrationError", { - message: Schema.String, + stage: Schema.Literals(["parse", "decode"]), + cause: Schema.Defect(), }, -) {} +) { + override get message(): string { + return `Could not ${this.stage} the legacy mobile connection catalog.`; + } +} function isRelayManaged(connection: typeof LegacySavedRemoteConnection.Type): boolean { return connection.relayManaged === true || connection.authenticationMethod === "dpop"; @@ -92,18 +97,10 @@ export const migrateLegacyConnectionCatalog = Effect.fn( )(function* (raw: string) { const parsed = yield* Effect.try({ try: () => JSON.parse(raw) as unknown, - catch: (cause) => - new LegacyConnectionMigrationError({ - message: `Could not parse the legacy mobile connection catalog: ${String(cause)}`, - }), + catch: (cause) => new LegacyConnectionMigrationError({ stage: "parse", cause }), }); const legacy = yield* decodeLegacyConnectionDocument(parsed).pipe( - Effect.mapError( - (cause) => - new LegacyConnectionMigrationError({ - message: `Could not decode the legacy mobile connection catalog: ${String(cause)}`, - }), - ), + Effect.mapError((cause) => new LegacyConnectionMigrationError({ stage: "decode", cause })), ); return (legacy.connections ?? []).reduce(migrateConnection, EMPTY_CONNECTION_CATALOG_DOCUMENT); diff --git a/apps/mobile/src/connection/platform.ts b/apps/mobile/src/connection/platform.ts index 769632a8fcb..0e33f2f0ec7 100644 --- a/apps/mobile/src/connection/platform.ts +++ b/apps/mobile/src/connection/platform.ts @@ -100,7 +100,8 @@ const capabilitiesLayer = Layer.succeedContext( (error) => new ConnectionTransientError({ reason: "network", - detail: error.message, + detail: "Could not read the T3 Cloud session token.", + cause: error, }), ), ); @@ -126,7 +127,8 @@ const capabilitiesLayer = Layer.succeedContext( catch: (cause) => new ConnectionTransientError({ reason: "remote-unavailable", - detail: `Could not load the mobile device identity: ${String(cause)}`, + detail: "Could not load the mobile device identity.", + cause, }), }).pipe(Effect.map(Option.some)), }), diff --git a/apps/mobile/src/connection/storage.ts b/apps/mobile/src/connection/storage.ts index 276ea3c5c08..ba894c7d17d 100644 --- a/apps/mobile/src/connection/storage.ts +++ b/apps/mobile/src/connection/storage.ts @@ -10,6 +10,7 @@ import { } from "@t3tools/client-runtime/platform"; import { TokenStore } from "@t3tools/client-runtime/authorization"; import { + ConnectionStorageOperationError, ConnectionTransientError, CredentialStore, ProfileStore, @@ -54,29 +55,11 @@ const LegacyStoredShellSnapshot = Schema.Struct({ snapshotReceivedAt: Schema.String, snapshot: OrchestrationShellSnapshot, }); - -function catalogError(operation: string, cause: unknown) { - return new ConnectionTransientError({ - reason: "remote-unavailable", - detail: `Could not ${operation} the local connection catalog: ${String(cause)}`, - }); -} - -function shellPersistenceError( - operation: - | "load-shell" - | "save-shell" - | "load-thread" - | "save-thread" - | "remove-thread" - | "clear-environment", - cause: unknown, -) { - return new ConnectionPersistenceError({ - operation, - message: `Could not ${operation.replaceAll("-", " ")}: ${String(cause)}`, - }); -} +const decodeStoredShellSnapshot = Schema.decodeUnknownEffect(StoredShellSnapshot); +const encodeStoredShellSnapshot = Schema.encodeEffect(StoredShellSnapshot); +const decodeStoredThreadSnapshot = Schema.decodeUnknownEffect(StoredThreadSnapshot); +const encodeStoredThreadSnapshot = Schema.encodeEffect(StoredThreadSnapshot); +const decodeLegacyStoredShellSnapshot = Schema.decodeUnknownEffect(LegacyStoredShellSnapshot); function threadSnapshotFileName(threadId: ThreadId): string { return `${encodeURIComponent(threadId)}.json`; @@ -100,7 +83,14 @@ const threadSnapshotDirectory = Effect.fn("mobile.connectionStorage.threadSnapsh } return directory; }, - catch: (cause) => shellPersistenceError(operation, cause), + catch: (cause) => + new ConnectionPersistenceError({ + operation, + stage: "resolve", + resource: "thread-cache", + environmentId, + cause, + }), }); }, ); @@ -110,38 +100,63 @@ const threadSnapshotFile = Effect.fn("mobile.connectionStorage.threadSnapshotFil threadId: ThreadId, operation: "load-thread" | "save-thread" | "remove-thread", ) { - const { File } = yield* Effect.promise(() => import("expo-file-system")); - return new File( - yield* threadSnapshotDirectory(environmentId, operation), - threadSnapshotFileName(threadId), - ); -}); - -function targetPersistenceError( - operation: "list-targets" | "register-connection" | "remove-connection", - error: ConnectionTransientError, -) { - return new ConnectionPersistenceError({ - operation, - message: error.message, + const directory = yield* threadSnapshotDirectory(environmentId, operation); + return yield* Effect.tryPromise({ + try: async () => { + const { File } = await import("expo-file-system"); + return new File(directory, threadSnapshotFileName(threadId)); + }, + catch: (cause) => + new ConnectionPersistenceError({ + operation, + stage: "resolve", + resource: "thread-cache", + environmentId, + threadId, + cause, + }), }); -} +}); const secureCatalogStorage: SecureCatalogStorage = { getItem: (key) => Effect.tryPromise({ try: () => SecureStore.getItemAsync(key), - catch: (cause) => catalogError("load", cause), + catch: (cause) => + ConnectionTransientError.fromStorageFailure( + new ConnectionStorageOperationError({ + operation: "load", + backend: "mobile-secure-storage", + key, + cause, + }), + ), }), setItem: (key, value) => Effect.tryPromise({ try: () => SecureStore.setItemAsync(key, value), - catch: (cause) => catalogError("save", cause), + catch: (cause) => + ConnectionTransientError.fromStorageFailure( + new ConnectionStorageOperationError({ + operation: "save", + backend: "mobile-secure-storage", + key, + cause, + }), + ), }), deleteItem: (key) => Effect.tryPromise({ try: () => SecureStore.deleteItemAsync(key), - catch: (cause) => catalogError("delete", cause), + catch: (cause) => + ConnectionTransientError.fromStorageFailure( + new ConnectionStorageOperationError({ + operation: "delete", + backend: "mobile-secure-storage", + key, + cause, + }), + ), }), }; @@ -156,6 +171,8 @@ const shellSnapshotFileInDirectory = Effect.fn( operation: "load-shell" | "save-shell" | "clear-environment", directoryName: string, ) { + const resource = + directoryName === LEGACY_SHELL_SNAPSHOT_CACHE_DIRECTORY ? "legacy-shell-cache" : "shell-cache"; return yield* Effect.tryPromise({ try: async () => { const { Directory, File, Paths } = await import("expo-file-system"); @@ -163,7 +180,14 @@ const shellSnapshotFileInDirectory = Effect.fn( directory.create({ idempotent: true, intermediates: true }); return new File(directory, shellSnapshotFileName(environmentId)); }, - catch: (cause) => shellPersistenceError(operation, cause), + catch: (cause) => + new ConnectionPersistenceError({ + operation, + stage: "resolve", + resource, + environmentId, + cause, + }), }); }); @@ -184,18 +208,45 @@ export const connectionStorageLayer = Layer.effectContext( const targetStore = ConnectionTargetStore.of({ list: catalog.read.pipe( Effect.map((document) => document.targets), - Effect.mapError((error) => targetPersistenceError("list-targets", error)), + Effect.mapError((cause) => + ConnectionPersistenceError.fromStorageFailure({ + operation: "list-targets", + fallbackStage: "read", + resource: "connection-catalog", + cause, + }), + ), ), }); const registrationStore = ConnectionRegistrationStore.of({ register: (registration) => catalog .update((document) => registerConnectionInCatalog(document, registration)) - .pipe(Effect.mapError((error) => targetPersistenceError("register-connection", error))), + .pipe( + Effect.mapError((cause) => + ConnectionPersistenceError.fromStorageFailure({ + operation: "register-connection", + fallbackStage: "write", + resource: "connection-catalog", + environmentId: registration.target.environmentId, + cause, + }), + ), + ), remove: (target) => catalog .update((document) => removeConnectionFromCatalog(document, target)) - .pipe(Effect.mapError((error) => targetPersistenceError("remove-connection", error))), + .pipe( + Effect.mapError((cause) => + ConnectionPersistenceError.fromStorageFailure({ + operation: "remove-connection", + fallbackStage: "write", + resource: "connection-catalog", + environmentId: target.environmentId, + cause, + }), + ), + ), }); const profileStore = ProfileStore.make({ get: (connectionId) => @@ -283,15 +334,38 @@ export const connectionStorageLayer = Layer.effectContext( if (file.exists) { const raw = yield* Effect.tryPromise({ try: () => file.text(), - catch: (cause) => shellPersistenceError("load-shell", cause), + catch: (cause) => + new ConnectionPersistenceError({ + operation: "load-shell", + stage: "read", + resource: "shell-cache", + environmentId, + cause, + }), }); const parsed = yield* Effect.try({ try: () => JSON.parse(raw) as unknown, - catch: (cause) => shellPersistenceError("load-shell", cause), + catch: (cause) => + new ConnectionPersistenceError({ + operation: "load-shell", + stage: "parse", + resource: "shell-cache", + environmentId, + cause, + }), }); - const stored = yield* Effect.fromResult( - Schema.decodeUnknownResult(StoredShellSnapshot)(parsed), - ).pipe(Effect.mapError((cause) => shellPersistenceError("load-shell", cause))); + const stored = yield* decodeStoredShellSnapshot(parsed).pipe( + Effect.mapError( + (cause) => + new ConnectionPersistenceError({ + operation: "load-shell", + stage: "decode", + resource: "shell-cache", + environmentId, + cause, + }), + ), + ); return stored.environmentId === environmentId ? Option.some(stored.snapshot) : Option.none(); @@ -303,15 +377,38 @@ export const connectionStorageLayer = Layer.effectContext( } const legacyRaw = yield* Effect.tryPromise({ try: () => legacyFile.text(), - catch: (cause) => shellPersistenceError("load-shell", cause), + catch: (cause) => + new ConnectionPersistenceError({ + operation: "load-shell", + stage: "read", + resource: "legacy-shell-cache", + environmentId, + cause, + }), }); const legacyParsed = yield* Effect.try({ try: () => JSON.parse(legacyRaw) as unknown, - catch: (cause) => shellPersistenceError("load-shell", cause), + catch: (cause) => + new ConnectionPersistenceError({ + operation: "load-shell", + stage: "parse", + resource: "legacy-shell-cache", + environmentId, + cause, + }), }); - const legacyStored = yield* Effect.fromResult( - Schema.decodeUnknownResult(LegacyStoredShellSnapshot)(legacyParsed), - ).pipe(Effect.mapError((cause) => shellPersistenceError("load-shell", cause))); + const legacyStored = yield* decodeLegacyStoredShellSnapshot(legacyParsed).pipe( + Effect.mapError( + (cause) => + new ConnectionPersistenceError({ + operation: "load-shell", + stage: "decode", + resource: "legacy-shell-cache", + environmentId, + cause, + }), + ), + ); return legacyStored.environmentId === environmentId ? Option.some(legacyStored.snapshot) : Option.none(); @@ -324,9 +421,18 @@ export const connectionStorageLayer = Layer.effectContext( environmentId, snapshot, } as const; - const encoded = yield* Effect.fromResult( - Schema.encodeUnknownResult(StoredShellSnapshot)(stored), - ).pipe(Effect.mapError((cause) => shellPersistenceError("save-shell", cause))); + const encoded = yield* encodeStoredShellSnapshot(stored).pipe( + Effect.mapError( + (cause) => + new ConnectionPersistenceError({ + operation: "save-shell", + stage: "encode", + resource: "shell-cache", + environmentId, + cause, + }), + ), + ); yield* Effect.try({ try: () => { if (!file.exists) { @@ -334,7 +440,14 @@ export const connectionStorageLayer = Layer.effectContext( } file.write(JSON.stringify(encoded)); }, - catch: (cause) => shellPersistenceError("save-shell", cause), + catch: (cause) => + new ConnectionPersistenceError({ + operation: "save-shell", + stage: "write", + resource: "shell-cache", + environmentId, + cause, + }), }); }), loadThread: (environmentId, threadId) => @@ -345,15 +458,41 @@ export const connectionStorageLayer = Layer.effectContext( } const raw = yield* Effect.tryPromise({ try: () => file.text(), - catch: (cause) => shellPersistenceError("load-thread", cause), + catch: (cause) => + new ConnectionPersistenceError({ + operation: "load-thread", + stage: "read", + resource: "thread-cache", + environmentId, + threadId, + cause, + }), }); const parsed = yield* Effect.try({ try: () => JSON.parse(raw) as unknown, - catch: (cause) => shellPersistenceError("load-thread", cause), + catch: (cause) => + new ConnectionPersistenceError({ + operation: "load-thread", + stage: "parse", + resource: "thread-cache", + environmentId, + threadId, + cause, + }), }); - const stored = yield* Effect.fromResult( - Schema.decodeUnknownResult(StoredThreadSnapshot)(parsed), - ).pipe(Effect.mapError((cause) => shellPersistenceError("load-thread", cause))); + const stored = yield* decodeStoredThreadSnapshot(parsed).pipe( + Effect.mapError( + (cause) => + new ConnectionPersistenceError({ + operation: "load-thread", + stage: "decode", + resource: "thread-cache", + environmentId, + threadId, + cause, + }), + ), + ); return stored.environmentId === environmentId && stored.threadId === threadId ? Option.some(stored.thread) : Option.none(); @@ -361,14 +500,24 @@ export const connectionStorageLayer = Layer.effectContext( saveThread: (environmentId, thread) => Effect.gen(function* () { const file = yield* threadSnapshotFile(environmentId, thread.id, "save-thread"); - const encoded = yield* Effect.fromResult( - Schema.encodeUnknownResult(StoredThreadSnapshot)({ - schemaVersion: THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION, - environmentId, - threadId: thread.id, - thread, - }), - ).pipe(Effect.mapError((cause) => shellPersistenceError("save-thread", cause))); + const encoded = yield* encodeStoredThreadSnapshot({ + schemaVersion: THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION, + environmentId, + threadId: thread.id, + thread, + }).pipe( + Effect.mapError( + (cause) => + new ConnectionPersistenceError({ + operation: "save-thread", + stage: "encode", + resource: "thread-cache", + environmentId, + threadId: thread.id, + cause, + }), + ), + ); yield* Effect.try({ try: () => { if (!file.exists) { @@ -376,36 +525,63 @@ export const connectionStorageLayer = Layer.effectContext( } file.write(JSON.stringify(encoded)); }, - catch: (cause) => shellPersistenceError("save-thread", cause), + catch: (cause) => + new ConnectionPersistenceError({ + operation: "save-thread", + stage: "write", + resource: "thread-cache", + environmentId, + threadId: thread.id, + cause, + }), }); }), removeThread: (environmentId, threadId) => Effect.gen(function* () { const file = yield* threadSnapshotFile(environmentId, threadId, "remove-thread"); if (file.exists) { - file.delete(); + yield* Effect.try({ + try: () => file.delete(), + catch: (cause) => + new ConnectionPersistenceError({ + operation: "remove-thread", + stage: "remove", + resource: "thread-cache", + environmentId, + threadId, + cause, + }), + }); } - }).pipe( - Effect.mapError((cause) => - cause._tag === "ConnectionPersistenceError" - ? cause - : shellPersistenceError("remove-thread", cause), - ), - ), + }), clear: (environmentId) => Effect.gen(function* () { const file = yield* shellSnapshotFile(environmentId, "clear-environment"); if (file.exists) { yield* Effect.try({ try: () => file.delete(), - catch: (cause) => shellPersistenceError("clear-environment", cause), + catch: (cause) => + new ConnectionPersistenceError({ + operation: "clear-environment", + stage: "remove", + resource: "shell-cache", + environmentId, + cause, + }), }); } const legacyFile = yield* legacyShellSnapshotFile(environmentId, "clear-environment"); if (legacyFile.exists) { yield* Effect.try({ try: () => legacyFile.delete(), - catch: (cause) => shellPersistenceError("clear-environment", cause), + catch: (cause) => + new ConnectionPersistenceError({ + operation: "clear-environment", + stage: "remove", + resource: "legacy-shell-cache", + environmentId, + cause, + }), }); } const threadDirectory = yield* threadSnapshotDirectory( @@ -415,7 +591,14 @@ export const connectionStorageLayer = Layer.effectContext( if (threadDirectory.exists) { yield* Effect.try({ try: () => threadDirectory.delete(), - catch: (cause) => shellPersistenceError("clear-environment", cause), + catch: (cause) => + new ConnectionPersistenceError({ + operation: "clear-environment", + stage: "remove", + resource: "thread-cache", + environmentId, + cause, + }), }); } }), diff --git a/apps/mobile/src/features/cloud/dpop.test.ts b/apps/mobile/src/features/cloud/dpop.test.ts index 8945d148ee9..608275ae8fc 100644 --- a/apps/mobile/src/features/cloud/dpop.test.ts +++ b/apps/mobile/src/features/cloud/dpop.test.ts @@ -9,8 +9,11 @@ import * as Effect from "effect/Effect"; import { verifyDpopProof } from "@t3tools/shared/dpop"; import { + CloudDpopProofError, + CloudDpopStorageError, createDpopProof, generateDpopProofKeyPair, + isCloudDpopError, loadOrCreateDpopProofKeyPair, cryptoLayer, } from "./dpop"; @@ -95,7 +98,82 @@ describe("mobile DPoP", () => { const error = yield* loadOrCreateDpopProofKeyPair().pipe(Effect.flip); - expect(error.message).toBe("Stored DPoP proof key is invalid."); + expect(error).toBeInstanceOf(CloudDpopStorageError); + expect(error).toMatchObject({ + operation: "decode", + storageKey: "t3code.cloud.dpop-proof-key", + }); + expect(isCloudDpopError(error)).toBe(true); + }).pipe(Effect.provide(cryptoLayer)), + ); + + it.effect("rejects stored key material whose public coordinates do not match", () => + Effect.gen(function* () { + secureStore.clear(); + const generated = yield* generateDpopProofKeyPair(); + secureStore.set( + "t3code.cloud.dpop-proof-key", + JSON.stringify({ ...generated.privateJwk, x: generated.privateJwk.y }), + ); + + const error = yield* loadOrCreateDpopProofKeyPair().pipe(Effect.flip); + + expect(error).toBeInstanceOf(CloudDpopStorageError); + expect(error).toMatchObject({ + operation: "restore", + storageKey: "t3code.cloud.dpop-proof-key", + }); + }).pipe(Effect.provide(cryptoLayer)), + ); + + it.effect("preserves request context for an invalid proof URL", () => + Effect.gen(function* () { + const proofKey = yield* generateDpopProofKeyPair(); + const error = yield* createDpopProof({ + method: "POST", + url: "http://", + proofKey, + }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(CloudDpopProofError); + expect(error).toMatchObject({ + operation: "normalize-url", + method: "POST", + requestTarget: "", + urlLength: "http://".length, + thumbprint: proofKey.thumbprint, + }); + expect(isCloudDpopError(error)).toBe(true); + }).pipe(Effect.provide(cryptoLayer)), + ); + + it.effect("redacts credentials and non-HTU URL components from proof failures", () => + Effect.gen(function* () { + const proofKey = yield* generateDpopProofKeyPair(); + const url = "https://user:password@example.com/oauth/token?access_token=secret#fragment"; + const error = yield* createDpopProof({ + method: "POST", + url, + proofKey: { + ...proofKey, + privateJwk: { ...proofKey.privateJwk, d: "%" }, + }, + }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(CloudDpopProofError); + expect(error).toMatchObject({ + operation: "import-private-key", + method: "POST", + requestTarget: "https://example.com/oauth/token", + urlLength: url.length, + thumbprint: proofKey.thumbprint, + }); + expect(error).not.toHaveProperty("url"); + expect(error.message).not.toContain("user"); + expect(error.message).not.toContain("password"); + expect(error.message).not.toContain("access_token"); + expect(error.message).not.toContain("secret"); + expect(error.message).not.toContain("fragment"); }).pipe(Effect.provide(cryptoLayer)), ); diff --git a/apps/mobile/src/features/cloud/dpop.ts b/apps/mobile/src/features/cloud/dpop.ts index 0bd4b7ff1bd..3590ace8073 100644 --- a/apps/mobile/src/features/cloud/dpop.ts +++ b/apps/mobile/src/features/cloud/dpop.ts @@ -1,8 +1,8 @@ import * as Clock from "effect/Clock"; import * as Crypto from "effect/Crypto"; -import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Encoding from "effect/Encoding"; +import * as Layer from "effect/Layer"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import * as ExpoCrypto from "expo-crypto"; @@ -12,19 +12,91 @@ import { computeDpopAccessTokenHash, computeDpopJwkThumbprint, DpopPublicJwk, - normalizeDpopHtu, + redactDpopRequestTarget, } from "@t3tools/shared/dpop"; -import * as Layer from "effect/Layer"; -export class CloudDpopError extends Data.TaggedError("CloudDpopError")<{ - readonly message: string; - readonly cause?: unknown; -}> {} +export class CloudDpopStorageError extends Schema.TaggedErrorClass()( + "CloudDpopStorageError", + { + operation: Schema.Literals(["read", "decode", "restore", "encode", "write"]), + storageKey: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Mobile DPoP key storage operation "${this.operation}" failed for key "${this.storageKey}".`; + } +} + +export class CloudDpopKeyError extends Schema.TaggedErrorClass()( + "CloudDpopKeyError", + { + operation: Schema.Literals(["generate-randomness", "derive-public-key"]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Mobile DPoP key operation "${this.operation}" failed.`; + } +} + +export class CloudDpopProofError extends Schema.TaggedErrorClass()( + "CloudDpopProofError", + { + operation: Schema.Literals([ + "import-private-key", + "generate-id", + "normalize-url", + "encode-header", + "encode-payload", + "hash-signing-input", + "sign", + ]), + method: Schema.String, + requestTarget: Schema.String, + urlLength: Schema.Number, + thumbprint: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Mobile DPoP proof operation "${this.operation}" failed for ${this.method.toUpperCase()} ${this.requestTarget}.`; + } +} -function cloudDpopError(message: string) { - return (cause: unknown) => new CloudDpopError({ message, cause }); +export class DpopPublicKeyFormatError extends Schema.TaggedErrorClass()( + "DpopPublicKeyFormatError", + { + byteLength: Schema.Number, + firstByte: Schema.optional(Schema.Number), + }, +) { + override get message(): string { + const prefix = + this.firstByte === undefined + ? "no prefix byte" + : `prefix 0x${this.firstByte.toString(16).padStart(2, "0")}`; + return `Expected a 65-byte uncompressed P-256 public key beginning with 0x04; received ${this.byteLength} bytes with ${prefix}.`; + } } +export class DpopStoredPublicKeyMismatchError extends Schema.TaggedErrorClass()( + "DpopStoredPublicKeyMismatchError", + {}, +) { + override get message(): string { + return "Stored DPoP private and public key material do not match."; + } +} + +export const CloudDpopError = Schema.Union([ + CloudDpopStorageError, + CloudDpopKeyError, + CloudDpopProofError, +]); +export type CloudDpopError = typeof CloudDpopError.Type; +export const isCloudDpopError = Schema.is(CloudDpopError); + const DpopPrivateJwkSchema = Schema.Struct({ ...DpopPublicJwk.fields, d: Schema.String, @@ -97,29 +169,12 @@ function base64UrlToBytes(value: string): Uint8Array { return Result.getOrThrow(Encoding.decodeBase64Url(value)); } -function sha256Digest( - data: Uint8Array, - message: string, -): Effect.Effect { - return Crypto.Crypto.pipe( - Effect.flatMap((crypto) => crypto.digest("SHA-256", data)), - Effect.mapError(cloudDpopError(message)), - ); -} - -function secureRandomBytes( - byteCount: number, - message: string, -): Effect.Effect { - return Crypto.Crypto.pipe( - Effect.flatMap((crypto) => crypto.randomBytes(byteCount)), - Effect.mapError(cloudDpopError(message)), - ); -} - function publicJwkFromUncompressedPublicKey(publicKey: Uint8Array): DpopPublicJwk { if (publicKey.length !== 65 || publicKey[0] !== 0x04) { - throw new Error("Generated DPoP public key is not an uncompressed P-256 point."); + throw new DpopPublicKeyFormatError({ + byteLength: publicKey.length, + ...(publicKey[0] === undefined ? {} : { firstByte: publicKey[0] }), + }); } return { kty: "EC", @@ -144,14 +199,16 @@ export function generateDpopProofKeyPair(): Effect.Effect< return Effect.gen(function* () { let privateKey: Uint8Array; do { - privateKey = yield* secureRandomBytes( - p256.CURVE.nByteLength, - "Could not generate DPoP key pair randomness.", + privateKey = yield* Crypto.Crypto.pipe( + Effect.flatMap((crypto) => crypto.randomBytes(p256.CURVE.nByteLength)), + Effect.mapError( + (cause) => new CloudDpopKeyError({ operation: "generate-randomness", cause }), + ), ); } while (!p256.utils.isValidPrivateKey(privateKey)); const publicJwk = yield* Effect.try({ try: () => publicJwkFromUncompressedPublicKey(p256.getPublicKey(privateKey, false)), - catch: cloudDpopError("Generated DPoP public key is invalid."), + catch: (cause) => new CloudDpopKeyError({ operation: "derive-public-key", cause }), }); const thumbprint = computeDpopJwkThumbprint(publicJwk); return { @@ -170,11 +227,23 @@ export function loadOrCreateDpopProofKeyPair(): Effect.Effect< return Effect.gen(function* () { const stored = yield* Effect.tryPromise({ try: () => SecureStore.getItemAsync(DPOP_PROOF_KEY_STORAGE_KEY), - catch: cloudDpopError("Could not read the DPoP proof key."), + catch: (cause) => + new CloudDpopStorageError({ + operation: "read", + storageKey: DPOP_PROOF_KEY_STORAGE_KEY, + cause, + }), }); if (stored) { const storedPrivateJwk = yield* decodeDpopPrivateJwkJson(stored).pipe( - Effect.mapError(cloudDpopError("Stored DPoP proof key is invalid.")), + Effect.mapError( + (cause) => + new CloudDpopStorageError({ + operation: "decode", + storageKey: DPOP_PROOF_KEY_STORAGE_KEY, + cause, + }), + ), ); const restored = yield* Effect.try({ try: () => { @@ -183,11 +252,16 @@ export function loadOrCreateDpopProofKeyPair(): Effect.Effect< p256.getPublicKey(privateKey, false), ); if (publicJwk.x !== storedPrivateJwk.x || publicJwk.y !== storedPrivateJwk.y) { - throw new Error("Stored DPoP key does not match its public key."); + throw new DpopStoredPublicKeyMismatchError(); } return { privateJwk: storedPrivateJwk, publicJwk }; }, - catch: cloudDpopError("Stored DPoP proof key is invalid."), + catch: (cause) => + new CloudDpopStorageError({ + operation: "restore", + storageKey: DPOP_PROOF_KEY_STORAGE_KEY, + cause, + }), }); return { ...restored, @@ -196,23 +270,28 @@ export function loadOrCreateDpopProofKeyPair(): Effect.Effect< } const generated = yield* generateDpopProofKeyPair(); const encodedPrivateJwk = yield* encodeDpopPrivateJwkJson(generated.privateJwk).pipe( - Effect.mapError(cloudDpopError("Could not encode the DPoP proof key.")), + Effect.mapError( + (cause) => + new CloudDpopStorageError({ + operation: "encode", + storageKey: DPOP_PROOF_KEY_STORAGE_KEY, + cause, + }), + ), ); yield* Effect.tryPromise({ try: () => SecureStore.setItemAsync(DPOP_PROOF_KEY_STORAGE_KEY, encodedPrivateJwk), - catch: cloudDpopError("Could not store the DPoP proof key."), + catch: (cause) => + new CloudDpopStorageError({ + operation: "write", + storageKey: DPOP_PROOF_KEY_STORAGE_KEY, + cause, + }), }); return generated; }); } -function normalizeHtu(url: string): Effect.Effect { - const normalized = normalizeDpopHtu(url); - return normalized - ? Effect.succeed(normalized) - : Effect.fail(new CloudDpopError({ message: "DPoP URL is invalid." })); -} - export function createDpopProof(input: { readonly method: string; readonly url: string; @@ -225,23 +304,69 @@ export function createDpopProof(input: { > { return Effect.gen(function* () { const keyPair = input.proofKey ?? (yield* generateDpopProofKeyPair()); + const requestTarget = redactDpopRequestTarget(input.url); + const urlLength = input.url.length; const privateKey = yield* Effect.try({ try: () => base64UrlToBytes(keyPair.privateJwk.d), - catch: cloudDpopError("Could not import DPoP private key."), + catch: (cause) => + new CloudDpopProofError({ + operation: "import-private-key", + method: input.method, + requestTarget, + urlLength, + thumbprint: keyPair.thumbprint, + cause, + }), }); const nowMs = yield* Clock.currentTimeMillis; const jti = yield* Crypto.Crypto.pipe( Effect.flatMap((crypto) => crypto.randomUUIDv4), - Effect.mapError(cloudDpopError("Could not generate DPoP proof identifier.")), + Effect.mapError( + (cause) => + new CloudDpopProofError({ + operation: "generate-id", + method: input.method, + requestTarget, + urlLength, + thumbprint: keyPair.thumbprint, + cause, + }), + ), ); - const htu = yield* normalizeHtu(input.url); + const htu = yield* Effect.try({ + try: () => { + const parsed = new URL(input.url); + parsed.hash = ""; + parsed.search = ""; + return parsed.toString(); + }, + catch: (cause) => + new CloudDpopProofError({ + operation: "normalize-url", + method: input.method, + requestTarget, + urlLength, + thumbprint: keyPair.thumbprint, + cause, + }), + }); const header = yield* encodeDpopJwtHeaderJson({ typ: "dpop+jwt", alg: "ES256", jwk: keyPair.publicJwk, }).pipe( Effect.map(Encoding.encodeBase64Url), - Effect.mapError(cloudDpopError("Could not encode DPoP proof header.")), + Effect.mapError( + (cause) => + new CloudDpopProofError({ + operation: "encode-header", + method: input.method, + requestTarget, + urlLength, + thumbprint: keyPair.thumbprint, + cause, + }), + ), ); const ath = input.accessToken ? computeDpopAccessTokenHash(input.accessToken) : null; const payload = yield* encodeDpopJwtPayloadJson({ @@ -252,15 +377,45 @@ export function createDpopProof(input: { ...(ath ? { ath } : {}), }).pipe( Effect.map(Encoding.encodeBase64Url), - Effect.mapError(cloudDpopError("Could not encode DPoP proof payload.")), + Effect.mapError( + (cause) => + new CloudDpopProofError({ + operation: "encode-payload", + method: input.method, + requestTarget, + urlLength, + thumbprint: keyPair.thumbprint, + cause, + }), + ), ); - const signatureInputHash = yield* sha256Digest( - new TextEncoder().encode(`${header}.${payload}`), - "Could not hash DPoP signing input.", + const signatureInputHash = yield* Crypto.Crypto.pipe( + Effect.flatMap((crypto) => + crypto.digest("SHA-256", new TextEncoder().encode(`${header}.${payload}`)), + ), + Effect.mapError( + (cause) => + new CloudDpopProofError({ + operation: "hash-signing-input", + method: input.method, + requestTarget, + urlLength, + thumbprint: keyPair.thumbprint, + cause, + }), + ), ); const signature = yield* Effect.try({ try: () => p256.sign(signatureInputHash, privateKey, { prehash: false }).toCompactRawBytes(), - catch: cloudDpopError("Could not sign DPoP proof."), + catch: (cause) => + new CloudDpopProofError({ + operation: "sign", + method: input.method, + requestTarget, + urlLength, + thumbprint: keyPair.thumbprint, + cause, + }), }); return { proof: `${header}.${payload}.${Encoding.encodeBase64Url(signature)}`, diff --git a/apps/mobile/src/features/cloud/linkEnvironment.test.ts b/apps/mobile/src/features/cloud/linkEnvironment.test.ts index b9ab3aeab05..59c6355b9a0 100644 --- a/apps/mobile/src/features/cloud/linkEnvironment.test.ts +++ b/apps/mobile/src/features/cloud/linkEnvironment.test.ts @@ -9,9 +9,11 @@ import { remoteHttpClientLayer } from "@t3tools/client-runtime/rpc"; import { HttpClient } from "effect/unstable/http"; import { + CloudEnvironmentLinkOperationError, cloudEnvironmentsPendingStatus, linkEnvironmentToCloud, connectCloudEnvironment, + isCloudEnvironmentLinkError, listCloudEnvironments, listCloudEnvironmentsWithStatus, normalizeRelayBaseUrl, @@ -148,6 +150,46 @@ describe("mobile cloud link environment client", () => { createProofMock.mockClear(); }); + it("keeps URL secrets out of operation error diagnostics", () => { + const relayUrl = + "https://relay-user:relay-password@relay.example.test/private/workspace?access_token=relay-secret#relay-fragment"; + const httpBaseUrl = + "https://desktop-user:desktop-password@desktop.example.test/private/workspace?access_token=desktop-secret#desktop-fragment"; + const cause = new Error("request failed"); + + const error = CloudEnvironmentLinkOperationError.fromCause({ + action: "link the environment", + cause, + environmentId: "env-1", + relayUrl, + httpBaseUrl, + }); + + expect(error).toMatchObject({ + relayUrlInputLength: relayUrl.length, + relayUrlProtocol: "https:", + relayUrlHostname: "relay.example.test", + httpBaseUrlInputLength: httpBaseUrl.length, + httpBaseUrlProtocol: "https:", + httpBaseUrlHostname: "desktop.example.test", + }); + const serialized = JSON.stringify(error); + for (const secret of [ + "relay-user", + "relay-password", + "relay-secret", + "relay-fragment", + "/private/workspace", + "desktop-user", + "desktop-password", + "desktop-secret", + "desktop-fragment", + ]) { + expect(serialized).not.toContain(secret); + expect(error.message).not.toContain(secret); + } + }); + it("normalizes configured relay base URLs before building DPoP-bound requests", () => { expect(normalizeRelayBaseUrl(" https://relay.example.test/// ")).toBe( "https://relay.example.test", @@ -235,9 +277,14 @@ describe("mobile cloud link environment client", () => { listCloudEnvironments({ clerkToken: "clerk-token" }), ).pipe(Effect.flip); expect(error).toMatchObject({ - _tag: "CloudEnvironmentLinkError", - message: "https://relay.example.test/v1/environments failed", + _tag: "CloudEnvironmentLinkOperationError", + action: "list cloud environments", + relayUrlInputLength: "https://relay.example.test".length, + relayUrlProtocol: "https:", + relayUrlHostname: "relay.example.test", }); + expect(error.message).toBe("Could not list cloud environments."); + expect(isCloudEnvironmentLinkError(error)).toBe(true); }), ); @@ -498,7 +545,7 @@ describe("mobile cloud link environment client", () => { label: "Desktop", }, status: null, - statusError: "https://relay.example.test/v1/environments/env-1/status failed", + statusError: 'Could not read cloud environment status for environment "env-1".', }, ]); }), @@ -562,7 +609,8 @@ describe("mobile cloud link environment client", () => { label: "Desktop", }, status: null, - statusError: "Relay returned status for a different environment.", + statusError: + 'The environment status response identified environment "env-other" instead of "env-1".', }, ]); }), @@ -590,13 +638,55 @@ describe("mobile cloud link environment client", () => { }), ).pipe(Effect.flip); expect(error).toMatchObject({ - _tag: "CloudEnvironmentLinkError", - message: "Relay returned credentials for a different environment.", + _tag: "CloudEnvironmentIdMismatchError", + source: "environment link response", + expectedEnvironmentId: "env-1", + actualEnvironmentId: "env-other", }); expect(fetchMock).toHaveBeenCalledTimes(3); }), ); + it.effect("reports invalid endpoint URLs with redacted diagnostics", () => + Effect.gen(function* () { + vi.stubGlobal( + "fetch", + vi.fn(() => Promise.resolve(Response.json(validLinkChallengeResponse()))), + ); + + const httpBaseUrl = + "https://desktop-user:desktop-password@[invalid-host]/private/workspace?access_token=desktop-secret#desktop-fragment"; + const error = yield* withCloudServices( + linkEnvironmentToCloud({ + clerkToken: "clerk-token", + connection: { + ...savedConnection, + httpBaseUrl, + }, + }), + ).pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "CloudEnvironmentLinkOperationError", + action: "derive the environment endpoint origin", + environmentId: "env-1", + httpBaseUrlInputLength: httpBaseUrl.length, + }); + expect(error).not.toHaveProperty("httpBaseUrl"); + const serialized = JSON.stringify({ ...error, cause: undefined }); + for (const secret of [ + "desktop-user", + "desktop-password", + "/private/workspace", + "desktop-secret", + "desktop-fragment", + ]) { + expect(serialized).not.toContain(secret); + expect(error.message).not.toContain(secret); + } + }), + ); + it.effect("preserves typed local environment failures while obtaining a link proof", () => Effect.gen(function* () { const fetchMock = vi.fn((url: string | URL) => { @@ -607,7 +697,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 }, ), @@ -621,9 +712,20 @@ describe("mobile cloud link environment client", () => { connection: savedConnection, }), ).pipe(Effect.flip); - expect(error._tag).toBe("CloudEnvironmentLinkError"); + expect(error).toMatchObject({ + _tag: "CloudEnvironmentLinkOperationError", + action: "obtain an environment link proof", + environmentId: "env-1", + httpBaseUrlInputLength: "https://desktop.example.test/".length, + httpBaseUrlProtocol: "https:", + httpBaseUrlHostname: "desktop.example.test", + environmentError: { + _tag: "EnvironmentHttpUnauthorizedError", + reason: "cloud_cli_authorization_required", + }, + }); expect(error.message).toBe( - "Could not obtain environment link proof: Invalid environment bearer session.", + 'Could not obtain an environment link proof for environment "env-1".', ); expect(fetchMock).toHaveBeenCalledTimes(2); }), @@ -659,11 +761,19 @@ describe("mobile cloud link environment client", () => { }), ).pipe(Effect.flip); expect(error).toMatchObject({ - _tag: "CloudEnvironmentLinkError", - message: - "https://relay.example.test/v1/client/environment-links failed: Relay rejected the environment link proof (origin_not_allowed).", + _tag: "CloudEnvironmentLinkOperationError", + action: "link the environment", + environmentId: "env-1", + relayUrlInputLength: "https://relay.example.test".length, + relayUrlProtocol: "https:", + relayUrlHostname: "relay.example.test", traceId: "trace-test", + relayError: { + _tag: "RelayEnvironmentLinkProofInvalidError", + reason: "origin_not_allowed", + }, }); + expect(error.message).toBe('Could not link the environment for environment "env-1".'); expect(fetchMock).toHaveBeenCalledTimes(3); }), ); @@ -696,8 +806,10 @@ describe("mobile cloud link environment client", () => { }), ).pipe(Effect.flip); expect(error).toMatchObject({ - _tag: "CloudEnvironmentLinkError", - message: "Relay returned credentials for a different endpoint provider.", + _tag: "CloudEnvironmentEndpointProviderMismatchError", + environmentId: "env-1", + expectedProviderKind: "cloudflare_tunnel", + actualProviderKind: "manual", }); expect(fetchMock).toHaveBeenCalledTimes(3); }), @@ -962,8 +1074,10 @@ describe("mobile cloud link environment client", () => { }), ).pipe(Effect.flip); expect(error).toMatchObject({ - _tag: "CloudEnvironmentLinkError", - message: "Relay returned credentials for a different environment.", + _tag: "CloudEnvironmentIdMismatchError", + source: "environment connect response", + expectedEnvironmentId: "env-1", + actualEnvironmentId: "env-other", }); }), ); @@ -1005,11 +1119,21 @@ describe("mobile cloud link environment client", () => { }), ).pipe(Effect.flip); expect(error).toMatchObject({ - _tag: "CloudEnvironmentLinkError", - message: - "https://relay.example.test/v1/environments/env-1/connect failed: Relay rejected the DPoP proof.", + _tag: "CloudEnvironmentLinkOperationError", + action: "connect to the cloud environment", + environmentId: "env-1", + relayUrlInputLength: "https://relay.example.test".length, + relayUrlProtocol: "https:", + relayUrlHostname: "relay.example.test", traceId: "trace-connect", + relayError: { + _tag: "RelayAuthInvalidError", + reason: "invalid_dpop", + }, }); + expect(error.message).toBe( + 'Could not connect to the cloud environment for environment "env-1".', + ); }), ); @@ -1051,8 +1175,23 @@ describe("mobile cloud link environment client", () => { }), ).pipe(Effect.flip); expect(error).toMatchObject({ - _tag: "CloudEnvironmentLinkError", - message: "Relay returned credentials for a different endpoint.", + _tag: "CloudEnvironmentEndpointMismatchError", + source: "environment connect response", + environmentId: "env-1", + expectedProviderKind: "cloudflare_tunnel", + expectedHttpBaseUrlInputLength: "https://desktop.example.test/".length, + expectedHttpBaseUrlProtocol: "https:", + expectedHttpBaseUrlHostname: "desktop.example.test", + expectedWsBaseUrlInputLength: "wss://desktop.example.test/ws".length, + expectedWsBaseUrlProtocol: "wss:", + expectedWsBaseUrlHostname: "desktop.example.test", + actualProviderKind: "cloudflare_tunnel", + actualHttpBaseUrlInputLength: "https://other-desktop.example.test/".length, + actualHttpBaseUrlProtocol: "https:", + actualHttpBaseUrlHostname: "other-desktop.example.test", + actualWsBaseUrlInputLength: "wss://other-desktop.example.test/ws".length, + actualWsBaseUrlProtocol: "wss:", + actualWsBaseUrlHostname: "other-desktop.example.test", }); }), ); @@ -1105,8 +1244,10 @@ describe("mobile cloud link environment client", () => { }), ).pipe(Effect.flip); expect(error).toMatchObject({ - _tag: "CloudEnvironmentLinkError", - message: "Connected endpoint descriptor does not match the selected environment.", + _tag: "CloudEnvironmentIdMismatchError", + source: "connected environment descriptor", + expectedEnvironmentId: "env-1", + actualEnvironmentId: "env-other", }); }), ); diff --git a/apps/mobile/src/features/cloud/linkEnvironment.ts b/apps/mobile/src/features/cloud/linkEnvironment.ts index a77ca628978..ea70eec6656 100644 --- a/apps/mobile/src/features/cloud/linkEnvironment.ts +++ b/apps/mobile/src/features/cloud/linkEnvironment.ts @@ -1,4 +1,3 @@ -import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import { HttpClient } from "effect/unstable/http"; @@ -17,16 +16,17 @@ import { RelayEnvironmentConnectScope, RelayEnvironmentStatusScope, type RelayDpopAccessTokenScope, - type RelayProtectedError as RelayProtectedErrorType, type RelayClientEnvironmentRecord, type RelayEnvironmentStatusResponse as RelayEnvironmentStatusResponseType, - type RelayManagedEndpointProviderKind, + RelayManagedEndpointProviderKind, + RelayProtectedError, } from "@t3tools/contracts/relay"; import { exchangeRemoteDpopAccessToken } from "@t3tools/client-runtime/authorization"; import { fetchRemoteEnvironmentDescriptor } from "@t3tools/client-runtime/environment"; import { findErrorTraceId } from "@t3tools/client-runtime/errors"; import { ManagedRelay } from "@t3tools/client-runtime/relay"; import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime/rpc"; +import { getUrlDiagnostics } from "@t3tools/shared/urlDiagnostics"; import { authClientMetadata } from "../../lib/authClientMetadata"; import type { SavedRemoteConnection } from "../../lib/connection"; @@ -50,144 +50,316 @@ function readRelayUrl(): string | null { return resolveCloudPublicConfig().relay.url; } -export class CloudEnvironmentLinkError extends Data.TaggedError("CloudEnvironmentLinkError")<{ - readonly message: string; - readonly cause?: unknown; - readonly traceId?: string; -}> {} +const EnvironmentCloudApiError = Schema.Union([ + EnvironmentHttpBadRequestError, + EnvironmentHttpUnauthorizedError, + EnvironmentHttpForbiddenError, + EnvironmentHttpConflictError, + EnvironmentHttpInternalServerError, + EnvironmentCloudEndpointUnavailableError, +]); +type EnvironmentCloudApiError = typeof EnvironmentCloudApiError.Type; +const isEnvironmentCloudApiError = Schema.is(EnvironmentCloudApiError); +const isManagedRelayRequestFailedError = Schema.is(ManagedRelay.ManagedRelayRequestFailedError); + +export const CloudEnvironmentLinkAction = Schema.Literals([ + "load the mobile device id", + "load mobile notification preferences", + "create an environment link challenge", + "obtain an environment link proof", + "link the environment", + "configure environment relay access", + "list cloud environments", + "read cloud environment status", + "connect to the cloud environment", + "fetch the connected environment descriptor", + "create a bootstrap DPoP proof", + "exchange a managed endpoint DPoP access token", + "derive the environment endpoint origin", + "initialize the environment HTTP client", + "parse the managed endpoint URL", +]); +export type CloudEnvironmentLinkAction = typeof CloudEnvironmentLinkAction.Type; + +function relayUrlDiagnosticFields(relayUrl: string | undefined) { + if (relayUrl === undefined) { + return {}; + } + const diagnostics = getUrlDiagnostics(relayUrl); + return { + relayUrlInputLength: diagnostics.inputLength, + ...(diagnostics.protocol === undefined ? {} : { relayUrlProtocol: diagnostics.protocol }), + ...(diagnostics.hostname === undefined ? {} : { relayUrlHostname: diagnostics.hostname }), + }; +} -export interface CloudEnvironmentRecordWithStatus { - readonly environment: RelayClientEnvironmentRecord; - readonly status: RelayEnvironmentStatusResponseType | null; - readonly statusError: string | null; +function httpBaseUrlDiagnosticFields(httpBaseUrl: string | undefined) { + if (httpBaseUrl === undefined) { + return {}; + } + const diagnostics = getUrlDiagnostics(httpBaseUrl); + return { + httpBaseUrlInputLength: diagnostics.inputLength, + ...(diagnostics.protocol === undefined ? {} : { httpBaseUrlProtocol: diagnostics.protocol }), + ...(diagnostics.hostname === undefined ? {} : { httpBaseUrlHostname: diagnostics.hostname }), + }; } -const isEnvironmentCloudApiError = Schema.is( - Schema.Union([ - EnvironmentHttpBadRequestError, - EnvironmentHttpUnauthorizedError, - EnvironmentHttpForbiddenError, - EnvironmentHttpConflictError, - EnvironmentHttpInternalServerError, - EnvironmentCloudEndpointUnavailableError, - ]), -); +export class CloudEnvironmentLinkOperationError extends Schema.TaggedErrorClass()( + "CloudEnvironmentLinkOperationError", + { + action: CloudEnvironmentLinkAction, + environmentId: Schema.optionalKey(Schema.String), + relayUrlInputLength: Schema.optionalKey(Schema.Number), + relayUrlProtocol: Schema.optionalKey(Schema.String), + relayUrlHostname: Schema.optionalKey(Schema.String), + httpBaseUrlInputLength: Schema.optionalKey(Schema.Number), + httpBaseUrlProtocol: Schema.optionalKey(Schema.String), + httpBaseUrlHostname: Schema.optionalKey(Schema.String), + traceId: Schema.optionalKey(Schema.String), + relayError: Schema.optionalKey(RelayProtectedError), + environmentError: Schema.optionalKey(EnvironmentCloudApiError), + cause: Schema.Defect(), + }, +) { + static fromCause(input: { + readonly action: CloudEnvironmentLinkAction; + readonly cause: unknown; + readonly environmentId?: string; + readonly relayUrl?: string; + readonly httpBaseUrl?: string; + }): CloudEnvironmentLinkOperationError { + const relayFailure = isManagedRelayRequestFailedError(input.cause) ? input.cause : undefined; + const environmentError = CloudEnvironmentLinkOperationError.findEnvironmentApiError( + input.cause, + ); + const traceId = relayFailure?.traceId ?? findErrorTraceId(input.cause); + return new CloudEnvironmentLinkOperationError({ + action: input.action, + cause: input.cause, + ...(input.environmentId === undefined ? {} : { environmentId: input.environmentId }), + ...relayUrlDiagnosticFields(input.relayUrl), + ...httpBaseUrlDiagnosticFields(input.httpBaseUrl), + ...(traceId === null || traceId === undefined ? {} : { traceId }), + ...(relayFailure?.relayError === undefined ? {} : { relayError: relayFailure.relayError }), + ...(environmentError === undefined ? {} : { environmentError }), + }); + } -const MANAGED_ENDPOINT_PROVIDER_KIND = - "cloudflare_tunnel" satisfies RelayManagedEndpointProviderKind; + private static findEnvironmentApiError(cause: unknown): EnvironmentCloudApiError | undefined { + const seen = new Set(); + let current = cause; + while (typeof current === "object" && current !== null && !seen.has(current)) { + if (isEnvironmentCloudApiError(current)) { + return current; + } + seen.add(current); + current = "cause" in current ? current.cause : undefined; + } + return undefined; + } -function cloudEnvironmentLinkError(message: string) { - return (cause: unknown) => { - const environmentError = findEnvironmentCloudApiError(cause); - const traceId = findErrorTraceId(cause); - return new CloudEnvironmentLinkError({ - message: environmentError - ? `${message.replace(/[.:]$/, "")}: ${environmentError.message}` - : withDevCause(message, cause), - cause, - ...(traceId === null ? {} : { traceId }), - }); - }; + override get message(): string { + const environment = + this.environmentId === undefined ? "" : ` for environment "${this.environmentId}"`; + return `Could not ${this.action}${environment}.`; + } } -function isDevRuntime(): boolean { - return typeof __DEV__ !== "undefined" && __DEV__; +export class CloudRelayUrlNotConfiguredError extends Schema.TaggedErrorClass()( + "CloudRelayUrlNotConfiguredError", + {}, +) { + override get message(): string { + return "Relay URL is not configured."; + } } -function causeMessage(cause: unknown): string | null { - if (cause instanceof Error && cause.message) { - return cause.message; - } - if (typeof cause === "object" && cause !== null) { - const record = cause as { readonly message?: unknown; readonly cause?: unknown }; - if (typeof record.message === "string" && record.message.length > 0) { - const nested = causeMessage(record.cause); - return nested ? `${record.message}: ${nested}` : record.message; - } +export class CloudEnvironmentLocalBearerRequiredError extends Schema.TaggedErrorClass()( + "CloudEnvironmentLocalBearerRequiredError", + { + environmentId: Schema.String, + httpBaseUrlInputLength: Schema.Number, + httpBaseUrlProtocol: Schema.optionalKey(Schema.String), + httpBaseUrlHostname: Schema.optionalKey(Schema.String), + }, +) { + static fromConnection(input: { + readonly environmentId: string; + readonly httpBaseUrl: string; + }): CloudEnvironmentLocalBearerRequiredError { + const diagnostics = getUrlDiagnostics(input.httpBaseUrl); + return new CloudEnvironmentLocalBearerRequiredError({ + environmentId: input.environmentId, + httpBaseUrlInputLength: diagnostics.inputLength, + ...(diagnostics.protocol === undefined ? {} : { httpBaseUrlProtocol: diagnostics.protocol }), + ...(diagnostics.hostname === undefined ? {} : { httpBaseUrlHostname: diagnostics.hostname }), + }); } - return null; -} -function withDevCause(message: string, cause: unknown): string { - if (!isDevRuntime()) { - return message; + override get message(): string { + return "Only a locally paired bearer connection can be linked to the cloud."; } - const detail = causeMessage(cause); - return detail ? `${message} (${detail})` : message; } -function relayProtectedErrorMessage(error: RelayProtectedErrorType): string { - switch (error._tag) { - case "RelayAuthInvalidError": - switch (error.reason) { - case "missing_bearer": - case "invalid_bearer": - return "Relay rejected the cloud session token."; - case "invalid_dpop": - return "Relay rejected the DPoP proof."; - case "not_authorized": - return "Relay rejected the authenticated request."; - } - case "RelayEnvironmentLinkProofExpiredError": - return "Relay rejected an expired environment link proof."; - case "RelayEnvironmentLinkProofInvalidError": - return `Relay rejected the environment link proof (${error.reason}).`; - case "RelayEnvironmentConnectNotAuthorizedError": - return "Relay rejected the environment connection request."; - case "RelayEnvironmentEndpointUnavailableError": - return `Relay could not reach the environment endpoint (${error.reason}).`; - case "RelayEnvironmentEndpointTimedOutError": - return "Relay timed out while contacting the environment endpoint."; - case "RelayEnvironmentLinkFailedError": - return `Relay could not link the environment (${error.reason}).`; - case "RelayEnvironmentLinkUnavailableError": - return `Relay cannot provision the managed endpoint (${error.reason}).`; - case "RelayAgentActivityPublishProofExpiredError": - return "Relay rejected an expired agent activity publish proof."; - case "RelayAgentActivityPublishProofInvalidError": - return `Relay rejected the agent activity publish proof (${error.reason}).`; - case "RelayInternalError": - return `Relay encountered an internal error (${error.reason}).`; +export class CloudEnvironmentIdMismatchError extends Schema.TaggedErrorClass()( + "CloudEnvironmentIdMismatchError", + { + source: Schema.Literals([ + "environment link response", + "environment status response", + "environment status descriptor", + "environment connect response", + "connected environment descriptor", + ]), + expectedEnvironmentId: Schema.String, + actualEnvironmentId: Schema.String, + }, +) { + override get message(): string { + return `The ${this.source} identified environment "${this.actualEnvironmentId}" instead of "${this.expectedEnvironmentId}".`; } } -function decodedRelayClientError(message: string) { - return (cause: ManagedRelay.ManagedRelayClientError) => { - const relayError = - cause._tag === "ManagedRelayRequestFailedError" ? cause.relayError : undefined; - const traceId = cause._tag === "ManagedRelayRequestFailedError" ? cause.traceId : undefined; - const detail = relayError ? relayProtectedErrorMessage(relayError) : null; - return new CloudEnvironmentLinkError({ - message: detail ? `${message}: ${detail}` : message, - cause, - ...(traceId ? { traceId } : {}), +export class CloudEnvironmentEndpointMismatchError extends Schema.TaggedErrorClass()( + "CloudEnvironmentEndpointMismatchError", + { + source: Schema.Literals(["environment status response", "environment connect response"]), + environmentId: Schema.String, + expectedProviderKind: RelayManagedEndpointProviderKind, + expectedHttpBaseUrlInputLength: Schema.Number, + expectedHttpBaseUrlProtocol: Schema.optionalKey(Schema.String), + expectedHttpBaseUrlHostname: Schema.optionalKey(Schema.String), + expectedWsBaseUrlInputLength: Schema.Number, + expectedWsBaseUrlProtocol: Schema.optionalKey(Schema.String), + expectedWsBaseUrlHostname: Schema.optionalKey(Schema.String), + actualProviderKind: RelayManagedEndpointProviderKind, + actualHttpBaseUrlInputLength: Schema.Number, + actualHttpBaseUrlProtocol: Schema.optionalKey(Schema.String), + actualHttpBaseUrlHostname: Schema.optionalKey(Schema.String), + actualWsBaseUrlInputLength: Schema.Number, + actualWsBaseUrlProtocol: Schema.optionalKey(Schema.String), + actualWsBaseUrlHostname: Schema.optionalKey(Schema.String), + }, +) { + static fromEndpoints(input: { + readonly source: "environment status response" | "environment connect response"; + readonly environmentId: string; + readonly expectedEndpoint: RelayClientEnvironmentRecord["endpoint"]; + readonly actualEndpoint: RelayClientEnvironmentRecord["endpoint"]; + }): CloudEnvironmentEndpointMismatchError { + const expectedHttp = getUrlDiagnostics(input.expectedEndpoint.httpBaseUrl); + const expectedWs = getUrlDiagnostics(input.expectedEndpoint.wsBaseUrl); + const actualHttp = getUrlDiagnostics(input.actualEndpoint.httpBaseUrl); + const actualWs = getUrlDiagnostics(input.actualEndpoint.wsBaseUrl); + return new CloudEnvironmentEndpointMismatchError({ + source: input.source, + environmentId: input.environmentId, + expectedProviderKind: input.expectedEndpoint.providerKind, + expectedHttpBaseUrlInputLength: expectedHttp.inputLength, + ...(expectedHttp.protocol === undefined + ? {} + : { expectedHttpBaseUrlProtocol: expectedHttp.protocol }), + ...(expectedHttp.hostname === undefined + ? {} + : { expectedHttpBaseUrlHostname: expectedHttp.hostname }), + expectedWsBaseUrlInputLength: expectedWs.inputLength, + ...(expectedWs.protocol === undefined + ? {} + : { expectedWsBaseUrlProtocol: expectedWs.protocol }), + ...(expectedWs.hostname === undefined + ? {} + : { expectedWsBaseUrlHostname: expectedWs.hostname }), + actualProviderKind: input.actualEndpoint.providerKind, + actualHttpBaseUrlInputLength: actualHttp.inputLength, + ...(actualHttp.protocol === undefined + ? {} + : { actualHttpBaseUrlProtocol: actualHttp.protocol }), + ...(actualHttp.hostname === undefined + ? {} + : { actualHttpBaseUrlHostname: actualHttp.hostname }), + actualWsBaseUrlInputLength: actualWs.inputLength, + ...(actualWs.protocol === undefined ? {} : { actualWsBaseUrlProtocol: actualWs.protocol }), + ...(actualWs.hostname === undefined ? {} : { actualWsBaseUrlHostname: actualWs.hostname }), }); - }; -} + } -function findEnvironmentCloudApiError(cause: unknown): { readonly message: string } | null { - if (isEnvironmentCloudApiError(cause)) { - return cause; + override get message(): string { + return `The ${this.source} returned a different endpoint for environment "${this.environmentId}".`; } - if (typeof cause !== "object" || cause === null) { - return null; +} + +export class CloudEnvironmentEndpointProviderMismatchError extends Schema.TaggedErrorClass()( + "CloudEnvironmentEndpointProviderMismatchError", + { + environmentId: Schema.String, + expectedProviderKind: RelayManagedEndpointProviderKind, + actualProviderKind: RelayManagedEndpointProviderKind, + }, +) { + override get message(): string { + return `Relay returned link credentials with endpoint provider "${this.actualProviderKind}" instead of "${this.expectedProviderKind}".`; } - return "cause" in cause ? findEnvironmentCloudApiError(cause.cause) : null; } +export const CloudEnvironmentLinkError = Schema.Union([ + CloudEnvironmentLinkOperationError, + CloudRelayUrlNotConfiguredError, + CloudEnvironmentLocalBearerRequiredError, + CloudEnvironmentIdMismatchError, + CloudEnvironmentEndpointMismatchError, + CloudEnvironmentEndpointProviderMismatchError, +]); +export type CloudEnvironmentLinkError = typeof CloudEnvironmentLinkError.Type; +export const isCloudEnvironmentLinkError = Schema.is(CloudEnvironmentLinkError); + +export interface CloudEnvironmentRecordWithStatus { + readonly environment: RelayClientEnvironmentRecord; + readonly status: RelayEnvironmentStatusResponseType | null; + readonly statusError: string | null; +} + +const MANAGED_ENDPOINT_PROVIDER_KIND = + "cloudflare_tunnel" satisfies RelayManagedEndpointProviderKind; + function requireRelayUrl(): Effect.Effect { const relayUrl = readRelayUrl(); - return relayUrl - ? Effect.succeed(relayUrl) - : Effect.fail(new CloudEnvironmentLinkError({ message: "Relay URL is not configured." })); + return relayUrl ? Effect.succeed(relayUrl) : Effect.fail(new CloudRelayUrlNotConfiguredError()); } -function endpointOrigin(httpBaseUrl: string) { - const url = new URL(httpBaseUrl); - return { - localHttpHost: "127.0.0.1", - localHttpPort: Number(url.port || (url.protocol === "https:" ? 443 : 80)), - }; +function endpointOrigin(input: { readonly environmentId: string; readonly httpBaseUrl: string }) { + return Effect.try({ + try: () => { + const url = new URL(input.httpBaseUrl); + return { + localHttpHost: "127.0.0.1", + localHttpPort: Number(url.port || (url.protocol === "https:" ? 443 : 80)), + }; + }, + catch: (cause) => + CloudEnvironmentLinkOperationError.fromCause({ + action: "derive the environment endpoint origin", + environmentId: input.environmentId, + httpBaseUrl: input.httpBaseUrl, + cause, + }), + }); +} + +function makeCloudEnvironmentHttpApiClient(input: { + readonly environmentId: string; + readonly httpBaseUrl: string; +}) { + return Effect.try({ + try: () => makeEnvironmentHttpApiClient(input.httpBaseUrl), + catch: (cause) => + CloudEnvironmentLinkOperationError.fromCause({ + action: "initialize the environment HTTP client", + environmentId: input.environmentId, + httpBaseUrl: input.httpBaseUrl, + cause, + }), + }).pipe(Effect.flatten); } function ensureLinkedEnvironmentMatches(input: { @@ -196,13 +368,17 @@ function ensureLinkedEnvironmentMatches(input: { readonly link: RelayEnvironmentLinkResponseType; }): Effect.Effect { if (input.link.environmentId !== input.expectedEnvironmentId) { - return new CloudEnvironmentLinkError({ - message: "Relay returned credentials for a different environment.", + return new CloudEnvironmentIdMismatchError({ + source: "environment link response", + expectedEnvironmentId: input.expectedEnvironmentId, + actualEnvironmentId: input.link.environmentId, }); } if (input.link.endpoint.providerKind !== input.expectedProviderKind) { - return new CloudEnvironmentLinkError({ - message: "Relay returned credentials for a different endpoint provider.", + return new CloudEnvironmentEndpointProviderMismatchError({ + environmentId: input.expectedEnvironmentId, + expectedProviderKind: input.expectedProviderKind, + actualProviderKind: input.link.endpoint.providerKind, }); } return Effect.void; @@ -224,21 +400,28 @@ function ensureStatusMatchesEnvironment(input: { readonly status: RelayEnvironmentStatusResponseType; }): Effect.Effect { if (input.status.environmentId !== input.environment.environmentId) { - return new CloudEnvironmentLinkError({ - message: "Relay returned status for a different environment.", + return new CloudEnvironmentIdMismatchError({ + source: "environment status response", + expectedEnvironmentId: input.environment.environmentId, + actualEnvironmentId: input.status.environmentId, }); } if (!endpointMatches(input.status.endpoint, input.environment.endpoint)) { - return new CloudEnvironmentLinkError({ - message: "Relay returned status for a different endpoint.", + return CloudEnvironmentEndpointMismatchError.fromEndpoints({ + source: "environment status response", + environmentId: input.environment.environmentId, + expectedEndpoint: input.environment.endpoint, + actualEndpoint: input.status.endpoint, }); } if ( input.status.descriptor && input.status.descriptor.environmentId !== input.environment.environmentId ) { - return new CloudEnvironmentLinkError({ - message: "Relay returned status descriptor for a different environment.", + return new CloudEnvironmentIdMismatchError({ + source: "environment status descriptor", + expectedEnvironmentId: input.environment.environmentId, + actualEnvironmentId: input.status.descriptor.environmentId, }); } return Effect.void; @@ -249,8 +432,11 @@ function ensureConnectEndpointMatchesEnvironment(input: { readonly connect: RelayEnvironmentConnectResponseType; }): Effect.Effect { if (!endpointMatches(input.connect.endpoint, input.environment.endpoint)) { - return new CloudEnvironmentLinkError({ - message: "Relay returned credentials for a different endpoint.", + return CloudEnvironmentEndpointMismatchError.fromEndpoints({ + source: "environment connect response", + environmentId: input.environment.environmentId, + expectedEndpoint: input.environment.endpoint, + actualEndpoint: input.connect.endpoint, }); } return Effect.void; @@ -266,8 +452,9 @@ export function linkEnvironmentToCloud(input: { > { return Effect.gen(function* () { if (!input.connection.bearerToken) { - return yield* new CloudEnvironmentLinkError({ - message: "Only a locally paired bearer connection can be linked to the cloud.", + return yield* CloudEnvironmentLocalBearerRequiredError.fromConnection({ + environmentId: input.connection.environmentId, + httpBaseUrl: input.connection.httpBaseUrl, }); } const localBearerToken = input.connection.bearerToken; @@ -275,11 +462,21 @@ export function linkEnvironmentToCloud(input: { const relayClient = yield* ManagedRelay.ManagedRelayClient; const deviceId = yield* Effect.tryPromise({ try: () => loadOrCreateAgentAwarenessDeviceId(), - catch: cloudEnvironmentLinkError("Could not load the mobile device id."), + catch: (cause) => + CloudEnvironmentLinkOperationError.fromCause({ + action: "load the mobile device id", + environmentId: input.connection.environmentId, + cause, + }), }); const preferences = yield* Effect.tryPromise({ try: () => loadPreferences(), - catch: cloudEnvironmentLinkError("Could not load mobile notification preferences."), + catch: (cause) => + CloudEnvironmentLinkOperationError.fromCause({ + action: "load mobile notification preferences", + environmentId: input.connection.environmentId, + cause, + }), }); const liveActivitiesEnabled = preferences.liveActivitiesEnabled !== false; const challenge = yield* relayClient @@ -292,11 +489,23 @@ export function linkEnvironmentToCloud(input: { }, }) .pipe( - Effect.mapError( - decodedRelayClientError(`${relayUrl}/v1/client/environment-link-challenges failed`), + Effect.mapError((cause) => + CloudEnvironmentLinkOperationError.fromCause({ + action: "create an environment link challenge", + environmentId: input.connection.environmentId, + relayUrl, + cause, + }), ), ); - const environmentClient = yield* makeEnvironmentHttpApiClient(input.connection.httpBaseUrl); + const origin = yield* endpointOrigin({ + environmentId: input.connection.environmentId, + httpBaseUrl: input.connection.httpBaseUrl, + }); + const environmentClient = yield* makeCloudEnvironmentHttpApiClient({ + environmentId: input.connection.environmentId, + httpBaseUrl: input.connection.httpBaseUrl, + }); const proof = yield* environmentClient.connect .linkProof({ headers: { authorization: `Bearer ${localBearerToken}` }, @@ -308,10 +517,19 @@ export function linkEnvironmentToCloud(input: { wsBaseUrl: input.connection.wsBaseUrl, providerKind: MANAGED_ENDPOINT_PROVIDER_KIND, }, - origin: endpointOrigin(input.connection.httpBaseUrl), + origin, }, }) - .pipe(Effect.mapError(cloudEnvironmentLinkError("Could not obtain environment link proof."))); + .pipe( + Effect.mapError((cause) => + CloudEnvironmentLinkOperationError.fromCause({ + action: "obtain an environment link proof", + environmentId: input.connection.environmentId, + httpBaseUrl: input.connection.httpBaseUrl, + cause, + }), + ), + ); const link = yield* relayClient .linkEnvironment({ clerkToken: input.clerkToken, @@ -324,7 +542,14 @@ export function linkEnvironmentToCloud(input: { }, }) .pipe( - Effect.mapError(decodedRelayClientError(`${relayUrl}/v1/client/environment-links failed`)), + Effect.mapError((cause) => + CloudEnvironmentLinkOperationError.fromCause({ + action: "link the environment", + environmentId: input.connection.environmentId, + relayUrl, + cause, + }), + ), ); yield* ensureLinkedEnvironmentMatches({ expectedEnvironmentId: input.connection.environmentId, @@ -345,7 +570,14 @@ export function linkEnvironmentToCloud(input: { }, }) .pipe( - Effect.mapError(cloudEnvironmentLinkError("Could not configure environment relay access.")), + Effect.mapError((cause) => + CloudEnvironmentLinkOperationError.fromCause({ + action: "configure environment relay access", + environmentId: input.connection.environmentId, + httpBaseUrl: input.connection.httpBaseUrl, + cause, + }), + ), ); }); } @@ -361,11 +593,15 @@ export function listCloudEnvironments(input: { const relayUrl = yield* requireRelayUrl(); const relayClient = yield* ManagedRelay.ManagedRelayClient; - return yield* relayClient - .listEnvironments({ - clerkToken: input.clerkToken, - }) - .pipe(Effect.mapError(decodedRelayClientError(`${relayUrl}/v1/environments failed`))); + return yield* relayClient.listEnvironments({ clerkToken: input.clerkToken }).pipe( + Effect.mapError((cause) => + CloudEnvironmentLinkOperationError.fromCause({ + action: "list cloud environments", + relayUrl, + cause, + }), + ), + ); }); } @@ -388,10 +624,13 @@ export function getCloudEnvironmentStatus(input: { environmentId: input.environment.environmentId, }) .pipe( - Effect.mapError( - decodedRelayClientError( - `${relayUrl}/v1/environments/${encodeURIComponent(input.environment.environmentId)}/status failed`, - ), + Effect.mapError((cause) => + CloudEnvironmentLinkOperationError.fromCause({ + action: "read cloud environment status", + environmentId: input.environment.environmentId, + relayUrl, + cause, + }), ), ); yield* ensureStatusMatchesEnvironment({ environment: input.environment, status }); @@ -458,14 +697,19 @@ export function listCloudEnvironmentsWithStatus(input: { }); } -const loadAgentAwarenessDeviceId = Effect.fn("mobile.cloud.loadAgentAwarenessDeviceId")( - function* () { - return yield* Effect.tryPromise({ - try: () => loadOrCreateAgentAwarenessDeviceId(), - catch: cloudEnvironmentLinkError("Could not load the mobile device id."), - }); - }, -); +const loadAgentAwarenessDeviceId = Effect.fn("mobile.cloud.loadAgentAwarenessDeviceId")(function* ( + environmentId: string, +) { + return yield* Effect.tryPromise({ + try: () => loadOrCreateAgentAwarenessDeviceId(), + catch: (cause) => + CloudEnvironmentLinkOperationError.fromCause({ + action: "load the mobile device id", + environmentId, + cause, + }), + }); +}); const connectRelayManagedEnvironment = Effect.fn("mobile.cloud.connectRelayManagedEnvironment")( function* (input: { @@ -477,7 +721,7 @@ const connectRelayManagedEnvironment = Effect.fn("mobile.cloud.connectRelayManag const relayUrl = yield* requireRelayUrl(); const relayClient = yield* ManagedRelay.ManagedRelayClient; - const deviceId = yield* loadAgentAwarenessDeviceId(); + const deviceId = yield* loadAgentAwarenessDeviceId(input.environmentId); const connect = yield* relayClient .connectEnvironment({ clerkToken: input.clerkToken, @@ -486,15 +730,20 @@ const connectRelayManagedEnvironment = Effect.fn("mobile.cloud.connectRelayManag deviceId, }) .pipe( - Effect.mapError( - decodedRelayClientError( - `${relayUrl}/v1/environments/${encodeURIComponent(input.environmentId)}/connect failed`, - ), + Effect.mapError((cause) => + CloudEnvironmentLinkOperationError.fromCause({ + action: "connect to the cloud environment", + environmentId: input.environmentId, + relayUrl, + cause, + }), ), ); if (connect.environmentId !== input.environmentId) { - return yield* new CloudEnvironmentLinkError({ - message: "Relay returned credentials for a different environment.", + return yield* new CloudEnvironmentIdMismatchError({ + source: "environment connect response", + expectedEnvironmentId: input.environmentId, + actualEnvironmentId: connect.environmentId, }); } if (input.expectedEnvironment) { @@ -507,39 +756,69 @@ const connectRelayManagedEnvironment = Effect.fn("mobile.cloud.connectRelayManag const descriptor = yield* fetchRemoteEnvironmentDescriptor({ httpBaseUrl: connect.endpoint.httpBaseUrl, }).pipe( - Effect.mapError( - cloudEnvironmentLinkError("Could not fetch the connected environment descriptor."), + Effect.mapError((cause) => + CloudEnvironmentLinkOperationError.fromCause({ + action: "fetch the connected environment descriptor", + environmentId: input.environmentId, + httpBaseUrl: connect.endpoint.httpBaseUrl, + cause, + }), ), ); if (descriptor.environmentId !== connect.environmentId) { - return yield* new CloudEnvironmentLinkError({ - message: "Connected endpoint descriptor does not match the selected environment.", + return yield* new CloudEnvironmentIdMismatchError({ + source: "connected environment descriptor", + expectedEnvironmentId: connect.environmentId, + actualEnvironmentId: descriptor.environmentId, }); } + const endpointUrl = yield* Effect.try({ + try: () => new URL(connect.endpoint.httpBaseUrl), + catch: (cause) => + CloudEnvironmentLinkOperationError.fromCause({ + action: "parse the managed endpoint URL", + environmentId: input.environmentId, + httpBaseUrl: connect.endpoint.httpBaseUrl, + cause, + }), + }); const signer = yield* ManagedRelay.ManagedRelayDpopSigner; const bootstrapDpop = yield* signer .createProof({ method: "POST", - url: new URL("/oauth/token", connect.endpoint.httpBaseUrl).toString(), + url: new URL("/oauth/token", endpointUrl).toString(), }) - .pipe(Effect.mapError(cloudEnvironmentLinkError("Could not create bootstrap DPoP proof."))); + .pipe( + Effect.mapError((cause) => + CloudEnvironmentLinkOperationError.fromCause({ + action: "create a bootstrap DPoP proof", + environmentId: input.environmentId, + httpBaseUrl: connect.endpoint.httpBaseUrl, + cause, + }), + ), + ); const bootstrap = yield* exchangeRemoteDpopAccessToken({ httpBaseUrl: connect.endpoint.httpBaseUrl, credential: connect.credential, dpopProof: bootstrapDpop, clientMetadata: authClientMetadata(), }).pipe( - Effect.mapError( - cloudEnvironmentLinkError("Could not exchange a managed endpoint DPoP access token."), + Effect.mapError((cause) => + CloudEnvironmentLinkOperationError.fromCause({ + action: "exchange a managed endpoint DPoP access token", + environmentId: input.environmentId, + httpBaseUrl: connect.endpoint.httpBaseUrl, + cause, + }), ), ); - const pairingUrl = new URL(connect.endpoint.httpBaseUrl); - pairingUrl.hash = new URLSearchParams([["token", connect.credential]]).toString(); + endpointUrl.hash = new URLSearchParams([["token", connect.credential]]).toString(); return { environmentId: descriptor.environmentId, environmentLabel: descriptor.label, - pairingUrl: stripPairingTokenFromUrl(pairingUrl).toString(), + pairingUrl: stripPairingTokenFromUrl(endpointUrl).toString(), displayUrl: connect.endpoint.httpBaseUrl, httpBaseUrl: connect.endpoint.httpBaseUrl, wsBaseUrl: connect.endpoint.wsBaseUrl, diff --git a/apps/mobile/src/features/cloud/managedRelayLayer.ts b/apps/mobile/src/features/cloud/managedRelayLayer.ts index 2da1fa9157c..9014a5e2cab 100644 --- a/apps/mobile/src/features/cloud/managedRelayLayer.ts +++ b/apps/mobile/src/features/cloud/managedRelayLayer.ts @@ -28,25 +28,24 @@ const relayDpopSignerLayer = Layer.effect( ), createProof: Effect.fn("mobile.managedRelayDpopSigner.createProof")(function* (input) { const proofKey = yield* loadProofKey.pipe( - Effect.mapError( - (error) => - new ManagedRelay.ManagedRelayDpopProofCreationError({ - method: input.method, - url: input.url, - cause: error, - }), + Effect.mapError((error) => + ManagedRelay.ManagedRelayDpopKeyLoadError.fromTarget({ + keyStore: "expo-secure-store", + method: input.method, + url: input.url, + cause: error, + }), ), ); return yield* createDpopProof({ ...input, proofKey }).pipe( Effect.provideService(Crypto.Crypto, crypto), Effect.map((proof) => proof.proof), - Effect.mapError( - (error) => - new ManagedRelay.ManagedRelayDpopProofCreationError({ - method: input.method, - url: input.url, - cause: error, - }), + Effect.mapError((error) => + ManagedRelay.ManagedRelayDpopProofCreationError.fromTarget({ + method: input.method, + url: input.url, + cause: error, + }), ), ); }), diff --git a/apps/mobile/src/features/connection/useConnectionController.ts b/apps/mobile/src/features/connection/useConnectionController.ts index bad6b6f1720..fa3b2a52302 100644 --- a/apps/mobile/src/features/connection/useConnectionController.ts +++ b/apps/mobile/src/features/connection/useConnectionController.ts @@ -58,7 +58,7 @@ export function useConnectionController() { environment: entry.environment, availability: entry.availability, status: Option.getOrNull(entry.status), - error: Option.getOrNull(entry.error)?.message ?? null, + error: Option.getOrNull(entry.error)?.detail ?? null, traceId: Option.getOrNull(entry.error)?.traceId ?? null, })), [discovery.environments], @@ -112,7 +112,7 @@ export function useConnectionController() { relayDiscovery: { isRefreshing: discovery.refreshing, isOffline: discovery.offline, - error: Option.getOrNull(discovery.error)?.message ?? null, + error: Option.getOrNull(discovery.error)?.detail ?? null, errorTraceId: Option.getOrNull(discovery.error)?.traceId ?? null, }, connectPairingUrl, diff --git a/apps/mobile/src/features/files/sourceHighlightingState.test.ts b/apps/mobile/src/features/files/sourceHighlightingState.test.ts index 6c4c00e1663..23b5334d9b1 100644 --- a/apps/mobile/src/features/files/sourceHighlightingState.test.ts +++ b/apps/mobile/src/features/files/sourceHighlightingState.test.ts @@ -1,9 +1,11 @@ +import * as Cause from "effect/Cause"; import { AtomRegistry } from "effect/unstable/reactivity"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import { createSourceHighlightAtomFamily, + SourceHighlightError, type SourceHighlightTokens, } from "./sourceHighlightingState"; @@ -100,22 +102,29 @@ describe("sourceHighlightingState", () => { registry.dispose(); }); - it("exposes highlighter errors as a failed async result", async () => { - const highlight = vi.fn(async () => { - throw new Error("highlight failed"); - }); + it("preserves highlighter failures with the source path and theme", async () => { + const cause = new Error("highlight failed"); + const highlight = vi.fn(() => Promise.reject(cause)); const sourceHighlightAtom = createSourceHighlightAtomFamily({ highlight }); const registry = AtomRegistry.make(); + const path = "src/example.ts"; + const theme = "light" as const; const atom = sourceHighlightAtom({ - path: "src/example.ts", + path, contents: "const value = 1;", - theme: "light", + theme, }); const unmount = registry.mount(atom); await vi.waitFor(() => { expect(AsyncResult.isFailure(registry.get(atom))).toBe(true); }); + const result = registry.get(atom); + if (AsyncResult.isFailure(result)) { + const error = Cause.squash(result.cause); + expect(error).toEqual(new SourceHighlightError({ path, theme, cause })); + expect((error as SourceHighlightError).cause).toBe(cause); + } unmount(); registry.dispose(); diff --git a/apps/mobile/src/features/files/sourceHighlightingState.ts b/apps/mobile/src/features/files/sourceHighlightingState.ts index 43363115bc8..45506d1cfcf 100644 --- a/apps/mobile/src/features/files/sourceHighlightingState.ts +++ b/apps/mobile/src/features/files/sourceHighlightingState.ts @@ -1,5 +1,6 @@ import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; import { Atom } from "effect/unstable/reactivity"; import { @@ -22,9 +23,18 @@ type SourceHighlighter = (input: SourceHighlightInput) => Promise {} -class SourceHighlightError extends Data.TaggedError("SourceHighlightError")<{ - readonly cause: unknown; -}> {} +export class SourceHighlightError extends Schema.TaggedErrorClass()( + "SourceHighlightError", + { + path: Schema.String, + theme: Schema.Literals(["light", "dark"]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Could not highlight ${this.path} with the ${this.theme} theme.`; + } +} export function createSourceHighlightAtomFamily(options?: { readonly highlight?: SourceHighlighter; @@ -36,7 +46,8 @@ export function createSourceHighlightAtomFamily(options?: { Atom.make( Effect.tryPromise({ try: () => highlight(request), - catch: (cause) => new SourceHighlightError({ cause }), + catch: (cause) => + new SourceHighlightError({ path: request.path, theme: request.theme, cause }), }), ).pipe( Atom.setIdleTTL(idleTtlMs), diff --git a/apps/mobile/src/features/files/workspace-file-image-cache.test.ts b/apps/mobile/src/features/files/workspace-file-image-cache.test.ts index 4acb67361a8..3764d4b29d0 100644 --- a/apps/mobile/src/features/files/workspace-file-image-cache.test.ts +++ b/apps/mobile/src/features/files/workspace-file-image-cache.test.ts @@ -1,8 +1,14 @@ +import * as Cause from "effect/Cause"; +import * as Hash from "effect/Hash"; import { AtomRegistry } from "effect/unstable/reactivity"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import { describe, expect, it, vi } from "vite-plus/test"; -import { createWorkspaceFileImageAtomFamily } from "./workspace-file-image-cache"; +import { + createWorkspaceFileImageAtomFamily, + WorkspaceImagePrefetchFailedError, + WorkspaceImagePrefetchUnavailableError, +} from "./workspace-file-image-cache"; describe("workspaceFileImageAtom", () => { it("reuses a prefetched image across route remounts", async () => { @@ -48,15 +54,65 @@ describe("workspaceFileImageAtom", () => { registry.dispose(); }); - it("exposes prefetch failures", async () => { + it("reports an unavailable image when prefetch completes without caching it", async () => { + const uri = "https://example.test/api/assets/signed-secret-token/missing.png?signature=private"; const imageAtom = createWorkspaceFileImageAtomFamily({ prefetch: async () => false }); const registry = AtomRegistry.make(); - const atom = imageAtom("https://example.test/missing.png"); + const atom = imageAtom(uri); + expect(atom.label?.[0]).not.toContain("signed-secret-token"); + expect(atom.label?.[0]).not.toContain("signature=private"); const unmount = registry.mount(atom); await vi.waitFor(() => { expect(AsyncResult.isFailure(registry.get(atom))).toBe(true); }); + const result = registry.get(atom); + if (AsyncResult.isFailure(result)) { + const error = Cause.squash(result.cause); + expect(error).toEqual( + new WorkspaceImagePrefetchUnavailableError({ + uriHash: Hash.hash(uri), + uriLength: uri.length, + uriProtocol: "https:", + }), + ); + expect(error).not.toHaveProperty("uri"); + expect(String(error)).not.toContain("signed-secret-token"); + expect(String(error)).not.toContain("signature=private"); + } + + unmount(); + registry.dispose(); + }); + + it("preserves rejected prefetch causes without retaining the signed image URI", async () => { + const uri = + "https://example.test/api/assets/signed-secret-token/rejected.png?signature=private"; + const cause = new Error("native image loader failed"); + const imageAtom = createWorkspaceFileImageAtomFamily({ prefetch: () => Promise.reject(cause) }); + const registry = AtomRegistry.make(); + const atom = imageAtom(uri); + const unmount = registry.mount(atom); + + await vi.waitFor(() => { + expect(AsyncResult.isFailure(registry.get(atom))).toBe(true); + }); + const result = registry.get(atom); + if (AsyncResult.isFailure(result)) { + const error = Cause.squash(result.cause); + expect(error).toEqual( + new WorkspaceImagePrefetchFailedError({ + uriHash: Hash.hash(uri), + uriLength: uri.length, + uriProtocol: "https:", + cause, + }), + ); + expect((error as WorkspaceImagePrefetchFailedError).cause).toBe(cause); + expect(error).not.toHaveProperty("uri"); + expect(String(error)).not.toContain("signed-secret-token"); + expect(String(error)).not.toContain("signature=private"); + } unmount(); registry.dispose(); diff --git a/apps/mobile/src/features/files/workspace-file-image-cache.ts b/apps/mobile/src/features/files/workspace-file-image-cache.ts index 3f58f65b46c..9251f57185c 100644 --- a/apps/mobile/src/features/files/workspace-file-image-cache.ts +++ b/apps/mobile/src/features/files/workspace-file-image-cache.ts @@ -1,5 +1,8 @@ +import { getUrlDiagnostics } from "@t3tools/shared/urlDiagnostics"; import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; +import * as Hash from "effect/Hash"; +import * as Schema from "effect/Schema"; import { Atom } from "effect/unstable/reactivity"; const WORKSPACE_IMAGE_IDLE_TTL_MS = 30 * 60_000; @@ -8,39 +11,77 @@ type ImagePrefetch = (uri: string) => Promise; class WorkspaceImageCacheKey extends Data.Class<{ readonly uri: string }> {} -export class WorkspaceImagePrefetchError extends Data.TaggedError("WorkspaceImagePrefetchError")<{ - readonly cause?: unknown; - readonly uri: string; -}> {} +export class WorkspaceImagePrefetchUnavailableError extends Schema.TaggedErrorClass()( + "WorkspaceImagePrefetchUnavailableError", + { + uriHash: Schema.Number, + uriLength: Schema.Number, + uriProtocol: Schema.NullOr(Schema.String), + }, +) { + override get message(): string { + return `Image prefetch did not cache the requested ${this.uriProtocol ?? "unknown-protocol"} resource (URI length ${this.uriLength}).`; + } +} + +export class WorkspaceImagePrefetchFailedError extends Schema.TaggedErrorClass()( + "WorkspaceImagePrefetchFailedError", + { + uriHash: Schema.Number, + uriLength: Schema.Number, + uriProtocol: Schema.NullOr(Schema.String), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Image prefetch failed for the requested ${this.uriProtocol ?? "unknown-protocol"} resource (URI length ${this.uriLength}).`; + } +} + +export const WorkspaceImagePrefetchError = Schema.Union([ + WorkspaceImagePrefetchUnavailableError, + WorkspaceImagePrefetchFailedError, +]); +export type WorkspaceImagePrefetchError = typeof WorkspaceImagePrefetchError.Type; async function prefetchWithNativeImage(uri: string): Promise { const { Image } = await import("react-native"); return Image.prefetch(uri); } +function describeWorkspaceImageUri(uri: string) { + const diagnostics = getUrlDiagnostics(uri); + return { + uriHash: Hash.hash(uri), + uriLength: diagnostics.inputLength, + uriProtocol: diagnostics.protocol ?? null, + }; +} + export function createWorkspaceFileImageAtomFamily(options?: { readonly idleTtlMs?: number; readonly prefetch?: ImagePrefetch; }) { const idleTtlMs = options?.idleTtlMs ?? WORKSPACE_IMAGE_IDLE_TTL_MS; const prefetch = options?.prefetch ?? prefetchWithNativeImage; - const family = Atom.family((key: WorkspaceImageCacheKey) => - Atom.make( - Effect.tryPromise({ - try: async () => { - const cached = await prefetch(key.uri); - if (!cached) { - throw new WorkspaceImagePrefetchError({ uri: key.uri }); - } - return key.uri; - }, - catch: (cause) => - cause instanceof WorkspaceImagePrefetchError - ? cause - : new WorkspaceImagePrefetchError({ uri: key.uri, cause }), + const family = Atom.family((key: WorkspaceImageCacheKey) => { + const uriContext = describeWorkspaceImageUri(key.uri); + return Atom.make( + Effect.gen(function* () { + const cached = yield* Effect.tryPromise({ + try: () => prefetch(key.uri), + catch: (cause) => new WorkspaceImagePrefetchFailedError({ ...uriContext, cause }), + }); + if (!cached) { + return yield* new WorkspaceImagePrefetchUnavailableError(uriContext); + } + return key.uri; }), - ).pipe(Atom.setIdleTTL(idleTtlMs), Atom.withLabel(`mobile:workspace-image:${key.uri}`)), - ); + ).pipe( + Atom.setIdleTTL(idleTtlMs), + Atom.withLabel(`mobile:workspace-image:${uriContext.uriHash.toString(36)}`), + ); + }); return (uri: string) => family(new WorkspaceImageCacheKey({ uri })); } diff --git a/apps/mobile/src/lib/composerImages.test.ts b/apps/mobile/src/lib/composerImages.test.ts index 40e00a271f7..0e047fee842 100644 --- a/apps/mobile/src/lib/composerImages.test.ts +++ b/apps/mobile/src/lib/composerImages.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS } from "@t3tools/contracts"; +const missingFileCause = new Error("missing file"); const files = new Map(); vi.mock("expo-file-system", () => ({ @@ -18,7 +19,7 @@ vi.mock("expo-file-system", () => ({ async base64(): Promise { const entry = files.get(this.uri); if (!entry || entry.deleted) { - throw new Error("missing file"); + throw missingFileCause; } return entry.base64; } @@ -91,4 +92,30 @@ describe("native pasted image cleanup", () => { expect(files.get(overflow)?.deleted).toBe(true); expect(files.get(userOwned)?.deleted).toBe(false); }); + + it("reports structured context when reading a pasted image fails", async () => { + const uri = "file:///private/var/mobile/photos/signed-secret-token/missing.png?token=private"; + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + + await expect( + convertPastedImagesToAttachments({ uris: [uri], existingCount: 0 }), + ).resolves.toEqual([]); + expect(warn).toHaveBeenCalledWith( + "[composer-images] failed to read pasted image", + expect.objectContaining({ + _tag: "ComposerImageOperationError", + operation: "read-pasted-image", + uriLength: uri.length, + uriProtocol: "file:", + cause: missingFileCause, + message: `Composer image operation read-pasted-image failed for a file: URI (length ${uri.length}).`, + }), + ); + const error = warn.mock.calls[0]?.[1]; + expect(error).not.toHaveProperty("uri"); + expect(String(error)).not.toContain("signed-secret-token"); + expect(String(error)).not.toContain("token=private"); + + warn.mockRestore(); + }); }); diff --git a/apps/mobile/src/lib/composerImages.ts b/apps/mobile/src/lib/composerImages.ts index 13b53af724e..edb5f43dda3 100644 --- a/apps/mobile/src/lib/composerImages.ts +++ b/apps/mobile/src/lib/composerImages.ts @@ -3,6 +3,8 @@ import { PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, type UploadChatImageAttachment, } from "@t3tools/contracts"; +import { getUrlDiagnostics } from "@t3tools/shared/urlDiagnostics"; +import * as Schema from "effect/Schema"; import { uuidv4 } from "./uuid"; export interface DraftComposerImageAttachment extends UploadChatImageAttachment { @@ -12,6 +14,31 @@ export interface DraftComposerImageAttachment extends UploadChatImageAttachment const OWNED_PASTED_IMAGE_DIRECTORY = "t3-composer-paste"; +export class ComposerImageOperationError extends Schema.TaggedErrorClass()( + "ComposerImageOperationError", + { + operation: Schema.Literals([ + "load-image-picker", + "request-media-library-permission", + "launch-image-library", + "load-clipboard", + "check-clipboard-image", + "read-clipboard-image", + "check-clipboard-text", + "read-clipboard-text", + "read-pasted-image", + "remove-pasted-image", + ]), + uriLength: Schema.optional(Schema.Number), + uriProtocol: Schema.optional(Schema.String), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Composer image operation ${this.operation} failed${this.uriLength === undefined ? "" : ` for a ${this.uriProtocol ?? "unknown-protocol"} URI (length ${this.uriLength})`}.`; + } +} + function estimateBase64ByteSize(base64: string): number { const padding = base64.endsWith("==") ? 2 : base64.endsWith("=") ? 1 : 0; return Math.floor((base64.length * 3) / 4) - padding; @@ -20,16 +47,22 @@ function estimateBase64ByteSize(base64: string): number { async function loadImagePicker() { try { return await import("expo-image-picker"); - } catch (error) { - throw new Error("Image attachments are unavailable right now.", { cause: error }); + } catch (cause) { + throw new ComposerImageOperationError({ + operation: "load-image-picker", + cause, + }); } } async function loadClipboard() { try { return await import("expo-clipboard"); - } catch (error) { - throw new Error("Clipboard paste is unavailable right now.", { cause: error }); + } catch (cause) { + throw new ComposerImageOperationError({ + operation: "load-clipboard", + cause, + }); } } @@ -49,14 +82,19 @@ export async function pickComposerImages(input: { readonly existingCount: number try { imagePicker = await loadImagePicker(); } catch (error) { + console.warn("[composer-images] image picker unavailable", error); return { images: [], - error: - error instanceof Error ? error.message : "Image attachments are unavailable right now.", + error: "Image attachments are unavailable right now.", }; } - const permission = await imagePicker.requestMediaLibraryPermissionsAsync(); + const permission = await imagePicker.requestMediaLibraryPermissionsAsync().catch((cause) => { + throw new ComposerImageOperationError({ + operation: "request-media-library-permission", + cause, + }); + }); if (!permission.granted) { return { images: [], @@ -64,13 +102,20 @@ export async function pickComposerImages(input: { readonly existingCount: number }; } - const result = await imagePicker.launchImageLibraryAsync({ - mediaTypes: ["images"], - allowsMultipleSelection: true, - selectionLimit: remainingSlots, - base64: true, - quality: 1, - }); + const result = await imagePicker + .launchImageLibraryAsync({ + mediaTypes: ["images"], + allowsMultipleSelection: true, + selectionLimit: remainingSlots, + base64: true, + quality: 1, + }) + .catch((cause) => { + throw new ComposerImageOperationError({ + operation: "launch-image-library", + cause, + }); + }); if (result.canceled) { return { @@ -127,16 +172,23 @@ export async function pasteComposerClipboard(input: { readonly existingCount: nu try { clipboard = await loadClipboard(); } catch (error) { + console.warn("[composer-images] clipboard unavailable", error); return { images: [], text: null, - error: error instanceof Error ? error.message : "Clipboard paste is unavailable right now.", + error: "Clipboard paste is unavailable right now.", }; } const remainingSlots = PROVIDER_SEND_TURN_MAX_ATTACHMENTS - input.existingCount; - if (await clipboard.hasImageAsync()) { + const hasImage = await clipboard.hasImageAsync().catch((cause) => { + throw new ComposerImageOperationError({ + operation: "check-clipboard-image", + cause, + }); + }); + if (hasImage) { if (remainingSlots <= 0) { return { images: [], @@ -144,7 +196,12 @@ export async function pasteComposerClipboard(input: { readonly existingCount: nu error: `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} images per message.`, }; } - const image = await clipboard.getImageAsync({ format: "png" }); + const image = await clipboard.getImageAsync({ format: "png" }).catch((cause) => { + throw new ComposerImageOperationError({ + operation: "read-clipboard-image", + cause, + }); + }); if (!image) { return { images: [], @@ -180,8 +237,19 @@ export async function pasteComposerClipboard(input: { readonly existingCount: nu }; } - if (await clipboard.hasStringAsync()) { - const text = await clipboard.getStringAsync(); + const hasText = await clipboard.hasStringAsync().catch((cause) => { + throw new ComposerImageOperationError({ + operation: "check-clipboard-text", + cause, + }); + }); + if (hasText) { + const text = await clipboard.getStringAsync().catch((cause) => { + throw new ComposerImageOperationError({ + operation: "read-clipboard-text", + cause, + }); + }); return { images: [], text: text.length > 0 ? text : null, @@ -230,6 +298,14 @@ export function isOwnedPastedImageUri(uri: string): boolean { } } +function describeComposerImageUri(uri: string) { + const diagnostics = getUrlDiagnostics(uri); + return { + uriLength: diagnostics.inputLength, + ...(diagnostics.protocol === undefined ? {} : { uriProtocol: diagnostics.protocol }), + }; +} + export async function convertPastedImagesToAttachments(input: { readonly uris: ReadonlyArray; readonly existingCount: number; @@ -260,8 +336,15 @@ export async function convertPastedImagesToAttachments(input: { dataUrl: `data:${mimeType};base64,${base64}`, previewUri: ownedTemporaryFile ? `data:${mimeType};base64,${base64}` : uri, }); - } catch (error) { - console.warn("Failed to read pasted image", uri, error); + } catch (cause) { + console.warn( + "[composer-images] failed to read pasted image", + new ComposerImageOperationError({ + operation: "read-pasted-image", + ...describeComposerImageUri(uri), + cause, + }), + ); } finally { if (ownedTemporaryFile) { try { @@ -269,8 +352,15 @@ export async function convertPastedImagesToAttachments(input: { if (file.exists) { file.delete(); } - } catch (error) { - console.warn("Failed to remove temporary pasted image", uri, error); + } catch (cause) { + console.warn( + "[composer-images] failed to remove temporary pasted image", + new ComposerImageOperationError({ + operation: "remove-pasted-image", + ...describeComposerImageUri(uri), + cause, + }), + ); } } } diff --git a/apps/mobile/src/state/thread-outbox-model.ts b/apps/mobile/src/state/thread-outbox-model.ts index aa7a1055136..a35f29a2994 100644 --- a/apps/mobile/src/state/thread-outbox-model.ts +++ b/apps/mobile/src/state/thread-outbox-model.ts @@ -1,4 +1,5 @@ import { isTransportConnectionErrorMessage } from "@t3tools/client-runtime/errors"; +import { isEnvironmentRpcUnavailableError } from "@t3tools/client-runtime/rpc"; import type { EnvironmentShellStatus } from "@t3tools/client-runtime/state/shell"; import { CommandId, EnvironmentId, IsoDateTime, MessageId, ThreadId } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; @@ -109,6 +110,9 @@ function errorMessage(error: unknown): string | null { } export function shouldRetryThreadOutboxDelivery(error: unknown): boolean { + if (isEnvironmentRpcUnavailableError(error)) { + return true; + } if ( typeof error === "object" && error !== null && diff --git a/apps/mobile/src/state/thread-outbox.test.ts b/apps/mobile/src/state/thread-outbox.test.ts index d6b91c1c4f6..6609b1c3a13 100644 --- a/apps/mobile/src/state/thread-outbox.test.ts +++ b/apps/mobile/src/state/thread-outbox.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; +import { EnvironmentRpcUnavailableError } from "@t3tools/client-runtime/rpc"; import { CommandId, EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts"; import { AtomRegistry } from "effect/unstable/reactivity"; @@ -262,6 +263,15 @@ describe("thread outbox", () => { }); it("retries transport failures but drops deterministic command failures", () => { + expect( + shouldRetryThreadOutboxDelivery( + new EnvironmentRpcUnavailableError({ + environmentId: EnvironmentId.make("environment-1"), + environmentLabel: "Test environment", + method: "thread.turn.start", + }), + ), + ).toBe(true); expect(shouldRetryThreadOutboxDelivery(new Error("Socket is not connected"))).toBe(true); expect( shouldRetryThreadOutboxDelivery({ 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/ServerSecretStore.test.ts b/apps/server/src/auth/ServerSecretStore.test.ts index d4411fb9f3b..824a7888617 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* () { @@ -145,7 +170,56 @@ const makeConcurrentCreateSecretStoreLayer = () => Layer.provideMerge(ConcurrentReadMissFileSystemLayer), ); +const ConcurrentCreationWithoutReadableSecretFileSystemLayer = Layer.effect( + FileSystem.FileSystem, + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + + return { + ...fileSystem, + readFile: (path) => + Effect.fail( + PlatformError.systemError({ + _tag: "NotFound", + module: "FileSystem", + method: "readFile", + pathOrDescriptor: path, + }), + ), + open: (path) => + Effect.fail( + PlatformError.systemError({ + _tag: "AlreadyExists", + module: "FileSystem", + method: "open", + pathOrDescriptor: path, + }), + ), + } satisfies FileSystem.FileSystem; + }), +).pipe(Layer.provide(NodeServices.layer)); + +const makeConcurrentCreationWithoutReadableSecretStoreLayer = () => + ServerSecretStore.layer.pipe( + Layer.provide(makeServerConfigLayer()), + Layer.provideMerge(ConcurrentCreationWithoutReadableSecretFileSystemLayer), + ); + 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; @@ -186,6 +260,25 @@ it.layer(NodeServices.layer)("ServerSecretStore.layer", (it) => { }).pipe(Effect.provide(makeConcurrentCreateSecretStoreLayer())), ); + it.effect("preserves the persist failure when a concurrent secret remains unreadable", () => + Effect.gen(function* () { + const secretStore = yield* ServerSecretStore.ServerSecretStore; + + const error = yield* Effect.flip(secretStore.getOrCreateRandom("session-signing-key", 32)); + + assert.instanceOf(error, ServerSecretStore.SecretStoreConcurrentReadError); + assert.equal(error.secretName, "session-signing-key"); + assert.equal( + error.message, + "Failed to read secret session-signing-key after concurrent creation.", + ); + assert.instanceOf(error.cause, ServerSecretStore.SecretStorePersistError); + assert.equal(error.cause.operation, "create"); + assert.instanceOf(error.cause.cause, PlatformError.PlatformError); + assert.equal(error.cause.cause.reason._tag, "AlreadyExists"); + }).pipe(Effect.provide(makeConcurrentCreationWithoutReadableSecretStoreLayer())), + ); + it.effect("uses restrictive permissions for the secret directory and files", () => Effect.gen(function* () { const chmodCalls: Array<{ readonly path: string; readonly mode: number }> = []; @@ -232,7 +325,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 +341,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 +356,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..7608d3ce577 100644 --- a/apps/server/src/auth/ServerSecretStore.ts +++ b/apps/server/src/auth/ServerSecretStore.ts @@ -11,114 +11,137 @@ 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, + cause: Schema.Defect(), }, ) { 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 +161,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 +190,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 +211,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 +255,9 @@ export const make = Effect.gen(function* () { Effect.flatMap(() => Effect.fail( new SecretStorePersistError({ - resource: `secret ${name}`, + operation: "set", + secretName: name, + secretPath, cause, }), ), @@ -237,7 +286,9 @@ export const make = Effect.gen(function* () { Effect.mapError( (cause) => new SecretStorePersistError({ - resource: `secret ${name}`, + operation: "create", + secretName: name, + secretPath, cause, }), ), @@ -254,30 +305,33 @@ 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, + cause: error, + }), + ), + }), + ), + ) + : Effect.fail(error), + }), ), ), ), @@ -286,20 +340,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 fa75c407b0c..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", @@ -15,10 +17,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 +35,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/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/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..f10a166cec4 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,35 @@ 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, + cause: error, + }), + ), + }), + ), + ) + : Effect.fail(error), + }), ); }); diff --git a/apps/server/src/cloud/http.test.ts b/apps/server/src/cloud/http.test.ts index ed2e5a4cf75..26b4646fea6 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,56 @@ 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, + input.secretStore ?? 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 +165,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 +310,127 @@ 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), + }), + ); + + 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" } }, + ), + ), ), - Effect.provideService( - ServerEnvironment.ServerEnvironment, - ServerEnvironment.ServerEnvironment.of({ - getEnvironmentId: unusedSecretStoreOperation(), - getDescriptor: unusedSecretStoreOperation(), + ); + 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" }, }), - ), - 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(), + ); + + 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> = []; + 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..5effca5340f 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: { @@ -564,8 +723,20 @@ 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", url: `${relayUrl}/v1/client/environment-links`, token: token.accessToken, payload: { @@ -576,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, @@ -584,18 +763,31 @@ 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.catchIf( - ServerSecretStore.isSecretStoreError, - failEnvironmentCloudInternalError("Could not persist desired T3 Connect link state."), - ), Effect.catchTags({ - CloudCliCredentialRemovalError: failCloudCliTokenManagerError, - CloudCliCredentialRefreshError: failCloudCliTokenManagerError, - CloudCliCredentialReadError: failCloudCliTokenManagerError, - CloudCliAuthorizationError: failCloudCliTokenManagerError, - CloudCliAuthorizationTimeoutError: failCloudCliTokenManagerError, + 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), + CloudCliCredentialRefreshError: (error) => + failEnvironmentCloudInternalError("refresh_cloud_cli_credential")(error), }), ); @@ -633,10 +825,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 +849,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 +870,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 +927,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 +941,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 +984,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 +1055,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 +1069,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 +1114,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/apps/server/src/http.test.ts b/apps/server/src/http.test.ts index a1d4230e75c..0e1f56c8e53 100644 --- a/apps/server/src/http.test.ts +++ b/apps/server/src/http.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from "vite-plus/test"; -import { isLoopbackHostname, resolveDevRedirectUrl } from "./http.ts"; +import { + BrowserOtlpTraceCollectionError, + BrowserOtlpTraceDecodeError, + BrowserOtlpTraceExportError, + isLoopbackHostname, + resolveDevRedirectUrl, +} from "./http.ts"; describe("http dev routing", () => { it("treats localhost and loopback addresses as local", () => { @@ -25,3 +31,73 @@ describe("http dev routing", () => { ); }); }); + +describe("browser OTLP diagnostics", () => { + it("retains decode causes without retaining the trace payload", () => { + const cause = new Error("private trace payload detail"); + const error = BrowserOtlpTraceDecodeError.fromPayload({ resourceSpans: [] }, cause); + + expect(error).toMatchObject({ + resourceSpanCount: 0, + cause, + message: "Failed to decode browser OTLP payload with 0 resource spans.", + }); + expect(error.cause).toBe(cause); + expect(error).not.toHaveProperty("bodyJson"); + }); + + it("describes malformed trace payloads without inspecting unsafe fields", () => { + const cause = new Error("private malformed trace payload detail"); + const error = BrowserOtlpTraceDecodeError.fromPayload( + { resourceSpans: "private malformed resource spans" }, + cause, + ); + + expect(error).toMatchObject({ + resourceSpanCount: 0, + cause, + message: "Failed to decode browser OTLP payload with 0 resource spans.", + }); + expect(error.cause).toBe(cause); + expect(error).not.toHaveProperty("resourceSpans"); + }); + + it("retains local collection causes with a structural record count", () => { + const records = [{ type: "private trace record" }]; + const cause = new Error("private local collector detail"); + const error = BrowserOtlpTraceCollectionError.fromRecords(records, cause); + + expect(error).toMatchObject({ + recordCount: 1, + cause, + message: "Failed to collect 1 browser OTLP trace records locally.", + }); + expect(error.cause).toBe(cause); + expect(error).not.toHaveProperty("records"); + }); + + it("redacts collector URL credentials while retaining the exact cause", () => { + const collectorUrl = + "https://collector-user:collector-password@traces.example.test/private/v1/traces?access_token=collector-secret#collector-fragment"; + const cause = new Error("collector transport failed"); + const error = BrowserOtlpTraceExportError.fromCollectorUrl(collectorUrl, cause); + + expect(error).toMatchObject({ + collectorUrlInputLength: collectorUrl.length, + collectorUrlProtocol: "https:", + collectorUrlHostname: "traces.example.test", + cause, + }); + expect(error.cause).toBe(cause); + expect(error).not.toHaveProperty("collectorUrl"); + for (const secret of [ + "collector-user", + "collector-password", + "/private/v1/traces", + "collector-secret", + "collector-fragment", + ]) { + expect(error.message).not.toContain(secret); + } + }); +}); diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index ce9b498cb1f..d7e88d62d1e 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -5,12 +5,13 @@ import { EnvironmentHttpApi, } from "@t3tools/contracts"; import { decodeOtlpTraceRecords } from "@t3tools/shared/observability"; -import * as Data from "effect/Data"; +import { getUrlDiagnostics } from "@t3tools/shared/urlDiagnostics"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; import { cast } from "effect/Function"; import { HttpBody, @@ -35,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"; @@ -80,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); @@ -108,10 +103,95 @@ export const serverEnvironmentHttpApiLayer = HttpApiBuilder.group( }), ); -class DecodeOtlpTraceRecordsError extends Data.TaggedError("DecodeOtlpTraceRecordsError")<{ - readonly cause: unknown; - readonly bodyJson: OtlpTracer.TraceData; -}> {} +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; +} + +export class BrowserOtlpTraceDecodeError extends Schema.TaggedErrorClass()( + "BrowserOtlpTraceDecodeError", + { + resourceSpanCount: Schema.Number, + cause: Schema.Defect(), + }, +) { + static fromPayload(payload: unknown, cause: unknown): BrowserOtlpTraceDecodeError { + const resourceSpanCount = + typeof payload === "object" && + payload !== null && + "resourceSpans" in payload && + Array.isArray(payload.resourceSpans) + ? payload.resourceSpans.length + : 0; + + return new BrowserOtlpTraceDecodeError({ + resourceSpanCount, + cause, + }); + } + + override get message(): string { + return `Failed to decode browser OTLP payload with ${this.resourceSpanCount} resource spans.`; + } +} + +export class BrowserOtlpTraceCollectionError extends Schema.TaggedErrorClass()( + "BrowserOtlpTraceCollectionError", + { + recordCount: Schema.Number, + cause: Schema.Defect(), + }, +) { + static fromRecords( + records: ReadonlyArray, + cause: unknown, + ): BrowserOtlpTraceCollectionError { + return new BrowserOtlpTraceCollectionError({ + recordCount: records.length, + cause, + }); + } + + override get message(): string { + return `Failed to collect ${this.recordCount} browser OTLP trace records locally.`; + } +} + +export class BrowserOtlpTraceExportError extends Schema.TaggedErrorClass()( + "BrowserOtlpTraceExportError", + { + collectorUrlInputLength: Schema.Number, + collectorUrlProtocol: Schema.optionalKey(Schema.String), + collectorUrlHostname: Schema.optionalKey(Schema.String), + cause: Schema.Defect(), + }, +) { + static fromCollectorUrl(collectorUrl: string, cause: unknown): BrowserOtlpTraceExportError { + const diagnostics = getUrlDiagnostics(collectorUrl); + return new BrowserOtlpTraceExportError({ + collectorUrlInputLength: diagnostics.inputLength, + ...(diagnostics.protocol === undefined ? {} : { collectorUrlProtocol: diagnostics.protocol }), + ...(diagnostics.hostname === undefined ? {} : { collectorUrlHostname: diagnostics.hostname }), + cause, + }); + } + + override get message(): string { + const collector = + this.collectorUrlHostname === undefined + ? "the configured collector" + : this.collectorUrlHostname; + return `Failed to export browser OTLP traces to ${collector} (collector URL input length ${this.collectorUrlInputLength}).`; + } +} export const otlpTracesProxyRouteLayer = HttpRouter.add( "POST", @@ -127,15 +207,31 @@ export const otlpTracesProxyRouteLayer = HttpRouter.add( yield* Effect.try({ try: () => decodeOtlpTraceRecords(bodyJson), - catch: (cause) => new DecodeOtlpTraceRecordsError({ cause, bodyJson }), + catch: (cause) => BrowserOtlpTraceDecodeError.fromPayload(bodyJson, cause), }).pipe( - Effect.flatMap((records) => browserTraceCollector.record(records)), - Effect.catch((cause) => - Effect.logWarning("Failed to decode browser OTLP traces", { - cause, - bodyJson, - }), + Effect.flatMap((records) => + browserTraceCollector + .record(records) + .pipe( + Effect.catchDefect((cause) => + Effect.fail(BrowserOtlpTraceCollectionError.fromRecords(records, cause)), + ), + ), ), + Effect.catchTags({ + BrowserOtlpTraceDecodeError: (error) => + Effect.logWarning(error.message, { + errorTag: error._tag, + resourceSpanCount: error.resourceSpanCount, + causeTag: errorDiagnosticTag(error.cause), + }), + BrowserOtlpTraceCollectionError: (error) => + Effect.logWarning(error.message, { + errorTag: error._tag, + recordCount: error.recordCount, + causeTag: errorDiagnosticTag(error.cause), + }), + }), ); if (otlpTracesUrl === undefined) { @@ -149,15 +245,23 @@ export const otlpTracesProxyRouteLayer = HttpRouter.add( .pipe( Effect.flatMap(HttpClientResponse.filterStatusOk), Effect.as(HttpServerResponse.empty({ status: 204 })), - Effect.tapError((cause) => - Effect.logWarning("Failed to export browser OTLP traces", { - cause, - otlpTracesUrl, - }), - ), - Effect.orElseSucceed(() => - HttpServerResponse.text("Trace export failed.", { status: 502 }), + Effect.mapError((cause) => + BrowserOtlpTraceExportError.fromCollectorUrl(otlpTracesUrl, cause), ), + Effect.catchTags({ + BrowserOtlpTraceExportError: (error) => + Effect.logWarning(error.message, { + errorTag: error._tag, + collectorUrlInputLength: error.collectorUrlInputLength, + ...(error.collectorUrlProtocol === undefined + ? {} + : { collectorUrlProtocol: error.collectorUrlProtocol }), + ...(error.collectorUrlHostname === undefined + ? {} + : { collectorUrlHostname: error.collectorUrlHostname }), + causeTag: errorDiagnosticTag(error.cause), + }).pipe(Effect.as(HttpServerResponse.text("Trace export failed.", { status: 502 }))), + }), ); }).pipe( Effect.catchTags({ diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index ba95422735c..9884552a05f 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -1,17 +1,16 @@ import { KeybindingCommand, KeybindingRule, KeybindingsConfig } from "@t3tools/contracts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; -import { assertFailure } from "@effect/vitest/utils"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Logger from "effect/Logger"; import * as Path from "effect/Path"; +import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import * as ServerConfig from "./config.ts"; import * as Keybindings from "./keybindings.ts"; -import { KeybindingsConfigError } from "@t3tools/contracts"; const KeybindingsConfigJson = Schema.fromJsonString(KeybindingsConfig); const encodeKeybindingsConfigJson = Schema.encodeEffect(KeybindingsConfigJson); @@ -34,12 +33,6 @@ const makeKeybindingsLayer = () => { ); }; -const toDetailResult = (effect: Effect.Effect) => - effect.pipe( - Effect.mapError((error) => error.detail), - Effect.result, - ); - const writeKeybindingsConfig = (configPath: string, rules: readonly KeybindingRule[]) => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -223,15 +216,21 @@ it.layer(NodeServices.layer)("keybindings", (it) => { assert.deepEqual(configState.issues, [ { kind: "keybindings.malformed-config", - message: configState.issues[0]?.message ?? "", + message: "Expected the keybindings configuration to be a JSON array.", }, ]); assert.equal(yield* fs.readFileString(keybindingsConfigPath), "{ not-json"); }).pipe(Effect.provide(makeKeybindingsLayer())), ); - it.effect("ignores invalid entries in runtime and reports them as issues", () => - Effect.gen(function* () { + it.effect("ignores invalid entries in runtime and reports them as issues", () => { + const logs: ReadonlyArray[] = []; + const logger = Logger.make(({ message }) => { + logs.push(Array.isArray(message) ? message : [message]); + }); + const secret = "private-shortcut-payload"; + + return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig; yield* fs.writeFileString( @@ -240,7 +239,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { JSON.stringify([ { key: "mod+j", command: "terminal.toggle" }, { key: "mod+shift+d+o", command: "terminal.new" }, - { key: "mod+x", command: "invalid.command" }, + { key: "mod+x", command: secret }, ]), ); @@ -250,23 +249,55 @@ it.layer(NodeServices.layer)("keybindings", (it) => { }); assert.isTrue(configState.keybindings.some((entry) => entry.command === "terminal.toggle")); - assert.isFalse( - configState.keybindings.some((entry) => String(entry.command) === "invalid.command"), - ); + assert.isFalse(configState.keybindings.some((entry) => String(entry.command) === secret)); assert.deepEqual(configState.issues, [ { kind: "keybindings.invalid-entry", index: 1, - message: configState.issues[0]?.message ?? "", + message: "The keybinding entry contains an invalid shortcut or when expression.", }, { kind: "keybindings.invalid-entry", index: 2, - message: configState.issues[1]?.message ?? "", + message: "Expected a keybinding entry with key, command, and optional when fields.", }, ]); - }).pipe(Effect.provide(makeKeybindingsLayer())), - ); + const invalidEntryLog = logs.find((log) => { + const attributes = log[1]; + return ( + String(log[0]).includes("ignoring invalid keybinding entry") && + typeof attributes === "object" && + attributes !== null && + Reflect.get(attributes, "entryIndex") === 2 + ); + }); + if (!invalidEntryLog) { + return assert.fail("Expected invalid keybinding warning"); + } + const attributes = invalidEntryLog[1]; + if (typeof attributes !== "object" || attributes === null) { + return assert.fail("Expected structured invalid keybinding attributes"); + } + assert.equal(Reflect.get(attributes, "validationStage"), "entry-schema"); + assert.equal(Reflect.get(attributes, "validationInputKind"), "object"); + assert.equal(Reflect.get(attributes, "validationInputSize"), 2); + assert.equal(Reflect.get(attributes, "validationHasKeyField"), true); + assert.equal(Reflect.get(attributes, "validationHasCommandField"), true); + assert.equal(Reflect.get(attributes, "validationHasWhenField"), false); + assert.equal(Reflect.get(attributes, "causeReasonCount"), 1); + assert.isFalse("entry" in attributes); + assert.isFalse("cause" in attributes); + assert.isFalse(String(invalidEntryLog[0]).includes(secret)); + assert.isFalse(Object.values(attributes).some((value) => String(value).includes(secret))); + }).pipe( + Effect.provide( + Layer.mergeAll( + makeKeybindingsLayer(), + Logger.layer([logger], { mergeWithExisting: false }), + ), + ), + ); + }); it.effect( "upserts missing default keybindings on startup without overriding existing command rules", @@ -301,9 +332,9 @@ it.layer(NodeServices.layer)("keybindings", (it) => { ); it.effect("skips conflicting default keybindings on startup and logs a detailed warning", () => { - const messages: string[] = []; + const logs: ReadonlyArray[] = []; const logger = Logger.make(({ message }) => { - messages.push(String(message)); + logs.push(Array.isArray(message) ? message : [message]); }); return Effect.gen(function* () { @@ -321,11 +352,21 @@ it.layer(NodeServices.layer)("keybindings", (it) => { assert.isFalse(persisted.some((entry) => entry.command === "terminal.toggle")); assert.isTrue(persisted.some((entry) => entry.command === "script.custom-action.run")); - assert.isTrue( - messages.some((message) => - message.includes("skipping default keybinding due to shortcut conflict"), - ), + const warning = logs.find((log) => + String(log[0]).includes("skipping default keybinding due to shortcut conflict"), ); + if (!warning) { + return assert.fail("Expected shortcut conflict warning"); + } + const attributes = warning[1]; + if (typeof attributes !== "object" || attributes === null) { + return assert.fail("Expected structured shortcut conflict attributes"); + } + assert.equal(Reflect.get(attributes, "defaultCommand"), "terminal.toggle"); + assert.equal(Reflect.get(attributes, "conflictingCommand"), "script.custom-action.run"); + assert.equal(Reflect.get(attributes, "hasWhenContext"), false); + assert.isFalse("key" in attributes); + assert.isFalse("when" in attributes); }).pipe( Effect.provide( Layer.mergeAll( @@ -443,15 +484,24 @@ it.layer(NodeServices.layer)("keybindings", (it) => { key: "mod+shift+r", command: "script.run-tests.run", }); - }).pipe(toDetailResult); - assertFailure(result, "expected JSON array"); + }).pipe(Effect.result); + if (Result.isSuccess(result)) { + return assert.fail("Expected malformed config update to fail"); + } + assert.equal(result.failure._tag, "KeybindingsConfigError"); + assert.equal(result.failure.operation, "decode"); + assert.isTrue(Schema.isSchemaError(result.failure.cause)); + assert.equal( + result.failure.message, + `Keybindings config operation 'decode' failed at ${keybindingsConfigPath}.`, + ); const persistedRaw = yield* fs.readFileString(keybindingsConfigPath); assert.equal(persistedRaw, "{ not-json"); }).pipe(Effect.provide(makeKeybindingsLayer())), ); - it.effect("reports non-array config parse errors without duplicate prefix", () => + it.effect("returns stable structured decode errors across retries", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig; @@ -466,8 +516,12 @@ it.layer(NodeServices.layer)("keybindings", (it) => { key: "mod+shift+r", command: "script.run-tests.run", }); - }).pipe(toDetailResult); - assertFailure(firstResult, "expected JSON array"); + }).pipe(Effect.result); + if (Result.isSuccess(firstResult)) { + return assert.fail("Expected first malformed config update to fail"); + } + assert.equal(firstResult.failure.operation, "decode"); + assert.isTrue(Schema.isSchemaError(firstResult.failure.cause)); const secondResult = yield* Effect.gen(function* () { const keybindings = yield* Keybindings.Keybindings; @@ -475,8 +529,13 @@ it.layer(NodeServices.layer)("keybindings", (it) => { key: "mod+shift+r", command: "script.run-tests.run", }); - }).pipe(toDetailResult); - assertFailure(secondResult, "expected JSON array"); + }).pipe(Effect.result); + if (Result.isSuccess(secondResult)) { + return assert.fail("Expected second malformed config update to fail"); + } + assert.equal(secondResult.failure.operation, "decode"); + assert.isTrue(Schema.isSchemaError(secondResult.failure.cause)); + assert.equal(secondResult.failure.message, firstResult.failure.message); }).pipe(Effect.provide(makeKeybindingsLayer())), ); @@ -496,8 +555,11 @@ it.layer(NodeServices.layer)("keybindings", (it) => { key: "mod+shift+r", command: "script.run-tests.run", }); - }).pipe(toDetailResult); - assertFailure(result, "failed to write keybindings config"); + }).pipe(Effect.result); + if (Result.isSuccess(result)) { + return assert.fail("Expected update in a read-only directory to fail"); + } + assert.equal(result.failure.operation, "write"); yield* fs.chmod(dirname(keybindingsConfigPath), 0o700); diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index 5ddae4943f8..baca9a6d71d 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -44,6 +44,7 @@ import * as Semaphore from "effect/Semaphore"; import * as ServerConfig from "./config.ts"; import { writeFileStringAtomically } from "./atomicWrite.ts"; import { fromJsonStringPretty, fromLenientJson } from "@t3tools/shared/schemaJson"; +import { causeErrorTag } from "@t3tools/shared/observability"; import { DEFAULT_KEYBINDINGS, DEFAULT_RESOLVED_KEYBINDINGS, @@ -186,23 +187,53 @@ export interface KeybindingsChangeEvent { readonly issues: readonly ServerConfigIssue[]; } -function trimIssueMessage(message: string): string { - const trimmed = message.trim(); - return trimmed.length > 0 ? trimmed : "Invalid keybindings configuration."; -} +const MALFORMED_KEYBINDINGS_CONFIG_MESSAGE = + "Expected the keybindings configuration to be a JSON array."; +const INVALID_KEYBINDING_ENTRY_MESSAGE = + "Expected a keybinding entry with key, command, and optional when fields."; +const INVALID_KEYBINDING_RULE_MESSAGE = + "The keybinding entry contains an invalid shortcut or when expression."; -function malformedConfigIssue(detail: string): ServerConfigIssue { +function keybindingsCauseLogAttributes(cause: Cause.Cause) { return { - kind: "keybindings.malformed-config", - message: trimIssueMessage(detail), + errorTag: causeErrorTag(cause), + causeReasonCount: cause.reasons.length, + causeFailureCount: cause.reasons.filter(Cause.isFailReason).length, + causeDefectCount: cause.reasons.filter(Cause.isDieReason).length, + causeInterruptionCount: cause.reasons.filter(Cause.isInterruptReason).length, }; } -function invalidEntryIssue(index: number, detail: string): ServerConfigIssue { +function keybindingsValidationInputLogAttributes(value: unknown) { + if (typeof value === "string") { + return { validationInputKind: "string", validationInputSize: value.length }; + } + if (Array.isArray(value)) { + return { validationInputKind: "array", validationInputSize: value.length }; + } + if (typeof value === "object" && value !== null) { + return { + validationInputKind: "object", + validationInputSize: Object.keys(value).length, + validationHasKeyField: Object.hasOwn(value, "key"), + validationHasCommandField: Object.hasOwn(value, "command"), + validationHasWhenField: Object.hasOwn(value, "when"), + }; + } + return { validationInputKind: value === null ? "null" : typeof value }; +} + +function keybindingsValidationLogAttributes(input: { + readonly stage: "document" | "entry-schema" | "resolved-rule"; + readonly value: unknown; + readonly cause: Cause.Cause; + readonly index?: number; +}) { return { - kind: "keybindings.invalid-entry", - index, - message: trimIssueMessage(detail), + validationStage: input.stage, + ...(input.index === undefined ? {} : { entryIndex: input.index }), + ...keybindingsValidationInputLogAttributes(input.value), + ...keybindingsCauseLogAttributes(input.cause), }; } @@ -306,7 +337,7 @@ const make = Effect.gen(function* () { (cause) => new KeybindingsConfigError({ configPath: keybindingsConfigPath, - detail: "failed to access keybindings config", + operation: "access", cause, }), ), @@ -317,7 +348,7 @@ const make = Effect.gen(function* () { (cause) => new KeybindingsConfigError({ configPath: keybindingsConfigPath, - detail: "failed to read keybindings config", + operation: "read", cause, }), ), @@ -337,20 +368,24 @@ const make = Effect.gen(function* () { (cause) => new KeybindingsConfigError({ configPath: keybindingsConfigPath, - detail: "expected JSON array", + operation: "decode", cause, }), ), ); - return yield* Effect.forEach(rawConfig, (entry) => + return yield* Effect.forEach(rawConfig, (entry, index) => Effect.gen(function* () { const decodedRule = decodeKeybindingRuleExit(entry); if (decodedRule._tag === "Failure") { yield* Effect.logWarning("ignoring invalid keybinding entry", { path: keybindingsConfigPath, - entry, - error: Cause.pretty(decodedRule.cause), + ...keybindingsValidationLogAttributes({ + stage: "entry-schema", + value: entry, + cause: decodedRule.cause, + index, + }), }); return null; } @@ -358,8 +393,12 @@ const make = Effect.gen(function* () { if (resolved._tag === "Failure") { yield* Effect.logWarning("ignoring invalid keybinding entry", { path: keybindingsConfigPath, - entry, - error: Cause.pretty(resolved.cause), + ...keybindingsValidationLogAttributes({ + stage: "resolved-rule", + value: entry, + cause: resolved.cause, + index, + }), }); return null; } @@ -382,10 +421,22 @@ const make = Effect.gen(function* () { const rawConfig = yield* readRawConfig; const decodedEntries = decodeRawKeybindingsEntriesExit(rawConfig); if (decodedEntries._tag === "Failure") { - const detail = `expected JSON array (${Cause.pretty(decodedEntries.cause)})`; + yield* Effect.logWarning("ignoring malformed keybindings config", { + path: keybindingsConfigPath, + ...keybindingsValidationLogAttributes({ + stage: "document", + value: rawConfig, + cause: decodedEntries.cause, + }), + }); return { keybindings: [], - issues: [malformedConfigIssue(detail)], + issues: [ + { + kind: "keybindings.malformed-config", + message: MALFORMED_KEYBINDINGS_CONFIG_MESSAGE, + }, + ], }; } @@ -394,26 +445,38 @@ const make = Effect.gen(function* () { for (const [index, entry] of decodedEntries.value.entries()) { const decodedRule = decodeKeybindingRuleExit(entry); if (decodedRule._tag === "Failure") { - const detail = Cause.pretty(decodedRule.cause); - issues.push(invalidEntryIssue(index, detail)); + issues.push({ + kind: "keybindings.invalid-entry", + index, + message: INVALID_KEYBINDING_ENTRY_MESSAGE, + }); yield* Effect.logWarning("ignoring invalid keybinding entry", { path: keybindingsConfigPath, - index, - entry, - error: detail, + ...keybindingsValidationLogAttributes({ + stage: "entry-schema", + value: entry, + cause: decodedRule.cause, + index, + }), }); continue; } const resolvedRule = decodeResolvedKeybindingFromConfigExit(decodedRule.value); if (resolvedRule._tag === "Failure") { - const detail = Cause.pretty(resolvedRule.cause); - issues.push(invalidEntryIssue(index, detail)); + issues.push({ + kind: "keybindings.invalid-entry", + index, + message: INVALID_KEYBINDING_RULE_MESSAGE, + }); yield* Effect.logWarning("ignoring invalid keybinding entry", { path: keybindingsConfigPath, - index, - entry, - error: detail, + ...keybindingsValidationLogAttributes({ + stage: "resolved-rule", + value: entry, + cause: resolvedRule.cause, + index, + }), }); continue; } @@ -425,6 +488,14 @@ const make = Effect.gen(function* () { const writeConfigAtomically = (rules: readonly KeybindingRule[]) => { return encodeKeybindingsConfigPrettyJson(rules).pipe( + Effect.mapError( + (cause) => + new KeybindingsConfigError({ + configPath: keybindingsConfigPath, + operation: "encode", + cause, + }), + ), Effect.map((encoded) => `${encoded}\n`), Effect.flatMap((encoded) => writeFileStringAtomically({ @@ -433,16 +504,16 @@ const make = Effect.gen(function* () { }).pipe( Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), + Effect.mapError( + (cause) => + new KeybindingsConfigError({ + configPath: keybindingsConfigPath, + operation: "write", + cause, + }), + ), ), ), - Effect.mapError( - (cause) => - new KeybindingsConfigError({ - configPath: keybindingsConfigPath, - detail: "failed to write keybindings config", - cause, - }), - ), ); }; @@ -487,7 +558,13 @@ const make = Effect.gen(function* () { "skipping startup keybindings default sync because config has issues", { path: keybindingsConfigPath, - issues: runtimeConfig.issues, + issueCount: runtimeConfig.issues.length, + malformedConfigIssueCount: runtimeConfig.issues.filter( + (issue) => issue.kind === "keybindings.malformed-config", + ).length, + invalidEntryIssueCount: runtimeConfig.issues.filter( + (issue) => issue.kind === "keybindings.invalid-entry", + ).length, }, ); yield* Cache.invalidate(resolvedConfigCache, resolvedConfigCacheKey); @@ -499,8 +576,7 @@ const make = Effect.gen(function* () { const shortcutConflictWarnings: Array<{ defaultCommand: KeybindingRule["command"]; conflictingCommand: KeybindingRule["command"]; - key: string; - when: string | null; + hasWhenContext: boolean; }> = []; for (const defaultRule of DEFAULT_KEYBINDINGS) { if (existingCommands.has(defaultRule.command)) { @@ -513,8 +589,7 @@ const make = Effect.gen(function* () { shortcutConflictWarnings.push({ defaultCommand: defaultRule.command, conflictingCommand: conflictingEntry.command, - key: defaultRule.key, - when: defaultRule.when ?? null, + hasWhenContext: defaultRule.when !== undefined, }); continue; } @@ -525,8 +600,7 @@ const make = Effect.gen(function* () { path: keybindingsConfigPath, defaultCommand: conflict.defaultCommand, conflictingCommand: conflict.conflictingCommand, - key: conflict.key, - when: conflict.when, + hasWhenContext: conflict.hasWhenContext, reason: "shortcut context already used by existing rule", }); } @@ -574,13 +648,22 @@ const make = Effect.gen(function* () { (cause) => new KeybindingsConfigError({ configPath: keybindingsConfigPath, - detail: "failed to prepare keybindings config directory", + operation: "prepare-directory", cause, }), ), ); - const revalidateAndEmitSafely = revalidateAndEmit.pipe(Effect.ignoreCause({ log: true })); + const revalidateAndEmitSafely = revalidateAndEmit.pipe( + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.void + : Effect.logWarning("keybindings config revalidation failed", { + path: keybindingsConfigPath, + ...keybindingsCauseLogAttributes(cause), + }), + ), + ); // Debounce watch events so the file is fully written before we read it. // Editors emit multiple events per save (truncate, write, rename) and @@ -597,7 +680,14 @@ const make = Effect.gen(function* () { ); yield* Stream.runForEach(debouncedKeybindingsEvents, () => revalidateAndEmitSafely).pipe( - Effect.ignoreCause({ log: true }), + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.void + : Effect.logWarning("keybindings config watcher failed", { + path: keybindingsConfigPath, + ...keybindingsCauseLogAttributes(cause), + }), + ), Effect.forkIn(watcherScope), Effect.asVoid, ); diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index 40ed694723d..79aa1bebe99 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -20,6 +20,7 @@ import { CommandId, ProviderInstanceId } from "@t3tools/contracts"; import { RelayClientTracer } from "@t3tools/shared/relayTracing"; import { RELAY_ACTIVITY_PUBLISH_TYP, verifyRelayJwt } from "@t3tools/shared/relayJwt"; import { describe, expect, it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -98,6 +99,50 @@ function makeMemorySecretStore() { } describe.sequential("signRelayAgentActivityPublishProof", () => { + it("redacts relay URL secrets from log attributes", () => { + const relayUrl = + "https://relay-user:relay-password@relay.example.test/private/path?token=relay-secret#fragment"; + const attributes = AgentAwarenessRelay.relayUrlLogAttributes(relayUrl); + + expect(attributes).toEqual({ + relayUrlConfigured: true, + relayUrlInputLength: relayUrl.length, + relayUrlProtocol: "https:", + relayUrlHostname: "relay.example.test", + }); + const renderedAttributes = Object.values(attributes).join(" "); + expect(renderedAttributes).not.toContain("relay-user"); + expect(renderedAttributes).not.toContain("relay-password"); + expect(renderedAttributes).not.toContain("private/path"); + expect(renderedAttributes).not.toContain("relay-secret"); + expect(renderedAttributes).not.toContain("fragment"); + }); + + it("summarizes publish causes without serializing nested failures or defects", () => { + const privateFailureDetail = "private relay response body"; + const privateDefectDetail = "private relay defect detail"; + const cause = Cause.combine( + Cause.fail({ + _tag: "RelayPublishError", + cause: new Error(privateFailureDetail), + detail: privateFailureDetail, + }), + Cause.die(new Error(privateDefectDetail)), + ); + const attributes = AgentAwarenessRelay.relayPublishCauseLogAttributes(cause); + + expect(attributes).toEqual({ + causeReasonCount: 2, + causeFailureCount: 1, + causeDefectCount: 1, + causeInterruptionCount: 0, + causeFailureTags: ["RelayPublishError"], + }); + const renderedAttributes = Object.values(attributes).join(" "); + expect(renderedAttributes).not.toContain(privateFailureDetail); + expect(renderedAttributes).not.toContain(privateDefectDetail); + }); + it("distinguishes pending link credentials from disabled publication", () => { expect( AgentAwarenessRelay.resolveAgentActivityPublishingStartupState({ diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index 4e036e3ea0e..0b955bcd5c6 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -18,6 +18,7 @@ import { RELAY_ACTIVITY_PUBLISH_TYP, signRelayJwt, } from "@t3tools/shared/relayJwt"; +import { getUrlDiagnostics } from "@t3tools/shared/urlDiagnostics"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; @@ -98,6 +99,44 @@ export function isAgentActivityPublishingEnabled(value: string | null): boolean return value === "true"; } +export function relayUrlLogAttributes(relayUrl: string | undefined) { + if (relayUrl === undefined) { + return { relayUrlConfigured: false }; + } + const diagnostics = getUrlDiagnostics(relayUrl); + return { + relayUrlConfigured: true, + relayUrlInputLength: diagnostics.inputLength, + ...(diagnostics.protocol === undefined ? {} : { relayUrlProtocol: diagnostics.protocol }), + ...(diagnostics.hostname === undefined ? {} : { relayUrlHostname: diagnostics.hostname }), + }; +} + +export function relayPublishCauseLogAttributes(cause: Cause.Cause) { + const failureTags = cause.reasons.flatMap((reason) => { + if (!Cause.isFailReason(reason)) { + return []; + } + const error = reason.error; + if ( + typeof error !== "object" || + error === null || + !("_tag" in error) || + typeof error._tag !== "string" + ) { + return []; + } + return [error._tag]; + }); + return { + causeReasonCount: cause.reasons.length, + causeFailureCount: cause.reasons.filter(Cause.isFailReason).length, + causeDefectCount: cause.reasons.filter(Cause.isDieReason).length, + causeInterruptionCount: cause.reasons.filter(Cause.isInterruptReason).length, + causeFailureTags: [...new Set(failureTags)], + }; +} + export function resolveAgentActivityPublishingStartupState(input: { readonly relayConfigured: boolean; readonly publishEnabled: boolean; @@ -417,12 +456,12 @@ export const make = Effect.gen(function* () { const publishThread: AgentAwarenessRelay["Service"]["publishThread"] = (threadId) => publishThreadUnsafe(threadId).pipe( - Effect.catchCause((cause) => { - return Effect.logWarning("agent activity publish failed", { + Effect.catchCause((cause) => + Effect.logWarning("agent activity publish failed", { threadId, - cause: Cause.pretty(cause), - }); - }), + ...relayPublishCauseLogAttributes(cause), + }), + ), Effect.withSpan("AgentAwarenessRelay.publishThread"), withRelayClientTracing, ); @@ -467,7 +506,7 @@ export const make = Effect.gen(function* () { if (logEnabledWhenReady) { const relayConfig = yield* readRelayConfig.pipe(Effect.orElseSucceed(() => null)); yield* Effect.logInfo("agent activity publishing enabled after link reconciliation", { - relayUrl: relayConfig?.url, + ...relayUrlLogAttributes(relayConfig?.url), }); } return; @@ -499,7 +538,7 @@ export const make = Effect.gen(function* () { break; case "enabled": yield* Effect.logInfo("agent activity publishing enabled", { - relayUrl: relayConfig?.url, + ...relayUrlLogAttributes(relayConfig?.url), }); break; } diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index e1daf20ed57..9feef4e5200 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -4027,6 +4027,31 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("continues browser OTLP requests when local trace collection defects", () => + Effect.gen(function* () { + const payload = yield* makeBrowserOtlpPayload("client.test"); + + yield* buildAppUnderTest({ + layers: { + browserTraceCollector: { + record: () => Effect.die(new Error("private local collector failure")), + }, + }, + }); + + const response = yield* HttpClient.post("/api/observability/v1/traces", { + headers: { + cookie: yield* getAuthenticatedSessionCookieHeader(), + "content-type": "application/json", + }, + // @effect-diagnostics-next-line preferSchemaOverJson:off + body: HttpBody.text(JSON.stringify(payload), "application/json"), + }); + + assert.equal(response.status, 204); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("routes websocket rpc server.upsertKeybinding", () => Effect.gen(function* () { const rule: KeybindingRule = { diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index b52b577c5b5..b401a1e5df2 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -309,13 +309,14 @@ export const make = Effect.gen(function* () { yield* runStartupPhase( "keybindings.start", keybindings.start.pipe( - Effect.catch((error) => - Effect.logWarning("failed to start keybindings runtime", { - path: error.configPath, - detail: error.detail, - cause: error.cause, - }), - ), + Effect.catchTags({ + KeybindingsConfigError: (error) => + Effect.logWarning("failed to start keybindings runtime", { + path: error.configPath, + operation: error.operation, + errorTag: error._tag, + }), + }), Effect.forkScoped, ), ); 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( diff --git a/apps/server/src/telemetry/AnalyticsService.test.ts b/apps/server/src/telemetry/AnalyticsService.test.ts index d69bab32feb..dd221ff176f 100644 --- a/apps/server/src/telemetry/AnalyticsService.test.ts +++ b/apps/server/src/telemetry/AnalyticsService.test.ts @@ -4,7 +4,11 @@ import { assert, 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 References from "effect/References"; +import * as Schema from "effect/Schema"; import * as HttpServer from "effect/unstable/http/HttpServer"; +import * as HttpClientError from "effect/unstable/http/HttpClientError"; import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; @@ -35,10 +39,25 @@ interface RecordedBatchBody { }>; } +interface CapturedLog { + readonly message: unknown; + readonly annotations: Readonly>; +} + +const isAnalyticsBatchDeliveryError = Schema.is(AnalyticsService.AnalyticsBatchDeliveryError); + it.layer(NodeServices.layer)("AnalyticsService test", (it) => { - it.effect("flush drains all buffered events across multiple batches", () => + it.effect("flush drains buffered events and retries failed batches with structured context", () => Effect.gen(function* () { const capturedRequests: Array = []; + const capturedLogs: CapturedLog[] = []; + let rejectNextBatch = false; + const logger = Logger.make(({ fiber, message }) => { + capturedLogs.push({ + message, + annotations: fiber.getRef(References.CurrentLogAnnotations), + }); + }); const serverConfigLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), { prefix: "t3-telemetry-base-", }); @@ -66,12 +85,20 @@ it.layer(NodeServices.layer)("AnalyticsService test", (it) => { capturedRequests.push({ path: request.url, body: payload }); + if (rejectNextBatch) { + rejectNextBatch = false; + return HttpServerResponse.empty({ status: 503 }); + } + return HttpServerResponse.jsonUnsafe({}); }), ); - const runtimeLayer = telemetryLayer.pipe( - Layer.provide(configLayer), - Layer.provideMerge(NodeHttpServer.layerTest), + const runtimeLayer = Layer.merge( + telemetryLayer.pipe( + Layer.provide(configLayer), + Layer.provideMerge(NodeHttpServer.layerTest), + ), + Logger.layer([logger], { mergeWithExisting: false }), ); yield* Effect.gen(function* () { @@ -85,12 +112,17 @@ it.layer(NodeServices.layer)("AnalyticsService test", (it) => { } yield* analytics.flush; + yield* analytics.record("test.flush.retry", { index: 45 }); + rejectNextBatch = true; + yield* analytics.flush; + yield* analytics.flush; }).pipe(Effect.provide(runtimeLayer)); - const batchRequests = capturedRequests.filter( - (request): request is RecordedBatchRequest & { readonly body: RecordedBatchBody } => + const batchRequests = capturedRequests + .slice(0, 3) + .filter((request): request is RecordedBatchRequest & { readonly body: RecordedBatchBody } => Array.isArray(request.body?.batch), - ); + ); assert.equal(batchRequests.length, 3); assert.equal( batchRequests.every((request) => request.path === "/batch/" || request.path === "/batch"), @@ -115,6 +147,53 @@ it.layer(NodeServices.layer)("AnalyticsService test", (it) => { ), true, ); + + const retryRequests = capturedRequests.slice(3); + assert.equal(retryRequests.length, 2); + assert.deepEqual(retryRequests[0]?.body, retryRequests[1]?.body); + + const deliveryLog = capturedLogs.find((log) => + isAnalyticsBatchDeliveryError(log.annotations.cause), + ); + assert.isDefined(deliveryLog); + assert.equal( + deliveryLog?.message, + "Failed to deliver 1 analytics event to PostHog (endpoint input length 7).", + ); + assert.equal("endpoint" in deliveryLog.annotations, false); + assert.equal(deliveryLog.annotations.endpointInputLength, 7); + + const error = deliveryLog?.annotations.cause; + assert.instanceOf(error, AnalyticsService.AnalyticsBatchDeliveryError); + if (isAnalyticsBatchDeliveryError(error)) { + assert.equal(error.endpointInputLength, 7); + assert.equal(error.endpointProtocol, undefined); + assert.equal(error.endpointHostname, undefined); + assert.equal(error.eventCount, 1); + assert.instanceOf(error.cause, HttpClientError.HttpClientError); + if (error.cause instanceof HttpClientError.HttpClientError) { + assert.equal(error.cause.reason._tag, "StatusCodeError"); + assert.equal(error.cause.response?.status, 503); + } + } }), ); }); + +it("keeps configured PostHog endpoint secrets out of direct error and log context", () => { + const endpoint = + "https://user:password@posthog.example.test/private/project?api_key=secret#fragment"; + const cause = new Error("delivery failed"); + const error = AnalyticsService.AnalyticsBatchDeliveryError.fromEndpoint({ + endpoint, + eventCount: 2, + cause, + }); + + assert.equal(error.endpointInputLength, endpoint.length); + assert.equal(error.endpointProtocol, "https:"); + assert.equal(error.endpointHostname, "posthog.example.test"); + assert.equal(error.cause, cause); + assert.equal("endpoint" in error, false); + assert.equal(/user|password|private|project|api_key|secret|fragment/.test(error.message), false); +}); diff --git a/apps/server/src/telemetry/AnalyticsService.ts b/apps/server/src/telemetry/AnalyticsService.ts index 5fdc7bdeb19..30454b5d5ab 100644 --- a/apps/server/src/telemetry/AnalyticsService.ts +++ b/apps/server/src/telemetry/AnalyticsService.ts @@ -7,6 +7,7 @@ * @module AnalyticsService */ import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { getUrlDiagnostics } from "@t3tools/shared/urlDiagnostics"; import * as Config from "effect/Config"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; @@ -14,6 +15,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; @@ -43,6 +45,38 @@ const TelemetryEnvConfig = Config.all({ wslDistroName: Config.string("WSL_DISTRO_NAME").pipe(Config.option), }); +export class AnalyticsBatchDeliveryError extends Schema.TaggedErrorClass()( + "AnalyticsBatchDeliveryError", + { + endpointInputLength: Schema.Number, + endpointProtocol: Schema.optionalKey(Schema.String), + endpointHostname: Schema.optionalKey(Schema.String), + eventCount: Schema.Int.check(Schema.isGreaterThan(0)), + cause: Schema.Defect(), + }, +) { + static fromEndpoint(input: { + readonly endpoint: string; + readonly eventCount: number; + readonly cause: unknown; + }): AnalyticsBatchDeliveryError { + const diagnostics = getUrlDiagnostics(input.endpoint); + return new AnalyticsBatchDeliveryError({ + endpointInputLength: diagnostics.inputLength, + ...(diagnostics.protocol === undefined ? {} : { endpointProtocol: diagnostics.protocol }), + ...(diagnostics.hostname === undefined ? {} : { endpointHostname: diagnostics.hostname }), + eventCount: input.eventCount, + cause: input.cause, + }); + } + + override get message(): string { + const eventLabel = this.eventCount === 1 ? "event" : "events"; + const destination = this.endpointHostname ? ` at ${this.endpointHostname}` : ""; + return `Failed to deliver ${this.eventCount} analytics ${eventLabel} to PostHog${destination} (endpoint input length ${this.endpointInputLength}).`; + } +} + export class AnalyticsService extends Context.Service< AnalyticsService, { @@ -75,6 +109,7 @@ export const make = Effect.gen(function* () { const clientType = serverConfig.mode === "desktop" ? "desktop-app" : "cli-web-client"; const hostPlatform = yield* HostProcessPlatform; const hostArchitecture = yield* HostProcessArchitecture; + const batchEndpoint = `${telemetryConfig.posthogHost}/batch/`; const enqueueBufferedEvent = (event: string, properties?: Readonly>) => Effect.flatMap(DateTime.now, (now) => @@ -126,10 +161,17 @@ export const make = Effect.gen(function* () { })), }; - yield* HttpClientRequest.post(`${telemetryConfig.posthogHost}/batch/`).pipe( + yield* HttpClientRequest.post(batchEndpoint).pipe( HttpClientRequest.bodyJson(payload), Effect.flatMap(httpClient.execute), Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.mapError((cause) => + AnalyticsBatchDeliveryError.fromEndpoint({ + endpoint: batchEndpoint, + eventCount: events.length, + cause, + }), + ), ); }); @@ -149,14 +191,32 @@ export const make = Effect.gen(function* () { } yield* sendBatch(batch).pipe( - Effect.catch((error) => - Ref.update(bufferRef, (current) => [...batch, ...current]).pipe( - Effect.flatMap(() => Effect.fail(error)), - ), - ), + Effect.catchTags({ + AnalyticsBatchDeliveryError: (error) => + Ref.update(bufferRef, (current) => [...batch, ...current]).pipe( + Effect.flatMap(() => Effect.fail(error)), + ), + }), ); } - }).pipe(Effect.catch((cause) => Effect.logError("Failed to flush telemetry", { cause }))); + }).pipe( + Effect.catchTags({ + AnalyticsBatchDeliveryError: (error) => + Effect.logError(error.message).pipe( + Effect.annotateLogs({ + endpointInputLength: error.endpointInputLength, + ...(error.endpointProtocol === undefined + ? {} + : { endpointProtocol: error.endpointProtocol }), + ...(error.endpointHostname === undefined + ? {} + : { endpointHostname: error.endpointHostname }), + eventCount: error.eventCount, + cause: error, + }), + ), + }), + ); const record: AnalyticsService["Service"]["record"] = Effect.fn("AnalyticsService.record")( function* (event, properties) { diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 554a942d78a..b4f7f78e28d 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -110,7 +110,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); @@ -510,16 +510,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; @@ -1279,15 +1289,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, ), @@ -1799,13 +1810,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, diff --git a/apps/web/src/browser/browserTargetResolver.test.ts b/apps/web/src/browser/browserTargetResolver.test.ts index 2305812784f..3a26985c23b 100644 --- a/apps/web/src/browser/browserTargetResolver.test.ts +++ b/apps/web/src/browser/browserTargetResolver.test.ts @@ -28,12 +28,71 @@ describe("browser target resolver", () => { it("refuses public relay hosts until the authenticated gateway exists", async () => { readPreparedConnection.mockReturnValue({ httpBaseUrl: "https://relay.example.com" }); const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); - expect(() => + + try { resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { kind: "environment-port", port: 5173, - }), - ).toThrow(/authenticated preview gateway/); + }); + expect.unreachable("Expected public environment host resolution to fail"); + } catch (error) { + expect(error).toMatchObject({ + _tag: "BrowserTargetPrivateNetworkRequiredError", + environmentId: "environment-1", + hostname: "relay.example.com", + message: + "Environment environment-1 host relay.example.com needs the planned authenticated preview gateway because it is not directly private-network reachable.", + }); + } + }); + + it("identifies the disconnected environment", async () => { + const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); + + try { + resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { + kind: "environment-port", + port: 5173, + }); + expect.unreachable("Expected disconnected environment resolution to fail"); + } catch (error) { + expect(error).toMatchObject({ + _tag: "BrowserTargetEnvironmentDisconnectedError", + environmentId: "environment-1", + message: "Environment environment-1 is not connected.", + }); + } + }); + + it("preserves invalid environment URL causes with connection context", async () => { + const sensitiveUrl = + "https://user:password@[invalid-host]/private/workspace?access_token=secret#fragment"; + readPreparedConnection.mockReturnValue({ httpBaseUrl: sensitiveUrl }); + const { BrowserTargetEnvironmentUrlInvalidError, resolveBrowserNavigationTarget } = + await import("./browserTargetResolver"); + + try { + resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { + kind: "environment-port", + port: 5173, + }); + expect.unreachable("Expected browser target resolution to fail"); + } catch (error) { + expect(error).toMatchObject({ + _tag: "BrowserTargetEnvironmentUrlInvalidError", + environmentId: "environment-1", + httpBaseUrlInputLength: sensitiveUrl.length, + cause: expect.any(TypeError), + message: `Environment environment-1 has an invalid HTTP base URL input of length ${sensitiveUrl.length}.`, + }); + expect(error).toBeInstanceOf(BrowserTargetEnvironmentUrlInvalidError); + expect(error).not.toHaveProperty("httpBaseUrl"); + expect(error).not.toHaveProperty("httpBaseUrlProtocol"); + expect(error).not.toHaveProperty("httpBaseUrlHostname"); + expect(String((error as Error).message)).not.toMatch( + /user|password|private|workspace|access_token|secret|fragment/, + ); + } }); it("normalizes schemeless localhost server-picker values", async () => { diff --git a/apps/web/src/browser/browserTargetResolver.ts b/apps/web/src/browser/browserTargetResolver.ts index 0a6dc3aa7c2..9ab7f77e84b 100644 --- a/apps/web/src/browser/browserTargetResolver.ts +++ b/apps/web/src/browser/browserTargetResolver.ts @@ -1,12 +1,52 @@ -import type { - BrowserNavigationTarget, +import { + type BrowserNavigationTarget, EnvironmentId, - PreviewUrlResolution, + type PreviewUrlResolution, } from "@t3tools/contracts"; import { isLoopbackHost, normalizePreviewUrl } from "@t3tools/shared/preview"; +import { getUrlDiagnostics } from "@t3tools/shared/urlDiagnostics"; +import * as Schema from "effect/Schema"; import { readPreparedConnection } from "~/state/session"; +export class BrowserTargetEnvironmentDisconnectedError extends Schema.TaggedErrorClass()( + "BrowserTargetEnvironmentDisconnectedError", + { + environmentId: EnvironmentId, + }, +) { + override get message(): string { + return `Environment ${this.environmentId} is not connected.`; + } +} + +export class BrowserTargetEnvironmentUrlInvalidError extends Schema.TaggedErrorClass()( + "BrowserTargetEnvironmentUrlInvalidError", + { + environmentId: EnvironmentId, + httpBaseUrlInputLength: Schema.Number, + httpBaseUrlProtocol: Schema.optionalKey(Schema.String), + httpBaseUrlHostname: Schema.optionalKey(Schema.String), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Environment ${this.environmentId} has an invalid HTTP base URL input of length ${this.httpBaseUrlInputLength}.`; + } +} + +export class BrowserTargetPrivateNetworkRequiredError extends Schema.TaggedErrorClass()( + "BrowserTargetPrivateNetworkRequiredError", + { + environmentId: EnvironmentId, + hostname: Schema.String, + }, +) { + override get message(): string { + return `Environment ${this.environmentId} host ${this.hostname} needs the planned authenticated preview gateway because it is not directly private-network reachable.`; + } +} + const isPrivateNetworkHost = (host: string): boolean => { const normalized = host.toLowerCase().replace(/^\[|\]$/g, ""); if (normalized === "localhost" || normalized === "::1" || normalized.endsWith(".local")) { @@ -37,12 +77,27 @@ export function resolveBrowserNavigationTarget( }; } const connection = readPreparedConnection(environmentId); - if (!connection) throw new Error(`Environment ${environmentId} is not connected.`); - const environmentUrl = new URL(connection.httpBaseUrl); + if (!connection) { + throw new BrowserTargetEnvironmentDisconnectedError({ environmentId }); + } + let environmentUrl: URL; + try { + environmentUrl = new URL(connection.httpBaseUrl); + } catch (cause) { + const diagnostics = getUrlDiagnostics(connection.httpBaseUrl); + throw new BrowserTargetEnvironmentUrlInvalidError({ + environmentId, + httpBaseUrlInputLength: diagnostics.inputLength, + ...(diagnostics.protocol === undefined ? {} : { httpBaseUrlProtocol: diagnostics.protocol }), + ...(diagnostics.hostname === undefined ? {} : { httpBaseUrlHostname: diagnostics.hostname }), + cause, + }); + } if (!isPrivateNetworkHost(environmentUrl.hostname)) { - throw new Error( - "This environment port needs the planned authenticated preview gateway; its server address is not directly private-network reachable.", - ); + throw new BrowserTargetPrivateNetworkRequiredError({ + environmentId, + hostname: environmentUrl.hostname, + }); } const protocol = target.protocol ?? "http"; const path = target.path?.startsWith("/") ? target.path : `/${target.path ?? ""}`; diff --git a/apps/web/src/cloud/dpop.test.ts b/apps/web/src/cloud/dpop.test.ts index 75951db1baf..82b49be1406 100644 --- a/apps/web/src/cloud/dpop.test.ts +++ b/apps/web/src/cloud/dpop.test.ts @@ -1,10 +1,17 @@ import { verifyDpopProof } from "@t3tools/shared/dpop"; import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; -import { decodeJwt } from "jose"; +import { decodeJwt, SignJWT } from "jose"; import { vi } from "vite-plus/test"; -import { browserCryptoLayer, createBrowserDpopProof, generateBrowserDpopKey } from "./dpop"; +import { + browserCryptoLayer, + BrowserDpopKeyError, + BrowserDpopProofError, + createBrowserDpopProof, + generateBrowserDpopKey, + isBrowserDpopError, +} from "./dpop"; describe("browser DPoP proofs", () => { it.effect("signs relay resource proofs with an access-token hash", () => @@ -32,4 +39,80 @@ describe("browser DPoP proofs", () => { ).toMatchObject({ ok: true }); }), ); + + it.effect("preserves safe invalid URL context and the parser cause", () => + Effect.gen(function* () { + const proofKey = yield* generateBrowserDpopKey; + const url = "http://"; + const error = yield* createBrowserDpopProof({ + method: "POST", + url, + proofKey, + }).pipe(Effect.provide(browserCryptoLayer), Effect.flip); + + expect(error).toBeInstanceOf(BrowserDpopProofError); + expect(error).toMatchObject({ + operation: "normalize-url", + method: "POST", + requestTarget: "", + urlLength: url.length, + thumbprint: proofKey.thumbprint, + }); + expect(error).not.toHaveProperty("url"); + expect(error).not.toHaveProperty("normalizedUrl"); + expect(error.cause).toBeInstanceOf(Error); + expect(error.message).not.toContain((error.cause as Error).message); + expect(isBrowserDpopError(error)).toBe(true); + }), + ); + + it.effect("redacts URL credentials, query, and fragment from proof errors", () => + Effect.gen(function* () { + const proofKey = yield* generateBrowserDpopKey; + const cause = new Error("signing failed"); + const sign = vi.spyOn(SignJWT.prototype, "sign").mockRejectedValueOnce(cause); + const url = "https://user:password@example.com/oauth/token?access_token=secret#fragment"; + + const error = yield* createBrowserDpopProof({ + method: "POST", + url, + proofKey, + }).pipe(Effect.provide(browserCryptoLayer), Effect.flip); + + expect(error).toBeInstanceOf(BrowserDpopProofError); + expect(error).toMatchObject({ + operation: "sign", + method: "POST", + requestTarget: "https://example.com/oauth/token", + urlLength: url.length, + thumbprint: proofKey.thumbprint, + cause, + }); + expect(error).not.toHaveProperty("url"); + expect(error).not.toHaveProperty("normalizedUrl"); + expect(error.message).not.toContain("user"); + expect(error.message).not.toContain("password"); + expect(error.message).not.toContain("access_token"); + expect(error.message).not.toContain("secret"); + expect(error.message).not.toContain("fragment"); + sign.mockRestore(); + }), + ); + + it.effect("preserves the browser crypto cause when key generation fails", () => + Effect.gen(function* () { + const cause = new Error("browser crypto unavailable"); + const generateKey = vi + .spyOn(globalThis.crypto.subtle, "generateKey") + .mockRejectedValueOnce(cause); + + const error = yield* generateBrowserDpopKey.pipe(Effect.flip); + + expect(error).toBeInstanceOf(BrowserDpopKeyError); + expect(error.operation).toBe("generate"); + expect(error.cause).toBe(cause); + expect(error.message).not.toContain(cause.message); + generateKey.mockRestore(); + }), + ); }); diff --git a/apps/web/src/cloud/dpop.ts b/apps/web/src/cloud/dpop.ts index d0994955db1..9c4a7536048 100644 --- a/apps/web/src/cloud/dpop.ts +++ b/apps/web/src/cloud/dpop.ts @@ -2,9 +2,9 @@ import { computeDpopAccessTokenHash, computeDpopJwkThumbprint, DpopPublicJwk, + redactDpopRequestTarget, } from "@t3tools/shared/dpop"; import * as Crypto from "effect/Crypto"; -import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; @@ -16,10 +16,62 @@ export interface BrowserDpopKey { readonly thumbprint: string; } -export class BrowserDpopError extends Data.TaggedError("BrowserDpopError")<{ - readonly message: string; - readonly cause?: unknown; -}> {} +export class BrowserDpopStorageError extends Schema.TaggedErrorClass()( + "BrowserDpopStorageError", + { + operation: Schema.Literals(["open", "read", "write"]), + databaseName: Schema.String, + storeName: Schema.String, + keyId: Schema.String, + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return `Browser DPoP key storage operation "${this.operation}" failed for database "${this.databaseName}".`; + } +} + +export class BrowserDpopKeyError extends Schema.TaggedErrorClass()( + "BrowserDpopKeyError", + { + operation: Schema.Literals([ + "generate", + "export-private", + "export-public", + "validate-public", + "import-private", + ]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Browser DPoP key operation "${this.operation}" failed.`; + } +} + +export class BrowserDpopProofError extends Schema.TaggedErrorClass()( + "BrowserDpopProofError", + { + operation: Schema.Literals(["normalize-url", "generate-id", "sign"]), + method: Schema.String, + requestTarget: Schema.String, + urlLength: Schema.Number, + thumbprint: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Browser DPoP proof operation "${this.operation}" failed for ${this.method.toUpperCase()} ${this.requestTarget}.`; + } +} + +export const BrowserDpopError = Schema.Union([ + BrowserDpopStorageError, + BrowserDpopKeyError, + BrowserDpopProofError, +]); +export type BrowserDpopError = typeof BrowserDpopError.Type; +export const isBrowserDpopError = Schema.is(BrowserDpopError); const DPOP_DATABASE_NAME = "t3code:cloud-auth"; const DPOP_DATABASE_VERSION = 1; @@ -40,16 +92,20 @@ export const browserCryptoLayer = Layer.succeed( }), ); -function dpopError(message: string, cause?: unknown) { - return new BrowserDpopError({ message, ...(cause === undefined ? {} : { cause }) }); -} - function openDpopDatabase(): Effect.Effect { return Effect.callback((resume) => { const request = indexedDB.open(DPOP_DATABASE_NAME, DPOP_DATABASE_VERSION); request.addEventListener("error", () => resume( - Effect.fail(dpopError("Could not open DPoP key storage.", request.error ?? undefined)), + Effect.fail( + new BrowserDpopStorageError({ + operation: "open", + databaseName: DPOP_DATABASE_NAME, + storeName: DPOP_KEY_STORE_NAME, + keyId: DPOP_KEY_ID, + ...(request.error === null ? {} : { cause: request.error }), + }), + ), ), ); request.addEventListener("upgradeneeded", () => { @@ -74,7 +130,17 @@ export function readStoredBrowserDpopKey(): Effect.Effect - resume(Effect.fail(dpopError("Could not read DPoP key.", request.error ?? undefined))), + resume( + Effect.fail( + new BrowserDpopStorageError({ + operation: "read", + databaseName: DPOP_DATABASE_NAME, + storeName: DPOP_KEY_STORE_NAME, + keyId: DPOP_KEY_ID, + ...(request.error === null ? {} : { cause: request.error }), + }), + ), + ), ); request.addEventListener("success", () => resume(Effect.succeed((request.result as BrowserDpopKey | undefined) ?? null)), @@ -97,7 +163,15 @@ export function writeStoredBrowserDpopKey( const transaction = database.transaction(DPOP_KEY_STORE_NAME, "readwrite"); transaction.addEventListener("error", () => resume( - Effect.fail(dpopError("Could not write DPoP key.", transaction.error ?? undefined)), + Effect.fail( + new BrowserDpopStorageError({ + operation: "write", + databaseName: DPOP_DATABASE_NAME, + storeName: DPOP_KEY_STORE_NAME, + keyId: DPOP_KEY_ID, + ...(transaction.error === null ? {} : { cause: transaction.error }), + }), + ), ), ); transaction.addEventListener("complete", () => resume(Effect.void)); @@ -114,26 +188,22 @@ export const generateBrowserDpopKey = Effect.gen(function* () { "sign", "verify", ]) as Promise, - catch: (cause) => dpopError("Could not generate DPoP proof key.", cause), + catch: (cause) => new BrowserDpopKeyError({ operation: "generate", cause }), }); const privateJwk = yield* Effect.tryPromise({ try: () => crypto.subtle.exportKey("jwk", generated.privateKey), - catch: (cause) => dpopError("Could not export DPoP private key.", cause), + catch: (cause) => new BrowserDpopKeyError({ operation: "export-private", cause }), }); - const publicJwk = yield* Effect.tryPromise({ + const encodedPublicJwk = yield* Effect.tryPromise({ try: () => crypto.subtle.exportKey("jwk", generated.publicKey), - catch: (cause) => dpopError("Could not export DPoP public key.", cause), - }).pipe( - Effect.flatMap((jwk) => decodeDpopPublicJwk(jwk)), - Effect.mapError((cause) => - cause instanceof BrowserDpopError - ? cause - : dpopError("Generated DPoP public key is invalid.", cause), - ), + catch: (cause) => new BrowserDpopKeyError({ operation: "export-public", cause }), + }); + const publicJwk = yield* decodeDpopPublicJwk(encodedPublicJwk).pipe( + Effect.mapError((cause) => new BrowserDpopKeyError({ operation: "validate-public", cause })), ); const privateKey = yield* Effect.tryPromise({ try: () => importJWK(privateJwk as JWK, "ES256", { extractable: false }) as Promise, - catch: (cause) => dpopError("Could not import DPoP private key.", cause), + catch: (cause) => new BrowserDpopKeyError({ operation: "import-private", cause }), }); return { privateKey, @@ -153,15 +223,35 @@ export function createBrowserDpopProof(input: { Crypto.Crypto > { return Effect.gen(function* () { + const requestTarget = redactDpopRequestTarget(input.url); + const urlLength = input.url.length; const normalizedUrl = yield* Effect.try({ try: () => new URL(input.url), - catch: (cause) => dpopError("Could not normalize DPoP proof URL.", cause), + catch: (cause) => + new BrowserDpopProofError({ + operation: "normalize-url", + method: input.method, + requestTarget, + urlLength, + thumbprint: input.proofKey.thumbprint, + cause, + }), }); normalizedUrl.search = ""; normalizedUrl.hash = ""; const jti = yield* Crypto.Crypto.pipe( Effect.flatMap((crypto) => crypto.randomUUIDv4), - Effect.mapError((cause) => dpopError("Could not generate DPoP proof identifier.", cause)), + Effect.mapError( + (cause) => + new BrowserDpopProofError({ + operation: "generate-id", + method: input.method, + requestTarget, + urlLength, + thumbprint: input.proofKey.thumbprint, + cause, + }), + ), ); const proof = yield* Effect.tryPromise({ try: () => @@ -178,7 +268,15 @@ export function createBrowserDpopProof(input: { }) .setIssuedAt() .sign(input.proofKey.privateKey), - catch: (cause) => dpopError("Could not sign DPoP proof.", cause), + catch: (cause) => + new BrowserDpopProofError({ + operation: "sign", + method: input.method, + requestTarget, + urlLength, + thumbprint: input.proofKey.thumbprint, + cause, + }), }); return { proof, thumbprint: input.proofKey.thumbprint }; }); diff --git a/apps/web/src/cloud/linkEnvironment.test.ts b/apps/web/src/cloud/linkEnvironment.test.ts index f823016ddf0..88497ed3707 100644 --- a/apps/web/src/cloud/linkEnvironment.test.ts +++ b/apps/web/src/cloud/linkEnvironment.test.ts @@ -2,12 +2,14 @@ import { type DesktopBridge, EnvironmentId, type RelayClientInstallProgressEvent, + type RelayClientStatus, WS_METHODS, } from "@t3tools/contracts"; import { RelayWebClientId } from "@t3tools/contracts/relay"; import { describe, expect, it } from "@effect/vitest"; 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 Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; @@ -26,7 +28,9 @@ import { remoteHttpClientLayer } from "@t3tools/client-runtime/rpc"; import { __resetDesktopPrimaryAuthForTests } from "../environments/primary/desktopAuth"; import { + CloudEnvironmentLinkOperationError, collectCloudLinkTargets, + isCloudEnvironmentLinkError, linkPrimaryEnvironmentToCloud, listManagedCloudEnvironments, normalizeRelayBaseUrl, @@ -48,12 +52,21 @@ const relayClientInstallDialog = vi.hoisted(() => ({ finish: vi.fn(), })); +const cloudPublicConfig = vi.hoisted(() => ({ + relayUrl: "https://relay.example.test", +})); + vi.mock("./relayClientInstallDialog", () => ({ requestRelayClientInstallConfirmation: relayClientInstallDialog.requestConfirmation, reportRelayClientInstallProgress: relayClientInstallDialog.reportProgress, finishRelayClientInstall: relayClientInstallDialog.finish, })); +vi.mock("./publicConfig", async (importOriginal) => ({ + ...(await importOriginal()), + resolveCloudPublicConfig: () => ({ relayUrl: cloudPublicConfig.relayUrl }), +})); + const createProof = vi.fn(() => Effect.succeed("dpop-proof")); const dpopSignerLayer = Layer.succeed( ManagedRelay.ManagedRelayDpopSigner, @@ -75,7 +88,7 @@ function relayLayer() { } function registryLayer(options?: { - readonly status?: { readonly status: "available"; readonly version: string }; + readonly status?: RelayClientStatus; readonly installEvents?: ReadonlyArray; }) { return Layer.effect( @@ -83,7 +96,14 @@ function registryLayer(options?: { Effect.gen(function* () { const client = { [WS_METHODS.cloudGetRelayClientStatus]: () => - Effect.succeed(options?.status ?? { status: "available", version: "2026.6.0" }), + Effect.succeed( + options?.status ?? { + status: "available", + executablePath: "/managed/cloudflared", + source: "managed", + version: "2026.6.0", + }, + ), [WS_METHODS.cloudInstallRelayClient]: () => Stream.fromIterable(options?.installEvents ?? []), } as unknown as RpcSession["client"]; @@ -141,6 +161,7 @@ function bodyText(body: BodyInit | null | undefined): string { beforeEach(() => { vi.clearAllMocks(); + cloudPublicConfig.relayUrl = "https://relay.example.test"; vi.stubEnv("VITE_T3CODE_RELAY_URL", "https://relay.example.test"); relayClientInstallDialog.requestConfirmation.mockResolvedValue(true); }); @@ -195,6 +216,47 @@ describe("web cloud link environment client", () => { }), ); + it.effect("preserves structured relay failures and trace IDs", () => + Effect.gen(function* () { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + Response.json( + { + _tag: "RelayAuthInvalidError", + code: "auth_invalid", + reason: "invalid_bearer", + traceId: "trace-web-cloud-link", + }, + { status: 401 }, + ), + ), + ); + + const error = yield* withServices( + listManagedCloudEnvironments({ clerkToken: "clerk-token" }), + ).pipe(Effect.flip); + + expect(error).toBeInstanceOf(CloudEnvironmentLinkOperationError); + expect(error).toMatchObject({ + action: "list relay-managed environments", + relayUrlInputLength: "https://relay.example.test".length, + relayUrlProtocol: "https:", + relayUrlHostname: "relay.example.test", + traceId: "trace-web-cloud-link", + relayError: { + _tag: "RelayAuthInvalidError", + reason: "invalid_bearer", + }, + cause: { + _tag: "ManagedRelayRequestFailedError", + }, + }); + expect(error.message).toBe("Could not list relay-managed environments."); + expect(isCloudEnvironmentLinkError(error)).toBe(true); + }), + ); + it.effect("reads primary cloud link state from the explicit target", () => Effect.gen(function* () { const fetchMock = vi.fn().mockResolvedValue( @@ -225,6 +287,96 @@ describe("web cloud link environment client", () => { }), ); + it.effect("preserves structured environment API failures and their cause chain", () => + Effect.gen(function* () { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + Response.json( + { + _tag: "EnvironmentHttpUnauthorizedError", + message: "Environment bearer token is invalid.", + }, + { status: 401 }, + ), + ), + ); + + const error = yield* withServices(readPrimaryCloudLinkState({ target: TARGET })).pipe( + Effect.flip, + ); + + expect(error).toBeInstanceOf(CloudEnvironmentLinkOperationError); + expect(error).toMatchObject({ + action: "read environment cloud link state", + environmentId: TARGET.environmentId, + httpBaseUrlInputLength: TARGET.httpBaseUrl.length, + httpBaseUrlProtocol: "http:", + httpBaseUrlHostname: "127.0.0.1", + environmentError: { + _tag: "EnvironmentHttpUnauthorizedError", + message: "Environment bearer token is invalid.", + }, + }); + expect(error.cause).toBeDefined(); + expect(error.message).toBe( + `Could not read environment cloud link state for environment "${TARGET.environmentId}".`, + ); + expect(isCloudEnvironmentLinkError(error)).toBe(true); + }), + ); + + it.effect("preserves invalid environment HTTP URL parser causes", () => + Effect.gen(function* () { + const invalidUrl = + "https://user:password@[invalid-host]/private/path?access_token=secret#fragment"; + const error = yield* withServices( + readPrimaryCloudLinkState({ + target: { + ...TARGET, + httpBaseUrl: invalidUrl, + }, + }), + ).pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "CloudEnvironmentLinkOperationError", + action: "initialize the environment HTTP client", + environmentId: TARGET.environmentId, + httpBaseUrlInputLength: invalidUrl.length, + }); + expect(error.cause).toBeInstanceOf(TypeError); + expect(error).not.toHaveProperty("httpBaseUrl"); + expect(error).not.toHaveProperty("httpBaseUrlProtocol"); + expect(error).not.toHaveProperty("httpBaseUrlHostname"); + expect(error.message).not.toMatch(/user|password|private|path|access_token|secret|fragment/); + }), + ); + + it("keeps environment endpoint secrets out of mapped error attributes", () => { + const httpBaseUrl = + "https://user:password@environment.example.test/private/path?access_token=secret#fragment"; + const cause = new Error("request failed"); + const error = CloudEnvironmentLinkOperationError.fromEnvironmentApi({ + action: "read environment cloud link state", + environmentId: TARGET.environmentId, + httpBaseUrl, + cause, + }); + + expect(error).toMatchObject({ + httpBaseUrlInputLength: httpBaseUrl.length, + httpBaseUrlProtocol: "https:", + httpBaseUrlHostname: "environment.example.test", + cause, + }); + expect(error.cause).toBe(cause); + expect(error).not.toHaveProperty("httpBaseUrl"); + const diagnostics = JSON.stringify(error); + expect(diagnostics).not.toMatch(/user|password|private|path|access_token|secret|fragment/); + expect(error.message).not.toMatch(/user|password|private|path|access_token|secret|fragment/); + }); + it.effect("uses desktop bearer auth for primary cloud link state", () => Effect.gen(function* () { const fetchMock = vi.fn().mockResolvedValue( @@ -306,6 +458,37 @@ describe("web cloud link environment client", () => { }), ); + it.effect("validates the environment endpoint before prompting to install a relay client", () => + Effect.gen(function* () { + const invalidUrl = + "https://user:password@[invalid-host]/private/path?access_token=secret#fragment"; + + const error = yield* withServices( + linkPrimaryEnvironmentToCloud({ + target: { + ...TARGET, + httpBaseUrl: invalidUrl, + }, + clerkToken: "clerk-token", + }), + { + status: { status: "missing", version: "2026.6.0" }, + }, + ).pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "CloudEnvironmentLinkOperationError", + action: "derive the environment endpoint origin", + environmentId: TARGET.environmentId, + httpBaseUrlInputLength: invalidUrl.length, + }); + expect(error.cause).toBeInstanceOf(TypeError); + expect(error).not.toHaveProperty("httpBaseUrl"); + expect(error.message).not.toMatch(/user|password|private|path|access_token|secret|fragment/); + expect(relayClientInstallDialog.requestConfirmation).not.toHaveBeenCalled(); + }), + ); + it.effect("installs a missing relay client before linking", () => Effect.gen(function* () { vi.stubGlobal("fetch", vi.fn().mockResolvedValue(Response.json({ malformed: true }))); @@ -316,7 +499,12 @@ describe("web cloud link environment client", () => { clerkToken: "clerk-token", }), { - status: { status: "available", version: "2026.6.0" }, + status: { + status: "available", + executablePath: "/managed/cloudflared", + source: "managed", + version: "2026.6.0", + }, installEvents: [], }, ).pipe(Effect.flip); @@ -348,4 +536,64 @@ describe("web cloud link environment client", () => { ); }), ); + + it.effect("keeps configured relay URL secrets out of revoke warnings", () => { + const relayUrl = + "https://relay-user:relay-password@relay.example.test/private/workspace?access_token=relay-secret#relay-fragment"; + cloudPublicConfig.relayUrl = relayUrl; + const capturedLogs: Array> = []; + const logger = Logger.make(({ message }) => { + capturedLogs.push(Array.isArray(message) ? message : [message]); + }); + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + Response.json({ ok: true, endpointRuntimeStatus: { status: "disabled" } }), + ) + .mockResolvedValueOnce(Response.json({ malformed: true }, { status: 500 })); + vi.stubGlobal("fetch", fetchMock); + + return unlinkPrimaryEnvironmentFromCloud({ + target: TARGET, + clerkToken: "clerk-token", + }).pipe( + Effect.provide( + Layer.merge( + services(), + Logger.layer([logger], { + mergeWithExisting: false, + }), + ), + ), + Effect.tap(() => + Effect.sync(() => { + expect(capturedLogs).toHaveLength(1); + const logFields = capturedLogs[0]?.find( + (value): value is Record => + typeof value === "object" && value !== null, + ); + expect(logFields).toMatchObject({ + relayUrlInputLength: relayUrl.length, + relayUrlProtocol: "https:", + relayUrlHostname: "relay.example.test", + causeTag: "ManagedRelayRequestFailedError", + }); + expect(logFields).not.toHaveProperty("relayUrl"); + expect(logFields).not.toHaveProperty("cause"); + const logText = capturedLogs[0] + ?.filter((value): value is string => typeof value === "string") + .join(" "); + for (const secret of [ + "relay-user", + "relay-password", + "/private/workspace", + "access_token=relay-secret", + "relay-fragment", + ]) { + expect(logText).not.toContain(secret); + } + }), + ), + ); + }); }); diff --git a/apps/web/src/cloud/linkEnvironment.ts b/apps/web/src/cloud/linkEnvironment.ts index 20bf75c7d6d..9030ac3d3ec 100644 --- a/apps/web/src/cloud/linkEnvironment.ts +++ b/apps/web/src/cloud/linkEnvironment.ts @@ -1,4 +1,3 @@ -import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; @@ -19,13 +18,14 @@ import { type RelayClientDeviceRecord, type RelayClientEnvironmentRecord, type RelayEnvironmentLinkResponse, - type RelayProtectedError as RelayProtectedErrorType, type RelayManagedEndpointProviderKind, + RelayProtectedError, } from "@t3tools/contracts/relay"; import { EnvironmentRegistry } from "@t3tools/client-runtime/connection"; import { request, runStream } from "@t3tools/client-runtime/rpc"; import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime/rpc"; import { ManagedRelay } from "@t3tools/client-runtime/relay"; +import { getUrlDiagnostics } from "@t3tools/shared/urlDiagnostics"; import { readPrimaryEnvironmentDescriptor, @@ -51,17 +51,218 @@ function relayUrl(): string | null { return resolveCloudPublicConfig().relayUrl; } -export class CloudEnvironmentLinkError extends Data.TaggedError("CloudEnvironmentLinkError")<{ - readonly message: string; - readonly cause?: unknown; - readonly traceId?: string; -}> {} +const EnvironmentCloudApiError = Schema.Union([ + EnvironmentHttpBadRequestError, + EnvironmentHttpUnauthorizedError, + EnvironmentHttpForbiddenError, + EnvironmentHttpConflictError, + EnvironmentHttpInternalServerError, + EnvironmentCloudEndpointUnavailableError, +]); +type EnvironmentCloudApiError = typeof EnvironmentCloudApiError.Type; +const isEnvironmentCloudApiError = Schema.is(EnvironmentCloudApiError); -const relayClientRpcError = (message: string) => (cause: unknown) => - new CloudEnvironmentLinkError({ - message, - cause, - }); +function relayUrlDiagnosticFields(relayUrl: string | undefined) { + if (relayUrl === undefined) return {}; + const diagnostics = getUrlDiagnostics(relayUrl); + return { + relayUrlInputLength: diagnostics.inputLength, + ...(diagnostics.protocol === undefined ? {} : { relayUrlProtocol: diagnostics.protocol }), + ...(diagnostics.hostname === undefined ? {} : { relayUrlHostname: diagnostics.hostname }), + }; +} + +function httpBaseUrlDiagnosticFields(httpBaseUrl: string | undefined) { + if (httpBaseUrl === undefined) return {}; + const diagnostics = getUrlDiagnostics(httpBaseUrl); + return { + httpBaseUrlInputLength: diagnostics.inputLength, + ...(diagnostics.protocol === undefined ? {} : { httpBaseUrlProtocol: diagnostics.protocol }), + ...(diagnostics.hostname === undefined ? {} : { httpBaseUrlHostname: diagnostics.hostname }), + }; +} + +export const CloudEnvironmentLinkAction = Schema.Literals([ + "check relay client availability", + "confirm relay client installation", + "install the relay client", + "list relay-managed environments", + "list cloud devices", + "read environment cloud link state", + "update environment cloud preferences", + "unlink the environment from cloud", + "revoke the cloud environment link", + "create an environment link challenge", + "obtain an environment link proof", + "link the environment", + "derive the environment endpoint origin", + "initialize the environment HTTP client", + "configure environment relay access", +]); +export type CloudEnvironmentLinkAction = typeof CloudEnvironmentLinkAction.Type; + +export class CloudEnvironmentLinkOperationError extends Schema.TaggedErrorClass()( + "CloudEnvironmentLinkOperationError", + { + action: CloudEnvironmentLinkAction, + environmentId: Schema.optionalKey(Schema.String), + relayUrlInputLength: Schema.optionalKey(Schema.Number), + relayUrlProtocol: Schema.optionalKey(Schema.String), + relayUrlHostname: Schema.optionalKey(Schema.String), + httpBaseUrlInputLength: Schema.optionalKey(Schema.Number), + httpBaseUrlProtocol: Schema.optionalKey(Schema.String), + httpBaseUrlHostname: Schema.optionalKey(Schema.String), + traceId: Schema.optionalKey(Schema.String), + relayError: Schema.optionalKey(RelayProtectedError), + environmentError: Schema.optionalKey(EnvironmentCloudApiError), + cause: Schema.Defect(), + }, +) { + static fromManagedRelay(input: { + readonly action: CloudEnvironmentLinkAction; + readonly cause: ManagedRelay.ManagedRelayClientError; + readonly environmentId?: string; + readonly relayUrl?: string; + readonly httpBaseUrl?: string; + }): CloudEnvironmentLinkOperationError { + const requestFailure = + input.cause._tag === "ManagedRelayRequestFailedError" ? input.cause : undefined; + return new CloudEnvironmentLinkOperationError({ + action: input.action, + cause: input.cause, + ...(input.environmentId === undefined ? {} : { environmentId: input.environmentId }), + ...relayUrlDiagnosticFields(input.relayUrl), + ...httpBaseUrlDiagnosticFields(input.httpBaseUrl), + ...(requestFailure?.traceId === undefined ? {} : { traceId: requestFailure.traceId }), + ...(requestFailure?.relayError === undefined + ? {} + : { relayError: requestFailure.relayError }), + }); + } + + static fromEnvironmentApi(input: { + readonly action: CloudEnvironmentLinkAction; + readonly cause: unknown; + readonly environmentId: string; + readonly httpBaseUrl: string; + }): CloudEnvironmentLinkOperationError { + const environmentError = CloudEnvironmentLinkOperationError.findEnvironmentApiError( + input.cause, + ); + return new CloudEnvironmentLinkOperationError({ + action: input.action, + environmentId: input.environmentId, + ...httpBaseUrlDiagnosticFields(input.httpBaseUrl), + cause: input.cause, + ...(environmentError === undefined ? {} : { environmentError }), + }); + } + + private static findEnvironmentApiError(cause: unknown): EnvironmentCloudApiError | undefined { + const seen = new Set(); + let current = cause; + while (typeof current === "object" && current !== null && !seen.has(current)) { + if (isEnvironmentCloudApiError(current)) { + return current; + } + seen.add(current); + current = "cause" in current ? current.cause : undefined; + } + return undefined; + } + + override get message(): string { + const environment = + this.environmentId === undefined ? "" : ` for environment "${this.environmentId}"`; + return `Could not ${this.action}${environment}.`; + } +} + +export class CloudRelayUrlNotConfiguredError extends Schema.TaggedErrorClass()( + "CloudRelayUrlNotConfiguredError", + { environmentId: Schema.optionalKey(Schema.String) }, +) { + override get message(): string { + return "T3CODE_RELAY_URL is not configured."; + } +} + +export class CloudRelayClientInstallUnsupportedError extends Schema.TaggedErrorClass()( + "CloudRelayClientInstallUnsupportedError", + { + environmentId: Schema.String, + phase: Schema.Literals(["preflight", "post-install"]), + version: Schema.String, + platform: Schema.String, + architecture: Schema.String, + }, +) { + override get message(): string { + return `T3 Code cannot install the relay client automatically on ${this.platform}-${this.architecture}.`; + } +} + +export class CloudRelayClientInstallCancelledError extends Schema.TaggedErrorClass()( + "CloudRelayClientInstallCancelledError", + { + environmentId: Schema.String, + version: Schema.String, + }, +) { + override get message(): string { + return "Relay client installation was cancelled."; + } +} + +export class CloudRelayClientInstallIncompleteError extends Schema.TaggedErrorClass()( + "CloudRelayClientInstallIncompleteError", + { + environmentId: Schema.String, + version: Schema.String, + }, +) { + override get message(): string { + return "The relay client install completed without a final status."; + } +} + +export class CloudRelayClientUnavailableAfterInstallError extends Schema.TaggedErrorClass()( + "CloudRelayClientUnavailableAfterInstallError", + { + environmentId: Schema.String, + version: Schema.String, + }, +) { + override get message(): string { + return "The relay client is still unavailable after installation."; + } +} + +export class CloudEnvironmentLinkResponseMismatchError extends Schema.TaggedErrorClass()( + "CloudEnvironmentLinkResponseMismatchError", + { + environmentId: Schema.String, + field: Schema.Literals(["environment id", "endpoint provider"]), + expected: Schema.String, + actual: Schema.String, + }, +) { + override get message(): string { + return `Relay returned link credentials with an unexpected ${this.field}.`; + } +} + +export const CloudEnvironmentLinkError = Schema.Union([ + CloudEnvironmentLinkOperationError, + CloudRelayUrlNotConfiguredError, + CloudRelayClientInstallUnsupportedError, + CloudRelayClientInstallCancelledError, + CloudRelayClientInstallIncompleteError, + CloudRelayClientUnavailableAfterInstallError, + CloudEnvironmentLinkResponseMismatchError, +]); +export type CloudEnvironmentLinkError = typeof CloudEnvironmentLinkError.Type; +export const isCloudEnvironmentLinkError = Schema.is(CloudEnvironmentLinkError); function ensureRelayClientAvailable( environmentId: EnvironmentId, @@ -70,21 +271,40 @@ function ensureRelayClientAvailable( const registry = yield* EnvironmentRegistry; const status = yield* registry .run(environmentId, request(WS_METHODS.cloudGetRelayClientStatus, {})) - .pipe(Effect.mapError(relayClientRpcError("Could not check relay client availability."))); + .pipe( + Effect.mapError( + (cause) => + new CloudEnvironmentLinkOperationError({ + action: "check relay client availability", + environmentId, + cause, + }), + ), + ); if (status.status === "available") return; if (status.status === "unsupported") { - return yield* new CloudEnvironmentLinkError({ - message: `T3 Code cannot install the relay client automatically on ${status.platform}-${status.arch}.`, + return yield* new CloudRelayClientInstallUnsupportedError({ + environmentId, + phase: "preflight", + version: status.version, + platform: status.platform, + architecture: status.arch, }); } const confirmed = yield* Effect.tryPromise({ try: () => requestRelayClientInstallConfirmation(status.version), - catch: relayClientRpcError("Could not confirm relay client installation."), + catch: (cause) => + new CloudEnvironmentLinkOperationError({ + action: "confirm relay client installation", + environmentId, + cause, + }), }); if (!confirmed) { - return yield* new CloudEnvironmentLinkError({ - message: "Relay client installation was cancelled.", + return yield* new CloudRelayClientInstallCancelledError({ + environmentId, + version: status.version, }); } @@ -97,112 +317,73 @@ function ensureRelayClientAvailable( ) .pipe( Stream.runLast, - Effect.mapError(relayClientRpcError("Could not install the relay client.")), + Effect.mapError( + (cause) => + new CloudEnvironmentLinkOperationError({ + action: "install the relay client", + environmentId, + cause, + }), + ), Effect.ensuring(Effect.sync(finishRelayClientInstall)), ); if (Option.isNone(installed) || installed.value.type !== "complete") { - return yield* new CloudEnvironmentLinkError({ - message: "The relay client install completed without a final status.", + return yield* new CloudRelayClientInstallIncompleteError({ + environmentId, + version: status.version, }); } const installedStatus = installed.value.status; if (installedStatus.status !== "available") { - return yield* new CloudEnvironmentLinkError({ - message: - installedStatus.status === "unsupported" - ? `T3 Code cannot install the relay client automatically on ${installedStatus.platform}-${installedStatus.arch}.` - : "The relay client is still unavailable after installation.", - }); + return yield* installedStatus.status === "unsupported" + ? new CloudRelayClientInstallUnsupportedError({ + environmentId, + phase: "post-install", + version: installedStatus.version, + platform: installedStatus.platform, + architecture: installedStatus.arch, + }) + : new CloudRelayClientUnavailableAfterInstallError({ + environmentId, + version: installedStatus.version, + }); } }); } -const isEnvironmentCloudApiError = Schema.is( - Schema.Union([ - EnvironmentHttpBadRequestError, - EnvironmentHttpUnauthorizedError, - EnvironmentHttpForbiddenError, - EnvironmentHttpConflictError, - EnvironmentHttpInternalServerError, - EnvironmentCloudEndpointUnavailableError, - ]), -); - -function relayProtectedErrorMessage(error: RelayProtectedErrorType): string { - switch (error._tag) { - case "RelayAuthInvalidError": - switch (error.reason) { - case "missing_bearer": - case "invalid_bearer": - return "Relay rejected the cloud session token."; - case "invalid_dpop": - return "Relay rejected the DPoP proof."; - case "not_authorized": - return "Relay rejected the authenticated request."; - } - case "RelayEnvironmentLinkProofExpiredError": - return "Relay rejected an expired environment link proof."; - case "RelayEnvironmentLinkProofInvalidError": - return `Relay rejected the environment link proof (${error.reason}).`; - case "RelayEnvironmentConnectNotAuthorizedError": - return "Relay rejected the environment connection request."; - case "RelayEnvironmentEndpointUnavailableError": - return `Relay could not reach the environment endpoint (${error.reason}).`; - case "RelayEnvironmentEndpointTimedOutError": - return "Relay timed out while contacting the environment endpoint."; - case "RelayEnvironmentLinkFailedError": - return `Relay could not link the environment (${error.reason}).`; - case "RelayEnvironmentLinkUnavailableError": - return `Relay cannot provision the managed endpoint (${error.reason}).`; - case "RelayAgentActivityPublishProofExpiredError": - return "Relay rejected an expired agent activity publish proof."; - case "RelayAgentActivityPublishProofInvalidError": - return `Relay rejected the agent activity publish proof (${error.reason}).`; - case "RelayInternalError": - return `Relay encountered an internal error (${error.reason}).`; - } -} - -function decodedRelayClientError(message: string) { - return (cause: ManagedRelay.ManagedRelayClientError) => { - const relayError = - cause._tag === "ManagedRelayRequestFailedError" ? cause.relayError : undefined; - const traceId = cause._tag === "ManagedRelayRequestFailedError" ? cause.traceId : undefined; - const detail = relayError ? relayProtectedErrorMessage(relayError) : null; - return new CloudEnvironmentLinkError({ - message: detail ? `${message}: ${detail}` : message, - cause, - ...(traceId ? { traceId } : {}), - }); - }; -} - -function findEnvironmentCloudApiError(cause: unknown): { readonly message: string } | null { - if (isEnvironmentCloudApiError(cause)) { - return cause; - } - if (typeof cause !== "object" || cause === null) { - return null; - } - return "cause" in cause ? findEnvironmentCloudApiError(cause.cause) : null; -} - -const environmentApiError = (message: string) => (cause: unknown) => { - const environmentError = findEnvironmentCloudApiError(cause); - return new CloudEnvironmentLinkError({ - message: environmentError - ? `${message.replace(/[.:]$/, "")}: ${environmentError.message}` - : message, - cause, +function endpointOrigin(input: { readonly environmentId: string; readonly httpBaseUrl: string }) { + return Effect.try({ + try: () => { + const url = new URL(input.httpBaseUrl); + return { + localHttpHost: "127.0.0.1", + localHttpPort: Number(url.port || (url.protocol === "https:" ? 443 : 80)), + }; + }, + catch: (cause) => + new CloudEnvironmentLinkOperationError({ + action: "derive the environment endpoint origin", + environmentId: input.environmentId, + ...httpBaseUrlDiagnosticFields(input.httpBaseUrl), + cause, + }), }); -}; +} -function endpointOrigin(httpBaseUrl: string) { - const url = new URL(httpBaseUrl); - return { - localHttpHost: "127.0.0.1", - localHttpPort: Number(url.port || (url.protocol === "https:" ? 443 : 80)), - }; +function makeCloudEnvironmentHttpApiClient(input: { + readonly environmentId: string; + readonly httpBaseUrl: string; +}) { + return Effect.try({ + try: () => makeEnvironmentHttpApiClient(input.httpBaseUrl), + catch: (cause) => + new CloudEnvironmentLinkOperationError({ + action: "initialize the environment HTTP client", + environmentId: input.environmentId, + ...httpBaseUrlDiagnosticFields(input.httpBaseUrl), + cause, + }), + }).pipe(Effect.flatten); } const MANAGED_ENDPOINT_PROVIDER_KIND = @@ -214,13 +395,19 @@ function ensureLinkedEnvironmentMatches(input: { readonly link: RelayEnvironmentLinkResponse; }): Effect.Effect { if (input.link.environmentId !== input.expectedEnvironmentId) { - return new CloudEnvironmentLinkError({ - message: "Relay returned credentials for a different environment.", + return new CloudEnvironmentLinkResponseMismatchError({ + environmentId: input.expectedEnvironmentId, + field: "environment id", + expected: input.expectedEnvironmentId, + actual: input.link.environmentId, }); } if (input.link.endpoint.providerKind !== input.expectedProviderKind) { - return new CloudEnvironmentLinkError({ - message: "Relay returned credentials for a different endpoint provider.", + return new CloudEnvironmentLinkResponseMismatchError({ + environmentId: input.expectedEnvironmentId, + field: "endpoint provider", + expected: input.expectedProviderKind, + actual: input.link.endpoint.providerKind, }); } return Effect.void; @@ -275,9 +462,7 @@ export function listManagedCloudEnvironments(input: { return Effect.gen(function* () { const configuredRelayUrl = relayUrl(); if (!configuredRelayUrl) { - return yield* new CloudEnvironmentLinkError({ - message: "T3CODE_RELAY_URL is not configured.", - }); + return yield* new CloudRelayUrlNotConfiguredError({}); } const relayClient = yield* ManagedRelay.ManagedRelayClient; return yield* relayClient @@ -285,12 +470,12 @@ export function listManagedCloudEnvironments(input: { clerkToken: input.clerkToken, }) .pipe( - Effect.mapError( - (cause) => - new CloudEnvironmentLinkError({ - message: "Could not list relay-managed environments.", - cause, - }), + Effect.mapError((cause) => + CloudEnvironmentLinkOperationError.fromManagedRelay({ + action: "list relay-managed environments", + relayUrl: configuredRelayUrl, + cause, + }), ), ); }); @@ -304,19 +489,18 @@ export function listCloudDevices(input: { ManagedRelay.ManagedRelayClient > { return Effect.gen(function* () { - if (!relayUrl()) { - return yield* new CloudEnvironmentLinkError({ - message: "T3CODE_RELAY_URL is not configured.", - }); + const configuredRelayUrl = relayUrl(); + if (!configuredRelayUrl) { + return yield* new CloudRelayUrlNotConfiguredError({}); } const relayClient = yield* ManagedRelay.ManagedRelayClient; return yield* relayClient.listDevices({ clerkToken: input.clerkToken }).pipe( - Effect.mapError( - (cause) => - new CloudEnvironmentLinkError({ - message: "Could not list cloud devices.", - cause, - }), + Effect.mapError((cause) => + CloudEnvironmentLinkOperationError.fromManagedRelay({ + action: "list cloud devices", + relayUrl: configuredRelayUrl, + cause, + }), ), ); }); @@ -326,10 +510,20 @@ export function readPrimaryCloudLinkState(input: { readonly target: CloudLinkTarget; }): Effect.Effect { return Effect.gen(function* () { - const client = yield* makeEnvironmentHttpApiClient(input.target.httpBaseUrl); - return yield* client.connect - .linkState({ headers: {} }) - .pipe(Effect.mapError(environmentApiError("Could not read environment cloud link state."))); + const client = yield* makeCloudEnvironmentHttpApiClient({ + environmentId: input.target.environmentId, + httpBaseUrl: input.target.httpBaseUrl, + }); + return yield* client.connect.linkState({ headers: {} }).pipe( + Effect.mapError((cause) => + CloudEnvironmentLinkOperationError.fromEnvironmentApi({ + action: "read environment cloud link state", + environmentId: input.target.environmentId, + httpBaseUrl: input.target.httpBaseUrl, + cause, + }), + ), + ); }).pipe(Effect.provide(primaryEnvironmentHttpLayer)); } @@ -338,14 +532,24 @@ export function updatePrimaryCloudPreferences(input: { readonly publishAgentActivity: boolean; }): Effect.Effect { return Effect.gen(function* () { - const client = yield* makeEnvironmentHttpApiClient(input.target.httpBaseUrl); + const client = yield* makeCloudEnvironmentHttpApiClient({ + environmentId: input.target.environmentId, + httpBaseUrl: input.target.httpBaseUrl, + }); return yield* client.connect .preferences({ headers: {}, payload: input, }) .pipe( - Effect.mapError(environmentApiError("Could not update environment cloud preferences.")), + Effect.mapError((cause) => + CloudEnvironmentLinkOperationError.fromEnvironmentApi({ + action: "update environment cloud preferences", + environmentId: input.target.environmentId, + httpBaseUrl: input.target.httpBaseUrl, + cause, + }), + ), ); }).pipe(Effect.provide(primaryEnvironmentHttpLayer)); } @@ -359,10 +563,20 @@ export function unlinkPrimaryEnvironmentFromCloud(input: { HttpClient.HttpClient | ManagedRelay.ManagedRelayClient > { return Effect.gen(function* () { - const client = yield* makeEnvironmentHttpApiClient(input.target.httpBaseUrl); - yield* client.connect - .unlink({ headers: {} }) - .pipe(Effect.mapError(environmentApiError("Could not unlink the environment from cloud."))); + const client = yield* makeCloudEnvironmentHttpApiClient({ + environmentId: input.target.environmentId, + httpBaseUrl: input.target.httpBaseUrl, + }); + yield* client.connect.unlink({ headers: {} }).pipe( + Effect.mapError((cause) => + CloudEnvironmentLinkOperationError.fromEnvironmentApi({ + action: "unlink the environment from cloud", + environmentId: input.target.environmentId, + httpBaseUrl: input.target.httpBaseUrl, + cause, + }), + ), + ); const configuredRelayUrl = relayUrl(); if (configuredRelayUrl && input.clerkToken) { @@ -373,11 +587,27 @@ export function unlinkPrimaryEnvironmentFromCloud(input: { environmentId: EnvironmentId.make(input.target.environmentId), }) .pipe( - Effect.catch((cause) => - Effect.logWarning("Could not revoke cloud environment link after local unlink.", { + Effect.catch((cause) => { + const error = CloudEnvironmentLinkOperationError.fromManagedRelay({ + action: "revoke the cloud environment link", + environmentId: input.target.environmentId, + relayUrl: configuredRelayUrl, cause, - }), - ), + }); + return Effect.logWarning(error.message, { + environmentId: input.target.environmentId, + ...(error.relayUrlInputLength === undefined + ? {} + : { relayUrlInputLength: error.relayUrlInputLength }), + ...(error.relayUrlProtocol === undefined + ? {} + : { relayUrlProtocol: error.relayUrlProtocol }), + ...(error.relayUrlHostname === undefined + ? {} + : { relayUrlHostname: error.relayUrlHostname }), + causeTag: cause._tag, + }); + }), ); } }).pipe(Effect.provide(primaryEnvironmentHttpLayer)); @@ -394,12 +624,19 @@ export function linkPrimaryEnvironmentToCloud(input: { return Effect.gen(function* () { const configuredRelayUrl = relayUrl(); if (!configuredRelayUrl) { - return yield* new CloudEnvironmentLinkError({ - message: "T3CODE_RELAY_URL is not configured.", + return yield* new CloudRelayUrlNotConfiguredError({ + environmentId: input.target.environmentId, }); } + const origin = yield* endpointOrigin({ + environmentId: input.target.environmentId, + httpBaseUrl: input.target.httpBaseUrl, + }); + const environmentClient = yield* makeCloudEnvironmentHttpApiClient({ + environmentId: input.target.environmentId, + httpBaseUrl: input.target.httpBaseUrl, + }); const relayClient = yield* ManagedRelay.ManagedRelayClient; - const environmentClient = yield* makeEnvironmentHttpApiClient(input.target.httpBaseUrl); yield* ensureRelayClientAvailable(EnvironmentId.make(input.target.environmentId)); const challenge = yield* relayClient @@ -412,10 +649,13 @@ export function linkPrimaryEnvironmentToCloud(input: { }, }) .pipe( - Effect.mapError( - decodedRelayClientError( - `${configuredRelayUrl}/v1/client/environment-link-challenges failed`, - ), + Effect.mapError((cause) => + CloudEnvironmentLinkOperationError.fromManagedRelay({ + action: "create an environment link challenge", + environmentId: input.target.environmentId, + relayUrl: configuredRelayUrl, + cause, + }), ), ); const proof = yield* environmentClient.connect @@ -429,10 +669,19 @@ export function linkPrimaryEnvironmentToCloud(input: { wsBaseUrl: input.target.wsBaseUrl, providerKind: MANAGED_ENDPOINT_PROVIDER_KIND, }, - origin: endpointOrigin(input.target.httpBaseUrl), + origin, }, }) - .pipe(Effect.mapError(environmentApiError("Could not obtain environment link proof."))); + .pipe( + Effect.mapError((cause) => + CloudEnvironmentLinkOperationError.fromEnvironmentApi({ + action: "obtain an environment link proof", + environmentId: input.target.environmentId, + httpBaseUrl: input.target.httpBaseUrl, + cause, + }), + ), + ); const link = yield* relayClient .linkEnvironment({ clerkToken: input.clerkToken, @@ -444,8 +693,13 @@ export function linkPrimaryEnvironmentToCloud(input: { }, }) .pipe( - Effect.mapError( - decodedRelayClientError(`${configuredRelayUrl}/v1/client/environment-links failed`), + Effect.mapError((cause) => + CloudEnvironmentLinkOperationError.fromManagedRelay({ + action: "link the environment", + environmentId: input.target.environmentId, + relayUrl: configuredRelayUrl, + cause, + }), ), ); yield* ensureLinkedEnvironmentMatches({ @@ -466,6 +720,15 @@ export function linkPrimaryEnvironmentToCloud(input: { endpointRuntime: link.endpointRuntime, }, }) - .pipe(Effect.mapError(environmentApiError("Could not configure environment relay access."))); + .pipe( + Effect.mapError((cause) => + CloudEnvironmentLinkOperationError.fromEnvironmentApi({ + action: "configure environment relay access", + environmentId: input.target.environmentId, + httpBaseUrl: input.target.httpBaseUrl, + cause, + }), + ), + ); }).pipe(Effect.provide(primaryEnvironmentHttpLayer)); } diff --git a/apps/web/src/cloud/managedRelayLayer.ts b/apps/web/src/cloud/managedRelayLayer.ts index 52f9b6496c9..6fc631f40ff 100644 --- a/apps/web/src/cloud/managedRelayLayer.ts +++ b/apps/web/src/cloud/managedRelayLayer.ts @@ -50,25 +50,24 @@ export const relayDpopSignerLayer = Layer.effect( ), createProof: Effect.fn("web.managedRelayDpopSigner.createProof")(function* (input) { const proofKey = yield* loadOrCreateBrowserDpopKey.pipe( - Effect.mapError( - (error) => - new ManagedRelay.ManagedRelayDpopProofCreationError({ - method: input.method, - url: input.url, - cause: error, - }), + Effect.mapError((error) => + ManagedRelay.ManagedRelayDpopKeyLoadError.fromTarget({ + keyStore: "indexed-db", + method: input.method, + url: input.url, + cause: error, + }), ), ); return yield* createBrowserDpopProof({ ...input, proofKey }).pipe( Effect.provideService(Crypto.Crypto, crypto), Effect.map((proof) => proof.proof), - Effect.mapError( - (error) => - new ManagedRelay.ManagedRelayDpopProofCreationError({ - method: input.method, - url: input.url, - cause: error, - }), + Effect.mapError((error) => + ManagedRelay.ManagedRelayDpopProofCreationError.fromTarget({ + method: input.method, + url: input.url, + cause: error, + }), ), ); }), diff --git a/apps/web/src/components/preview/PreviewMoreMenu.tsx b/apps/web/src/components/preview/PreviewMoreMenu.tsx index f11ff4d2d30..6ffefe53600 100644 --- a/apps/web/src/components/preview/PreviewMoreMenu.tsx +++ b/apps/web/src/components/preview/PreviewMoreMenu.tsx @@ -7,6 +7,7 @@ import { Menu, MenuItem, MenuPopup, MenuSeparator, MenuTrigger } from "~/compone import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; import { previewBridge } from "./previewBridge"; +import { reportPreviewActionFailure } from "./reportPreviewActionFailure"; interface Props { /** Active preview tab id. Tab-targeting actions are disabled without it. */ @@ -30,9 +31,11 @@ export function PreviewMoreMenu({ tabId, hasWebContents, zoomFactor }: Props) { if (!previewBridge) return null; const bridge = previewBridge; const tabDisabled = !tabId || !hasWebContents; - const callTab = (op: (tabId: string) => Promise) => () => { + const callTab = (operation: string, op: (tabId: string) => Promise) => () => { if (!tabId) return; - void op(tabId).catch(() => undefined); + void op(tabId).catch((cause) => { + reportPreviewActionFailure({ operation, tabId }, cause); + }); }; const zoomLabel = `${Math.round(zoomFactor * 100)}%`; @@ -53,10 +56,10 @@ export function PreviewMoreMenu({ tabId, hasWebContents, zoomFactor }: Props) { More - + Hard reload - + Open DevTools {/* @@ -75,7 +78,7 @@ export function PreviewMoreMenu({ tabId, hasWebContents, zoomFactor }: Props) { variant="outline" size="icon-xs" type="button" - onClick={callTab(bridge.zoomOut)} + onClick={callTab("zoom-out", bridge.zoomOut)} aria-label="Zoom out" disabled={tabDisabled} > @@ -88,7 +91,7 @@ export function PreviewMoreMenu({ tabId, hasWebContents, zoomFactor }: Props) { variant="outline" size="icon-xs" type="button" - onClick={callTab(bridge.zoomIn)} + onClick={callTab("zoom-in", bridge.zoomIn)} aria-label="Zoom in" disabled={tabDisabled} > @@ -98,7 +101,7 @@ export function PreviewMoreMenu({ tabId, hasWebContents, zoomFactor }: Props) { variant="ghost" size="icon-xs" type="button" - onClick={callTab(bridge.resetZoom)} + onClick={callTab("reset-zoom", bridge.resetZoom)} aria-label="Reset zoom" disabled={tabDisabled} > @@ -107,10 +110,22 @@ export function PreviewMoreMenu({ tabId, hasWebContents, zoomFactor }: Props) { - void bridge.clearCookies().catch(() => undefined)}> + + void bridge.clearCookies().catch((cause) => { + reportPreviewActionFailure({ operation: "clear-cookies" }, cause); + }) + } + > Clear cookies - void bridge.clearCache().catch(() => undefined)}> + + void bridge.clearCache().catch((cause) => { + reportPreviewActionFailure({ operation: "clear-cache" }, cause); + }) + } + > Clear cache diff --git a/apps/web/src/components/preview/PreviewView.tsx b/apps/web/src/components/preview/PreviewView.tsx index 861a8df616b..038859f881a 100644 --- a/apps/web/src/components/preview/PreviewView.tsx +++ b/apps/web/src/components/preview/PreviewView.tsx @@ -1,6 +1,7 @@ "use client"; import { scopedThreadKey } from "@t3tools/client-runtime/environment"; +import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime"; import { type ScopedThreadRef } from "@t3tools/contracts"; import { useCallback, useEffect, useRef, useState } from "react"; @@ -20,6 +21,7 @@ import { PreviewChromeRow } from "./PreviewChromeRow"; import { formatPreviewUrl } from "./previewUrlPresentation"; import { PreviewEmptyState } from "./PreviewEmptyState"; import { PreviewMoreMenu } from "./PreviewMoreMenu"; +import { previewUrlFailureContext, reportPreviewActionFailure } from "./reportPreviewActionFailure"; import { PreviewUnreachable } from "./PreviewUnreachable"; import { revealInFileExplorerLabel } from "./fileExplorerLabel"; import { shouldShowPreviewEmptyState } from "./previewEmptyStateLogic"; @@ -91,58 +93,112 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, environmentHttpBaseUrl, }) ?? undefined) : undefined; + const threadKey = scopedThreadKey(threadRef); const handleSubmitUrl = useCallback( async (next: string) => { + let operation = "resolve-url"; + let targetUrl = next; try { const resolvedUrl = resolveDiscoveredServerUrl(threadRef.environmentId, next); + targetUrl = resolvedUrl; if (tabId && previewBridge) { // Drive the webview imperatively; `usePreviewBridge` mirrors the // resolved URL back to the server so other clients stay in sync. + operation = "navigate"; await previewBridge.navigate(tabId, resolvedUrl); rememberPreviewUrl(threadRef, resolvedUrl); } else { - await openPreviewSession({ + operation = "open-session"; + const result = await openPreviewSession({ openPreview: open, threadRef, url: resolvedUrl, }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + reportPreviewActionFailure( + { + operation, + threadKey, + ...(tabId ? { tabId } : {}), + ...previewUrlFailureContext(targetUrl), + }, + result.cause, + ); + } } - } catch { + } catch (cause) { + reportPreviewActionFailure( + { + operation, + threadKey, + ...(tabId ? { tabId } : {}), + ...previewUrlFailureContext(targetUrl), + }, + cause, + ); // Server-side `failed` event renders the unreachable view. } }, - [open, tabId, threadRef], + [open, tabId, threadKey, threadRef], ); const handleRefresh = useCallback(() => { - if (previewBridge && tabId) void previewBridge.refresh(tabId); - }, [tabId]); + if (!previewBridge || !tabId) return; + void previewBridge.refresh(tabId).catch((cause) => { + reportPreviewActionFailure({ operation: "refresh", threadKey, tabId }, cause); + }); + }, [tabId, threadKey]); const handleZoomIn = useCallback(() => { - if (previewBridge && tabId) void previewBridge.zoomIn(tabId); - }, [tabId]); + if (!previewBridge || !tabId) return; + void previewBridge.zoomIn(tabId).catch((cause) => { + reportPreviewActionFailure({ operation: "zoom-in", threadKey, tabId }, cause); + }); + }, [tabId, threadKey]); const handleZoomOut = useCallback(() => { - if (previewBridge && tabId) void previewBridge.zoomOut(tabId); - }, [tabId]); + if (!previewBridge || !tabId) return; + void previewBridge.zoomOut(tabId).catch((cause) => { + reportPreviewActionFailure({ operation: "zoom-out", threadKey, tabId }, cause); + }); + }, [tabId, threadKey]); const handleResetZoom = useCallback(() => { - if (previewBridge && tabId) void previewBridge.resetZoom(tabId); - }, [tabId]); + if (!previewBridge || !tabId) return; + void previewBridge.resetZoom(tabId).catch((cause) => { + reportPreviewActionFailure({ operation: "reset-zoom", threadKey, tabId }, cause); + }); + }, [tabId, threadKey]); const handleBack = useCallback(() => { - if (previewBridge && tabId) void previewBridge.goBack(tabId); - }, [tabId]); + if (!previewBridge || !tabId) return; + void previewBridge.goBack(tabId).catch((cause) => { + reportPreviewActionFailure({ operation: "go-back", threadKey, tabId }, cause); + }); + }, [tabId, threadKey]); const handleForward = useCallback(() => { - if (previewBridge && tabId) void previewBridge.goForward(tabId); - }, [tabId]); + if (!previewBridge || !tabId) return; + void previewBridge.goForward(tabId).catch((cause) => { + reportPreviewActionFailure({ operation: "go-forward", threadKey, tabId }, cause); + }); + }, [tabId, threadKey]); const handleOpenInBrowser = useCallback(() => { if (!localApi || !url) return; - void localApi.shell.openExternal(url).catch(() => undefined); - }, [url]); + void localApi.shell.openExternal(url).catch((cause) => { + reportPreviewActionFailure( + { + operation: "open-external", + threadKey, + ...(tabId ? { tabId } : {}), + ...previewUrlFailureContext(url), + }, + cause, + ); + }); + }, [tabId, threadKey, url]); const handleCapture = useCallback( (record: boolean) => { @@ -180,6 +236,15 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, }, 2_000); }, (error) => { + reportPreviewActionFailure( + { + operation: "copy-recording-path", + threadKey, + tabId, + artifactPath: artifact.path, + }, + error, + ); toastManager.update( toastId, stackedThreadToast({ @@ -195,7 +260,18 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, const revealAction = { children: revealInFileExplorerLabel(navigator.platform), - onClick: () => void bridge.revealArtifact(artifact.path), + onClick: () => + void bridge.revealArtifact(artifact.path).catch((cause) => { + reportPreviewActionFailure( + { + operation: "reveal-recording", + threadKey, + tabId, + artifactPath: artifact.path, + }, + cause, + ); + }), }; const updateRecordingToast = () => { toastManager.update( @@ -232,6 +308,7 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, ); }, (error) => { + reportPreviewActionFailure({ operation: "stop-recording", threadKey, tabId }, error); toastManager.add({ type: "error", title: "Unable to stop recording", @@ -251,6 +328,7 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, return; } void startBrowserRecording(tabId).catch((error) => { + reportPreviewActionFailure({ operation: "start-recording", threadKey, tabId }, error); toastManager.add({ type: "error", title: "Unable to start recording", @@ -263,7 +341,18 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, (artifact) => { const revealAction = { children: revealInFileExplorerLabel(navigator.platform), - onClick: () => void bridge.revealArtifact(artifact.path), + onClick: () => + void bridge.revealArtifact(artifact.path).catch((cause) => { + reportPreviewActionFailure( + { + operation: "reveal-screenshot", + threadKey, + tabId, + artifactPath: artifact.path, + }, + cause, + ); + }), }; let pathCopied = false; let imageCopied = false; @@ -325,6 +414,15 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, }, 2_000); }, (error) => { + reportPreviewActionFailure( + { + operation: "copy-screenshot-path", + threadKey, + tabId, + artifactPath: artifact.path, + }, + error, + ); updateScreenshotToast( "error", "Unable to copy screenshot path", @@ -345,6 +443,15 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, }, 2_000); }, (error) => { + reportPreviewActionFailure( + { + operation: "copy-screenshot-image", + threadKey, + tabId, + artifactPath: artifact.path, + }, + error, + ); updateScreenshotToast( "error", "Unable to copy screenshot", @@ -381,6 +488,7 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, ); }, (error) => { + reportPreviewActionFailure({ operation: "capture-screenshot", threadKey, tabId }, error); toastManager.add({ type: "error", title: "Unable to capture screenshot", @@ -389,13 +497,18 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, }, ); }, - [activeRecordingTabId, tabId], + [activeRecordingTabId, tabId, threadKey], ); const handlePickElement = useCallback(() => { if (!previewBridge || !tabId) return; if (pickActiveRef.current) { - void previewBridge.cancelPickElement(tabId).catch(() => undefined); + void previewBridge.cancelPickElement(tabId).catch((cause) => { + reportPreviewActionFailure( + { operation: "cancel-element-picker", threadKey, tabId, trigger: "toggle" }, + cause, + ); + }); return; } // Snapshot whatever the user was focused on (typically the chat @@ -408,12 +521,18 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, pickActiveRef.current = true; setPickActive(true); void (async () => { + let operation = "pick-element"; + let annotationId: string | undefined; try { const annotation = await previewBridge.pickElement(tabId); if (!annotation) return; + annotationId = annotation.id; + operation = "add-preview-annotation"; addPreviewAnnotation(threadRef, annotation); + operation = "prepare-annotation-screenshot"; const screenshotFile = await previewAnnotationScreenshotFile(annotation); if (screenshotFile && annotation.screenshot) { + operation = "attach-annotation-screenshot"; addImage(threadRef, { type: "image", id: annotation.id, @@ -424,8 +543,17 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, file: screenshotFile, }); } - } catch { - // Picker failed (e.g. webview navigated). Treat as silent cancel. + } catch (cause) { + reportPreviewActionFailure( + { + operation, + threadKey, + tabId, + ...(annotationId ? { annotationId } : {}), + }, + cause, + ); + // Keep picker failures silent in the UI; navigating during a pick is expected. } finally { pickActiveRef.current = false; // Avoid `setState on unmounted component` if the panel/thread closed @@ -447,7 +575,7 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, } } })(); - }, [addImage, addPreviewAnnotation, tabId, threadRef]); + }, [addImage, addPreviewAnnotation, tabId, threadKey, threadRef]); // If the active tab changes mid-pick (close, thread switch, hot restart), // tell main to tear down the in-flight session AND reset our local toggle @@ -457,11 +585,16 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, if (!pickActiveRef.current) return; pickActiveRef.current = false; if (previewBridge && tabId) { - void previewBridge.cancelPickElement(tabId).catch(() => undefined); + void previewBridge.cancelPickElement(tabId).catch((cause) => { + reportPreviewActionFailure( + { operation: "cancel-element-picker", threadKey, tabId, trigger: "tab-change" }, + cause, + ); + }); } if (isMountedRef.current) setPickActive(false); }; - }, [tabId]); + }, [tabId, threadKey]); // Subscribe only while visible; `toggle-panel` is owned by ChatView's // URL-aware handler regardless of whether the panel is currently mounted. @@ -491,10 +624,7 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, }, [handleRefresh, handleResetZoom, handleZoomIn, handleZoomOut, visible]); return ( -
+
{ + it("logs safe preview target metadata without credentials or URL parameters", () => { + const url = + "https://user:password@example.com/preview/signed-secret-token?access_token=private#fragment"; + const cause = new Error("navigation failed"); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); + + reportPreviewActionFailure( + { + operation: "navigate", + ...previewUrlFailureContext(url), + }, + cause, + ); + + expect(consoleError).toHaveBeenCalledWith( + "[preview] action failed", + { + operation: "navigate", + urlHostname: "example.com", + urlLength: url.length, + urlProtocol: "https:", + }, + cause, + ); + const loggedContext = consoleError.mock.calls[0]?.[1]; + expect(loggedContext).not.toHaveProperty("url"); + expect(JSON.stringify(loggedContext)).not.toContain("signed-secret-token"); + expect(JSON.stringify(loggedContext)).not.toContain("access_token=private"); + + consoleError.mockRestore(); + }); +}); diff --git a/apps/web/src/components/preview/reportPreviewActionFailure.ts b/apps/web/src/components/preview/reportPreviewActionFailure.ts new file mode 100644 index 00000000000..ceaac4cd1a9 --- /dev/null +++ b/apps/web/src/components/preview/reportPreviewActionFailure.ts @@ -0,0 +1,29 @@ +import { getUrlDiagnostics } from "@t3tools/shared/urlDiagnostics"; + +export interface PreviewActionFailureContext { + readonly operation: string; + readonly threadKey?: string; + readonly tabId?: string; + readonly urlHostname?: string; + readonly urlLength?: number; + readonly urlProtocol?: string; + readonly artifactPath?: string; + readonly annotationId?: string; + readonly trigger?: string; +} + +export function previewUrlFailureContext(url: string) { + const diagnostics = getUrlDiagnostics(url); + return { + urlLength: diagnostics.inputLength, + ...(diagnostics.hostname === undefined ? {} : { urlHostname: diagnostics.hostname }), + ...(diagnostics.protocol === undefined ? {} : { urlProtocol: diagnostics.protocol }), + }; +} + +export function reportPreviewActionFailure( + context: PreviewActionFailureContext, + cause: unknown, +): void { + console.error("[preview] action failed", context, cause); +} diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 96d9dd4510f..a8bb5f20f01 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -1837,7 +1837,7 @@ function ConfiguredCloudRemoteEnvironmentRows({ ? "Relay offline" : availability === "checking" ? "Checking relay status" - : (Option.getOrNull(error)?.message ?? "Relay status unavailable") + : (Option.getOrNull(error)?.detail ?? "Relay status unavailable") } />

{environment.label}

@@ -1854,7 +1854,7 @@ function ConfiguredCloudRemoteEnvironmentRows({ ? "Available · Relay offline" : availability === "checking" ? "Available · Checking relay status…" - : (Option.getOrNull(error)?.message ?? "Available · Relay status unavailable")} + : (Option.getOrNull(error)?.detail ?? "Available · Relay status unavailable")}