authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-05 14:39:14-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-05 14:39:14-07:00
log2a68a777afda95aa23538b9954c5a15221ed2857
tree5896db9a5b3788857fed1e2babb69e64a40df3fb
parent404e229f22ef2dc7e42074d5379d5c554dd2c083
signature Signed by PGP key B5690EEEBB952194

fix: a failed DPoP key load and a fresh maintenance read are no longer cached (#15500)

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
3import * as NodeCrypto from "node:crypto";
4
5import { vi } from "vite-plus/test";
6import { assert, describe, it } from "@effect/vitest";
7import { ManagedRelay } from "@t3tools/client-runtime/relay";
8import * as Effect from "effect/Effect";
9import * as Layer from "effect/Layer";
10import { FetchHttpClient } from "effect/http";
11
12import { cryptoLayer } from "./dpop";
13import { managedRelayClientLayer } from "./managedRelayLayer";
14
15vi.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
24const secureStore = new Map<string, string>();
25let failNextRead = false;
26vi.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
44describe("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 @@
11import { ManagedRelay } from "@t3tools/client-runtime/relay";
22import { RelayMobileClientId } from "@t3tools/contracts/relay";
3import * as Cache from "effect/Cache";
34import * as Crypto from "effect/Crypto";
5import * as Duration from "effect/Duration";
46import * as Effect from "effect/Effect";
7import * as Exit from "effect/Exit";
58import * as Layer from "effect/Layer";
69
710import { createDpopProof, loadOrCreateDpopProofKeyPair } from "./dpop";
......@@ -11,9 +14,16 @@ const relayDpopSignerLayer = Layer.effect(
1114 ManagedRelay.ManagedRelayDpopSigner,
1215 Effect.gen(function* () {
1316 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 },
1625 );
26 const loadProofKey = Cache.get(proofKeyCache, undefined);
1727 return ManagedRelay.ManagedRelayDpopSigner.of({
1828 thumbprint: loadProofKey.pipe(
1929 Effect.map((proofKey) => proofKey.thumbprint),
apps/server/src/provider/providerMaintenance.test.ts+57
......@@ -8,7 +8,9 @@ import * as NodePath from "node:path";
88import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts";
99import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
1010import * as Crypto from "effect/Crypto";
11import * as Deferred from "effect/Deferred";
1112import * as Effect from "effect/Effect";
13import * as Fiber from "effect/Fiber";
1214import * as Sink from "effect/Sink";
1315import * as Stream from "effect/Stream";
1416import { HttpClient } from "effect/http";
......@@ -902,6 +904,61 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => {
902904 expect(resolutions).toBe(2);
903905 }),
904906 );
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 );
905962});
906963
907964it("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";
77import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
88import { causeErrorTag } from "@t3tools/shared/observability";
99import { resolveCommandPath } from "@t3tools/shared/shell";
10import * as Cache from "effect/Cache";
1011import * as Config from "effect/Config";
1112import * as Context from "effect/Context";
1213import * as DateTime from "effect/DateTime";
......@@ -702,17 +703,20 @@ export const resolveProviderMaintenanceCapabilitiesEffect = Effect.fn(
702703/**
703704 * Turn a one-shot resolution into the shape drivers expose: a cached read for
704705 * 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.
706708 */
707709export const makeCachedProviderMaintenanceResolution = Effect.fn(
708710 "makeCachedProviderMaintenanceResolution",
709711)(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);
714718 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;
716720});
717721
718722function deriveVersionAdvisory(input: {