| author | |
| committer | |
| log | 2a68a777afda95aa23538b9954c5a15221ed2857 |
| tree | 5896db9a5b3788857fed1e2babb69e64a40df3fb |
| parent | 404e229f22ef2dc7e42074d5379d5c554dd2c083 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>4 files changed, 142 insertions(+), 8 deletions(-)
apps/mobile/src/features/cloud/managedRelayLayer.test.ts created+63| ... | @@ -0,0 +1,63 @@ | ||
| 1 | /// <reference types="node" /> | ||
| 2 | |||
| 3 | import * as NodeCrypto from "node:crypto"; | ||
| 4 | |||
| 5 | import { vi } from "vite-plus/test"; | ||
| 6 | import { assert, describe, it } from "@effect/vitest"; | ||
| 7 | import { ManagedRelay } from "@t3tools/client-runtime/relay"; | ||
| 8 | import * as Effect from "effect/Effect"; | ||
| 9 | import * as Layer from "effect/Layer"; | ||
| 10 | import { FetchHttpClient } from "effect/http"; | ||
| 11 | |||
| 12 | import { cryptoLayer } from "./dpop"; | ||
| 13 | import { managedRelayClientLayer } from "./managedRelayLayer"; | ||
| 14 | |||
| 15 | vi.mock("expo-crypto", () => ({ | ||
| 16 | CryptoDigestAlgorithm: { SHA256: "SHA-256" }, | ||
| 17 | getRandomBytes: (byteCount: number) => new Uint8Array(NodeCrypto.randomBytes(byteCount)), | ||
| 18 | getRandomBytesAsync: (byteCount: number) => | ||
| 19 | Promise.resolve(new Uint8Array(NodeCrypto.randomBytes(byteCount))), | ||
| 20 | digest: (algorithm: string, data: Uint8Array) => | ||
| 21 | Promise.resolve(new Uint8Array(NodeCrypto.createHash(algorithm).update(data).digest()).buffer), | ||
| 22 | })); | ||
| 23 | |||
| 24 | const secureStore = new Map<string, string>(); | ||
| 25 | let failNextRead = false; | ||
| 26 | vi.mock("expo-secure-store", () => ({ | ||
| 27 | getItemAsync: (key: string) => { | ||
| 28 | if (failNextRead) { | ||
| 29 | failNextRead = false; | ||
| 30 | return Promise.reject(new Error("keychain locked")); | ||
| 31 | } | ||
| 32 | return Promise.resolve(secureStore.get(key) ?? null); | ||
| 33 | }, | ||
| 34 | setItemAsync: (key: string, value: string) => { | ||
| 35 | secureStore.set(key, value); | ||
| 36 | return Promise.resolve(); | ||
| 37 | }, | ||
| 38 | deleteItemAsync: (key: string) => { | ||
| 39 | secureStore.delete(key); | ||
| 40 | return Promise.resolve(); | ||
| 41 | }, | ||
| 42 | })); | ||
| 43 | |||
| 44 | describe("managed relay DPoP signer", () => { | ||
| 45 | it.effect("loads the proof key again after a failed read", () => | ||
| 46 | Effect.gen(function* () { | ||
| 47 | const signer = yield* ManagedRelay.ManagedRelayDpopSigner; | ||
| 48 | failNextRead = true; | ||
| 49 | const failed = yield* Effect.flip(signer.thumbprint); | ||
| 50 | assert.equal(failed._tag, "ManagedRelayDpopKeyLoadError"); | ||
| 51 | |||
| 52 | // The failure was not kept: the next request reads the key store again. | ||
| 53 | const thumbprint = yield* signer.thumbprint; | ||
| 54 | assert.equal(yield* signer.thumbprint, thumbprint); | ||
| 55 | }).pipe( | ||
| 56 | Effect.provide( | ||
| 57 | managedRelayClientLayer("https://relay.example.test").pipe( | ||
| 58 | Layer.provide(Layer.mergeAll(FetchHttpClient.layer, cryptoLayer)), | ||
| 59 | ), | ||
| 60 | ), | ||
| 61 | ), | ||
| 62 | ); | ||
| 63 | }); | ||
apps/mobile/src/features/cloud/managedRelayLayer.ts+12-2| ... | @@ -1,7 +1,10 @@ | ... | @@ -1,7 +1,10 @@ |
| 1 | import { ManagedRelay } from "@t3tools/client-runtime/relay"; | 1 | import { ManagedRelay } from "@t3tools/client-runtime/relay"; |
| 2 | import { RelayMobileClientId } from "@t3tools/contracts/relay"; | 2 | import { RelayMobileClientId } from "@t3tools/contracts/relay"; |
| 3 | import * as Cache from "effect/Cache"; | ||
| 3 | import * as Crypto from "effect/Crypto"; | 4 | import * as Crypto from "effect/Crypto"; |
| 5 | import * as Duration from "effect/Duration"; | ||
| 4 | import * as Effect from "effect/Effect"; | 6 | import * as Effect from "effect/Effect"; |
| 7 | import * as Exit from "effect/Exit"; | ||
| 5 | import * as Layer from "effect/Layer"; | 8 | import * as Layer from "effect/Layer"; |
| 6 | 9 | ||
| 7 | import { createDpopProof, loadOrCreateDpopProofKeyPair } from "./dpop"; | 10 | import { createDpopProof, loadOrCreateDpopProofKeyPair } from "./dpop"; |
| ... | @@ -11,9 +14,16 @@ const relayDpopSignerLayer = Layer.effect( | ... | @@ -11,9 +14,16 @@ const relayDpopSignerLayer = Layer.effect( |
| 11 | ManagedRelay.ManagedRelayDpopSigner, | 14 | ManagedRelay.ManagedRelayDpopSigner, |
| 12 | Effect.gen(function* () { | 15 | Effect.gen(function* () { |
| 13 | const crypto = yield* Crypto.Crypto; | 16 | const crypto = yield* Crypto.Crypto; |
| 14 | const loadProofKey = yield* Effect.cached( | 17 | // Keeps the loaded key for the app's lifetime. A failed or interrupted |
| 15 | loadOrCreateDpopProofKeyPair().pipe(Effect.provideService(Crypto.Crypto, crypto)), | 18 | // load is not kept, so the next relay request loads it again. |
| 19 | const proofKeyCache = yield* Cache.makeWith( | ||
| 20 | () => loadOrCreateDpopProofKeyPair().pipe(Effect.provideService(Crypto.Crypto, crypto)), | ||
| 21 | { | ||
| 22 | capacity: 1, | ||
| 23 | timeToLive: (exit) => (Exit.isSuccess(exit) ? Duration.infinity : Duration.zero), | ||
| 24 | }, | ||
| 16 | ); | 25 | ); |
| 26 | const loadProofKey = Cache.get(proofKeyCache, undefined); | ||
| 17 | return ManagedRelay.ManagedRelayDpopSigner.of({ | 27 | return ManagedRelay.ManagedRelayDpopSigner.of({ |
| 18 | thumbprint: loadProofKey.pipe( | 28 | thumbprint: loadProofKey.pipe( |
| 19 | Effect.map((proofKey) => proofKey.thumbprint), | 29 | Effect.map((proofKey) => proofKey.thumbprint), |
apps/server/src/provider/providerMaintenance.test.ts+57| ... | @@ -8,7 +8,9 @@ import * as NodePath from "node:path"; | ... | @@ -8,7 +8,9 @@ import * as NodePath from "node:path"; |
| 8 | import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; | 8 | import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; |
| 9 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | 9 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; |
| 10 | import * as Crypto from "effect/Crypto"; | 10 | import * as Crypto from "effect/Crypto"; |
| 11 | import * as Deferred from "effect/Deferred"; | ||
| 11 | import * as Effect from "effect/Effect"; | 12 | import * as Effect from "effect/Effect"; |
| 13 | import * as Fiber from "effect/Fiber"; | ||
| 12 | import * as Sink from "effect/Sink"; | 14 | import * as Sink from "effect/Sink"; |
| 13 | import * as Stream from "effect/Stream"; | 15 | import * as Stream from "effect/Stream"; |
| 14 | import { HttpClient } from "effect/http"; | 16 | import { HttpClient } from "effect/http"; |
| ... | @@ -902,6 +904,61 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { | ... | @@ -902,6 +904,61 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { |
| 902 | expect(resolutions).toBe(2); | 904 | expect(resolutions).toBe(2); |
| 903 | }), | 905 | }), |
| 904 | ); | 906 | ); |
| 907 | |||
| 908 | it.effect("starts a fresh read instead of joining the resolution already running", () => | ||
| 909 | Effect.gen(function* () { | ||
| 910 | const releaseStale = yield* Deferred.make<void>(); | ||
| 911 | let resolutions = 0; | ||
| 912 | const resolve = yield* makeCachedProviderMaintenanceResolution( | ||
| 913 | Effect.suspend(() => { | ||
| 914 | resolutions += 1; | ||
| 915 | return resolutions === 1 | ||
| 916 | ? Deferred.await(releaseStale).pipe(Effect.as(manualPackageTool)) | ||
| 917 | : Effect.succeed(manualPackageTool); | ||
| 918 | }), | ||
| 919 | ); | ||
| 920 | |||
| 921 | // An advisory read starts a resolution that is still running when the | ||
| 922 | // user clicks Update; the fresh read must not wait on or reuse it. | ||
| 923 | const advisory = yield* Effect.forkChild(resolve(), { startImmediately: true }); | ||
| 924 | const fresh = yield* Effect.forkChild(resolve({ fresh: true }), { startImmediately: true }); | ||
| 925 | // The fresh read runs its own resolution, so it finishes while the stale | ||
| 926 | // one is still waiting. | ||
| 927 | expect(resolutions).toBe(2); | ||
| 928 | expect(fresh.pollUnsafe()).toBeDefined(); | ||
| 929 | yield* Deferred.succeed(releaseStale, undefined); | ||
| 930 | expect(yield* Fiber.join(fresh)).toEqual(manualPackageTool); | ||
| 931 | yield* Fiber.join(advisory); | ||
| 932 | }), | ||
| 933 | ); | ||
| 934 | |||
| 935 | it.effect("resolves again after the first read is interrupted", () => | ||
| 936 | Effect.gen(function* () { | ||
| 937 | const release = yield* Deferred.make<void>(); | ||
| 938 | let resolutions = 0; | ||
| 939 | const resolve = yield* makeCachedProviderMaintenanceResolution( | ||
| 940 | Effect.suspend(() => { | ||
| 941 | resolutions += 1; | ||
| 942 | return resolutions === 1 | ||
| 943 | ? Effect.never | ||
| 944 | : Deferred.await(release).pipe(Effect.as(manualPackageTool)); | ||
| 945 | }), | ||
| 946 | ); | ||
| 947 | |||
| 948 | const first = yield* Effect.forkChild(resolve(), { startImmediately: true }); | ||
| 949 | yield* Fiber.interrupt(first); | ||
| 950 | |||
| 951 | // The next read resolves again, and a reader that joined it keeps it | ||
| 952 | // running when the reader that started it goes away. | ||
| 953 | const owner = yield* Effect.forkChild(resolve(), { startImmediately: true }); | ||
| 954 | const waiter = yield* Effect.forkChild(resolve(), { startImmediately: true }); | ||
| 955 | yield* Fiber.interrupt(owner); | ||
| 956 | yield* Deferred.succeed(release, undefined); | ||
| 957 | expect(yield* Fiber.join(waiter)).toEqual(manualPackageTool); | ||
| 958 | expect(yield* resolve()).toEqual(manualPackageTool); | ||
| 959 | expect(resolutions).toBe(2); | ||
| 960 | }), | ||
| 961 | ); | ||
| 905 | }); | 962 | }); |
| 906 | 963 | ||
| 907 | it("pins only owned package-manager installs and preserves their execution context", () => { | 964 | it("pins only owned package-manager installs and preserves their execution context", () => { |
apps/server/src/provider/providerMaintenance.ts+10-6| ... | @@ -7,6 +7,7 @@ import { compareSemverVersions } from "@t3tools/shared/semver"; | ... | @@ -7,6 +7,7 @@ import { compareSemverVersions } from "@t3tools/shared/semver"; |
| 7 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | 7 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; |
| 8 | import { causeErrorTag } from "@t3tools/shared/observability"; | 8 | import { causeErrorTag } from "@t3tools/shared/observability"; |
| 9 | import { resolveCommandPath } from "@t3tools/shared/shell"; | 9 | import { resolveCommandPath } from "@t3tools/shared/shell"; |
| 10 | import * as Cache from "effect/Cache"; | ||
| 10 | import * as Config from "effect/Config"; | 11 | import * as Config from "effect/Config"; |
| 11 | import * as Context from "effect/Context"; | 12 | import * as Context from "effect/Context"; |
| 12 | import * as DateTime from "effect/DateTime"; | 13 | import * as DateTime from "effect/DateTime"; |
| ... | @@ -702,17 +703,20 @@ export const resolveProviderMaintenanceCapabilitiesEffect = Effect.fn( | ... | @@ -702,17 +703,20 @@ export const resolveProviderMaintenanceCapabilitiesEffect = Effect.fn( |
| 702 | /** | 703 | /** |
| 703 | * Turn a one-shot resolution into the shape drivers expose: a cached read for | 704 | * Turn a one-shot resolution into the shape drivers expose: a cached read for |
| 704 | * advisories and a `fresh` read that update execution uses so it never trusts | 705 | * advisories and a `fresh` read that update execution uses so it never trusts |
| 705 | * ownership derived before the user clicked. | 706 | * ownership derived before the user clicked. An interrupted resolution is not |
| 707 | * cached, so the next read resolves again. | ||
| 706 | */ | 708 | */ |
| 707 | export const makeCachedProviderMaintenanceResolution = Effect.fn( | 709 | export const makeCachedProviderMaintenanceResolution = Effect.fn( |
| 708 | "makeCachedProviderMaintenanceResolution", | 710 | "makeCachedProviderMaintenanceResolution", |
| 709 | )(function* (resolve: Effect.Effect<ProviderMaintenanceCapabilities>) { | 711 | )(function* (resolve: Effect.Effect<ProviderMaintenanceCapabilities>) { |
| 710 | const [cached, invalidate] = yield* Effect.cachedInvalidateWithTTL( | 712 | const cache = yield* Cache.make({ |
| 711 | resolve, | 713 | capacity: 1, |
| 712 | MAINTENANCE_CAPABILITIES_CACHE_TTL, | 714 | timeToLive: MAINTENANCE_CAPABILITIES_CACHE_TTL, |
| 713 | ); | 715 | lookup: () => resolve, |
| 716 | }); | ||
| 717 | const cached = Cache.get(cache, undefined); | ||
| 714 | return (options?: { readonly fresh?: boolean }) => | 718 | return (options?: { readonly fresh?: boolean }) => |
| 715 | options?.fresh ? invalidate.pipe(Effect.andThen(cached)) : cached; | 719 | options?.fresh ? Cache.invalidate(cache, undefined).pipe(Effect.andThen(cached)) : cached; |
| 716 | }); | 720 | }); |
| 717 | 721 | ||
| 718 | function deriveVersionAdvisory(input: { | 722 | function deriveVersionAdvisory(input: { |