Skip to content

Commit 8e76f94

Browse files
t3dotggclaude
andauthored
feat(relay): T3 Chat Pro and Premier accounts get more tunnels (#25931)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent c0f9e36 commit 8e76f94

24 files changed

Lines changed: 1026 additions & 60 deletions

‎.github/workflows/deploy-relay.yml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ jobs:
8080
CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }}
8181
APNS_PRIVATE_KEY: ${{ secrets.APNS_PRIVATE_KEY }}
8282
FCM_SERVICE_ACCOUNT: ${{ secrets.FCM_SERVICE_ACCOUNT }}
83+
T3CHAT_LINK_SECRET: ${{ secrets.T3CHAT_LINK_SECRET }}
8384

8485
- name: Publish relay deploy commit status
8586
uses: actions/github-script@v8

‎.github/workflows/relay-preview.yml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,3 +79,5 @@ jobs:
7979
PLANETSCALE_API_TOKEN: ${{ secrets.PLANETSCALE_API_TOKEN }}
8080
AXIOM_TOKEN: ${{ secrets.AXIOM_TOKEN }}
8181
CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }}
82+
# Unset by default, so previews keep the T3 Chat link off.
83+
T3CHAT_LINK_SECRET: ${{ secrets.T3CHAT_LINK_SECRET }}

‎apps/web/src/cloud/managedRelayState.ts‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import { useAtomValue } from "@effect/atom-react";
22
import {
33
createManagedRelayQueryManager,
4+
createManagedRelayT3ChatLinkRequest,
45
deregisterManagedRelayEnvironment,
56
ManagedRelay,
67
managedRelaySessionAtom,
78
readManagedRelaySnapshotState,
9+
unlinkManagedRelayT3Chat,
810
} from "@t3tools/client-runtime/relay";
911
import {
1012
createAtomCommandScheduler,
@@ -13,6 +15,7 @@ import {
1315
import type {
1416
RelayClientDeviceRecord,
1517
RelayClientEnvironmentRecord,
18+
RelayT3ChatLinkResponse,
1619
} from "@t3tools/contracts/relay";
1720
import type { EnvironmentId } from "@t3tools/contracts";
1821
import * as Context from "effect/Context";
@@ -50,6 +53,50 @@ export const deregisterManagedRelayEnvironmentCommand = createRuntimeCommand(
5053
},
5154
);
5255

56+
const accountSerial = {
57+
mode: "serial",
58+
key: (input: { readonly accountId: string }) => input.accountId,
59+
} as const;
60+
61+
export const createManagedRelayT3ChatLinkRequestCommand = createRuntimeCommand(
62+
managedRelayAtomRuntime,
63+
{
64+
label: "web:managed-relay:create-t3chat-link-request",
65+
scheduler: managedRelayMutationScheduler,
66+
concurrency: accountSerial,
67+
execute: (input: { readonly accountId: string }, registry) =>
68+
createManagedRelayT3ChatLinkRequest(registry, input),
69+
},
70+
);
71+
72+
export const unlinkManagedRelayT3ChatCommand = createRuntimeCommand(managedRelayAtomRuntime, {
73+
label: "web:managed-relay:unlink-t3chat",
74+
scheduler: managedRelayMutationScheduler,
75+
concurrency: accountSerial,
76+
execute: (input: { readonly accountId: string }, registry) =>
77+
unlinkManagedRelayT3Chat(registry, input),
78+
});
79+
80+
const EMPTY_T3CHAT_LINK_ATOM = Atom.make(
81+
AsyncResult.success<RelayT3ChatLinkResponse>({ available: false, link: null }),
82+
).pipe(Atom.keepAlive, Atom.withLabel("managed-relay:web:t3chat-link:null"));
83+
84+
export function useManagedRelayT3ChatLink() {
85+
const session = useAtomValue(managedRelaySessionAtom);
86+
const accountId = session?.accountId ?? null;
87+
const atom = accountId
88+
? managedRelayQueryManager.t3ChatLinkAtom(accountId)
89+
: EMPTY_T3CHAT_LINK_ATOM;
90+
const snapshot = readManagedRelaySnapshotState(useAtomValue(atom));
91+
const refresh = () => {
92+
if (accountId) {
93+
managedRelayQueryManager.refreshT3ChatLink(appAtomRegistry, accountId);
94+
}
95+
};
96+
97+
return { ...snapshot, accountId, refresh };
98+
}
99+
53100
const EMPTY_ENVIRONMENTS_ATOM = Atom.make(
54101
AsyncResult.success<ReadonlyArray<RelayClientEnvironmentRecord>>([]),
55102
).pipe(Atom.keepAlive, Atom.withLabel("managed-relay:web:environments:null"));
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import { findErrorTraceId } from "@t3tools/client-runtime/errors";
2+
import {
3+
isAtomCommandInterrupted,
4+
squashAtomCommandFailure,
5+
} from "@t3tools/client-runtime/state/runtime";
6+
import { T3CHAT_PLAN_TUNNEL_LIMITS, type RelayT3ChatLink } from "@t3tools/contracts/relay";
7+
import { MessageSquareIcon } from "lucide-react";
8+
import { useState } from "react";
9+
10+
import {
11+
createManagedRelayT3ChatLinkRequestCommand,
12+
unlinkManagedRelayT3ChatCommand,
13+
useManagedRelayT3ChatLink,
14+
} from "../../cloud/managedRelayState";
15+
import { readLocalApi } from "../../localApi";
16+
import { useAtomCommand } from "../../state/use-atom-command";
17+
import { Button } from "../ui/button";
18+
import { toastManager } from "../ui/toast";
19+
import {
20+
ClerkUserProfilePage,
21+
ClerkUserProfileRefreshButton,
22+
ClerkUserProfileRow,
23+
} from "./ClerkUserProfilePage";
24+
25+
function planLabel(link: RelayT3ChatLink): string {
26+
switch (link.plan) {
27+
case "premier":
28+
return `Premier · up to ${T3CHAT_PLAN_TUNNEL_LIMITS.premier} tunnels`;
29+
case "pro":
30+
return `Pro · up to ${T3CHAT_PLAN_TUNNEL_LIMITS.pro} tunnels, or ${T3CHAT_PLAN_TUNNEL_LIMITS.premier} with Premier`;
31+
case "free":
32+
return "Upgrade on T3 Chat for more tunnels";
33+
}
34+
}
35+
36+
function reportFailure(title: string, cause: unknown) {
37+
const traceId = findErrorTraceId(cause);
38+
toastManager.add({
39+
type: "error",
40+
title,
41+
description: cause instanceof Error ? cause.message : "Try again.",
42+
data: traceId
43+
? {
44+
secondaryActionProps: {
45+
children: "Copy trace ID",
46+
onClick: () => void navigator.clipboard?.writeText(traceId),
47+
},
48+
}
49+
: undefined,
50+
});
51+
}
52+
53+
/** Links a T3 Chat account so paid subscribers get more tunnels. */
54+
export function T3ChatUserProfilePage() {
55+
const linkState = useManagedRelayT3ChatLink();
56+
const createLinkRequest = useAtomCommand(createManagedRelayT3ChatLinkRequestCommand, {
57+
reportFailure: false,
58+
});
59+
const unlink = useAtomCommand(unlinkManagedRelayT3ChatCommand, { reportFailure: false });
60+
const [pending, setPending] = useState<"link" | "unlink" | null>(null);
61+
// Kept so a blocked popup still leaves the user a link to open.
62+
const [linkUrl, setLinkUrl] = useState<string | null>(null);
63+
64+
const handleLink = async () => {
65+
const accountId = linkState.accountId;
66+
if (!accountId || pending) return;
67+
setPending("link");
68+
const result = await createLinkRequest({ accountId });
69+
if (result._tag === "Success") {
70+
setLinkUrl(result.value);
71+
await readLocalApi()
72+
?.shell.openExternal(result.value)
73+
.catch(() => undefined);
74+
} else if (!isAtomCommandInterrupted(result)) {
75+
reportFailure("Could not start linking", squashAtomCommandFailure(result));
76+
}
77+
setPending(null);
78+
};
79+
80+
const handleUnlink = async () => {
81+
const accountId = linkState.accountId;
82+
if (!accountId || pending) return;
83+
setPending("unlink");
84+
const result = await unlink({ accountId });
85+
if (result._tag === "Success") {
86+
setLinkUrl(null);
87+
linkState.refresh();
88+
} else if (!isAtomCommandInterrupted(result)) {
89+
reportFailure("Could not unlink T3 Chat", squashAtomCommandFailure(result));
90+
}
91+
setPending(null);
92+
};
93+
94+
const data = linkState.data;
95+
const link = data?.link ?? null;
96+
97+
return (
98+
<ClerkUserProfilePage
99+
title="T3 Chat"
100+
description={`Link a paid T3 Chat account for more T3 Connect tunnels: up to ${T3CHAT_PLAN_TUNNEL_LIMITS.pro} with Pro, ${T3CHAT_PLAN_TUNNEL_LIMITS.premier} with Premier.`}
101+
action={
102+
<ClerkUserProfileRefreshButton
103+
disabled={pending !== null}
104+
isPending={linkState.isPending}
105+
onClick={linkState.refresh}
106+
/>
107+
}
108+
>
109+
{linkState.error ? (
110+
<div className="border-t py-3 text-xs" role="alert">
111+
<p className="font-medium text-destructive-foreground">Could not load T3 Chat link</p>
112+
<p className="mt-0.5 text-muted-foreground">{linkState.error}</p>
113+
</div>
114+
) : data === null ? null : !data.available ? (
115+
<p className="border-t py-4 text-xs text-muted-foreground">
116+
This T3 Connect server does not support T3 Chat linking.
117+
</p>
118+
) : (
119+
<ul className="border-t">
120+
<ClerkUserProfileRow icon={<MessageSquareIcon className="size-4" />}>
121+
<div className="flex items-start gap-4">
122+
<div className="min-w-0 flex-1">
123+
<h3 className="truncate text-sm leading-4.5 font-medium text-foreground">
124+
{link ? link.email : "Not linked"}
125+
</h3>
126+
<p className="mt-1 text-xs leading-4.5 text-muted-foreground">
127+
{link ? (
128+
planLabel(link)
129+
) : linkUrl ? (
130+
<>
131+
Confirm on{" "}
132+
<a
133+
className="underline"
134+
href={linkUrl}
135+
target="_blank"
136+
rel="noopener noreferrer"
137+
>
138+
t3.chat
139+
</a>
140+
, then select Refresh.
141+
</>
142+
) : (
143+
"You confirm the link on t3.chat."
144+
)}
145+
</p>
146+
</div>
147+
{link ? (
148+
<Button
149+
size="sm"
150+
variant="destructive-outline"
151+
disabled={pending !== null}
152+
onClick={() => void handleUnlink()}
153+
>
154+
{pending === "unlink" ? "Unlinking…" : "Unlink"}
155+
</Button>
156+
) : (
157+
<Button size="sm" disabled={pending !== null} onClick={() => void handleLink()}>
158+
{pending === "link" ? "Opening…" : "Link T3 Chat"}
159+
</Button>
160+
)}
161+
</div>
162+
</ClerkUserProfileRow>
163+
</ul>
164+
)}
165+
</ClerkUserProfilePage>
166+
);
167+
}

‎apps/web/src/components/clerk/T3ConnectAccountPages.tsx‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { useAuth, useClerk } from "@clerk/react";
2-
import { ServerIcon, SmartphoneIcon } from "lucide-react";
2+
import { MessageSquareIcon, ServerIcon, SmartphoneIcon } from "lucide-react";
33
import { type ReactNode, useState } from "react";
44
import { createPortal } from "react-dom";
55

66
import { MobileClientsUserProfilePage } from "./MobileClientsUserProfilePage";
7+
import { T3ChatUserProfilePage } from "./T3ChatUserProfilePage";
78
import { T3ConnectUserProfilePage } from "./T3ConnectUserProfilePage";
89

910
export const T3_CONNECT_ACCOUNT_PAGES = [
@@ -19,6 +20,12 @@ export const T3_CONNECT_ACCOUNT_PAGES = [
1920
icon: <ServerIcon className="size-4" />,
2021
content: <T3ConnectUserProfilePage />,
2122
},
23+
{
24+
label: "T3 Chat",
25+
url: "t3-chat",
26+
icon: <MessageSquareIcon className="size-4" />,
27+
content: <T3ChatUserProfilePage />,
28+
},
2229
] as const;
2330

2431
type PortalTargets = Readonly<Record<string, HTMLDivElement | undefined>>;

‎docs/internals/t3-connect.md‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,21 @@ and clients keep their bindings. Every mutation on an allocation bumps its
131131
`generation`, and deletion locks the row at the generation it claimed, so a
132132
host that reconnects mid-sweep wins.
133133

134+
## A paid T3 Chat plan raises the tunnel cap
135+
136+
T3 Chat uses WorkOS, not Clerk, so the two accounts are linked by consent
137+
rather than by a shared identity. The relay and T3 Chat share one secret
138+
(`T3CHAT_LINK_SECRET` here, `T3CODE_LINK_SECRET` there). The relay signs a
139+
short-lived token naming the Clerk user and email; the user confirms it while
140+
signed in to T3 Chat, and T3 Chat stores the link. T3 Chat owns the link and the
141+
plan, so the relay keeps no copy. It asks T3 Chat only when an account reaches
142+
its normal cap for a new environment, and any failure keeps the normal cap.
143+
An environment that already holds a counted slot restarts without asking, so a
144+
downgrade or a T3 Chat outage only blocks new environments. A manual override
145+
row replaces the default for new environments, and a T3 Chat plan does not
146+
raise it
147+
([limits](../../infra/relay/src/environments/ManagedTunnelLimits.ts)).
148+
134149
## The relay client follows the server's pin
135150

136151
The host runs `cloudflared` pinned by `CLOUDFLARED_VERSION` in

‎docs/operations/release.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,11 @@ Required `production` environment secrets:
148148
- `CLERK_SECRET_KEY`
149149
- `APNS_PRIVATE_KEY`
150150

151+
Optional `production` environment secrets:
152+
153+
- `T3CHAT_LINK_SECRET` for the T3 Chat account link. It must match
154+
`T3CODE_LINK_SECRET` on T3 Chat.
155+
151156
After changing a variable or secret, run the **Deploy T3 Connect relay** workflow manually from
152157
`main` with **force** unchecked. Alchemy compares the values the Worker reads and redeploys it when
153158
one changed. Check **force** only to redeploy resources with no detected change.

‎infra/relay/README.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,9 @@ The `production` GitHub environment must define these Actions secrets:
160160
- `CLERK_SECRET_KEY`
161161
- `APNS_PRIVATE_KEY`
162162
- `FCM_SERVICE_ACCOUNT` when Android push is enabled
163+
- `T3CHAT_LINK_SECRET`, optional, shared with T3 Chat's `T3CODE_LINK_SECRET`. Without it the
164+
T3 Chat account link is off. Local and preview stages can set `T3CHAT_URL` to point
165+
at another T3 Chat deployment; production uses `https://t3.chat`.
163166

164167
The account-scoped repository credentials are consumed by Alchemy while provisioning relay stages; they
165168
are not bound into the relay Worker. The production deployment uses an Axiom personal access token,

‎infra/relay/src/Config.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,12 @@ export interface ApnsCredentials {
4848
readonly environment: ApnsEnvironment;
4949
}
5050

51+
export interface T3ChatSettings {
52+
readonly baseUrl: string;
53+
// Shared with T3 Chat: signs link tokens and authenticates relay calls.
54+
readonly linkSecret: Redacted.Redacted<string>;
55+
}
56+
5157
export class RelayConfiguration extends Context.Service<
5258
RelayConfiguration,
5359
{
@@ -65,6 +71,7 @@ export class RelayConfiguration extends Context.Service<
6571
readonly managedEndpointCleanupMode?: ManagedEndpointCleanupMode;
6672
readonly legacyManagedEndpointCleanupMode?: ManagedEndpointCleanupMode;
6773
readonly legacyTunnelGraceMinutes?: number;
74+
readonly t3Chat?: T3ChatSettings;
6875
}
6976
>()("t3code-relay/Config/RelayConfiguration") {}
7077

0 commit comments

Comments
 (0)