authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-10-14 04:50:20-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-14 04:50:20-07:00
log8e018ff91f64d6caedc029e498eca446ff0fad20
treeec536a425a92e0f0bd6d0464962f8fa5b25198c2
parentb037b9a535ff30f3f390da682dabeffe4c439044
signature Signed by PGP key B5690EEEBB952194

fix(server): a deregistered T3 Connect host shows it was removed and offers a relink (#19379)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

16 files changed, 388 insertions(+), 45 deletions(-)

apps/server/src/cli/connect.ts+43-22
...@@ -31,6 +31,7 @@ import * as CliState from "../cloud/CliState.ts";...@@ -31,6 +31,7 @@ import * as CliState from "../cloud/CliState.ts";
31import * as CliTokenManager from "../cloud/CliTokenManager.ts";31import * as CliTokenManager from "../cloud/CliTokenManager.ts";
32import { filterRelayResponse } from "../cloud/relayResponse.ts";32import { filterRelayResponse } from "../cloud/relayResponse.ts";
33import {33import {
34 CLOUD_LINK_REVOKED,
34 CLOUD_LINKED_USER_ID,35 CLOUD_LINKED_USER_ID,
35 isAgentActivityPublishingEnabledValue,36 isAgentActivityPublishingEnabledValue,
36 PUBLISH_AGENT_ACTIVITY_SECRET,37 PUBLISH_AGENT_ACTIVITY_SECRET,
...@@ -140,6 +141,8 @@ interface CloudCliStatus {...@@ -140,6 +141,8 @@ interface CloudCliStatus {
140 readonly desired: boolean;141 readonly desired: boolean;
141 readonly authenticated: boolean;142 readonly authenticated: boolean;
142 readonly linked: boolean;143 readonly linked: boolean;
144 // The relay rejected the stored link, usually after a Deregister.
145 readonly revoked: boolean;
143 readonly cloudUserId: string | null;146 readonly cloudUserId: string | null;
144 readonly relayUrl: string | null;147 readonly relayUrl: string | null;
145 readonly publishAgentActivity: boolean;148 readonly publishAgentActivity: boolean;
...@@ -176,18 +179,22 @@ function formatCloudStatus(status: CloudCliStatus, options?: { readonly json?: b...@@ -176,18 +179,22 @@ function formatCloudStatus(status: CloudCliStatus, options?: { readonly json?: b
176 return JSON.stringify(status, null, 2);179 return JSON.stringify(status, null, 2);
177 }180 }
178181
179 const provisioned = status.linked182 const provisioned = status.revoked
180 ? "provisioned"183 ? "removed from T3 Connect"
181 : status.desired && status.authenticated184 : status.linked
182 ? "pending server startup"185 ? "provisioned"
183 : "not provisioned";186 : status.desired && status.authenticated
187 ? "pending server startup"
188 : "not provisioned";
184 const nextStep = !status.authenticated189 const nextStep = !status.authenticated
185 ? "Run `t3 connect link` to authorize and enable T3 Connect."190 ? "Run `t3 connect link` to authorize and enable T3 Connect."
186 : !status.desired191 : status.revoked
187 ? "Run `t3 connect link` to enable T3 Connect."192 ? "Run `t3 connect link` to link this environment again."
188 : !status.linked193 : !status.desired
189 ? "Start T3 to provision the environment link and launch its managed tunnel."194 ? "Run `t3 connect link` to enable T3 Connect."
190 : undefined;195 : !status.linked
196 ? "Start T3 to provision the environment link and launch its managed tunnel."
197 : undefined;
191198
192 return [199 return [
193 "T3 Connect",200 "T3 Connect",
...@@ -477,6 +484,11 @@ const linkEnvironmentForConnect = Effect.fn("cloud.cli.link_environment")(functi...@@ -477,6 +484,11 @@ const linkEnvironmentForConnect = Effect.fn("cloud.cli.link_environment")(functi
477484
478 const identity = yield* authorizeCli(options);485 const identity = yield* authorizeCli(options);
479 yield* CliState.setCliDesiredCloudLink(true, publishOnly ? "publish_only" : "managed");486 yield* CliState.setCliDesiredCloudLink(true, publishOnly ? "publish_only" : "managed");
487 if (yield* CliState.dropRevokedCloudLink) {
488 yield* Console.log(
489 "Removed the link T3 Connect no longer accepts. The next start links this environment again.",
490 );
491 }
480 if (publishOnly) {492 if (publishOnly) {
481 const secrets = yield* ServerSecretStore.ServerSecretStore;493 const secrets = yield* ServerSecretStore.ServerSecretStore;
482 yield* secrets.set(PUBLISH_AGENT_ACTIVITY_SECRET, stringToBytes("true"));494 yield* secrets.set(PUBLISH_AGENT_ACTIVITY_SECRET, stringToBytes("true"));
...@@ -543,22 +555,31 @@ const connectStatusCommand = Command.make("status", {...@@ -543,22 +555,31 @@ const connectStatusCommand = Command.make("status", {
543 const secrets = yield* ServerSecretStore.ServerSecretStore;555 const secrets = yield* ServerSecretStore.ServerSecretStore;
544 const relayClient = yield* RelayClient.RelayClient;556 const relayClient = yield* RelayClient.RelayClient;
545 const tokens = yield* CliTokenManager.CloudCliTokenManager;557 const tokens = yield* CliTokenManager.CloudCliTokenManager;
546 const [desired, authenticated, cloudUserId, relayUrl, publishAgentActivity, executable] =558 const [
547 yield* Effect.all(559 desired,
548 [560 authenticated,
549 CliState.readCliDesiredCloudLink,561 cloudUserId,
550 tokens.hasCredential,562 relayUrl,
551 secrets.get(CLOUD_LINKED_USER_ID),563 publishAgentActivity,
552 secrets.get(RELAY_URL_SECRET),564 revoked,
553 secrets.get(PUBLISH_AGENT_ACTIVITY_SECRET),565 executable,
554 relayClient.resolve,566 ] = yield* Effect.all(
555 ],567 [
556 { concurrency: "unbounded" },568 CliState.readCliDesiredCloudLink,
557 );569 tokens.hasCredential,
570 secrets.get(CLOUD_LINKED_USER_ID),
571 secrets.get(RELAY_URL_SECRET),
572 secrets.get(PUBLISH_AGENT_ACTIVITY_SECRET),
573 secrets.get(CLOUD_LINK_REVOKED),
574 relayClient.resolve,
575 ],
576 { concurrency: "unbounded" },
577 );
558 const status: CloudCliStatus = {578 const status: CloudCliStatus = {
559 desired,579 desired,
560 authenticated,580 authenticated,
561 linked: Option.isSome(cloudUserId),581 linked: Option.isSome(cloudUserId),
582 revoked: Option.isSome(cloudUserId) && Option.isSome(revoked),
562 cloudUserId: Option.isSome(cloudUserId) ? bytesToString(cloudUserId.value) : null,583 cloudUserId: Option.isSome(cloudUserId) ? bytesToString(cloudUserId.value) : null,
563 relayUrl: Option.isSome(relayUrl) ? bytesToString(relayUrl.value) : null,584 relayUrl: Option.isSome(relayUrl) ? bytesToString(relayUrl.value) : null,
564 publishAgentActivity: isAgentActivityPublishingEnabledValue(585 publishAgentActivity: isAgentActivityPublishingEnabledValue(
apps/server/src/cloud/CliState.test.ts+32
...@@ -10,6 +10,7 @@ import * as CliState from "./CliState.ts";...@@ -10,6 +10,7 @@ import * as CliState from "./CliState.ts";
10import {10import {
11 CLOUD_ENDPOINT_CONFIRMED_ORIGIN,11 CLOUD_ENDPOINT_CONFIRMED_ORIGIN,
12 CLOUD_ENDPOINT_RUNTIME_CONFIG,12 CLOUD_ENDPOINT_RUNTIME_CONFIG,
13 CLOUD_LINK_REVOKED,
13 CLOUD_LINKED_USER_ID,14 CLOUD_LINKED_USER_ID,
14 CLOUD_MINT_PUBLIC_KEY,15 CLOUD_MINT_PUBLIC_KEY,
15 PUBLISH_AGENT_ACTIVITY_SECRET,16 PUBLISH_AGENT_ACTIVITY_SECRET,
...@@ -59,6 +60,37 @@ it.layer(NodeServices.layer)("CliState", (it) => {...@@ -59,6 +60,37 @@ it.layer(NodeServices.layer)("CliState", (it) => {
59 }).pipe(Effect.provide(layerTest())),60 }).pipe(Effect.provide(layerTest())),
60 );61 );
6162
63 it.effect("drops only a revoked link, keeping the sign-in and preferences", () =>
64 Effect.gen(function* () {
65 const secrets = yield* ServerSecretStore.ServerSecretStore;
66 const dropped = [
67 CLOUD_LINKED_USER_ID,
68 RELAY_ENVIRONMENT_CREDENTIAL_SECRET,
69 CLOUD_MINT_PUBLIC_KEY,
70 CLOUD_ENDPOINT_RUNTIME_CONFIG,
71 CLOUD_ENDPOINT_CONFIRMED_ORIGIN,
72 ];
73 const kept = [RELAY_URL_SECRET, PUBLISH_AGENT_ACTIVITY_SECRET];
74 for (const name of [...dropped, ...kept]) {
75 yield* secrets.set(name, new TextEncoder().encode(name));
76 }
77 yield* CliState.setCliDesiredCloudLink(true);
78
79 assert.isFalse(yield* CliState.dropRevokedCloudLink);
80 assert.isTrue(Option.isSome(yield* secrets.get(CLOUD_LINKED_USER_ID)));
81
82 yield* secrets.set(CLOUD_LINK_REVOKED, new TextEncoder().encode("true"));
83 assert.isTrue(yield* CliState.dropRevokedCloudLink);
84 for (const name of [...dropped, CLOUD_LINK_REVOKED]) {
85 assert.isTrue(Option.isNone(yield* secrets.get(name)));
86 }
87 for (const name of kept) {
88 assert.isTrue(Option.isSome(yield* secrets.get(name)));
89 }
90 assert.isTrue(yield* CliState.readCliDesiredCloudLink);
91 }).pipe(Effect.provide(layerTest())),
92 );
93
62 it.effect("round-trips the desired link mode and defaults legacy links to managed", () =>94 it.effect("round-trips the desired link mode and defaults legacy links to managed", () =>
63 Effect.gen(function* () {95 Effect.gen(function* () {
64 const secrets = yield* ServerSecretStore.ServerSecretStore;96 const secrets = yield* ServerSecretStore.ServerSecretStore;
apps/server/src/cloud/CliState.ts+27
...@@ -5,6 +5,7 @@ import * as ServerSecretStore from "../auth/ServerSecretStore.ts";...@@ -5,6 +5,7 @@ import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
5import {5import {
6 CLOUD_ENDPOINT_CONFIRMED_ORIGIN,6 CLOUD_ENDPOINT_CONFIRMED_ORIGIN,
7 CLOUD_ENDPOINT_RUNTIME_CONFIG,7 CLOUD_ENDPOINT_RUNTIME_CONFIG,
8 CLOUD_LINK_REVOKED,
8 CLOUD_LINKED_USER_ID,9 CLOUD_LINKED_USER_ID,
9 CLOUD_MINT_PUBLIC_KEY,10 CLOUD_MINT_PUBLIC_KEY,
10 PUBLISH_AGENT_ACTIVITY_SECRET,11 PUBLISH_AGENT_ACTIVITY_SECRET,
...@@ -57,6 +58,31 @@ export const setCliDesiredCloudLink = Effect.fn("cloud.cli_state.set_desired")(f...@@ -57,6 +58,31 @@ export const setCliDesiredCloudLink = Effect.fn("cloud.cli_state.set_desired")(f
57 }58 }
58});59});
5960
61/**
62 * Drops a link the relay rejected, keeping the sign-in and preferences, so the
63 * next start links this environment again. `t3 connect link` calls it: the
64 * server never relinks a revoked link on its own. Returns whether it dropped one.
65 */
66export const dropRevokedCloudLink = Effect.gen(function* () {
67 const secrets = yield* ServerSecretStore.ServerSecretStore;
68 if (Option.isNone(yield* secrets.get(CLOUD_LINK_REVOKED))) {
69 return false;
70 }
71 yield* Effect.all(
72 [
73 secrets.remove(CLOUD_LINKED_USER_ID),
74 secrets.remove(RELAY_ENVIRONMENT_CREDENTIAL_SECRET),
75 secrets.remove(CLOUD_MINT_PUBLIC_KEY),
76 secrets.remove(CLOUD_ENDPOINT_RUNTIME_CONFIG),
77 secrets.remove(CLOUD_ENDPOINT_CONFIRMED_ORIGIN),
78 ],
79 { concurrency: "unbounded" },
80 );
81 // Last, so a failed removal above leaves the mark for the next attempt.
82 yield* secrets.remove(CLOUD_LINK_REVOKED);
83 return true;
84});
85
60export const clearPersistedCloudLink = Effect.gen(function* () {86export const clearPersistedCloudLink = Effect.gen(function* () {
61 const secrets = yield* ServerSecretStore.ServerSecretStore;87 const secrets = yield* ServerSecretStore.ServerSecretStore;
62 yield* Effect.all(88 yield* Effect.all(
...@@ -70,6 +96,7 @@ export const clearPersistedCloudLink = Effect.gen(function* () {...@@ -70,6 +96,7 @@ export const clearPersistedCloudLink = Effect.gen(function* () {
70 secrets.remove(CLOUD_ENDPOINT_RUNTIME_CONFIG),96 secrets.remove(CLOUD_ENDPOINT_RUNTIME_CONFIG),
71 secrets.remove(CLOUD_ENDPOINT_CONFIRMED_ORIGIN),97 secrets.remove(CLOUD_ENDPOINT_CONFIRMED_ORIGIN),
72 secrets.remove(PUBLISH_AGENT_ACTIVITY_SECRET),98 secrets.remove(PUBLISH_AGENT_ACTIVITY_SECRET),
99 secrets.remove(CLOUD_LINK_REVOKED),
73 ],100 ],
74 { concurrency: "unbounded" },101 { concurrency: "unbounded" },
75 );102 );
apps/server/src/cloud/CloudLink.lifecycle.test.ts+151-1
...@@ -34,6 +34,7 @@ import { RelayManagedEndpointRecoveryRegistrationRequest } from "@t3tools/contra...@@ -34,6 +34,7 @@ import { RelayManagedEndpointRecoveryRegistrationRequest } from "@t3tools/contra
34import {34import {
35 CLOUD_ENDPOINT_CONFIRMED_ORIGIN,35 CLOUD_ENDPOINT_CONFIRMED_ORIGIN,
36 CLOUD_ENDPOINT_RUNTIME_CONFIG,36 CLOUD_ENDPOINT_RUNTIME_CONFIG,
37 CLOUD_LINK_REVOKED,
37 CLOUD_LINKED_USER_ID,38 CLOUD_LINKED_USER_ID,
38 decodeConfirmedOrigin,39 decodeConfirmedOrigin,
39 decodeRuntimeConfig,40 decodeRuntimeConfig,
...@@ -694,6 +695,115 @@ describe("releaseManagedTunnelOnShutdown", () => {...@@ -694,6 +695,115 @@ describe("releaseManagedTunnelOnShutdown", () => {
694 );695 );
695 });696 });
696697
698 it.effect("marks a link the relay rejects as revoked without dropping or replacing it", () => {
699 const { store, values } = makeMemorySecretStore([
700 [
701 CLOUD_ENDPOINT_RUNTIME_CONFIG,
702 '{"providerKind":"cloudflare_tunnel","connectorToken":"existing-token","tunnelId":"existing-tunnel"}',
703 ],
704 [RELAY_URL_SECRET, "https://relay.example.test"],
705 [CLOUD_LINKED_USER_ID, "user-123"],
706 [RELAY_ENVIRONMENT_CREDENTIAL_SECRET, "environment-credential"],
707 [CLOUD_CLI_DESIRED_LINK_SECRET, "managed"],
708 ]);
709 const applyConfigCalls: Array<unknown> = [];
710 const requests: Array<HttpClientRequest.HttpClientRequest> = [];
711
712 return Effect.gen(function* () {
713 const link = yield* CloudLink.CloudLink;
714 expect(yield* link.registerManagedTunnelRecovery("http://127.0.0.1:3773")).toEqual({
715 status: "revoked",
716 });
717 expect((yield* link.linkState()).linkRevoked).toBe(true);
718 // One registration request, no relink, and nothing started or removed.
719 expect(requests).toHaveLength(1);
720 expect(applyConfigCalls).toEqual([]);
721 expect(values.has(RELAY_ENVIRONMENT_CREDENTIAL_SECRET)).toBe(true);
722 expect(values.has(CLOUD_ENDPOINT_RUNTIME_CONFIG)).toBe(true);
723 }).pipe(
724 provideReleaseHarness({
725 store,
726 applyConfigCalls,
727 requests,
728 respond: () =>
729 Response.json(
730 {
731 _tag: "RelayAuthInvalidError",
732 code: "auth_invalid",
733 reason: "not_authorized",
734 traceId: "trace-revoked",
735 },
736 { status: 401 },
737 ),
738 }),
739 );
740 });
741
742 it.effect("does not mark a link that was replaced while its rejection was in flight", () => {
743 const { store, values } = makeMemorySecretStore([
744 [
745 CLOUD_ENDPOINT_RUNTIME_CONFIG,
746 '{"providerKind":"cloudflare_tunnel","connectorToken":"existing-token","tunnelId":"existing-tunnel"}',
747 ],
748 [RELAY_URL_SECRET, "https://relay.example.test"],
749 [CLOUD_LINKED_USER_ID, "user-123"],
750 [RELAY_ENVIRONMENT_CREDENTIAL_SECRET, "old-credential"],
751 ]);
752
753 return Effect.gen(function* () {
754 const link = yield* CloudLink.CloudLink;
755 expect(yield* link.registerManagedTunnelRecovery("http://127.0.0.1:3773")).toEqual({
756 status: "superseded",
757 });
758 expect(values.has(CLOUD_LINK_REVOKED)).toBe(false);
759 }).pipe(
760 provideReleaseHarness({
761 store,
762 applyConfigCalls: [],
763 requests: [],
764 respond: () => {
765 values.set(
766 RELAY_ENVIRONMENT_CREDENTIAL_SECRET,
767 new TextEncoder().encode("replacement-credential"),
768 );
769 return Response.json(
770 {
771 _tag: "RelayAuthInvalidError",
772 code: "auth_invalid",
773 reason: "not_authorized",
774 traceId: "trace-stale",
775 },
776 { status: 401 },
777 );
778 },
779 }),
780 );
781 });
782
783 it.effect("clears the revoked mark when the environment is linked again", () => {
784 const { store } = makeMemorySecretStore([
785 [RELAY_URL_SECRET, "https://relay.example.test"],
786 [CLOUD_LINKED_USER_ID, "user-123"],
787 [RELAY_ENVIRONMENT_CREDENTIAL_SECRET, "revoked-credential"],
788 [CLOUD_LINK_REVOKED, "true"],
789 ]);
790 const cloudMintPublicKey =
791 "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAqZ2u2LjMoAyG2QTHXmITJ9w6RzZtVUAv394XCcPV0zE=\n-----END PUBLIC KEY-----";
792
793 return Effect.gen(function* () {
794 const link = yield* CloudLink.CloudLink;
795 expect((yield* link.linkState()).linkRevoked).toBe(true);
796 yield* link.applyRelayConfig({
797 relayUrl: "https://relay.example.test",
798 cloudUserId: "user-123",
799 environmentCredential: "fresh-credential",
800 cloudMintPublicKey,
801 endpointRuntime: null,
802 });
803 expect(yield* link.linkState()).toMatchObject({ linked: true, linkRevoked: false });
804 }).pipe(provideReleaseHarness({ store, applyConfigCalls: [], requests: [] }));
805 });
806
697 it.effect("reconciles a changed port after a relay outage outlasts the startup fallback", () => {807 it.effect("reconciles a changed port after a relay outage outlasts the startup fallback", () => {
698 const config = {808 const config = {
699 providerKind: "cloudflare_tunnel" as const,809 providerKind: "cloudflare_tunnel" as const,
...@@ -944,7 +1054,7 @@ describe("releaseManagedTunnelOnShutdown", () => {...@@ -944,7 +1054,7 @@ describe("releaseManagedTunnelOnShutdown", () => {
944 }).pipe(provideReleaseHarness({ store, applyConfigCalls, requests }));1054 }).pipe(provideReleaseHarness({ store, applyConfigCalls, requests }));
945 });1055 });
9461056
947 it.effect("recovers a web-linked tunnel with its environment credential", () => {1057 it.effect("recovers a web-linked tunnel and clears its revoked mark", () => {
948 const oldConfig =1058 const oldConfig =
949 '{"providerKind":"cloudflare_tunnel","connectorToken":"old-token","tunnelId":"old-tunnel"}';1059 '{"providerKind":"cloudflare_tunnel","connectorToken":"old-token","tunnelId":"old-tunnel"}';
950 const nextConfig = {1060 const nextConfig = {
...@@ -957,6 +1067,7 @@ describe("releaseManagedTunnelOnShutdown", () => {...@@ -957,6 +1067,7 @@ describe("releaseManagedTunnelOnShutdown", () => {
957 [RELAY_URL_SECRET, "https://relay.example.test"],1067 [RELAY_URL_SECRET, "https://relay.example.test"],
958 [CLOUD_LINKED_USER_ID, "user-123"],1068 [CLOUD_LINKED_USER_ID, "user-123"],
959 [RELAY_ENVIRONMENT_CREDENTIAL_SECRET, "environment-credential"],1069 [RELAY_ENVIRONMENT_CREDENTIAL_SECRET, "environment-credential"],
1070 [CLOUD_LINK_REVOKED, "true"],
960 ]);1071 ]);
961 const applyConfigCalls: Array<unknown> = [];1072 const applyConfigCalls: Array<unknown> = [];
962 const requests: Array<HttpClientRequest.HttpClientRequest> = [];1073 const requests: Array<HttpClientRequest.HttpClientRequest> = [];
...@@ -974,6 +1085,7 @@ describe("releaseManagedTunnelOnShutdown", () => {...@@ -974,6 +1085,7 @@ describe("releaseManagedTunnelOnShutdown", () => {
974 decodeRuntimeConfig(new TextDecoder().decode(values.get(CLOUD_ENDPOINT_RUNTIME_CONFIG))),1085 decodeRuntimeConfig(new TextDecoder().decode(values.get(CLOUD_ENDPOINT_RUNTIME_CONFIG))),
975 ),1086 ),
976 ).toEqual(nextConfig);1087 ).toEqual(nextConfig);
1088 expect(values.has(CLOUD_LINK_REVOKED)).toBe(false);
977 }).pipe(1089 }).pipe(
978 provideReleaseHarness({1090 provideReleaseHarness({
979 store,1091 store,
...@@ -992,6 +1104,44 @@ describe("releaseManagedTunnelOnShutdown", () => {...@@ -992,6 +1104,44 @@ describe("releaseManagedTunnelOnShutdown", () => {
992 );1104 );
993 });1105 });
9941106
1107 it.effect("marks the link revoked when the relay rejects a running host's recovery", () => {
1108 const { store, values } = makeMemorySecretStore([
1109 [
1110 CLOUD_ENDPOINT_RUNTIME_CONFIG,
1111 '{"providerKind":"cloudflare_tunnel","connectorToken":"old-token","tunnelId":"old-tunnel"}',
1112 ],
1113 [RELAY_URL_SECRET, "https://relay.example.test"],
1114 [CLOUD_LINKED_USER_ID, "user-123"],
1115 [RELAY_ENVIRONMENT_CREDENTIAL_SECRET, "environment-credential"],
1116 ]);
1117 const applyConfigCalls: Array<unknown> = [];
1118
1119 return Effect.gen(function* () {
1120 const link = yield* CloudLink.CloudLink;
1121 const error = yield* Effect.flip(link.recoverManagedTunnel("http://127.0.0.1:3773"));
1122 expect(error).toMatchObject({ _tag: "RelayRequestError", rejection: "unauthorized" });
1123 expect((yield* link.linkState()).linkRevoked).toBe(true);
1124 expect(applyConfigCalls).toEqual([]);
1125 expect(values.has(RELAY_ENVIRONMENT_CREDENTIAL_SECRET)).toBe(true);
1126 }).pipe(
1127 provideReleaseHarness({
1128 store,
1129 applyConfigCalls,
1130 requests: [],
1131 respond: () =>
1132 Response.json(
1133 {
1134 _tag: "RelayAuthInvalidError",
1135 code: "auth_invalid",
1136 reason: "not_authorized",
1137 traceId: "trace-recovery-revoked",
1138 },
1139 { status: 401 },
1140 ),
1141 }),
1142 );
1143 });
1144
995 it.effect("allows managed tunnel provisioning to take longer than ten seconds", () =>1145 it.effect("allows managed tunnel provisioning to take longer than ten seconds", () =>
996 Effect.gen(function* () {1146 Effect.gen(function* () {
997 const oldConfig =1147 const oldConfig =
apps/server/src/cloud/CloudLink.ts+62-7
...@@ -85,6 +85,7 @@ import * as CliTokenManager from "./CliTokenManager.ts";...@@ -85,6 +85,7 @@ import * as CliTokenManager from "./CliTokenManager.ts";
85import {85import {
86 CLOUD_ENDPOINT_CONFIRMED_ORIGIN,86 CLOUD_ENDPOINT_CONFIRMED_ORIGIN,
87 CLOUD_ENDPOINT_RUNTIME_CONFIG,87 CLOUD_ENDPOINT_RUNTIME_CONFIG,
88 CLOUD_LINK_REVOKED,
88 CLOUD_LINKED_USER_ID,89 CLOUD_LINKED_USER_ID,
89 CLOUD_MINT_PUBLIC_KEY,90 CLOUD_MINT_PUBLIC_KEY,
90 decodeConfirmedOrigin,91 decodeConfirmedOrigin,
...@@ -431,8 +432,10 @@ type CloudLinkBackgroundError =...@@ -431,8 +432,10 @@ type CloudLinkBackgroundError =
431 | Schema.SchemaError432 | Schema.SchemaError
432 | PlatformError.PlatformError;433 | PlatformError.PlatformError;
433434
435// "revoked" means the relay rejected this environment's credential, as after
436// the environment is deregistered from T3 Connect.
434type ManagedTunnelRegistration =437type ManagedTunnelRegistration =
435 | { readonly status: "not_linked" | "superseded" }438 | { readonly status: "not_linked" | "superseded" | "revoked" }
436 | { readonly status: "recovery_required"; readonly config: RelayManagedEndpointRuntimeConfig }439 | { readonly status: "recovery_required"; readonly config: RelayManagedEndpointRuntimeConfig }
437 | {440 | {
438 readonly status: "ready";441 readonly status: "ready";
...@@ -838,6 +841,7 @@ const make = Effect.gen(function* () {...@@ -838,6 +841,7 @@ const make = Effect.gen(function* () {
838 RELAY_ENVIRONMENT_CREDENTIAL_SECRET,841 RELAY_ENVIRONMENT_CREDENTIAL_SECRET,
839 stringToBytes(payload.environmentCredential),842 stringToBytes(payload.environmentCredential),
840 );843 );
844 yield* secrets.remove(CLOUD_LINK_REVOKED);
841 yield* secrets.set(CLOUD_MINT_PUBLIC_KEY, stringToBytes(payload.cloudMintPublicKey));845 yield* secrets.set(CLOUD_MINT_PUBLIC_KEY, stringToBytes(payload.cloudMintPublicKey));
842 yield* awarenessRelay.requestCatchUp();846 yield* awarenessRelay.requestCatchUp();
843 if (endpointRuntimeJson !== null) {847 if (endpointRuntimeJson !== null) {
...@@ -1017,6 +1021,30 @@ const make = Effect.gen(function* () {...@@ -1017,6 +1021,30 @@ const make = Effect.gen(function* () {
1017 );1021 );
1018 });1022 });
10191023
1024 /**
1025 * Marks the link revoked after the relay answered 401 to `credential`, usually
1026 * because the environment was deregistered. Retrying cannot fix that, and
1027 * relinking is the user's call, so only mark it. Nothing stored is removed:
1028 * the relay also answers 401 for a skewed clock, and the next accepted
1029 * registration clears the mark. Returns false, without marking, when a relink
1030 * replaced the credential while the rejected request was in flight.
1031 */
1032 const markLinkRevoked = (credential: string, cause: RelayRequestError) =>
1033 endpointRuntime.withLinkStateLock(
1034 Effect.gen(function* () {
1035 const current = yield* secrets.get(RELAY_ENVIRONMENT_CREDENTIAL_SECRET);
1036 if (Option.isNone(current) || bytesToString(current.value) !== credential) {
1037 return false;
1038 }
1039 yield* Effect.logWarning(
1040 "T3 Connect no longer accepts this environment's link. Link it again to restore remote access.",
1041 { cause },
1042 );
1043 yield* secrets.set(CLOUD_LINK_REVOKED, stringToBytes("true"));
1044 return true;
1045 }),
1046 );
1047
1020 const registerManagedTunnelRecovery = Effect.fn(1048 const registerManagedTunnelRecovery = Effect.fn(
1021 "environment.cloud.registerManagedCloudTunnelRecovery",1049 "environment.cloud.registerManagedCloudTunnelRecovery",
1022 )(function* (localOrigin: string, options?: { readonly retryRuntimeFailures?: boolean }) {1050 )(function* (localOrigin: string, options?: { readonly retryRuntimeFailures?: boolean }) {
...@@ -1059,9 +1087,10 @@ const make = Effect.gen(function* () {...@@ -1059,9 +1087,10 @@ const make = Effect.gen(function* () {
1059 tunnelId: config.tunnelId,1087 tunnelId: config.tunnelId,
1060 origin,1088 origin,
1061 });1089 });
1090 const credential = bytesToString(environmentCredential.value);
1062 const registered = yield* relayClientRequest({1091 const registered = yield* relayClientRequest({
1063 url: `${relayUrlValue}/v1/environments/${encodeURIComponent(environmentId)}/tunnel/recovery`,1092 url: `${relayUrlValue}/v1/environments/${encodeURIComponent(environmentId)}/tunnel/recovery`,
1064 token: bytesToString(environmentCredential.value),1093 token: credential,
1065 payload: {1094 payload: {
1066 cloudUserId: cloudUserIdValue,1095 cloudUserId: cloudUserIdValue,
1067 tunnelId: config.tunnelId,1096 tunnelId: config.tunnelId,
...@@ -1069,7 +1098,20 @@ const make = Effect.gen(function* () {...@@ -1069,7 +1098,20 @@ const make = Effect.gen(function* () {
1069 proof,1098 proof,
1070 },1099 },
1071 schema: RelayManagedEndpointRecoveryRegistrationResponse,1100 schema: RelayManagedEndpointRecoveryRegistrationResponse,
1072 });1101 }).pipe(
1102 Effect.catchTags({
1103 RelayRequestError: (error) =>
1104 error.rejection === "unauthorized"
1105 ? markLinkRevoked(credential, error).pipe(
1106 Effect.map((marked) => (marked ? ("revoked" as const) : ("superseded" as const))),
1107 )
1108 : Effect.fail(error),
1109 }),
1110 );
1111 if (registered === "revoked" || registered === "superseded") {
1112 return { status: registered };
1113 }
1114 yield* secrets.remove(CLOUD_LINK_REVOKED);
1073 if (registered.status === "recovery_required") {1115 if (registered.status === "recovery_required") {
1074 return { status: registered.status, config };1116 return { status: registered.status, config };
1075 }1117 }
...@@ -1134,9 +1176,10 @@ const make = Effect.gen(function* () {...@@ -1134,9 +1176,10 @@ const make = Effect.gen(function* () {
1134 relayUrl: relayUrlValue,1176 relayUrl: relayUrlValue,
1135 origin,1177 origin,
1136 });1178 });
1179 const credential = bytesToString(environmentCredential.value);
1137 const recovered = yield* relayClientRequest({1180 const recovered = yield* relayClientRequest({
1138 url: `${relayUrlValue}/v1/environments/${encodeURIComponent(environmentId)}/tunnel`,1181 url: `${relayUrlValue}/v1/environments/${encodeURIComponent(environmentId)}/tunnel`,
1139 token: bytesToString(environmentCredential.value),1182 token: credential,
1140 payload: {1183 payload: {
1141 cloudUserId: cloudUserIdValue,1184 cloudUserId: cloudUserIdValue,
1142 origin,1185 origin,
...@@ -1144,7 +1187,13 @@ const make = Effect.gen(function* () {...@@ -1144,7 +1187,13 @@ const make = Effect.gen(function* () {
1144 },1187 },
1145 schema: RelayManagedEndpointRecoveryResponse,1188 schema: RelayManagedEndpointRecoveryResponse,
1146 timeout: MANAGED_ENDPOINT_PROVISION_REQUEST_TIMEOUT,1189 timeout: MANAGED_ENDPOINT_PROVISION_REQUEST_TIMEOUT,
1147 });1190 }).pipe(
1191 // A running host learns of a Deregister here, when its dead tunnel asks
1192 // for a replacement. The rejection still fails the recovery.
1193 Effect.tapError((error) =>
1194 error.rejection === "unauthorized" ? markLinkRevoked(credential, error) : Effect.void,
1195 ),
1196 );
1148 if (recovered.endpointRuntime.providerKind !== "cloudflare_tunnel") {1197 if (recovered.endpointRuntime.providerKind !== "cloudflare_tunnel") {
1149 return yield* new CloudLinkInternalError({ operation: "unsupported-recovered-tunnel" });1198 return yield* new CloudLinkInternalError({ operation: "unsupported-recovered-tunnel" });
1150 }1199 }
...@@ -1165,6 +1214,8 @@ const make = Effect.gen(function* () {...@@ -1165,6 +1214,8 @@ const make = Effect.gen(function* () {
1165 }1214 }
1166 yield* secrets.set(CLOUD_ENDPOINT_RUNTIME_CONFIG, stringToBytes(encoded));1215 yield* secrets.set(CLOUD_ENDPOINT_RUNTIME_CONFIG, stringToBytes(encoded));
1167 yield* secrets.remove(CLOUD_ENDPOINT_CONFIRMED_ORIGIN);1216 yield* secrets.remove(CLOUD_ENDPOINT_CONFIRMED_ORIGIN);
1217 // The relay accepted this link, so an earlier rejection no longer holds.
1218 yield* secrets.remove(CLOUD_LINK_REVOKED);
1168 return true;1219 return true;
1169 }),1220 }),
1170 );1221 );
...@@ -1315,6 +1366,7 @@ const make = Effect.gen(function* () {...@@ -1315,6 +1366,7 @@ const make = Effect.gen(function* () {
1315 endpointRuntimeConfig,1366 endpointRuntimeConfig,
1316 publishAgentActivity,1367 publishAgentActivity,
1317 holdWebhooks,1368 holdWebhooks,
1369 revoked,
1318 ] = yield* Effect.all(1370 ] = yield* Effect.all(
1319 [1371 [
1320 secrets.get(CLOUD_LINKED_USER_ID),1372 secrets.get(CLOUD_LINKED_USER_ID),
...@@ -1323,11 +1375,13 @@ const make = Effect.gen(function* () {...@@ -1323,11 +1375,13 @@ const make = Effect.gen(function* () {
1323 secrets.get(CLOUD_ENDPOINT_RUNTIME_CONFIG),1375 secrets.get(CLOUD_ENDPOINT_RUNTIME_CONFIG),
1324 secrets.get(PUBLISH_AGENT_ACTIVITY_SECRET),1376 secrets.get(PUBLISH_AGENT_ACTIVITY_SECRET),
1325 secrets.get(HOLD_WEBHOOKS_WHILE_OFFLINE_SECRET),1377 secrets.get(HOLD_WEBHOOKS_WHILE_OFFLINE_SECRET),
1378 secrets.get(CLOUD_LINK_REVOKED),
1326 ],1379 ],
1327 { concurrency: 6 },1380 { concurrency: 7 },
1328 );1381 );
1329 return {1382 return {
1330 linked: Option.isSome(cloudUserId),1383 linked: Option.isSome(cloudUserId),
1384 linkRevoked: Option.isSome(cloudUserId) && Option.isSome(revoked),
1331 cloudUserId: Option.isSome(cloudUserId) ? bytesToString(cloudUserId.value) : null,1385 cloudUserId: Option.isSome(cloudUserId) ? bytesToString(cloudUserId.value) : null,
1332 relayUrl: Option.isSome(relayUrl) ? bytesToString(relayUrl.value) : null,1386 relayUrl: Option.isSome(relayUrl) ? bytesToString(relayUrl.value) : null,
1333 relayIssuer: Option.isSome(relayIssuer) ? bytesToString(relayIssuer.value) : null,1387 relayIssuer: Option.isSome(relayIssuer) ? bytesToString(relayIssuer.value) : null,
...@@ -1365,8 +1419,9 @@ const make = Effect.gen(function* () {...@@ -1365,8 +1419,9 @@ const make = Effect.gen(function* () {
1365 secrets.remove(CLOUD_ENDPOINT_CONFIRMED_ORIGIN),1419 secrets.remove(CLOUD_ENDPOINT_CONFIRMED_ORIGIN),
1366 secrets.remove(PUBLISH_AGENT_ACTIVITY_SECRET),1420 secrets.remove(PUBLISH_AGENT_ACTIVITY_SECRET),
1367 secrets.remove(HOLD_WEBHOOKS_WHILE_OFFLINE_SECRET),1421 secrets.remove(HOLD_WEBHOOKS_WHILE_OFFLINE_SECRET),
1422 secrets.remove(CLOUD_LINK_REVOKED),
1368 ],1423 ],
1369 { concurrency: 9 },1424 { concurrency: 10 },
1370 );1425 );
1371 yield* setCliDesiredCloudLink(false).pipe(withSecrets);1426 yield* setCliDesiredCloudLink(false).pipe(withSecrets);
1372 return { ok: true, endpointRuntimeStatus } satisfies EnvironmentCloudRelayConfigResult;1427 return { ok: true, endpointRuntimeStatus } satisfies EnvironmentCloudRelayConfigResult;
apps/server/src/cloud/config.ts+5
...@@ -17,6 +17,11 @@ export const RELAY_ISSUER_SECRET = "cloud-relay-issuer";...@@ -17,6 +17,11 @@ export const RELAY_ISSUER_SECRET = "cloud-relay-issuer";
17export const RELAY_ENVIRONMENT_CREDENTIAL_SECRET = "cloud-relay-environment-credential";17export const RELAY_ENVIRONMENT_CREDENTIAL_SECRET = "cloud-relay-environment-credential";
18export const PUBLISH_AGENT_ACTIVITY_SECRET = "cloud-publish-agent-activity";18export const PUBLISH_AGENT_ACTIVITY_SECRET = "cloud-publish-agent-activity";
19export const HOLD_WEBHOOKS_WHILE_OFFLINE_SECRET = "cloud-hold-webhooks-while-offline";19export const HOLD_WEBHOOKS_WHILE_OFFLINE_SECRET = "cloud-hold-webhooks-while-offline";
20// Set when the relay rejects this environment's credential (401), as after the
21// environment is deregistered from T3 Connect. The link stays stored; the mark
22// tells `t3 connect status` and clients to offer a relink. Linking, unlinking,
23// or a later successful tunnel registration clears it.
24export const CLOUD_LINK_REVOKED = "cloud-link-revoked";
2025
21export const encodeEndpointRuntimeConfigJson = Schema.encodeEffect(26export const encodeEndpointRuntimeConfigJson = Schema.encodeEffect(
22 Schema.fromJsonString(RelayManagedEndpointRuntimeConfig),27 Schema.fromJsonString(RelayManagedEndpointRuntimeConfig),
apps/server/src/cloud/managedTunnelStartup.test.ts+1-1
...@@ -34,7 +34,7 @@ describe("managedTunnelStartupAction", () => {...@@ -34,7 +34,7 @@ describe("managedTunnelStartupAction", () => {
34 ).toEqual({ action: "reconcile_link" });34 ).toEqual({ action: "reconcile_link" });
35 });35 });
3636
37 it.each(["ready", "unavailable"] as const)(37 it.each(["ready", "unavailable", "revoked"] as const)(
38 "does not provision after a %s registration result",38 "does not provision after a %s registration result",
39 (status) => {39 (status) => {
40 expect(40 expect(
apps/server/src/cloud/managedTunnelStartup.ts+3-1
...@@ -3,8 +3,10 @@ import * as Duration from "effect/Duration";...@@ -3,8 +3,10 @@ import * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";4import * as Schedule from "effect/Schedule";
55
6// "revoked" leads to no action: relinking a deregistered environment is the
7// user's call, so startup neither retries nor relinks it.
6export type ManagedTunnelRegistrationResult =8export type ManagedTunnelRegistrationResult =
7 | { readonly status: "not_linked" | "ready" | "unavailable" | "superseded" }9 | { readonly status: "not_linked" | "ready" | "unavailable" | "superseded" | "revoked" }
8 | {10 | {
9 readonly status: "recovery_required";11 readonly status: "recovery_required";
10 readonly config: RelayManagedEndpointRuntimeConfig;12 readonly config: RelayManagedEndpointRuntimeConfig;
apps/web/src/cloud/useCloudLinkController.test.tsx+19
...@@ -222,6 +222,25 @@ describe("useCloudLinkController", () => {...@@ -222,6 +222,25 @@ describe("useCloudLinkController", () => {
222 });222 });
223 });223 });
224224
225 it("shows a revoked link as off and links it again when turned on", async () => {
226 testState.snapshot = { ...linkedState, linkRevoked: true };
227 testState.renderedSnapshot = testState.snapshot;
228 await mountController();
229 expect(controller).toMatchObject({
230 linked: false,
231 linkRevoked: true,
232 managedTunnelActive: false,
233 managedTunnelOutOfSync: false,
234 });
235
236 expect(await reconcile({ managedTunnel: true, publish: false })).toBe(true);
237 expect(testState.link).toHaveBeenCalledExactlyOnceWith({
238 target,
239 clerkToken: "clerk-token",
240 mode: "managed",
241 });
242 });
243
225 it("keeps a managed link that the relay also holds as managed", async () => {244 it("keeps a managed link that the relay also holds as managed", async () => {
226 testState.relayProviderKind = "cloudflare_tunnel";245 testState.relayProviderKind = "cloudflare_tunnel";
227 await mountController();246 await mountController();
apps/web/src/cloud/useCloudLinkController.ts+12-3
...@@ -39,7 +39,8 @@ export interface CloudLinkDesiredState {...@@ -39,7 +39,8 @@ export interface CloudLinkDesiredState {
39 * outlive a relay link that drifted to publish-only, and clients then get39 * outlive a relay link that drifted to publish-only, and clients then get
40 * `endpoint_provider_not_managed`. So when relay discovery has this40 * `endpoint_provider_not_managed`. So when relay discovery has this
41 * environment's record, it must also say managed. Older environment servers41 * environment's record, it must also say managed. Older environment servers
42 * predate managedTunnelActive; for them a link always implies a tunnel.42 * predate managedTunnelActive; for them a link always implies a tunnel. A link
43 * the relay rejected (usually after a Deregister) gives no access at all.
43 */44 */
44function isManagedTunnelActive(45function isManagedTunnelActive(
45 linkState: EnvironmentCloudLinkStateResult,46 linkState: EnvironmentCloudLinkStateResult,
...@@ -47,6 +48,7 @@ function isManagedTunnelActive(...@@ -47,6 +48,7 @@ function isManagedTunnelActive(
47) {48) {
48 const localManagedTunnel = linkState.managedTunnelActive ?? linkState.linked;49 const localManagedTunnel = linkState.managedTunnelActive ?? linkState.linked;
49 return (50 return (
51 linkState.linkRevoked !== true &&
50 localManagedTunnel &&52 localManagedTunnel &&
51 (relayProviderKind === undefined || relayProviderKind === "cloudflare_tunnel")53 (relayProviderKind === undefined || relayProviderKind === "cloudflare_tunnel")
52 );54 );
...@@ -105,6 +107,8 @@ export function useCloudLinkController() {...@@ -105,6 +107,8 @@ export function useCloudLinkController() {
105 };107 };
106108
107 const linkData = primaryCloudLinkState.data;109 const linkData = primaryCloudLinkState.data;
110 // A revoked link reads as off; turning it on links again.
111 const linkRevoked = linkData?.linkRevoked ?? false;
108 const managedTunnelActive = linkData ? isManagedTunnelActive(linkData, relayProviderKind) : false;112 const managedTunnelActive = linkData ? isManagedTunnelActive(linkData, relayProviderKind) : false;
109 // Local state says managed but the relay holds a publish-only link. Turning113 // Local state says managed but the relay holds a publish-only link. Turning
110 // T3 Connect on relinks as managed, which repairs it.114 // T3 Connect on relinks as managed, which repairs it.
...@@ -112,7 +116,7 @@ export function useCloudLinkController() {...@@ -112,7 +116,7 @@ export function useCloudLinkController() {
112 linkData !== null && !managedTunnelActive && isManagedTunnelActive(linkData, undefined);116 linkData !== null && !managedTunnelActive && isManagedTunnelActive(linkData, undefined);
113 const publishAgentActivity = primaryCloudLinkState.data?.publishAgentActivity ?? false;117 const publishAgentActivity = primaryCloudLinkState.data?.publishAgentActivity ?? false;
114 const holdWebhooksWhileOffline = primaryCloudLinkState.data?.holdWebhooksWhileOffline ?? false;118 const holdWebhooksWhileOffline = primaryCloudLinkState.data?.holdWebhooksWhileOffline ?? false;
115 const linked = primaryCloudLinkState.data?.linked ?? false;119 const linked = !linkRevoked && (primaryCloudLinkState.data?.linked ?? false);
116120
117 const reconcileCloudState = async (desired: CloudLinkDesiredState): Promise<boolean> => {121 const reconcileCloudState = async (desired: CloudLinkDesiredState): Promise<boolean> => {
118 setOperationError(null);122 setOperationError(null);
...@@ -178,7 +182,11 @@ export function useCloudLinkController() {...@@ -178,7 +182,11 @@ export function useCloudLinkController() {
178 return false;182 return false;
179 }183 }
180 const currentManagedTunnel = isManagedTunnelActive(currentLinkState, relayProviderKind);184 const currentManagedTunnel = isManagedTunnelActive(currentLinkState, relayProviderKind);
181 if (!currentLinkState.linked || currentManagedTunnel !== desired.managedTunnel) {185 if (
186 !currentLinkState.linked ||
187 currentLinkState.linkRevoked === true ||
188 currentManagedTunnel !== desired.managedTunnel
189 ) {
182 const linkResult = await linkPrimaryEnvironment({190 const linkResult = await linkPrimaryEnvironment({
183 target,191 target,
184 clerkToken,192 clerkToken,
...@@ -225,6 +233,7 @@ export function useCloudLinkController() {...@@ -225,6 +233,7 @@ export function useCloudLinkController() {
225 isSignedIn,233 isSignedIn,
226 linkState: primaryCloudLinkState,234 linkState: primaryCloudLinkState,
227 linked,235 linked,
236 linkRevoked,
228 managedTunnelActive,237 managedTunnelActive,
229 managedTunnelOutOfSync,238 managedTunnelOutOfSync,
230 publishAgentActivity,239 publishAgentActivity,
apps/web/src/components/cloud/ConnectOnboardingDialog.tsx+5-1
...@@ -173,7 +173,11 @@ function ConfiguredConnectOnboardingDialog() {...@@ -173,7 +173,11 @@ function ConfiguredConnectOnboardingDialog() {
173 return;173 return;
174 }174 }
175 prefilledFromLinkStateRef.current = true;175 prefilledFromLinkStateRef.current = true;
176 if (linkStateData.linked && linkStateData.cloudUserId === openForAccount) {176 if (
177 linkStateData.linked &&
178 linkStateData.linkRevoked !== true &&
179 linkStateData.cloudUserId === openForAccount
180 ) {
177 setExposeEnvironment(linkStateData.managedTunnelActive ?? linkStateData.linked);181 setExposeEnvironment(linkStateData.managedTunnelActive ?? linkStateData.linked);
178 setPublishAgentActivity(linkStateData.publishAgentActivity);182 setPublishAgentActivity(linkStateData.publishAgentActivity);
179 }183 }
apps/web/src/components/settings/ConnectionsSettings.tsx+13-5
...@@ -1828,6 +1828,7 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b...@@ -1828,6 +1828,7 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b
1828 const {1828 const {
1829 isSignedIn,1829 isSignedIn,
1830 linkState: primaryCloudLinkState,1830 linkState: primaryCloudLinkState,
1831 linkRevoked,
1831 managedTunnelActive,1832 managedTunnelActive,
1832 managedTunnelOutOfSync,1833 managedTunnelOutOfSync,
1833 publishAgentActivity,1834 publishAgentActivity,
...@@ -1918,11 +1919,13 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b...@@ -1918,11 +1919,13 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b
1918 <SettingsRow1919 <SettingsRow
1919 title={searchableSetting("t3-connect").title}1920 title={searchableSetting("t3-connect").title}
1920 description={1921 description={
1921 managedTunnelActive1922 linkRevoked
1922 ? "This environment is available to your other devices through T3 Connect."1923 ? "This environment was removed from T3 Connect. Turn it on to link it again."
1923 : managedTunnelOutOfSync1924 : managedTunnelActive
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 ? "This environment is available to your other devices through T3 Connect."
1925 : "Make this environment available to your other devices through T3 Connect."1926 : managedTunnelOutOfSync
1927 ? "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."
1928 : "Make this environment available to your other devices through T3 Connect."
1926 }1929 }
1927 status={operationError ?? primaryCloudLinkState.error}1930 status={operationError ?? primaryCloudLinkState.error}
1928 control={1931 control={
...@@ -1940,6 +1943,11 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b...@@ -1940,6 +1943,11 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b
1940 />1943 />
1941 }1944 }
1942 />1945 />
1946 ) : linkRevoked ? (
1947 <SettingsRow
1948 title={searchableSetting("t3-connect").title}
1949 description="This environment was removed from T3 Connect. Run `t3 connect link` on the host to link it again."
1950 />
1943 ) : null}1951 ) : null}
1944 <SettingsRow1952 <SettingsRow
1945 title={searchableSetting("publish-agent-activity").title}1953 title={searchableSetting("publish-agent-activity").title}
apps/web/src/components/settings/useAvailableSettingsSearchItems.ts+2-1
...@@ -29,7 +29,8 @@ export function useAvailableSettingsSearchItems(scopeSearch: SettingsScopeSearch...@@ -29,7 +29,8 @@ export function useAvailableSettingsSearchItems(scopeSearch: SettingsScopeSearch
29 const cloudLinkState = usePrimaryCloudLinkState().data;29 const cloudLinkState = usePrimaryCloudLinkState().data;
30 // Same fallback as the Connections row: older servers imply a tunnel from `linked`.30 // Same fallback as the Connections row: older servers imply a tunnel from `linked`.
31 const managedTunnelActive =31 const managedTunnelActive =
32 cloudLinkState?.managedTunnelActive ?? cloudLinkState?.linked ?? false;32 cloudLinkState?.linkRevoked !== true &&
33 (cloudLinkState?.managedTunnelActive ?? cloudLinkState?.linked ?? false);
3334
34 return useMemo(35 return useMemo(
35 () =>36 () =>
docs/user/remote-access.md+8-2
...@@ -290,8 +290,14 @@ the workspace. These scopes control direct file access from the client....@@ -290,8 +290,14 @@ the workspace. These scopes control direct file access from the client.
290To remove an environment from T3 Connect, open your account menu's **T3 Connect**290To remove an environment from T3 Connect, open your account menu's **T3 Connect**
291page, or **Settings → T3 Connect** on mobile, and choose **Deregister**. This291page, or **Settings → T3 Connect** on mobile, and choose **Deregister**. This
292revokes its cloud access and frees its host space even when the environment is292revokes its cloud access and frees its host space even when the environment is
293offline or has been wiped. Removing an environment from a device's connection293offline or has been wiped. When T3 Connect rejects a host's link, the host
294settings only forgets it on that device; it stays registered to your account.294shows that it was removed from T3 Connect. To link it again, turn on
295**T3 Connect** in **Settings → Connections** on a desktop host, or run
296`t3 connect link` on a command-line host and then restart T3 Code there. A
297command-line host that released its tunnel when it last stopped may link itself
298again on its next start; run `t3 connect unlink` on that host to keep it off.
299Removing an environment from a device's connection settings only forgets it on
300that device; it stays registered to your account.
295301
296When idle tunnel cleanup is enabled, T3 Connect removes a linked environment's302When idle tunnel cleanup is enabled, T3 Connect removes a linked environment's
297tunnel after it stays offline for several minutes. The environment stays linked303tunnel after it stays offline for several minutes. The environment stays linked
packages/client-runtime/src/relay/errorPresentation.ts+1-1
...@@ -41,7 +41,7 @@ export function relayProtectedErrorMessage(error: RelayProtectedError): string {...@@ -41,7 +41,7 @@ export function relayProtectedErrorMessage(error: RelayProtectedError): string {
41 // "Not authorized" covers non-auth causes too; surface the reason so a41 // "Not authorized" covers non-auth causes too; surface the reason so a
42 // missing link does not read as a credential problem.42 // missing link does not read as a credential problem.
43 if (error.reason === "environment_link_not_found") {43 if (error.reason === "environment_link_not_found") {
44 return "Relay has no active link for this environment. The environment server may not have re-established its link yet.";44 return "Relay has no active link for this environment. If it was deregistered from T3 Connect, link it again on that machine.";
45 }45 }
46 if (error.reason === "managed_endpoint_allocation_not_ready") {46 if (error.reason === "managed_endpoint_allocation_not_ready") {
47 return "The environment's T3 Connect tunnel is still being set up.";47 return "The environment's T3 Connect tunnel is still being set up.";
packages/contracts/src/environmentHttp.ts+4
...@@ -421,6 +421,10 @@ export const EnvironmentCloudLinkStateResult = Schema.Struct({...@@ -421,6 +421,10 @@ export const EnvironmentCloudLinkStateResult = Schema.Struct({
421 // Opt-in: T3 Connect holds webhook requests while this environment is421 // Opt-in: T3 Connect holds webhook requests while this environment is
422 // offline. Optional so newer clients tolerate older environment servers.422 // offline. Optional so newer clients tolerate older environment servers.
423 holdWebhooksWhileOffline: Schema.optional(Schema.Boolean),423 holdWebhooksWhileOffline: Schema.optional(Schema.Boolean),
424 // The relay rejected this link's credential, usually because the environment
425 // was deregistered from T3 Connect. The link stays stored until it is linked
426 // again. Optional so newer clients tolerate older environment servers.
427 linkRevoked: Schema.optional(Schema.Boolean),
424});428});
425export type EnvironmentCloudLinkStateResult = typeof EnvironmentCloudLinkStateResult.Type;429export type EnvironmentCloudLinkStateResult = typeof EnvironmentCloudLinkStateResult.Type;
426430