diff --git a/apps/desktop/src/ipc/methods/sshEnvironment.ts b/apps/desktop/src/ipc/methods/sshEnvironment.ts
index 9c9af2a4e2b..c3628e3c238 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";
@@ -52,10 +52,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)) {
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/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/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts
index cbdf58c4d67..846c91e6f78 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/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/packages/client-runtime/src/authorization/remote.test.ts b/packages/client-runtime/src/authorization/remote.test.ts
index 6e6ccc86052..6db80e31233 100644
--- a/packages/client-runtime/src/authorization/remote.test.ts
+++ b/packages/client-runtime/src/authorization/remote.test.ts
@@ -391,7 +391,9 @@ describe("remote environment authorization", () => {
expect(error).toBeInstanceOf(RemoteEnvironmentAuthTimeoutError);
expect(error.message).toBe(
- "Remote environment endpoint http://remote.example.com/.well-known/t3/environment timed out after 25ms.",
+ `Remote environment endpoint at host remote.example.com (${
+ "http://remote.example.com/.well-known/t3/environment".length
+ } URL characters) timed out after 25ms.`,
);
}).pipe(Effect.provide(TestClock.layer())),
);
@@ -446,7 +448,9 @@ describe("remote environment authorization", () => {
expect(error).toBeInstanceOf(RemoteEnvironmentAuthInvalidJsonError);
expect(error.message).toBe(
- "Remote environment endpoint returned an invalid response from https://remote.example.com/oauth/token.",
+ `Remote environment endpoint at host remote.example.com (${
+ "https://remote.example.com/oauth/token".length
+ } URL characters) returned an invalid response.`,
);
}),
);
diff --git a/packages/client-runtime/src/authorization/remote.ts b/packages/client-runtime/src/authorization/remote.ts
index 69c157d0e50..4cfa6877ec7 100644
--- a/packages/client-runtime/src/authorization/remote.ts
+++ b/packages/client-runtime/src/authorization/remote.ts
@@ -15,6 +15,7 @@ import {
} from "../rpc/http.ts";
export {
+ isRemoteEnvironmentAuthUndeclaredStatusError,
RemoteEnvironmentAuthFetchError,
RemoteEnvironmentAuthInvalidJsonError,
RemoteEnvironmentAuthTimeoutError,
diff --git a/packages/client-runtime/src/authorization/service.ts b/packages/client-runtime/src/authorization/service.ts
index 624ecf7f672..adcc7072d96 100644
--- a/packages/client-runtime/src/authorization/service.ts
+++ b/packages/client-runtime/src/authorization/service.ts
@@ -6,7 +6,7 @@ import {
resolveRemoteDpopWebSocketConnectionUrl,
resolveRemoteWebSocketConnectionUrl,
} from "./remote.ts";
-import { environmentMismatchError, mapRemoteEnvironmentError } from "../connection/errors.ts";
+import { mapRemoteEnvironmentError } from "../connection/errors.ts";
import { ConnectionBlockedError, type ConnectionAttemptError } from "../connection/model.ts";
import { fetchRemoteEnvironmentDescriptor } from "../environment/descriptor.ts";
import { environmentEndpointUrl } from "../environment/endpoint.ts";
@@ -112,9 +112,11 @@ export const make = Effect.gen(function* () {
Effect.provideService(HttpClient.HttpClient, httpClient),
);
if (descriptor.environmentId !== input.expectedEnvironmentId) {
- return yield* environmentMismatchError({
- expected: input.expectedEnvironmentId,
- actual: descriptor.environmentId,
+ return yield* new ConnectionBlockedError({
+ reason: "configuration",
+ detail: `Connected environment ${descriptor.environmentId} does not match ${input.expectedEnvironmentId}.`,
+ expectedEnvironmentId: input.expectedEnvironmentId,
+ actualEnvironmentId: descriptor.environmentId,
});
}
const socketUrl = yield* resolveRemoteWebSocketConnectionUrl({
@@ -148,10 +150,11 @@ export const make = Effect.gen(function* () {
})
.pipe(
Effect.mapError(
- () =>
+ (cause) =>
new ConnectionBlockedError({
reason: "configuration",
detail: "Could not create the websocket authorization proof.",
+ cause,
}),
),
);
@@ -175,10 +178,11 @@ export const make = Effect.gen(function* () {
}) {
const thumbprint = yield* signer.thumbprint.pipe(
Effect.mapError(
- () =>
+ (cause) =>
new ConnectionBlockedError({
reason: "configuration",
detail: "Could not load the environment authorization key.",
+ cause,
}),
),
Effect.withSpan("environment.authorization.dpopKey.resolve"),
@@ -236,9 +240,11 @@ export const make = Effect.gen(function* () {
Effect.withSpan("environment.authorization.descriptor"),
);
if (descriptor.environmentId !== input.expectedEnvironmentId) {
- return yield* environmentMismatchError({
- expected: input.expectedEnvironmentId,
- actual: descriptor.environmentId,
+ return yield* new ConnectionBlockedError({
+ reason: "configuration",
+ detail: `Connected environment ${descriptor.environmentId} does not match ${input.expectedEnvironmentId}.`,
+ expectedEnvironmentId: input.expectedEnvironmentId,
+ actualEnvironmentId: descriptor.environmentId,
});
}
const bootstrapProof = yield* signer
@@ -248,10 +254,11 @@ export const make = Effect.gen(function* () {
})
.pipe(
Effect.mapError(
- () =>
+ (cause) =>
new ConnectionBlockedError({
reason: "configuration",
detail: "Could not create the environment authorization proof.",
+ cause,
}),
),
);
diff --git a/packages/client-runtime/src/connection/errors.test.ts b/packages/client-runtime/src/connection/errors.test.ts
new file mode 100644
index 00000000000..c078408c48a
--- /dev/null
+++ b/packages/client-runtime/src/connection/errors.test.ts
@@ -0,0 +1,120 @@
+import { EnvironmentAuthInvalidError } from "@t3tools/contracts";
+import { RelayAuthInvalidError } from "@t3tools/contracts/relay";
+import { describe, expect, it } from "@effect/vitest";
+
+import { mapManagedRelayError, mapRemoteEnvironmentError } from "./errors.ts";
+import * as ManagedRelay from "../relay/managedRelay.ts";
+import {
+ RemoteEnvironmentAuthFetchError,
+ RemoteEnvironmentAuthUndeclaredStatusError,
+} from "../rpc/http.ts";
+
+describe("connection error mapping", () => {
+ it("retains the managed relay request as the cause when classifying a protected error", () => {
+ const relayError = new RelayAuthInvalidError({
+ code: "auth_invalid",
+ reason: "invalid_bearer",
+ traceId: "relay-trace-id",
+ });
+ const source = new ManagedRelay.ManagedRelayRequestFailedError({
+ action: "connect relay environment",
+ cause: relayError,
+ relayError,
+ traceId: relayError.traceId,
+ });
+
+ const error = mapManagedRelayError(source);
+
+ expect(error).toMatchObject({
+ _tag: "ConnectionBlockedError",
+ reason: "authentication",
+ traceId: "relay-trace-id",
+ });
+ expect(error.cause).toBe(source);
+ });
+
+ it("retains a managed relay timeout and its structured activity", () => {
+ const source = new ManagedRelay.ManagedRelayRequestTimeoutError({
+ activity: "Relay environment connection",
+ timeoutMs: 10_000,
+ });
+
+ const error = mapManagedRelayError(source);
+
+ expect(error).toMatchObject({
+ _tag: "ConnectionTransientError",
+ reason: "timeout",
+ });
+ expect(error.cause).toBe(source);
+ expect(source.activity).toBe("Relay environment connection");
+ });
+
+ it("retains structured remote authorization failures", () => {
+ const source = new EnvironmentAuthInvalidError({
+ code: "auth_invalid",
+ reason: "invalid_credential",
+ traceId: "environment-trace-id",
+ });
+
+ const error = mapRemoteEnvironmentError(source);
+
+ expect(error).toMatchObject({
+ _tag: "ConnectionBlockedError",
+ reason: "authentication",
+ traceId: "environment-trace-id",
+ });
+ expect(error.cause).toBe(source);
+ });
+
+ it("retains local transport failures without deriving their message from the cause", () => {
+ const transportCause = new Error("sensitive transport implementation detail");
+ const requestUrl =
+ "https://environment-user:environment-password@environment.example.test/private/session?access_token=environment-secret#environment-fragment";
+ const source = RemoteEnvironmentAuthFetchError.fromRequestUrl(requestUrl, transportCause);
+
+ const error = mapRemoteEnvironmentError(source);
+
+ expect(source.message).toBe(
+ `Failed to fetch remote environment endpoint at host environment.example.test (${requestUrl.length} URL characters).`,
+ );
+ expect(source.message).not.toContain(transportCause.message);
+ expect(error).toMatchObject({
+ _tag: "ConnectionTransientError",
+ reason: "network",
+ });
+ expect(error.cause).toBe(source);
+ expect(source.cause).toBe(transportCause);
+ expect(source).toMatchObject({
+ requestUrlInputLength: requestUrl.length,
+ requestUrlProtocol: "https:",
+ requestUrlHostname: "environment.example.test",
+ });
+ const diagnostics = JSON.stringify(source);
+ for (const secret of [
+ "environment-user",
+ "environment-password",
+ "/private/session",
+ "environment-secret",
+ "environment-fragment",
+ ]) {
+ expect(diagnostics).not.toContain(secret);
+ expect(source.message).not.toContain(secret);
+ }
+ });
+
+ it("retains the HTTP client cause for undeclared statuses", () => {
+ const cause = new Error("upstream response metadata");
+ const requestUrl =
+ "https://environment-user:environment-password@environment.example.test/private/session?access_token=environment-secret#environment-fragment";
+ const error = RemoteEnvironmentAuthUndeclaredStatusError.fromRequestUrl(requestUrl, 502, cause);
+
+ expect(error).toMatchObject({
+ status: 502,
+ requestUrlInputLength: requestUrl.length,
+ requestUrlProtocol: "https:",
+ requestUrlHostname: "environment.example.test",
+ });
+ expect(error.cause).toBe(cause);
+ expect(error).not.toHaveProperty("requestUrl");
+ });
+});
diff --git a/packages/client-runtime/src/connection/errors.ts b/packages/client-runtime/src/connection/errors.ts
index f70e41adfe7..36a6fcc8fa8 100644
--- a/packages/client-runtime/src/connection/errors.ts
+++ b/packages/client-runtime/src/connection/errors.ts
@@ -1,6 +1,8 @@
-import type { EnvironmentId } from "@t3tools/contracts";
import type { RelayProtectedError } from "@t3tools/contracts/relay";
-import type { ManagedRelayClientError } from "../relay/managedRelay.ts";
+import type {
+ ManagedRelayClientError,
+ ManagedRelayRequestFailedError,
+} from "../relay/managedRelay.ts";
import type { RemoteEnvironmentAuthError } from "../authorization/remote.ts";
import {
ConnectionBlockedError,
@@ -8,31 +10,10 @@ import {
ConnectionTransientError,
} from "./model.ts";
-export function profileMissingError(connectionId: string): ConnectionBlockedError {
- return new ConnectionBlockedError({
- reason: "configuration",
- detail: `Connection profile ${connectionId} is unavailable.`,
- });
-}
-
-export function credentialMissingError(connectionId: string): ConnectionBlockedError {
- return new ConnectionBlockedError({
- reason: "authentication",
- detail: `Connection credential ${connectionId} is unavailable.`,
- });
-}
-
-export function environmentMismatchError(input: {
- readonly expected: EnvironmentId;
- readonly actual: EnvironmentId;
-}): ConnectionBlockedError {
- return new ConnectionBlockedError({
- reason: "configuration",
- detail: `Connected environment ${input.actual} does not match ${input.expected}.`,
- });
-}
-
-function relayProtectedError(error: RelayProtectedError): ConnectionAttemptError {
+function connectionErrorFromRelayProtectedError(
+ error: RelayProtectedError,
+ cause: ManagedRelayRequestFailedError,
+): ConnectionAttemptError {
switch (error._tag) {
case "RelayAuthInvalidError":
case "RelayEnvironmentLinkProofExpiredError":
@@ -42,6 +23,7 @@ function relayProtectedError(error: RelayProtectedError): ConnectionAttemptError
reason: "authentication",
detail: error.message,
traceId: error.traceId,
+ cause,
});
case "RelayEnvironmentConnectNotAuthorizedError":
case "RelayEnvironmentLinkProofInvalidError":
@@ -49,12 +31,14 @@ function relayProtectedError(error: RelayProtectedError): ConnectionAttemptError
reason: "permission",
detail: error.message,
traceId: error.traceId,
+ cause,
});
case "RelayEnvironmentEndpointTimedOutError":
return new ConnectionTransientError({
reason: "timeout",
detail: error.message,
traceId: error.traceId,
+ cause,
});
case "RelayEnvironmentEndpointUnavailableError":
case "RelayEnvironmentLinkUnavailableError":
@@ -62,6 +46,7 @@ function relayProtectedError(error: RelayProtectedError): ConnectionAttemptError
reason: "endpoint-unavailable",
detail: error.message,
traceId: error.traceId,
+ cause,
});
case "RelayEnvironmentLinkFailedError":
case "RelayInternalError":
@@ -69,6 +54,7 @@ function relayProtectedError(error: RelayProtectedError): ConnectionAttemptError
reason: "relay-unavailable",
detail: error.message,
traceId: error.traceId,
+ cause,
});
}
}
@@ -77,27 +63,31 @@ export function mapManagedRelayError(error: ManagedRelayClientError): Connection
switch (error._tag) {
case "ManagedRelayRequestFailedError":
if (error.relayError) {
- return relayProtectedError(error.relayError);
+ return connectionErrorFromRelayProtectedError(error.relayError, error);
}
return new ConnectionTransientError({
reason: "relay-unavailable",
detail: error.message,
...(error.traceId ? { traceId: error.traceId } : {}),
+ cause: error,
});
case "ManagedRelayRequestTimeoutError":
return new ConnectionTransientError({
reason: "timeout",
detail: error.message,
+ cause: error,
});
case "ManagedRelayUrlInvalidError":
return new ConnectionBlockedError({
reason: "configuration",
detail: error.message,
+ cause: error,
});
case "ManagedRelayAccessTokenScopesUnexpectedError":
return new ConnectionBlockedError({
reason: "permission",
detail: error.message,
+ cause: error,
});
case "ManagedRelayDpopKeyLoadError":
case "ManagedRelayTokenProofCreationError":
@@ -105,6 +95,7 @@ export function mapManagedRelayError(error: ManagedRelayClientError): Connection
return new ConnectionBlockedError({
reason: "authentication",
detail: error.message,
+ cause: error,
});
}
}
@@ -118,6 +109,7 @@ export function mapRemoteEnvironmentError(
reason: "authentication",
detail: "The environment credential is invalid.",
traceId: error.traceId,
+ cause: error,
});
case "EnvironmentScopeRequiredError":
case "EnvironmentOperationForbiddenError":
@@ -125,34 +117,40 @@ export function mapRemoteEnvironmentError(
reason: "permission",
detail: "The environment credential does not grant the required access.",
traceId: error.traceId,
+ cause: error,
});
case "EnvironmentRequestInvalidError":
return new ConnectionBlockedError({
reason: "configuration",
detail: "The environment rejected the authentication request.",
traceId: error.traceId,
+ cause: error,
});
case "RemoteEnvironmentAuthTimeoutError":
return new ConnectionTransientError({
reason: "timeout",
detail: error.message,
+ cause: error,
});
case "RemoteEnvironmentAuthFetchError":
return new ConnectionTransientError({
reason: "network",
detail: error.message,
+ cause: error,
});
case "EnvironmentInternalError":
return new ConnectionTransientError({
reason: "remote-unavailable",
detail: "The environment could not authorize the connection.",
traceId: error.traceId,
+ cause: error,
});
case "RemoteEnvironmentAuthInvalidJsonError":
case "RemoteEnvironmentAuthUndeclaredStatusError":
return new ConnectionTransientError({
reason: "remote-unavailable",
detail: error.message,
+ cause: error,
});
}
}
diff --git a/packages/client-runtime/src/connection/model.ts b/packages/client-runtime/src/connection/model.ts
index fbcb302ed13..df5fca25ad4 100644
--- a/packages/client-runtime/src/connection/model.ts
+++ b/packages/client-runtime/src/connection/model.ts
@@ -81,6 +81,7 @@ export class ConnectionTransientError extends Schema.TaggedErrorClass
acquireSupervisor(target.environmentId).pipe(
- Effect.catchTag("EnvironmentNotRegisteredError", () => Effect.void),
+ Effect.catchTags({
+ EnvironmentNotRegisteredError: () => Effect.void,
+ }),
),
{
concurrency: "unbounded",
@@ -516,7 +518,9 @@ export const make = Effect.gen(function* () {
relayEnvironmentIds,
(environmentId) =>
remove(environmentId).pipe(
- Effect.catchTag("EnvironmentNotRegisteredError", () => Effect.void),
+ Effect.catchTags({
+ EnvironmentNotRegisteredError: () => Effect.void,
+ }),
),
{
concurrency: "unbounded",
@@ -529,7 +533,9 @@ export const make = Effect.gen(function* () {
const retryNow = (environmentId: EnvironmentId) =>
acquireSupervisor(environmentId).pipe(
Effect.flatMap((supervisor) => supervisor.retryNow),
- Effect.catchTag("EnvironmentNotRegisteredError", () => Effect.void),
+ Effect.catchTags({
+ EnvironmentNotRegisteredError: () => Effect.void,
+ }),
Effect.withSpan("EnvironmentRegistry.retryNow"),
);
const state = Effect.fn("EnvironmentRegistry.state")(function* (environmentId: EnvironmentId) {
diff --git a/packages/client-runtime/src/connection/resolver.test.ts b/packages/client-runtime/src/connection/resolver.test.ts
index 0469e459d16..3e7dbc17dd8 100644
--- a/packages/client-runtime/src/connection/resolver.test.ts
+++ b/packages/client-runtime/src/connection/resolver.test.ts
@@ -23,6 +23,7 @@ import {
import * as ConnectionCredentialStore from "./credentialStore.ts";
import {
BearerConnectionTarget,
+ ConnectionBlockedError,
ConnectionTransientError,
PrimaryConnectionTarget,
RelayConnectionTarget,
@@ -437,6 +438,62 @@ describe("ConnectionResolver", () => {
}),
);
+ it.effect("retains the connection id when a bearer credential is unavailable", () =>
+ Effect.gen(function* () {
+ const target = new BearerConnectionTarget({
+ environmentId: ENVIRONMENT_ID,
+ label: "Saved",
+ connectionId: "saved-1",
+ });
+ const profile = new BearerConnectionProfile({
+ connectionId: "saved-1",
+ environmentId: ENVIRONMENT_ID,
+ label: "Saved",
+ httpBaseUrl: ENDPOINT.httpBaseUrl,
+ wsBaseUrl: ENDPOINT.wsBaseUrl,
+ });
+ const brokerLayer = yield* makeDependencies();
+ const broker = yield* ConnectionResolver.ConnectionResolver.pipe(Effect.provide(brokerLayer));
+
+ const error = yield* Effect.flip(broker.prepare(catalogEntry(target, Option.some(profile))));
+
+ expect(error).toBeInstanceOf(ConnectionBlockedError);
+ expect(error).toMatchObject({
+ reason: "authentication",
+ connectionId: "saved-1",
+ });
+ }),
+ );
+
+ it.effect("retains both environment ids when a connection profile does not match", () =>
+ Effect.gen(function* () {
+ const actualEnvironmentId = EnvironmentId.make("environment-2");
+ const target = new BearerConnectionTarget({
+ environmentId: ENVIRONMENT_ID,
+ label: "Saved",
+ connectionId: "saved-1",
+ });
+ const profile = new BearerConnectionProfile({
+ connectionId: "saved-1",
+ environmentId: actualEnvironmentId,
+ label: "Saved",
+ httpBaseUrl: ENDPOINT.httpBaseUrl,
+ wsBaseUrl: ENDPOINT.wsBaseUrl,
+ });
+ const brokerLayer = yield* makeDependencies();
+ const broker = yield* ConnectionResolver.ConnectionResolver.pipe(Effect.provide(brokerLayer));
+
+ const error = yield* Effect.flip(broker.prepare(catalogEntry(target, Option.some(profile))));
+
+ expect(error).toBeInstanceOf(ConnectionBlockedError);
+ expect(error).toMatchObject({
+ reason: "configuration",
+ expectedEnvironmentId: ENVIRONMENT_ID,
+ actualEnvironmentId,
+ });
+ }),
+ );
+
it.effect("classifies relay request timeouts as retryable connection failures", () =>
Effect.gen(function* () {
const target = new RelayConnectionTarget({
diff --git a/packages/client-runtime/src/connection/resolver.ts b/packages/client-runtime/src/connection/resolver.ts
index c219bde092c..80357e7be12 100644
--- a/packages/client-runtime/src/connection/resolver.ts
+++ b/packages/client-runtime/src/connection/resolver.ts
@@ -16,12 +16,7 @@ import {
SshConnectionProfile,
} from "./catalog.ts";
import * as ConnectionCredentialStore from "./credentialStore.ts";
-import {
- credentialMissingError,
- environmentMismatchError,
- mapManagedRelayError,
- profileMissingError,
-} from "./errors.ts";
+import { mapManagedRelayError } from "./errors.ts";
import type {
BearerConnectionTarget,
ConnectionTarget,
@@ -95,7 +90,14 @@ const makeBearerBroker = Effect.fn("clientRuntime.connection.broker.makeBearer")
) {
const target = entry.target;
const profile = yield* Option.match(entry.profile, {
- onNone: () => Effect.fail(profileMissingError(target.connectionId)),
+ onNone: () =>
+ Effect.fail(
+ new ConnectionBlockedError({
+ reason: "configuration",
+ detail: `Connection profile ${target.connectionId} is unavailable.`,
+ connectionId: target.connectionId,
+ }),
+ ),
onSome: Effect.succeed,
});
if (!isBearerProfile(profile)) {
@@ -105,21 +107,34 @@ const makeBearerBroker = Effect.fn("clientRuntime.connection.broker.makeBearer")
});
}
if (profile.environmentId !== target.environmentId) {
- return yield* environmentMismatchError({
- expected: target.environmentId,
- actual: profile.environmentId,
+ return yield* new ConnectionBlockedError({
+ reason: "configuration",
+ detail: `Connected environment ${profile.environmentId} does not match ${target.environmentId}.`,
+ expectedEnvironmentId: target.environmentId,
+ actualEnvironmentId: profile.environmentId,
});
}
const credential = yield* credentials.get(target.connectionId).pipe(
Effect.flatMap(
Option.match({
- onNone: () => Effect.fail(credentialMissingError(target.connectionId)),
+ onNone: () =>
+ Effect.fail(
+ new ConnectionBlockedError({
+ reason: "authentication",
+ detail: `Connection credential ${target.connectionId} is unavailable.`,
+ connectionId: target.connectionId,
+ }),
+ ),
onSome: Effect.succeed,
}),
),
);
if (!isBearerCredential(credential)) {
- return yield* credentialMissingError(target.connectionId);
+ return yield* new ConnectionBlockedError({
+ reason: "authentication",
+ detail: `Connection credential ${target.connectionId} is unavailable.`,
+ connectionId: target.connectionId,
+ });
}
const authorized = yield* remote.authorizeBearer({
expectedEnvironmentId: target.environmentId,
@@ -164,9 +179,11 @@ const makeRelayBroker = Effect.fn("clientRuntime.connection.broker.makeRelay")(f
})
.pipe(Effect.mapError(mapManagedRelayError));
if (connected.environmentId !== target.environmentId) {
- return yield* environmentMismatchError({
- expected: target.environmentId,
- actual: connected.environmentId,
+ return yield* new ConnectionBlockedError({
+ reason: "configuration",
+ detail: `Connected environment ${connected.environmentId} does not match ${target.environmentId}.`,
+ expectedEnvironmentId: target.environmentId,
+ actualEnvironmentId: connected.environmentId,
});
}
return connected;
@@ -196,7 +213,14 @@ const makeSshBroker = Effect.fn("clientRuntime.connection.broker.makeSsh")(funct
) {
const target = entry.target;
const profile = yield* Option.match(entry.profile, {
- onNone: () => Effect.fail(profileMissingError(target.connectionId)),
+ onNone: () =>
+ Effect.fail(
+ new ConnectionBlockedError({
+ reason: "configuration",
+ detail: `Connection profile ${target.connectionId} is unavailable.`,
+ connectionId: target.connectionId,
+ }),
+ ),
onSome: Effect.succeed,
});
if (!isSshProfile(profile)) {
@@ -206,9 +230,11 @@ const makeSshBroker = Effect.fn("clientRuntime.connection.broker.makeSsh")(funct
});
}
if (profile.environmentId !== target.environmentId) {
- return yield* environmentMismatchError({
- expected: target.environmentId,
- actual: profile.environmentId,
+ return yield* new ConnectionBlockedError({
+ reason: "configuration",
+ detail: `Connected environment ${profile.environmentId} does not match ${target.environmentId}.`,
+ expectedEnvironmentId: target.environmentId,
+ actualEnvironmentId: profile.environmentId,
});
}
const prepared = yield* ssh.prepare({
diff --git a/packages/client-runtime/src/relay/discovery.test.ts b/packages/client-runtime/src/relay/discovery.test.ts
index 6bdc7798fb2..59ec0310873 100644
--- a/packages/client-runtime/src/relay/discovery.test.ts
+++ b/packages/client-runtime/src/relay/discovery.test.ts
@@ -8,6 +8,7 @@ import * as Deferred from "effect/Deferred";
import * as Effect from "effect/Effect";
import * as Fiber from "effect/Fiber";
import * as Layer from "effect/Layer";
+import * as Logger from "effect/Logger";
import * as Option from "effect/Option";
import * as Ref from "effect/Ref";
import * as Stream from "effect/Stream";
@@ -55,7 +56,9 @@ function status(
};
}
-const makeHarness = Effect.fn("RelayDiscoveryTest.makeHarness")(function* () {
+const makeHarness = Effect.fn("RelayDiscoveryTest.makeHarness")(function* (
+ testEnvironments: ReadonlyArray = environments,
+) {
const networkStatus = yield* SubscriptionRef.make("online");
const listCalls = yield* Ref.make(0);
const listFailure = yield* Ref.make(null);
@@ -74,7 +77,7 @@ const makeHarness = Effect.fn("RelayDiscoveryTest.makeHarness")(function* () {
Deferred.Deferred
>(),
);
- for (const environment of environments) {
+ for (const environment of testEnvironments) {
const request = yield* Deferred.make<
RelayEnvironmentStatusResponse,
ManagedRelay.ManagedRelayClientError
@@ -98,7 +101,7 @@ const makeHarness = Effect.fn("RelayDiscoveryTest.makeHarness")(function* () {
if (failure) {
return yield* failure;
}
- return environments;
+ return testEnvironments;
}),
getEnvironmentStatus: ({ environmentId }) =>
Ref.get(statusRequests).pipe(
@@ -170,6 +173,52 @@ const makeHarness = Effect.fn("RelayDiscoveryTest.makeHarness")(function* () {
});
describe("RelayEnvironmentDiscovery", () => {
+ it.effect("keeps managed endpoint secrets out of offline warnings", () => {
+ const httpBaseUrl =
+ "https://endpoint-user:endpoint-password@offline.example.test/private/workspace?access_token=endpoint-secret#endpoint-fragment";
+ const environment = {
+ ...environments[0]!,
+ endpoint: {
+ ...environments[0]!.endpoint,
+ httpBaseUrl,
+ },
+ } satisfies RelayClientEnvironmentRecord;
+ const capturedLogs: Array> = [];
+ const logger = Logger.make(({ message }) => {
+ capturedLogs.push(Array.isArray(message) ? message : [message]);
+ });
+
+ return Effect.gen(function* () {
+ const harness = yield* makeHarness([environment]);
+ yield* Effect.gen(function* () {
+ const discovery = yield* RelayEnvironmentDiscovery.RelayEnvironmentDiscovery;
+ const refreshFiber = yield* Effect.forkChild(discovery.refresh);
+ const requests = yield* Ref.get(harness.statusRequests);
+ yield* Deferred.succeed(
+ requests.get(environment.environmentId)!,
+ status(environment, "offline"),
+ );
+ yield* Fiber.join(refreshFiber);
+
+ expect(capturedLogs).toHaveLength(1);
+ const logFields = capturedLogs[0]?.find(
+ (value): value is Record => typeof value === "object" && value !== null,
+ );
+ expect(logFields).toMatchObject({
+ environmentId: environment.environmentId,
+ endpointInputLength: httpBaseUrl.length,
+ endpointProtocol: "https:",
+ endpointHostname: "offline.example.test",
+ });
+ expect(logFields).not.toHaveProperty("endpoint");
+ }).pipe(
+ Effect.provide(
+ Layer.merge(harness.layer, Logger.layer([logger], { mergeWithExisting: false })),
+ ),
+ );
+ });
+ });
+
it.effect("publishes each environment status as soon as that lookup completes", () =>
Effect.gen(function* () {
const harness = yield* makeHarness();
diff --git a/packages/client-runtime/src/relay/discovery.ts b/packages/client-runtime/src/relay/discovery.ts
index 8cbadea1ca5..1a6212a9bbc 100644
--- a/packages/client-runtime/src/relay/discovery.ts
+++ b/packages/client-runtime/src/relay/discovery.ts
@@ -3,6 +3,7 @@ import type {
RelayEnvironmentStatusResponse,
} from "@t3tools/contracts/relay";
import { decodeRelayJwt } from "@t3tools/shared/relayJwt";
+import { getUrlDiagnostics } from "@t3tools/shared/urlDiagnostics";
import {
RelayEnvironmentConnectScope,
RelayEnvironmentStatusScope,
@@ -63,6 +64,8 @@ function validateStatus(
new ConnectionBlockedError({
reason: "configuration",
detail: "Relay returned status for a different environment.",
+ expectedEnvironmentId: environment.environmentId,
+ actualEnvironmentId: status.environmentId,
}),
);
}
@@ -86,6 +89,8 @@ function validateStatus(
new ConnectionBlockedError({
reason: "configuration",
detail: "Relay returned a descriptor for a different environment.",
+ expectedEnvironmentId: environment.environmentId,
+ actualEnvironmentId: status.descriptor.environmentId,
}),
);
}
@@ -174,9 +179,16 @@ export const make = Effect.fn("RelayEnvironmentDiscovery.make")(function* () {
return [true, new Map(current).set(environment.environmentId, fingerprint)];
});
if (shouldReport) {
+ const endpointDiagnostics = getUrlDiagnostics(result.success.endpoint.httpBaseUrl);
yield* Effect.logWarning("Relay environment health check reported offline", {
environmentId: result.success.environmentId,
- endpoint: result.success.endpoint.httpBaseUrl,
+ endpointInputLength: endpointDiagnostics.inputLength,
+ ...(endpointDiagnostics.protocol === undefined
+ ? {}
+ : { endpointProtocol: endpointDiagnostics.protocol }),
+ ...(endpointDiagnostics.hostname === undefined
+ ? {}
+ : { endpointHostname: endpointDiagnostics.hostname }),
message: result.success.error,
traceId: result.success.traceId,
});
diff --git a/packages/client-runtime/src/relay/managedRelay.test.ts b/packages/client-runtime/src/relay/managedRelay.test.ts
index 278c205883f..7d282754a3e 100644
--- a/packages/client-runtime/src/relay/managedRelay.test.ts
+++ b/packages/client-runtime/src/relay/managedRelay.test.ts
@@ -15,15 +15,15 @@ function managedRelayTestLayer(
fetchFn: typeof globalThis.fetch,
relayUrl = "https://relay.example.test",
accessTokenStore?: ManagedRelay.ManagedRelayAccessTokenStore,
+ signer: ManagedRelay.ManagedRelayDpopSigner["Service"] = {
+ thumbprint: Effect.succeed("client-thumbprint"),
+ createProof: (input) => Effect.succeed(`proof:${input.url}`),
+ },
) {
const httpClientLayer = remoteHttpClientLayer(fetchFn);
const signerLayer = Layer.succeed(
ManagedRelay.ManagedRelayDpopSigner,
- ManagedRelay.ManagedRelayDpopSigner.of({
- thumbprint: Effect.succeed("client-thumbprint"),
- createProof: (input: ManagedRelay.ManagedRelayDpopProofInput) =>
- Effect.succeed(`proof:${input.url}`),
- }),
+ ManagedRelay.ManagedRelayDpopSigner.of(signer),
);
return ManagedRelay.layer({
relayUrl,
@@ -39,6 +39,65 @@ function clerkToken(subject: string, nonce: string): string {
}
describe("ManagedRelayClient", () => {
+ it("redacts relay URLs and DPoP targets while preserving causes", () => {
+ const relayUrl =
+ "http://relay-user:relay-password@relay.example.test/private/workspace?access_token=relay-secret#relay-fragment";
+ const dpopTarget =
+ "https://dpop-user:dpop-password@relay.example.test/v1/client/dpop-token?access_token=dpop-secret#dpop-fragment";
+ const cause = new Error("proof failed");
+ const invalidUrlError = ManagedRelay.ManagedRelayUrlInvalidError.fromInput(relayUrl);
+ const proofErrors = [
+ ManagedRelay.ManagedRelayDpopKeyLoadError.fromTarget({
+ keyStore: "indexed-db",
+ method: "POST",
+ url: dpopTarget,
+ cause,
+ }),
+ ManagedRelay.ManagedRelayDpopProofCreationError.fromTarget({
+ method: "POST",
+ url: dpopTarget,
+ cause,
+ }),
+ ManagedRelay.ManagedRelayTokenProofCreationError.fromTarget({
+ method: "POST",
+ url: dpopTarget,
+ cause,
+ }),
+ ManagedRelay.ManagedRelayRequestProofCreationError.fromTarget({
+ method: "POST",
+ url: dpopTarget,
+ cause,
+ }),
+ ];
+
+ expect(invalidUrlError).toMatchObject({
+ relayUrlInputLength: relayUrl.length,
+ relayUrlProtocol: "http:",
+ relayUrlHostname: "relay.example.test",
+ });
+ const invalidUrlDiagnostics = JSON.stringify(invalidUrlError);
+ for (const secret of [
+ "relay-user",
+ "relay-password",
+ "/private/workspace",
+ "relay-secret",
+ "relay-fragment",
+ ]) {
+ expect(invalidUrlDiagnostics).not.toContain(secret);
+ expect(invalidUrlError.message).not.toContain(secret);
+ }
+
+ for (const error of proofErrors) {
+ expect(error.requestTarget).toBe("https://relay.example.test/v1/client/dpop-token");
+ expect(error.cause).toBe(cause);
+ const diagnostics = JSON.stringify(error);
+ for (const secret of ["dpop-user", "dpop-password", "dpop-secret", "dpop-fragment"]) {
+ expect(diagnostics).not.toContain(secret);
+ expect(error.message).not.toContain(secret);
+ }
+ }
+ });
+
it.effect("owns tracing at service and implementation boundaries", () => {
const spanNames: Array = [];
const tracer = Tracer.make({
@@ -124,13 +183,57 @@ describe("ManagedRelayClient", () => {
expect(error).toMatchObject({
_tag: "ManagedRelayUrlInvalidError",
- relayUrl: "http://relay.example.test",
+ relayUrlInputLength: "http://relay.example.test".length,
+ relayUrlProtocol: "http:",
+ relayUrlHostname: "relay.example.test",
message: "Relay URL must be a secure absolute HTTPS origin.",
});
expect(requestCount).toBe(0);
}).pipe(Effect.provide(managedRelayTestLayer(fetchFn, "http://relay.example.test")));
});
+ it.effect("preserves key-load failures when creating a token proof", () => {
+ const keyStoreCause = new Error("IndexedDB unavailable");
+ const fetchFn = (() => Promise.resolve(Response.json({}))) satisfies typeof globalThis.fetch;
+
+ return Effect.gen(function* () {
+ const relayClient = yield* ManagedRelay.ManagedRelayClient;
+ const error = yield* relayClient
+ .getEnvironmentStatus({
+ clerkToken: clerkToken("user-1", "session-1"),
+ scopes: [RelayEnvironmentStatusScope],
+ environmentId: EnvironmentId.make("env-1"),
+ })
+ .pipe(Effect.flip);
+
+ expect(error).toMatchObject({
+ _tag: "ManagedRelayTokenProofCreationError",
+ cause: {
+ _tag: "ManagedRelayDpopKeyLoadError",
+ keyStore: "indexed-db",
+ method: "POST",
+ message: "Could not load relay DPoP proof key.",
+ cause: keyStoreCause,
+ },
+ });
+ }).pipe(
+ Effect.provide(
+ managedRelayTestLayer(fetchFn, undefined, undefined, {
+ thumbprint: Effect.succeed("client-thumbprint"),
+ createProof: (input) =>
+ Effect.fail(
+ ManagedRelay.ManagedRelayDpopKeyLoadError.fromTarget({
+ keyStore: "indexed-db",
+ method: input.method,
+ url: input.url,
+ cause: keyStoreCause,
+ }),
+ ),
+ }),
+ ),
+ );
+ });
+
it.effect("reuses usable DPoP tokens and refreshes cleared or expiring cache entries", () => {
let tokenExchangeCount = 0;
const fetchFn = ((input) => {
diff --git a/packages/client-runtime/src/relay/managedRelay.ts b/packages/client-runtime/src/relay/managedRelay.ts
index 08b720b46a3..fb4e6e4f0be 100644
--- a/packages/client-runtime/src/relay/managedRelay.ts
+++ b/packages/client-runtime/src/relay/managedRelay.ts
@@ -28,9 +28,11 @@ import {
RelayUnregisterDeviceEndpoint,
} from "@t3tools/contracts/relay";
import { encodeOAuthScope, oauthScopeSetEquals } from "@t3tools/shared/oauthScope";
+import { redactDpopRequestTarget } from "@t3tools/shared/dpop";
import { decodeRelayJwt } from "@t3tools/shared/relayJwt";
import { withRelayClientTracing } from "@t3tools/shared/relayTracing";
import { normalizeSecureRelayUrl } from "@t3tools/shared/relayUrl";
+import { getUrlDiagnostics } from "@t3tools/shared/urlDiagnostics";
import * as Clock from "effect/Clock";
import * as Context from "effect/Context";
import * as Duration from "effect/Duration";
@@ -53,9 +55,25 @@ export class ManagedRelayDpopKeyLoadError extends Schema.TaggedErrorClass()(
"ManagedRelayUrlInvalidError",
{
- relayUrl: Schema.String,
+ relayUrlInputLength: Schema.Number,
+ relayUrlProtocol: Schema.optionalKey(Schema.String),
+ relayUrlHostname: Schema.optionalKey(Schema.String),
},
) {
+ static fromInput(relayUrl: string): ManagedRelayUrlInvalidError {
+ const diagnostics = getUrlDiagnostics(relayUrl);
+ return new ManagedRelayUrlInvalidError({
+ relayUrlInputLength: diagnostics.inputLength,
+ ...(diagnostics.protocol === undefined ? {} : { relayUrlProtocol: diagnostics.protocol }),
+ ...(diagnostics.hostname === undefined ? {} : { relayUrlHostname: diagnostics.hostname }),
+ });
+ }
+
override get message(): string {
return "Relay URL must be a secure absolute HTTPS origin.";
}
}
+type RelayHttpRequestError =
+ | RelayProtectedErrorType
+ | HttpClientError.HttpClientError
+ | Schema.SchemaError;
+
+const isRelayProtectedError = Schema.is(RelayProtectedError);
+
export class ManagedRelayRequestFailedError extends Schema.TaggedErrorClass()(
"ManagedRelayRequestFailedError",
{
@@ -145,6 +193,15 @@ export class ManagedRelayRequestFailedError extends Schema.TaggedErrorClass
+ new ManagedRelayRequestFailedError({
+ action,
+ cause,
+ ...(isRelayProtectedError(cause) ? { relayError: cause, traceId: cause.traceId } : {}),
+ });
+ }
}
export class ManagedRelayAccessTokenScopesUnexpectedError extends Schema.TaggedErrorClass()(
@@ -163,10 +220,22 @@ export class ManagedRelayTokenProofCreationError extends Schema.TaggedErrorClass
"ManagedRelayTokenProofCreationError",
{
method: Schema.String,
- url: Schema.String,
+ requestTarget: Schema.String,
cause: Schema.Defect(),
},
) {
+ static fromTarget(input: {
+ readonly method: string;
+ readonly url: string;
+ readonly cause: unknown;
+ }): ManagedRelayTokenProofCreationError {
+ return new ManagedRelayTokenProofCreationError({
+ method: input.method,
+ requestTarget: redactDpopRequestTarget(input.url),
+ cause: input.cause,
+ });
+ }
+
override get message(): string {
return "Could not create relay token DPoP proof.";
}
@@ -176,10 +245,22 @@ export class ManagedRelayRequestProofCreationError extends Schema.TaggedErrorCla
"ManagedRelayRequestProofCreationError",
{
method: Schema.String,
- url: Schema.String,
+ requestTarget: Schema.String,
cause: Schema.Defect(),
},
) {
+ static fromTarget(input: {
+ readonly method: string;
+ readonly url: string;
+ readonly cause: unknown;
+ }): ManagedRelayRequestProofCreationError {
+ return new ManagedRelayRequestProofCreationError({
+ method: input.method,
+ requestTarget: redactDpopRequestTarget(input.url),
+ cause: input.cause,
+ });
+ }
+
override get message(): string {
return "Could not create relay request DPoP proof.";
}
@@ -196,18 +277,13 @@ export const ManagedRelayClientError = Schema.Union([
]);
export type ManagedRelayClientError = typeof ManagedRelayClientError.Type;
-type RelayHttpRequestError =
- | RelayProtectedErrorType
- | HttpClientError.HttpClientError
- | Schema.SchemaError;
-
export class ManagedRelayDpopSigner extends Context.Service<
ManagedRelayDpopSigner,
{
readonly thumbprint: Effect.Effect;
readonly createProof: (
input: ManagedRelayDpopProofInput,
- ) => Effect.Effect;
+ ) => Effect.Effect;
}
>()("@t3tools/client-runtime/relay/managedRelay/ManagedRelayDpopSigner") {}
@@ -290,28 +366,8 @@ export class ManagedRelayClient extends Context.Service<
}
>()("@t3tools/client-runtime/relay/managedRelay/ManagedRelayClient") {}
-const isRelayProtectedError = Schema.is(RelayProtectedError);
-
-function relayRequestError(action: ManagedRelayRequestAction) {
- return (cause: RelayHttpRequestError): ManagedRelayClientError =>
- new ManagedRelayRequestFailedError({
- action,
- cause,
- ...(isRelayProtectedError(cause) ? { relayError: cause, traceId: cause.traceId } : {}),
- });
-}
-
-function proofCreationErrorFields(error: ManagedRelayDpopProofCreationError) {
- return {
- method: error.method,
- url: error.url,
- cause: error,
- };
-}
-
-function isRejectedDpopAccessToken(error: ManagedRelayClientError): boolean {
+function isRejectedDpopAccessToken(error: ManagedRelayRequestFailedError): boolean {
return (
- error._tag === "ManagedRelayRequestFailedError" &&
error.relayError?._tag === "RelayAuthInvalidError" &&
error.relayError.reason === "invalid_bearer"
);
@@ -383,7 +439,7 @@ function dpopHeaders(authorization: ManagedRelayAuthorization) {
function disabledManagedRelayClient(relayUrl: string): ManagedRelayClient["Service"] {
const unavailable = (spanName: string) =>
Effect.fn(spanName)(function* () {
- return yield* new ManagedRelayUrlInvalidError({ relayUrl });
+ return yield* ManagedRelayUrlInvalidError.fromInput(relayUrl);
});
return ManagedRelayClient.of({
relayUrl,
@@ -461,13 +517,16 @@ export const make = Effect.fn("ManagedRelayClient.make")(function* (
"relay.client_id": options.clientId,
"relay.scopes": input.scopes.join(" "),
});
- const proof = yield* signer
- .createProof(dpopProofTargets.exchangeAccessToken())
- .pipe(
- Effect.mapError(
- (error) => new ManagedRelayTokenProofCreationError(proofCreationErrorFields(error)),
- ),
- );
+ const proofTarget = dpopProofTargets.exchangeAccessToken();
+ const proof = yield* signer.createProof(proofTarget).pipe(
+ Effect.mapError((cause) =>
+ ManagedRelayTokenProofCreationError.fromTarget({
+ method: proofTarget.method,
+ url: proofTarget.url,
+ cause,
+ }),
+ ),
+ );
const response = yield* client.token
.exchangeDpopAccessToken({
headers: { dpop: proof },
@@ -482,7 +541,9 @@ export const make = Effect.fn("ManagedRelayClient.make")(function* (
},
})
.pipe(
- Effect.mapError(relayRequestError("exchange relay DPoP access token")),
+ Effect.mapError(
+ ManagedRelayRequestFailedError.fromHttpRequest("exchange relay DPoP access token"),
+ ),
timeoutRelayRequest("Relay DPoP access token exchange"),
);
if (!oauthScopeSetEquals(response.scope, input.scopes)) {
@@ -574,7 +635,7 @@ export const make = Effect.fn("ManagedRelayClient.make")(function* (
"relay.client_id": options.clientId,
"relay.scopes": input.scopes.join(" "),
"http.request.method": input.target.method,
- "url.full": input.target.url,
+ "url.full": redactDpopRequestTarget(input.target.url),
});
const thumbprint = yield* signer.thumbprint;
const token = yield* obtainAccessToken({
@@ -588,8 +649,12 @@ export const make = Effect.fn("ManagedRelayClient.make")(function* (
accessToken: token.accessToken,
})
.pipe(
- Effect.mapError(
- (error) => new ManagedRelayRequestProofCreationError(proofCreationErrorFields(error)),
+ Effect.mapError((cause) =>
+ ManagedRelayRequestProofCreationError.fromTarget({
+ method: input.target.method,
+ url: input.target.url,
+ cause,
+ }),
),
);
return { accessToken: token.accessToken, proof, thumbprint };
@@ -623,27 +688,29 @@ export const make = Effect.fn("ManagedRelayClient.make")(function* (
authorize(input).pipe(
Effect.flatMap((authorization) =>
request(authorization).pipe(
- Effect.catch((error) => {
- if (!isRejectedDpopAccessToken(error)) {
- return Effect.fail(error);
- }
- return invalidateAccessToken(authorization.accessToken).pipe(
- Effect.tap((invalidated) =>
- Effect.annotateCurrentSpan({
- "relay.token_cache.invalidated": invalidated,
- "relay.token_cache.invalidation_reason": "invalid_bearer",
- "relay.token_cache.retry_after_invalidation": refreshRejectedToken,
- }),
- ),
- Effect.tap((invalidated) =>
- invalidated && refreshRejectedToken
- ? Effect.logWarning(
- "Relay rejected a cached DPoP access token; refreshing it once.",
- )
- : Effect.void,
- ),
- Effect.andThen(refreshRejectedToken ? attempt(false) : Effect.fail(error)),
- );
+ Effect.catchTags({
+ ManagedRelayRequestFailedError: (error) => {
+ if (!isRejectedDpopAccessToken(error)) {
+ return Effect.fail(error);
+ }
+ return invalidateAccessToken(authorization.accessToken).pipe(
+ Effect.tap((invalidated) =>
+ Effect.annotateCurrentSpan({
+ "relay.token_cache.invalidated": invalidated,
+ "relay.token_cache.invalidation_reason": "invalid_bearer",
+ "relay.token_cache.retry_after_invalidation": refreshRejectedToken,
+ }),
+ ),
+ Effect.tap((invalidated) =>
+ invalidated && refreshRejectedToken
+ ? Effect.logWarning(
+ "Relay rejected a cached DPoP access token; refreshing it once.",
+ )
+ : Effect.void,
+ ),
+ Effect.andThen(refreshRejectedToken ? attempt(false) : Effect.fail(error)),
+ );
+ },
}),
),
),
@@ -676,7 +743,9 @@ export const make = Effect.fn("ManagedRelayClient.make")(function* (
.listEnvironments({ headers: bearerHeaders(input.clerkToken) })
.pipe(
Effect.map((response) => response.environments),
- Effect.mapError(relayRequestError("list relay-managed environments")),
+ Effect.mapError(
+ ManagedRelayRequestFailedError.fromHttpRequest("list relay-managed environments"),
+ ),
timeoutRelayRequest("Relay environment listing"),
);
},
@@ -691,7 +760,9 @@ export const make = Effect.fn("ManagedRelayClient.make")(function* (
})
.pipe(
Effect.map((response) => response.devices),
- Effect.mapError(relayRequestError("list relay client devices")),
+ Effect.mapError(
+ ManagedRelayRequestFailedError.fromHttpRequest("list relay client devices"),
+ ),
timeoutRelayRequest("Relay client device listing"),
);
},
@@ -706,7 +777,11 @@ export const make = Effect.fn("ManagedRelayClient.make")(function* (
payload: input.payload,
})
.pipe(
- Effect.mapError(relayRequestError("create relay environment link challenge")),
+ Effect.mapError(
+ ManagedRelayRequestFailedError.fromHttpRequest(
+ "create relay environment link challenge",
+ ),
+ ),
timeoutRelayRequest("Relay environment link challenge"),
);
},
@@ -721,7 +796,9 @@ export const make = Effect.fn("ManagedRelayClient.make")(function* (
payload: input.payload,
})
.pipe(
- Effect.mapError(relayRequestError("link relay environment")),
+ Effect.mapError(
+ ManagedRelayRequestFailedError.fromHttpRequest("link relay environment"),
+ ),
timeoutRelayRequest("Relay environment linking"),
);
},
@@ -736,7 +813,9 @@ export const make = Effect.fn("ManagedRelayClient.make")(function* (
params: { environmentId: input.environmentId },
})
.pipe(
- Effect.mapError(relayRequestError("unlink relay environment")),
+ Effect.mapError(
+ ManagedRelayRequestFailedError.fromHttpRequest("unlink relay environment"),
+ ),
timeoutRelayRequest("Relay environment unlinking"),
);
},
@@ -761,7 +840,9 @@ export const make = Effect.fn("ManagedRelayClient.make")(function* (
params: { environmentId: input.environmentId },
})
.pipe(
- Effect.mapError(relayRequestError("get relay environment status")),
+ Effect.mapError(
+ ManagedRelayRequestFailedError.fromHttpRequest("get relay environment status"),
+ ),
timeoutRelayRequest("Relay environment status request"),
),
);
@@ -792,7 +873,9 @@ export const make = Effect.fn("ManagedRelayClient.make")(function* (
payload,
})
.pipe(
- Effect.mapError(relayRequestError("connect relay environment")),
+ Effect.mapError(
+ ManagedRelayRequestFailedError.fromHttpRequest("connect relay environment"),
+ ),
timeoutRelayRequest("Relay environment connection"),
);
},
@@ -815,7 +898,9 @@ export const make = Effect.fn("ManagedRelayClient.make")(function* (
payload: input.payload,
})
.pipe(
- Effect.mapError(relayRequestError("register relay mobile device")),
+ Effect.mapError(
+ ManagedRelayRequestFailedError.fromHttpRequest("register relay mobile device"),
+ ),
timeoutRelayRequest("Relay mobile device registration"),
),
);
@@ -837,7 +922,9 @@ export const make = Effect.fn("ManagedRelayClient.make")(function* (
params: { deviceId: input.deviceId },
})
.pipe(
- Effect.mapError(relayRequestError("unregister relay mobile device")),
+ Effect.mapError(
+ ManagedRelayRequestFailedError.fromHttpRequest("unregister relay mobile device"),
+ ),
timeoutRelayRequest("Relay mobile device unregistration"),
),
);
@@ -859,7 +946,9 @@ export const make = Effect.fn("ManagedRelayClient.make")(function* (
payload: input.payload,
})
.pipe(
- Effect.mapError(relayRequestError("register relay live activity")),
+ Effect.mapError(
+ ManagedRelayRequestFailedError.fromHttpRequest("register relay live activity"),
+ ),
timeoutRelayRequest("Relay Live Activity registration"),
),
);
diff --git a/packages/client-runtime/src/relay/managedRelayState.test.ts b/packages/client-runtime/src/relay/managedRelayState.test.ts
index 49400d32aef..0c0793d966d 100644
--- a/packages/client-runtime/src/relay/managedRelayState.test.ts
+++ b/packages/client-runtime/src/relay/managedRelayState.test.ts
@@ -5,6 +5,7 @@ import type {
RelayEnvironmentStatusResponse,
} from "@t3tools/contracts/relay";
import { describe, expect, it } from "@effect/vitest";
+import * as Cause from "effect/Cause";
import * as Effect from "effect/Effect";
import * as Fiber from "effect/Fiber";
import * as Layer from "effect/Layer";
@@ -17,6 +18,11 @@ import {
createManagedRelayQueryManager,
createManagedRelaySession,
managedRelayAccountChanges,
+ ManagedRelaySessionUnavailableError,
+ ManagedRelayStatusEndpointMismatchError,
+ ManagedRelayStatusEnvironmentMismatchError,
+ ManagedRelayTokenReadError,
+ ManagedRelayTokenUnavailableError,
type ManagedRelayQueryEvent,
managedRelaySessionAtom,
readManagedRelaySnapshotState,
@@ -109,6 +115,65 @@ function clerkToken(expiresAtSeconds: number): string {
describe("createManagedRelayQueryManager", () => {
afterEach(resetRegistry);
+ it("redacts endpoint mismatch secrets while retaining correlation fields", () => {
+ const expectedHttpBaseUrl =
+ "https://expected-user:expected-password@expected.example.test/private/catalog?token=expected-secret#expected-fragment";
+ const expectedWsBaseUrl =
+ "wss://expected-user:expected-password@expected.example.test/private/socket?token=expected-secret#expected-fragment";
+ const actualHttpBaseUrl =
+ "https://actual-user:actual-password@actual.example.test/private/catalog?token=actual-secret#actual-fragment";
+ const actualWsBaseUrl =
+ "wss://actual-user:actual-password@actual.example.test/private/socket?token=actual-secret#actual-fragment";
+
+ const error = ManagedRelayStatusEndpointMismatchError.fromEndpoints({
+ environmentId: environment.environmentId,
+ expectedEndpoint: {
+ providerKind: "cloudflare_tunnel",
+ httpBaseUrl: expectedHttpBaseUrl,
+ wsBaseUrl: expectedWsBaseUrl,
+ },
+ actualEndpoint: {
+ providerKind: "cloudflare_tunnel",
+ httpBaseUrl: actualHttpBaseUrl,
+ wsBaseUrl: actualWsBaseUrl,
+ },
+ });
+
+ expect(error).toMatchObject({
+ expectedProviderKind: "cloudflare_tunnel",
+ expectedHttpBaseUrlInputLength: expectedHttpBaseUrl.length,
+ expectedHttpBaseUrlProtocol: "https:",
+ expectedHttpBaseUrlHostname: "expected.example.test",
+ expectedWsBaseUrlInputLength: expectedWsBaseUrl.length,
+ expectedWsBaseUrlProtocol: "wss:",
+ expectedWsBaseUrlHostname: "expected.example.test",
+ actualProviderKind: "cloudflare_tunnel",
+ actualHttpBaseUrlInputLength: actualHttpBaseUrl.length,
+ actualHttpBaseUrlProtocol: "https:",
+ actualHttpBaseUrlHostname: "actual.example.test",
+ actualWsBaseUrlInputLength: actualWsBaseUrl.length,
+ actualWsBaseUrlProtocol: "wss:",
+ actualWsBaseUrlHostname: "actual.example.test",
+ });
+ expect(error).not.toHaveProperty("expectedEndpoint");
+ expect(error).not.toHaveProperty("actualEndpoint");
+ const exposedText = `${Object.values(error).join(" ")} ${error.message}`;
+ for (const secret of [
+ "expected-user",
+ "expected-password",
+ "/private/catalog",
+ "/private/socket",
+ "expected-secret",
+ "expected-fragment",
+ "actual-user",
+ "actual-password",
+ "actual-secret",
+ "actual-fragment",
+ ]) {
+ expect(exposedText).not.toContain(secret);
+ }
+ });
+
it.effect("waits for the current cloud session before reading its token", () =>
Effect.gen(function* () {
const tokenFiber = yield* waitForManagedRelayClerkToken(registry).pipe(Effect.forkChild);
@@ -148,6 +213,62 @@ describe("createManagedRelayQueryManager", () => {
}),
);
+ it.effect("preserves token provider failure context", () =>
+ Effect.gen(function* () {
+ const cause = new Error("Clerk session failed");
+ const session = createManagedRelaySession({
+ accountId: "account-1",
+ readClerkToken: () => Promise.reject(cause),
+ });
+
+ const error = yield* Effect.flip(session.readClerkToken());
+
+ expect(error).toBeInstanceOf(ManagedRelayTokenReadError);
+ expect(error).toMatchObject({
+ _tag: "ManagedRelayTokenReadError",
+ accountId: "account-1",
+ cause,
+ });
+ }),
+ );
+
+ it.effect("distinguishes unavailable tokens from unavailable sessions", () =>
+ Effect.gen(function* () {
+ setManagedRelaySession(registry, {
+ accountId: "account-1",
+ readClerkToken: () => Promise.resolve(null),
+ });
+
+ const tokenError = yield* Effect.flip(waitForManagedRelayClerkToken(registry));
+ expect(tokenError).toBeInstanceOf(ManagedRelayTokenUnavailableError);
+ expect(tokenError).toMatchObject({
+ _tag: "ManagedRelayTokenUnavailableError",
+ accountId: "account-1",
+ });
+
+ setManagedRelaySession(registry, null);
+ const manager = createManager();
+ const atom = manager.environmentsAtom("account-1");
+ registry.get(atom);
+
+ yield* Effect.promise(() =>
+ vi.waitFor(() => {
+ const result = registry.get(atom);
+ expect(result._tag).toBe("Failure");
+ if (result._tag !== "Failure") {
+ return;
+ }
+ const error = Cause.squash(result.cause);
+ expect(error).toBeInstanceOf(ManagedRelaySessionUnavailableError);
+ expect(error).toMatchObject({
+ _tag: "ManagedRelaySessionUnavailableError",
+ requestedAccountId: "account-1",
+ });
+ }),
+ );
+ }),
+ );
+
it.effect("updates the token provider without replacing a same-account session", () =>
Effect.gen(function* () {
const firstRead = vi.fn(() => Promise.resolve(null));
@@ -197,30 +318,38 @@ describe("createManagedRelayQueryManager", () => {
}),
);
- it("emits credential changes only when the managed relay account changes", async () => {
- setManagedRelaySession(registry, {
- accountId: "account-1",
- readClerkToken: () => Promise.resolve("first-token"),
- });
- const changes = Effect.runPromise(
- managedRelayAccountChanges(registry).pipe(Stream.take(2), Stream.runCollect),
- );
- await vi.waitFor(() => {
- expect(registry.getNodes().get(managedRelaySessionAtom)?.listeners.size).toBeGreaterThan(0);
- });
+ it.effect("emits credential changes only when the managed relay account changes", () =>
+ Effect.gen(function* () {
+ setManagedRelaySession(registry, {
+ accountId: "account-1",
+ readClerkToken: () => Promise.resolve("first-token"),
+ });
+ const changes = yield* managedRelayAccountChanges(registry).pipe(
+ Stream.take(2),
+ Stream.runCollect,
+ Effect.forkChild,
+ );
+ yield* Effect.promise(() =>
+ vi.waitFor(() => {
+ expect(registry.getNodes().get(managedRelaySessionAtom)?.listeners.size).toBeGreaterThan(
+ 0,
+ );
+ }),
+ );
- setManagedRelaySession(registry, {
- accountId: "account-1",
- readClerkToken: () => Promise.resolve("refreshed-token"),
- });
- setManagedRelaySession(registry, {
- accountId: "account-2",
- readClerkToken: () => Promise.resolve("second-token"),
- });
- setManagedRelaySession(registry, null);
+ setManagedRelaySession(registry, {
+ accountId: "account-1",
+ readClerkToken: () => Promise.resolve("refreshed-token"),
+ });
+ setManagedRelaySession(registry, {
+ accountId: "account-2",
+ readClerkToken: () => Promise.resolve("second-token"),
+ });
+ setManagedRelaySession(registry, null);
- expect(Array.from(await changes)).toEqual(["account-2", null]);
- });
+ expect(Array.from(yield* Fiber.join(changes))).toEqual(["account-2", null]);
+ }),
+ );
it("shares one Clerk token read across concurrent relay list and status queries", async () => {
const secondEnvironment = {
@@ -349,8 +478,20 @@ describe("createManagedRelayQueryManager", () => {
registry.get(atom);
await vi.waitFor(() => {
- expect(readManagedRelaySnapshotState(registry.get(atom)).error).toBe(
- "Relay returned status for a different environment.",
+ const result = registry.get(atom);
+ expect(result._tag).toBe("Failure");
+ if (result._tag !== "Failure") {
+ return;
+ }
+ const error = Cause.squash(result.cause);
+ expect(error).toBeInstanceOf(ManagedRelayStatusEnvironmentMismatchError);
+ expect(error).toMatchObject({
+ _tag: "ManagedRelayStatusEnvironmentMismatchError",
+ expectedEnvironmentId: environment.environmentId,
+ actualEnvironmentId: mismatchedStatus.environmentId,
+ });
+ expect(readManagedRelaySnapshotState(result).error).toBe(
+ "Relay returned status for environment environment-2 instead of environment-1.",
);
});
});
diff --git a/packages/client-runtime/src/relay/managedRelayState.ts b/packages/client-runtime/src/relay/managedRelayState.ts
index ec6a0710dd1..b65dbff002a 100644
--- a/packages/client-runtime/src/relay/managedRelayState.ts
+++ b/packages/client-runtime/src/relay/managedRelayState.ts
@@ -2,16 +2,19 @@ import type {
RelayClientEnvironmentRecord,
RelayEnvironmentStatusResponse,
} from "@t3tools/contracts/relay";
+import { EnvironmentId } from "@t3tools/contracts";
import {
RelayEnvironmentConnectScope,
RelayEnvironmentStatusScope,
+ RelayManagedEndpointProviderKind,
} from "@t3tools/contracts/relay";
import { decodeRelayJwt } from "@t3tools/shared/relayJwt";
+import { getUrlDiagnostics } from "@t3tools/shared/urlDiagnostics";
import * as Cause from "effect/Cause";
import * as Clock from "effect/Clock";
-import * as Data from "effect/Data";
import * as Effect from "effect/Effect";
import * as Option from "effect/Option";
+import * as Schema from "effect/Schema";
import * as Stream from "effect/Stream";
import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity";
@@ -55,14 +58,138 @@ export interface ManagedRelayQueryEvent {
readonly traceId?: string | null;
}
-export class ManagedRelaySessionError extends Data.TaggedError("ManagedRelaySessionError")<{
- readonly message: string;
- readonly cause?: unknown;
-}> {}
+export class ManagedRelayTokenReadError extends Schema.TaggedErrorClass()(
+ "ManagedRelayTokenReadError",
+ {
+ accountId: Schema.String,
+ cause: Schema.Defect(),
+ },
+) {
+ override get message(): string {
+ return `Could not obtain the T3 Cloud session token for account ${this.accountId}.`;
+ }
+}
+
+export class ManagedRelayTokenUnavailableError extends Schema.TaggedErrorClass()(
+ "ManagedRelayTokenUnavailableError",
+ { accountId: Schema.String },
+) {
+ override get message(): string {
+ return `The T3 Cloud session token is unavailable for account ${this.accountId}.`;
+ }
+}
+
+export class ManagedRelaySessionUnavailableError extends Schema.TaggedErrorClass()(
+ "ManagedRelaySessionUnavailableError",
+ { requestedAccountId: Schema.String },
+) {
+ override get message(): string {
+ return `Sign in to T3 Cloud as account ${this.requestedAccountId} before loading relay data.`;
+ }
+}
+
+export const ManagedRelaySessionError = Schema.Union([
+ ManagedRelayTokenReadError,
+ ManagedRelayTokenUnavailableError,
+ ManagedRelaySessionUnavailableError,
+]);
+export type ManagedRelaySessionError = typeof ManagedRelaySessionError.Type;
+
+export class ManagedRelayStatusEnvironmentMismatchError extends Schema.TaggedErrorClass()(
+ "ManagedRelayStatusEnvironmentMismatchError",
+ {
+ expectedEnvironmentId: EnvironmentId,
+ actualEnvironmentId: EnvironmentId,
+ },
+) {
+ override get message(): string {
+ return `Relay returned status for environment ${this.actualEnvironmentId} instead of ${this.expectedEnvironmentId}.`;
+ }
+}
+
+export class ManagedRelayStatusEndpointMismatchError extends Schema.TaggedErrorClass()(
+ "ManagedRelayStatusEndpointMismatchError",
+ {
+ environmentId: EnvironmentId,
+ 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 environmentId: EnvironmentId;
+ readonly expectedEndpoint: RelayClientEnvironmentRecord["endpoint"];
+ readonly actualEndpoint: RelayClientEnvironmentRecord["endpoint"];
+ }): ManagedRelayStatusEndpointMismatchError {
+ 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 ManagedRelayStatusEndpointMismatchError({
+ 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 }),
+ });
+ }
+
+ override get message(): string {
+ return `Relay returned a different endpoint for environment ${this.environmentId}.`;
+ }
+}
+
+export class ManagedRelayStatusDescriptorEnvironmentMismatchError extends Schema.TaggedErrorClass()(
+ "ManagedRelayStatusDescriptorEnvironmentMismatchError",
+ {
+ expectedEnvironmentId: EnvironmentId,
+ actualEnvironmentId: EnvironmentId,
+ },
+) {
+ override get message(): string {
+ return `Relay returned a descriptor for environment ${this.actualEnvironmentId} instead of ${this.expectedEnvironmentId}.`;
+ }
+}
-export class ManagedRelaySnapshotError extends Data.TaggedError("ManagedRelaySnapshotError")<{
- readonly message: string;
-}> {}
+export const ManagedRelaySnapshotError = Schema.Union([
+ ManagedRelayStatusEnvironmentMismatchError,
+ ManagedRelayStatusEndpointMismatchError,
+ ManagedRelayStatusDescriptorEnvironmentMismatchError,
+]);
+export type ManagedRelaySnapshotError = typeof ManagedRelaySnapshotError.Type;
export const managedRelaySessionAtom = Atom.make(null).pipe(
Atom.keepAlive,
@@ -122,8 +249,8 @@ export function createManagedRelaySession(input: ManagedRelaySessionInput): Mana
return yield* Effect.tryPromise({
try: () => readCachedClerkToken(nowMillis),
catch: (cause) =>
- new ManagedRelaySessionError({
- message: "Could not obtain the T3 Cloud session token.",
+ new ManagedRelayTokenReadError({
+ accountId: input.accountId,
cause,
}),
});
@@ -180,8 +307,8 @@ function readSessionClerkToken(
token
? Effect.succeed(token)
: Effect.fail(
- new ManagedRelaySessionError({
- message: "The T3 Cloud session token is unavailable.",
+ new ManagedRelayTokenUnavailableError({
+ accountId: session.accountId,
}),
),
),
@@ -225,8 +352,8 @@ function requireClerkToken(
const session = get(managedRelaySessionAtom);
if (!session || session.accountId !== accountId) {
return Effect.fail(
- new ManagedRelaySessionError({
- message: "Sign in to T3 Cloud before loading relay data.",
+ new ManagedRelaySessionUnavailableError({
+ requestedAccountId: accountId,
}),
);
}
@@ -267,22 +394,26 @@ function validateEnvironmentStatus(
): Effect.Effect {
if (status.environmentId !== environment.environmentId) {
return Effect.fail(
- new ManagedRelaySnapshotError({
- message: "Relay returned status for a different environment.",
+ new ManagedRelayStatusEnvironmentMismatchError({
+ expectedEnvironmentId: environment.environmentId,
+ actualEnvironmentId: status.environmentId,
}),
);
}
if (!endpointMatches(status.endpoint, environment.endpoint)) {
return Effect.fail(
- new ManagedRelaySnapshotError({
- message: "Relay returned status for a different endpoint.",
+ ManagedRelayStatusEndpointMismatchError.fromEndpoints({
+ environmentId: environment.environmentId,
+ expectedEndpoint: environment.endpoint,
+ actualEndpoint: status.endpoint,
}),
);
}
if (status.descriptor && status.descriptor.environmentId !== environment.environmentId) {
return Effect.fail(
- new ManagedRelaySnapshotError({
- message: "Relay returned status descriptor for a different environment.",
+ new ManagedRelayStatusDescriptorEnvironmentMismatchError({
+ expectedEnvironmentId: environment.environmentId,
+ actualEnvironmentId: status.descriptor.environmentId,
}),
);
}
diff --git a/packages/client-runtime/src/rpc/http.ts b/packages/client-runtime/src/rpc/http.ts
index 11bfa794fa7..4b0514df747 100644
--- a/packages/client-runtime/src/rpc/http.ts
+++ b/packages/client-runtime/src/rpc/http.ts
@@ -8,63 +8,131 @@ import {
type EnvironmentScopeRequiredError,
} from "@t3tools/contracts";
import { httpHeaderRedactionLayer } from "@t3tools/shared/httpObservability";
-import * as Data from "effect/Data";
+import { getUrlDiagnostics } from "@t3tools/shared/urlDiagnostics";
import * as Duration from "effect/Duration";
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 { FetchHttpClient, HttpClient, HttpClientError } from "effect/unstable/http";
+import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient";
+import * as HttpClient from "effect/unstable/http/HttpClient";
+import * as HttpClientError from "effect/unstable/http/HttpClientError";
import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient";
const isEnvironmentHttpCommonError = Schema.is(EnvironmentHttpCommonError);
-export class RemoteEnvironmentAuthFetchError extends Data.TaggedError(
+const requestUrlDiagnosticSchema = {
+ requestUrlInputLength: Schema.Number,
+ requestUrlProtocol: Schema.optionalKey(Schema.String),
+ requestUrlHostname: Schema.optionalKey(Schema.String),
+} as const;
+
+function requestUrlDiagnosticFields(requestUrl: string) {
+ const diagnostics = getUrlDiagnostics(requestUrl);
+ return {
+ requestUrlInputLength: diagnostics.inputLength,
+ ...(diagnostics.protocol === undefined ? {} : { requestUrlProtocol: diagnostics.protocol }),
+ ...(diagnostics.hostname === undefined ? {} : { requestUrlHostname: diagnostics.hostname }),
+ };
+}
+
+function requestUrlDescription(input: {
+ readonly requestUrlInputLength: number;
+ readonly requestUrlHostname?: string;
+}): string {
+ return input.requestUrlHostname === undefined
+ ? `an invalid URL (${input.requestUrlInputLength} characters)`
+ : `host ${input.requestUrlHostname} (${input.requestUrlInputLength} URL characters)`;
+}
+
+export class RemoteEnvironmentAuthFetchError extends Schema.TaggedErrorClass()(
"RemoteEnvironmentAuthFetchError",
-)<{
- readonly message: string;
- readonly cause: unknown;
-}> {}
+ {
+ ...requestUrlDiagnosticSchema,
+ cause: Schema.Defect(),
+ },
+) {
+ static fromRequestUrl(requestUrl: string, cause: unknown): RemoteEnvironmentAuthFetchError {
+ return new RemoteEnvironmentAuthFetchError({
+ ...requestUrlDiagnosticFields(requestUrl),
+ cause,
+ });
+ }
+
+ override get message(): string {
+ return `Failed to fetch remote environment endpoint at ${requestUrlDescription(this)}.`;
+ }
+}
-export class RemoteEnvironmentAuthInvalidJsonError extends Data.TaggedError(
+export class RemoteEnvironmentAuthInvalidJsonError extends Schema.TaggedErrorClass()(
"RemoteEnvironmentAuthInvalidJsonError",
-)<{
- readonly message: string;
- readonly cause: unknown;
-}> {}
+ {
+ ...requestUrlDiagnosticSchema,
+ cause: Schema.Defect(),
+ },
+) {
+ static fromRequestUrl(requestUrl: string, cause: unknown): RemoteEnvironmentAuthInvalidJsonError {
+ return new RemoteEnvironmentAuthInvalidJsonError({
+ ...requestUrlDiagnosticFields(requestUrl),
+ cause,
+ });
+ }
-export class RemoteEnvironmentAuthUndeclaredStatusError extends Data.TaggedError(
+ override get message(): string {
+ return `Remote environment endpoint at ${requestUrlDescription(this)} returned an invalid response.`;
+ }
+}
+
+export class RemoteEnvironmentAuthUndeclaredStatusError extends Schema.TaggedErrorClass()(
"RemoteEnvironmentAuthUndeclaredStatusError",
-)<{
- readonly message: string;
- readonly status: number;
- readonly requestUrl: string;
-}> {
- constructor(requestUrl: string, status: number) {
- super({
- message: `Remote environment endpoint ${requestUrl} returned undeclared status ${status}.`,
- requestUrl,
+ {
+ ...requestUrlDiagnosticSchema,
+ status: Schema.Number,
+ cause: Schema.Defect(),
+ },
+) {
+ static fromRequestUrl(
+ requestUrl: string,
+ status: number,
+ cause: unknown,
+ ): RemoteEnvironmentAuthUndeclaredStatusError {
+ return new RemoteEnvironmentAuthUndeclaredStatusError({
+ ...requestUrlDiagnosticFields(requestUrl),
status,
+ cause,
});
}
+
+ override get message(): string {
+ return `Remote environment endpoint at ${requestUrlDescription(this)} returned undeclared status ${this.status}.`;
+ }
}
-export class RemoteEnvironmentAuthTimeoutError extends Data.TaggedError(
+export const isRemoteEnvironmentAuthUndeclaredStatusError = Schema.is(
+ RemoteEnvironmentAuthUndeclaredStatusError,
+);
+
+export class RemoteEnvironmentAuthTimeoutError extends Schema.TaggedErrorClass()(
"RemoteEnvironmentAuthTimeoutError",
-)<{
- readonly message: string;
- readonly requestUrl: string;
- readonly timeoutMs: number;
-}> {
- constructor(requestUrl: string, timeoutMs: number) {
- super({
- message: `Remote environment endpoint ${requestUrl} timed out after ${timeoutMs}ms.`,
- requestUrl,
+ {
+ ...requestUrlDiagnosticSchema,
+ timeoutMs: Schema.Number,
+ },
+) {
+ static fromRequestUrl(requestUrl: string, timeoutMs: number): RemoteEnvironmentAuthTimeoutError {
+ return new RemoteEnvironmentAuthTimeoutError({
+ ...requestUrlDiagnosticFields(requestUrl),
timeoutMs,
});
}
+
+ override get message(): string {
+ return `Remote environment endpoint at ${requestUrlDescription(this)} timed out after ${this.timeoutMs}ms.`;
+ }
}
+const isRemoteEnvironmentAuthTimeoutError = Schema.is(RemoteEnvironmentAuthTimeoutError);
+
export type RemoteEnvironmentRequestError =
| EnvironmentRequestInvalidError
| EnvironmentAuthInvalidError
@@ -101,40 +169,29 @@ const failRemoteRequest = (
requestUrl: string,
cause: unknown,
): Effect.Effect => {
- if (cause instanceof RemoteEnvironmentAuthTimeoutError) {
+ if (isRemoteEnvironmentAuthTimeoutError(cause)) {
return Effect.fail(cause);
}
if (isEnvironmentHttpCommonError(cause)) {
return Effect.fail(cause);
}
if (Schema.isSchemaError(cause)) {
- return Effect.fail(
- new RemoteEnvironmentAuthInvalidJsonError({
- message: `Remote environment endpoint returned an invalid response from ${requestUrl}.`,
- cause,
- }),
- );
+ return Effect.fail(RemoteEnvironmentAuthInvalidJsonError.fromRequestUrl(requestUrl, cause));
}
if (HttpClientError.isHttpClientError(cause) && cause.response !== undefined) {
const response = cause.response;
if (response.status < 200 || response.status >= 300) {
return Effect.fail(
- new RemoteEnvironmentAuthUndeclaredStatusError(requestUrl, response.status),
+ RemoteEnvironmentAuthUndeclaredStatusError.fromRequestUrl(
+ requestUrl,
+ response.status,
+ cause,
+ ),
);
}
- return Effect.fail(
- new RemoteEnvironmentAuthInvalidJsonError({
- message: `Remote environment endpoint returned an invalid response from ${requestUrl}.`,
- cause,
- }),
- );
+ return Effect.fail(RemoteEnvironmentAuthInvalidJsonError.fromRequestUrl(requestUrl, cause));
}
- return Effect.fail(
- new RemoteEnvironmentAuthFetchError({
- message: `Failed to fetch remote environment endpoint ${requestUrl} (${String(cause)}).`,
- cause,
- }),
- );
+ return Effect.fail(RemoteEnvironmentAuthFetchError.fromRequestUrl(requestUrl, cause));
};
export const executeEnvironmentHttpRequest = (
@@ -146,7 +203,8 @@ export const executeEnvironmentHttpRequest = (
Effect.timeoutOption(Duration.millis(timeoutMs)),
Effect.flatMap(
Option.match({
- onNone: () => Effect.fail(new RemoteEnvironmentAuthTimeoutError(requestUrl, timeoutMs)),
+ onNone: () =>
+ Effect.fail(RemoteEnvironmentAuthTimeoutError.fromRequestUrl(requestUrl, timeoutMs)),
onSome: Effect.succeed,
}),
),
diff --git a/packages/client-runtime/src/rpc/session.ts b/packages/client-runtime/src/rpc/session.ts
index f9594c1b7ca..c651ac697d3 100644
--- a/packages/client-runtime/src/rpc/session.ts
+++ b/packages/client-runtime/src/rpc/session.ts
@@ -49,17 +49,20 @@ function mapInitialConfigError(error: InitialConfigError): ConnectionAttemptErro
return new ConnectionBlockedError({
reason: "permission",
detail: error.message,
+ cause: error,
});
- case "KeybindingsConfigParseError":
+ case "KeybindingsConfigError":
case "ServerSettingsError":
return new ConnectionTransientErrorClass({
reason: "remote-unavailable",
detail: error.message,
+ cause: error,
});
case "RpcClientError":
return new ConnectionTransientErrorClass({
reason: "transport",
detail: error.message,
+ cause: error,
});
}
}
diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts
index 4a5ffd0c3dd..c0e6eea7f0f 100644
--- a/packages/contracts/src/keybindings.ts
+++ b/packages/contracts/src/keybindings.ts
@@ -157,14 +157,21 @@ export const ResolvedKeybindingsConfig = Schema.Array(ResolvedKeybindingRule).ch
export type ResolvedKeybindingsConfig = typeof ResolvedKeybindingsConfig.Type;
export class KeybindingsConfigError extends Schema.TaggedErrorClass()(
- "KeybindingsConfigParseError",
+ "KeybindingsConfigError",
{
configPath: Schema.String,
- detail: Schema.String,
- cause: Schema.optional(Schema.Defect()),
+ operation: Schema.Literals([
+ "access",
+ "read",
+ "decode",
+ "encode",
+ "write",
+ "prepare-directory",
+ ]),
+ cause: Schema.Defect(),
},
) {
override get message(): string {
- return `Unable to parse keybindings config at ${this.configPath}: ${this.detail}`;
+ return `Keybindings config operation '${this.operation}' failed at ${this.configPath}.`;
}
}