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";
3131import * as CliTokenManager from "../cloud/CliTokenManager.ts";
3232import { filterRelayResponse } from "../cloud/relayResponse.ts";
3333import {
34 CLOUD_LINK_REVOKED,
3435 CLOUD_LINKED_USER_ID,
3536 isAgentActivityPublishingEnabledValue,
3637 PUBLISH_AGENT_ACTIVITY_SECRET,
......@@ -140,6 +141,8 @@ interface CloudCliStatus {
140141 readonly desired: boolean;
141142 readonly authenticated: boolean;
142143 readonly linked: boolean;
144 // The relay rejected the stored link, usually after a Deregister.
145 readonly revoked: boolean;
143146 readonly cloudUserId: string | null;
144147 readonly relayUrl: string | null;
145148 readonly publishAgentActivity: boolean;
......@@ -176,18 +179,22 @@ function formatCloudStatus(status: CloudCliStatus, options?: { readonly json?: b
176179 return JSON.stringify(status, null, 2);
177180 }
178181
179 const provisioned = status.linked
180 ? "provisioned"
181 : status.desired && status.authenticated
182 ? "pending server startup"
183 : "not provisioned";
182 const provisioned = status.revoked
183 ? "removed from T3 Connect"
184 : status.linked
185 ? "provisioned"
186 : status.desired && status.authenticated
187 ? "pending server startup"
188 : "not provisioned";
184189 const nextStep = !status.authenticated
185190 ? "Run `t3 connect link` to authorize and enable T3 Connect."
186 : !status.desired
187 ? "Run `t3 connect link` to enable T3 Connect."
188 : !status.linked
189 ? "Start T3 to provision the environment link and launch its managed tunnel."
190 : undefined;
191 : status.revoked
192 ? "Run `t3 connect link` to link this environment again."
193 : !status.desired
194 ? "Run `t3 connect link` to enable T3 Connect."
195 : !status.linked
196 ? "Start T3 to provision the environment link and launch its managed tunnel."
197 : undefined;
191198
192199 return [
193200 "T3 Connect",
......@@ -477,6 +484,11 @@ const linkEnvironmentForConnect = Effect.fn("cloud.cli.link_environment")(functi
477484
478485 const identity = yield* authorizeCli(options);
479486 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 }
480492 if (publishOnly) {
481493 const secrets = yield* ServerSecretStore.ServerSecretStore;
482494 yield* secrets.set(PUBLISH_AGENT_ACTIVITY_SECRET, stringToBytes("true"));
......@@ -543,22 +555,31 @@ const connectStatusCommand = Command.make("status", {
543555 const secrets = yield* ServerSecretStore.ServerSecretStore;
544556 const relayClient = yield* RelayClient.RelayClient;
545557 const tokens = yield* CliTokenManager.CloudCliTokenManager;
546 const [desired, authenticated, cloudUserId, relayUrl, publishAgentActivity, executable] =
547 yield* Effect.all(
548 [
549 CliState.readCliDesiredCloudLink,
550 tokens.hasCredential,
551 secrets.get(CLOUD_LINKED_USER_ID),
552 secrets.get(RELAY_URL_SECRET),
553 secrets.get(PUBLISH_AGENT_ACTIVITY_SECRET),
554 relayClient.resolve,
555 ],
556 { concurrency: "unbounded" },
557 );
558 const [
559 desired,
560 authenticated,
561 cloudUserId,
562 relayUrl,
563 publishAgentActivity,
564 revoked,
565 executable,
566 ] = yield* Effect.all(
567 [
568 CliState.readCliDesiredCloudLink,
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 );
558578 const status: CloudCliStatus = {
559579 desired,
560580 authenticated,
561581 linked: Option.isSome(cloudUserId),
582 revoked: Option.isSome(cloudUserId) && Option.isSome(revoked),
562583 cloudUserId: Option.isSome(cloudUserId) ? bytesToString(cloudUserId.value) : null,
563584 relayUrl: Option.isSome(relayUrl) ? bytesToString(relayUrl.value) : null,
564585 publishAgentActivity: isAgentActivityPublishingEnabledValue(
apps/server/src/cloud/CliState.test.ts+32
......@@ -10,6 +10,7 @@ import * as CliState from "./CliState.ts";
1010import {
1111 CLOUD_ENDPOINT_CONFIRMED_ORIGIN,
1212 CLOUD_ENDPOINT_RUNTIME_CONFIG,
13 CLOUD_LINK_REVOKED,
1314 CLOUD_LINKED_USER_ID,
1415 CLOUD_MINT_PUBLIC_KEY,
1516 PUBLISH_AGENT_ACTIVITY_SECRET,
......@@ -59,6 +60,37 @@ it.layer(NodeServices.layer)("CliState", (it) => {
5960 }).pipe(Effect.provide(layerTest())),
6061 );
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
6294 it.effect("round-trips the desired link mode and defaults legacy links to managed", () =>
6395 Effect.gen(function* () {
6496 const secrets = yield* ServerSecretStore.ServerSecretStore;
apps/server/src/cloud/CliState.ts+27
......@@ -5,6 +5,7 @@ import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
55import {
66 CLOUD_ENDPOINT_CONFIRMED_ORIGIN,
77 CLOUD_ENDPOINT_RUNTIME_CONFIG,
8 CLOUD_LINK_REVOKED,
89 CLOUD_LINKED_USER_ID,
910 CLOUD_MINT_PUBLIC_KEY,
1011 PUBLISH_AGENT_ACTIVITY_SECRET,
......@@ -57,6 +58,31 @@ export const setCliDesiredCloudLink = Effect.fn("cloud.cli_state.set_desired")(f
5758 }
5859});
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
6086export const clearPersistedCloudLink = Effect.gen(function* () {
6187 const secrets = yield* ServerSecretStore.ServerSecretStore;
6288 yield* Effect.all(
......@@ -70,6 +96,7 @@ export const clearPersistedCloudLink = Effect.gen(function* () {
7096 secrets.remove(CLOUD_ENDPOINT_RUNTIME_CONFIG),
7197 secrets.remove(CLOUD_ENDPOINT_CONFIRMED_ORIGIN),
7298 secrets.remove(PUBLISH_AGENT_ACTIVITY_SECRET),
99 secrets.remove(CLOUD_LINK_REVOKED),
73100 ],
74101 { concurrency: "unbounded" },
75102 );
apps/server/src/cloud/CloudLink.lifecycle.test.ts+151-1
......@@ -34,6 +34,7 @@ import { RelayManagedEndpointRecoveryRegistrationRequest } from "@t3tools/contra
3434import {
3535 CLOUD_ENDPOINT_CONFIRMED_ORIGIN,
3636 CLOUD_ENDPOINT_RUNTIME_CONFIG,
37 CLOUD_LINK_REVOKED,
3738 CLOUD_LINKED_USER_ID,
3839 decodeConfirmedOrigin,
3940 decodeRuntimeConfig,
......@@ -694,6 +695,115 @@ describe("releaseManagedTunnelOnShutdown", () => {
694695 );
695696 });
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
697807 it.effect("reconciles a changed port after a relay outage outlasts the startup fallback", () => {
698808 const config = {
699809 providerKind: "cloudflare_tunnel" as const,
......@@ -944,7 +1054,7 @@ describe("releaseManagedTunnelOnShutdown", () => {
9441054 }).pipe(provideReleaseHarness({ store, applyConfigCalls, requests }));
9451055 });
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", () => {
9481058 const oldConfig =
9491059 '{"providerKind":"cloudflare_tunnel","connectorToken":"old-token","tunnelId":"old-tunnel"}';
9501060 const nextConfig = {
......@@ -957,6 +1067,7 @@ describe("releaseManagedTunnelOnShutdown", () => {
9571067 [RELAY_URL_SECRET, "https://relay.example.test"],
9581068 [CLOUD_LINKED_USER_ID, "user-123"],
9591069 [RELAY_ENVIRONMENT_CREDENTIAL_SECRET, "environment-credential"],
1070 [CLOUD_LINK_REVOKED, "true"],
9601071 ]);
9611072 const applyConfigCalls: Array<unknown> = [];
9621073 const requests: Array<HttpClientRequest.HttpClientRequest> = [];
......@@ -974,6 +1085,7 @@ describe("releaseManagedTunnelOnShutdown", () => {
9741085 decodeRuntimeConfig(new TextDecoder().decode(values.get(CLOUD_ENDPOINT_RUNTIME_CONFIG))),
9751086 ),
9761087 ).toEqual(nextConfig);
1088 expect(values.has(CLOUD_LINK_REVOKED)).toBe(false);
9771089 }).pipe(
9781090 provideReleaseHarness({
9791091 store,
......@@ -992,6 +1104,44 @@ describe("releaseManagedTunnelOnShutdown", () => {
9921104 );
9931105 });
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
9951145 it.effect("allows managed tunnel provisioning to take longer than ten seconds", () =>
9961146 Effect.gen(function* () {
9971147 const oldConfig =
apps/server/src/cloud/CloudLink.ts+62-7
......@@ -85,6 +85,7 @@ import * as CliTokenManager from "./CliTokenManager.ts";
8585import {
8686 CLOUD_ENDPOINT_CONFIRMED_ORIGIN,
8787 CLOUD_ENDPOINT_RUNTIME_CONFIG,
88 CLOUD_LINK_REVOKED,
8889 CLOUD_LINKED_USER_ID,
8990 CLOUD_MINT_PUBLIC_KEY,
9091 decodeConfirmedOrigin,
......@@ -431,8 +432,10 @@ type CloudLinkBackgroundError =
431432 | Schema.SchemaError
432433 | PlatformError.PlatformError;
433434
435// "revoked" means the relay rejected this environment's credential, as after
436// the environment is deregistered from T3 Connect.
434437type ManagedTunnelRegistration =
435 | { readonly status: "not_linked" | "superseded" }
438 | { readonly status: "not_linked" | "superseded" | "revoked" }
436439 | { readonly status: "recovery_required"; readonly config: RelayManagedEndpointRuntimeConfig }
437440 | {
438441 readonly status: "ready";
......@@ -838,6 +841,7 @@ const make = Effect.gen(function* () {
838841 RELAY_ENVIRONMENT_CREDENTIAL_SECRET,
839842 stringToBytes(payload.environmentCredential),
840843 );
844 yield* secrets.remove(CLOUD_LINK_REVOKED);
841845 yield* secrets.set(CLOUD_MINT_PUBLIC_KEY, stringToBytes(payload.cloudMintPublicKey));
842846 yield* awarenessRelay.requestCatchUp();
843847 if (endpointRuntimeJson !== null) {
......@@ -1017,6 +1021,30 @@ const make = Effect.gen(function* () {
10171021 );
10181022 });
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
10201048 const registerManagedTunnelRecovery = Effect.fn(
10211049 "environment.cloud.registerManagedCloudTunnelRecovery",
10221050 )(function* (localOrigin: string, options?: { readonly retryRuntimeFailures?: boolean }) {
......@@ -1059,9 +1087,10 @@ const make = Effect.gen(function* () {
10591087 tunnelId: config.tunnelId,
10601088 origin,
10611089 });
1090 const credential = bytesToString(environmentCredential.value);
10621091 const registered = yield* relayClientRequest({
10631092 url: `${relayUrlValue}/v1/environments/${encodeURIComponent(environmentId)}/tunnel/recovery`,
1064 token: bytesToString(environmentCredential.value),
1093 token: credential,
10651094 payload: {
10661095 cloudUserId: cloudUserIdValue,
10671096 tunnelId: config.tunnelId,
......@@ -1069,7 +1098,20 @@ const make = Effect.gen(function* () {
10691098 proof,
10701099 },
10711100 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);
10731115 if (registered.status === "recovery_required") {
10741116 return { status: registered.status, config };
10751117 }
......@@ -1134,9 +1176,10 @@ const make = Effect.gen(function* () {
11341176 relayUrl: relayUrlValue,
11351177 origin,
11361178 });
1179 const credential = bytesToString(environmentCredential.value);
11371180 const recovered = yield* relayClientRequest({
11381181 url: `${relayUrlValue}/v1/environments/${encodeURIComponent(environmentId)}/tunnel`,
1139 token: bytesToString(environmentCredential.value),
1182 token: credential,
11401183 payload: {
11411184 cloudUserId: cloudUserIdValue,
11421185 origin,
......@@ -1144,7 +1187,13 @@ const make = Effect.gen(function* () {
11441187 },
11451188 schema: RelayManagedEndpointRecoveryResponse,
11461189 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 );
11481197 if (recovered.endpointRuntime.providerKind !== "cloudflare_tunnel") {
11491198 return yield* new CloudLinkInternalError({ operation: "unsupported-recovered-tunnel" });
11501199 }
......@@ -1165,6 +1214,8 @@ const make = Effect.gen(function* () {
11651214 }
11661215 yield* secrets.set(CLOUD_ENDPOINT_RUNTIME_CONFIG, stringToBytes(encoded));
11671216 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);
11681219 return true;
11691220 }),
11701221 );
......@@ -1315,6 +1366,7 @@ const make = Effect.gen(function* () {
13151366 endpointRuntimeConfig,
13161367 publishAgentActivity,
13171368 holdWebhooks,
1369 revoked,
13181370 ] = yield* Effect.all(
13191371 [
13201372 secrets.get(CLOUD_LINKED_USER_ID),
......@@ -1323,11 +1375,13 @@ const make = Effect.gen(function* () {
13231375 secrets.get(CLOUD_ENDPOINT_RUNTIME_CONFIG),
13241376 secrets.get(PUBLISH_AGENT_ACTIVITY_SECRET),
13251377 secrets.get(HOLD_WEBHOOKS_WHILE_OFFLINE_SECRET),
1378 secrets.get(CLOUD_LINK_REVOKED),
13261379 ],
1327 { concurrency: 6 },
1380 { concurrency: 7 },
13281381 );
13291382 return {
13301383 linked: Option.isSome(cloudUserId),
1384 linkRevoked: Option.isSome(cloudUserId) && Option.isSome(revoked),
13311385 cloudUserId: Option.isSome(cloudUserId) ? bytesToString(cloudUserId.value) : null,
13321386 relayUrl: Option.isSome(relayUrl) ? bytesToString(relayUrl.value) : null,
13331387 relayIssuer: Option.isSome(relayIssuer) ? bytesToString(relayIssuer.value) : null,
......@@ -1365,8 +1419,9 @@ const make = Effect.gen(function* () {
13651419 secrets.remove(CLOUD_ENDPOINT_CONFIRMED_ORIGIN),
13661420 secrets.remove(PUBLISH_AGENT_ACTIVITY_SECRET),
13671421 secrets.remove(HOLD_WEBHOOKS_WHILE_OFFLINE_SECRET),
1422 secrets.remove(CLOUD_LINK_REVOKED),
13681423 ],
1369 { concurrency: 9 },
1424 { concurrency: 10 },
13701425 );
13711426 yield* setCliDesiredCloudLink(false).pipe(withSecrets);
13721427 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";
1717export const RELAY_ENVIRONMENT_CREDENTIAL_SECRET = "cloud-relay-environment-credential";
1818export const PUBLISH_AGENT_ACTIVITY_SECRET = "cloud-publish-agent-activity";
1919export 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
2126export const encodeEndpointRuntimeConfigJson = Schema.encodeEffect(
2227 Schema.fromJsonString(RelayManagedEndpointRuntimeConfig),
apps/server/src/cloud/managedTunnelStartup.test.ts+1-1
......@@ -34,7 +34,7 @@ describe("managedTunnelStartupAction", () => {
3434 ).toEqual({ action: "reconcile_link" });
3535 });
3636
37 it.each(["ready", "unavailable"] as const)(
37 it.each(["ready", "unavailable", "revoked"] as const)(
3838 "does not provision after a %s registration result",
3939 (status) => {
4040 expect(
apps/server/src/cloud/managedTunnelStartup.ts+3-1
......@@ -3,8 +3,10 @@ import * as Duration from "effect/Duration";
33import * as Effect from "effect/Effect";
44import * 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.
68export type ManagedTunnelRegistrationResult =
7 | { readonly status: "not_linked" | "ready" | "unavailable" | "superseded" }
9 | { readonly status: "not_linked" | "ready" | "unavailable" | "superseded" | "revoked" }
810 | {
911 readonly status: "recovery_required";
1012 readonly config: RelayManagedEndpointRuntimeConfig;
apps/web/src/cloud/useCloudLinkController.test.tsx+19
......@@ -222,6 +222,25 @@ describe("useCloudLinkController", () => {
222222 });
223223 });
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
225244 it("keeps a managed link that the relay also holds as managed", async () => {
226245 testState.relayProviderKind = "cloudflare_tunnel";
227246 await mountController();
apps/web/src/cloud/useCloudLinkController.ts+12-3
......@@ -39,7 +39,8 @@ export interface CloudLinkDesiredState {
3939 * outlive a relay link that drifted to publish-only, and clients then get
4040 * `endpoint_provider_not_managed`. So when relay discovery has this
4141 * 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.
4344 */
4445function isManagedTunnelActive(
4546 linkState: EnvironmentCloudLinkStateResult,
......@@ -47,6 +48,7 @@ function isManagedTunnelActive(
4748) {
4849 const localManagedTunnel = linkState.managedTunnelActive ?? linkState.linked;
4950 return (
51 linkState.linkRevoked !== true &&
5052 localManagedTunnel &&
5153 (relayProviderKind === undefined || relayProviderKind === "cloudflare_tunnel")
5254 );
......@@ -105,6 +107,8 @@ export function useCloudLinkController() {
105107 };
106108
107109 const linkData = primaryCloudLinkState.data;
110 // A revoked link reads as off; turning it on links again.
111 const linkRevoked = linkData?.linkRevoked ?? false;
108112 const managedTunnelActive = linkData ? isManagedTunnelActive(linkData, relayProviderKind) : false;
109113 // Local state says managed but the relay holds a publish-only link. Turning
110114 // T3 Connect on relinks as managed, which repairs it.
......@@ -112,7 +116,7 @@ export function useCloudLinkController() {
112116 linkData !== null && !managedTunnelActive && isManagedTunnelActive(linkData, undefined);
113117 const publishAgentActivity = primaryCloudLinkState.data?.publishAgentActivity ?? false;
114118 const holdWebhooksWhileOffline = primaryCloudLinkState.data?.holdWebhooksWhileOffline ?? false;
115 const linked = primaryCloudLinkState.data?.linked ?? false;
119 const linked = !linkRevoked && (primaryCloudLinkState.data?.linked ?? false);
116120
117121 const reconcileCloudState = async (desired: CloudLinkDesiredState): Promise<boolean> => {
118122 setOperationError(null);
......@@ -178,7 +182,11 @@ export function useCloudLinkController() {
178182 return false;
179183 }
180184 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 ) {
182190 const linkResult = await linkPrimaryEnvironment({
183191 target,
184192 clerkToken,
......@@ -225,6 +233,7 @@ export function useCloudLinkController() {
225233 isSignedIn,
226234 linkState: primaryCloudLinkState,
227235 linked,
236 linkRevoked,
228237 managedTunnelActive,
229238 managedTunnelOutOfSync,
230239 publishAgentActivity,
apps/web/src/components/cloud/ConnectOnboardingDialog.tsx+5-1
......@@ -173,7 +173,11 @@ function ConfiguredConnectOnboardingDialog() {
173173 return;
174174 }
175175 prefilledFromLinkStateRef.current = true;
176 if (linkStateData.linked && linkStateData.cloudUserId === openForAccount) {
176 if (
177 linkStateData.linked &&
178 linkStateData.linkRevoked !== true &&
179 linkStateData.cloudUserId === openForAccount
180 ) {
177181 setExposeEnvironment(linkStateData.managedTunnelActive ?? linkStateData.linked);
178182 setPublishAgentActivity(linkStateData.publishAgentActivity);
179183 }
apps/web/src/components/settings/ConnectionsSettings.tsx+13-5
......@@ -1828,6 +1828,7 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b
18281828 const {
18291829 isSignedIn,
18301830 linkState: primaryCloudLinkState,
1831 linkRevoked,
18311832 managedTunnelActive,
18321833 managedTunnelOutOfSync,
18331834 publishAgentActivity,
......@@ -1918,11 +1919,13 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b
19181919 <SettingsRow
19191920 title={searchableSetting("t3-connect").title}
19201921 description={
1921 managedTunnelActive
1922 ? "This environment is 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."
1922 linkRevoked
1923 ? "This environment was removed from T3 Connect. Turn it on to link it again."
1924 : managedTunnelActive
1925 ? "This environment is 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."
19261929 }
19271930 status={operationError ?? primaryCloudLinkState.error}
19281931 control={
......@@ -1940,6 +1943,11 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b
19401943 />
19411944 }
19421945 />
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 />
19431951 ) : null}
19441952 <SettingsRow
19451953 title={searchableSetting("publish-agent-activity").title}
apps/web/src/components/settings/useAvailableSettingsSearchItems.ts+2-1
......@@ -29,7 +29,8 @@ export function useAvailableSettingsSearchItems(scopeSearch: SettingsScopeSearch
2929 const cloudLinkState = usePrimaryCloudLinkState().data;
3030 // Same fallback as the Connections row: older servers imply a tunnel from `linked`.
3131 const managedTunnelActive =
32 cloudLinkState?.managedTunnelActive ?? cloudLinkState?.linked ?? false;
32 cloudLinkState?.linkRevoked !== true &&
33 (cloudLinkState?.managedTunnelActive ?? cloudLinkState?.linked ?? false);
3334
3435 return useMemo(
3536 () =>
docs/user/remote-access.md+8-2
......@@ -290,8 +290,14 @@ the workspace. These scopes control direct file access from the client.
290290To remove an environment from T3 Connect, open your account menu's **T3 Connect**
291291page, or **Settings → T3 Connect** on mobile, and choose **Deregister**. This
292292revokes 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 connection
294settings only forgets it on that device; it stays registered to your account.
293offline or has been wiped. When T3 Connect rejects a host's link, the host
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
296302When idle tunnel cleanup is enabled, T3 Connect removes a linked environment's
297303tunnel 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 {
4141 // "Not authorized" covers non-auth causes too; surface the reason so a
4242 // missing link does not read as a credential problem.
4343 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.";
4545 }
4646 if (error.reason === "managed_endpoint_allocation_not_ready") {
4747 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({
421421 // Opt-in: T3 Connect holds webhook requests while this environment is
422422 // offline. Optional so newer clients tolerate older environment servers.
423423 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),
424428});
425429export type EnvironmentCloudLinkStateResult = typeof EnvironmentCloudLinkStateResult.Type;
426430