| 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 | 1 | import { ManagedRelay } from "@t3tools/client-runtime/relay"; |
| 2 | 2 | import { RelayMobileClientId } from "@t3tools/contracts/relay"; |
| 3 | import * as Cache from "effect/Cache"; | |
| 3 | 4 | import * as Crypto from "effect/Crypto"; |
| 5 | import * as Duration from "effect/Duration"; | |
| 4 | 6 | import * as Effect from "effect/Effect"; |
| 7 | import * as Exit from "effect/Exit"; | |
| 5 | 8 | import * as Layer from "effect/Layer"; |
| 6 | 9 | |
| 7 | 10 | import { createDpopProof, loadOrCreateDpopProofKeyPair } from "./dpop"; |
| ... | ... | @@ -11,9 +14,16 @@ const relayDpopSignerLayer = Layer.effect( |
| 11 | 14 | ManagedRelay.ManagedRelayDpopSigner, |
| 12 | 15 | Effect.gen(function* () { |
| 13 | 16 | const crypto = yield* Crypto.Crypto; |
| 14 | const loadProofKey = yield* Effect.cached( | |
| 15 | loadOrCreateDpopProofKeyPair().pipe(Effect.provideService(Crypto.Crypto, crypto)), | |
| 17 | // Keeps the loaded key for the app's lifetime. A failed or interrupted | |
| 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 | 27 | return ManagedRelay.ManagedRelayDpopSigner.of({ |
| 18 | 28 | thumbprint: loadProofKey.pipe( |
| 19 | 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 | 8 | import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; |
| 9 | 9 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; |
| 10 | 10 | import * as Crypto from "effect/Crypto"; |
| 11 | import * as Deferred from "effect/Deferred"; | |
| 11 | 12 | import * as Effect from "effect/Effect"; |
| 13 | import * as Fiber from "effect/Fiber"; | |
| 12 | 14 | import * as Sink from "effect/Sink"; |
| 13 | 15 | import * as Stream from "effect/Stream"; |
| 14 | 16 | import { HttpClient } from "effect/http"; |
| ... | ... | @@ -902,6 +904,61 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { |
| 902 | 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 | 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 | 7 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; |
| 8 | 8 | import { causeErrorTag } from "@t3tools/shared/observability"; |
| 9 | 9 | import { resolveCommandPath } from "@t3tools/shared/shell"; |
| 10 | import * as Cache from "effect/Cache"; | |
| 10 | 11 | import * as Config from "effect/Config"; |
| 11 | 12 | import * as Context from "effect/Context"; |
| 12 | 13 | import * as DateTime from "effect/DateTime"; |
| ... | ... | @@ -702,17 +703,20 @@ export const resolveProviderMaintenanceCapabilitiesEffect = Effect.fn( |
| 702 | 703 | /** |
| 703 | 704 | * Turn a one-shot resolution into the shape drivers expose: a cached read for |
| 704 | 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 | 709 | export const makeCachedProviderMaintenanceResolution = Effect.fn( |
| 708 | 710 | "makeCachedProviderMaintenanceResolution", |
| 709 | 711 | )(function* (resolve: Effect.Effect<ProviderMaintenanceCapabilities>) { |
| 710 | const [cached, invalidate] = yield* Effect.cachedInvalidateWithTTL( | |
| 711 | resolve, | |
| 712 | MAINTENANCE_CAPABILITIES_CACHE_TTL, | |
| 713 | ); | |
| 712 | const cache = yield* Cache.make({ | |
| 713 | capacity: 1, | |
| 714 | timeToLive: MAINTENANCE_CAPABILITIES_CACHE_TTL, | |
| 715 | lookup: () => resolve, | |
| 716 | }); | |
| 717 | const cached = Cache.get(cache, undefined); | |
| 714 | 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 | 722 | function deriveVersionAdvisory(input: { |