| author | |
| committer | |
| log | 8e018ff91f64d6caedc029e498eca446ff0fad20 |
| tree | ec536a425a92e0f0bd6d0464962f8fa5b25198c2 |
| parent | b037b9a535ff30f3f390da682dabeffe4c439044 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 31 | import * as CliTokenManager from "../cloud/CliTokenManager.ts"; |
| 32 | 32 | import { filterRelayResponse } from "../cloud/relayResponse.ts"; |
| 33 | 33 | import { |
| 34 | CLOUD_LINK_REVOKED, | |
| 34 | 35 | CLOUD_LINKED_USER_ID, |
| 35 | 36 | isAgentActivityPublishingEnabledValue, |
| 36 | 37 | PUBLISH_AGENT_ACTIVITY_SECRET, |
| ... | ... | @@ -140,6 +141,8 @@ interface CloudCliStatus { |
| 140 | 141 | readonly desired: boolean; |
| 141 | 142 | readonly authenticated: boolean; |
| 142 | 143 | readonly linked: boolean; |
| 144 | // The relay rejected the stored link, usually after a Deregister. | |
| 145 | readonly revoked: boolean; | |
| 143 | 146 | readonly cloudUserId: string | null; |
| 144 | 147 | readonly relayUrl: string | null; |
| 145 | 148 | readonly publishAgentActivity: boolean; |
| ... | ... | @@ -176,18 +179,22 @@ function formatCloudStatus(status: CloudCliStatus, options?: { readonly json?: b |
| 176 | 179 | return JSON.stringify(status, null, 2); |
| 177 | 180 | } |
| 178 | 181 | |
| 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"; | |
| 184 | 189 | const nextStep = !status.authenticated |
| 185 | 190 | ? "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; | |
| 191 | 198 | |
| 192 | 199 | return [ |
| 193 | 200 | "T3 Connect", |
| ... | ... | @@ -477,6 +484,11 @@ const linkEnvironmentForConnect = Effect.fn("cloud.cli.link_environment")(functi |
| 477 | 484 | |
| 478 | 485 | const identity = yield* authorizeCli(options); |
| 479 | 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 | 492 | if (publishOnly) { |
| 481 | 493 | const secrets = yield* ServerSecretStore.ServerSecretStore; |
| 482 | 494 | yield* secrets.set(PUBLISH_AGENT_ACTIVITY_SECRET, stringToBytes("true")); |
| ... | ... | @@ -543,22 +555,31 @@ const connectStatusCommand = Command.make("status", { |
| 543 | 555 | const secrets = yield* ServerSecretStore.ServerSecretStore; |
| 544 | 556 | const relayClient = yield* RelayClient.RelayClient; |
| 545 | 557 | 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 | ); | |
| 558 | 578 | const status: CloudCliStatus = { |
| 559 | 579 | desired, |
| 560 | 580 | authenticated, |
| 561 | 581 | linked: Option.isSome(cloudUserId), |
| 582 | revoked: Option.isSome(cloudUserId) && Option.isSome(revoked), | |
| 562 | 583 | cloudUserId: Option.isSome(cloudUserId) ? bytesToString(cloudUserId.value) : null, |
| 563 | 584 | relayUrl: Option.isSome(relayUrl) ? bytesToString(relayUrl.value) : null, |
| 564 | 585 | publishAgentActivity: isAgentActivityPublishingEnabledValue( |
apps/server/src/cloud/CliState.test.ts+32| ... | ... | @@ -10,6 +10,7 @@ import * as CliState from "./CliState.ts"; |
| 10 | 10 | import { |
| 11 | 11 | CLOUD_ENDPOINT_CONFIRMED_ORIGIN, |
| 12 | 12 | CLOUD_ENDPOINT_RUNTIME_CONFIG, |
| 13 | CLOUD_LINK_REVOKED, | |
| 13 | 14 | CLOUD_LINKED_USER_ID, |
| 14 | 15 | CLOUD_MINT_PUBLIC_KEY, |
| 15 | 16 | PUBLISH_AGENT_ACTIVITY_SECRET, |
| ... | ... | @@ -59,6 +60,37 @@ it.layer(NodeServices.layer)("CliState", (it) => { |
| 59 | 60 | }).pipe(Effect.provide(layerTest())), |
| 60 | 61 | ); |
| 61 | 62 | |
| 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 | 94 | it.effect("round-trips the desired link mode and defaults legacy links to managed", () => |
| 63 | 95 | Effect.gen(function* () { |
| 64 | 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 | 5 | import { |
| 6 | 6 | CLOUD_ENDPOINT_CONFIRMED_ORIGIN, |
| 7 | 7 | CLOUD_ENDPOINT_RUNTIME_CONFIG, |
| 8 | CLOUD_LINK_REVOKED, | |
| 8 | 9 | CLOUD_LINKED_USER_ID, |
| 9 | 10 | CLOUD_MINT_PUBLIC_KEY, |
| 10 | 11 | PUBLISH_AGENT_ACTIVITY_SECRET, |
| ... | ... | @@ -57,6 +58,31 @@ export const setCliDesiredCloudLink = Effect.fn("cloud.cli_state.set_desired")(f |
| 57 | 58 | } |
| 58 | 59 | }); |
| 59 | 60 | |
| 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 | */ | |
| 66 | export 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 | ||
| 60 | 86 | export const clearPersistedCloudLink = Effect.gen(function* () { |
| 61 | 87 | const secrets = yield* ServerSecretStore.ServerSecretStore; |
| 62 | 88 | yield* Effect.all( |
| ... | ... | @@ -70,6 +96,7 @@ export const clearPersistedCloudLink = Effect.gen(function* () { |
| 70 | 96 | secrets.remove(CLOUD_ENDPOINT_RUNTIME_CONFIG), |
| 71 | 97 | secrets.remove(CLOUD_ENDPOINT_CONFIRMED_ORIGIN), |
| 72 | 98 | secrets.remove(PUBLISH_AGENT_ACTIVITY_SECRET), |
| 99 | secrets.remove(CLOUD_LINK_REVOKED), | |
| 73 | 100 | ], |
| 74 | 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 | 34 | import { |
| 35 | 35 | CLOUD_ENDPOINT_CONFIRMED_ORIGIN, |
| 36 | 36 | CLOUD_ENDPOINT_RUNTIME_CONFIG, |
| 37 | CLOUD_LINK_REVOKED, | |
| 37 | 38 | CLOUD_LINKED_USER_ID, |
| 38 | 39 | decodeConfirmedOrigin, |
| 39 | 40 | decodeRuntimeConfig, |
| ... | ... | @@ -694,6 +695,115 @@ describe("releaseManagedTunnelOnShutdown", () => { |
| 694 | 695 | ); |
| 695 | 696 | }); |
| 696 | 697 | |
| 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 | 807 | it.effect("reconciles a changed port after a relay outage outlasts the startup fallback", () => { |
| 698 | 808 | const config = { |
| 699 | 809 | providerKind: "cloudflare_tunnel" as const, |
| ... | ... | @@ -944,7 +1054,7 @@ describe("releaseManagedTunnelOnShutdown", () => { |
| 944 | 1054 | }).pipe(provideReleaseHarness({ store, applyConfigCalls, requests })); |
| 945 | 1055 | }); |
| 946 | 1056 | |
| 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 | 1058 | const oldConfig = |
| 949 | 1059 | '{"providerKind":"cloudflare_tunnel","connectorToken":"old-token","tunnelId":"old-tunnel"}'; |
| 950 | 1060 | const nextConfig = { |
| ... | ... | @@ -957,6 +1067,7 @@ describe("releaseManagedTunnelOnShutdown", () => { |
| 957 | 1067 | [RELAY_URL_SECRET, "https://relay.example.test"], |
| 958 | 1068 | [CLOUD_LINKED_USER_ID, "user-123"], |
| 959 | 1069 | [RELAY_ENVIRONMENT_CREDENTIAL_SECRET, "environment-credential"], |
| 1070 | [CLOUD_LINK_REVOKED, "true"], | |
| 960 | 1071 | ]); |
| 961 | 1072 | const applyConfigCalls: Array<unknown> = []; |
| 962 | 1073 | const requests: Array<HttpClientRequest.HttpClientRequest> = []; |
| ... | ... | @@ -974,6 +1085,7 @@ describe("releaseManagedTunnelOnShutdown", () => { |
| 974 | 1085 | decodeRuntimeConfig(new TextDecoder().decode(values.get(CLOUD_ENDPOINT_RUNTIME_CONFIG))), |
| 975 | 1086 | ), |
| 976 | 1087 | ).toEqual(nextConfig); |
| 1088 | expect(values.has(CLOUD_LINK_REVOKED)).toBe(false); | |
| 977 | 1089 | }).pipe( |
| 978 | 1090 | provideReleaseHarness({ |
| 979 | 1091 | store, |
| ... | ... | @@ -992,6 +1104,44 @@ describe("releaseManagedTunnelOnShutdown", () => { |
| 992 | 1104 | ); |
| 993 | 1105 | }); |
| 994 | 1106 | |
| 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 | 1145 | it.effect("allows managed tunnel provisioning to take longer than ten seconds", () => |
| 996 | 1146 | Effect.gen(function* () { |
| 997 | 1147 | const oldConfig = |
apps/server/src/cloud/CloudLink.ts+62-7| ... | ... | @@ -85,6 +85,7 @@ import * as CliTokenManager from "./CliTokenManager.ts"; |
| 85 | 85 | import { |
| 86 | 86 | CLOUD_ENDPOINT_CONFIRMED_ORIGIN, |
| 87 | 87 | CLOUD_ENDPOINT_RUNTIME_CONFIG, |
| 88 | CLOUD_LINK_REVOKED, | |
| 88 | 89 | CLOUD_LINKED_USER_ID, |
| 89 | 90 | CLOUD_MINT_PUBLIC_KEY, |
| 90 | 91 | decodeConfirmedOrigin, |
| ... | ... | @@ -431,8 +432,10 @@ type CloudLinkBackgroundError = |
| 431 | 432 | | Schema.SchemaError |
| 432 | 433 | | PlatformError.PlatformError; |
| 433 | 434 | |
| 435 | // "revoked" means the relay rejected this environment's credential, as after | |
| 436 | // the environment is deregistered from T3 Connect. | |
| 434 | 437 | type ManagedTunnelRegistration = |
| 435 | | { readonly status: "not_linked" | "superseded" } | |
| 438 | | { readonly status: "not_linked" | "superseded" | "revoked" } | |
| 436 | 439 | | { readonly status: "recovery_required"; readonly config: RelayManagedEndpointRuntimeConfig } |
| 437 | 440 | | { |
| 438 | 441 | readonly status: "ready"; |
| ... | ... | @@ -838,6 +841,7 @@ const make = Effect.gen(function* () { |
| 838 | 841 | RELAY_ENVIRONMENT_CREDENTIAL_SECRET, |
| 839 | 842 | stringToBytes(payload.environmentCredential), |
| 840 | 843 | ); |
| 844 | yield* secrets.remove(CLOUD_LINK_REVOKED); | |
| 841 | 845 | yield* secrets.set(CLOUD_MINT_PUBLIC_KEY, stringToBytes(payload.cloudMintPublicKey)); |
| 842 | 846 | yield* awarenessRelay.requestCatchUp(); |
| 843 | 847 | if (endpointRuntimeJson !== null) { |
| ... | ... | @@ -1017,6 +1021,30 @@ const make = Effect.gen(function* () { |
| 1017 | 1021 | ); |
| 1018 | 1022 | }); |
| 1019 | 1023 | |
| 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 | 1048 | const registerManagedTunnelRecovery = Effect.fn( |
| 1021 | 1049 | "environment.cloud.registerManagedCloudTunnelRecovery", |
| 1022 | 1050 | )(function* (localOrigin: string, options?: { readonly retryRuntimeFailures?: boolean }) { |
| ... | ... | @@ -1059,9 +1087,10 @@ const make = Effect.gen(function* () { |
| 1059 | 1087 | tunnelId: config.tunnelId, |
| 1060 | 1088 | origin, |
| 1061 | 1089 | }); |
| 1090 | const credential = bytesToString(environmentCredential.value); | |
| 1062 | 1091 | const registered = yield* relayClientRequest({ |
| 1063 | 1092 | url: `${relayUrlValue}/v1/environments/${encodeURIComponent(environmentId)}/tunnel/recovery`, |
| 1064 | token: bytesToString(environmentCredential.value), | |
| 1093 | token: credential, | |
| 1065 | 1094 | payload: { |
| 1066 | 1095 | cloudUserId: cloudUserIdValue, |
| 1067 | 1096 | tunnelId: config.tunnelId, |
| ... | ... | @@ -1069,7 +1098,20 @@ const make = Effect.gen(function* () { |
| 1069 | 1098 | proof, |
| 1070 | 1099 | }, |
| 1071 | 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 | 1115 | if (registered.status === "recovery_required") { |
| 1074 | 1116 | return { status: registered.status, config }; |
| 1075 | 1117 | } |
| ... | ... | @@ -1134,9 +1176,10 @@ const make = Effect.gen(function* () { |
| 1134 | 1176 | relayUrl: relayUrlValue, |
| 1135 | 1177 | origin, |
| 1136 | 1178 | }); |
| 1179 | const credential = bytesToString(environmentCredential.value); | |
| 1137 | 1180 | const recovered = yield* relayClientRequest({ |
| 1138 | 1181 | url: `${relayUrlValue}/v1/environments/${encodeURIComponent(environmentId)}/tunnel`, |
| 1139 | token: bytesToString(environmentCredential.value), | |
| 1182 | token: credential, | |
| 1140 | 1183 | payload: { |
| 1141 | 1184 | cloudUserId: cloudUserIdValue, |
| 1142 | 1185 | origin, |
| ... | ... | @@ -1144,7 +1187,13 @@ const make = Effect.gen(function* () { |
| 1144 | 1187 | }, |
| 1145 | 1188 | schema: RelayManagedEndpointRecoveryResponse, |
| 1146 | 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 | 1197 | if (recovered.endpointRuntime.providerKind !== "cloudflare_tunnel") { |
| 1149 | 1198 | return yield* new CloudLinkInternalError({ operation: "unsupported-recovered-tunnel" }); |
| 1150 | 1199 | } |
| ... | ... | @@ -1165,6 +1214,8 @@ const make = Effect.gen(function* () { |
| 1165 | 1214 | } |
| 1166 | 1215 | yield* secrets.set(CLOUD_ENDPOINT_RUNTIME_CONFIG, stringToBytes(encoded)); |
| 1167 | 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 | 1219 | return true; |
| 1169 | 1220 | }), |
| 1170 | 1221 | ); |
| ... | ... | @@ -1315,6 +1366,7 @@ const make = Effect.gen(function* () { |
| 1315 | 1366 | endpointRuntimeConfig, |
| 1316 | 1367 | publishAgentActivity, |
| 1317 | 1368 | holdWebhooks, |
| 1369 | revoked, | |
| 1318 | 1370 | ] = yield* Effect.all( |
| 1319 | 1371 | [ |
| 1320 | 1372 | secrets.get(CLOUD_LINKED_USER_ID), |
| ... | ... | @@ -1323,11 +1375,13 @@ const make = Effect.gen(function* () { |
| 1323 | 1375 | secrets.get(CLOUD_ENDPOINT_RUNTIME_CONFIG), |
| 1324 | 1376 | secrets.get(PUBLISH_AGENT_ACTIVITY_SECRET), |
| 1325 | 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 | 1382 | return { |
| 1330 | 1383 | linked: Option.isSome(cloudUserId), |
| 1384 | linkRevoked: Option.isSome(cloudUserId) && Option.isSome(revoked), | |
| 1331 | 1385 | cloudUserId: Option.isSome(cloudUserId) ? bytesToString(cloudUserId.value) : null, |
| 1332 | 1386 | relayUrl: Option.isSome(relayUrl) ? bytesToString(relayUrl.value) : null, |
| 1333 | 1387 | relayIssuer: Option.isSome(relayIssuer) ? bytesToString(relayIssuer.value) : null, |
| ... | ... | @@ -1365,8 +1419,9 @@ const make = Effect.gen(function* () { |
| 1365 | 1419 | secrets.remove(CLOUD_ENDPOINT_CONFIRMED_ORIGIN), |
| 1366 | 1420 | secrets.remove(PUBLISH_AGENT_ACTIVITY_SECRET), |
| 1367 | 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 | 1426 | yield* setCliDesiredCloudLink(false).pipe(withSecrets); |
| 1372 | 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 | 17 | export const RELAY_ENVIRONMENT_CREDENTIAL_SECRET = "cloud-relay-environment-credential"; |
| 18 | 18 | export const PUBLISH_AGENT_ACTIVITY_SECRET = "cloud-publish-agent-activity"; |
| 19 | 19 | export 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. | |
| 24 | export const CLOUD_LINK_REVOKED = "cloud-link-revoked"; | |
| 20 | 25 | |
| 21 | 26 | export const encodeEndpointRuntimeConfigJson = Schema.encodeEffect( |
| 22 | 27 | Schema.fromJsonString(RelayManagedEndpointRuntimeConfig), |
apps/server/src/cloud/managedTunnelStartup.test.ts+1-1| ... | ... | @@ -34,7 +34,7 @@ describe("managedTunnelStartupAction", () => { |
| 34 | 34 | ).toEqual({ action: "reconcile_link" }); |
| 35 | 35 | }); |
| 36 | 36 | |
| 37 | it.each(["ready", "unavailable"] as const)( | |
| 37 | it.each(["ready", "unavailable", "revoked"] as const)( | |
| 38 | 38 | "does not provision after a %s registration result", |
| 39 | 39 | (status) => { |
| 40 | 40 | expect( |
apps/server/src/cloud/managedTunnelStartup.ts+3-1| ... | ... | @@ -3,8 +3,10 @@ import * as Duration from "effect/Duration"; |
| 3 | 3 | import * as Effect from "effect/Effect"; |
| 4 | 4 | import * as Schedule from "effect/Schedule"; |
| 5 | 5 | |
| 6 | // "revoked" leads to no action: relinking a deregistered environment is the | |
| 7 | // user's call, so startup neither retries nor relinks it. | |
| 6 | 8 | export type ManagedTunnelRegistrationResult = |
| 7 | | { readonly status: "not_linked" | "ready" | "unavailable" | "superseded" } | |
| 9 | | { readonly status: "not_linked" | "ready" | "unavailable" | "superseded" | "revoked" } | |
| 8 | 10 | | { |
| 9 | 11 | readonly status: "recovery_required"; |
| 10 | 12 | readonly config: RelayManagedEndpointRuntimeConfig; |
apps/web/src/cloud/useCloudLinkController.test.tsx+19| ... | ... | @@ -222,6 +222,25 @@ describe("useCloudLinkController", () => { |
| 222 | 222 | }); |
| 223 | 223 | }); |
| 224 | 224 | |
| 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 | 244 | it("keeps a managed link that the relay also holds as managed", async () => { |
| 226 | 245 | testState.relayProviderKind = "cloudflare_tunnel"; |
| 227 | 246 | await mountController(); |
apps/web/src/cloud/useCloudLinkController.ts+12-3| ... | ... | @@ -39,7 +39,8 @@ export interface CloudLinkDesiredState { |
| 39 | 39 | * outlive a relay link that drifted to publish-only, and clients then get |
| 40 | 40 | * `endpoint_provider_not_managed`. So when relay discovery has this |
| 41 | 41 | * 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 | */ |
| 44 | 45 | function isManagedTunnelActive( |
| 45 | 46 | linkState: EnvironmentCloudLinkStateResult, |
| ... | ... | @@ -47,6 +48,7 @@ function isManagedTunnelActive( |
| 47 | 48 | ) { |
| 48 | 49 | const localManagedTunnel = linkState.managedTunnelActive ?? linkState.linked; |
| 49 | 50 | return ( |
| 51 | linkState.linkRevoked !== true && | |
| 50 | 52 | localManagedTunnel && |
| 51 | 53 | (relayProviderKind === undefined || relayProviderKind === "cloudflare_tunnel") |
| 52 | 54 | ); |
| ... | ... | @@ -105,6 +107,8 @@ export function useCloudLinkController() { |
| 105 | 107 | }; |
| 106 | 108 | |
| 107 | 109 | const linkData = primaryCloudLinkState.data; |
| 110 | // A revoked link reads as off; turning it on links again. | |
| 111 | const linkRevoked = linkData?.linkRevoked ?? false; | |
| 108 | 112 | const managedTunnelActive = linkData ? isManagedTunnelActive(linkData, relayProviderKind) : false; |
| 109 | 113 | // Local state says managed but the relay holds a publish-only link. Turning |
| 110 | 114 | // T3 Connect on relinks as managed, which repairs it. |
| ... | ... | @@ -112,7 +116,7 @@ export function useCloudLinkController() { |
| 112 | 116 | linkData !== null && !managedTunnelActive && isManagedTunnelActive(linkData, undefined); |
| 113 | 117 | const publishAgentActivity = primaryCloudLinkState.data?.publishAgentActivity ?? false; |
| 114 | 118 | const holdWebhooksWhileOffline = primaryCloudLinkState.data?.holdWebhooksWhileOffline ?? false; |
| 115 | const linked = primaryCloudLinkState.data?.linked ?? false; | |
| 119 | const linked = !linkRevoked && (primaryCloudLinkState.data?.linked ?? false); | |
| 116 | 120 | |
| 117 | 121 | const reconcileCloudState = async (desired: CloudLinkDesiredState): Promise<boolean> => { |
| 118 | 122 | setOperationError(null); |
| ... | ... | @@ -178,7 +182,11 @@ export function useCloudLinkController() { |
| 178 | 182 | return false; |
| 179 | 183 | } |
| 180 | 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 | 190 | const linkResult = await linkPrimaryEnvironment({ |
| 183 | 191 | target, |
| 184 | 192 | clerkToken, |
| ... | ... | @@ -225,6 +233,7 @@ export function useCloudLinkController() { |
| 225 | 233 | isSignedIn, |
| 226 | 234 | linkState: primaryCloudLinkState, |
| 227 | 235 | linked, |
| 236 | linkRevoked, | |
| 228 | 237 | managedTunnelActive, |
| 229 | 238 | managedTunnelOutOfSync, |
| 230 | 239 | publishAgentActivity, |
apps/web/src/components/cloud/ConnectOnboardingDialog.tsx+5-1| ... | ... | @@ -173,7 +173,11 @@ function ConfiguredConnectOnboardingDialog() { |
| 173 | 173 | return; |
| 174 | 174 | } |
| 175 | 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 | 181 | setExposeEnvironment(linkStateData.managedTunnelActive ?? linkStateData.linked); |
| 178 | 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 | 1828 | const { |
| 1829 | 1829 | isSignedIn, |
| 1830 | 1830 | linkState: primaryCloudLinkState, |
| 1831 | linkRevoked, | |
| 1831 | 1832 | managedTunnelActive, |
| 1832 | 1833 | managedTunnelOutOfSync, |
| 1833 | 1834 | publishAgentActivity, |
| ... | ... | @@ -1918,11 +1919,13 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b |
| 1918 | 1919 | <SettingsRow |
| 1919 | 1920 | title={searchableSetting("t3-connect").title} |
| 1920 | 1921 | 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." | |
| 1926 | 1929 | } |
| 1927 | 1930 | status={operationError ?? primaryCloudLinkState.error} |
| 1928 | 1931 | control={ |
| ... | ... | @@ -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 | 1951 | ) : null} |
| 1944 | 1952 | <SettingsRow |
| 1945 | 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 | 29 | const cloudLinkState = usePrimaryCloudLinkState().data; |
| 30 | 30 | // Same fallback as the Connections row: older servers imply a tunnel from `linked`. |
| 31 | 31 | const managedTunnelActive = |
| 32 | cloudLinkState?.managedTunnelActive ?? cloudLinkState?.linked ?? false; | |
| 32 | cloudLinkState?.linkRevoked !== true && | |
| 33 | (cloudLinkState?.managedTunnelActive ?? cloudLinkState?.linked ?? false); | |
| 33 | 34 | |
| 34 | 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 | 290 | To remove an environment from T3 Connect, open your account menu's **T3 Connect** |
| 291 | 291 | page, or **Settings → T3 Connect** on mobile, and choose **Deregister**. This |
| 292 | 292 | revokes its cloud access and frees its host space even when the environment is |
| 293 | offline or has been wiped. Removing an environment from a device's connection | |
| 294 | settings only forgets it on that device; it stays registered to your account. | |
| 293 | offline or has been wiped. When T3 Connect rejects a host's link, the host | |
| 294 | shows 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 | |
| 297 | command-line host that released its tunnel when it last stopped may link itself | |
| 298 | again on its next start; run `t3 connect unlink` on that host to keep it off. | |
| 299 | Removing an environment from a device's connection settings only forgets it on | |
| 300 | that device; it stays registered to your account. | |
| 295 | 301 | |
| 296 | 302 | When idle tunnel cleanup is enabled, T3 Connect removes a linked environment's |
| 297 | 303 | tunnel 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 | 41 | // "Not authorized" covers non-auth causes too; surface the reason so a |
| 42 | 42 | // missing link does not read as a credential problem. |
| 43 | 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 | 46 | if (error.reason === "managed_endpoint_allocation_not_ready") { |
| 47 | 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 | 421 | // Opt-in: T3 Connect holds webhook requests while this environment is |
| 422 | 422 | // offline. Optional so newer clients tolerate older environment servers. |
| 423 | 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 | }); |
| 425 | 429 | export type EnvironmentCloudLinkStateResult = typeof EnvironmentCloudLinkStateResult.Type; |
| 426 | 430 |