| author | |
| committer | |
| log | f99b20275359ff7acecdb916a9fa25ac33102b12 |
| tree | db79fa1ad241c9fb1c5274a8929ec16660f3b8ac |
| parent | 3062701c24ac6b6bde3d238fab8689e48ce2396b |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>5 files changed, 89 insertions(+), 8 deletions(-)
apps/web/src/cloud/useCloudLinkController.test.tsx+42-1| ... | ... | @@ -4,6 +4,7 @@ import { |
| 4 | 4 | EnvironmentId, |
| 5 | 5 | type EnvironmentCloudLinkStateResult, |
| 6 | 6 | } from "@t3tools/contracts"; |
| 7 | import type { RelayManagedEndpointProviderKind } from "@t3tools/contracts/relay"; | |
| 7 | 8 | import { AsyncResult } from "effect/reactivity"; |
| 8 | 9 | import { act, useLayoutEffect } from "react"; |
| 9 | 10 | import { create, type ReactTestRenderer } from "react-test-renderer"; |
| ... | ... | @@ -13,6 +14,7 @@ const testState = vi.hoisted(() => ({ |
| 13 | 14 | scopes: new Set<string>(), |
| 14 | 15 | snapshot: null as EnvironmentCloudLinkStateResult | null, |
| 15 | 16 | renderedSnapshot: null as EnvironmentCloudLinkStateResult | null, |
| 17 | relayProviderKind: null as RelayManagedEndpointProviderKind | null, | |
| 16 | 18 | getToken: vi.fn<() => Promise<string | null>>(), |
| 17 | 19 | link: vi.fn(), |
| 18 | 20 | unlink: vi.fn(), |
| ... | ... | @@ -22,12 +24,26 @@ const testState = vi.hoisted(() => ({ |
| 22 | 24 | toast: vi.fn(), |
| 23 | 25 | })); |
| 24 | 26 | |
| 27 | vi.mock("@effect/atom-react", () => ({ | |
| 28 | useAtomValue: () => ({ | |
| 29 | environments: new Map( | |
| 30 | testState.relayProviderKind === null | |
| 31 | ? [] | |
| 32 | : [ | |
| 33 | [ | |
| 34 | "primary", | |
| 35 | { environment: { endpoint: { providerKind: testState.relayProviderKind } } }, | |
| 36 | ], | |
| 37 | ], | |
| 38 | ), | |
| 39 | }), | |
| 40 | })); | |
| 25 | 41 | vi.mock("@clerk/react", () => ({ |
| 26 | 42 | useAuth: () => ({ getToken: testState.getToken, isSignedIn: true }), |
| 27 | 43 | })); |
| 28 | 44 | vi.mock("../components/ui/toast", () => ({ toastManager: { add: testState.toast } })); |
| 29 | 45 | vi.mock("../state/relay", () => ({ |
| 30 | relayEnvironmentDiscovery: { refresh: testState.refreshDiscovery }, | |
| 46 | relayEnvironmentDiscovery: { refresh: testState.refreshDiscovery, stateValueAtom: {} }, | |
| 31 | 47 | })); |
| 32 | 48 | vi.mock("../state/session", () => ({ |
| 33 | 49 | readEnvironmentScope: (_environmentId: string, scope: string) => testState.scopes.has(scope), |
| ... | ... | @@ -105,6 +121,7 @@ beforeEach(() => { |
| 105 | 121 | testState.scopes = new Set([AuthRelayReadScope, AuthRelayWriteScope]); |
| 106 | 122 | testState.snapshot = linkedState; |
| 107 | 123 | testState.renderedSnapshot = linkedState; |
| 124 | testState.relayProviderKind = null; | |
| 108 | 125 | testState.getToken.mockReset().mockResolvedValue("clerk-token"); |
| 109 | 126 | for (const command of [ |
| 110 | 127 | testState.link, |
| ... | ... | @@ -191,6 +208,30 @@ describe("useCloudLinkController", () => { |
| 191 | 208 | }); |
| 192 | 209 | }); |
| 193 | 210 | |
| 211 | it("shows a link the relay holds as publish-only as off and relinks it as managed", async () => { | |
| 212 | testState.relayProviderKind = "manual"; | |
| 213 | await mountController(); | |
| 214 | ||
| 215 | expect(controller?.managedTunnelActive).toBe(false); | |
| 216 | expect(controller?.managedTunnelOutOfSync).toBe(true); | |
| 217 | expect(await reconcile({ managedTunnel: true, publish: false })).toBe(true); | |
| 218 | expect(testState.link).toHaveBeenCalledExactlyOnceWith({ | |
| 219 | target, | |
| 220 | clerkToken: "clerk-token", | |
| 221 | mode: "managed", | |
| 222 | }); | |
| 223 | }); | |
| 224 | ||
| 225 | it("keeps a managed link that the relay also holds as managed", async () => { | |
| 226 | testState.relayProviderKind = "cloudflare_tunnel"; | |
| 227 | await mountController(); | |
| 228 | ||
| 229 | expect(controller?.managedTunnelActive).toBe(true); | |
| 230 | expect(controller?.managedTunnelOutOfSync).toBe(false); | |
| 231 | expect(await reconcile({ managedTunnel: true, publish: false })).toBe(true); | |
| 232 | expect(testState.link).not.toHaveBeenCalled(); | |
| 233 | }); | |
| 234 | ||
| 194 | 235 | it("stops the preference write if management permission is lost after linking", async () => { |
| 195 | 236 | await mountController(); |
| 196 | 237 | testState.link.mockImplementationOnce(async () => { |
apps/web/src/cloud/useCloudLinkController.ts+39-6| ... | ... | @@ -1,6 +1,13 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | |
| 1 | 2 | import { useAuth } from "@clerk/react"; |
| 2 | 3 | import { findErrorTraceId } from "@t3tools/client-runtime/errors"; |
| 3 | import { EnvironmentId, AuthRelayReadScope, AuthRelayWriteScope } from "@t3tools/contracts"; | |
| 4 | import { | |
| 5 | EnvironmentId, | |
| 6 | AuthRelayReadScope, | |
| 7 | AuthRelayWriteScope, | |
| 8 | type EnvironmentCloudLinkStateResult, | |
| 9 | } from "@t3tools/contracts"; | |
| 10 | import type { RelayManagedEndpointProviderKind } from "@t3tools/contracts/relay"; | |
| 4 | 11 | import { |
| 5 | 12 | isAtomCommandInterrupted, |
| 6 | 13 | settlePromise, |
| ... | ... | @@ -27,6 +34,24 @@ export interface CloudLinkDesiredState { |
| 27 | 34 | readonly holdWebhooksWhileOffline?: boolean; |
| 28 | 35 | } |
| 29 | 36 | |
| 37 | /** | |
| 38 | * Whether the link has a working managed tunnel. The local tunnel config can | |
| 39 | * outlive a relay link that drifted to publish-only, and clients then get | |
| 40 | * `endpoint_provider_not_managed`. So when relay discovery has this | |
| 41 | * environment's record, it must also say managed. Older environment servers | |
| 42 | * predate managedTunnelActive; for them a link always implies a tunnel. | |
| 43 | */ | |
| 44 | function isManagedTunnelActive( | |
| 45 | linkState: EnvironmentCloudLinkStateResult, | |
| 46 | relayProviderKind: RelayManagedEndpointProviderKind | undefined, | |
| 47 | ) { | |
| 48 | const localManagedTunnel = linkState.managedTunnelActive ?? linkState.linked; | |
| 49 | return ( | |
| 50 | localManagedTunnel && | |
| 51 | (relayProviderKind === undefined || relayProviderKind === "cloudflare_tunnel") | |
| 52 | ); | |
| 53 | } | |
| 54 | ||
| 30 | 55 | /** |
| 31 | 56 | * Drives the primary environment's T3 Connect link. T3 Connect (managed |
| 32 | 57 | * tunnel) and agent-activity publishing are independent capabilities backed by |
| ... | ... | @@ -52,6 +77,11 @@ export function useCloudLinkController() { |
| 52 | 77 | { reportFailure: false }, |
| 53 | 78 | ); |
| 54 | 79 | const primaryCloudLinkState = usePrimaryCloudLinkState(); |
| 80 | const relayEnvironments = useAtomValue(relayEnvironmentDiscovery.stateValueAtom).environments; | |
| 81 | const primaryEnvironmentId = primaryCloudLinkState.target?.environmentId; | |
| 82 | const relayProviderKind = primaryEnvironmentId | |
| 83 | ? relayEnvironments.get(primaryEnvironmentId)?.environment.endpoint.providerKind | |
| 84 | : undefined; | |
| 55 | 85 | const [operationError, setOperationError] = useState<string | null>(null); |
| 56 | 86 | |
| 57 | 87 | const reportUpdateFailure = (cause: unknown) => { |
| ... | ... | @@ -74,10 +104,12 @@ export function useCloudLinkController() { |
| 74 | 104 | }); |
| 75 | 105 | }; |
| 76 | 106 | |
| 77 | // Older environment servers predate the managedTunnelActive field; for them a | |
| 78 | // link always implies a managed tunnel, so fall back to `linked`. | |
| 79 | const managedTunnelActive = | |
| 80 | primaryCloudLinkState.data?.managedTunnelActive ?? primaryCloudLinkState.data?.linked ?? false; | |
| 107 | const linkData = primaryCloudLinkState.data; | |
| 108 | const managedTunnelActive = linkData ? isManagedTunnelActive(linkData, relayProviderKind) : false; | |
| 109 | // Local state says managed but the relay holds a publish-only link. Turning | |
| 110 | // T3 Connect on relinks as managed, which repairs it. | |
| 111 | const managedTunnelOutOfSync = | |
| 112 | linkData !== null && !managedTunnelActive && isManagedTunnelActive(linkData, undefined); | |
| 81 | 113 | const publishAgentActivity = primaryCloudLinkState.data?.publishAgentActivity ?? false; |
| 82 | 114 | const holdWebhooksWhileOffline = primaryCloudLinkState.data?.holdWebhooksWhileOffline ?? false; |
| 83 | 115 | const linked = primaryCloudLinkState.data?.linked ?? false; |
| ... | ... | @@ -145,7 +177,7 @@ export function useCloudLinkController() { |
| 145 | 177 | reportUpdateFailure(new Error("Sign in to T3 Connect before enabling this.")); |
| 146 | 178 | return false; |
| 147 | 179 | } |
| 148 | const currentManagedTunnel = currentLinkState.managedTunnelActive ?? currentLinkState.linked; | |
| 180 | const currentManagedTunnel = isManagedTunnelActive(currentLinkState, relayProviderKind); | |
| 149 | 181 | if (!currentLinkState.linked || currentManagedTunnel !== desired.managedTunnel) { |
| 150 | 182 | const linkResult = await linkPrimaryEnvironment({ |
| 151 | 183 | target, |
| ... | ... | @@ -194,6 +226,7 @@ export function useCloudLinkController() { |
| 194 | 226 | linkState: primaryCloudLinkState, |
| 195 | 227 | linked, |
| 196 | 228 | managedTunnelActive, |
| 229 | managedTunnelOutOfSync, | |
| 197 | 230 | publishAgentActivity, |
| 198 | 231 | holdWebhooksWhileOffline, |
| 199 | 232 | operationError, |
apps/web/src/components/settings/ConnectionsSettings.tsx+4-1| ... | ... | @@ -1829,6 +1829,7 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b |
| 1829 | 1829 | isSignedIn, |
| 1830 | 1830 | linkState: primaryCloudLinkState, |
| 1831 | 1831 | managedTunnelActive, |
| 1832 | managedTunnelOutOfSync, | |
| 1832 | 1833 | publishAgentActivity, |
| 1833 | 1834 | holdWebhooksWhileOffline, |
| 1834 | 1835 | operationError, |
| ... | ... | @@ -1919,7 +1920,9 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b |
| 1919 | 1920 | description={ |
| 1920 | 1921 | managedTunnelActive |
| 1921 | 1922 | ? "This environment is available to your other devices through T3 Connect." |
| 1922 | : "Make this environment available to your other devices through T3 Connect." | |
| 1923 | : managedTunnelOutOfSync | |
| 1924 | ? "T3 Connect is out of sync. This environment is linked only to publish agent activity, so other devices cannot connect. Turn T3 Connect on to repair it." | |
| 1925 | : "Make this environment available to your other devices through T3 Connect." | |
| 1923 | 1926 | } |
| 1924 | 1927 | status={operationError ?? primaryCloudLinkState.error} |
| 1925 | 1928 | control={ |
packages/client-runtime/src/connection/errors.test.ts+1| ... | ... | @@ -61,6 +61,7 @@ describe("mapManagedRelayError", () => { |
| 61 | 61 | expect(mapConnectRefusal("endpoint_provider_not_managed")).toMatchObject({ |
| 62 | 62 | _tag: "ConnectionBlockedError", |
| 63 | 63 | reason: "permission", |
| 64 | detail: expect.stringContaining("turn T3 Connect on"), | |
| 64 | 65 | }); |
| 65 | 66 | expect(mapConnectRefusal(undefined)).toMatchObject({ |
| 66 | 67 | _tag: "ConnectionBlockedError", |
packages/client-runtime/src/relay/errorPresentation.ts+3| ... | ... | @@ -46,6 +46,9 @@ export function relayProtectedErrorMessage(error: RelayProtectedError): string { |
| 46 | 46 | if (error.reason === "managed_endpoint_allocation_not_ready") { |
| 47 | 47 | return "The environment's T3 Connect tunnel is still being set up."; |
| 48 | 48 | } |
| 49 | if (error.reason === "endpoint_provider_not_managed") { | |
| 50 | return "This environment is linked only to publish agent activity, so it cannot accept connections. On that computer, open Settings > Connections and turn T3 Connect on."; | |
| 51 | } | |
| 49 | 52 | return error.reason |
| 50 | 53 | ? `Relay rejected the environment connection request (${error.reason}).` |
| 51 | 54 | : "Relay rejected the environment connection request."; |