From da8e4767a506c7f5dc02623fe74a8f12e7c5d474 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 08:11:46 -0700 Subject: [PATCH 1/2] Structure client cloud cryptography errors Co-authored-by: codex --- apps/mobile/src/features/cloud/dpop.test.ts | 19 +++- apps/mobile/src/features/cloud/dpop.ts | 100 ++++++++++++------ .../features/cloud/managedRelayTokenStore.ts | 65 +++++++----- apps/web/src/cloud/dpop.test.ts | 24 ++++- apps/web/src/cloud/dpop.ts | 75 +++++++++---- 5 files changed, 198 insertions(+), 85 deletions(-) diff --git a/apps/mobile/src/features/cloud/dpop.test.ts b/apps/mobile/src/features/cloud/dpop.test.ts index 8945d148ee9..d61a1164b74 100644 --- a/apps/mobile/src/features/cloud/dpop.test.ts +++ b/apps/mobile/src/features/cloud/dpop.test.ts @@ -95,7 +95,24 @@ describe("mobile DPoP", () => { const error = yield* loadOrCreateDpopProofKeyPair().pipe(Effect.flip); - expect(error.message).toBe("Stored DPoP proof key is invalid."); + expect(error).toMatchObject({ + operation: "decode-key", + cause: expect.anything(), + }); + }).pipe(Effect.provide(cryptoLayer)), + ); + + it.effect("reports invalid proof URLs as a structural operation failure", () => + Effect.gen(function* () { + const proofKey = yield* generateDpopProofKeyPair(); + const error = yield* createDpopProof({ + method: "POST", + url: "not a URL", + proofKey, + }).pipe(Effect.flip); + + expect(error).toMatchObject({ operation: "normalize-proof-url" }); + expect(error.cause).toBeUndefined(); }).pipe(Effect.provide(cryptoLayer)), ); diff --git a/apps/mobile/src/features/cloud/dpop.ts b/apps/mobile/src/features/cloud/dpop.ts index 0bd4b7ff1bd..daf08942583 100644 --- a/apps/mobile/src/features/cloud/dpop.ts +++ b/apps/mobile/src/features/cloud/dpop.ts @@ -1,6 +1,5 @@ import * as Clock from "effect/Clock"; import * as Crypto from "effect/Crypto"; -import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Encoding from "effect/Encoding"; import * as Result from "effect/Result"; @@ -16,13 +15,53 @@ import { } from "@t3tools/shared/dpop"; import * as Layer from "effect/Layer"; -export class CloudDpopError extends Data.TaggedError("CloudDpopError")<{ - readonly message: string; - readonly cause?: unknown; -}> {} +export class CloudDpopError extends Schema.TaggedErrorClass()("CloudDpopError", { + operation: Schema.Literals([ + "generate-key-randomness", + "derive-public-key", + "read-key", + "decode-key", + "validate-key", + "encode-key", + "store-key", + "normalize-proof-url", + "import-private-key", + "generate-proof-id", + "encode-proof-header", + "encode-proof-payload", + "hash-signing-input", + "sign-proof", + ]), + cause: Schema.optional(Schema.Defect()), +}) { + override get message(): string { + return `Cloud DPoP operation "${this.operation}" failed.`; + } +} -function cloudDpopError(message: string) { - return (cause: unknown) => new CloudDpopError({ message, cause }); +export class DpopPublicKeyFormatError extends Schema.TaggedErrorClass()( + "DpopPublicKeyFormatError", + { + byteLength: Schema.Number, + firstByte: Schema.optional(Schema.Number), + }, +) { + override get message(): string { + const prefix = + this.firstByte === undefined + ? "no prefix byte" + : `prefix 0x${this.firstByte.toString(16).padStart(2, "0")}`; + return `Expected a 65-byte uncompressed P-256 public key beginning with 0x04; received ${this.byteLength} bytes with ${prefix}.`; + } +} + +export class DpopStoredPublicKeyMismatchError extends Schema.TaggedErrorClass()( + "DpopStoredPublicKeyMismatchError", + {}, +) { + override get message(): string { + return "Stored DPoP private and public key material do not match."; + } } const DpopPrivateJwkSchema = Schema.Struct({ @@ -97,29 +136,28 @@ function base64UrlToBytes(value: string): Uint8Array { return Result.getOrThrow(Encoding.decodeBase64Url(value)); } -function sha256Digest( - data: Uint8Array, - message: string, -): Effect.Effect { +function sha256Digest(data: Uint8Array): Effect.Effect { return Crypto.Crypto.pipe( Effect.flatMap((crypto) => crypto.digest("SHA-256", data)), - Effect.mapError(cloudDpopError(message)), + Effect.mapError((cause) => new CloudDpopError({ operation: "hash-signing-input", cause })), ); } function secureRandomBytes( byteCount: number, - message: string, ): Effect.Effect { return Crypto.Crypto.pipe( Effect.flatMap((crypto) => crypto.randomBytes(byteCount)), - Effect.mapError(cloudDpopError(message)), + Effect.mapError((cause) => new CloudDpopError({ operation: "generate-key-randomness", cause })), ); } function publicJwkFromUncompressedPublicKey(publicKey: Uint8Array): DpopPublicJwk { if (publicKey.length !== 65 || publicKey[0] !== 0x04) { - throw new Error("Generated DPoP public key is not an uncompressed P-256 point."); + throw new DpopPublicKeyFormatError({ + byteLength: publicKey.length, + ...(publicKey[0] === undefined ? {} : { firstByte: publicKey[0] }), + }); } return { kty: "EC", @@ -144,14 +182,11 @@ export function generateDpopProofKeyPair(): Effect.Effect< return Effect.gen(function* () { let privateKey: Uint8Array; do { - privateKey = yield* secureRandomBytes( - p256.CURVE.nByteLength, - "Could not generate DPoP key pair randomness.", - ); + privateKey = yield* secureRandomBytes(p256.CURVE.nByteLength); } while (!p256.utils.isValidPrivateKey(privateKey)); const publicJwk = yield* Effect.try({ try: () => publicJwkFromUncompressedPublicKey(p256.getPublicKey(privateKey, false)), - catch: cloudDpopError("Generated DPoP public key is invalid."), + catch: (cause) => new CloudDpopError({ operation: "derive-public-key", cause }), }); const thumbprint = computeDpopJwkThumbprint(publicJwk); return { @@ -170,11 +205,11 @@ export function loadOrCreateDpopProofKeyPair(): Effect.Effect< return Effect.gen(function* () { const stored = yield* Effect.tryPromise({ try: () => SecureStore.getItemAsync(DPOP_PROOF_KEY_STORAGE_KEY), - catch: cloudDpopError("Could not read the DPoP proof key."), + catch: (cause) => new CloudDpopError({ operation: "read-key", cause }), }); if (stored) { const storedPrivateJwk = yield* decodeDpopPrivateJwkJson(stored).pipe( - Effect.mapError(cloudDpopError("Stored DPoP proof key is invalid.")), + Effect.mapError((cause) => new CloudDpopError({ operation: "decode-key", cause })), ); const restored = yield* Effect.try({ try: () => { @@ -183,11 +218,11 @@ export function loadOrCreateDpopProofKeyPair(): Effect.Effect< p256.getPublicKey(privateKey, false), ); if (publicJwk.x !== storedPrivateJwk.x || publicJwk.y !== storedPrivateJwk.y) { - throw new Error("Stored DPoP key does not match its public key."); + throw new DpopStoredPublicKeyMismatchError(); } return { privateJwk: storedPrivateJwk, publicJwk }; }, - catch: cloudDpopError("Stored DPoP proof key is invalid."), + catch: (cause) => new CloudDpopError({ operation: "validate-key", cause }), }); return { ...restored, @@ -196,11 +231,11 @@ export function loadOrCreateDpopProofKeyPair(): Effect.Effect< } const generated = yield* generateDpopProofKeyPair(); const encodedPrivateJwk = yield* encodeDpopPrivateJwkJson(generated.privateJwk).pipe( - Effect.mapError(cloudDpopError("Could not encode the DPoP proof key.")), + Effect.mapError((cause) => new CloudDpopError({ operation: "encode-key", cause })), ); yield* Effect.tryPromise({ try: () => SecureStore.setItemAsync(DPOP_PROOF_KEY_STORAGE_KEY, encodedPrivateJwk), - catch: cloudDpopError("Could not store the DPoP proof key."), + catch: (cause) => new CloudDpopError({ operation: "store-key", cause }), }); return generated; }); @@ -210,7 +245,7 @@ function normalizeHtu(url: string): Effect.Effect { const normalized = normalizeDpopHtu(url); return normalized ? Effect.succeed(normalized) - : Effect.fail(new CloudDpopError({ message: "DPoP URL is invalid." })); + : Effect.fail(new CloudDpopError({ operation: "normalize-proof-url" })); } export function createDpopProof(input: { @@ -227,12 +262,12 @@ export function createDpopProof(input: { const keyPair = input.proofKey ?? (yield* generateDpopProofKeyPair()); const privateKey = yield* Effect.try({ try: () => base64UrlToBytes(keyPair.privateJwk.d), - catch: cloudDpopError("Could not import DPoP private key."), + catch: (cause) => new CloudDpopError({ operation: "import-private-key", cause }), }); const nowMs = yield* Clock.currentTimeMillis; const jti = yield* Crypto.Crypto.pipe( Effect.flatMap((crypto) => crypto.randomUUIDv4), - Effect.mapError(cloudDpopError("Could not generate DPoP proof identifier.")), + Effect.mapError((cause) => new CloudDpopError({ operation: "generate-proof-id", cause })), ); const htu = yield* normalizeHtu(input.url); const header = yield* encodeDpopJwtHeaderJson({ @@ -241,7 +276,7 @@ export function createDpopProof(input: { jwk: keyPair.publicJwk, }).pipe( Effect.map(Encoding.encodeBase64Url), - Effect.mapError(cloudDpopError("Could not encode DPoP proof header.")), + Effect.mapError((cause) => new CloudDpopError({ operation: "encode-proof-header", cause })), ); const ath = input.accessToken ? computeDpopAccessTokenHash(input.accessToken) : null; const payload = yield* encodeDpopJwtPayloadJson({ @@ -252,15 +287,14 @@ export function createDpopProof(input: { ...(ath ? { ath } : {}), }).pipe( Effect.map(Encoding.encodeBase64Url), - Effect.mapError(cloudDpopError("Could not encode DPoP proof payload.")), + Effect.mapError((cause) => new CloudDpopError({ operation: "encode-proof-payload", cause })), ); const signatureInputHash = yield* sha256Digest( new TextEncoder().encode(`${header}.${payload}`), - "Could not hash DPoP signing input.", ); const signature = yield* Effect.try({ try: () => p256.sign(signatureInputHash, privateKey, { prehash: false }).toCompactRawBytes(), - catch: cloudDpopError("Could not sign DPoP proof."), + catch: (cause) => new CloudDpopError({ operation: "sign-proof", cause }), }); return { proof: `${header}.${payload}.${Encoding.encodeBase64Url(signature)}`, diff --git a/apps/mobile/src/features/cloud/managedRelayTokenStore.ts b/apps/mobile/src/features/cloud/managedRelayTokenStore.ts index 460c71c1fa7..25c4ba879a2 100644 --- a/apps/mobile/src/features/cloud/managedRelayTokenStore.ts +++ b/apps/mobile/src/features/cloud/managedRelayTokenStore.ts @@ -1,5 +1,4 @@ import { ManagedRelay } from "@t3tools/client-runtime/relay"; -import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import * as SecureStore from "expo-secure-store"; @@ -31,36 +30,45 @@ const decodeManagedRelayAccessTokenCache = Schema.decodeUnknownEffect( ); const encodeManagedRelayAccessTokenCache = Schema.encodeEffect(ManagedRelayAccessTokenCacheSchema); -export class ManagedRelayTokenStoreError extends Data.TaggedError("ManagedRelayTokenStoreError")<{ - readonly message: string; - readonly cause: unknown; -}> {} - -const storeError = - (message: string) => - (cause: unknown): ManagedRelayTokenStoreError => - new ManagedRelayTokenStoreError({ message, cause }); +export class ManagedRelayTokenStoreError extends Schema.TaggedErrorClass()( + "ManagedRelayTokenStoreError", + { + operation: Schema.Literals([ + "read-cache", + "decode-cache", + "encode-cache", + "write-cache", + "clear-cache", + ]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Managed relay token store operation "${this.operation}" failed.`; + } +} -function logStoreFailure(operation: string) { - return (error: ManagedRelayTokenStoreError) => - Effect.logWarning(`Managed relay token store ${operation} failed.`).pipe( - Effect.annotateLogs({ - errorTag: error._tag, - message: error.message, - }), - ); +function logStoreFailure(error: ManagedRelayTokenStoreError) { + return Effect.logWarning(error.message).pipe( + Effect.annotateLogs({ + errorTag: error._tag, + operation: error.operation, + }), + ); } const loadManagedRelayAccessTokens = Effect.tryPromise({ try: () => SecureStore.getItemAsync(MANAGED_RELAY_TOKEN_CACHE_KEY), - catch: storeError("Could not read persisted relay access tokens."), + catch: (cause) => new ManagedRelayTokenStoreError({ operation: "read-cache", cause }), }).pipe( Effect.flatMap((encoded) => encoded === null ? Effect.succeed>([]) : decodeManagedRelayAccessTokenCache(encoded).pipe( Effect.map((cache) => cache.entries), - Effect.mapError(storeError("Persisted relay access tokens are invalid.")), + Effect.mapError( + (cause) => new ManagedRelayTokenStoreError({ operation: "decode-cache", cause }), + ), ), ), ); @@ -72,34 +80,33 @@ const saveManagedRelayAccessTokens = ( version: MANAGED_RELAY_TOKEN_CACHE_VERSION, entries, }).pipe( - Effect.mapError(storeError("Could not encode relay access tokens.")), + Effect.mapError( + (cause) => new ManagedRelayTokenStoreError({ operation: "encode-cache", cause }), + ), Effect.flatMap((encoded) => Effect.tryPromise({ try: () => SecureStore.setItemAsync(MANAGED_RELAY_TOKEN_CACHE_KEY, encoded), - catch: storeError("Could not persist relay access tokens."), + catch: (cause) => new ManagedRelayTokenStoreError({ operation: "write-cache", cause }), }), ), ); const clearManagedRelayAccessTokens = Effect.tryPromise({ try: () => SecureStore.deleteItemAsync(MANAGED_RELAY_TOKEN_CACHE_KEY), - catch: storeError("Could not clear persisted relay access tokens."), + catch: (cause) => new ManagedRelayTokenStoreError({ operation: "clear-cache", cause }), }); export const managedRelayAccessTokenStore: ManagedRelay.ManagedRelayAccessTokenStore = { load: loadManagedRelayAccessTokens.pipe( - Effect.tapError(logStoreFailure("load")), + Effect.tapError(logStoreFailure), Effect.orElseSucceed(() => []), Effect.withSpan("mobile.managedRelayTokenStore.load"), ), save: Effect.fn("mobile.managedRelayTokenStore.save")((entries) => - saveManagedRelayAccessTokens(entries).pipe( - Effect.tapError(logStoreFailure("save")), - Effect.ignore, - ), + saveManagedRelayAccessTokens(entries).pipe(Effect.tapError(logStoreFailure), Effect.ignore), ), clear: clearManagedRelayAccessTokens.pipe( - Effect.tapError(logStoreFailure("clear")), + Effect.tapError(logStoreFailure), Effect.ignore, Effect.withSpan("mobile.managedRelayTokenStore.clear"), ), diff --git a/apps/web/src/cloud/dpop.test.ts b/apps/web/src/cloud/dpop.test.ts index 75951db1baf..477135f51b8 100644 --- a/apps/web/src/cloud/dpop.test.ts +++ b/apps/web/src/cloud/dpop.test.ts @@ -4,9 +4,31 @@ import * as Effect from "effect/Effect"; import { decodeJwt } from "jose"; import { vi } from "vite-plus/test"; -import { browserCryptoLayer, createBrowserDpopProof, generateBrowserDpopKey } from "./dpop"; +import { + BrowserDpopError, + type BrowserDpopKey, + browserCryptoLayer, + createBrowserDpopProof, + generateBrowserDpopKey, +} from "./dpop"; describe("browser DPoP proofs", () => { + it.effect("reports URL normalization failures structurally with their cause", () => + Effect.gen(function* () { + const error = yield* createBrowserDpopProof({ + method: "POST", + url: "not a URL", + proofKey: {} as BrowserDpopKey, + }).pipe(Effect.provide(browserCryptoLayer), Effect.flip); + + expect(error).toBeInstanceOf(BrowserDpopError); + expect(error).toMatchObject({ + operation: "normalize-proof-url", + cause: expect.any(TypeError), + }); + }), + ); + it.effect("signs relay resource proofs with an access-token hash", () => Effect.gen(function* () { vi.stubGlobal("indexedDB", undefined); diff --git a/apps/web/src/cloud/dpop.ts b/apps/web/src/cloud/dpop.ts index d0994955db1..90c0ba927ee 100644 --- a/apps/web/src/cloud/dpop.ts +++ b/apps/web/src/cloud/dpop.ts @@ -4,7 +4,6 @@ import { DpopPublicJwk, } from "@t3tools/shared/dpop"; import * as Crypto from "effect/Crypto"; -import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; @@ -16,10 +15,31 @@ export interface BrowserDpopKey { readonly thumbprint: string; } -export class BrowserDpopError extends Data.TaggedError("BrowserDpopError")<{ - readonly message: string; - readonly cause?: unknown; -}> {} +export class BrowserDpopError extends Schema.TaggedErrorClass()( + "BrowserDpopError", + { + operation: Schema.Literals([ + "open-key-storage", + "read-key", + "write-key", + "generate-key", + "export-private-key", + "export-public-key", + "decode-public-key", + "import-private-key", + "normalize-proof-url", + "generate-proof-id", + "sign-proof", + ]), + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return `Browser DPoP operation "${this.operation}" failed.`; + } +} + +export const isBrowserDpopError = Schema.is(BrowserDpopError); const DPOP_DATABASE_NAME = "t3code:cloud-auth"; const DPOP_DATABASE_VERSION = 1; @@ -40,16 +60,17 @@ export const browserCryptoLayer = Layer.succeed( }), ); -function dpopError(message: string, cause?: unknown) { - return new BrowserDpopError({ message, ...(cause === undefined ? {} : { cause }) }); -} - function openDpopDatabase(): Effect.Effect { return Effect.callback((resume) => { const request = indexedDB.open(DPOP_DATABASE_NAME, DPOP_DATABASE_VERSION); request.addEventListener("error", () => resume( - Effect.fail(dpopError("Could not open DPoP key storage.", request.error ?? undefined)), + Effect.fail( + new BrowserDpopError({ + operation: "open-key-storage", + ...(request.error === null ? {} : { cause: request.error }), + }), + ), ), ); request.addEventListener("upgradeneeded", () => { @@ -74,7 +95,14 @@ export function readStoredBrowserDpopKey(): Effect.Effect - resume(Effect.fail(dpopError("Could not read DPoP key.", request.error ?? undefined))), + resume( + Effect.fail( + new BrowserDpopError({ + operation: "read-key", + ...(request.error === null ? {} : { cause: request.error }), + }), + ), + ), ); request.addEventListener("success", () => resume(Effect.succeed((request.result as BrowserDpopKey | undefined) ?? null)), @@ -97,7 +125,12 @@ export function writeStoredBrowserDpopKey( const transaction = database.transaction(DPOP_KEY_STORE_NAME, "readwrite"); transaction.addEventListener("error", () => resume( - Effect.fail(dpopError("Could not write DPoP key.", transaction.error ?? undefined)), + Effect.fail( + new BrowserDpopError({ + operation: "write-key", + ...(transaction.error === null ? {} : { cause: transaction.error }), + }), + ), ), ); transaction.addEventListener("complete", () => resume(Effect.void)); @@ -114,26 +147,26 @@ export const generateBrowserDpopKey = Effect.gen(function* () { "sign", "verify", ]) as Promise, - catch: (cause) => dpopError("Could not generate DPoP proof key.", cause), + catch: (cause) => new BrowserDpopError({ operation: "generate-key", cause }), }); const privateJwk = yield* Effect.tryPromise({ try: () => crypto.subtle.exportKey("jwk", generated.privateKey), - catch: (cause) => dpopError("Could not export DPoP private key.", cause), + catch: (cause) => new BrowserDpopError({ operation: "export-private-key", cause }), }); const publicJwk = yield* Effect.tryPromise({ try: () => crypto.subtle.exportKey("jwk", generated.publicKey), - catch: (cause) => dpopError("Could not export DPoP public key.", cause), + catch: (cause) => new BrowserDpopError({ operation: "export-public-key", cause }), }).pipe( Effect.flatMap((jwk) => decodeDpopPublicJwk(jwk)), Effect.mapError((cause) => - cause instanceof BrowserDpopError + isBrowserDpopError(cause) ? cause - : dpopError("Generated DPoP public key is invalid.", cause), + : new BrowserDpopError({ operation: "decode-public-key", cause }), ), ); const privateKey = yield* Effect.tryPromise({ try: () => importJWK(privateJwk as JWK, "ES256", { extractable: false }) as Promise, - catch: (cause) => dpopError("Could not import DPoP private key.", cause), + catch: (cause) => new BrowserDpopError({ operation: "import-private-key", cause }), }); return { privateKey, @@ -155,13 +188,13 @@ export function createBrowserDpopProof(input: { return Effect.gen(function* () { const normalizedUrl = yield* Effect.try({ try: () => new URL(input.url), - catch: (cause) => dpopError("Could not normalize DPoP proof URL.", cause), + catch: (cause) => new BrowserDpopError({ operation: "normalize-proof-url", cause }), }); normalizedUrl.search = ""; normalizedUrl.hash = ""; const jti = yield* Crypto.Crypto.pipe( Effect.flatMap((crypto) => crypto.randomUUIDv4), - Effect.mapError((cause) => dpopError("Could not generate DPoP proof identifier.", cause)), + Effect.mapError((cause) => new BrowserDpopError({ operation: "generate-proof-id", cause })), ); const proof = yield* Effect.tryPromise({ try: () => @@ -178,7 +211,7 @@ export function createBrowserDpopProof(input: { }) .setIssuedAt() .sign(input.proofKey.privateKey), - catch: (cause) => dpopError("Could not sign DPoP proof.", cause), + catch: (cause) => new BrowserDpopError({ operation: "sign-proof", cause }), }); return { proof, thumbprint: input.proofKey.thumbprint }; }); From df4f4d70e0f525b489ea50160df7cfd6ab8b272d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 08:15:31 -0700 Subject: [PATCH 2/2] Preserve token store causes in logs Co-authored-by: codex --- .../cloud/managedRelayTokenStore.test.ts | 23 +++++++++++++++++-- .../features/cloud/managedRelayTokenStore.ts | 3 +++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/features/cloud/managedRelayTokenStore.test.ts b/apps/mobile/src/features/cloud/managedRelayTokenStore.test.ts index 616fc1add7c..b3e369fe2f6 100644 --- a/apps/mobile/src/features/cloud/managedRelayTokenStore.test.ts +++ b/apps/mobile/src/features/cloud/managedRelayTokenStore.test.ts @@ -1,5 +1,7 @@ import { expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as Logger from "effect/Logger"; +import * as References from "effect/References"; import { vi } from "vite-plus/test"; const secureStore = vi.hoisted(() => new Map()); @@ -16,7 +18,10 @@ vi.mock("expo-secure-store", () => ({ }), })); -import { managedRelayAccessTokenStore } from "./managedRelayTokenStore"; +import { + isManagedRelayTokenStoreError, + managedRelayAccessTokenStore, +} from "./managedRelayTokenStore"; it.effect("round-trips and clears persisted managed relay access tokens", () => Effect.gen(function* () { @@ -45,7 +50,21 @@ it.effect("falls back to an empty cache when persisted data is invalid", () => Effect.gen(function* () { secureStore.clear(); secureStore.set("t3code.cloud.relay-access-tokens", "not-json"); + const annotations: Array> = []; + const logger = Logger.make(({ fiber }) => { + annotations.push(fiber.getRef(References.CurrentLogAnnotations)); + }); - expect(yield* managedRelayAccessTokenStore.load).toEqual([]); + expect( + yield* managedRelayAccessTokenStore.load.pipe( + Effect.provide(Logger.layer([logger], { mergeWithExisting: false })), + ), + ).toEqual([]); + expect(annotations).toHaveLength(1); + expect(isManagedRelayTokenStoreError(annotations[0]?.error)).toBe(true); + expect(annotations[0]?.error).toMatchObject({ + operation: "decode-cache", + cause: expect.anything(), + }); }), ); diff --git a/apps/mobile/src/features/cloud/managedRelayTokenStore.ts b/apps/mobile/src/features/cloud/managedRelayTokenStore.ts index 25c4ba879a2..beee43b9406 100644 --- a/apps/mobile/src/features/cloud/managedRelayTokenStore.ts +++ b/apps/mobile/src/features/cloud/managedRelayTokenStore.ts @@ -48,9 +48,12 @@ export class ManagedRelayTokenStoreError extends Schema.TaggedErrorClass