From 8e018ff91f64d6caedc029e498eca446ff0fad20 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 14 Oct 2026 04:50:20 -0700 Subject: [PATCH] fix(server): a deregistered T3 Connect host shows it was removed and offers a relink (#19379) Co-authored-by: Claude Opus 5.5 --- apps/server/src/cli/connect.ts | 65 +++++--- apps/server/src/cloud/CliState.test.ts | 32 ++++ apps/server/src/cloud/CliState.ts | 27 ++++ .../src/cloud/CloudLink.lifecycle.test.ts | 152 +++++++++++++++++- apps/server/src/cloud/CloudLink.ts | 69 +++++++- apps/server/src/cloud/config.ts | 5 + .../src/cloud/managedTunnelStartup.test.ts | 2 +- apps/server/src/cloud/managedTunnelStartup.ts | 4 +- .../src/cloud/useCloudLinkController.test.tsx | 19 +++ apps/web/src/cloud/useCloudLinkController.ts | 15 +- .../cloud/ConnectOnboardingDialog.tsx | 6 +- .../settings/ConnectionsSettings.tsx | 18 ++- .../useAvailableSettingsSearchItems.ts | 3 +- docs/user/remote-access.md | 10 +- .../src/relay/errorPresentation.ts | 2 +- packages/contracts/src/environmentHttp.ts | 4 + 16 files changed, 388 insertions(+), 45 deletions(-) diff --git a/apps/server/src/cli/connect.ts b/apps/server/src/cli/connect.ts index 866cc5ec8927a0ed5e3440057e305dd6a13e383d..9f812e615e0088c8942767f0ca3689b2f4727082 100644 --- a/apps/server/src/cli/connect.ts +++ b/apps/server/src/cli/connect.ts @@ -31,6 +31,7 @@ import * as CliState from "../cloud/CliState.ts"; import * as CliTokenManager from "../cloud/CliTokenManager.ts"; import { filterRelayResponse } from "../cloud/relayResponse.ts"; import { + CLOUD_LINK_REVOKED, CLOUD_LINKED_USER_ID, isAgentActivityPublishingEnabledValue, PUBLISH_AGENT_ACTIVITY_SECRET, @@ -140,6 +141,8 @@ interface CloudCliStatus { readonly desired: boolean; readonly authenticated: boolean; readonly linked: boolean; + // The relay rejected the stored link, usually after a Deregister. + readonly revoked: boolean; readonly cloudUserId: string | null; readonly relayUrl: string | null; readonly publishAgentActivity: boolean; @@ -176,18 +179,22 @@ function formatCloudStatus(status: CloudCliStatus, options?: { readonly json?: b return JSON.stringify(status, null, 2); } - const provisioned = status.linked - ? "provisioned" - : status.desired && status.authenticated - ? "pending server startup" - : "not provisioned"; + const provisioned = status.revoked + ? "removed from T3 Connect" + : status.linked + ? "provisioned" + : status.desired && status.authenticated + ? "pending server startup" + : "not provisioned"; const nextStep = !status.authenticated ? "Run `t3 connect link` to authorize and enable T3 Connect." - : !status.desired - ? "Run `t3 connect link` to enable T3 Connect." - : !status.linked - ? "Start T3 to provision the environment link and launch its managed tunnel." - : undefined; + : status.revoked + ? "Run `t3 connect link` to link this environment again." + : !status.desired + ? "Run `t3 connect link` to enable T3 Connect." + : !status.linked + ? "Start T3 to provision the environment link and launch its managed tunnel." + : undefined; return [ "T3 Connect", @@ -477,6 +484,11 @@ const linkEnvironmentForConnect = Effect.fn("cloud.cli.link_environment")(functi const identity = yield* authorizeCli(options); yield* CliState.setCliDesiredCloudLink(true, publishOnly ? "publish_only" : "managed"); + if (yield* CliState.dropRevokedCloudLink) { + yield* Console.log( + "Removed the link T3 Connect no longer accepts. The next start links this environment again.", + ); + } if (publishOnly) { const secrets = yield* ServerSecretStore.ServerSecretStore; yield* secrets.set(PUBLISH_AGENT_ACTIVITY_SECRET, stringToBytes("true")); @@ -543,22 +555,31 @@ const connectStatusCommand = Command.make("status", { const secrets = yield* ServerSecretStore.ServerSecretStore; const relayClient = yield* RelayClient.RelayClient; const tokens = yield* CliTokenManager.CloudCliTokenManager; - const [desired, authenticated, cloudUserId, relayUrl, publishAgentActivity, executable] = - yield* Effect.all( - [ - CliState.readCliDesiredCloudLink, - tokens.hasCredential, - secrets.get(CLOUD_LINKED_USER_ID), - secrets.get(RELAY_URL_SECRET), - secrets.get(PUBLISH_AGENT_ACTIVITY_SECRET), - relayClient.resolve, - ], - { concurrency: "unbounded" }, - ); + const [ + desired, + authenticated, + cloudUserId, + relayUrl, + publishAgentActivity, + revoked, + executable, + ] = yield* Effect.all( + [ + CliState.readCliDesiredCloudLink, + tokens.hasCredential, + secrets.get(CLOUD_LINKED_USER_ID), + secrets.get(RELAY_URL_SECRET), + secrets.get(PUBLISH_AGENT_ACTIVITY_SECRET), + secrets.get(CLOUD_LINK_REVOKED), + relayClient.resolve, + ], + { concurrency: "unbounded" }, + ); const status: CloudCliStatus = { desired, authenticated, linked: Option.isSome(cloudUserId), + revoked: Option.isSome(cloudUserId) && Option.isSome(revoked), cloudUserId: Option.isSome(cloudUserId) ? bytesToString(cloudUserId.value) : null, relayUrl: Option.isSome(relayUrl) ? bytesToString(relayUrl.value) : null, publishAgentActivity: isAgentActivityPublishingEnabledValue( diff --git a/apps/server/src/cloud/CliState.test.ts b/apps/server/src/cloud/CliState.test.ts index ee897012496dba67170fe251be75e576168fc2c6..319b2660a539950c244bc9559600d57652b0de5a 100644 --- a/apps/server/src/cloud/CliState.test.ts +++ b/apps/server/src/cloud/CliState.test.ts @@ -10,6 +10,7 @@ import * as CliState from "./CliState.ts"; import { CLOUD_ENDPOINT_CONFIRMED_ORIGIN, CLOUD_ENDPOINT_RUNTIME_CONFIG, + CLOUD_LINK_REVOKED, CLOUD_LINKED_USER_ID, CLOUD_MINT_PUBLIC_KEY, PUBLISH_AGENT_ACTIVITY_SECRET, @@ -59,6 +60,37 @@ it.layer(NodeServices.layer)("CliState", (it) => { }).pipe(Effect.provide(layerTest())), ); + it.effect("drops only a revoked link, keeping the sign-in and preferences", () => + Effect.gen(function* () { + const secrets = yield* ServerSecretStore.ServerSecretStore; + const dropped = [ + CLOUD_LINKED_USER_ID, + RELAY_ENVIRONMENT_CREDENTIAL_SECRET, + CLOUD_MINT_PUBLIC_KEY, + CLOUD_ENDPOINT_RUNTIME_CONFIG, + CLOUD_ENDPOINT_CONFIRMED_ORIGIN, + ]; + const kept = [RELAY_URL_SECRET, PUBLISH_AGENT_ACTIVITY_SECRET]; + for (const name of [...dropped, ...kept]) { + yield* secrets.set(name, new TextEncoder().encode(name)); + } + yield* CliState.setCliDesiredCloudLink(true); + + assert.isFalse(yield* CliState.dropRevokedCloudLink); + assert.isTrue(Option.isSome(yield* secrets.get(CLOUD_LINKED_USER_ID))); + + yield* secrets.set(CLOUD_LINK_REVOKED, new TextEncoder().encode("true")); + assert.isTrue(yield* CliState.dropRevokedCloudLink); + for (const name of [...dropped, CLOUD_LINK_REVOKED]) { + assert.isTrue(Option.isNone(yield* secrets.get(name))); + } + for (const name of kept) { + assert.isTrue(Option.isSome(yield* secrets.get(name))); + } + assert.isTrue(yield* CliState.readCliDesiredCloudLink); + }).pipe(Effect.provide(layerTest())), + ); + it.effect("round-trips the desired link mode and defaults legacy links to managed", () => Effect.gen(function* () { const secrets = yield* ServerSecretStore.ServerSecretStore; diff --git a/apps/server/src/cloud/CliState.ts b/apps/server/src/cloud/CliState.ts index dc77609ccc92760f15ea6c18eb78de62d848626a..2ec2af555c24425ba63d8d68953a9ccaaea4bf22 100644 --- a/apps/server/src/cloud/CliState.ts +++ b/apps/server/src/cloud/CliState.ts @@ -5,6 +5,7 @@ import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import { CLOUD_ENDPOINT_CONFIRMED_ORIGIN, CLOUD_ENDPOINT_RUNTIME_CONFIG, + CLOUD_LINK_REVOKED, CLOUD_LINKED_USER_ID, CLOUD_MINT_PUBLIC_KEY, PUBLISH_AGENT_ACTIVITY_SECRET, @@ -57,6 +58,31 @@ export const setCliDesiredCloudLink = Effect.fn("cloud.cli_state.set_desired")(f } }); +/** + * Drops a link the relay rejected, keeping the sign-in and preferences, so the + * next start links this environment again. `t3 connect link` calls it: the + * server never relinks a revoked link on its own. Returns whether it dropped one. + */ +export const dropRevokedCloudLink = Effect.gen(function* () { + const secrets = yield* ServerSecretStore.ServerSecretStore; + if (Option.isNone(yield* secrets.get(CLOUD_LINK_REVOKED))) { + return false; + } + yield* Effect.all( + [ + secrets.remove(CLOUD_LINKED_USER_ID), + secrets.remove(RELAY_ENVIRONMENT_CREDENTIAL_SECRET), + secrets.remove(CLOUD_MINT_PUBLIC_KEY), + secrets.remove(CLOUD_ENDPOINT_RUNTIME_CONFIG), + secrets.remove(CLOUD_ENDPOINT_CONFIRMED_ORIGIN), + ], + { concurrency: "unbounded" }, + ); + // Last, so a failed removal above leaves the mark for the next attempt. + yield* secrets.remove(CLOUD_LINK_REVOKED); + return true; +}); + export const clearPersistedCloudLink = Effect.gen(function* () { const secrets = yield* ServerSecretStore.ServerSecretStore; yield* Effect.all( @@ -70,6 +96,7 @@ export const clearPersistedCloudLink = Effect.gen(function* () { secrets.remove(CLOUD_ENDPOINT_RUNTIME_CONFIG), secrets.remove(CLOUD_ENDPOINT_CONFIRMED_ORIGIN), secrets.remove(PUBLISH_AGENT_ACTIVITY_SECRET), + secrets.remove(CLOUD_LINK_REVOKED), ], { concurrency: "unbounded" }, ); diff --git a/apps/server/src/cloud/CloudLink.lifecycle.test.ts b/apps/server/src/cloud/CloudLink.lifecycle.test.ts index 8ff5a063e6bd9aa46dfc10dcd2e34789ef4dede5..86239f9e73a910222f6c082abf9af690f132f7b4 100644 --- a/apps/server/src/cloud/CloudLink.lifecycle.test.ts +++ b/apps/server/src/cloud/CloudLink.lifecycle.test.ts @@ -34,6 +34,7 @@ import { RelayManagedEndpointRecoveryRegistrationRequest } from "@t3tools/contra import { CLOUD_ENDPOINT_CONFIRMED_ORIGIN, CLOUD_ENDPOINT_RUNTIME_CONFIG, + CLOUD_LINK_REVOKED, CLOUD_LINKED_USER_ID, decodeConfirmedOrigin, decodeRuntimeConfig, @@ -694,6 +695,115 @@ describe("releaseManagedTunnelOnShutdown", () => { ); }); + it.effect("marks a link the relay rejects as revoked without dropping or replacing it", () => { + const { store, values } = makeMemorySecretStore([ + [ + CLOUD_ENDPOINT_RUNTIME_CONFIG, + '{"providerKind":"cloudflare_tunnel","connectorToken":"existing-token","tunnelId":"existing-tunnel"}', + ], + [RELAY_URL_SECRET, "https://relay.example.test"], + [CLOUD_LINKED_USER_ID, "user-123"], + [RELAY_ENVIRONMENT_CREDENTIAL_SECRET, "environment-credential"], + [CLOUD_CLI_DESIRED_LINK_SECRET, "managed"], + ]); + const applyConfigCalls: Array = []; + const requests: Array = []; + + return Effect.gen(function* () { + const link = yield* CloudLink.CloudLink; + expect(yield* link.registerManagedTunnelRecovery("http://127.0.0.1:3773")).toEqual({ + status: "revoked", + }); + expect((yield* link.linkState()).linkRevoked).toBe(true); + // One registration request, no relink, and nothing started or removed. + expect(requests).toHaveLength(1); + expect(applyConfigCalls).toEqual([]); + expect(values.has(RELAY_ENVIRONMENT_CREDENTIAL_SECRET)).toBe(true); + expect(values.has(CLOUD_ENDPOINT_RUNTIME_CONFIG)).toBe(true); + }).pipe( + provideReleaseHarness({ + store, + applyConfigCalls, + requests, + respond: () => + Response.json( + { + _tag: "RelayAuthInvalidError", + code: "auth_invalid", + reason: "not_authorized", + traceId: "trace-revoked", + }, + { status: 401 }, + ), + }), + ); + }); + + it.effect("does not mark a link that was replaced while its rejection was in flight", () => { + const { store, values } = makeMemorySecretStore([ + [ + CLOUD_ENDPOINT_RUNTIME_CONFIG, + '{"providerKind":"cloudflare_tunnel","connectorToken":"existing-token","tunnelId":"existing-tunnel"}', + ], + [RELAY_URL_SECRET, "https://relay.example.test"], + [CLOUD_LINKED_USER_ID, "user-123"], + [RELAY_ENVIRONMENT_CREDENTIAL_SECRET, "old-credential"], + ]); + + return Effect.gen(function* () { + const link = yield* CloudLink.CloudLink; + expect(yield* link.registerManagedTunnelRecovery("http://127.0.0.1:3773")).toEqual({ + status: "superseded", + }); + expect(values.has(CLOUD_LINK_REVOKED)).toBe(false); + }).pipe( + provideReleaseHarness({ + store, + applyConfigCalls: [], + requests: [], + respond: () => { + values.set( + RELAY_ENVIRONMENT_CREDENTIAL_SECRET, + new TextEncoder().encode("replacement-credential"), + ); + return Response.json( + { + _tag: "RelayAuthInvalidError", + code: "auth_invalid", + reason: "not_authorized", + traceId: "trace-stale", + }, + { status: 401 }, + ); + }, + }), + ); + }); + + it.effect("clears the revoked mark when the environment is linked again", () => { + const { store } = makeMemorySecretStore([ + [RELAY_URL_SECRET, "https://relay.example.test"], + [CLOUD_LINKED_USER_ID, "user-123"], + [RELAY_ENVIRONMENT_CREDENTIAL_SECRET, "revoked-credential"], + [CLOUD_LINK_REVOKED, "true"], + ]); + const cloudMintPublicKey = + "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAqZ2u2LjMoAyG2QTHXmITJ9w6RzZtVUAv394XCcPV0zE=\n-----END PUBLIC KEY-----"; + + return Effect.gen(function* () { + const link = yield* CloudLink.CloudLink; + expect((yield* link.linkState()).linkRevoked).toBe(true); + yield* link.applyRelayConfig({ + relayUrl: "https://relay.example.test", + cloudUserId: "user-123", + environmentCredential: "fresh-credential", + cloudMintPublicKey, + endpointRuntime: null, + }); + expect(yield* link.linkState()).toMatchObject({ linked: true, linkRevoked: false }); + }).pipe(provideReleaseHarness({ store, applyConfigCalls: [], requests: [] })); + }); + it.effect("reconciles a changed port after a relay outage outlasts the startup fallback", () => { const config = { providerKind: "cloudflare_tunnel" as const, @@ -944,7 +1054,7 @@ describe("releaseManagedTunnelOnShutdown", () => { }).pipe(provideReleaseHarness({ store, applyConfigCalls, requests })); }); - it.effect("recovers a web-linked tunnel with its environment credential", () => { + it.effect("recovers a web-linked tunnel and clears its revoked mark", () => { const oldConfig = '{"providerKind":"cloudflare_tunnel","connectorToken":"old-token","tunnelId":"old-tunnel"}'; const nextConfig = { @@ -957,6 +1067,7 @@ describe("releaseManagedTunnelOnShutdown", () => { [RELAY_URL_SECRET, "https://relay.example.test"], [CLOUD_LINKED_USER_ID, "user-123"], [RELAY_ENVIRONMENT_CREDENTIAL_SECRET, "environment-credential"], + [CLOUD_LINK_REVOKED, "true"], ]); const applyConfigCalls: Array = []; const requests: Array = []; @@ -974,6 +1085,7 @@ describe("releaseManagedTunnelOnShutdown", () => { decodeRuntimeConfig(new TextDecoder().decode(values.get(CLOUD_ENDPOINT_RUNTIME_CONFIG))), ), ).toEqual(nextConfig); + expect(values.has(CLOUD_LINK_REVOKED)).toBe(false); }).pipe( provideReleaseHarness({ store, @@ -992,6 +1104,44 @@ describe("releaseManagedTunnelOnShutdown", () => { ); }); + it.effect("marks the link revoked when the relay rejects a running host's recovery", () => { + const { store, values } = makeMemorySecretStore([ + [ + CLOUD_ENDPOINT_RUNTIME_CONFIG, + '{"providerKind":"cloudflare_tunnel","connectorToken":"old-token","tunnelId":"old-tunnel"}', + ], + [RELAY_URL_SECRET, "https://relay.example.test"], + [CLOUD_LINKED_USER_ID, "user-123"], + [RELAY_ENVIRONMENT_CREDENTIAL_SECRET, "environment-credential"], + ]); + const applyConfigCalls: Array = []; + + return Effect.gen(function* () { + const link = yield* CloudLink.CloudLink; + const error = yield* Effect.flip(link.recoverManagedTunnel("http://127.0.0.1:3773")); + expect(error).toMatchObject({ _tag: "RelayRequestError", rejection: "unauthorized" }); + expect((yield* link.linkState()).linkRevoked).toBe(true); + expect(applyConfigCalls).toEqual([]); + expect(values.has(RELAY_ENVIRONMENT_CREDENTIAL_SECRET)).toBe(true); + }).pipe( + provideReleaseHarness({ + store, + applyConfigCalls, + requests: [], + respond: () => + Response.json( + { + _tag: "RelayAuthInvalidError", + code: "auth_invalid", + reason: "not_authorized", + traceId: "trace-recovery-revoked", + }, + { status: 401 }, + ), + }), + ); + }); + it.effect("allows managed tunnel provisioning to take longer than ten seconds", () => Effect.gen(function* () { const oldConfig = diff --git a/apps/server/src/cloud/CloudLink.ts b/apps/server/src/cloud/CloudLink.ts index 26846edd99d90cf7ddd63f8654812d37866fce4c..f11b94115325b7f46322d571020c97be8bfe5f09 100644 --- a/apps/server/src/cloud/CloudLink.ts +++ b/apps/server/src/cloud/CloudLink.ts @@ -85,6 +85,7 @@ import * as CliTokenManager from "./CliTokenManager.ts"; import { CLOUD_ENDPOINT_CONFIRMED_ORIGIN, CLOUD_ENDPOINT_RUNTIME_CONFIG, + CLOUD_LINK_REVOKED, CLOUD_LINKED_USER_ID, CLOUD_MINT_PUBLIC_KEY, decodeConfirmedOrigin, @@ -431,8 +432,10 @@ type CloudLinkBackgroundError = | Schema.SchemaError | PlatformError.PlatformError; +// "revoked" means the relay rejected this environment's credential, as after +// the environment is deregistered from T3 Connect. type ManagedTunnelRegistration = - | { readonly status: "not_linked" | "superseded" } + | { readonly status: "not_linked" | "superseded" | "revoked" } | { readonly status: "recovery_required"; readonly config: RelayManagedEndpointRuntimeConfig } | { readonly status: "ready"; @@ -838,6 +841,7 @@ const make = Effect.gen(function* () { RELAY_ENVIRONMENT_CREDENTIAL_SECRET, stringToBytes(payload.environmentCredential), ); + yield* secrets.remove(CLOUD_LINK_REVOKED); yield* secrets.set(CLOUD_MINT_PUBLIC_KEY, stringToBytes(payload.cloudMintPublicKey)); yield* awarenessRelay.requestCatchUp(); if (endpointRuntimeJson !== null) { @@ -1017,6 +1021,30 @@ const make = Effect.gen(function* () { ); }); + /** + * Marks the link revoked after the relay answered 401 to `credential`, usually + * because the environment was deregistered. Retrying cannot fix that, and + * relinking is the user's call, so only mark it. Nothing stored is removed: + * the relay also answers 401 for a skewed clock, and the next accepted + * registration clears the mark. Returns false, without marking, when a relink + * replaced the credential while the rejected request was in flight. + */ + const markLinkRevoked = (credential: string, cause: RelayRequestError) => + endpointRuntime.withLinkStateLock( + Effect.gen(function* () { + const current = yield* secrets.get(RELAY_ENVIRONMENT_CREDENTIAL_SECRET); + if (Option.isNone(current) || bytesToString(current.value) !== credential) { + return false; + } + yield* Effect.logWarning( + "T3 Connect no longer accepts this environment's link. Link it again to restore remote access.", + { cause }, + ); + yield* secrets.set(CLOUD_LINK_REVOKED, stringToBytes("true")); + return true; + }), + ); + const registerManagedTunnelRecovery = Effect.fn( "environment.cloud.registerManagedCloudTunnelRecovery", )(function* (localOrigin: string, options?: { readonly retryRuntimeFailures?: boolean }) { @@ -1059,9 +1087,10 @@ const make = Effect.gen(function* () { tunnelId: config.tunnelId, origin, }); + const credential = bytesToString(environmentCredential.value); const registered = yield* relayClientRequest({ url: `${relayUrlValue}/v1/environments/${encodeURIComponent(environmentId)}/tunnel/recovery`, - token: bytesToString(environmentCredential.value), + token: credential, payload: { cloudUserId: cloudUserIdValue, tunnelId: config.tunnelId, @@ -1069,7 +1098,20 @@ const make = Effect.gen(function* () { proof, }, schema: RelayManagedEndpointRecoveryRegistrationResponse, - }); + }).pipe( + Effect.catchTags({ + RelayRequestError: (error) => + error.rejection === "unauthorized" + ? markLinkRevoked(credential, error).pipe( + Effect.map((marked) => (marked ? ("revoked" as const) : ("superseded" as const))), + ) + : Effect.fail(error), + }), + ); + if (registered === "revoked" || registered === "superseded") { + return { status: registered }; + } + yield* secrets.remove(CLOUD_LINK_REVOKED); if (registered.status === "recovery_required") { return { status: registered.status, config }; } @@ -1134,9 +1176,10 @@ const make = Effect.gen(function* () { relayUrl: relayUrlValue, origin, }); + const credential = bytesToString(environmentCredential.value); const recovered = yield* relayClientRequest({ url: `${relayUrlValue}/v1/environments/${encodeURIComponent(environmentId)}/tunnel`, - token: bytesToString(environmentCredential.value), + token: credential, payload: { cloudUserId: cloudUserIdValue, origin, @@ -1144,7 +1187,13 @@ const make = Effect.gen(function* () { }, schema: RelayManagedEndpointRecoveryResponse, timeout: MANAGED_ENDPOINT_PROVISION_REQUEST_TIMEOUT, - }); + }).pipe( + // A running host learns of a Deregister here, when its dead tunnel asks + // for a replacement. The rejection still fails the recovery. + Effect.tapError((error) => + error.rejection === "unauthorized" ? markLinkRevoked(credential, error) : Effect.void, + ), + ); if (recovered.endpointRuntime.providerKind !== "cloudflare_tunnel") { return yield* new CloudLinkInternalError({ operation: "unsupported-recovered-tunnel" }); } @@ -1165,6 +1214,8 @@ const make = Effect.gen(function* () { } yield* secrets.set(CLOUD_ENDPOINT_RUNTIME_CONFIG, stringToBytes(encoded)); yield* secrets.remove(CLOUD_ENDPOINT_CONFIRMED_ORIGIN); + // The relay accepted this link, so an earlier rejection no longer holds. + yield* secrets.remove(CLOUD_LINK_REVOKED); return true; }), ); @@ -1315,6 +1366,7 @@ const make = Effect.gen(function* () { endpointRuntimeConfig, publishAgentActivity, holdWebhooks, + revoked, ] = yield* Effect.all( [ secrets.get(CLOUD_LINKED_USER_ID), @@ -1323,11 +1375,13 @@ const make = Effect.gen(function* () { secrets.get(CLOUD_ENDPOINT_RUNTIME_CONFIG), secrets.get(PUBLISH_AGENT_ACTIVITY_SECRET), secrets.get(HOLD_WEBHOOKS_WHILE_OFFLINE_SECRET), + secrets.get(CLOUD_LINK_REVOKED), ], - { concurrency: 6 }, + { concurrency: 7 }, ); return { linked: Option.isSome(cloudUserId), + linkRevoked: Option.isSome(cloudUserId) && Option.isSome(revoked), cloudUserId: Option.isSome(cloudUserId) ? bytesToString(cloudUserId.value) : null, relayUrl: Option.isSome(relayUrl) ? bytesToString(relayUrl.value) : null, relayIssuer: Option.isSome(relayIssuer) ? bytesToString(relayIssuer.value) : null, @@ -1365,8 +1419,9 @@ const make = Effect.gen(function* () { secrets.remove(CLOUD_ENDPOINT_CONFIRMED_ORIGIN), secrets.remove(PUBLISH_AGENT_ACTIVITY_SECRET), secrets.remove(HOLD_WEBHOOKS_WHILE_OFFLINE_SECRET), + secrets.remove(CLOUD_LINK_REVOKED), ], - { concurrency: 9 }, + { concurrency: 10 }, ); yield* setCliDesiredCloudLink(false).pipe(withSecrets); return { ok: true, endpointRuntimeStatus } satisfies EnvironmentCloudRelayConfigResult; diff --git a/apps/server/src/cloud/config.ts b/apps/server/src/cloud/config.ts index 910f0820282f2ef6ebb02ec9893a9dfef2deae19..1b57dea438cef0d02aa4fe6695f5dc8103d01fd0 100644 --- a/apps/server/src/cloud/config.ts +++ b/apps/server/src/cloud/config.ts @@ -17,6 +17,11 @@ export const RELAY_ISSUER_SECRET = "cloud-relay-issuer"; export const RELAY_ENVIRONMENT_CREDENTIAL_SECRET = "cloud-relay-environment-credential"; export const PUBLISH_AGENT_ACTIVITY_SECRET = "cloud-publish-agent-activity"; export const HOLD_WEBHOOKS_WHILE_OFFLINE_SECRET = "cloud-hold-webhooks-while-offline"; +// Set when the relay rejects this environment's credential (401), as after the +// environment is deregistered from T3 Connect. The link stays stored; the mark +// tells `t3 connect status` and clients to offer a relink. Linking, unlinking, +// or a later successful tunnel registration clears it. +export const CLOUD_LINK_REVOKED = "cloud-link-revoked"; export const encodeEndpointRuntimeConfigJson = Schema.encodeEffect( Schema.fromJsonString(RelayManagedEndpointRuntimeConfig), diff --git a/apps/server/src/cloud/managedTunnelStartup.test.ts b/apps/server/src/cloud/managedTunnelStartup.test.ts index 1d75f606cd2f4bab40609c101c34171d426deb2a..14293271335041e663a4017db318d8c01bede268 100644 --- a/apps/server/src/cloud/managedTunnelStartup.test.ts +++ b/apps/server/src/cloud/managedTunnelStartup.test.ts @@ -34,7 +34,7 @@ describe("managedTunnelStartupAction", () => { ).toEqual({ action: "reconcile_link" }); }); - it.each(["ready", "unavailable"] as const)( + it.each(["ready", "unavailable", "revoked"] as const)( "does not provision after a %s registration result", (status) => { expect( diff --git a/apps/server/src/cloud/managedTunnelStartup.ts b/apps/server/src/cloud/managedTunnelStartup.ts index 511d5545a0ac94d3434baa76babae08f5f2df76d..94fb0ba48d91439cddaaccf348893184b57d4423 100644 --- a/apps/server/src/cloud/managedTunnelStartup.ts +++ b/apps/server/src/cloud/managedTunnelStartup.ts @@ -3,8 +3,10 @@ import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Schedule from "effect/Schedule"; +// "revoked" leads to no action: relinking a deregistered environment is the +// user's call, so startup neither retries nor relinks it. export type ManagedTunnelRegistrationResult = - | { readonly status: "not_linked" | "ready" | "unavailable" | "superseded" } + | { readonly status: "not_linked" | "ready" | "unavailable" | "superseded" | "revoked" } | { readonly status: "recovery_required"; readonly config: RelayManagedEndpointRuntimeConfig; diff --git a/apps/web/src/cloud/useCloudLinkController.test.tsx b/apps/web/src/cloud/useCloudLinkController.test.tsx index b9d7b3a4c7b42fb0b467647328fe7def7654a446..1bd7f0b22cd931514481bfe9b10b4a4d2ec16833 100644 --- a/apps/web/src/cloud/useCloudLinkController.test.tsx +++ b/apps/web/src/cloud/useCloudLinkController.test.tsx @@ -222,6 +222,25 @@ describe("useCloudLinkController", () => { }); }); + it("shows a revoked link as off and links it again when turned on", async () => { + testState.snapshot = { ...linkedState, linkRevoked: true }; + testState.renderedSnapshot = testState.snapshot; + await mountController(); + expect(controller).toMatchObject({ + linked: false, + linkRevoked: true, + managedTunnelActive: false, + managedTunnelOutOfSync: false, + }); + + expect(await reconcile({ managedTunnel: true, publish: false })).toBe(true); + expect(testState.link).toHaveBeenCalledExactlyOnceWith({ + target, + clerkToken: "clerk-token", + mode: "managed", + }); + }); + it("keeps a managed link that the relay also holds as managed", async () => { testState.relayProviderKind = "cloudflare_tunnel"; await mountController(); diff --git a/apps/web/src/cloud/useCloudLinkController.ts b/apps/web/src/cloud/useCloudLinkController.ts index f9f9e342b7a836aab89e91bb692d8e40686d79e8..cf28f68d58a412c6d6dcb67074943110c7eedc8b 100644 --- a/apps/web/src/cloud/useCloudLinkController.ts +++ b/apps/web/src/cloud/useCloudLinkController.ts @@ -39,7 +39,8 @@ export interface CloudLinkDesiredState { * outlive a relay link that drifted to publish-only, and clients then get * `endpoint_provider_not_managed`. So when relay discovery has this * environment's record, it must also say managed. Older environment servers - * predate managedTunnelActive; for them a link always implies a tunnel. + * predate managedTunnelActive; for them a link always implies a tunnel. A link + * the relay rejected (usually after a Deregister) gives no access at all. */ function isManagedTunnelActive( linkState: EnvironmentCloudLinkStateResult, @@ -47,6 +48,7 @@ function isManagedTunnelActive( ) { const localManagedTunnel = linkState.managedTunnelActive ?? linkState.linked; return ( + linkState.linkRevoked !== true && localManagedTunnel && (relayProviderKind === undefined || relayProviderKind === "cloudflare_tunnel") ); @@ -105,6 +107,8 @@ export function useCloudLinkController() { }; const linkData = primaryCloudLinkState.data; + // A revoked link reads as off; turning it on links again. + const linkRevoked = linkData?.linkRevoked ?? false; const managedTunnelActive = linkData ? isManagedTunnelActive(linkData, relayProviderKind) : false; // Local state says managed but the relay holds a publish-only link. Turning // T3 Connect on relinks as managed, which repairs it. @@ -112,7 +116,7 @@ export function useCloudLinkController() { linkData !== null && !managedTunnelActive && isManagedTunnelActive(linkData, undefined); const publishAgentActivity = primaryCloudLinkState.data?.publishAgentActivity ?? false; const holdWebhooksWhileOffline = primaryCloudLinkState.data?.holdWebhooksWhileOffline ?? false; - const linked = primaryCloudLinkState.data?.linked ?? false; + const linked = !linkRevoked && (primaryCloudLinkState.data?.linked ?? false); const reconcileCloudState = async (desired: CloudLinkDesiredState): Promise => { setOperationError(null); @@ -178,7 +182,11 @@ export function useCloudLinkController() { return false; } const currentManagedTunnel = isManagedTunnelActive(currentLinkState, relayProviderKind); - if (!currentLinkState.linked || currentManagedTunnel !== desired.managedTunnel) { + if ( + !currentLinkState.linked || + currentLinkState.linkRevoked === true || + currentManagedTunnel !== desired.managedTunnel + ) { const linkResult = await linkPrimaryEnvironment({ target, clerkToken, @@ -225,6 +233,7 @@ export function useCloudLinkController() { isSignedIn, linkState: primaryCloudLinkState, linked, + linkRevoked, managedTunnelActive, managedTunnelOutOfSync, publishAgentActivity, diff --git a/apps/web/src/components/cloud/ConnectOnboardingDialog.tsx b/apps/web/src/components/cloud/ConnectOnboardingDialog.tsx index afe71469799186d7a7985f0794c03e22624728dc..f793a127f43bc6197c65c00c2f626bf9429bbb8f 100644 --- a/apps/web/src/components/cloud/ConnectOnboardingDialog.tsx +++ b/apps/web/src/components/cloud/ConnectOnboardingDialog.tsx @@ -173,7 +173,11 @@ function ConfiguredConnectOnboardingDialog() { return; } prefilledFromLinkStateRef.current = true; - if (linkStateData.linked && linkStateData.cloudUserId === openForAccount) { + if ( + linkStateData.linked && + linkStateData.linkRevoked !== true && + linkStateData.cloudUserId === openForAccount + ) { setExposeEnvironment(linkStateData.managedTunnelActive ?? linkStateData.linked); setPublishAgentActivity(linkStateData.publishAgentActivity); } diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index e35e6aeeb324b1b9082c08a5c38e627ad339594f..1ff650a9b77dd84d427ba2358b3c77b0433cf341 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -1828,6 +1828,7 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b const { isSignedIn, linkState: primaryCloudLinkState, + linkRevoked, managedTunnelActive, managedTunnelOutOfSync, publishAgentActivity, @@ -1918,11 +1919,13 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b } /> + ) : linkRevoked ? ( + ) : null} diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index 231823f13dcc9d954552e03ccd0a7ce1b194c832..7ca2ae41aa853572488e29746e680ecb2c999eb9 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -290,8 +290,14 @@ the workspace. These scopes control direct file access from the client. To remove an environment from T3 Connect, open your account menu's **T3 Connect** page, or **Settings → T3 Connect** on mobile, and choose **Deregister**. This revokes its cloud access and frees its host space even when the environment is -offline or has been wiped. Removing an environment from a device's connection -settings only forgets it on that device; it stays registered to your account. +offline or has been wiped. When T3 Connect rejects a host's link, the host +shows that it was removed from T3 Connect. To link it again, turn on +**T3 Connect** in **Settings → Connections** on a desktop host, or run +`t3 connect link` on a command-line host and then restart T3 Code there. A +command-line host that released its tunnel when it last stopped may link itself +again on its next start; run `t3 connect unlink` on that host to keep it off. +Removing an environment from a device's connection settings only forgets it on +that device; it stays registered to your account. When idle tunnel cleanup is enabled, T3 Connect removes a linked environment's tunnel after it stays offline for several minutes. The environment stays linked diff --git a/packages/client-runtime/src/relay/errorPresentation.ts b/packages/client-runtime/src/relay/errorPresentation.ts index deed043538417e103b391c4b6128a7c61ed77dd1..ed8f16ac48e38b0ec5bc419562da6fe9bf075673 100644 --- a/packages/client-runtime/src/relay/errorPresentation.ts +++ b/packages/client-runtime/src/relay/errorPresentation.ts @@ -41,7 +41,7 @@ export function relayProtectedErrorMessage(error: RelayProtectedError): string { // "Not authorized" covers non-auth causes too; surface the reason so a // missing link does not read as a credential problem. if (error.reason === "environment_link_not_found") { - return "Relay has no active link for this environment. The environment server may not have re-established its link yet."; + return "Relay has no active link for this environment. If it was deregistered from T3 Connect, link it again on that machine."; } if (error.reason === "managed_endpoint_allocation_not_ready") { return "The environment's T3 Connect tunnel is still being set up."; diff --git a/packages/contracts/src/environmentHttp.ts b/packages/contracts/src/environmentHttp.ts index e60db4527a91ea95e0f0369f27f265ebb21822e0..fe7a9e4f9461bfa85cef30340f08e4b3f73c415b 100644 --- a/packages/contracts/src/environmentHttp.ts +++ b/packages/contracts/src/environmentHttp.ts @@ -421,6 +421,10 @@ export const EnvironmentCloudLinkStateResult = Schema.Struct({ // Opt-in: T3 Connect holds webhook requests while this environment is // offline. Optional so newer clients tolerate older environment servers. holdWebhooksWhileOffline: Schema.optional(Schema.Boolean), + // The relay rejected this link's credential, usually because the environment + // was deregistered from T3 Connect. The link stays stored until it is linked + // again. Optional so newer clients tolerate older environment servers. + linkRevoked: Schema.optional(Schema.Boolean), }); export type EnvironmentCloudLinkStateResult = typeof EnvironmentCloudLinkStateResult.Type; -- 2.54.0