authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-09 12:25:02-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-09 12:25:02-07:00
log22f9f3058b7e2da973fec5b65597a778390c4790
tree16d9bb0e79af2dc25e05d18743566331de7629d6
parent983a8c7fa6d6393fbc5fb46618f04d8f6a29648b
signature Signed by PGP key B5690EEEBB952194

[codex] Rebrand T3 Cloud as T3 Connect (#3011)

Co-authored-by: codex <codex@users.noreply.github.com>

45 files changed, 2283 insertions(+), 2347 deletions(-)

.env.example+2-2
......@@ -1,5 +1,5 @@
1# Optional: T3 Cloud source builds
2# Leave these unset to disable optional T3 Cloud features in local source builds.
1# Optional: T3 Connect source builds
2# Leave these unset to disable optional T3 Connect features in local source builds.
33# Release builds inject their public values at build time. Do not add server-side
44# secrets to this file.
55
.github/workflows/deploy-relay.yml+1-1
......@@ -1,4 +1,4 @@
1name: Deploy T3 Cloud relay
1name: Deploy T3 Connect relay
22
33on:
44 push:
.github/workflows/release.yml+1-1
......@@ -167,7 +167,7 @@ jobs:
167167 --github-output
168168
169169 relay_public_config:
170 name: Resolve T3 Cloud public config
170 name: Resolve T3 Connect public config
171171 needs: preflight
172172 if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' }}
173173 runs-on: blacksmith-8vcpu-ubuntu-2404
apps/desktop/src/window/DesktopWindow.test.ts+1-1
......@@ -165,7 +165,7 @@ describe("DesktopWindow", () => {
165165 assert.isTrue(
166166 DesktopWindow.isSameOriginRendererNavigation({
167167 applicationUrl: "http://127.0.0.1:3773/",
168 navigationUrl: "http://127.0.0.1:3773/settings/cloud",
168 navigationUrl: "http://127.0.0.1:3773/settings/connections",
169169 }),
170170 );
171171 assert.isFalse(
apps/mobile/README.md+1-1
......@@ -16,7 +16,7 @@ This app has three variants:
1616
1717Run commands from `apps/mobile`.
1818
19T3 Cloud is optional and disabled in a fresh clone. Public configuration belongs in the
19T3 Connect is optional and disabled in a fresh clone. Public configuration belongs in the
2020repository-root `.env` or `.env.local`, not an `apps/mobile/.env` file. See
2121[`../../.env.example`](../../.env.example).
2222
apps/mobile/src/features/cloud/linkEnvironment.test.ts+4-4
......@@ -572,7 +572,7 @@ describe("mobile cloud link environment client", () => {
572572 if (String(url).endsWith("/v1/client/environment-link-challenges")) {
573573 return Promise.resolve(Response.json(validLinkChallengeResponse()));
574574 }
575 if (String(url).endsWith("/api/cloud/link-proof")) {
575 if (String(url).endsWith("/api/connect/link-proof")) {
576576 return Promise.resolve(Response.json(validLinkProof()));
577577 }
578578 return Promise.resolve(Response.json(validLinkResponse("env-other")));
......@@ -631,7 +631,7 @@ describe("mobile cloud link environment client", () => {
631631 if (String(url).endsWith("/v1/client/environment-link-challenges")) {
632632 return Promise.resolve(Response.json(validLinkChallengeResponse()));
633633 }
634 if (String(url).endsWith("/api/cloud/link-proof")) {
634 if (String(url).endsWith("/api/connect/link-proof")) {
635635 return Promise.resolve(Response.json(validLinkProof()));
636636 }
637637 return Promise.resolve(
......@@ -669,7 +669,7 @@ describe("mobile cloud link environment client", () => {
669669 if (String(url).endsWith("/v1/client/environment-link-challenges")) {
670670 return Promise.resolve(Response.json(validLinkChallengeResponse()));
671671 }
672 if (String(url).endsWith("/api/cloud/link-proof")) {
672 if (String(url).endsWith("/api/connect/link-proof")) {
673673 return Promise.resolve(Response.json(validLinkProof()));
674674 }
675675 return Promise.resolve(
......@@ -713,7 +713,7 @@ describe("mobile cloud link environment client", () => {
713713 if (String(url).endsWith("/v1/client/environment-link-challenges")) {
714714 return Promise.resolve(Response.json(validLinkChallengeResponse()));
715715 }
716 if (String(url).endsWith("/api/cloud/link-proof")) {
716 if (String(url).endsWith("/api/connect/link-proof")) {
717717 return Promise.resolve(Response.json(validLinkProof()));
718718 }
719719 if (String(url).endsWith("/v1/client/environment-links")) {
apps/mobile/src/features/cloud/linkEnvironment.ts+2-2
......@@ -301,7 +301,7 @@ export function linkEnvironmentToCloud(input: {
301301 ),
302302 );
303303 const environmentClient = yield* makeEnvironmentHttpApiClient(input.connection.httpBaseUrl);
304 const proof = yield* environmentClient.cloud
304 const proof = yield* environmentClient.connect
305305 .linkProof({
306306 headers: { authorization: `Bearer ${localBearerToken}` },
307307 payload: {
......@@ -336,7 +336,7 @@ export function linkEnvironmentToCloud(input: {
336336 link,
337337 });
338338
339 yield* environmentClient.cloud
339 yield* environmentClient.connect
340340 .relayConfig({
341341 headers: { authorization: `Bearer ${localBearerToken}` },
342342 payload: {
apps/server/src/bin.test.ts+26-24
......@@ -38,11 +38,11 @@ import { environmentAuthenticatedAuthLayer } from "./auth/http.ts";
3838const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer);
3939class ProjectCliHttpApi extends HttpApi.make("environment").add(EnvironmentOrchestrationHttpApi) {}
4040
41const cloudCli = makeCli({ cloudEnabled: true });
42const noCloudCli = makeCli({ cloudEnabled: false });
41const connectCli = makeCli({ cloudEnabled: true });
42const noConnectCli = makeCli({ cloudEnabled: false });
4343const runCli = (args: ReadonlyArray<string>, command = cli) =>
4444 Command.runWith(command, { version: "0.0.0" })(args);
45const runCloudCli = (args: ReadonlyArray<string>) => runCli(args, cloudCli);
45const runConnectCli = (args: ReadonlyArray<string>) => runCli(args, connectCli);
4646const runCliWithRuntime = (args: ReadonlyArray<string>) =>
4747 runCli(args).pipe(Effect.provide(CliRuntimeLayer));
4848
......@@ -179,9 +179,9 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => {
179179 }),
180180 );
181181
182 it.effect("rejects cloud commands when public cloud configuration is missing", () =>
182 it.effect("rejects connect commands when public configuration is missing", () =>
183183 Effect.gen(function* () {
184 const error = yield* runCli(["cloud", "status"], noCloudCli).pipe(Effect.flip);
184 const error = yield* runCli(["connect", "status"], noConnectCli).pipe(Effect.flip);
185185
186186 if (!CliError.isCliError(error)) {
187187 assert.fail(`Expected CliError, got ${String(error)}`);
......@@ -189,20 +189,20 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => {
189189 if (error._tag !== "ShowHelp") {
190190 assert.fail(`Expected ShowHelp, got ${error._tag}`);
191191 }
192 assert.deepEqual(error.commandPath, ["t3", "cloud"]);
193 assert.include(error.errors[0]?.message ?? "", "missing T3 Cloud public configuration");
192 assert.deepEqual(error.commandPath, ["t3", "connect"]);
193 assert.include(error.errors[0]?.message ?? "", "missing T3 Connect public configuration");
194194
195195 const output = (yield* TestConsole.errorLines).join("\n");
196196 assert.include(output, "ERROR");
197 assert.include(output, "missing T3 Cloud public configuration");
197 assert.include(output, "missing T3 Connect public configuration");
198198 }).pipe(Effect.provide(Layer.mergeAll(CliRuntimeLayer, TestConsole.layer))),
199199 );
200200
201 it.effect("reports fresh headless cloud state without requiring local configuration", () =>
201 it.effect("reports fresh headless connect state without requiring local configuration", () =>
202202 Effect.gen(function* () {
203203 const baseDir = mkdtempSync(join(tmpdir(), "t3-cli-cloud-status-test-"));
204204 const { output } = yield* captureStdout(
205 runCloudCli(["cloud", "status", "--base-dir", baseDir, "--json"]),
205 runConnectCli(["connect", "status", "--base-dir", baseDir, "--json"]),
206206 );
207207 // @effect-diagnostics-next-line preferSchemaOverJson:off - CLI JSON output is decoded as a presentation DTO.
208208 const status = JSON.parse(output) as {
......@@ -221,21 +221,21 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => {
221221 }),
222222 );
223223
224 it.effect("reports actionable human-readable headless cloud state", () =>
224 it.effect("reports actionable human-readable headless connect state", () =>
225225 Effect.gen(function* () {
226226 const baseDir = mkdtempSync(join(tmpdir(), "t3-cli-cloud-status-human-test-"));
227227 const { output } = yield* captureStdout(
228 runCloudCli(["cloud", "status", "--base-dir", baseDir]),
228 runConnectCli(["connect", "status", "--base-dir", baseDir]),
229229 );
230230
231 assert.include(output, "T3 Cloud\n Exposure: disabled");
231 assert.include(output, "T3 Connect\n Exposure: disabled");
232232 assert.include(output, " Authorization: missing");
233233 assert.include(output, " Environment link: not provisioned");
234 assert.include(output, "Next: Run `t3 cloud link` to authorize and enable cloud exposure.");
234 assert.include(output, "Next: Run `t3 connect link` to authorize and enable T3 Connect.");
235235 }),
236236 );
237237
238 it.effect("logs in to headless cloud without enabling exposure", () =>
238 it.effect("logs in to headless connect without enabling access", () =>
239239 Effect.gen(function* () {
240240 const baseDir = mkdtempSync(join(tmpdir(), "t3-cli-cloud-login-test-"));
241241 const { secretsDir } = yield* deriveServerPaths(baseDir, undefined);
......@@ -250,9 +250,11 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => {
250250 }),
251251 );
252252
253 const login = yield* captureStdout(runCloudCli(["cloud", "login", "--base-dir", baseDir]));
253 const login = yield* captureStdout(
254 runConnectCli(["connect", "login", "--base-dir", baseDir]),
255 );
254256 const status = yield* captureStdout(
255 runCloudCli(["cloud", "status", "--base-dir", baseDir, "--json"]),
257 runConnectCli(["connect", "status", "--base-dir", baseDir, "--json"]),
256258 );
257259 // @effect-diagnostics-next-line preferSchemaOverJson:off - CLI JSON output is decoded as a presentation DTO.
258260 const decoded = JSON.parse(status.output) as {
......@@ -260,24 +262,24 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => {
260262 readonly authenticated: boolean;
261263 };
262264
263 assert.equal(login.output, "Signed in to T3 Cloud.");
265 assert.equal(login.output, "Signed in to T3 Connect.");
264266 assert.isFalse(decoded.desired);
265267 assert.isTrue(decoded.authenticated);
266268 }),
267269 );
268270
269 it.effect("disables headless cloud exposure without a running server", () =>
271 it.effect("disables headless connect without a running server", () =>
270272 Effect.gen(function* () {
271273 const baseDir = mkdtempSync(join(tmpdir(), "t3-cli-cloud-unlink-test-"));
272274 const { output } = yield* captureStdout(
273 runCloudCli(["cloud", "unlink", "--base-dir", baseDir]),
275 runConnectCli(["connect", "unlink", "--base-dir", baseDir]),
274276 );
275277
276 assert.equal(output, "T3 Cloud exposure is disabled locally.");
278 assert.equal(output, "T3 Connect is disabled locally.");
277279 }),
278280 );
279281
280 it.effect("logs out of headless cloud and removes the stored CLI authorization", () =>
282 it.effect("logs out of headless connect and removes the stored CLI authorization", () =>
281283 Effect.gen(function* () {
282284 const baseDir = mkdtempSync(join(tmpdir(), "t3-cli-cloud-logout-test-"));
283285 const { secretsDir } = yield* deriveServerPaths(baseDir, undefined);
......@@ -286,10 +288,10 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => {
286288 writeFileSync(tokenPath, "invalid persisted token");
287289
288290 const { output } = yield* captureStdout(
289 runCloudCli(["cloud", "logout", "--base-dir", baseDir]),
291 runConnectCli(["connect", "logout", "--base-dir", baseDir]),
290292 );
291293
292 assert.equal(output, "Signed out of T3 Cloud locally.");
294 assert.equal(output, "Signed out of T3 Connect locally.");
293295 assert.isFalse(existsSync(tokenPath));
294296 }),
295297 );
apps/server/src/bin.ts+10-10
......@@ -8,7 +8,7 @@ import * as CliError from "effect/unstable/cli/CliError";
88import * as NetService from "@t3tools/shared/Net";
99import packageJson from "../package.json" with { type: "json" };
1010import { authCommand } from "./cli/auth.ts";
11import { cloudCommand } from "./cli/cloud.ts";
11import { connectCommand } from "./cli/connect.ts";
1212import { hasCloudPublicConfig } from "./cloud/publicConfig.ts";
1313import { sharedServerCommandFlags } from "./cli/config.ts";
1414import { projectCommand } from "./cli/project.ts";
......@@ -16,23 +16,23 @@ import { runServerCommand, serveCommand, startCommand } from "./cli/server.ts";
1616
1717const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer);
1818
19const cloudPublicConfigMissingMessage =
20 "T3 Cloud commands are unavailable: this build is missing T3 Cloud public configuration.";
19const connectPublicConfigMissingMessage =
20 "T3 Connect commands are unavailable: this build is missing T3 Connect public configuration.";
2121
22class CloudPublicConfigMissingError extends CliError.UserError {
22class ConnectPublicConfigMissingError extends CliError.UserError {
2323 override get message() {
24 return cloudPublicConfigMissingMessage;
24 return connectPublicConfigMissingMessage;
2525 }
2626}
2727
28const cloudUnavailableCommand = Command.make("cloud").pipe(
29 Command.withDescription("T3 Cloud is unavailable in builds without public cloud configuration."),
28const connectUnavailableCommand = Command.make("connect").pipe(
29 Command.withDescription("T3 Connect is unavailable in builds without public configuration."),
3030 Command.withHidden,
3131 Command.withHandler(() =>
3232 Effect.fail(
3333 new CliError.ShowHelp({
34 commandPath: ["t3", "cloud"],
35 errors: [new CloudPublicConfigMissingError({ cause: cloudPublicConfigMissingMessage })],
34 commandPath: ["t3", "connect"],
35 errors: [new ConnectPublicConfigMissingError({ cause: connectPublicConfigMissingMessage })],
3636 }),
3737 ),
3838 ),
......@@ -47,7 +47,7 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) =>
4747 serveCommand,
4848 authCommand,
4949 projectCommand,
50 cloudEnabled ? cloudCommand : cloudUnavailableCommand,
50 cloudEnabled ? connectCommand : connectUnavailableCommand,
5151 ]),
5252 );
5353
apps/server/src/cli/cloud.test.ts deleted-102
......@@ -1,102 +0,0 @@
1import * as RelayClient from "@t3tools/shared/relayClient";
2import { assert, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as Option from "effect/Option";
5
6import { acquireRelayClientForLink } from "./cloud.ts";
7
8const managedExecutable = {
9 status: "available",
10 executablePath: "/tmp/cloudflared",
11 source: "managed",
12 version: RelayClient.CLOUDFLARED_VERSION,
13} as const;
14
15it.effect("does not install the relay client when the user declines the managed download", () =>
16 Effect.gen(function* () {
17 let installCalls = 0;
18 const result = yield* acquireRelayClientForLink(
19 {
20 resolve: Effect.succeed({
21 status: "missing",
22 version: RelayClient.CLOUDFLARED_VERSION,
23 }),
24 install: Effect.sync(() => {
25 installCalls += 1;
26 return managedExecutable;
27 }),
28 installWithProgress: () =>
29 Effect.sync(() => {
30 installCalls += 1;
31 return managedExecutable;
32 }),
33 },
34 () => Effect.succeed(false),
35 () => Effect.void,
36 );
37
38 assert.isTrue(Option.isNone(result));
39 assert.equal(installCalls, 0);
40 }),
41);
42
43it.effect("installs the relay client after the user accepts the managed download", () =>
44 Effect.gen(function* () {
45 let installCalls = 0;
46 const progress: Array<string> = [];
47 const result = yield* acquireRelayClientForLink(
48 {
49 resolve: Effect.succeed({
50 status: "missing",
51 version: RelayClient.CLOUDFLARED_VERSION,
52 }),
53 install: Effect.sync(() => {
54 installCalls += 1;
55 return managedExecutable;
56 }),
57 installWithProgress: (report) =>
58 report({ type: "progress", stage: "downloading" }).pipe(
59 Effect.andThen(
60 Effect.sync(() => {
61 installCalls += 1;
62 return managedExecutable;
63 }),
64 ),
65 ),
66 },
67 () => Effect.succeed(true),
68 (event) =>
69 Effect.sync(() => {
70 if (event.type === "progress") {
71 progress.push(event.stage);
72 }
73 }),
74 );
75
76 assert.deepEqual(Option.getOrThrow(result), managedExecutable);
77 assert.equal(installCalls, 1);
78 assert.deepEqual(progress, ["downloading"]);
79 }),
80);
81
82it.effect("reuses an available relay client executable without prompting", () =>
83 Effect.gen(function* () {
84 let promptCalls = 0;
85 const result = yield* acquireRelayClientForLink(
86 {
87 resolve: Effect.succeed(managedExecutable),
88 install: Effect.die("unexpected install"),
89 installWithProgress: () => Effect.die("unexpected install"),
90 },
91 () =>
92 Effect.sync(() => {
93 promptCalls += 1;
94 return false;
95 }),
96 () => Effect.void,
97 );
98
99 assert.deepEqual(Option.getOrThrow(result), managedExecutable);
100 assert.equal(promptCalls, 0);
101 }),
102);
apps/server/src/cli/cloud.ts deleted-425
......@@ -1,425 +0,0 @@
1import {
2 AuthRelayWriteScope,
3 EnvironmentHttpApi,
4 type RelayClientInstallProgressEvent,
5 type RelayClientInstallProgressStage,
6} from "@t3tools/contracts";
7import { RelayOkResponse } from "@t3tools/contracts/relay";
8import * as RelayClient from "@t3tools/shared/relayClient";
9import * as Console from "effect/Console";
10import * as Duration from "effect/Duration";
11import * as Effect from "effect/Effect";
12import * as Exit from "effect/Exit";
13import * as FileSystem from "effect/FileSystem";
14import * as Layer from "effect/Layer";
15import * as Option from "effect/Option";
16import * as References from "effect/References";
17import { Command, Flag, GlobalFlag, Prompt } from "effect/unstable/cli";
18import {
19 FetchHttpClient,
20 HttpClient,
21 HttpClientRequest,
22 HttpClientResponse,
23} from "effect/unstable/http";
24import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient";
25
26import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";
27import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
28import * as CliState from "../cloud/CliState.ts";
29import * as CliTokenManager from "../cloud/CliTokenManager.ts";
30import { CLOUD_LINKED_USER_ID, RELAY_URL_SECRET } from "../cloud/config.ts";
31import { relayUrlConfig } from "../cloud/publicConfig.ts";
32import { ServerConfig } from "../config.ts";
33import { ServerEnvironmentLive } from "../environment/Layers/ServerEnvironment.ts";
34import { ServerEnvironment } from "../environment/Services/ServerEnvironment.ts";
35import { readPersistedServerRuntimeState } from "../serverRuntimeState.ts";
36import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts";
37
38const jsonFlag = Flag.boolean("json").pipe(
39 Flag.withDescription("Emit JSON instead of human-readable output."),
40 Flag.withDefault(false),
41);
42
43function bytesToString(value: Uint8Array): string {
44 return new TextDecoder().decode(value);
45}
46
47interface CloudCliStatus {
48 readonly desired: boolean;
49 readonly authenticated: boolean;
50 readonly linked: boolean;
51 readonly cloudUserId: string | null;
52 readonly relayUrl: string | null;
53 readonly relayClient: RelayClient.RelayClientStatus;
54}
55
56function formatRelayClientStatus(executable: RelayClient.RelayClientStatus): ReadonlyArray<string> {
57 switch (executable.status) {
58 case "available": {
59 const source =
60 executable.source === "path"
61 ? "PATH"
62 : executable.source === "managed"
63 ? "managed install"
64 : "configured override";
65 return [
66 ` Relay client: available via ${source}`,
67 ` Path: ${executable.executablePath}`,
68 ` Version: ${executable.version}`,
69 ];
70 }
71 case "missing":
72 return [" Relay client: not installed"];
73 case "unsupported":
74 return [
75 ` Relay client: unsupported on ${executable.platform}-${executable.arch}`,
76 ` Managed version: ${executable.version}`,
77 ];
78 }
79}
80
81function formatCloudStatus(status: CloudCliStatus, options?: { readonly json?: boolean }): string {
82 if (options?.json) {
83 return JSON.stringify(status, null, 2);
84 }
85
86 const provisioned = status.linked
87 ? "provisioned"
88 : status.desired && status.authenticated
89 ? "pending server startup"
90 : "not provisioned";
91 const nextStep = !status.authenticated
92 ? "Run `t3 cloud link` to authorize and enable cloud exposure."
93 : !status.desired
94 ? "Run `t3 cloud link` to enable cloud exposure."
95 : !status.linked
96 ? "Start T3 to provision the environment link and launch its managed tunnel."
97 : undefined;
98
99 return [
100 "T3 Cloud",
101 ` Exposure: ${status.desired ? "enabled" : "disabled"}`,
102 ` Authorization: ${status.authenticated ? "stored credential" : "missing"}`,
103 ` Environment link: ${provisioned}`,
104 ` Relay: ${status.relayUrl ?? "not provisioned"}`,
105 ...formatRelayClientStatus(status.relayClient),
106 ...(nextStep ? ["", `Next: ${nextStep}`] : []),
107 ].join("\n");
108}
109
110const CLOUD_CLI_LIVE_SERVER_TIMEOUT = Duration.seconds(5);
111
112const confirmRelayClientInstall = (version: string) =>
113 Prompt.run(
114 Prompt.confirm({
115 message: `The T3 relay client is required for T3 Cloud exposure. Download and install version ${version}?`,
116 initial: false,
117 }),
118 );
119
120function relayClientInstallProgressMessage(stage: RelayClientInstallProgressStage): string {
121 switch (stage) {
122 case "checking":
123 return "Checking existing installation";
124 case "waiting_for_lock":
125 return "Waiting for installation lock";
126 case "downloading":
127 return "Downloading";
128 case "verifying":
129 return "Verifying download";
130 case "installing":
131 return "Installing";
132 case "validating":
133 return "Validating executable";
134 case "activating":
135 return "Activating installation";
136 }
137}
138
139const reportRelayClientInstallProgress = (event: RelayClientInstallProgressEvent) =>
140 event.type === "progress"
141 ? Console.log(`Relay client: ${relayClientInstallProgressMessage(event.stage)}...`)
142 : Effect.void;
143
144export const acquireRelayClientForLink = Effect.fn("cloud.cli.acquire_relay_client_for_link")(
145 function* <ConfirmError, ConfirmContext>(
146 relayClient: RelayClient.RelayClientShape,
147 confirmInstall: (version: string) => Effect.Effect<boolean, ConfirmError, ConfirmContext>,
148 reportProgress: (event: RelayClientInstallProgressEvent) => Effect.Effect<void>,
149 ) {
150 const executable = yield* relayClient.resolve;
151 if (executable.status === "available") {
152 return Option.some(executable);
153 }
154 if (executable.status === "unsupported") {
155 return Option.some(yield* relayClient.installWithProgress(reportProgress));
156 }
157 if (!(yield* confirmInstall(executable.version))) {
158 return Option.none();
159 }
160 return Option.some(yield* relayClient.installWithProgress(reportProgress));
161 },
162);
163
164const withCloudCliSessionToken = <A, E, R>(
165 environmentAuth: EnvironmentAuth.EnvironmentAuthShape,
166 run: (token: string) => Effect.Effect<A, E, R>,
167) =>
168 Effect.acquireUseRelease(
169 environmentAuth.issueSession({
170 scopes: [AuthRelayWriteScope],
171 subject: "cloud-cli",
172 label: "t3 cloud cli",
173 }),
174 (issued) => run(issued.token),
175 (issued) => environmentAuth.revokeSession(issued.sessionId).pipe(Effect.ignore({ log: true })),
176 );
177
178type LiveCloudActionResult =
179 | { readonly status: "not-running" }
180 | { readonly status: "succeeded" }
181 | { readonly status: "failed"; readonly cause: unknown };
182
183const runLiveCloudUnlink = Effect.fn("cloud.cli.run_live_unlink")(function* () {
184 const config = yield* ServerConfig;
185 const runtimeState = yield* readPersistedServerRuntimeState(config.serverRuntimeStatePath);
186 if (Option.isNone(runtimeState)) {
187 return { status: "not-running" } satisfies LiveCloudActionResult;
188 }
189
190 const environmentAuth = yield* EnvironmentAuth.EnvironmentAuth;
191 const result = yield* Effect.exit(
192 withCloudCliSessionToken(environmentAuth, (token) =>
193 HttpApiClient.make(EnvironmentHttpApi, {
194 baseUrl: runtimeState.value.origin,
195 }).pipe(
196 Effect.flatMap((client) =>
197 client.cloud.unlink({ headers: { authorization: `Bearer ${token}` } }),
198 ),
199 Effect.timeout(CLOUD_CLI_LIVE_SERVER_TIMEOUT),
200 ),
201 ),
202 );
203 return Exit.isSuccess(result)
204 ? ({ status: "succeeded" } satisfies LiveCloudActionResult)
205 : ({ status: "failed", cause: result.cause } satisfies LiveCloudActionResult);
206});
207
208type RelayUnlinkResult =
209 | { readonly status: "not-authenticated" }
210 | { readonly status: "revoked" }
211 | { readonly status: "not-linked" };
212
213const unlinkRelayEnvironment = Effect.fn("cloud.cli.unlink_relay_environment")(function* () {
214 const tokens = yield* CliTokenManager.CloudCliTokenManager;
215 const token = yield* tokens.getExisting;
216 if (Option.isNone(token)) {
217 return { status: "not-authenticated" } satisfies RelayUnlinkResult;
218 }
219
220 const environment = yield* ServerEnvironment;
221 const environmentId = yield* environment.getEnvironmentId;
222 const relayUrl = yield* relayUrlConfig;
223 const httpClient = yield* HttpClient.HttpClient;
224 const response = yield* HttpClientRequest.delete(
225 `${relayUrl}/v1/client/environment-links/${encodeURIComponent(environmentId)}`,
226 ).pipe(
227 HttpClientRequest.bearerToken(token.value.accessToken),
228 httpClient.execute,
229 Effect.flatMap(HttpClientResponse.filterStatusOk),
230 Effect.flatMap(HttpClientResponse.schemaBodyJson(RelayOkResponse)),
231 );
232 return response.ok
233 ? ({ status: "revoked" } satisfies RelayUnlinkResult)
234 : ({ status: "not-linked" } satisfies RelayUnlinkResult);
235});
236
237const disconnectCloud = Effect.fn("cloud.cli.disconnect")(function* (options: {
238 readonly clearAuthorization: boolean;
239}) {
240 yield* CliState.setCliDesiredCloudLink(false);
241 const liveResult = yield* runLiveCloudUnlink();
242 const relayResult = yield* Effect.exit(unlinkRelayEnvironment());
243 yield* CliState.clearPersistedCloudLink;
244
245 if (options.clearAuthorization) {
246 const tokens = yield* CliTokenManager.CloudCliTokenManager;
247 yield* tokens.clear;
248 }
249
250 if (liveResult.status === "failed") {
251 yield* Console.warn(
252 `T3 Cloud exposure is disabled, but the running server could not stop its tunnel: ${String(liveResult.cause)}\nRestart that server to stop the connector.`,
253 );
254 } else {
255 yield* Console.log("T3 Cloud exposure is disabled locally.");
256 }
257
258 if (Exit.isFailure(relayResult)) {
259 yield* Console.warn(
260 options.clearAuthorization
261 ? `Could not revoke the relay-side environment record before signing out: ${String(relayResult.cause)}\nThe stored CLI authorization was still removed locally.`
262 : `Could not revoke the relay-side environment record yet: ${String(relayResult.cause)}\nRun \`t3 cloud unlink\` again when the relay is reachable.`,
263 );
264 } else if (relayResult.value.status === "revoked") {
265 yield* Console.log("Revoked the relay-side environment record.");
266 }
267
268 if (options.clearAuthorization) {
269 yield* Console.log("Signed out of T3 Cloud locally.");
270 }
271});
272
273const runCloudCommand = <A, E>(
274 flags: { readonly baseDir: Option.Option<string> },
275 run: Effect.Effect<
276 A,
277 E,
278 | ServerSecretStore.ServerSecretStore
279 | CliTokenManager.CloudCliTokenManager
280 | RelayClient.RelayClient
281 | EnvironmentAuth.EnvironmentAuth
282 | FileSystem.FileSystem
283 | HttpClient.HttpClient
284 | Prompt.Environment
285 | ServerConfig
286 | ServerEnvironment
287 >,
288 options?: {
289 readonly quietLogs?: boolean;
290 },
291) =>
292 Effect.gen(function* () {
293 const logLevel = yield* GlobalFlag.LogLevel;
294 const config = yield* resolveCliAuthConfig(flags, logLevel);
295 const minimumLogLevel = options?.quietLogs ? "Error" : config.logLevel;
296 const runtimeLayer = Layer.mergeAll(
297 ServerSecretStore.layer,
298 CliTokenManager.layer.pipe(Layer.provide(ServerSecretStore.layer)),
299 RelayClient.layerCloudflared({ baseDir: config.baseDir }),
300 EnvironmentAuth.runtimeLayer,
301 ServerEnvironmentLive,
302 ).pipe(
303 Layer.provideMerge(FetchHttpClient.layer),
304 Layer.provideMerge(Layer.succeed(ServerConfig, config)),
305 Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)),
306 );
307 return yield* run.pipe(Effect.provide(runtimeLayer));
308 });
309
310const cloudLoginCommand = Command.make("login", {
311 ...projectLocationFlags,
312}).pipe(
313 Command.withDescription("Authorize the T3 Cloud CLI without enabling cloud exposure."),
314 Command.withHandler((flags) =>
315 runCloudCommand(
316 flags,
317 Effect.gen(function* () {
318 const tokens = yield* CliTokenManager.CloudCliTokenManager;
319 yield* tokens.get;
320 yield* Console.log("Signed in to T3 Cloud.");
321 }),
322 ),
323 ),
324);
325
326const cloudLinkCommand = Command.make("link", {
327 ...projectLocationFlags,
328}).pipe(
329 Command.withDescription("Authorize this environment for T3 Cloud and expose it on next start."),
330 Command.withHandler((flags) =>
331 runCloudCommand(
332 flags,
333 Effect.gen(function* () {
334 const relayClient = yield* RelayClient.RelayClient;
335 const installed = yield* acquireRelayClientForLink(
336 relayClient,
337 confirmRelayClientInstall,
338 reportRelayClientInstallProgress,
339 );
340 if (Option.isNone(installed)) {
341 yield* Console.log("T3 Cloud link cancelled. The relay client was not installed.");
342 return;
343 }
344 yield* Console.log(
345 `Using relay client ${installed.value.version} from ${installed.value.executablePath}.`,
346 );
347
348 const tokens = yield* CliTokenManager.CloudCliTokenManager;
349 yield* tokens.get;
350 yield* CliState.setCliDesiredCloudLink(true);
351 yield* Console.log(
352 "This T3 environment will be available over T3 Cloud the next time T3 starts.",
353 );
354 }),
355 ),
356 ),
357);
358
359const cloudStatusCommand = Command.make("status", {
360 ...projectLocationFlags,
361 json: jsonFlag,
362}).pipe(
363 Command.withDescription("Show persisted T3 Cloud and relay client state."),
364 Command.withHandler((flags) =>
365 runCloudCommand(
366 flags,
367 Effect.gen(function* () {
368 const secrets = yield* ServerSecretStore.ServerSecretStore;
369 const relayClient = yield* RelayClient.RelayClient;
370 const tokens = yield* CliTokenManager.CloudCliTokenManager;
371 const [desired, authenticated, cloudUserId, relayUrl, executable] = yield* Effect.all(
372 [
373 CliState.readCliDesiredCloudLink,
374 tokens.hasCredential,
375 secrets.get(CLOUD_LINKED_USER_ID),
376 secrets.get(RELAY_URL_SECRET),
377 relayClient.resolve,
378 ],
379 { concurrency: "unbounded" },
380 );
381 const status: CloudCliStatus = {
382 desired,
383 authenticated,
384 linked: cloudUserId !== null,
385 cloudUserId: cloudUserId ? bytesToString(cloudUserId) : null,
386 relayUrl: relayUrl ? bytesToString(relayUrl) : null,
387 relayClient: executable,
388 };
389 yield* Console.log(formatCloudStatus(status, { json: flags.json }));
390 }),
391 {
392 quietLogs: flags.json,
393 },
394 ),
395 ),
396);
397
398const cloudUnlinkCommand = Command.make("unlink", {
399 ...projectLocationFlags,
400}).pipe(
401 Command.withDescription("Disable T3 Cloud exposure while retaining the stored authorization."),
402 Command.withHandler((flags) =>
403 runCloudCommand(flags, disconnectCloud({ clearAuthorization: false })),
404 ),
405);
406
407const cloudLogoutCommand = Command.make("logout", {
408 ...projectLocationFlags,
409}).pipe(
410 Command.withDescription("Disable T3 Cloud exposure and clear the stored CLI authorization."),
411 Command.withHandler((flags) =>
412 runCloudCommand(flags, disconnectCloud({ clearAuthorization: true })),
413 ),
414);
415
416export const cloudCommand = Command.make("cloud").pipe(
417 Command.withDescription("Manage headless T3 Cloud exposure."),
418 Command.withSubcommands([
419 cloudLoginCommand,
420 cloudLinkCommand,
421 cloudStatusCommand,
422 cloudUnlinkCommand,
423 cloudLogoutCommand,
424 ]),
425);
apps/server/src/cli/connect.test.ts created+102
......@@ -0,0 +1,102 @@
1import * as RelayClient from "@t3tools/shared/relayClient";
2import { assert, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as Option from "effect/Option";
5
6import { acquireRelayClientForLink } from "./connect.ts";
7
8const managedExecutable = {
9 status: "available",
10 executablePath: "/tmp/cloudflared",
11 source: "managed",
12 version: RelayClient.CLOUDFLARED_VERSION,
13} as const;
14
15it.effect("does not install the relay client when the user declines the managed download", () =>
16 Effect.gen(function* () {
17 let installCalls = 0;
18 const result = yield* acquireRelayClientForLink(
19 {
20 resolve: Effect.succeed({
21 status: "missing",
22 version: RelayClient.CLOUDFLARED_VERSION,
23 }),
24 install: Effect.sync(() => {
25 installCalls += 1;
26 return managedExecutable;
27 }),
28 installWithProgress: () =>
29 Effect.sync(() => {
30 installCalls += 1;
31 return managedExecutable;
32 }),
33 },
34 () => Effect.succeed(false),
35 () => Effect.void,
36 );
37
38 assert.isTrue(Option.isNone(result));
39 assert.equal(installCalls, 0);
40 }),
41);
42
43it.effect("installs the relay client after the user accepts the managed download", () =>
44 Effect.gen(function* () {
45 let installCalls = 0;
46 const progress: Array<string> = [];
47 const result = yield* acquireRelayClientForLink(
48 {
49 resolve: Effect.succeed({
50 status: "missing",
51 version: RelayClient.CLOUDFLARED_VERSION,
52 }),
53 install: Effect.sync(() => {
54 installCalls += 1;
55 return managedExecutable;
56 }),
57 installWithProgress: (report) =>
58 report({ type: "progress", stage: "downloading" }).pipe(
59 Effect.andThen(
60 Effect.sync(() => {
61 installCalls += 1;
62 return managedExecutable;
63 }),
64 ),
65 ),
66 },
67 () => Effect.succeed(true),
68 (event) =>
69 Effect.sync(() => {
70 if (event.type === "progress") {
71 progress.push(event.stage);
72 }
73 }),
74 );
75
76 assert.deepEqual(Option.getOrThrow(result), managedExecutable);
77 assert.equal(installCalls, 1);
78 assert.deepEqual(progress, ["downloading"]);
79 }),
80);
81
82it.effect("reuses an available relay client executable without prompting", () =>
83 Effect.gen(function* () {
84 let promptCalls = 0;
85 const result = yield* acquireRelayClientForLink(
86 {
87 resolve: Effect.succeed(managedExecutable),
88 install: Effect.die("unexpected install"),
89 installWithProgress: () => Effect.die("unexpected install"),
90 },
91 () =>
92 Effect.sync(() => {
93 promptCalls += 1;
94 return false;
95 }),
96 () => Effect.void,
97 );
98
99 assert.deepEqual(Option.getOrThrow(result), managedExecutable);
100 assert.equal(promptCalls, 0);
101 }),
102);
apps/server/src/cli/connect.ts created+425
......@@ -0,0 +1,425 @@
1import {
2 AuthRelayWriteScope,
3 EnvironmentHttpApi,
4 type RelayClientInstallProgressEvent,
5 type RelayClientInstallProgressStage,
6} from "@t3tools/contracts";
7import { RelayOkResponse } from "@t3tools/contracts/relay";
8import * as RelayClient from "@t3tools/shared/relayClient";
9import * as Console from "effect/Console";
10import * as Duration from "effect/Duration";
11import * as Effect from "effect/Effect";
12import * as Exit from "effect/Exit";
13import * as FileSystem from "effect/FileSystem";
14import * as Layer from "effect/Layer";
15import * as Option from "effect/Option";
16import * as References from "effect/References";
17import { Command, Flag, GlobalFlag, Prompt } from "effect/unstable/cli";
18import {
19 FetchHttpClient,
20 HttpClient,
21 HttpClientRequest,
22 HttpClientResponse,
23} from "effect/unstable/http";
24import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient";
25
26import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";
27import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
28import * as CliState from "../cloud/CliState.ts";
29import * as CliTokenManager from "../cloud/CliTokenManager.ts";
30import { CLOUD_LINKED_USER_ID, RELAY_URL_SECRET } from "../cloud/config.ts";
31import { relayUrlConfig } from "../cloud/publicConfig.ts";
32import { ServerConfig } from "../config.ts";
33import { ServerEnvironmentLive } from "../environment/Layers/ServerEnvironment.ts";
34import { ServerEnvironment } from "../environment/Services/ServerEnvironment.ts";
35import { readPersistedServerRuntimeState } from "../serverRuntimeState.ts";
36import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts";
37
38const jsonFlag = Flag.boolean("json").pipe(
39 Flag.withDescription("Emit JSON instead of human-readable output."),
40 Flag.withDefault(false),
41);
42
43function bytesToString(value: Uint8Array): string {
44 return new TextDecoder().decode(value);
45}
46
47interface CloudCliStatus {
48 readonly desired: boolean;
49 readonly authenticated: boolean;
50 readonly linked: boolean;
51 readonly cloudUserId: string | null;
52 readonly relayUrl: string | null;
53 readonly relayClient: RelayClient.RelayClientStatus;
54}
55
56function formatRelayClientStatus(executable: RelayClient.RelayClientStatus): ReadonlyArray<string> {
57 switch (executable.status) {
58 case "available": {
59 const source =
60 executable.source === "path"
61 ? "PATH"
62 : executable.source === "managed"
63 ? "managed install"
64 : "configured override";
65 return [
66 ` Relay client: available via ${source}`,
67 ` Path: ${executable.executablePath}`,
68 ` Version: ${executable.version}`,
69 ];
70 }
71 case "missing":
72 return [" Relay client: not installed"];
73 case "unsupported":
74 return [
75 ` Relay client: unsupported on ${executable.platform}-${executable.arch}`,
76 ` Managed version: ${executable.version}`,
77 ];
78 }
79}
80
81function formatCloudStatus(status: CloudCliStatus, options?: { readonly json?: boolean }): string {
82 if (options?.json) {
83 return JSON.stringify(status, null, 2);
84 }
85
86 const provisioned = status.linked
87 ? "provisioned"
88 : status.desired && status.authenticated
89 ? "pending server startup"
90 : "not provisioned";
91 const nextStep = !status.authenticated
92 ? "Run `t3 connect link` to authorize and enable T3 Connect."
93 : !status.desired
94 ? "Run `t3 connect link` to enable T3 Connect."
95 : !status.linked
96 ? "Start T3 to provision the environment link and launch its managed tunnel."
97 : undefined;
98
99 return [
100 "T3 Connect",
101 ` Exposure: ${status.desired ? "enabled" : "disabled"}`,
102 ` Authorization: ${status.authenticated ? "stored credential" : "missing"}`,
103 ` Environment link: ${provisioned}`,
104 ` Relay: ${status.relayUrl ?? "not provisioned"}`,
105 ...formatRelayClientStatus(status.relayClient),
106 ...(nextStep ? ["", `Next: ${nextStep}`] : []),
107 ].join("\n");
108}
109
110const CLOUD_CLI_LIVE_SERVER_TIMEOUT = Duration.seconds(5);
111
112const confirmRelayClientInstall = (version: string) =>
113 Prompt.run(
114 Prompt.confirm({
115 message: `The T3 relay client is required for T3 Connect. Download and install version ${version}?`,
116 initial: false,
117 }),
118 );
119
120function relayClientInstallProgressMessage(stage: RelayClientInstallProgressStage): string {
121 switch (stage) {
122 case "checking":
123 return "Checking existing installation";
124 case "waiting_for_lock":
125 return "Waiting for installation lock";
126 case "downloading":
127 return "Downloading";
128 case "verifying":
129 return "Verifying download";
130 case "installing":
131 return "Installing";
132 case "validating":
133 return "Validating executable";
134 case "activating":
135 return "Activating installation";
136 }
137}
138
139const reportRelayClientInstallProgress = (event: RelayClientInstallProgressEvent) =>
140 event.type === "progress"
141 ? Console.log(`Relay client: ${relayClientInstallProgressMessage(event.stage)}...`)
142 : Effect.void;
143
144export const acquireRelayClientForLink = Effect.fn("cloud.cli.acquire_relay_client_for_link")(
145 function* <ConfirmError, ConfirmContext>(
146 relayClient: RelayClient.RelayClientShape,
147 confirmInstall: (version: string) => Effect.Effect<boolean, ConfirmError, ConfirmContext>,
148 reportProgress: (event: RelayClientInstallProgressEvent) => Effect.Effect<void>,
149 ) {
150 const executable = yield* relayClient.resolve;
151 if (executable.status === "available") {
152 return Option.some(executable);
153 }
154 if (executable.status === "unsupported") {
155 return Option.some(yield* relayClient.installWithProgress(reportProgress));
156 }
157 if (!(yield* confirmInstall(executable.version))) {
158 return Option.none();
159 }
160 return Option.some(yield* relayClient.installWithProgress(reportProgress));
161 },
162);
163
164const withCloudCliSessionToken = <A, E, R>(
165 environmentAuth: EnvironmentAuth.EnvironmentAuthShape,
166 run: (token: string) => Effect.Effect<A, E, R>,
167) =>
168 Effect.acquireUseRelease(
169 environmentAuth.issueSession({
170 scopes: [AuthRelayWriteScope],
171 subject: "cloud-cli",
172 label: "t3 connect cli",
173 }),
174 (issued) => run(issued.token),
175 (issued) => environmentAuth.revokeSession(issued.sessionId).pipe(Effect.ignore({ log: true })),
176 );
177
178type LiveCloudActionResult =
179 | { readonly status: "not-running" }
180 | { readonly status: "succeeded" }
181 | { readonly status: "failed"; readonly cause: unknown };
182
183const runLiveCloudUnlink = Effect.fn("cloud.cli.run_live_unlink")(function* () {
184 const config = yield* ServerConfig;
185 const runtimeState = yield* readPersistedServerRuntimeState(config.serverRuntimeStatePath);
186 if (Option.isNone(runtimeState)) {
187 return { status: "not-running" } satisfies LiveCloudActionResult;
188 }
189
190 const environmentAuth = yield* EnvironmentAuth.EnvironmentAuth;
191 const result = yield* Effect.exit(
192 withCloudCliSessionToken(environmentAuth, (token) =>
193 HttpApiClient.make(EnvironmentHttpApi, {
194 baseUrl: runtimeState.value.origin,
195 }).pipe(
196 Effect.flatMap((client) =>
197 client.connect.unlink({ headers: { authorization: `Bearer ${token}` } }),
198 ),
199 Effect.timeout(CLOUD_CLI_LIVE_SERVER_TIMEOUT),
200 ),
201 ),
202 );
203 return Exit.isSuccess(result)
204 ? ({ status: "succeeded" } satisfies LiveCloudActionResult)
205 : ({ status: "failed", cause: result.cause } satisfies LiveCloudActionResult);
206});
207
208type RelayUnlinkResult =
209 | { readonly status: "not-authenticated" }
210 | { readonly status: "revoked" }
211 | { readonly status: "not-linked" };
212
213const unlinkRelayEnvironment = Effect.fn("cloud.cli.unlink_relay_environment")(function* () {
214 const tokens = yield* CliTokenManager.CloudCliTokenManager;
215 const token = yield* tokens.getExisting;
216 if (Option.isNone(token)) {
217 return { status: "not-authenticated" } satisfies RelayUnlinkResult;
218 }
219
220 const environment = yield* ServerEnvironment;
221 const environmentId = yield* environment.getEnvironmentId;
222 const relayUrl = yield* relayUrlConfig;
223 const httpClient = yield* HttpClient.HttpClient;
224 const response = yield* HttpClientRequest.delete(
225 `${relayUrl}/v1/client/environment-links/${encodeURIComponent(environmentId)}`,
226 ).pipe(
227 HttpClientRequest.bearerToken(token.value.accessToken),
228 httpClient.execute,
229 Effect.flatMap(HttpClientResponse.filterStatusOk),
230 Effect.flatMap(HttpClientResponse.schemaBodyJson(RelayOkResponse)),
231 );
232 return response.ok
233 ? ({ status: "revoked" } satisfies RelayUnlinkResult)
234 : ({ status: "not-linked" } satisfies RelayUnlinkResult);
235});
236
237const disconnectCloud = Effect.fn("cloud.cli.disconnect")(function* (options: {
238 readonly clearAuthorization: boolean;
239}) {
240 yield* CliState.setCliDesiredCloudLink(false);
241 const liveResult = yield* runLiveCloudUnlink();
242 const relayResult = yield* Effect.exit(unlinkRelayEnvironment());
243 yield* CliState.clearPersistedCloudLink;
244
245 if (options.clearAuthorization) {
246 const tokens = yield* CliTokenManager.CloudCliTokenManager;
247 yield* tokens.clear;
248 }
249
250 if (liveResult.status === "failed") {
251 yield* Console.warn(
252 `T3 Connect is disabled, but the running server could not stop its tunnel: ${String(liveResult.cause)}\nRestart that server to stop the connector.`,
253 );
254 } else {
255 yield* Console.log("T3 Connect is disabled locally.");
256 }
257
258 if (Exit.isFailure(relayResult)) {
259 yield* Console.warn(
260 options.clearAuthorization
261 ? `Could not revoke the relay-side environment record before signing out: ${String(relayResult.cause)}\nThe stored CLI authorization was still removed locally.`
262 : `Could not revoke the relay-side environment record yet: ${String(relayResult.cause)}\nRun \`t3 connect unlink\` again when the relay is reachable.`,
263 );
264 } else if (relayResult.value.status === "revoked") {
265 yield* Console.log("Revoked the relay-side environment record.");
266 }
267
268 if (options.clearAuthorization) {
269 yield* Console.log("Signed out of T3 Connect locally.");
270 }
271});
272
273const runCloudCommand = <A, E>(
274 flags: { readonly baseDir: Option.Option<string> },
275 run: Effect.Effect<
276 A,
277 E,
278 | ServerSecretStore.ServerSecretStore
279 | CliTokenManager.CloudCliTokenManager
280 | RelayClient.RelayClient
281 | EnvironmentAuth.EnvironmentAuth
282 | FileSystem.FileSystem
283 | HttpClient.HttpClient
284 | Prompt.Environment
285 | ServerConfig
286 | ServerEnvironment
287 >,
288 options?: {
289 readonly quietLogs?: boolean;
290 },
291) =>
292 Effect.gen(function* () {
293 const logLevel = yield* GlobalFlag.LogLevel;
294 const config = yield* resolveCliAuthConfig(flags, logLevel);
295 const minimumLogLevel = options?.quietLogs ? "Error" : config.logLevel;
296 const runtimeLayer = Layer.mergeAll(
297 ServerSecretStore.layer,
298 CliTokenManager.layer.pipe(Layer.provide(ServerSecretStore.layer)),
299 RelayClient.layerCloudflared({ baseDir: config.baseDir }),
300 EnvironmentAuth.runtimeLayer,
301 ServerEnvironmentLive,
302 ).pipe(
303 Layer.provideMerge(FetchHttpClient.layer),
304 Layer.provideMerge(Layer.succeed(ServerConfig, config)),
305 Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)),
306 );
307 return yield* run.pipe(Effect.provide(runtimeLayer));
308 });
309
310const connectLoginCommand = Command.make("login", {
311 ...projectLocationFlags,
312}).pipe(
313 Command.withDescription("Authorize the T3 Connect CLI without enabling remote access."),
314 Command.withHandler((flags) =>
315 runCloudCommand(
316 flags,
317 Effect.gen(function* () {
318 const tokens = yield* CliTokenManager.CloudCliTokenManager;
319 yield* tokens.get;
320 yield* Console.log("Signed in to T3 Connect.");
321 }),
322 ),
323 ),
324);
325
326const connectLinkCommand = Command.make("link", {
327 ...projectLocationFlags,
328}).pipe(
329 Command.withDescription("Authorize this environment for T3 Connect on next start."),
330 Command.withHandler((flags) =>
331 runCloudCommand(
332 flags,
333 Effect.gen(function* () {
334 const relayClient = yield* RelayClient.RelayClient;
335 const installed = yield* acquireRelayClientForLink(
336 relayClient,
337 confirmRelayClientInstall,
338 reportRelayClientInstallProgress,
339 );
340 if (Option.isNone(installed)) {
341 yield* Console.log("T3 Connect setup cancelled. The relay client was not installed.");
342 return;
343 }
344 yield* Console.log(
345 `Using relay client ${installed.value.version} from ${installed.value.executablePath}.`,
346 );
347
348 const tokens = yield* CliTokenManager.CloudCliTokenManager;
349 yield* tokens.get;
350 yield* CliState.setCliDesiredCloudLink(true);
351 yield* Console.log(
352 "This T3 environment will be available through T3 Connect the next time T3 starts.",
353 );
354 }),
355 ),
356 ),
357);
358
359const connectStatusCommand = Command.make("status", {
360 ...projectLocationFlags,
361 json: jsonFlag,
362}).pipe(
363 Command.withDescription("Show persisted T3 Connect and relay client state."),
364 Command.withHandler((flags) =>
365 runCloudCommand(
366 flags,
367 Effect.gen(function* () {
368 const secrets = yield* ServerSecretStore.ServerSecretStore;
369 const relayClient = yield* RelayClient.RelayClient;
370 const tokens = yield* CliTokenManager.CloudCliTokenManager;
371 const [desired, authenticated, cloudUserId, relayUrl, executable] = yield* Effect.all(
372 [
373 CliState.readCliDesiredCloudLink,
374 tokens.hasCredential,
375 secrets.get(CLOUD_LINKED_USER_ID),
376 secrets.get(RELAY_URL_SECRET),
377 relayClient.resolve,
378 ],
379 { concurrency: "unbounded" },
380 );
381 const status: CloudCliStatus = {
382 desired,
383 authenticated,
384 linked: cloudUserId !== null,
385 cloudUserId: cloudUserId ? bytesToString(cloudUserId) : null,
386 relayUrl: relayUrl ? bytesToString(relayUrl) : null,
387 relayClient: executable,
388 };
389 yield* Console.log(formatCloudStatus(status, { json: flags.json }));
390 }),
391 {
392 quietLogs: flags.json,
393 },
394 ),
395 ),
396);
397
398const connectUnlinkCommand = Command.make("unlink", {
399 ...projectLocationFlags,
400}).pipe(
401 Command.withDescription("Disable T3 Connect while retaining the stored authorization."),
402 Command.withHandler((flags) =>
403 runCloudCommand(flags, disconnectCloud({ clearAuthorization: false })),
404 ),
405);
406
407const connectLogoutCommand = Command.make("logout", {
408 ...projectLocationFlags,
409}).pipe(
410 Command.withDescription("Disable T3 Connect and clear the stored CLI authorization."),
411 Command.withHandler((flags) =>
412 runCloudCommand(flags, disconnectCloud({ clearAuthorization: true })),
413 ),
414);
415
416export const connectCommand = Command.make("connect").pipe(
417 Command.withDescription("Manage headless T3 Connect access."),
418 Command.withSubcommands([
419 connectLoginCommand,
420 connectLinkCommand,
421 connectStatusCommand,
422 connectUnlinkCommand,
423 connectLogoutCommand,
424 ]),
425);
apps/server/src/cloud/CliTokenManager.ts+8-8
......@@ -96,7 +96,7 @@ const make = Effect.gen(function* () {
9696
9797 const clear = secrets
9898 .remove(CLOUD_CLI_OAUTH_TOKEN_SECRET)
99 .pipe(wrapError("Could not remove the stored T3 Cloud CLI credential."));
99 .pipe(wrapError("Could not remove the stored T3 Connect CLI credential."));
100100
101101 const read = Effect.fn("cloud.cli_token.read")(function* () {
102102 const encoded = yield* secrets.get(CLOUD_CLI_OAUTH_TOKEN_SECRET);
......@@ -146,7 +146,7 @@ const make = Effect.gen(function* () {
146146 const url = new URL(request.originalUrl, metadata.redirectUri);
147147 const code = url.searchParams.get("code");
148148 if (url.searchParams.get("state") !== state || !code) {
149 return HttpServerResponse.text("Invalid T3 Cloud authorization callback.", {
149 return HttpServerResponse.text("Invalid T3 Connect authorization callback.", {
150150 status: 400,
151151 });
152152 }
......@@ -154,7 +154,7 @@ const make = Effect.gen(function* () {
154154 return yield* HttpServerResponse.html`
155155<html>
156156 <body style="font-family: sans-serif; text-align: center; margin-top: 50px;">
157 <h1>T3 Cloud authorization complete</h1>
157 <h1>T3 Connect authorization complete</h1>
158158 <p>You can close this window and return to your terminal.</p>
159159 </body>
160160</html>
......@@ -182,13 +182,13 @@ const make = Effect.gen(function* () {
182182 authorizationUrl.searchParams.set("state", state);
183183 authorizationUrl.searchParams.set("code_challenge", challenge);
184184 authorizationUrl.searchParams.set("code_challenge_method", "S256");
185 yield* Console.log(`Open this URL to authorize T3 Cloud:\n${authorizationUrl.toString()}\n`);
185 yield* Console.log(`Open this URL to authorize T3 Connect:\n${authorizationUrl.toString()}\n`);
186186 const code = yield* Deferred.await(callback).pipe(
187187 Effect.timeout(CLOUD_CLI_OAUTH_CALLBACK_TIMEOUT),
188188 Effect.catchTag("TimeoutError", () =>
189189 Effect.fail(
190190 new CloudCliTokenManagerError({
191 message: "Timed out waiting for T3 Cloud authorization.",
191 message: "Timed out waiting for T3 Connect authorization.",
192192 }),
193193 ),
194194 ),
......@@ -213,12 +213,12 @@ const make = Effect.gen(function* () {
213213 });
214214
215215 const getExisting = semaphore.withPermits(1)(
216 getExistingNoLock().pipe(wrapError("Could not refresh the T3 Cloud CLI credential.")),
216 getExistingNoLock().pipe(wrapError("Could not refresh the T3 Connect CLI credential.")),
217217 );
218218 const hasCredential = semaphore.withPermits(1)(
219219 read().pipe(
220220 Effect.map(Option.isSome),
221 wrapError("Could not read the stored T3 Cloud CLI credential."),
221 wrapError("Could not read the stored T3 Connect CLI credential."),
222222 ),
223223 );
224224 const get = semaphore.withPermits(1)(
......@@ -227,7 +227,7 @@ const make = Effect.gen(function* () {
227227 return Option.isSome(token)
228228 ? token.value
229229 : yield* Effect.scoped(login()).pipe(Effect.flatMap(persist));
230 }).pipe(wrapError("Could not authorize the T3 Cloud CLI.")),
230 }).pipe(wrapError("Could not authorize the T3 Connect CLI.")),
231231 );
232232
233233 return CloudCliTokenManager.of({ get, getExisting, hasCredential, clear });
apps/server/src/cloud/http.test.ts+1-1
......@@ -76,7 +76,7 @@ describe("reconcileDesiredCloudLink", () => {
7676
7777 expect(error).toMatchObject({
7878 _tag: "EnvironmentHttpUnauthorizedError",
79 message: "Run `t3 cloud link` to authorize this environment.",
79 message: "Run `t3 connect link` to authorize this environment.",
8080 });
8181 }).pipe(
8282 Effect.provideService(
apps/server/src/cloud/http.ts+6-6
......@@ -509,7 +509,7 @@ const relayClientRequest = <A>(
509509 Effect.mapError(
510510 (cause) =>
511511 new EnvironmentHttpInternalServerError({
512 message: `T3 Cloud relay request failed: ${String(cause)}`,
512 message: `T3 Connect relay request failed: ${String(cause)}`,
513513 }),
514514 ),
515515 );
......@@ -535,7 +535,7 @@ const reconcileDesiredCloudLinkWith = Effect.fn("environment.cloud.reconcileDesi
535535 onNone: () =>
536536 Effect.fail(
537537 new EnvironmentHttpUnauthorizedError({
538 message: "Run `t3 cloud link` to authorize this environment.",
538 message: "Run `t3 connect link` to authorize this environment.",
539539 }),
540540 ),
541541 onSome: Effect.succeed,
......@@ -595,7 +595,7 @@ const reconcileDesiredCloudLinkWith = Effect.fn("environment.cloud.reconcileDesi
595595 CloudCliTokenManagerError: (error) =>
596596 failEnvironmentCloudInternalError(error.message)(error.cause),
597597 SecretStoreError: failEnvironmentCloudInternalError(
598 "Could not persist desired T3 Cloud link state.",
598 "Could not persist desired T3 Connect link state.",
599599 ),
600600 }),
601601);
......@@ -874,7 +874,7 @@ const cloudMintCredentialHandler = Effect.fn("environment.cloud.mintCredential")
874874 scopes: AuthStandardClientScopes,
875875 subject: "cloud-connect",
876876 ttl: Duration.minutes(2),
877 label: "T3 Cloud connect",
877 label: "T3 Connect connect",
878878 proofKeyThumbprint: proof.clientProofKeyThumbprint,
879879 });
880880 const responsePayload = {
......@@ -924,9 +924,9 @@ const cloudMintCredentialHandler = Effect.fn("environment.cloud.mintCredential")
924924 }),
925925);
926926
927export const cloudHttpApiLayer = HttpApiBuilder.group(
927export const connectHttpApiLayer = HttpApiBuilder.group(
928928 EnvironmentHttpApi,
929 "cloud",
929 "connect",
930930 Effect.fnUntraced(function* (handlers) {
931931 const dependencies = yield* cloudHttpDependencies;
932932 return handlers
apps/server/src/relay/AgentAwarenessRelay.ts+3-3
......@@ -303,7 +303,7 @@ const make = Effect.gen(function* () {
303303 }
304304 const relayConfig = yield* readRelayConfig.pipe(Effect.orElseSucceed(() => null));
305305 if (!relayConfig) {
306 yield* Effect.logDebug("agent activity publish skipped; T3 Cloud config missing", {
306 yield* Effect.logDebug("agent activity publish skipped; T3 Connect config missing", {
307307 threadId,
308308 });
309309 return;
......@@ -421,7 +421,7 @@ const make = Effect.gen(function* () {
421421 }
422422 const relayConfig = yield* readRelayConfig.pipe(Effect.orElseSucceed(() => null));
423423 if (!relayConfig) {
424 yield* Effect.logDebug("agent activity snapshot skipped; T3 Cloud config missing");
424 yield* Effect.logDebug("agent activity snapshot skipped; T3 Connect config missing");
425425 return false;
426426 }
427427 const environmentId = yield* serverEnvironment.getEnvironmentId;
......@@ -459,7 +459,7 @@ const make = Effect.gen(function* () {
459459 function* () {
460460 const relayConfig = yield* readRelayConfig.pipe(Effect.orElseSucceed(() => null));
461461 if (!relayConfig) {
462 yield* Effect.logInfo("agent activity publishing standby; T3 Cloud config missing");
462 yield* Effect.logInfo("agent activity publishing standby; T3 Connect config missing");
463463 } else {
464464 yield* Effect.logInfo("agent activity publishing enabled", {
465465 relayUrl: relayConfig.url,
apps/server/src/server.test.ts+50-50
......@@ -782,7 +782,7 @@ const buildAppUnderTest = (options?: {
782782 ),
783783 Layer.provide(
784784 Layer.mock(CloudCliTokenManager.CloudCliTokenManager)({
785 get: Effect.die(new Error("Unexpected T3 Cloud CLI authorization request.")),
785 get: Effect.die(new Error("Unexpected T3 Connect CLI authorization request.")),
786786 getExisting: Effect.succeed(Option.none()),
787787 hasCredential: Effect.succeed(false),
788788 clear: Effect.void,
......@@ -1713,7 +1713,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
17131713 yield* buildAppUnderTest();
17141714
17151715 const ownerCookie = yield* getAuthenticatedSessionCookieHeader();
1716 const linkProofUrl = yield* getHttpServerUrl("/api/cloud/link-proof");
1716 const linkProofUrl = yield* getHttpServerUrl("/api/connect/link-proof");
17171717 const linkProofResponse = yield* fetchEffect(linkProofUrl, {
17181718 method: "POST",
17191719 headers: {
......@@ -1750,7 +1750,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
17501750 yield* buildAppUnderTest();
17511751
17521752 const ownerCookie = yield* getAuthenticatedSessionCookieHeader();
1753 const linkProofUrl = yield* getHttpServerUrl("/api/cloud/link-proof");
1753 const linkProofUrl = yield* getHttpServerUrl("/api/connect/link-proof");
17541754 const serverPort = Number(new URL(linkProofUrl).port);
17551755 const linkProofResponse = yield* fetchEffect(linkProofUrl, {
17561756 method: "POST",
......@@ -1762,10 +1762,10 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
17621762 challenge: "relay-link-challenge",
17631763 relayIssuer: "https://relay.example.test",
17641764 endpoint: {
1765 httpBaseUrl: linkProofUrl.replace("/api/cloud/link-proof", ""),
1765 httpBaseUrl: linkProofUrl.replace("/api/connect/link-proof", ""),
17661766 wsBaseUrl: linkProofUrl
17671767 .replace("http://", "ws://")
1768 .replace("/api/cloud/link-proof", "/ws"),
1768 .replace("/api/connect/link-proof", "/ws"),
17691769 providerKind: "manual",
17701770 },
17711771 origin: {
......@@ -1789,9 +1789,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
17891789 Effect.gen(function* () {
17901790 yield* buildAppUnderTest();
17911791
1792 const linkProofUrl = yield* getHttpServerUrl("/api/cloud/link-proof");
1792 const linkProofUrl = yield* getHttpServerUrl("/api/connect/link-proof");
17931793 const serverPort = Number(new URL(linkProofUrl).port);
1794 const linkProofResponse = yield* HttpClient.post("/api/cloud/link-proof", {
1794 const linkProofResponse = yield* HttpClient.post("/api/connect/link-proof", {
17951795 headers: {
17961796 cookie: yield* getAuthenticatedSessionCookieHeader(),
17971797 "content-type": "application/json",
......@@ -1833,9 +1833,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
18331833 Effect.gen(function* () {
18341834 yield* buildAppUnderTest();
18351835
1836 const linkProofUrl = yield* getHttpServerUrl("/api/cloud/link-proof");
1836 const linkProofUrl = yield* getHttpServerUrl("/api/connect/link-proof");
18371837 const serverPort = Number(new URL(linkProofUrl).port);
1838 const linkProofResponse = yield* HttpClient.post("/api/cloud/link-proof", {
1838 const linkProofResponse = yield* HttpClient.post("/api/connect/link-proof", {
18391839 headers: {
18401840 cookie: yield* getAuthenticatedSessionCookieHeader(),
18411841 "content-type": "application/json",
......@@ -1875,9 +1875,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
18751875 Effect.gen(function* () {
18761876 yield* buildAppUnderTest();
18771877
1878 const linkProofUrl = yield* getHttpServerUrl("/api/cloud/link-proof");
1878 const linkProofUrl = yield* getHttpServerUrl("/api/connect/link-proof");
18791879 const serverPort = Number(new URL(linkProofUrl).port);
1880 const linkProofResponse = yield* HttpClient.post("/api/cloud/link-proof", {
1880 const linkProofResponse = yield* HttpClient.post("/api/connect/link-proof", {
18811881 headers: {
18821882 cookie: yield* getAuthenticatedSessionCookieHeader(),
18831883 "content-type": "application/json",
......@@ -1918,7 +1918,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
19181918 yield* buildAppUnderTest();
19191919
19201920 const ownerCookie = yield* getAuthenticatedSessionCookieHeader();
1921 const linkProofUrl = yield* getHttpServerUrl("/api/cloud/link-proof");
1921 const linkProofUrl = yield* getHttpServerUrl("/api/connect/link-proof");
19221922 const serverPort = Number(new URL(linkProofUrl).port);
19231923 const linkProofResponse = yield* fetchEffect(linkProofUrl, {
19241924 method: "POST",
......@@ -1962,7 +1962,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
19621962 });
19631963 const credential = (yield* credentialResponse.json) as { readonly credential: string };
19641964 const pairedCookie = yield* getAuthenticatedSessionCookieHeader(credential.credential);
1965 const linkStateUrl = yield* getHttpServerUrl("/api/cloud/link-state");
1965 const linkStateUrl = yield* getHttpServerUrl("/api/connect/link-state");
19661966 const response = yield* fetchEffect(linkStateUrl, {
19671967 headers: { cookie: pairedCookie },
19681968 });
......@@ -2028,7 +2028,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
20282028 yield* buildAppUnderTest();
20292029
20302030 const ownerCookie = yield* getAuthenticatedSessionCookieHeader();
2031 const preferencesUrl = yield* getHttpServerUrl("/api/cloud/preferences");
2031 const preferencesUrl = yield* getHttpServerUrl("/api/connect/preferences");
20322032 const ownerResponse = yield* fetchEffect(preferencesUrl, {
20332033 method: "POST",
20342034 headers: {
......@@ -2072,7 +2072,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
20722072 yield* buildAppUnderTest();
20732073
20742074 const ownerCookie = yield* getAuthenticatedSessionCookieHeader();
2075 const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config");
2075 const relayConfigUrl = yield* getHttpServerUrl("/api/connect/relay-config");
20762076 const relayConfigResponse = yield* fetchEffect(relayConfigUrl, {
20772077 method: "POST",
20782078 headers: {
......@@ -2107,7 +2107,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
21072107 publicKeyEncoding: { format: "pem", type: "spki" },
21082108 });
21092109 const ownerCookie = yield* getAuthenticatedSessionCookieHeader();
2110 const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config");
2110 const relayConfigUrl = yield* getHttpServerUrl("/api/connect/relay-config");
21112111 const postRelayConfig = (body: {
21122112 readonly relayUrl: string;
21132113 readonly relayIssuer?: string;
......@@ -2184,7 +2184,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
21842184 publicKeyEncoding: { format: "pem", type: "spki" },
21852185 });
21862186 const ownerCookie = yield* getAuthenticatedSessionCookieHeader();
2187 const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config");
2187 const relayConfigUrl = yield* getHttpServerUrl("/api/connect/relay-config");
21882188 const postRelayConfig = (cloudUserId: string, environmentCredential: string) =>
21892189 fetchEffect(relayConfigUrl, {
21902190 method: "POST",
......@@ -2227,8 +2227,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
22272227 publicKeyEncoding: { format: "pem", type: "spki" },
22282228 });
22292229 const ownerCookie = yield* getAuthenticatedSessionCookieHeader();
2230 const linkStateUrl = yield* getHttpServerUrl("/api/cloud/link-state");
2231 const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config");
2230 const linkStateUrl = yield* getHttpServerUrl("/api/connect/link-state");
2231 const relayConfigUrl = yield* getHttpServerUrl("/api/connect/relay-config");
22322232
22332233 const initialResponse = yield* fetchEffect(linkStateUrl, {
22342234 headers: {
......@@ -2284,7 +2284,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
22842284 Effect.gen(function* () {
22852285 yield* buildAppUnderTest();
22862286
2287 const reconcileUrl = yield* getHttpServerUrl("/api/cloud/reconcile");
2287 const reconcileUrl = yield* getHttpServerUrl("/api/connect/reconcile");
22882288 const response = yield* fetchEffect(reconcileUrl, {
22892289 method: "POST",
22902290 });
......@@ -2321,9 +2321,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
23212321 publicKeyEncoding: { format: "pem", type: "spki" },
23222322 });
23232323 const ownerCookie = yield* getAuthenticatedSessionCookieHeader();
2324 const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config");
2325 const unlinkUrl = yield* getHttpServerUrl("/api/cloud/unlink");
2326 const linkStateUrl = yield* getHttpServerUrl("/api/cloud/link-state");
2324 const relayConfigUrl = yield* getHttpServerUrl("/api/connect/relay-config");
2325 const unlinkUrl = yield* getHttpServerUrl("/api/connect/unlink");
2326 const linkStateUrl = yield* getHttpServerUrl("/api/connect/link-state");
23272327
23282328 const relayConfigResponse = yield* fetchEffect(relayConfigUrl, {
23292329 method: "POST",
......@@ -2398,7 +2398,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
23982398 publicKeyEncoding: { format: "pem", type: "spki" },
23992399 });
24002400 const ownerCookie = yield* getAuthenticatedSessionCookieHeader();
2401 const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config");
2401 const relayConfigUrl = yield* getHttpServerUrl("/api/connect/relay-config");
24022402 const relayConfigResponse = yield* fetchEffect(relayConfigUrl, {
24032403 method: "POST",
24042404 headers: {
......@@ -2424,7 +2424,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
24242424 issuedAt: DateTime.formatIso(now),
24252425 expiresAt: DateTime.formatIso(DateTime.add(now, { minutes: 5 })),
24262426 });
2427 const mintUrl = yield* getHttpServerUrl("/api/cloud/mint-credential");
2427 const mintUrl = yield* getHttpServerUrl("/api/connect/mint-credential");
24282428 const postMint = () =>
24292429 fetchEffect(mintUrl, {
24302430 method: "POST",
......@@ -2448,7 +2448,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
24482448 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
24492449 );
24502450
2451 it.effect("serves the documented T3 Cloud mint credential endpoint", () =>
2451 it.effect("serves the documented T3 Connect mint credential endpoint", () =>
24522452 Effect.gen(function* () {
24532453 yield* buildAppUnderTest();
24542454
......@@ -2457,7 +2457,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
24572457 publicKeyEncoding: { format: "pem", type: "spki" },
24582458 });
24592459 const ownerCookie = yield* getAuthenticatedSessionCookieHeader();
2460 const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config");
2460 const relayConfigUrl = yield* getHttpServerUrl("/api/connect/relay-config");
24612461 const relayConfigResponse = yield* fetchEffect(relayConfigUrl, {
24622462 method: "POST",
24632463 headers: {
......@@ -2484,7 +2484,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
24842484 issuedAt: DateTime.formatIso(now),
24852485 expiresAt: DateTime.formatIso(DateTime.add(now, { minutes: 5 })),
24862486 });
2487 const mintUrl = yield* getHttpServerUrl("/api/t3-cloud/mint-credential");
2487 const mintUrl = yield* getHttpServerUrl("/api/t3-connect/mint-credential");
24882488 const response = yield* fetchEffect(mintUrl, {
24892489 method: "POST",
24902490 headers: {
......@@ -2507,7 +2507,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
25072507 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
25082508 );
25092509
2510 it.effect("serves signed T3 Cloud environment health checks", () =>
2510 it.effect("serves signed T3 Connect environment health checks", () =>
25112511 Effect.gen(function* () {
25122512 yield* buildAppUnderTest();
25132513
......@@ -2516,7 +2516,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
25162516 publicKeyEncoding: { format: "pem", type: "spki" },
25172517 });
25182518 const ownerCookie = yield* getAuthenticatedSessionCookieHeader();
2519 const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config");
2519 const relayConfigUrl = yield* getHttpServerUrl("/api/connect/relay-config");
25202520 const relayConfigResponse = yield* fetchEffect(relayConfigUrl, {
25212521 method: "POST",
25222522 headers: {
......@@ -2542,7 +2542,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
25422542 issuedAt: DateTime.formatIso(now),
25432543 expiresAt: DateTime.formatIso(DateTime.add(now, { minutes: 5 })),
25442544 });
2545 const healthUrl = yield* getHttpServerUrl("/api/t3-cloud/health");
2545 const healthUrl = yield* getHttpServerUrl("/api/t3-connect/health");
25462546 const response = yield* fetchEffect(healthUrl, {
25472547 method: "POST",
25482548 headers: {
......@@ -2576,7 +2576,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
25762576 publicKeyEncoding: { format: "pem", type: "spki" },
25772577 });
25782578 const ownerCookie = yield* getAuthenticatedSessionCookieHeader();
2579 const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config");
2579 const relayConfigUrl = yield* getHttpServerUrl("/api/connect/relay-config");
25802580 const relayConfigResponse = yield* fetchEffect(relayConfigUrl, {
25812581 method: "POST",
25822582 headers: {
......@@ -2602,7 +2602,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
26022602 issuedAt: DateTime.formatIso(now),
26032603 expiresAt: DateTime.formatIso(DateTime.add(now, { minutes: 5 })),
26042604 });
2605 const healthUrl = yield* getHttpServerUrl("/api/t3-cloud/health");
2605 const healthUrl = yield* getHttpServerUrl("/api/t3-connect/health");
26062606 const postHealth = () =>
26072607 fetchEffect(healthUrl, {
26082608 method: "POST",
......@@ -2637,7 +2637,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
26372637 publicKeyEncoding: { format: "pem", type: "spki" },
26382638 });
26392639 const ownerCookie = yield* getAuthenticatedSessionCookieHeader();
2640 const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config");
2640 const relayConfigUrl = yield* getHttpServerUrl("/api/connect/relay-config");
26412641 const relayConfigResponse = yield* fetchEffect(relayConfigUrl, {
26422642 method: "POST",
26432643 headers: {
......@@ -2656,7 +2656,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
26562656 assert.equal(relayConfigResponse.status, 200);
26572657
26582658 const now = yield* DateTime.now;
2659 const mintUrl = yield* getHttpServerUrl("/api/t3-cloud/mint-credential");
2659 const mintUrl = yield* getHttpServerUrl("/api/t3-connect/mint-credential");
26602660 const postMint = (request: ReturnType<typeof makeCloudMintCredentialRequest>) =>
26612661 fetchEffect(mintUrl, {
26622662 method: "POST",
......@@ -2717,7 +2717,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
27172717 publicKeyEncoding: { format: "pem", type: "spki" },
27182718 });
27192719 const ownerCookie = yield* getAuthenticatedSessionCookieHeader();
2720 const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config");
2720 const relayConfigUrl = yield* getHttpServerUrl("/api/connect/relay-config");
27212721 const relayConfigResponse = yield* fetchEffect(relayConfigUrl, {
27222722 method: "POST",
27232723 headers: {
......@@ -2756,7 +2756,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
27562756 issuedAt: DateTime.formatIso(now),
27572757 expiresAt: DateTime.formatIso(DateTime.add(now, { minutes: 5 })),
27582758 });
2759 const healthUrl = yield* getHttpServerUrl("/api/t3-cloud/health");
2759 const healthUrl = yield* getHttpServerUrl("/api/t3-connect/health");
27602760 const healthResponse = yield* fetchEffect(healthUrl, {
27612761 method: "POST",
27622762 headers: {
......@@ -2786,7 +2786,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
27862786 publicKeyEncoding: { format: "pem", type: "spki" },
27872787 });
27882788 const ownerCookie = yield* getAuthenticatedSessionCookieHeader();
2789 const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config");
2789 const relayConfigUrl = yield* getHttpServerUrl("/api/connect/relay-config");
27902790 const relayConfigResponse = yield* fetchEffect(relayConfigUrl, {
27912791 method: "POST",
27922792 headers: {
......@@ -2804,7 +2804,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
28042804 assert.equal(relayConfigResponse.status, 200);
28052805
28062806 const now = yield* DateTime.now;
2807 const mintUrl = yield* getHttpServerUrl("/api/cloud/mint-credential");
2807 const mintUrl = yield* getHttpServerUrl("/api/connect/mint-credential");
28082808 const postMint = (request: ReturnType<typeof makeCloudMintCredentialRequest>) =>
28092809 fetchEffect(mintUrl, {
28102810 method: "POST",
......@@ -2853,7 +2853,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
28532853 publicKeyEncoding: { format: "pem", type: "spki" },
28542854 });
28552855 const ownerCookie = yield* getAuthenticatedSessionCookieHeader();
2856 const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config");
2856 const relayConfigUrl = yield* getHttpServerUrl("/api/connect/relay-config");
28572857 const relayConfigResponse = yield* fetchEffect(relayConfigUrl, {
28582858 method: "POST",
28592859 headers: {
......@@ -2871,7 +2871,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
28712871 assert.equal(relayConfigResponse.status, 200);
28722872
28732873 const now = yield* DateTime.now;
2874 const mintUrl = yield* getHttpServerUrl("/api/t3-cloud/mint-credential");
2874 const mintUrl = yield* getHttpServerUrl("/api/t3-connect/mint-credential");
28752875 const response = yield* fetchEffect(mintUrl, {
28762876 method: "POST",
28772877 headers: {
......@@ -2904,7 +2904,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
29042904 publicKeyEncoding: { format: "pem", type: "spki" },
29052905 });
29062906 const ownerCookie = yield* getAuthenticatedSessionCookieHeader();
2907 const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config");
2907 const relayConfigUrl = yield* getHttpServerUrl("/api/connect/relay-config");
29082908 const relayConfigResponse = yield* fetchEffect(relayConfigUrl, {
29092909 method: "POST",
29102910 headers: {
......@@ -2922,7 +2922,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
29222922 assert.equal(relayConfigResponse.status, 200);
29232923
29242924 const now = yield* DateTime.now;
2925 const mintUrl = yield* getHttpServerUrl("/api/t3-cloud/mint-credential");
2925 const mintUrl = yield* getHttpServerUrl("/api/t3-connect/mint-credential");
29262926 const response = yield* fetchEffect(mintUrl, {
29272927 method: "POST",
29282928 headers: {
......@@ -2955,7 +2955,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
29552955 publicKeyEncoding: { format: "pem", type: "spki" },
29562956 });
29572957 const ownerCookie = yield* getAuthenticatedSessionCookieHeader();
2958 const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config");
2958 const relayConfigUrl = yield* getHttpServerUrl("/api/connect/relay-config");
29592959 const relayConfigResponse = yield* fetchEffect(relayConfigUrl, {
29602960 method: "POST",
29612961 headers: {
......@@ -2973,7 +2973,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
29732973 assert.equal(relayConfigResponse.status, 200);
29742974
29752975 const now = yield* DateTime.now;
2976 const healthUrl = yield* getHttpServerUrl("/api/t3-cloud/health");
2976 const healthUrl = yield* getHttpServerUrl("/api/t3-connect/health");
29772977 const postHealth = (request: ReturnType<typeof makeCloudEnvironmentHealthRequest>) =>
29782978 fetchEffect(healthUrl, {
29792979 method: "POST",
......@@ -3020,7 +3020,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
30203020 publicKeyEncoding: { format: "pem", type: "spki" },
30213021 });
30223022 const ownerCookie = yield* getAuthenticatedSessionCookieHeader();
3023 const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config");
3023 const relayConfigUrl = yield* getHttpServerUrl("/api/connect/relay-config");
30243024 const relayConfigResponse = yield* fetchEffect(relayConfigUrl, {
30253025 method: "POST",
30263026 headers: {
......@@ -3038,7 +3038,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
30383038 assert.equal(relayConfigResponse.status, 200);
30393039
30403040 const now = yield* DateTime.now;
3041 const healthUrl = yield* getHttpServerUrl("/api/t3-cloud/health");
3041 const healthUrl = yield* getHttpServerUrl("/api/t3-connect/health");
30423042 const response = yield* fetchEffect(healthUrl, {
30433043 method: "POST",
30443044 headers: {
......@@ -3070,7 +3070,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
30703070 publicKeyEncoding: { format: "pem", type: "spki" },
30713071 });
30723072 const ownerCookie = yield* getAuthenticatedSessionCookieHeader();
3073 const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config");
3073 const relayConfigUrl = yield* getHttpServerUrl("/api/connect/relay-config");
30743074 const relayConfigResponse = yield* fetchEffect(relayConfigUrl, {
30753075 method: "POST",
30763076 headers: {
......@@ -3088,7 +3088,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
30883088 assert.equal(relayConfigResponse.status, 200);
30893089
30903090 const now = yield* DateTime.now;
3091 const healthUrl = yield* getHttpServerUrl("/api/t3-cloud/health");
3091 const healthUrl = yield* getHttpServerUrl("/api/t3-connect/health");
30923092 const response = yield* fetchEffect(healthUrl, {
30933093 method: "POST",
30943094 headers: {
......@@ -3280,7 +3280,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
32803280 config: { devUrl: new URL(crossOriginClientOrigin) },
32813281 });
32823282
3283 const linkProofUrl = yield* getHttpServerUrl("/api/cloud/link-proof");
3283 const linkProofUrl = yield* getHttpServerUrl("/api/connect/link-proof");
32843284 const response = yield* fetchEffect(linkProofUrl, {
32853285 method: "OPTIONS",
32863286 headers: {
apps/server/src/server.ts+4-4
......@@ -69,7 +69,7 @@ import { ServerEnvironmentLive } from "./environment/Layers/ServerEnvironment.ts
6969import { authHttpApiLayer, environmentAuthenticatedAuthLayer } from "./auth/http.ts";
7070import * as ServerSecretStore from "./auth/ServerSecretStore.ts";
7171import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";
72import { cloudHttpApiLayer, reconcileDesiredCloudLink } from "./cloud/http.ts";
72import { connectHttpApiLayer, reconcileDesiredCloudLink } from "./cloud/http.ts";
7373import * as CloudManagedEndpointRuntime from "./cloud/ManagedEndpointRuntime.ts";
7474import * as CloudCliTokenManager from "./cloud/CliTokenManager.ts";
7575import * as CloudCliState from "./cloud/CliState.ts";
......@@ -321,7 +321,7 @@ const RuntimeServicesLive = ServerRuntimeStartupLive.pipe(
321321export const makeRoutesLayer = Layer.mergeAll(
322322 HttpApiBuilder.layer(EnvironmentHttpApi).pipe(
323323 Layer.provide(authHttpApiLayer),
324 Layer.provide(cloudHttpApiLayer),
324 Layer.provide(connectHttpApiLayer),
325325 Layer.provide(orchestrationHttpApiLayer),
326326 Layer.provide(serverEnvironmentHttpApiLayer),
327327 Layer.provide(environmentAuthenticatedAuthLayer),
......@@ -429,9 +429,9 @@ export const makeServerLayer = Layer.unwrap(
429429 Effect.sleep("250 millis").pipe(
430430 Effect.andThen(reconcileDesiredCloudLink(`http://127.0.0.1:${address.port}`)),
431431 Effect.retry({ times: 4 }),
432 Effect.tap(() => Effect.logInfo("T3 Cloud desired link reconciled on startup")),
432 Effect.tap(() => Effect.logInfo("T3 Connect desired link reconciled on startup")),
433433 Effect.catch((cause) =>
434 Effect.logWarning("Failed to reconcile T3 Cloud desired link on startup", {
434 Effect.logWarning("Failed to reconcile T3 Connect desired link on startup", {
435435 cause,
436436 }),
437437 ),
apps/web/src/cloud/linkEnvironment.test.ts+5-5
......@@ -514,7 +514,7 @@ describe("web cloud link environment client", () => {
514514 expect(fetchMock.mock.calls[0]?.[1]?.credentials).not.toBe("include");
515515
516516 expect(String(fetchMock.mock.calls[1]?.[0])).toBe(
517 "http://127.0.0.1:3000/api/cloud/link-proof",
517 "http://127.0.0.1:3000/api/connect/link-proof",
518518 );
519519 expect(fetchMock.mock.calls[1]?.[1]).toMatchObject({
520520 method: "POST",
......@@ -553,7 +553,7 @@ describe("web cloud link environment client", () => {
553553 });
554554
555555 expect(String(fetchMock.mock.calls[3]?.[0])).toBe(
556 "http://127.0.0.1:3000/api/cloud/relay-config",
556 "http://127.0.0.1:3000/api/connect/relay-config",
557557 );
558558 expect(fetchMock.mock.calls[3]?.[1]).toMatchObject({
559559 method: "POST",
......@@ -615,7 +615,7 @@ describe("web cloud link environment client", () => {
615615 publishAgentActivity: false,
616616 });
617617 expect(String(fetchMock.mock.calls[0]?.[0])).toBe(
618 "http://127.0.0.1:3000/api/cloud/link-state",
618 "http://127.0.0.1:3000/api/connect/link-state",
619619 );
620620 expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({
621621 method: "GET",
......@@ -654,7 +654,7 @@ describe("web cloud link environment client", () => {
654654 }),
655655 );
656656
657 expect(String(fetchMock.mock.calls[0]?.[0])).toBe("http://127.0.0.1:3000/api/cloud/unlink");
657 expect(String(fetchMock.mock.calls[0]?.[0])).toBe("http://127.0.0.1:3000/api/connect/unlink");
658658 expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({
659659 method: "POST",
660660 credentials: "include",
......@@ -698,7 +698,7 @@ describe("web cloud link environment client", () => {
698698 );
699699
700700 expect(fetchMock).toHaveBeenCalledTimes(2);
701 expect(String(fetchMock.mock.calls[0]?.[0])).toBe("http://127.0.0.1:3000/api/cloud/unlink");
701 expect(String(fetchMock.mock.calls[0]?.[0])).toBe("http://127.0.0.1:3000/api/connect/unlink");
702702 expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({
703703 method: "POST",
704704 credentials: "include",
apps/web/src/cloud/linkEnvironment.ts+7-7
......@@ -451,7 +451,7 @@ export function readPrimaryCloudLinkState(): Effect.Effect<
451451 return null;
452452 }
453453 const client = yield* makeEnvironmentHttpApiClient(resolvePrimaryEnvironmentHttpUrl("/"));
454 return yield* client.cloud
454 return yield* client.connect
455455 .linkState({ headers: {} })
456456 .pipe(
457457 withPrimaryEnvironmentRequestInit,
......@@ -465,7 +465,7 @@ export function updatePrimaryCloudPreferences(input: {
465465}): Effect.Effect<CloudLinkState, CloudEnvironmentLinkError, HttpClient.HttpClient> {
466466 return Effect.gen(function* () {
467467 const client = yield* makeEnvironmentHttpApiClient(resolvePrimaryEnvironmentHttpUrl("/"));
468 return yield* client.cloud
468 return yield* client.connect
469469 .preferences({
470470 headers: {},
471471 payload: input,
......@@ -488,7 +488,7 @@ export function unlinkPrimaryEnvironmentFromCloud(input: {
488488 });
489489 }
490490 const client = yield* makeEnvironmentHttpApiClient(resolvePrimaryEnvironmentHttpUrl("/"));
491 yield* client.cloud
491 yield* client.connect
492492 .unlink({ headers: {} })
493493 .pipe(
494494 withPrimaryEnvironmentRequestInit,
......@@ -568,7 +568,7 @@ export function linkEnvironmentToCloud(input: {
568568 ),
569569 ),
570570 );
571 const proof = yield* environmentClient.cloud
571 const proof = yield* environmentClient.connect
572572 .linkProof({
573573 headers,
574574 payload: {
......@@ -604,7 +604,7 @@ export function linkEnvironmentToCloud(input: {
604604 link,
605605 });
606606
607 yield* environmentClient.cloud
607 yield* environmentClient.connect
608608 .relayConfig({
609609 headers,
610610 payload: {
......@@ -656,7 +656,7 @@ export function linkPrimaryEnvironmentToCloud(input: {
656656 ),
657657 ),
658658 );
659 const proof = yield* environmentClient.cloud
659 const proof = yield* environmentClient.connect
660660 .linkProof({
661661 headers: {},
662662 payload: {
......@@ -695,7 +695,7 @@ export function linkPrimaryEnvironmentToCloud(input: {
695695 link,
696696 });
697697
698 yield* environmentClient.cloud
698 yield* environmentClient.connect
699699 .relayConfig({
700700 headers: {},
701701 payload: {
apps/web/src/cloud/primaryCloudLinkState.ts+1-1
......@@ -55,7 +55,7 @@ export function usePrimaryCloudLinkState() {
5555 let error: string | null = null;
5656 if (result._tag === "Failure") {
5757 const cause = Cause.squash(result.cause);
58 error = cause instanceof Error ? cause.message : "Could not read T3 Cloud link state.";
58 error = cause instanceof Error ? cause.message : "Could not read T3 Connect link state.";
5959 }
6060
6161 return {
apps/web/src/components/clerk/T3ConnectSidebarSignIn.tsx created+60
......@@ -0,0 +1,60 @@
1import { UserButton, useAuth } from "@clerk/react";
2import { LogInIcon } from "lucide-react";
3
4import { hasCloudPublicConfig } from "../../cloud/publicConfig";
5import { SidebarMenu, SidebarMenuButton, SidebarMenuItem } from "../ui/sidebar";
6import { useT3ConnectAuthPrompt } from "./useT3ConnectAuthPrompt";
7
8export function T3ConnectSidebarSignIn() {
9 if (!hasCloudPublicConfig()) return null;
10
11 return <ConfiguredT3ConnectSidebarSignIn />;
12}
13
14export function T3ConnectSidebarAvatar() {
15 if (!hasCloudPublicConfig()) return null;
16
17 return <ConfiguredT3ConnectSidebarAvatar />;
18}
19
20function ConfiguredT3ConnectSidebarAvatar() {
21 const { isLoaded, isSignedIn } = useAuth();
22
23 if (!isLoaded || !isSignedIn) return null;
24
25 return (
26 <UserButton
27 appearance={{
28 elements: {
29 avatarBox: "size-7",
30 userButtonTrigger: "rounded-lg p-1 hover:bg-sidebar-accent",
31 },
32 }}
33 />
34 );
35}
36
37function ConfiguredT3ConnectSidebarSignIn() {
38 const { isLoaded, isSignedIn } = useAuth();
39 const { authPrompt, openAuthPrompt } = useT3ConnectAuthPrompt();
40
41 if (!isLoaded || isSignedIn) return null;
42
43 return (
44 <>
45 <SidebarMenu>
46 <SidebarMenuItem>
47 <SidebarMenuButton
48 size="sm"
49 className="gap-2 px-2 py-2 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
50 onClick={openAuthPrompt}
51 >
52 <LogInIcon className="size-4" />
53 <span>Sign in to T3 Connect</span>
54 </SidebarMenuButton>
55 </SidebarMenuItem>
56 </SidebarMenu>
57 {authPrompt}
58 </>
59 );
60}
apps/web/src/components/clerk/useT3ConnectAuthPrompt.tsx created+29
......@@ -0,0 +1,29 @@
1import { useClerk } from "@clerk/react";
2import { useState } from "react";
3
4import { isElectron } from "../../env";
5import { Dialog, DialogPopup } from "../ui/dialog";
6import { DesktopClerkWaitlist } from "./DesktopClerkWaitlist";
7
8export function useT3ConnectAuthPrompt() {
9 const clerk = useClerk();
10 const [desktopAuthOpen, setDesktopAuthOpen] = useState(false);
11
12 const openAuthPrompt = () => {
13 if (isElectron) {
14 setDesktopAuthOpen(true);
15 return;
16 }
17 clerk.openWaitlist();
18 };
19
20 const authPrompt = isElectron ? (
21 <Dialog open={desktopAuthOpen} onOpenChange={setDesktopAuthOpen}>
22 <DialogPopup className="max-w-md border-0 bg-transparent shadow-none" showCloseButton={false}>
23 <DesktopClerkWaitlist />
24 </DialogPopup>
25 </Dialog>
26 ) : null;
27
28 return { authPrompt, openAuthPrompt };
29}
apps/web/src/components/cloud/RelayClientInstallDialog.tsx+2-2
......@@ -69,8 +69,8 @@ export function RelayClientInstallDialog() {
6969 </DialogTitle>
7070 <DialogDescription>
7171 {isInstalling
72 ? "T3 Code is preparing this environment for secure access through T3 Cloud."
73 : "T3 Code needs the relay client to make this environment available through T3 Cloud."}
72 ? "T3 Code is preparing this environment for secure access through T3 Connect."
73 : "T3 Code needs the relay client to make this environment available through T3 Connect."}
7474 </DialogDescription>
7575 </DialogHeader>
7676 <DialogPanel scrollFade={false}>
apps/web/src/components/settings/CloudSettings.tsx deleted-197
......@@ -1,197 +0,0 @@
1import { UserButton, Waitlist, useAuth } from "@clerk/react";
2import { AuthRelayWriteScope } from "@t3tools/contracts";
3import { CloudIcon, RefreshCwIcon, SmartphoneIcon } from "lucide-react";
4import { useEffect, useState } from "react";
5
6import { updatePrimaryCloudPreferences } from "../../cloud/linkEnvironment";
7import { hasCloudPublicConfig } from "../../cloud/publicConfig";
8import { useManagedRelayDevices } from "../../cloud/managedRelayState";
9import { usePrimaryCloudLinkState } from "../../cloud/primaryCloudLinkState";
10import { isElectron } from "../../env";
11import { usePrimarySessionState } from "../../environments/primary";
12import { webRuntime } from "../../lib/runtime";
13import { cn } from "../../lib/utils";
14import { DesktopClerkWaitlist } from "../clerk/DesktopClerkWaitlist";
15import { Button } from "../ui/button";
16import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty";
17import { Skeleton } from "../ui/skeleton";
18import { Switch } from "../ui/switch";
19import { toastManager } from "../ui/toast";
20import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
21import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsLayout";
22
23const NOTIFICATION_DEVICE_SKELETON_ROWS = ["primary", "secondary"] as const;
24
25function NotificationDevicesSkeleton() {
26 return NOTIFICATION_DEVICE_SKELETON_ROWS.map((row) => (
27 <div key={row} className="border-t border-border/60 px-4 py-3.5 first:border-t-0 sm:px-5">
28 <div className="space-y-2">
29 <Skeleton className="h-4 w-32 rounded-full" />
30 <Skeleton className="h-3 w-44 rounded-full" />
31 <Skeleton className="h-3 w-56 rounded-full" />
32 </div>
33 </div>
34 ));
35}
36
37function EmptyNotificationDevices() {
38 return (
39 <Empty className="min-h-52">
40 <EmptyMedia variant="icon">
41 <SmartphoneIcon />
42 </EmptyMedia>
43 <EmptyHeader>
44 <EmptyTitle>No notification devices</EmptyTitle>
45 <EmptyDescription>
46 Sign in on the mobile app to register a device for account-level notifications.
47 </EmptyDescription>
48 </EmptyHeader>
49 </Empty>
50 );
51}
52
53function cloudErrorMessage(error: unknown, fallback: string): string {
54 return error instanceof Error ? error.message : fallback;
55}
56
57export function CloudSettingsPanel() {
58 if (!hasCloudPublicConfig()) return null;
59
60 return <ConfiguredCloudSettingsPanel />;
61}
62
63function ConfiguredCloudSettingsPanel() {
64 const { isLoaded, isSignedIn } = useAuth();
65
66 if (!isLoaded) {
67 return null;
68 }
69
70 return isSignedIn ? <CloudSettingsPanelInner /> : <CloudWaitlistPanel />;
71}
72
73function CloudWaitlistPanel() {
74 return (
75 <SettingsPageContainer className="min-h-full items-center justify-center">
76 {isElectron ? <DesktopClerkWaitlist /> : <Waitlist />}
77 </SettingsPageContainer>
78 );
79}
80
81function CloudSettingsPanelInner() {
82 const primaryLinkState = usePrimaryCloudLinkState();
83 const primarySessionState = usePrimarySessionState();
84 const devicesState = useManagedRelayDevices();
85 const [isUpdatingPreference, setIsUpdatingPreference] = useState(false);
86 const devices = devicesState.data ?? [];
87 const canManageRelay =
88 primarySessionState.data?.authenticated === true &&
89 Boolean(primarySessionState.data.scopes?.includes(AuthRelayWriteScope));
90
91 useEffect(() => {
92 if (devicesState.error) {
93 toastManager.add({
94 type: "error",
95 title: "Cloud devices unavailable",
96 description: devicesState.error,
97 });
98 }
99 }, [devicesState.error]);
100
101 const updatePublishAgentActivity = async (enabled: boolean) => {
102 setIsUpdatingPreference(true);
103 try {
104 await webRuntime.runPromise(updatePrimaryCloudPreferences({ publishAgentActivity: enabled }));
105 primaryLinkState.refresh();
106 toastManager.add({
107 type: "success",
108 title: enabled ? "Agent activity enabled" : "Agent activity disabled",
109 description: enabled
110 ? "This environment can publish agent activity to your notification devices."
111 : "This environment will stop publishing agent activity.",
112 });
113 } catch (error) {
114 toastManager.add({
115 type: "error",
116 title: "Cloud preference update failed",
117 description: cloudErrorMessage(error, "Could not update cloud preferences."),
118 });
119 } finally {
120 setIsUpdatingPreference(false);
121 }
122 };
123
124 return (
125 <SettingsPageContainer>
126 <SettingsSection title="T3 Cloud" icon={<CloudIcon className="size-3.5" />}>
127 <SettingsRow
128 title="Cloud account"
129 description="Manage your private-beta T3 Cloud session."
130 control={<UserButton />}
131 />
132 </SettingsSection>
133 <SettingsSection title="Preferences">
134 <SettingsRow
135 title="Publish agent activity"
136 description="Allow this environment to send agent activity to your notification devices."
137 status={
138 !primaryLinkState.data?.linked ? "Link this environment from Connections first." : null
139 }
140 control={
141 <Switch
142 aria-label="Publish agent activity"
143 checked={primaryLinkState.data?.publishAgentActivity ?? false}
144 disabled={!primaryLinkState.data?.linked || !canManageRelay || isUpdatingPreference}
145 onCheckedChange={(enabled) => void updatePublishAgentActivity(enabled)}
146 />
147 }
148 />
149 </SettingsSection>
150 <SettingsSection
151 title="Notification devices"
152 headerAction={
153 <Tooltip>
154 <TooltipTrigger
155 render={
156 <Button
157 size="icon-xs"
158 variant="ghost"
159 className="size-5 rounded-sm p-0 text-muted-foreground hover:text-foreground"
160 disabled={devicesState.isPending}
161 onClick={devicesState.refresh}
162 aria-label="Refresh notification devices"
163 >
164 <RefreshCwIcon
165 className={cn("size-3", devicesState.isPending && "animate-spin")}
166 />
167 </Button>
168 }
169 />
170 <TooltipPopup side="top">Refresh notification devices</TooltipPopup>
171 </Tooltip>
172 }
173 >
174 {devicesState.data === null ? (
175 <NotificationDevicesSkeleton />
176 ) : devices.length > 0 ? (
177 devices.map((device) => (
178 <SettingsRow
179 key={device.deviceId}
180 title={device.label}
181 description={`iOS ${device.iosMajorVersion}${device.appVersion ? ` · T3 Code ${device.appVersion}` : ""}`}
182 status={
183 device.notifications.enabled
184 ? device.liveActivities.enabled
185 ? "Notifications and Live Activities enabled"
186 : "Notifications enabled · Live Activities disabled"
187 : "Notifications disabled on device"
188 }
189 />
190 ))
191 ) : (
192 <EmptyNotificationDevices />
193 )}
194 </SettingsSection>
195 </SettingsPageContainer>
196 );
197}
apps/web/src/components/settings/ConnectionsSettings.tsx+122-36
......@@ -76,6 +76,7 @@ import { stackedThreadToast, toastManager } from "../ui/toast";
7676import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
7777import { Button } from "../ui/button";
7878import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty";
79import { useT3ConnectAuthPrompt } from "../clerk/useT3ConnectAuthPrompt";
7980import { Group, GroupSeparator } from "../ui/group";
8081import { AnimatedHeight } from "../AnimatedHeight";
8182import {
......@@ -121,6 +122,7 @@ import {
121122 connectManagedCloudEnvironment,
122123 linkPrimaryEnvironmentToCloud,
123124 unlinkPrimaryEnvironmentFromCloud,
125 updatePrimaryCloudPreferences,
124126} from "~/cloud/linkEnvironment";
125127import {
126128 refreshManagedRelayEnvironments,
......@@ -1614,7 +1616,7 @@ function CloudLinkSwitch({
16141616}) {
16151617 const control = (
16161618 <Switch
1617 aria-label="Enable T3 Cloud"
1619 aria-label="Enable T3 Connect"
16181620 checked={checked}
16191621 disabled={disabled}
16201622 {...(onCheckedChange ? { onCheckedChange } : {})}
......@@ -1632,9 +1634,11 @@ function CloudLinkSwitch({
16321634
16331635function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: boolean }) {
16341636 const { getToken, isSignedIn } = useAuth();
1637 const { authPrompt, openAuthPrompt } = useT3ConnectAuthPrompt();
16351638 const primaryCloudLinkState = usePrimaryCloudLinkState();
16361639 const [operationError, setOperationError] = useState<string | null>(null);
16371640 const [isUpdating, setIsUpdating] = useState(false);
1641 const [isUpdatingPreference, setIsUpdatingPreference] = useState(false);
16381642
16391643 const updateLink = async (enabled: boolean) => {
16401644 setIsUpdating(true);
......@@ -1643,7 +1647,7 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b
16431647 const clerkToken = await getToken(resolveRelayClerkTokenOptions());
16441648 if (enabled) {
16451649 if (!clerkToken) {
1646 throw new Error("Sign in from T3 Cloud settings before linking this environment.");
1650 throw new Error("Sign in to T3 Connect before linking this environment.");
16471651 }
16481652 await webRuntime.runPromise(linkPrimaryEnvironmentToCloud({ clerkToken }));
16491653 } else {
......@@ -1655,48 +1659,103 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b
16551659 refreshManagedRelayEnvironments();
16561660 toastManager.add({
16571661 type: "success",
1658 title: enabled ? "T3 Cloud linked" : "T3 Cloud unlinked",
1662 title: enabled ? "T3 Connect linked" : "T3 Connect unlinked",
16591663 description: enabled
1660 ? "This environment is available through T3 Cloud."
1661 : "This environment is no longer available through T3 Cloud.",
1664 ? "This environment is available through T3 Connect."
1665 : "This environment is no longer available through T3 Connect.",
16621666 });
16631667 } catch (cause) {
1664 const message = cause instanceof Error ? cause.message : "Could not update T3 Cloud access.";
1668 const message =
1669 cause instanceof Error ? cause.message : "Could not update T3 Connect access.";
16651670 setOperationError(message);
16661671 toastManager.add({
16671672 type: "error",
1668 title: "Could not update T3 Cloud",
1673 title: "Could not update T3 Connect",
16691674 description: message,
16701675 });
16711676 } finally {
16721677 setIsUpdating(false);
16731678 }
16741679 };
1680 const updatePublishAgentActivity = async (enabled: boolean) => {
1681 setIsUpdatingPreference(true);
1682 try {
1683 await webRuntime.runPromise(updatePrimaryCloudPreferences({ publishAgentActivity: enabled }));
1684 primaryCloudLinkState.refresh();
1685 toastManager.add({
1686 type: "success",
1687 title: enabled ? "Agent activity enabled" : "Agent activity disabled",
1688 description: enabled
1689 ? "This environment can publish agent activity to your notification devices."
1690 : "This environment will stop publishing agent activity.",
1691 });
1692 } catch (cause) {
1693 toastManager.add({
1694 type: "error",
1695 title: "Could not update T3 Connect preferences",
1696 description:
1697 cause instanceof Error ? cause.message : "Could not update agent activity publishing.",
1698 });
1699 } finally {
1700 setIsUpdatingPreference(false);
1701 }
1702 };
16751703 const disabledReason = !isSignedIn
1676 ? "Sign in from T3 Cloud settings to manage this environment."
1704 ? "Sign in to T3 Connect"
16771705 : !canManageRelay
1678 ? "Your session does not have permission to manage T3 Cloud access."
1706 ? "Your session does not have permission to manage T3 Connect access."
16791707 : null;
16801708 const linked = primaryCloudLinkState.data?.linked ?? false;
16811709
16821710 return (
1683 <SettingsRow
1684 title="T3 Cloud"
1685 description={
1686 linked
1687 ? "This environment is available to your other devices through T3 Cloud."
1688 : "Make this environment available to your other devices through T3 Cloud."
1689 }
1690 status={operationError ?? primaryCloudLinkState.error}
1691 control={
1692 <CloudLinkSwitch
1693 checked={linked}
1694 disabled={!canManageRelay || !isSignedIn || primaryCloudLinkState.isPending || isUpdating}
1695 disabledReason={disabledReason}
1696 onCheckedChange={(enabled) => void updateLink(enabled)}
1711 <>
1712 <SettingsRow
1713 title="T3 Connect"
1714 description={
1715 linked
1716 ? "This environment is available to your other devices through T3 Connect."
1717 : "Make this environment available to your other devices through T3 Connect."
1718 }
1719 status={operationError ?? primaryCloudLinkState.error}
1720 control={
1721 <CloudLinkSwitch
1722 checked={linked}
1723 disabled={
1724 (isSignedIn && !canManageRelay) || primaryCloudLinkState.isPending || isUpdating
1725 }
1726 disabledReason={disabledReason}
1727 onCheckedChange={(enabled) => {
1728 if (!isSignedIn) {
1729 openAuthPrompt();
1730 return;
1731 }
1732 void updateLink(enabled);
1733 }}
1734 />
1735 }
1736 />
1737 {linked ? (
1738 <SettingsRow
1739 title="Publish agent activity"
1740 description="Send agent activity from this environment to your notification devices."
1741 className="bg-muted/20 pl-7 sm:pl-8"
1742 control={
1743 <Switch
1744 aria-label="Publish agent activity"
1745 checked={primaryCloudLinkState.data?.publishAgentActivity ?? false}
1746 disabled={
1747 !canManageRelay ||
1748 !isSignedIn ||
1749 primaryCloudLinkState.isPending ||
1750 isUpdatingPreference
1751 }
1752 onCheckedChange={(enabled) => void updatePublishAgentActivity(enabled)}
1753 />
1754 }
16971755 />
1698 }
1699 />
1756 ) : null}
1757 {authPrompt}
1758 </>
17001759 );
17011760}
17021761
......@@ -1704,7 +1763,13 @@ function CloudLinkRow({ canManageRelay }: { readonly canManageRelay: boolean })
17041763 return hasCloudPublicConfig() ? <ConfiguredCloudLinkRow canManageRelay={canManageRelay} /> : null;
17051764}
17061765
1707function EmptyRemoteEnvironments({ cloudEnabled = true }: { readonly cloudEnabled?: boolean }) {
1766function EmptyRemoteEnvironments({
1767 cloudEnabled = true,
1768 onConnectFromCloud,
1769}: {
1770 readonly cloudEnabled?: boolean;
1771 readonly onConnectFromCloud?: () => void;
1772}) {
17081773 return (
17091774 <Empty className="min-h-52">
17101775 <EmptyMedia variant="icon">
......@@ -1713,9 +1778,24 @@ function EmptyRemoteEnvironments({ cloudEnabled = true }: { readonly cloudEnable
17131778 <EmptyHeader>
17141779 <EmptyTitle>No saved remote environments</EmptyTitle>
17151780 <EmptyDescription>
1716 {cloudEnabled
1717 ? "Click “Add environment” to pair another environment, or connect one from T3 Cloud."
1718 : "Click “Add environment” to pair another environment."}
1781 Click “Add environment” to pair another environment
1782 {cloudEnabled ? (
1783 <>
1784 , or connect one from{" "}
1785 {onConnectFromCloud ? (
1786 <button
1787 type="button"
1788 className="border-b border-dotted border-current text-foreground/80 hover:text-foreground"
1789 onClick={onConnectFromCloud}
1790 >
1791 T3 Connect
1792 </button>
1793 ) : (
1794 "T3 Connect"
1795 )}
1796 </>
1797 ) : null}
1798 .
17191799 </EmptyDescription>
17201800 </EmptyHeader>
17211801 </Empty>
......@@ -1743,7 +1823,8 @@ function ConfiguredCloudRemoteEnvironmentRows({
17431823 readonly primaryEnvironmentId: EnvironmentId | null;
17441824 readonly savedEnvironmentIds: ReadonlyArray<EnvironmentId>;
17451825}) {
1746 const { getToken } = useAuth();
1826 const { getToken, isSignedIn } = useAuth();
1827 const { authPrompt, openAuthPrompt } = useT3ConnectAuthPrompt();
17471828 const environmentsState = useManagedRelayEnvironments();
17481829 const [connectingEnvironmentId, setConnectingEnvironmentId] = useState<EnvironmentId | null>(
17491830 null,
......@@ -1755,7 +1836,7 @@ function ConfiguredCloudRemoteEnvironmentRows({
17551836 try {
17561837 const clerkToken = await getToken(resolveRelayClerkTokenOptions());
17571838 if (!clerkToken) {
1758 throw new Error("Sign in from T3 Cloud settings before connecting this environment.");
1839 throw new Error("Sign in to T3 Connect before connecting this environment.");
17591840 }
17601841 const connection = await webRuntime.runPromise(
17611842 connectManagedCloudEnvironment({ clerkToken, environment }),
......@@ -1764,14 +1845,14 @@ function ConfiguredCloudRemoteEnvironmentRows({
17641845 toastManager.add({
17651846 type: "success",
17661847 title: "Environment connected",
1767 description: `${connection.label} is available through T3 Cloud.`,
1848 description: `${connection.label} is available through T3 Connect.`,
17681849 });
17691850 } catch (cause) {
17701851 toastManager.add({
17711852 type: "error",
17721853 title: "Could not connect environment",
17731854 description:
1774 cause instanceof Error ? cause.message : "Could not connect the T3 Cloud environment.",
1855 cause instanceof Error ? cause.message : "Could not connect the T3 Connect environment.",
17751856 });
17761857 } finally {
17771858 setConnectingEnvironmentId(null);
......@@ -1789,7 +1870,12 @@ function ConfiguredCloudRemoteEnvironmentRows({
17891870 }
17901871
17911872 if (savedEnvironmentIds.length === 0 && connectableEnvironments.length === 0) {
1792 return <EmptyRemoteEnvironments />;
1873 return (
1874 <>
1875 <EmptyRemoteEnvironments {...(!isSignedIn ? { onConnectFromCloud: openAuthPrompt } : {})} />
1876 {authPrompt}
1877 </>
1878 );
17931879 }
17941880
17951881 return connectableEnvironments.map((environment) => (
......@@ -1799,11 +1885,11 @@ function ConfiguredCloudRemoteEnvironmentRows({
17991885 <div className="flex items-center gap-2">
18001886 <ConnectionStatusDot
18011887 dotClassName="bg-muted-foreground/35"
1802 tooltipText="Available through T3 Cloud"
1888 tooltipText="Available through T3 Connect"
18031889 />
18041890 <p className="truncate text-sm font-medium">{environment.label}</p>
18051891 </div>
1806 <p className="mt-1 truncate text-xs text-muted-foreground">T3 Cloud</p>
1892 <p className="mt-1 truncate text-xs text-muted-foreground">T3 Connect</p>
18071893 </div>
18081894 <Button
18091895 size="sm"
apps/web/src/components/settings/SettingsSidebarNav.tsx+18-26
......@@ -3,7 +3,6 @@ import {
33 ArchiveIcon,
44 ArrowLeftIcon,
55 BotIcon,
6 CloudIcon,
76 GitBranchIcon,
87 KeyboardIcon,
98 Link2Icon,
......@@ -21,15 +20,13 @@ import {
2120 SidebarSeparator,
2221 useSidebar,
2322} from "../ui/sidebar";
24import { Badge } from "../ui/badge";
25import { hasCloudPublicConfig } from "../../cloud/publicConfig";
23import { T3ConnectSidebarAvatar, T3ConnectSidebarSignIn } from "../clerk/T3ConnectSidebarSignIn";
2624
2725export type SettingsSectionPath =
2826 | "/settings/general"
2927 | "/settings/keybindings"
3028 | "/settings/providers"
3129 | "/settings/source-control"
32 | "/settings/cloud"
3330 | "/settings/connections"
3431 | "/settings/archived";
3532
......@@ -37,13 +34,11 @@ export const SETTINGS_NAV_ITEMS: ReadonlyArray<{
3734 label: string;
3835 to: SettingsSectionPath;
3936 icon: ComponentType<{ className?: string }>;
40 badgeLabel?: string;
4137}> = [
4238 { label: "General", to: "/settings/general", icon: Settings2Icon },
4339 { label: "Keybindings", to: "/settings/keybindings", icon: KeyboardIcon },
4440 { label: "Providers", to: "/settings/providers", icon: BotIcon },
4541 { label: "Source Control", to: "/settings/source-control", icon: GitBranchIcon },
46 { label: "T3 Cloud", to: "/settings/cloud", icon: CloudIcon, badgeLabel: "Private Beta" },
4742 { label: "Connections", to: "/settings/connections", icon: Link2Icon },
4843 { label: "Archive", to: "/settings/archived", icon: ArchiveIcon },
4944];
......@@ -77,9 +72,7 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {
7772 <SidebarContent className="overflow-x-hidden">
7873 <SidebarGroup className="px-2 py-3">
7974 <SidebarMenu>
80 {SETTINGS_NAV_ITEMS.filter(
81 (item) => item.to !== "/settings/cloud" || hasCloudPublicConfig(),
82 ).map((item) => {
75 {SETTINGS_NAV_ITEMS.map((item) => {
8376 const Icon = item.icon;
8477 const isActive = pathname === item.to;
8578 return (
......@@ -102,11 +95,6 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {
10295 }
10396 />
10497 <span className="truncate">{item.label}</span>
105 {item.badgeLabel ? (
106 <Badge variant="warning" size="sm" className="ml-auto">
107 {item.badgeLabel}
108 </Badge>
109 ) : null}
11098 </SidebarMenuButton>
11199 </SidebarMenuItem>
112100 );
......@@ -117,18 +105,22 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {
117105
118106 <SidebarSeparator />
119107 <SidebarFooter className="p-2">
120 <SidebarMenu>
121 <SidebarMenuItem>
122 <SidebarMenuButton
123 size="sm"
124 className="gap-2 px-2 py-2 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
125 onClick={handleBackClick}
126 >
127 <ArrowLeftIcon className="size-4" />
128 <span>Back</span>
129 </SidebarMenuButton>
130 </SidebarMenuItem>
131 </SidebarMenu>
108 <T3ConnectSidebarSignIn />
109 <div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-1">
110 <SidebarMenu className="min-w-0">
111 <SidebarMenuItem>
112 <SidebarMenuButton
113 size="sm"
114 className="gap-2 px-2 py-2 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
115 onClick={handleBackClick}
116 >
117 <ArrowLeftIcon className="size-4" />
118 <span>Back</span>
119 </SidebarMenuButton>
120 </SidebarMenuItem>
121 </SidebarMenu>
122 <T3ConnectSidebarAvatar />
123 </div>
132124 </SidebarFooter>
133125 </>
134126 );
apps/web/src/environments/runtime/service.ts+1-1
......@@ -1582,7 +1582,7 @@ async function ensureSavedEnvironmentConnection(
15821582 await removeSavedEnvironmentBearerToken(activeRecord.environmentId);
15831583 throw new Error(
15841584 activeCredential.current.method === "dpop"
1585 ? "Managed tunnel credential expired. Connect it again from T3 Cloud."
1585 ? "Managed tunnel credential expired. Connect it again from T3 Connect."
15861586 : "Saved environment credential expired. Pair it again.",
15871587 {
15881588 cause: error,
apps/web/src/main.tsx+1-2
......@@ -27,7 +27,6 @@ if (isElectron) {
2727document.title = APP_DISPLAY_NAME;
2828
2929const clerkPublishableKey = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY as string | undefined;
30const cloudWaitlistUrl = "/settings/cloud";
3130
3231const app = <RouterProvider router={router} />;
3332
......@@ -39,7 +38,7 @@ ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
3938 <ManagedRelayAuthProvider>{app}</ManagedRelayAuthProvider>
4039 </DesktopClerkProvider>
4140 ) : (
42 <ClerkProvider publishableKey={clerkPublishableKey} waitlistUrl={cloudWaitlistUrl}>
41 <ClerkProvider publishableKey={clerkPublishableKey}>
4342 <ManagedRelayAuthProvider>{app}</ManagedRelayAuthProvider>
4443 </ClerkProvider>
4544 )
apps/web/src/routeTree.gen.ts-21
......@@ -19,7 +19,6 @@ import { Route as SettingsKeybindingsRouteImport } from './routes/settings.keybi
1919import { Route as SettingsGeneralRouteImport } from './routes/settings.general'
2020import { Route as SettingsDiagnosticsRouteImport } from './routes/settings.diagnostics'
2121import { Route as SettingsConnectionsRouteImport } from './routes/settings.connections'
22import { Route as SettingsCloudRouteImport } from './routes/settings.cloud'
2322import { Route as SettingsArchivedRouteImport } from './routes/settings.archived'
2423import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId'
2524import { Route as ChatEnvironmentIdThreadIdRouteImport } from './routes/_chat.$environmentId.$threadId'
......@@ -73,11 +72,6 @@ const SettingsConnectionsRoute = SettingsConnectionsRouteImport.update({
7372 path: '/connections',
7473 getParentRoute: () => SettingsRoute,
7574} as any)
76const SettingsCloudRoute = SettingsCloudRouteImport.update({
77 id: '/cloud',
78 path: '/cloud',
79 getParentRoute: () => SettingsRoute,
80} as any)
8175const SettingsArchivedRoute = SettingsArchivedRouteImport.update({
8276 id: '/archived',
8377 path: '/archived',
......@@ -100,7 +94,6 @@ export interface FileRoutesByFullPath {
10094 '/pair': typeof PairRoute
10195 '/settings': typeof SettingsRouteWithChildren
10296 '/settings/archived': typeof SettingsArchivedRoute
103 '/settings/cloud': typeof SettingsCloudRoute
10497 '/settings/connections': typeof SettingsConnectionsRoute
10598 '/settings/diagnostics': typeof SettingsDiagnosticsRoute
10699 '/settings/general': typeof SettingsGeneralRoute
......@@ -114,7 +107,6 @@ export interface FileRoutesByTo {
114107 '/pair': typeof PairRoute
115108 '/settings': typeof SettingsRouteWithChildren
116109 '/settings/archived': typeof SettingsArchivedRoute
117 '/settings/cloud': typeof SettingsCloudRoute
118110 '/settings/connections': typeof SettingsConnectionsRoute
119111 '/settings/diagnostics': typeof SettingsDiagnosticsRoute
120112 '/settings/general': typeof SettingsGeneralRoute
......@@ -131,7 +123,6 @@ export interface FileRoutesById {
131123 '/pair': typeof PairRoute
132124 '/settings': typeof SettingsRouteWithChildren
133125 '/settings/archived': typeof SettingsArchivedRoute
134 '/settings/cloud': typeof SettingsCloudRoute
135126 '/settings/connections': typeof SettingsConnectionsRoute
136127 '/settings/diagnostics': typeof SettingsDiagnosticsRoute
137128 '/settings/general': typeof SettingsGeneralRoute
......@@ -149,7 +140,6 @@ export interface FileRouteTypes {
149140 | '/pair'
150141 | '/settings'
151142 | '/settings/archived'
152 | '/settings/cloud'
153143 | '/settings/connections'
154144 | '/settings/diagnostics'
155145 | '/settings/general'
......@@ -163,7 +153,6 @@ export interface FileRouteTypes {
163153 | '/pair'
164154 | '/settings'
165155 | '/settings/archived'
166 | '/settings/cloud'
167156 | '/settings/connections'
168157 | '/settings/diagnostics'
169158 | '/settings/general'
......@@ -179,7 +168,6 @@ export interface FileRouteTypes {
179168 | '/pair'
180169 | '/settings'
181170 | '/settings/archived'
182 | '/settings/cloud'
183171 | '/settings/connections'
184172 | '/settings/diagnostics'
185173 | '/settings/general'
......@@ -269,13 +257,6 @@ declare module '@tanstack/react-router' {
269257 preLoaderRoute: typeof SettingsConnectionsRouteImport
270258 parentRoute: typeof SettingsRoute
271259 }
272 '/settings/cloud': {
273 id: '/settings/cloud'
274 path: '/cloud'
275 fullPath: '/settings/cloud'
276 preLoaderRoute: typeof SettingsCloudRouteImport
277 parentRoute: typeof SettingsRoute
278 }
279260 '/settings/archived': {
280261 id: '/settings/archived'
281262 path: '/archived'
......@@ -316,7 +297,6 @@ const ChatRouteWithChildren = ChatRoute._addFileChildren(ChatRouteChildren)
316297
317298interface SettingsRouteChildren {
318299 SettingsArchivedRoute: typeof SettingsArchivedRoute
319 SettingsCloudRoute: typeof SettingsCloudRoute
320300 SettingsConnectionsRoute: typeof SettingsConnectionsRoute
321301 SettingsDiagnosticsRoute: typeof SettingsDiagnosticsRoute
322302 SettingsGeneralRoute: typeof SettingsGeneralRoute
......@@ -327,7 +307,6 @@ interface SettingsRouteChildren {
327307
328308const SettingsRouteChildren: SettingsRouteChildren = {
329309 SettingsArchivedRoute: SettingsArchivedRoute,
330 SettingsCloudRoute: SettingsCloudRoute,
331310 SettingsConnectionsRoute: SettingsConnectionsRoute,
332311 SettingsDiagnosticsRoute: SettingsDiagnosticsRoute,
333312 SettingsGeneralRoute: SettingsGeneralRoute,
apps/web/src/routes/_chat.index.tsx+4-7
......@@ -1,4 +1,4 @@
1import { createFileRoute } from "@tanstack/react-router";
1import { createFileRoute, Link } from "@tanstack/react-router";
22import { LinkIcon, PlusIcon } from "lucide-react";
33
44import { NoActiveThreadState } from "../components/NoActiveThreadState";
......@@ -52,16 +52,13 @@ function HostedStaticOnboardingState() {
5252 </EmptyTitle>
5353 <EmptyDescription className="mt-2 text-sm leading-relaxed text-muted-foreground/78">
5454 {cloudEnabled
55 ? "Sign in to T3 Cloud to connect a linked environment through its managed tunnel, or add a reachable backend manually."
55 ? "Sign in to T3 Connect to connect a linked environment through its managed tunnel, or add a reachable backend manually."
5656 : "Add a reachable backend manually to start working from this browser."}
5757 </EmptyDescription>
5858 <div className="mt-6 flex justify-center">
59 <Button
60 render={<a href={cloudEnabled ? "/settings/cloud" : "/settings/connections"} />}
61 size="sm"
62 >
59 <Button render={<Link to="/settings/connections" />} size="sm">
6360 <PlusIcon className="size-4" />
64 {cloudEnabled ? "Open T3 Cloud" : "Add environment"}
61 {cloudEnabled ? "Open Connections" : "Add environment"}
6562 </Button>
6663 </div>
6764 </EmptyHeader>
apps/web/src/routes/settings.cloud.tsx deleted-13
......@@ -1,13 +0,0 @@
1import { createFileRoute, redirect } from "@tanstack/react-router";
2
3import { CloudSettingsPanel } from "../components/settings/CloudSettings";
4import { hasCloudPublicConfig } from "../cloud/publicConfig";
5
6export const Route = createFileRoute("/settings/cloud")({
7 beforeLoad: () => {
8 if (!hasCloudPublicConfig()) {
9 throw redirect({ to: "/settings/general", replace: true });
10 }
11 },
12 component: CloudSettingsPanel,
13});
docs/README.md+1-1
......@@ -2,7 +2,7 @@
22
33- [Getting started](./getting-started/quick-start.md)
44- [Architecture](./architecture/overview.md)
5- [Cloud](./cloud/t3-cloud-clerk.md)
5- [T3 Connect](./cloud/t3-connect-clerk.md)
66- [Integrations](./integrations/source-control-providers.md)
77- [Mobile](./mobile/app.md)
88- [Operations](./operations/ci.md)
docs/cloud/t3-cloud-clerk.md deleted-174
......@@ -1,174 +0,0 @@
1# T3 Cloud Clerk Setup
2
3T3 Cloud uses one Clerk application for web, desktop, and mobile authentication. The relay accepts
4Clerk JWTs only when they are generated from the `t3-relay` template with the shared
5`t3-code-relay` audience.
6
7## Application Keys
8
9T3 Cloud is disabled in a fresh clone. To enable it for source builds, add a repository-root `.env`
10or `.env.local` file:
11
12```dotenv
13T3CODE_CLERK_PUBLISHABLE_KEY=<publishable key>
14T3CODE_CLERK_JWT_TEMPLATE=<JWT template name>
15T3CODE_CLERK_CLI_OAUTH_CLIENT_ID=<public OAuth application client ID>
16T3CODE_RELAY_URL=https://relay.example.com
17```
18
19The shared client loader projects these canonical values into framework-specific `VITE_*` and
20`EXPO_PUBLIC_*` aliases. Existing aliases remain accepted as overrides for compatibility, but new
21client configuration should use the canonical names.
22
23Configuration precedence is:
24
251. Process or CI environment variables.
262. Repository-root `.env.local`.
273. Repository-root `.env`.
28
29The Clerk publishable key, JWT template name, CLI OAuth client ID, and relay URL are public
30identifiers, not secrets.
31Web, desktop, mobile, and bundled server builds statically inject the values they consume during
32their build step. A built artifact does not need an environment file at runtime. CI release builds
33should set `T3CODE_CLERK_PUBLISHABLE_KEY`, `T3CODE_CLERK_JWT_TEMPLATE`,
34`T3CODE_CLERK_CLI_OAUTH_CLIENT_ID`, and `T3CODE_RELAY_URL` before building. EAS preview and
35production builds only need the Clerk publishable key, JWT template name, and relay URL in their EAS
36environment.
37
38When any client-facing public value is absent, cloud UI is omitted. When the CLI public values are
39absent, the `t3 cloud` CLI command group is omitted. The bundled server still accepts runtime
40overrides for self-hosted or operator-managed
41deployments.
42
43For a hosted relay deployment, copy `infra/relay/.env.example` to `infra/relay/.env`. The relay
44deployment reads `RELAY_DOMAIN`, `RELAY_API_ZONE_NAME`, `RELAY_TUNNEL_ZONE_NAME`,
45`CLERK_PUBLISHABLE_KEY`, and `CLERK_JWT_AUDIENCE` through Effect `Config`. There are no checked-in
46deployment defaults.
47`vp run --filter t3code-relay deploy` invokes Alchemy from the relay directory, so Alchemy loads
48`infra/relay/.env`. After a successful deployment, the wrapper updates the repository-root `.env`
49with the deployed HTTPS relay URL. The relay still requires
50`CLERK_SECRET_KEY` as an Alchemy secret. Never put `CLERK_SECRET_KEY` in a client application
51environment or commit it to the repository.
52
53The `prod` Alchemy stage owns the retained PlanetScale database. Non-production stages reference
54that database and provision isolated PlanetScale branches, so deploy `prod` before creating a
55personal developer stage.
56
57## Headless CLI OAuth Application
58
59The `t3 cloud` commands authorize a headless environment with a separate Clerk OAuth application.
60This uses an OAuth public client with PKCE, so the CLI stores no client secret.
61
62In **Clerk Dashboard > OAuth applications**:
63
641. Create an OAuth application for the T3 CLI.
652. Enable the **Public** option so authorization-code exchange uses PKCE.
663. Add `http://127.0.0.1:34338/callback` as an allowed redirect URI.
674. Enable the `openid`, `profile`, and `email` scopes.
685. Set `T3CODE_CLERK_CLI_OAUTH_CLIENT_ID` in the repository-root `.env` file and release build
69 environment to the generated public client ID.
70
71The CLI derives Clerk's frontend API URL from the publishable key and calls Clerk's
72`/oauth/authorize` and `/oauth/token` endpoints directly. The relay is not involved in the OAuth
73handshake; it only validates the issued Clerk bearer token when the CLI manages an environment link.
74
75The CLI supports these headless operations:
76
77```sh
78t3 cloud login
79t3 cloud link
80t3 cloud status
81t3 cloud unlink
82t3 cloud logout
83t3 serve
84```
85
86`t3 cloud login` opens the Clerk authorization flow and stores the CLI credential without enabling
87cloud exposure. `t3 cloud link` installs the pinned managed `cloudflared` binary when needed,
88authorizes when needed, and records durable intent to expose the environment. It works without a
89running T3 server. The next `t3 serve` or `t3 start` reconciles the relay link and launches the
90managed tunnel. `t3 cloud unlink` records disabled intent immediately, stops a reachable running
91connector, and attempts to revoke the relay-side environment record. It retains the stored CLI
92authorization so `t3 cloud link` can re-enable exposure without another browser flow. `t3 cloud
93logout` performs the same cleanup and removes the stored CLI authorization.
94
95The current OAuth callback listener binds to loopback port `34338`. When running the CLI over SSH,
96forward that port before running `t3 cloud login` or `t3 cloud link`:
97
98```sh
99ssh -L 34338:127.0.0.1:34338 <host>
100```
101
102A relay-hosted callback broker can remove this port-forward requirement later without changing the
103stored PKCE token model.
104
105## JWT Template
106
107In **Clerk Dashboard > JWT templates**, create a template with:
108
109| Setting | Value |
110| ------- | ---------------------------- |
111| Name | `t3-relay` |
112| Claims | `{ "aud": "t3-code-relay" }` |
113
114Set `T3CODE_CLERK_JWT_TEMPLATE=t3-relay` in the repository-root `.env`, and set
115`CLERK_JWT_AUDIENCE=t3-code-relay` in `infra/relay/.env`. Define `CLERK_JWT_TEMPLATE` and
116`CLERK_JWT_AUDIENCE` in the production relay deployment environment as well. The stable `aud` value
117is shared by production and non-production relay stages. The client-facing `T3CODE_RELAY_URL` still
118selects the concrete relay deployment, but changing that URL does not require a JWT template change.
119
120## Desktop OAuth Redirect Allowlist
121
122The desktop app opens OAuth in the system browser and returns to the app with a custom URL scheme.
123In **Clerk Dashboard > Native applications**, enable native application support and add these
124entries under the mobile SSO redirect allowlist:
125
126```text
127t3code-dev://auth/callback
128t3code://auth/callback
129```
130
131The first entry is for local desktop development. The second is for packaged desktop builds.
132The app also adds a request-scoped `t3_state` query parameter and validates it on callback. Initial
133sign-in and linked-account OAuth flows both return through this bridge. The desktop provider keeps
134Clerk's stock profile component, replaces its renderer-page callback with the custom-scheme callback,
135and opens the provider URL in the system browser. Do not add the local renderer URL as an OAuth
136redirect: an external browser cannot use it to reopen the packaged app.
137
138The current mobile UI uses Clerk's native authentication view. If a future mobile browser OAuth
139flow uses a custom redirect URI, add that exact URI to the same allowlist.
140
141## Enable Waitlist Access
142
143For a private beta where people should request access, use **Clerk Dashboard > Waitlist**:
144
1451. Toggle on **Enable waitlist** and save.
1462. Review requests on the same page and select **Invite** or **Deny**.
147
148Signed-out web and desktop users see Clerk's waitlist enrollment as the T3 Cloud page content,
149while approved signed-in users see cloud settings. The browser app also uses `/settings/cloud` as
150its Clerk waitlist URL.
151
152On mobile, signed-out users open **Settings > T3 Account** to reach `/settings/waitlist` within the
153Settings form sheet. It submits enrollment through Clerk's `useWaitlist()` flow because the prebuilt
154`<Waitlist />` component is web-only in the Expo SDK. Approved users can use **Sign in** from that
155screen.
156
157## Alternative: Known-User Allowlist
158
159For a closed beta where all permitted users are known in advance, use an allowlist instead of a
160request-and-approval waitlist:
161
162To restrict the beta to permitted email addresses or domains:
163
1641. In **Clerk Dashboard > Restrictions > Allowlist**, add each permitted email address or email
165 domain.
1662. Enable the allowlist and save.
1673. Alternatively, enable **Restricted mode** when all new users must be explicitly invited or
168 manually created without a waitlist request flow.
169
170Do not enable an empty allowlist: it blocks all new sign-ups.
171
172Clerk allowlists control who can sign up. They do not revoke an existing user's active cloud
173access. To remove an already-created user's access, ban that user in Clerk so their active
174sessions are ended and future sign-ins are rejected.
docs/cloud/t3-code-cloud-auth-flow.html deleted-1160
......@@ -1,1160 +0,0 @@
1<!doctype html>
2<html lang="en">
3 <head>
4 <meta charset="utf-8" />
5 <meta name="viewport" content="width=device-width, initial-scale=1" />
6 <title>T3 Code Cloud Architecture</title>
7 <style>
8 :root {
9 color-scheme: light;
10 --ink: #171717;
11 --muted: #62646b;
12 --hairline: #d8d8dd;
13 --panel: #ffffff;
14 --paper: #f5f5f2;
15 --blue: #1457d9;
16 --green: #1d7d4d;
17 --amber: #a65f00;
18 --red: #b8242c;
19 --violet: #6150b8;
20 --shadow: 0 18px 55px rgba(25, 25, 28, 0.08);
21 }
22
23 * {
24 box-sizing: border-box;
25 }
26
27 body {
28 margin: 0;
29 background:
30 linear-gradient(90deg, rgba(0, 0, 0, 0.035) 1px, transparent 1px),
31 linear-gradient(rgba(0, 0, 0, 0.035) 1px, transparent 1px), var(--paper);
32 background-size: 34px 34px;
33 color: var(--ink);
34 font-family:
35 ui-sans-serif,
36 -apple-system,
37 BlinkMacSystemFont,
38 "Segoe UI",
39 sans-serif;
40 line-height: 1.48;
41 }
42
43 .shell {
44 width: min(1540px, calc(100vw - 40px));
45 margin: 0 auto;
46 padding: 38px 0 72px;
47 }
48
49 header {
50 border-bottom: 2px solid var(--ink);
51 padding-bottom: 24px;
52 margin-bottom: 28px;
53 }
54
55 .eyebrow {
56 color: var(--blue);
57 font-size: 12px;
58 font-weight: 800;
59 letter-spacing: 0.08em;
60 text-transform: uppercase;
61 }
62
63 h1 {
64 max-width: 980px;
65 margin: 8px 0 12px;
66 font-size: clamp(34px, 5vw, 66px);
67 line-height: 0.98;
68 }
69
70 h2 {
71 margin: 0 0 14px;
72 font-size: 26px;
73 line-height: 1.08;
74 }
75
76 h3 {
77 margin: 22px 0 8px;
78 font-size: 17px;
79 }
80
81 p {
82 margin: 0 0 12px;
83 }
84
85 code,
86 .mono {
87 font-family:
88 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
89 }
90
91 code {
92 border-radius: 4px;
93 background: #f0f0ed;
94 padding: 1px 4px;
95 font-size: 0.92em;
96 }
97
98 .lede {
99 max-width: 1000px;
100 margin: 0;
101 color: var(--muted);
102 font-size: 18px;
103 }
104
105 .grid {
106 display: grid;
107 grid-template-columns: repeat(12, 1fr);
108 gap: 18px;
109 }
110
111 .panel {
112 grid-column: span 12;
113 border: 1px solid var(--hairline);
114 border-radius: 10px;
115 background: var(--panel);
116 box-shadow: var(--shadow);
117 padding: 22px;
118 }
119
120 .half {
121 grid-column: span 6;
122 }
123
124 .callout {
125 border-left: 5px solid var(--blue);
126 background: #eef4ff;
127 padding: 14px 16px;
128 margin-top: 14px;
129 }
130
131 .callout.warning {
132 border-left-color: var(--amber);
133 background: #fff6e6;
134 }
135
136 .badge-row {
137 display: flex;
138 flex-wrap: wrap;
139 gap: 8px;
140 margin-top: 14px;
141 }
142
143 .badge {
144 border: 1px solid var(--hairline);
145 border-radius: 999px;
146 background: #fff;
147 padding: 5px 9px;
148 font-size: 12px;
149 font-weight: 700;
150 }
151
152 .badge.user {
153 border-color: rgba(20, 87, 217, 0.3);
154 color: var(--blue);
155 }
156
157 .badge.env {
158 border-color: rgba(29, 125, 77, 0.3);
159 color: var(--green);
160 }
161
162 .badge.local {
163 border-color: rgba(166, 95, 0, 0.35);
164 color: var(--amber);
165 }
166
167 .diagram {
168 overflow-x: auto;
169 margin: 12px 0 0;
170 border: 1px solid var(--hairline);
171 border-radius: 8px;
172 background: #fbfbf8;
173 color: var(--ink);
174 padding: 18px;
175 }
176
177 .diagram svg {
178 display: block;
179 width: auto !important;
180 min-width: 100%;
181 max-width: none !important;
182 height: auto;
183 margin: 0 auto;
184 }
185
186 table {
187 width: 100%;
188 border-collapse: collapse;
189 font-size: 14px;
190 }
191
192 th,
193 td {
194 border-bottom: 1px solid var(--hairline);
195 padding: 11px 9px;
196 text-align: left;
197 vertical-align: top;
198 }
199
200 th {
201 color: var(--muted);
202 font-size: 12px;
203 letter-spacing: 0.05em;
204 text-transform: uppercase;
205 }
206
207 ul {
208 margin: 8px 0 0;
209 padding-left: 20px;
210 }
211
212 li + li {
213 margin-top: 6px;
214 }
215
216 .flow {
217 display: grid;
218 gap: 10px;
219 margin-top: 12px;
220 }
221
222 .step {
223 border: 1px solid var(--hairline);
224 border-left: 5px solid var(--green);
225 border-radius: 8px;
226 background: #fff;
227 padding: 12px 14px;
228 }
229
230 .step strong {
231 display: block;
232 margin-bottom: 3px;
233 }
234
235 .source-list a {
236 color: var(--blue);
237 font-weight: 700;
238 }
239
240 @media (max-width: 860px) {
241 .half {
242 grid-column: span 12;
243 }
244
245 .shell {
246 width: min(100vw - 24px, 1540px);
247 padding-top: 24px;
248 }
249 }
250 </style>
251 </head>
252 <body>
253 <main class="shell">
254 <header>
255 <div class="eyebrow">Implemented architecture</div>
256 <h1>T3 Code Cloud Control Plane and Managed Endpoint Flow</h1>
257 <p class="lede">
258 T3 Cloud links a locally authorized T3 environment to a signed-in cloud user, provisions a
259 managed HTTPS/WSS endpoint for that environment, and brokers proof-bound remote
260 connections. The local environment remains the authority that issues environment access
261 credentials. The hosted relay stores links, reconciles managed endpoint resources,
262 validates signed proofs, and delivers agent-activity notifications.
263 </p>
264 <div class="badge-row">
265 <span class="badge user">Client -> Relay: Clerk bearer or relay DPoP</span>
266 <span class="badge env">Relay -> Environment: relay-signed health and mint proofs</span>
267 <span class="badge env"
268 >Environment -> Relay: environment bearer plus signed activity proof</span
269 >
270 <span class="badge local">Client -> Environment: environment access token plus DPoP</span>
271 </div>
272 </header>
273
274 <section class="grid" aria-label="Architecture summary">
275 <article class="panel half">
276 <h2>Security Invariant</h2>
277 <p>
278 A cloud identity is not an environment login. Remote access requires all of the
279 following:
280 </p>
281 <ul>
282 <li>a Clerk-authenticated T3 Cloud user;</li>
283 <li>an active relay link for that user and environment;</li>
284 <li>a DPoP proof key held by the remote client;</li>
285 <li>a relay-signed mint request accepted by the linked local environment; and</li>
286 <li>an environment-issued credential bound to the remote client's DPoP thumbprint.</li>
287 </ul>
288 <div class="callout">
289 The relay cannot mint an environment access token by itself. It can only ask the local
290 environment to mint a short-lived bootstrap credential for an authorized cloud user.
291 </div>
292 <p>During an honest connection flow, the relay cannot act as the remote client:</p>
293 <ul>
294 <li>
295 Normal environment API traffic goes directly from the remote client through the
296 managed endpoint. The relay does not receive the resulting environment access token.
297 </li>
298 <li>
299 The one-time bootstrap credential and resulting environment access token are bound to
300 the remote client's DPoP public-key thumbprint. Redeeming or using them requires
301 request proofs signed by the corresponding private key, which stays on the client.
302 </li>
303 <li>
304 Relay-signed health and mint proofs are short-lived, audience-bound, scoped, and
305 replay-guarded. They are accepted only by the narrow cloud endpoints, not as normal
306 environment API credentials.
307 </li>
308 <li>
309 Environment health and mint responses are signed by the linked environment and bound
310 to the request nonce, so a different process behind the tunnel cannot impersonate the
311 linked environment.
312 </li>
313 </ul>
314 <div class="callout warning">
315 The hosted relay remains a trusted mint broker: it holds the cloud-mint signing key.
316 DPoP prevents credential reuse by the relay or tunnel transport during an honest flow,
317 but it does not make compromise of the relay signing authority harmless.
318 </div>
319 </article>
320
321 <article class="panel half">
322 <h2>Product Boundaries</h2>
323 <p>
324 The generic local connector is called the <strong>relay client</strong>. The current
325 implementation layer uses <code>cloudflared</code>, but that is an internal transport
326 detail rather than the product contract.
327 </p>
328 <ul>
329 <li>
330 The relay Worker owns cloud links, managed endpoint allocations, and notifications.
331 </li>
332 <li>
333 The environment server owns local sessions, environment keys, and access tokens.
334 </li>
335 <li>The relay client exposes only the environment's loopback HTTP server.</li>
336 <li>
337 Web and mobile clients connect directly to the managed environment endpoint after
338 bootstrap.
339 </li>
340 </ul>
341 </article>
342 </section>
343
344 <section class="panel" aria-label="Topology">
345 <h2>Current Topology</h2>
346 <pre class="diagram mermaid">
347flowchart LR
348 subgraph Clients["User-facing clients"]
349 Web["Desktop / hosted web UI"]
350 Mobile["Mobile app"]
351 CLI["Headless CLI"]
352 end
353
354 Clerk["Clerk<br/>user identity and OAuth"]
355
356 subgraph Relay["T3 Code Relay on Cloudflare"]
357 Worker["Relay Worker<br/>HTTP API"]
358 Queue["APNs delivery queue<br/>with dead-letter queue"]
359 Hyperdrive["Hyperdrive"]
360 CF["Cloudflare tunnel + DNS APIs"]
361 end
362
363 DB["PlanetScale Postgres<br/>prod database + stage branches"]
364 APNs["Apple Push Notification service"]
365 Traces["Axiom OTLP traces"]
366
367 subgraph Local["Linked T3 environment"]
368 Env["Environment server<br/>local auth authority"]
369 RelayClient["Relay client<br/>cloudflared implementation"]
370 Secrets["Server secret store<br/>keys + relay config"]
371 end
372
373 Endpoint["Managed HTTPS/WSS endpoint"]
374
375 Web -->|"Clerk bearer / relay DPoP"| Worker
376 Mobile -->|"Clerk bearer / relay DPoP"| Worker
377 CLI -->|"Clerk OAuth bearer"| Worker
378 Web --> Clerk
379 Mobile --> Clerk
380 CLI -->|"PKCE authorization code flow"| Clerk
381 Worker --> Hyperdrive --> DB
382 Worker --> Traces
383 Worker --> CF
384 Worker --> Queue --> APNs
385 Worker --> Endpoint
386 Endpoint --> RelayClient -->|"http://127.0.0.1:port"| Env
387 Env --> Secrets
388 Web -->|"environment token + DPoP<br/>HTTPS / WSS"| Endpoint
389 Mobile -->|"environment token + DPoP<br/>HTTPS / WSS"| Endpoint
390 Env -->|"environment bearer + signed activity proof"| Worker
391 </pre>
392 </section>
393
394 <section class="panel" aria-label="Authentication matrix">
395 <h2>Authentication and Transport Matrix</h2>
396 <table>
397 <thead>
398 <tr>
399 <th>Boundary</th>
400 <th>Transport</th>
401 <th>Authentication</th>
402 <th>Purpose</th>
403 </tr>
404 </thead>
405 <tbody>
406 <tr>
407 <td>Web or mobile -> relay link management</td>
408 <td>HTTPS</td>
409 <td>Clerk session-template bearer token</td>
410 <td>Create a link challenge, submit an environment proof, list links, or unlink.</td>
411 </tr>
412 <tr>
413 <td>CLI -> relay link management</td>
414 <td>HTTPS</td>
415 <td>Clerk OAuth access token obtained with PKCE</td>
416 <td>Reconcile the desired headless cloud link when the server starts.</td>
417 </tr>
418 <tr>
419 <td>Web or mobile -> relay protected client endpoints</td>
420 <td>HTTPS</td>
421 <td>Relay-issued DPoP token plus per-request DPoP proof</td>
422 <td>
423 Check environment status, request a remote connection, and register mobile devices.
424 </td>
425 </tr>
426 <tr>
427 <td>Relay -> managed environment endpoint</td>
428 <td>HTTPS over the managed tunnel</td>
429 <td>Short-lived relay-signed JWT request proof</td>
430 <td>
431 Request a signed health response or ask the local environment to mint a credential.
432 </td>
433 </tr>
434 <tr>
435 <td>Environment -> relay activity publication</td>
436 <td>HTTPS</td>
437 <td>Relay-issued environment bearer credential plus environment-signed JWT proof</td>
438 <td>
439 Publish redacted agent-activity state for push notifications and Live Activities.
440 </td>
441 </tr>
442 <tr>
443 <td>Remote client -> environment</td>
444 <td>HTTPS and WSS over the managed tunnel</td>
445 <td>Environment-issued access token bound to the client DPoP key</td>
446 <td>Use the normal environment HTTP APIs and request a one-time WebSocket ticket.</td>
447 </tr>
448 </tbody>
449 </table>
450 </section>
451
452 <section class="panel" aria-label="Credential ownership">
453 <h2>Credential Ownership</h2>
454 <table>
455 <thead>
456 <tr>
457 <th>Credential</th>
458 <th>Created by</th>
459 <th>Stored by</th>
460 <th>Use</th>
461 </tr>
462 </thead>
463 <tbody>
464 <tr>
465 <td>Clerk session-template JWT</td>
466 <td>Clerk</td>
467 <td>Web or mobile client</td>
468 <td>
469 Authenticate cloud-user relay requests and bootstrap relay DPoP token exchange.
470 </td>
471 </tr>
472 <tr>
473 <td>CLI OAuth access and refresh token</td>
474 <td>Clerk OAuth</td>
475 <td>Local server secret store</td>
476 <td>Authorize headless CLI link reconciliation.</td>
477 </tr>
478 <tr>
479 <td>Environment Ed25519 key pair</td>
480 <td>Local environment server</td>
481 <td>Local server secret store</td>
482 <td>
483 Sign link proofs, health responses, mint responses, and activity publication proofs.
484 </td>
485 </tr>
486 <tr>
487 <td>Relay cloud-mint key pair</td>
488 <td>Relay deployment</td>
489 <td>Private key in relay Worker config; public key installed into the environment</td>
490 <td>Sign relay-to-environment health and mint requests.</td>
491 </tr>
492 <tr>
493 <td>Relay environment bearer credential</td>
494 <td>Relay</td>
495 <td>Hashed in relay Postgres; plaintext in local server secret store</td>
496 <td>Authenticate environment-to-relay agent-activity publication.</td>
497 </tr>
498 <tr>
499 <td>Relay DPoP access token</td>
500 <td>Relay</td>
501 <td>Remote client memory cache</td>
502 <td>
503 Authorize status, connect, and mobile registration endpoints with proof of
504 possession.
505 </td>
506 </tr>
507 <tr>
508 <td>Environment bootstrap credential</td>
509 <td>Local environment server</td>
510 <td>Passed relay -> remote client</td>
511 <td>One-time exchange at the environment <code>/oauth/token</code> endpoint.</td>
512 </tr>
513 <tr>
514 <td>Environment access token</td>
515 <td>Local environment server</td>
516 <td>Remote client</td>
517 <td>Authorize environment HTTP operations and WebSocket ticket issuance.</td>
518 </tr>
519 <tr>
520 <td>WebSocket ticket</td>
521 <td>Local environment server</td>
522 <td>Remote client until consumed</td>
523 <td>Short-lived one-time credential for the WSS upgrade.</td>
524 </tr>
525 <tr>
526 <td>Relay client connector token</td>
527 <td>Cloudflare tunnel API</td>
528 <td>Local server secret store</td>
529 <td>
530 Start the local relay client. It is passed through <code>TUNNEL_TOKEN</code>, not a
531 shell argument.
532 </td>
533 </tr>
534 </tbody>
535 </table>
536 </section>
537
538 <section class="grid" aria-label="Core flows">
539 <article class="panel">
540 <h2>Flow 1: Link From the Desktop Web UI</h2>
541 <pre class="diagram mermaid">
542sequenceDiagram
543 participant U as Signed-in user
544 participant C as Desktop web UI
545 participant E as Local environment
546 participant R as Relay Worker
547 participant CF as Cloudflare APIs
548 participant RC as Local relay client
549
550 U->>C: Enable T3 Cloud for a locally paired environment
551 C->>E: Check relay client availability
552 alt relay client is missing
553 C->>U: Prompt before download and install
554 U->>C: Confirm
555 C->>E: Stream relay-client install RPC
556 E-->>C: Progress stages
557 end
558 C->>R: POST /v1/client/environment-link-challenges (Clerk bearer)
559 R-->>C: Short-lived challenge
560 C->>E: POST /api/cloud/link-proof (local relay:write bearer)
561 E->>E: Authenticate, reject forwarded authority, get or create key pair, validate loopback origin
562 E-->>C: Environment-signed link proof
563 C->>R: POST /v1/client/environment-links (Clerk bearer + proof)
564 R->>R: Verify challenge, proof, capabilities, nonce, and loopback origin
565 R->>CF: Reconcile tunnel, ingress, and CNAME
566 R->>R: Upsert user/environment link and issue environment bearer
567 R-->>C: Managed endpoint, relay config, and connector token
568 C->>E: POST /api/cloud/relay-config (local relay:write bearer)
569 E->>RC: Start relay client with TUNNEL_TOKEN
570 </pre>
571 <div class="callout">
572 The local link-proof endpoint requires <code>relay:write</code>, rejects forwarded
573 authority headers, and accepts only an exact loopback origin. Environment keypair
574 persistence is atomic across concurrent link attempts.
575 </div>
576 <p>
577 A locally paired mobile client uses the same relay challenge, local proof, relay link,
578 and local relay-config endpoints. The desktop web UI is the surface that checks relay
579 client availability and offers the managed install dialog.
580 </p>
581 </article>
582
583 <article class="panel">
584 <h2>Flow 2: Headless CLI Link</h2>
585 <pre class="diagram mermaid">
586sequenceDiagram
587 participant U as Operator
588 participant CLI as t3 cloud CLI
589 participant Clerk as Clerk OAuth
590 participant S as Environment server on next start
591 participant R as Relay Worker
592 participant RC as Local relay client
593
594 U->>CLI: t3 cloud link
595 CLI->>CLI: Resolve relay client
596 alt relay client is missing
597 CLI->>U: Confirm managed relay-client install
598 U->>CLI: Confirm
599 CLI->>CLI: Install with terminal progress updates
600 end
601 CLI->>Clerk: Browser PKCE authorization-code flow
602 Clerk-->>CLI: OAuth access and refresh tokens
603 CLI->>CLI: Persist desired cloud-link state
604 U->>S: Start T3 environment server
605 S->>R: Create challenge and submit local signed proof
606 R-->>S: Managed endpoint config and connector token
607 S->>RC: Start relay client
608 </pre>
609 <p>
610 <code>t3 cloud login</code> stores authorization without enabling exposure.
611 <code>t3 cloud unlink</code> disables exposure while retaining authorization.
612 <code>t3 cloud logout</code> also removes the stored CLI authorization.
613 </p>
614 </article>
615
616 <article class="panel">
617 <h2>Flow 3: Remote Connect Bootstrap</h2>
618 <pre class="diagram mermaid">
619sequenceDiagram
620 participant C as Web or mobile client
621 participant Clerk as Clerk
622 participant R as Relay Worker
623 participant E as Managed environment endpoint
624
625 C->>Clerk: Get Clerk session-template JWT
626 C->>R: POST /v1/client/dpop-token (Clerk JWT + DPoP proof)
627 R-->>C: Relay DPoP access token
628 C->>R: POST /v1/environments/:id/connect (relay DPoP)
629 R->>E: POST /api/t3-cloud/mint-credential (relay-signed proof)
630 E->>E: Verify relay signer, linked user, scope, lifetime, cnf, and replay guards
631 E-->>R: Bootstrap credential + environment-signed proof
632 R->>R: Verify environment signature, nonce, endpoint, and DPoP binding
633 R-->>C: Managed endpoint + bootstrap credential
634 C->>E: POST /oauth/token (bootstrap credential + DPoP proof)
635 E-->>C: Environment DPoP-bound access token
636 C->>E: POST /api/auth/websocket-ticket
637 E-->>C: One-time WebSocket ticket
638 C->>E: WSS /ws with ticket
639 </pre>
640 <div class="callout">
641 Relay-to-environment control requests disable redirects, use only a reconciled managed
642 endpoint allocation, and require a signed environment response before the relay returns
643 data to the client.
644 </div>
645 </article>
646
647 <article class="panel">
648 <h2>Flow 4: Agent Activity Notifications</h2>
649 <pre class="diagram mermaid">
650sequenceDiagram
651 participant E as Local environment
652 participant R as Relay Worker
653 participant DB as Relay Postgres
654 participant Q as APNs queue
655 participant APNs as Apple Push Notification service
656 participant M as Mobile app
657
658 E->>E: Project publishable thread state and redact failure detail
659 E->>R: POST /v1/environments/:environmentId/threads/:threadId/agent-activity
660 Note over E,R: Environment bearer credential + environment-signed per-state proof
661 R->>R: Verify credential, signature, scope, expiry, and nonce
662 R->>DB: Store current activity row
663 R->>Q: Enqueue push or Live Activity delivery jobs
664 Q->>APNs: Deliver signed APNs request
665 APNs-->>M: Push notification or Live Activity update
666 </pre>
667 <p>
668 The local server publishes a deliberately narrow projection. Failed runs use a fixed
669 redacted summary, and detail strings are capped before publication.
670 </p>
671 </article>
672 </section>
673
674 <section class="grid" aria-label="Managed endpoint lifecycle">
675 <article class="panel half">
676 <h2>Managed Endpoint Reconciliation</h2>
677 <p>
678 Managed endpoint resources are keyed by <code>(userId, environmentId)</code>. The relay
679 reserves a stable hostname and tunnel name before provisioning, then checkpoints the
680 tunnel ID, DNS record ID, and ready state in Postgres.
681 </p>
682 <div class="flow">
683 <div class="step">
684 <strong>1. Reserve deterministic allocation</strong>
685 Hostname and tunnel name derive from relay stage, cloud user, and environment ID.
686 </div>
687 <div class="step">
688 <strong>2. Reuse or create tunnel</strong>
689 Existing named tunnels are reused. Tunnel ingress is rewritten to the validated
690 loopback origin.
691 </div>
692 <div class="step">
693 <strong>3. Reconcile DNS</strong>
694 Existing CNAMEs are updated and duplicate records are removed. Create conflicts are
695 recovered by listing and reconciling the winning record.
696 </div>
697 <div class="step">
698 <strong>4. Mark ready</strong>
699 Connect and status requests use only ready allocations whose hostname still matches
700 the relay-managed namespace.
701 </div>
702 </div>
703 </article>
704
705 <article class="panel half">
706 <h2>Unlink Cleanup</h2>
707 <p>
708 Relay unlink removes the user's managed endpoint allocation before revoking the
709 user/environment link:
710 </p>
711 <ul>
712 <li>delete the managed DNS record if present;</li>
713 <li>delete the Cloudflare tunnel if present;</li>
714 <li>remove the allocation row;</li>
715 <li>revoke the user's environment link; and</li>
716 <li>
717 revoke the environment publication credential only after the final active link for
718 that environment key disappears.
719 </li>
720 </ul>
721 <p>
722 Local unlink stops the relay client and removes the locally persisted relay URL, issuer,
723 linked user, environment publication credential, cloud-mint public key, connector
724 config, and agent-activity preference.
725 </p>
726 </article>
727 </section>
728
729 <section class="panel" aria-label="Cloudflare tunnel operational profile">
730 <h2>Cloudflare Tunnel Operational Profile</h2>
731 <p>
732 Managed tunnels are private application backends, not general-purpose public hosting. The
733 relay provisions one named Cloudflare tunnel and one DNS record per active
734 <code>(userId, environmentId)</code> allocation.
735 </p>
736 <ul>
737 <li>
738 <strong>Lifecycle:</strong> the tunnel is created when an environment is linked, reused
739 across retry-safe reconciliation, kept for the lifetime of that link, and deleted on
740 unlink. If teardown fails, the allocation checkpoint remains so cleanup can be retried.
741 </li>
742 <li>
743 <strong>Origin:</strong> tunnel ingress is restricted to the linked environment server's
744 validated loopback HTTP origin. Arbitrary upstream hosts and raw TCP origins are
745 rejected.
746 </li>
747 <li>
748 <strong>Protocols:</strong> managed endpoints carry HTTPS request/response traffic and
749 WSS connections only. Remote clients use the normal environment HTTP APIs and
750 authenticated WebSocket RPC transport through the tunnel.
751 </li>
752 <li>
753 <strong>Relay-generated traffic:</strong> the hosted relay sends sparse HTTPS health
754 checks and credential-mint requests during status checks and remote connection
755 bootstrap. It does not proxy steady-state remote sessions.
756 </li>
757 <li>
758 <strong>Traffic shape:</strong> expected load is interactive and user-driven. Active
759 remote sessions may keep WebSockets open and exchange terminal, agent, and UI updates;
760 inactive linked environments should carry little or no tunnel traffic.
761 </li>
762 <li>
763 <strong>Capacity planning:</strong> the project is pre-launch, so there is not yet a
764 production bandwidth baseline. Tunnel count grows with active linked user/environment
765 allocations, while bandwidth and concurrent WebSockets grow with active remote use.
766 </li>
767 </ul>
768 </section>
769
770 <section class="panel" aria-label="SSRF and tunnel hardening">
771 <h2>SSRF and Tunnel Hardening</h2>
772 <table>
773 <thead>
774 <tr>
775 <th>Risk</th>
776 <th>Implemented control</th>
777 </tr>
778 </thead>
779 <tbody>
780 <tr>
781 <td>A client asks the relay to tunnel an arbitrary host.</td>
782 <td>
783 The environment and relay both accept managed tunnel origins only for loopback hosts
784 and valid ports.
785 </td>
786 </tr>
787 <tr>
788 <td>A forwarded request tricks local origin validation.</td>
789 <td>
790 The local link-proof handler requires an exact loopback request URL and rejects
791 forwarded host or protocol headers.
792 </td>
793 </tr>
794 <tr>
795 <td>A stored endpoint is replaced with an arbitrary external URL.</td>
796 <td>
797 Connect and status resolve a ready allocation from Postgres and require a hostname
798 under the configured managed endpoint zone.
799 </td>
800 </tr>
801 <tr>
802 <td>A managed endpoint redirects relay egress elsewhere.</td>
803 <td>
804 Relay-to-environment status and mint requests set redirect handling to
805 <code>manual</code>.
806 </td>
807 </tr>
808 <tr>
809 <td>A process behind the tunnel returns attacker-controlled data.</td>
810 <td>
811 The relay verifies environment-signed health and mint responses against the linked
812 environment public key, expected nonce, and request binding.
813 </td>
814 </tr>
815 <tr>
816 <td>A stolen remote access token is replayed.</td>
817 <td>
818 Relay and environment access tokens are DPoP-bound; per-request proofs and replay
819 guards are required.
820 </td>
821 </tr>
822 <tr>
823 <td>The relay client token leaks through process listings or shell parsing.</td>
824 <td>
825 The relay client is spawned without a shell and receives its connector token in
826 <code>TUNNEL_TOKEN</code>.
827 </td>
828 </tr>
829 </tbody>
830 </table>
831 <div class="callout warning">
832 Managed tunnel hostnames live under the configured tunnel DNS zone. Serving them from a
833 dedicated registrable domain with a Public Suffix List entry remains an operational
834 isolation requirement before broad untrusted use.
835 </div>
836 </section>
837
838 <section class="panel" aria-label="Relay HTTP API">
839 <h2>Relay HTTP API</h2>
840 <p>
841 The relay publishes an OpenAPI document at <code>/openapi.json</code>, interactive API
842 docs at <code>/docs</code>, and redirects <code>/</code> to the docs.
843 </p>
844 <table>
845 <thead>
846 <tr>
847 <th>Endpoint</th>
848 <th>Authentication</th>
849 <th>Purpose</th>
850 </tr>
851 </thead>
852 <tbody>
853 <tr>
854 <td><code>GET /health</code></td>
855 <td>None</td>
856 <td>Relay health check.</td>
857 </tr>
858 <tr>
859 <td><code>GET /.well-known/oauth-authorization-server</code></td>
860 <td>None</td>
861 <td>Relay OAuth token-exchange discovery metadata.</td>
862 </tr>
863 <tr>
864 <td><code>GET /.well-known/oauth-protected-resource</code></td>
865 <td>None</td>
866 <td>Relay DPoP protected-resource discovery metadata.</td>
867 </tr>
868 <tr>
869 <td><code>GET /v1/environments</code></td>
870 <td>Clerk bearer</td>
871 <td>List environments linked to the signed-in user.</td>
872 </tr>
873 <tr>
874 <td><code>POST /v1/client/environment-link-challenges</code></td>
875 <td>Clerk bearer</td>
876 <td>Create a short-lived user-bound environment link challenge.</td>
877 </tr>
878 <tr>
879 <td><code>POST /v1/client/environment-links</code></td>
880 <td>Clerk bearer</td>
881 <td>
882 Verify a local environment proof, reconcile a managed endpoint, and upsert the user
883 link.
884 </td>
885 </tr>
886 <tr>
887 <td><code>DELETE /v1/client/environment-links/:environmentId</code></td>
888 <td>Clerk bearer</td>
889 <td>Remove managed endpoint resources and revoke the user's link.</td>
890 </tr>
891 <tr>
892 <td><code>POST /v1/client/dpop-token</code></td>
893 <td>Clerk token in token-exchange payload plus DPoP proof</td>
894 <td>Issue a relay DPoP access token for the requested supported scopes.</td>
895 </tr>
896 <tr>
897 <td><code>POST /v1/environments/:environmentId/status</code></td>
898 <td>Relay DPoP</td>
899 <td>Request and validate a signed environment health response.</td>
900 </tr>
901 <tr>
902 <td><code>POST /v1/environments/:environmentId/connect</code></td>
903 <td>Relay DPoP</td>
904 <td>Request and validate an environment bootstrap credential.</td>
905 </tr>
906 <tr>
907 <td><code>POST /v1/mobile/devices</code></td>
908 <td>Relay DPoP</td>
909 <td>Register or update a mobile device for notifications.</td>
910 </tr>
911 <tr>
912 <td><code>POST /v1/mobile/live-activities</code></td>
913 <td>Relay DPoP</td>
914 <td>Register a Live Activity push token.</td>
915 </tr>
916 <tr>
917 <td><code>DELETE /v1/mobile/devices/:deviceId</code></td>
918 <td>Relay DPoP</td>
919 <td>Unregister a mobile device.</td>
920 </tr>
921 <tr>
922 <td>
923 <code>POST /v1/environments/:environmentId/threads/:threadId/agent-activity</code>
924 </td>
925 <td>Environment bearer plus signed publish proof</td>
926 <td>Publish redacted current agent activity for notification delivery.</td>
927 </tr>
928 </tbody>
929 </table>
930 </section>
931
932 <section class="panel" aria-label="Environment HTTP API">
933 <h2>Environment Cloud HTTP API</h2>
934 <table>
935 <thead>
936 <tr>
937 <th>Endpoint</th>
938 <th>Authentication</th>
939 <th>Purpose</th>
940 </tr>
941 </thead>
942 <tbody>
943 <tr>
944 <td><code>POST /api/cloud/link-proof</code></td>
945 <td>Local environment token with <code>relay:write</code></td>
946 <td>Validate local origin and sign an environment link proof.</td>
947 </tr>
948 <tr>
949 <td><code>POST /api/cloud/relay-config</code></td>
950 <td>Local environment token with <code>relay:write</code></td>
951 <td>Persist linked relay configuration and start the relay client.</td>
952 </tr>
953 <tr>
954 <td><code>GET /api/cloud/link-state</code></td>
955 <td>Local environment token with <code>relay:read</code></td>
956 <td>Read the environment's current T3 Cloud link state.</td>
957 </tr>
958 <tr>
959 <td><code>POST /api/cloud/preferences</code></td>
960 <td>Local environment token with <code>relay:write</code></td>
961 <td>Enable or disable agent-activity publication.</td>
962 </tr>
963 <tr>
964 <td><code>POST /api/cloud/unlink</code></td>
965 <td>Local environment token with <code>relay:write</code></td>
966 <td>Stop the relay client and clear local T3 Cloud configuration.</td>
967 </tr>
968 <tr>
969 <td><code>POST /api/t3-cloud/health</code></td>
970 <td>Relay-signed proof</td>
971 <td>Return a signed environment health response.</td>
972 </tr>
973 <tr>
974 <td><code>POST /api/t3-cloud/mint-credential</code></td>
975 <td>Relay-signed proof</td>
976 <td>Mint a DPoP-bound one-time bootstrap credential for a remote client.</td>
977 </tr>
978 <tr>
979 <td><code>POST /oauth/token</code></td>
980 <td>Bootstrap credential plus DPoP proof</td>
981 <td>Exchange a bootstrap credential for an environment access token.</td>
982 </tr>
983 <tr>
984 <td><code>POST /api/auth/websocket-ticket</code></td>
985 <td>Environment access token</td>
986 <td>Issue a one-time ticket for the WSS upgrade.</td>
987 </tr>
988 </tbody>
989 </table>
990 </section>
991
992 <section class="grid" aria-label="Runtime and infrastructure">
993 <article class="panel half">
994 <h2>Relay Client Installation</h2>
995 <p>
996 Desktop web UI availability checks and installs are local WebSocket RPC methods:
997 <code>cloud.getRelayClientStatus</code> and streaming
998 <code>cloud.installRelayClient</code>. The install stream emits the current stage:
999 checking, waiting for lock, downloading, verifying, installing, validating, and
1000 activating.
1001 </p>
1002 <p>
1003 The web UI asks for confirmation before installation and displays a custom progress
1004 dialog. The CLI invokes the same local relay-client service directly and reports the
1005 same progress stages in the terminal.
1006 </p>
1007 </article>
1008
1009 <article class="panel half">
1010 <h2>Relay Infrastructure</h2>
1011 <ul>
1012 <li>Cloudflare Worker for the hosted relay HTTP API.</li>
1013 <li>PlanetScale Postgres database named <code>t3coderelay</code>.</li>
1014 <li>
1015 Production uses the shared database; non-production stages use database branches.
1016 </li>
1017 <li>
1018 Cloudflare Hyperdrive with caching disabled and a constrained origin connection limit.
1019 </li>
1020 <li>Cloudflare tunnel and DNS bindings for managed endpoint reconciliation.</li>
1021 <li>Cloudflare APNs delivery queue plus dead-letter queue.</li>
1022 <li>A five-minute cron job that prunes expired DPoP replay rows.</li>
1023 <li>Axiom OTLP trace dataset, scoped ingest token, and recent-spans view per stage.</li>
1024 </ul>
1025 </article>
1026 </section>
1027
1028 <section class="panel" aria-label="Deployment configuration">
1029 <h2>Deployment Configuration</h2>
1030 <table>
1031 <thead>
1032 <tr>
1033 <th>Boundary</th>
1034 <th>Configuration</th>
1035 </tr>
1036 </thead>
1037 <tbody>
1038 <tr>
1039 <td>Relay infrastructure</td>
1040 <td>
1041 <code>RELAY_API_ZONE_NAME</code>, <code>RELAY_TUNNEL_ZONE_NAME</code>, optional
1042 <code>RELAY_DOMAIN</code>, <code>CLERK_PUBLISHABLE_KEY</code>,
1043 <code>CLERK_SECRET_KEY</code>, <code>CLERK_JWT_AUDIENCE</code>, and APNs
1044 credentials.
1045 </td>
1046 </tr>
1047 <tr>
1048 <td>Source-built desktop/server cloud features</td>
1049 <td>
1050 Optional <code>T3CODE_RELAY_URL</code>, <code>T3CODE_CLERK_PUBLISHABLE_KEY</code>,
1051 and <code>T3CODE_CLERK_CLI_OAUTH_CLIENT_ID</code>. Release builds inject public
1052 values at build time.
1053 </td>
1054 </tr>
1055 <tr>
1056 <td>Web and mobile cloud clients</td>
1057 <td>
1058 Public Clerk publishable key, Clerk JWT template name, and relay URL. Relay URLs
1059 must normalize to an absolute HTTPS origin without credentials, query, fragment, or
1060 non-root path.
1061 </td>
1062 </tr>
1063 </tbody>
1064 </table>
1065 </section>
1066
1067 <section class="panel" aria-label="Implementation checklist">
1068 <h2>Implementation Checklist</h2>
1069 <ul>
1070 <li>
1071 Keep the relay cloud-mint private key hosted-only; install only its public key into
1072 environments.
1073 </li>
1074 <li>
1075 Keep environment signing private keys local and persist keypair creation atomically.
1076 </li>
1077 <li>
1078 Require a local <code>relay:write</code> session before linking or changing relay
1079 config.
1080 </li>
1081 <li>Validate relay URLs as secure absolute HTTPS origins before sending credentials.</li>
1082 <li>Require loopback-only tunnel origins on both sides of the link-proof boundary.</li>
1083 <li>
1084 Resolve managed endpoint egress from ready allocation rows, not client-supplied URLs.
1085 </li>
1086 <li>Disable redirects on relay-to-environment requests.</li>
1087 <li>Validate environment-signed response proofs before consuming tunneled responses.</li>
1088 <li>Keep tunnel and DNS provisioning retry-safe and deprovision them on unlink.</li>
1089 <li>
1090 Revoke shared environment publication credentials only after the final active link
1091 disappears.
1092 </li>
1093 <li>Redact and cap local agent failure details before publishing them externally.</li>
1094 </ul>
1095 </section>
1096
1097 <section class="panel" aria-label="Standards references">
1098 <h2>Standards References</h2>
1099 <ul class="source-list">
1100 <li>
1101 <a href="https://www.rfc-editor.org/rfc/rfc8252">RFC 8252</a>: browser-based OAuth for
1102 native apps, used by the CLI PKCE authorization flow.
1103 </li>
1104 <li>
1105 <a href="https://www.rfc-editor.org/rfc/rfc8693">RFC 8693</a>: OAuth token exchange,
1106 used for Clerk-token to relay-DPoP-token exchange.
1107 </li>
1108 <li>
1109 <a href="https://www.rfc-editor.org/rfc/rfc9449">RFC 9449</a>: DPoP proof of possession,
1110 used for relay and environment access tokens.
1111 </li>
1112 </ul>
1113 </section>
1114 </main>
1115 <script type="module">
1116 import mermaid from "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs";
1117
1118 mermaid.initialize({
1119 startOnLoad: false,
1120 securityLevel: "strict",
1121 theme: "base",
1122 themeVariables: {
1123 fontFamily: 'ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
1124 primaryColor: "#eef4ff",
1125 primaryBorderColor: "#1457d9",
1126 primaryTextColor: "#171717",
1127 lineColor: "#62646b",
1128 signalColor: "#171717",
1129 signalTextColor: "#171717",
1130 noteBkgColor: "#fff6e6",
1131 noteTextColor: "#171717",
1132 actorBkg: "#fbfbf8",
1133 actorBorder: "#62646b",
1134 actorTextColor: "#171717",
1135 },
1136 flowchart: {
1137 htmlLabels: true,
1138 curve: "basis",
1139 padding: 18,
1140 },
1141 sequence: {
1142 showSequenceNumbers: true,
1143 mirrorActors: false,
1144 rightAngles: false,
1145 },
1146 });
1147
1148 await mermaid.run({ querySelector: ".diagram.mermaid" });
1149
1150 for (const svg of document.querySelectorAll(".diagram.mermaid svg")) {
1151 const viewBox = svg.getAttribute("viewBox");
1152 const width = viewBox?.split(/\s+/).map(Number)[2];
1153 if (Number.isFinite(width) && width > 0) {
1154 svg.style.setProperty("width", `${Math.ceil(width)}px`, "important");
1155 svg.style.setProperty("max-width", "none", "important");
1156 }
1157 }
1158 </script>
1159 </body>
1160</html>
docs/cloud/t3-code-connect-auth-flow.html created+1160
......@@ -0,0 +1,1160 @@
1<!doctype html>
2<html lang="en">
3 <head>
4 <meta charset="utf-8" />
5 <meta name="viewport" content="width=device-width, initial-scale=1" />
6 <title>T3 Connect Architecture</title>
7 <style>
8 :root {
9 color-scheme: light;
10 --ink: #171717;
11 --muted: #62646b;
12 --hairline: #d8d8dd;
13 --panel: #ffffff;
14 --paper: #f5f5f2;
15 --blue: #1457d9;
16 --green: #1d7d4d;
17 --amber: #a65f00;
18 --red: #b8242c;
19 --violet: #6150b8;
20 --shadow: 0 18px 55px rgba(25, 25, 28, 0.08);
21 }
22
23 * {
24 box-sizing: border-box;
25 }
26
27 body {
28 margin: 0;
29 background:
30 linear-gradient(90deg, rgba(0, 0, 0, 0.035) 1px, transparent 1px),
31 linear-gradient(rgba(0, 0, 0, 0.035) 1px, transparent 1px), var(--paper);
32 background-size: 34px 34px;
33 color: var(--ink);
34 font-family:
35 ui-sans-serif,
36 -apple-system,
37 BlinkMacSystemFont,
38 "Segoe UI",
39 sans-serif;
40 line-height: 1.48;
41 }
42
43 .shell {
44 width: min(1540px, calc(100vw - 40px));
45 margin: 0 auto;
46 padding: 38px 0 72px;
47 }
48
49 header {
50 border-bottom: 2px solid var(--ink);
51 padding-bottom: 24px;
52 margin-bottom: 28px;
53 }
54
55 .eyebrow {
56 color: var(--blue);
57 font-size: 12px;
58 font-weight: 800;
59 letter-spacing: 0.08em;
60 text-transform: uppercase;
61 }
62
63 h1 {
64 max-width: 980px;
65 margin: 8px 0 12px;
66 font-size: clamp(34px, 5vw, 66px);
67 line-height: 0.98;
68 }
69
70 h2 {
71 margin: 0 0 14px;
72 font-size: 26px;
73 line-height: 1.08;
74 }
75
76 h3 {
77 margin: 22px 0 8px;
78 font-size: 17px;
79 }
80
81 p {
82 margin: 0 0 12px;
83 }
84
85 code,
86 .mono {
87 font-family:
88 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
89 }
90
91 code {
92 border-radius: 4px;
93 background: #f0f0ed;
94 padding: 1px 4px;
95 font-size: 0.92em;
96 }
97
98 .lede {
99 max-width: 1000px;
100 margin: 0;
101 color: var(--muted);
102 font-size: 18px;
103 }
104
105 .grid {
106 display: grid;
107 grid-template-columns: repeat(12, 1fr);
108 gap: 18px;
109 }
110
111 .panel {
112 grid-column: span 12;
113 border: 1px solid var(--hairline);
114 border-radius: 10px;
115 background: var(--panel);
116 box-shadow: var(--shadow);
117 padding: 22px;
118 }
119
120 .half {
121 grid-column: span 6;
122 }
123
124 .callout {
125 border-left: 5px solid var(--blue);
126 background: #eef4ff;
127 padding: 14px 16px;
128 margin-top: 14px;
129 }
130
131 .callout.warning {
132 border-left-color: var(--amber);
133 background: #fff6e6;
134 }
135
136 .badge-row {
137 display: flex;
138 flex-wrap: wrap;
139 gap: 8px;
140 margin-top: 14px;
141 }
142
143 .badge {
144 border: 1px solid var(--hairline);
145 border-radius: 999px;
146 background: #fff;
147 padding: 5px 9px;
148 font-size: 12px;
149 font-weight: 700;
150 }
151
152 .badge.user {
153 border-color: rgba(20, 87, 217, 0.3);
154 color: var(--blue);
155 }
156
157 .badge.env {
158 border-color: rgba(29, 125, 77, 0.3);
159 color: var(--green);
160 }
161
162 .badge.local {
163 border-color: rgba(166, 95, 0, 0.35);
164 color: var(--amber);
165 }
166
167 .diagram {
168 overflow-x: auto;
169 margin: 12px 0 0;
170 border: 1px solid var(--hairline);
171 border-radius: 8px;
172 background: #fbfbf8;
173 color: var(--ink);
174 padding: 18px;
175 }
176
177 .diagram svg {
178 display: block;
179 width: auto !important;
180 min-width: 100%;
181 max-width: none !important;
182 height: auto;
183 margin: 0 auto;
184 }
185
186 table {
187 width: 100%;
188 border-collapse: collapse;
189 font-size: 14px;
190 }
191
192 th,
193 td {
194 border-bottom: 1px solid var(--hairline);
195 padding: 11px 9px;
196 text-align: left;
197 vertical-align: top;
198 }
199
200 th {
201 color: var(--muted);
202 font-size: 12px;
203 letter-spacing: 0.05em;
204 text-transform: uppercase;
205 }
206
207 ul {
208 margin: 8px 0 0;
209 padding-left: 20px;
210 }
211
212 li + li {
213 margin-top: 6px;
214 }
215
216 .flow {
217 display: grid;
218 gap: 10px;
219 margin-top: 12px;
220 }
221
222 .step {
223 border: 1px solid var(--hairline);
224 border-left: 5px solid var(--green);
225 border-radius: 8px;
226 background: #fff;
227 padding: 12px 14px;
228 }
229
230 .step strong {
231 display: block;
232 margin-bottom: 3px;
233 }
234
235 .source-list a {
236 color: var(--blue);
237 font-weight: 700;
238 }
239
240 @media (max-width: 860px) {
241 .half {
242 grid-column: span 12;
243 }
244
245 .shell {
246 width: min(100vw - 24px, 1540px);
247 padding-top: 24px;
248 }
249 }
250 </style>
251 </head>
252 <body>
253 <main class="shell">
254 <header>
255 <div class="eyebrow">Implemented architecture</div>
256 <h1>T3 Connect Control Plane and Managed Endpoint Flow</h1>
257 <p class="lede">
258 T3 Connect links a locally authorized T3 environment to a signed-in cloud user, provisions
259 a managed HTTPS/WSS endpoint for that environment, and brokers proof-bound remote
260 connections. The local environment remains the authority that issues environment access
261 credentials. The hosted relay stores links, reconciles managed endpoint resources,
262 validates signed proofs, and delivers agent-activity notifications.
263 </p>
264 <div class="badge-row">
265 <span class="badge user">Client -> Relay: Clerk bearer or relay DPoP</span>
266 <span class="badge env">Relay -> Environment: relay-signed health and mint proofs</span>
267 <span class="badge env"
268 >Environment -> Relay: environment bearer plus signed activity proof</span
269 >
270 <span class="badge local">Client -> Environment: environment access token plus DPoP</span>
271 </div>
272 </header>
273
274 <section class="grid" aria-label="Architecture summary">
275 <article class="panel half">
276 <h2>Security Invariant</h2>
277 <p>
278 A cloud identity is not an environment login. Remote access requires all of the
279 following:
280 </p>
281 <ul>
282 <li>a Clerk-authenticated T3 Connect user;</li>
283 <li>an active relay link for that user and environment;</li>
284 <li>a DPoP proof key held by the remote client;</li>
285 <li>a relay-signed mint request accepted by the linked local environment; and</li>
286 <li>an environment-issued credential bound to the remote client's DPoP thumbprint.</li>
287 </ul>
288 <div class="callout">
289 The relay cannot mint an environment access token by itself. It can only ask the local
290 environment to mint a short-lived bootstrap credential for an authorized cloud user.
291 </div>
292 <p>During an honest connection flow, the relay cannot act as the remote client:</p>
293 <ul>
294 <li>
295 Normal environment API traffic goes directly from the remote client through the
296 managed endpoint. The relay does not receive the resulting environment access token.
297 </li>
298 <li>
299 The one-time bootstrap credential and resulting environment access token are bound to
300 the remote client's DPoP public-key thumbprint. Redeeming or using them requires
301 request proofs signed by the corresponding private key, which stays on the client.
302 </li>
303 <li>
304 Relay-signed health and mint proofs are short-lived, audience-bound, scoped, and
305 replay-guarded. They are accepted only by the narrow cloud endpoints, not as normal
306 environment API credentials.
307 </li>
308 <li>
309 Environment health and mint responses are signed by the linked environment and bound
310 to the request nonce, so a different process behind the tunnel cannot impersonate the
311 linked environment.
312 </li>
313 </ul>
314 <div class="callout warning">
315 The hosted relay remains a trusted mint broker: it holds the cloud-mint signing key.
316 DPoP prevents credential reuse by the relay or tunnel transport during an honest flow,
317 but it does not make compromise of the relay signing authority harmless.
318 </div>
319 </article>
320
321 <article class="panel half">
322 <h2>Product Boundaries</h2>
323 <p>
324 The generic local connector is called the <strong>relay client</strong>. The current
325 implementation layer uses <code>cloudflared</code>, but that is an internal transport
326 detail rather than the product contract.
327 </p>
328 <ul>
329 <li>
330 The relay Worker owns cloud links, managed endpoint allocations, and notifications.
331 </li>
332 <li>
333 The environment server owns local sessions, environment keys, and access tokens.
334 </li>
335 <li>The relay client exposes only the environment's loopback HTTP server.</li>
336 <li>
337 Web and mobile clients connect directly to the managed environment endpoint after
338 bootstrap.
339 </li>
340 </ul>
341 </article>
342 </section>
343
344 <section class="panel" aria-label="Topology">
345 <h2>Current Topology</h2>
346 <pre class="diagram mermaid">
347flowchart LR
348 subgraph Clients["User-facing clients"]
349 Web["Desktop / hosted web UI"]
350 Mobile["Mobile app"]
351 CLI["Headless CLI"]
352 end
353
354 Clerk["Clerk<br/>user identity and OAuth"]
355
356 subgraph Relay["T3 Code Relay on Cloudflare"]
357 Worker["Relay Worker<br/>HTTP API"]
358 Queue["APNs delivery queue<br/>with dead-letter queue"]
359 Hyperdrive["Hyperdrive"]
360 CF["Cloudflare tunnel + DNS APIs"]
361 end
362
363 DB["PlanetScale Postgres<br/>prod database + stage branches"]
364 APNs["Apple Push Notification service"]
365 Traces["Axiom OTLP traces"]
366
367 subgraph Local["Linked T3 environment"]
368 Env["Environment server<br/>local auth authority"]
369 RelayClient["Relay client<br/>cloudflared implementation"]
370 Secrets["Server secret store<br/>keys + relay config"]
371 end
372
373 Endpoint["Managed HTTPS/WSS endpoint"]
374
375 Web -->|"Clerk bearer / relay DPoP"| Worker
376 Mobile -->|"Clerk bearer / relay DPoP"| Worker
377 CLI -->|"Clerk OAuth bearer"| Worker
378 Web --> Clerk
379 Mobile --> Clerk
380 CLI -->|"PKCE authorization code flow"| Clerk
381 Worker --> Hyperdrive --> DB
382 Worker --> Traces
383 Worker --> CF
384 Worker --> Queue --> APNs
385 Worker --> Endpoint
386 Endpoint --> RelayClient -->|"http://127.0.0.1:port"| Env
387 Env --> Secrets
388 Web -->|"environment token + DPoP<br/>HTTPS / WSS"| Endpoint
389 Mobile -->|"environment token + DPoP<br/>HTTPS / WSS"| Endpoint
390 Env -->|"environment bearer + signed activity proof"| Worker
391 </pre>
392 </section>
393
394 <section class="panel" aria-label="Authentication matrix">
395 <h2>Authentication and Transport Matrix</h2>
396 <table>
397 <thead>
398 <tr>
399 <th>Boundary</th>
400 <th>Transport</th>
401 <th>Authentication</th>
402 <th>Purpose</th>
403 </tr>
404 </thead>
405 <tbody>
406 <tr>
407 <td>Web or mobile -> relay link management</td>
408 <td>HTTPS</td>
409 <td>Clerk session-template bearer token</td>
410 <td>Create a link challenge, submit an environment proof, list links, or unlink.</td>
411 </tr>
412 <tr>
413 <td>CLI -> relay link management</td>
414 <td>HTTPS</td>
415 <td>Clerk OAuth access token obtained with PKCE</td>
416 <td>Reconcile the desired headless cloud link when the server starts.</td>
417 </tr>
418 <tr>
419 <td>Web or mobile -> relay protected client endpoints</td>
420 <td>HTTPS</td>
421 <td>Relay-issued DPoP token plus per-request DPoP proof</td>
422 <td>
423 Check environment status, request a remote connection, and register mobile devices.
424 </td>
425 </tr>
426 <tr>
427 <td>Relay -> managed environment endpoint</td>
428 <td>HTTPS over the managed tunnel</td>
429 <td>Short-lived relay-signed JWT request proof</td>
430 <td>
431 Request a signed health response or ask the local environment to mint a credential.
432 </td>
433 </tr>
434 <tr>
435 <td>Environment -> relay activity publication</td>
436 <td>HTTPS</td>
437 <td>Relay-issued environment bearer credential plus environment-signed JWT proof</td>
438 <td>
439 Publish redacted agent-activity state for push notifications and Live Activities.
440 </td>
441 </tr>
442 <tr>
443 <td>Remote client -> environment</td>
444 <td>HTTPS and WSS over the managed tunnel</td>
445 <td>Environment-issued access token bound to the client DPoP key</td>
446 <td>Use the normal environment HTTP APIs and request a one-time WebSocket ticket.</td>
447 </tr>
448 </tbody>
449 </table>
450 </section>
451
452 <section class="panel" aria-label="Credential ownership">
453 <h2>Credential Ownership</h2>
454 <table>
455 <thead>
456 <tr>
457 <th>Credential</th>
458 <th>Created by</th>
459 <th>Stored by</th>
460 <th>Use</th>
461 </tr>
462 </thead>
463 <tbody>
464 <tr>
465 <td>Clerk session-template JWT</td>
466 <td>Clerk</td>
467 <td>Web or mobile client</td>
468 <td>
469 Authenticate cloud-user relay requests and bootstrap relay DPoP token exchange.
470 </td>
471 </tr>
472 <tr>
473 <td>CLI OAuth access and refresh token</td>
474 <td>Clerk OAuth</td>
475 <td>Local server secret store</td>
476 <td>Authorize headless CLI link reconciliation.</td>
477 </tr>
478 <tr>
479 <td>Environment Ed25519 key pair</td>
480 <td>Local environment server</td>
481 <td>Local server secret store</td>
482 <td>
483 Sign link proofs, health responses, mint responses, and activity publication proofs.
484 </td>
485 </tr>
486 <tr>
487 <td>Relay cloud-mint key pair</td>
488 <td>Relay deployment</td>
489 <td>Private key in relay Worker config; public key installed into the environment</td>
490 <td>Sign relay-to-environment health and mint requests.</td>
491 </tr>
492 <tr>
493 <td>Relay environment bearer credential</td>
494 <td>Relay</td>
495 <td>Hashed in relay Postgres; plaintext in local server secret store</td>
496 <td>Authenticate environment-to-relay agent-activity publication.</td>
497 </tr>
498 <tr>
499 <td>Relay DPoP access token</td>
500 <td>Relay</td>
501 <td>Remote client memory cache</td>
502 <td>
503 Authorize status, connect, and mobile registration endpoints with proof of
504 possession.
505 </td>
506 </tr>
507 <tr>
508 <td>Environment bootstrap credential</td>
509 <td>Local environment server</td>
510 <td>Passed relay -> remote client</td>
511 <td>One-time exchange at the environment <code>/oauth/token</code> endpoint.</td>
512 </tr>
513 <tr>
514 <td>Environment access token</td>
515 <td>Local environment server</td>
516 <td>Remote client</td>
517 <td>Authorize environment HTTP operations and WebSocket ticket issuance.</td>
518 </tr>
519 <tr>
520 <td>WebSocket ticket</td>
521 <td>Local environment server</td>
522 <td>Remote client until consumed</td>
523 <td>Short-lived one-time credential for the WSS upgrade.</td>
524 </tr>
525 <tr>
526 <td>Relay client connector token</td>
527 <td>Cloudflare tunnel API</td>
528 <td>Local server secret store</td>
529 <td>
530 Start the local relay client. It is passed through <code>TUNNEL_TOKEN</code>, not a
531 shell argument.
532 </td>
533 </tr>
534 </tbody>
535 </table>
536 </section>
537
538 <section class="grid" aria-label="Core flows">
539 <article class="panel">
540 <h2>Flow 1: Link From the Desktop Web UI</h2>
541 <pre class="diagram mermaid">
542sequenceDiagram
543 participant U as Signed-in user
544 participant C as Desktop web UI
545 participant E as Local environment
546 participant R as Relay Worker
547 participant CF as Cloudflare APIs
548 participant RC as Local relay client
549
550 U->>C: Enable T3 Connect for a locally paired environment
551 C->>E: Check relay client availability
552 alt relay client is missing
553 C->>U: Prompt before download and install
554 U->>C: Confirm
555 C->>E: Stream relay-client install RPC
556 E-->>C: Progress stages
557 end
558 C->>R: POST /v1/client/environment-link-challenges (Clerk bearer)
559 R-->>C: Short-lived challenge
560 C->>E: POST /api/connect/link-proof (local relay:write bearer)
561 E->>E: Authenticate, reject forwarded authority, get or create key pair, validate loopback origin
562 E-->>C: Environment-signed link proof
563 C->>R: POST /v1/client/environment-links (Clerk bearer + proof)
564 R->>R: Verify challenge, proof, capabilities, nonce, and loopback origin
565 R->>CF: Reconcile tunnel, ingress, and CNAME
566 R->>R: Upsert user/environment link and issue environment bearer
567 R-->>C: Managed endpoint, relay config, and connector token
568 C->>E: POST /api/connect/relay-config (local relay:write bearer)
569 E->>RC: Start relay client with TUNNEL_TOKEN
570 </pre>
571 <div class="callout">
572 The local link-proof endpoint requires <code>relay:write</code>, rejects forwarded
573 authority headers, and accepts only an exact loopback origin. Environment keypair
574 persistence is atomic across concurrent link attempts.
575 </div>
576 <p>
577 A locally paired mobile client uses the same relay challenge, local proof, relay link,
578 and local relay-config endpoints. The desktop web UI is the surface that checks relay
579 client availability and offers the managed install dialog.
580 </p>
581 </article>
582
583 <article class="panel">
584 <h2>Flow 2: Headless CLI Link</h2>
585 <pre class="diagram mermaid">
586sequenceDiagram
587 participant U as Operator
588 participant CLI as t3 connect CLI
589 participant Clerk as Clerk OAuth
590 participant S as Environment server on next start
591 participant R as Relay Worker
592 participant RC as Local relay client
593
594 U->>CLI: t3 connect link
595 CLI->>CLI: Resolve relay client
596 alt relay client is missing
597 CLI->>U: Confirm managed relay-client install
598 U->>CLI: Confirm
599 CLI->>CLI: Install with terminal progress updates
600 end
601 CLI->>Clerk: Browser PKCE authorization-code flow
602 Clerk-->>CLI: OAuth access and refresh tokens
603 CLI->>CLI: Persist desired cloud-link state
604 U->>S: Start T3 environment server
605 S->>R: Create challenge and submit local signed proof
606 R-->>S: Managed endpoint config and connector token
607 S->>RC: Start relay client
608 </pre>
609 <p>
610 <code>t3 connect login</code> stores authorization without enabling exposure.
611 <code>t3 connect unlink</code> disables exposure while retaining authorization.
612 <code>t3 connect logout</code> also removes the stored CLI authorization.
613 </p>
614 </article>
615
616 <article class="panel">
617 <h2>Flow 3: Remote Connect Bootstrap</h2>
618 <pre class="diagram mermaid">
619sequenceDiagram
620 participant C as Web or mobile client
621 participant Clerk as Clerk
622 participant R as Relay Worker
623 participant E as Managed environment endpoint
624
625 C->>Clerk: Get Clerk session-template JWT
626 C->>R: POST /v1/client/dpop-token (Clerk JWT + DPoP proof)
627 R-->>C: Relay DPoP access token
628 C->>R: POST /v1/environments/:id/connect (relay DPoP)
629 R->>E: POST /api/t3-connect/mint-credential (relay-signed proof)
630 E->>E: Verify relay signer, linked user, scope, lifetime, cnf, and replay guards
631 E-->>R: Bootstrap credential + environment-signed proof
632 R->>R: Verify environment signature, nonce, endpoint, and DPoP binding
633 R-->>C: Managed endpoint + bootstrap credential
634 C->>E: POST /oauth/token (bootstrap credential + DPoP proof)
635 E-->>C: Environment DPoP-bound access token
636 C->>E: POST /api/auth/websocket-ticket
637 E-->>C: One-time WebSocket ticket
638 C->>E: WSS /ws with ticket
639 </pre>
640 <div class="callout">
641 Relay-to-environment control requests disable redirects, use only a reconciled managed
642 endpoint allocation, and require a signed environment response before the relay returns
643 data to the client.
644 </div>
645 </article>
646
647 <article class="panel">
648 <h2>Flow 4: Agent Activity Notifications</h2>
649 <pre class="diagram mermaid">
650sequenceDiagram
651 participant E as Local environment
652 participant R as Relay Worker
653 participant DB as Relay Postgres
654 participant Q as APNs queue
655 participant APNs as Apple Push Notification service
656 participant M as Mobile app
657
658 E->>E: Project publishable thread state and redact failure detail
659 E->>R: POST /v1/environments/:environmentId/threads/:threadId/agent-activity
660 Note over E,R: Environment bearer credential + environment-signed per-state proof
661 R->>R: Verify credential, signature, scope, expiry, and nonce
662 R->>DB: Store current activity row
663 R->>Q: Enqueue push or Live Activity delivery jobs
664 Q->>APNs: Deliver signed APNs request
665 APNs-->>M: Push notification or Live Activity update
666 </pre>
667 <p>
668 The local server publishes a deliberately narrow projection. Failed runs use a fixed
669 redacted summary, and detail strings are capped before publication.
670 </p>
671 </article>
672 </section>
673
674 <section class="grid" aria-label="Managed endpoint lifecycle">
675 <article class="panel half">
676 <h2>Managed Endpoint Reconciliation</h2>
677 <p>
678 Managed endpoint resources are keyed by <code>(userId, environmentId)</code>. The relay
679 reserves a stable hostname and tunnel name before provisioning, then checkpoints the
680 tunnel ID, DNS record ID, and ready state in Postgres.
681 </p>
682 <div class="flow">
683 <div class="step">
684 <strong>1. Reserve deterministic allocation</strong>
685 Hostname and tunnel name derive from relay stage, cloud user, and environment ID.
686 </div>
687 <div class="step">
688 <strong>2. Reuse or create tunnel</strong>
689 Existing named tunnels are reused. Tunnel ingress is rewritten to the validated
690 loopback origin.
691 </div>
692 <div class="step">
693 <strong>3. Reconcile DNS</strong>
694 Existing CNAMEs are updated and duplicate records are removed. Create conflicts are
695 recovered by listing and reconciling the winning record.
696 </div>
697 <div class="step">
698 <strong>4. Mark ready</strong>
699 Connect and status requests use only ready allocations whose hostname still matches
700 the relay-managed namespace.
701 </div>
702 </div>
703 </article>
704
705 <article class="panel half">
706 <h2>Unlink Cleanup</h2>
707 <p>
708 Relay unlink removes the user's managed endpoint allocation before revoking the
709 user/environment link:
710 </p>
711 <ul>
712 <li>delete the managed DNS record if present;</li>
713 <li>delete the Cloudflare tunnel if present;</li>
714 <li>remove the allocation row;</li>
715 <li>revoke the user's environment link; and</li>
716 <li>
717 revoke the environment publication credential only after the final active link for
718 that environment key disappears.
719 </li>
720 </ul>
721 <p>
722 Local unlink stops the relay client and removes the locally persisted relay URL, issuer,
723 linked user, environment publication credential, cloud-mint public key, connector
724 config, and agent-activity preference.
725 </p>
726 </article>
727 </section>
728
729 <section class="panel" aria-label="Cloudflare tunnel operational profile">
730 <h2>Cloudflare Tunnel Operational Profile</h2>
731 <p>
732 Managed tunnels are private application backends, not general-purpose public hosting. The
733 relay provisions one named Cloudflare tunnel and one DNS record per active
734 <code>(userId, environmentId)</code> allocation.
735 </p>
736 <ul>
737 <li>
738 <strong>Lifecycle:</strong> the tunnel is created when an environment is linked, reused
739 across retry-safe reconciliation, kept for the lifetime of that link, and deleted on
740 unlink. If teardown fails, the allocation checkpoint remains so cleanup can be retried.
741 </li>
742 <li>
743 <strong>Origin:</strong> tunnel ingress is restricted to the linked environment server's
744 validated loopback HTTP origin. Arbitrary upstream hosts and raw TCP origins are
745 rejected.
746 </li>
747 <li>
748 <strong>Protocols:</strong> managed endpoints carry HTTPS request/response traffic and
749 WSS connections only. Remote clients use the normal environment HTTP APIs and
750 authenticated WebSocket RPC transport through the tunnel.
751 </li>
752 <li>
753 <strong>Relay-generated traffic:</strong> the hosted relay sends sparse HTTPS health
754 checks and credential-mint requests during status checks and remote connection
755 bootstrap. It does not proxy steady-state remote sessions.
756 </li>
757 <li>
758 <strong>Traffic shape:</strong> expected load is interactive and user-driven. Active
759 remote sessions may keep WebSockets open and exchange terminal, agent, and UI updates;
760 inactive linked environments should carry little or no tunnel traffic.
761 </li>
762 <li>
763 <strong>Capacity planning:</strong> the project is pre-launch, so there is not yet a
764 production bandwidth baseline. Tunnel count grows with active linked user/environment
765 allocations, while bandwidth and concurrent WebSockets grow with active remote use.
766 </li>
767 </ul>
768 </section>
769
770 <section class="panel" aria-label="SSRF and tunnel hardening">
771 <h2>SSRF and Tunnel Hardening</h2>
772 <table>
773 <thead>
774 <tr>
775 <th>Risk</th>
776 <th>Implemented control</th>
777 </tr>
778 </thead>
779 <tbody>
780 <tr>
781 <td>A client asks the relay to tunnel an arbitrary host.</td>
782 <td>
783 The environment and relay both accept managed tunnel origins only for loopback hosts
784 and valid ports.
785 </td>
786 </tr>
787 <tr>
788 <td>A forwarded request tricks local origin validation.</td>
789 <td>
790 The local link-proof handler requires an exact loopback request URL and rejects
791 forwarded host or protocol headers.
792 </td>
793 </tr>
794 <tr>
795 <td>A stored endpoint is replaced with an arbitrary external URL.</td>
796 <td>
797 Connect and status resolve a ready allocation from Postgres and require a hostname
798 under the configured managed endpoint zone.
799 </td>
800 </tr>
801 <tr>
802 <td>A managed endpoint redirects relay egress elsewhere.</td>
803 <td>
804 Relay-to-environment status and mint requests set redirect handling to
805 <code>manual</code>.
806 </td>
807 </tr>
808 <tr>
809 <td>A process behind the tunnel returns attacker-controlled data.</td>
810 <td>
811 The relay verifies environment-signed health and mint responses against the linked
812 environment public key, expected nonce, and request binding.
813 </td>
814 </tr>
815 <tr>
816 <td>A stolen remote access token is replayed.</td>
817 <td>
818 Relay and environment access tokens are DPoP-bound; per-request proofs and replay
819 guards are required.
820 </td>
821 </tr>
822 <tr>
823 <td>The relay client token leaks through process listings or shell parsing.</td>
824 <td>
825 The relay client is spawned without a shell and receives its connector token in
826 <code>TUNNEL_TOKEN</code>.
827 </td>
828 </tr>
829 </tbody>
830 </table>
831 <div class="callout warning">
832 Managed tunnel hostnames live under the configured tunnel DNS zone. Serving them from a
833 dedicated registrable domain with a Public Suffix List entry remains an operational
834 isolation requirement before broad untrusted use.
835 </div>
836 </section>
837
838 <section class="panel" aria-label="Relay HTTP API">
839 <h2>Relay HTTP API</h2>
840 <p>
841 The relay publishes an OpenAPI document at <code>/openapi.json</code>, interactive API
842 docs at <code>/docs</code>, and redirects <code>/</code> to the docs.
843 </p>
844 <table>
845 <thead>
846 <tr>
847 <th>Endpoint</th>
848 <th>Authentication</th>
849 <th>Purpose</th>
850 </tr>
851 </thead>
852 <tbody>
853 <tr>
854 <td><code>GET /health</code></td>
855 <td>None</td>
856 <td>Relay health check.</td>
857 </tr>
858 <tr>
859 <td><code>GET /.well-known/oauth-authorization-server</code></td>
860 <td>None</td>
861 <td>Relay OAuth token-exchange discovery metadata.</td>
862 </tr>
863 <tr>
864 <td><code>GET /.well-known/oauth-protected-resource</code></td>
865 <td>None</td>
866 <td>Relay DPoP protected-resource discovery metadata.</td>
867 </tr>
868 <tr>
869 <td><code>GET /v1/environments</code></td>
870 <td>Clerk bearer</td>
871 <td>List environments linked to the signed-in user.</td>
872 </tr>
873 <tr>
874 <td><code>POST /v1/client/environment-link-challenges</code></td>
875 <td>Clerk bearer</td>
876 <td>Create a short-lived user-bound environment link challenge.</td>
877 </tr>
878 <tr>
879 <td><code>POST /v1/client/environment-links</code></td>
880 <td>Clerk bearer</td>
881 <td>
882 Verify a local environment proof, reconcile a managed endpoint, and upsert the user
883 link.
884 </td>
885 </tr>
886 <tr>
887 <td><code>DELETE /v1/client/environment-links/:environmentId</code></td>
888 <td>Clerk bearer</td>
889 <td>Remove managed endpoint resources and revoke the user's link.</td>
890 </tr>
891 <tr>
892 <td><code>POST /v1/client/dpop-token</code></td>
893 <td>Clerk token in token-exchange payload plus DPoP proof</td>
894 <td>Issue a relay DPoP access token for the requested supported scopes.</td>
895 </tr>
896 <tr>
897 <td><code>POST /v1/environments/:environmentId/status</code></td>
898 <td>Relay DPoP</td>
899 <td>Request and validate a signed environment health response.</td>
900 </tr>
901 <tr>
902 <td><code>POST /v1/environments/:environmentId/connect</code></td>
903 <td>Relay DPoP</td>
904 <td>Request and validate an environment bootstrap credential.</td>
905 </tr>
906 <tr>
907 <td><code>POST /v1/mobile/devices</code></td>
908 <td>Relay DPoP</td>
909 <td>Register or update a mobile device for notifications.</td>
910 </tr>
911 <tr>
912 <td><code>POST /v1/mobile/live-activities</code></td>
913 <td>Relay DPoP</td>
914 <td>Register a Live Activity push token.</td>
915 </tr>
916 <tr>
917 <td><code>DELETE /v1/mobile/devices/:deviceId</code></td>
918 <td>Relay DPoP</td>
919 <td>Unregister a mobile device.</td>
920 </tr>
921 <tr>
922 <td>
923 <code>POST /v1/environments/:environmentId/threads/:threadId/agent-activity</code>
924 </td>
925 <td>Environment bearer plus signed publish proof</td>
926 <td>Publish redacted current agent activity for notification delivery.</td>
927 </tr>
928 </tbody>
929 </table>
930 </section>
931
932 <section class="panel" aria-label="Environment HTTP API">
933 <h2>Environment Cloud HTTP API</h2>
934 <table>
935 <thead>
936 <tr>
937 <th>Endpoint</th>
938 <th>Authentication</th>
939 <th>Purpose</th>
940 </tr>
941 </thead>
942 <tbody>
943 <tr>
944 <td><code>POST /api/connect/link-proof</code></td>
945 <td>Local environment token with <code>relay:write</code></td>
946 <td>Validate local origin and sign an environment link proof.</td>
947 </tr>
948 <tr>
949 <td><code>POST /api/connect/relay-config</code></td>
950 <td>Local environment token with <code>relay:write</code></td>
951 <td>Persist linked relay configuration and start the relay client.</td>
952 </tr>
953 <tr>
954 <td><code>GET /api/connect/link-state</code></td>
955 <td>Local environment token with <code>relay:read</code></td>
956 <td>Read the environment's current T3 Connect link state.</td>
957 </tr>
958 <tr>
959 <td><code>POST /api/connect/preferences</code></td>
960 <td>Local environment token with <code>relay:write</code></td>
961 <td>Enable or disable agent-activity publication.</td>
962 </tr>
963 <tr>
964 <td><code>POST /api/connect/unlink</code></td>
965 <td>Local environment token with <code>relay:write</code></td>
966 <td>Stop the relay client and clear local T3 Connect configuration.</td>
967 </tr>
968 <tr>
969 <td><code>POST /api/t3-connect/health</code></td>
970 <td>Relay-signed proof</td>
971 <td>Return a signed environment health response.</td>
972 </tr>
973 <tr>
974 <td><code>POST /api/t3-connect/mint-credential</code></td>
975 <td>Relay-signed proof</td>
976 <td>Mint a DPoP-bound one-time bootstrap credential for a remote client.</td>
977 </tr>
978 <tr>
979 <td><code>POST /oauth/token</code></td>
980 <td>Bootstrap credential plus DPoP proof</td>
981 <td>Exchange a bootstrap credential for an environment access token.</td>
982 </tr>
983 <tr>
984 <td><code>POST /api/auth/websocket-ticket</code></td>
985 <td>Environment access token</td>
986 <td>Issue a one-time ticket for the WSS upgrade.</td>
987 </tr>
988 </tbody>
989 </table>
990 </section>
991
992 <section class="grid" aria-label="Runtime and infrastructure">
993 <article class="panel half">
994 <h2>Relay Client Installation</h2>
995 <p>
996 Desktop web UI availability checks and installs are local WebSocket RPC methods:
997 <code>cloud.getRelayClientStatus</code> and streaming
998 <code>cloud.installRelayClient</code>. The install stream emits the current stage:
999 checking, waiting for lock, downloading, verifying, installing, validating, and
1000 activating.
1001 </p>
1002 <p>
1003 The web UI asks for confirmation before installation and displays a custom progress
1004 dialog. The CLI invokes the same local relay-client service directly and reports the
1005 same progress stages in the terminal.
1006 </p>
1007 </article>
1008
1009 <article class="panel half">
1010 <h2>Relay Infrastructure</h2>
1011 <ul>
1012 <li>Cloudflare Worker for the hosted relay HTTP API.</li>
1013 <li>PlanetScale Postgres database named <code>t3coderelay</code>.</li>
1014 <li>
1015 Production uses the shared database; non-production stages use database branches.
1016 </li>
1017 <li>
1018 Cloudflare Hyperdrive with caching disabled and a constrained origin connection limit.
1019 </li>
1020 <li>Cloudflare tunnel and DNS bindings for managed endpoint reconciliation.</li>
1021 <li>Cloudflare APNs delivery queue plus dead-letter queue.</li>
1022 <li>A five-minute cron job that prunes expired DPoP replay rows.</li>
1023 <li>Axiom OTLP trace dataset, scoped ingest token, and recent-spans view per stage.</li>
1024 </ul>
1025 </article>
1026 </section>
1027
1028 <section class="panel" aria-label="Deployment configuration">
1029 <h2>Deployment Configuration</h2>
1030 <table>
1031 <thead>
1032 <tr>
1033 <th>Boundary</th>
1034 <th>Configuration</th>
1035 </tr>
1036 </thead>
1037 <tbody>
1038 <tr>
1039 <td>Relay infrastructure</td>
1040 <td>
1041 <code>RELAY_API_ZONE_NAME</code>, <code>RELAY_TUNNEL_ZONE_NAME</code>, optional
1042 <code>RELAY_DOMAIN</code>, <code>CLERK_PUBLISHABLE_KEY</code>,
1043 <code>CLERK_SECRET_KEY</code>, <code>CLERK_JWT_AUDIENCE</code>, and APNs
1044 credentials.
1045 </td>
1046 </tr>
1047 <tr>
1048 <td>Source-built desktop/server cloud features</td>
1049 <td>
1050 Optional <code>T3CODE_RELAY_URL</code>, <code>T3CODE_CLERK_PUBLISHABLE_KEY</code>,
1051 and <code>T3CODE_CLERK_CLI_OAUTH_CLIENT_ID</code>. Release builds inject public
1052 values at build time.
1053 </td>
1054 </tr>
1055 <tr>
1056 <td>Web and mobile cloud clients</td>
1057 <td>
1058 Public Clerk publishable key, Clerk JWT template name, and relay URL. Relay URLs
1059 must normalize to an absolute HTTPS origin without credentials, query, fragment, or
1060 non-root path.
1061 </td>
1062 </tr>
1063 </tbody>
1064 </table>
1065 </section>
1066
1067 <section class="panel" aria-label="Implementation checklist">
1068 <h2>Implementation Checklist</h2>
1069 <ul>
1070 <li>
1071 Keep the relay cloud-mint private key hosted-only; install only its public key into
1072 environments.
1073 </li>
1074 <li>
1075 Keep environment signing private keys local and persist keypair creation atomically.
1076 </li>
1077 <li>
1078 Require a local <code>relay:write</code> session before linking or changing relay
1079 config.
1080 </li>
1081 <li>Validate relay URLs as secure absolute HTTPS origins before sending credentials.</li>
1082 <li>Require loopback-only tunnel origins on both sides of the link-proof boundary.</li>
1083 <li>
1084 Resolve managed endpoint egress from ready allocation rows, not client-supplied URLs.
1085 </li>
1086 <li>Disable redirects on relay-to-environment requests.</li>
1087 <li>Validate environment-signed response proofs before consuming tunneled responses.</li>
1088 <li>Keep tunnel and DNS provisioning retry-safe and deprovision them on unlink.</li>
1089 <li>
1090 Revoke shared environment publication credentials only after the final active link
1091 disappears.
1092 </li>
1093 <li>Redact and cap local agent failure details before publishing them externally.</li>
1094 </ul>
1095 </section>
1096
1097 <section class="panel" aria-label="Standards references">
1098 <h2>Standards References</h2>
1099 <ul class="source-list">
1100 <li>
1101 <a href="https://www.rfc-editor.org/rfc/rfc8252">RFC 8252</a>: browser-based OAuth for
1102 native apps, used by the CLI PKCE authorization flow.
1103 </li>
1104 <li>
1105 <a href="https://www.rfc-editor.org/rfc/rfc8693">RFC 8693</a>: OAuth token exchange,
1106 used for Clerk-token to relay-DPoP-token exchange.
1107 </li>
1108 <li>
1109 <a href="https://www.rfc-editor.org/rfc/rfc9449">RFC 9449</a>: DPoP proof of possession,
1110 used for relay and environment access tokens.
1111 </li>
1112 </ul>
1113 </section>
1114 </main>
1115 <script type="module">
1116 import mermaid from "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs";
1117
1118 mermaid.initialize({
1119 startOnLoad: false,
1120 securityLevel: "strict",
1121 theme: "base",
1122 themeVariables: {
1123 fontFamily: 'ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
1124 primaryColor: "#eef4ff",
1125 primaryBorderColor: "#1457d9",
1126 primaryTextColor: "#171717",
1127 lineColor: "#62646b",
1128 signalColor: "#171717",
1129 signalTextColor: "#171717",
1130 noteBkgColor: "#fff6e6",
1131 noteTextColor: "#171717",
1132 actorBkg: "#fbfbf8",
1133 actorBorder: "#62646b",
1134 actorTextColor: "#171717",
1135 },
1136 flowchart: {
1137 htmlLabels: true,
1138 curve: "basis",
1139 padding: 18,
1140 },
1141 sequence: {
1142 showSequenceNumbers: true,
1143 mirrorActors: false,
1144 rightAngles: false,
1145 },
1146 });
1147
1148 await mermaid.run({ querySelector: ".diagram.mermaid" });
1149
1150 for (const svg of document.querySelectorAll(".diagram.mermaid svg")) {
1151 const viewBox = svg.getAttribute("viewBox");
1152 const width = viewBox?.split(/\s+/).map(Number)[2];
1153 if (Number.isFinite(width) && width > 0) {
1154 svg.style.setProperty("width", `${Math.ceil(width)}px`, "important");
1155 svg.style.setProperty("max-width", "none", "important");
1156 }
1157 }
1158 </script>
1159 </body>
1160</html>
docs/cloud/t3-connect-clerk.md created+174
......@@ -0,0 +1,174 @@
1# T3 Connect Clerk Setup
2
3T3 Connect uses one Clerk application for web, desktop, and mobile authentication. The relay accepts
4Clerk JWTs only when they are generated from the `t3-relay` template with the shared
5`t3-code-relay` audience.
6
7## Application Keys
8
9T3 Connect is disabled in a fresh clone. To enable it for source builds, add a repository-root `.env`
10or `.env.local` file:
11
12```dotenv
13T3CODE_CLERK_PUBLISHABLE_KEY=<publishable key>
14T3CODE_CLERK_JWT_TEMPLATE=<JWT template name>
15T3CODE_CLERK_CLI_OAUTH_CLIENT_ID=<public OAuth application client ID>
16T3CODE_RELAY_URL=https://relay.example.com
17```
18
19The shared client loader projects these canonical values into framework-specific `VITE_*` and
20`EXPO_PUBLIC_*` aliases. Existing aliases remain accepted as overrides for compatibility, but new
21client configuration should use the canonical names.
22
23Configuration precedence is:
24
251. Process or CI environment variables.
262. Repository-root `.env.local`.
273. Repository-root `.env`.
28
29The Clerk publishable key, JWT template name, CLI OAuth client ID, and relay URL are public
30identifiers, not secrets.
31Web, desktop, mobile, and bundled server builds statically inject the values they consume during
32their build step. A built artifact does not need an environment file at runtime. CI release builds
33should set `T3CODE_CLERK_PUBLISHABLE_KEY`, `T3CODE_CLERK_JWT_TEMPLATE`,
34`T3CODE_CLERK_CLI_OAUTH_CLIENT_ID`, and `T3CODE_RELAY_URL` before building. EAS preview and
35production builds only need the Clerk publishable key, JWT template name, and relay URL in their EAS
36environment.
37
38When any client-facing public value is absent, cloud UI is omitted. When the CLI public values are
39absent, the `t3 connect` CLI command group is omitted. The bundled server still accepts runtime
40overrides for self-hosted or operator-managed
41deployments.
42
43For a hosted relay deployment, copy `infra/relay/.env.example` to `infra/relay/.env`. The relay
44deployment reads `RELAY_DOMAIN`, `RELAY_API_ZONE_NAME`, `RELAY_TUNNEL_ZONE_NAME`,
45`CLERK_PUBLISHABLE_KEY`, and `CLERK_JWT_AUDIENCE` through Effect `Config`. There are no checked-in
46deployment defaults.
47`vp run --filter t3code-relay deploy` invokes Alchemy from the relay directory, so Alchemy loads
48`infra/relay/.env`. After a successful deployment, the wrapper updates the repository-root `.env`
49with the deployed HTTPS relay URL. The relay still requires
50`CLERK_SECRET_KEY` as an Alchemy secret. Never put `CLERK_SECRET_KEY` in a client application
51environment or commit it to the repository.
52
53The `prod` Alchemy stage owns the retained PlanetScale database. Non-production stages reference
54that database and provision isolated PlanetScale branches, so deploy `prod` before creating a
55personal developer stage.
56
57## Headless CLI OAuth Application
58
59The `t3 connect` commands authorize a headless environment with a separate Clerk OAuth application.
60This uses an OAuth public client with PKCE, so the CLI stores no client secret.
61
62In **Clerk Dashboard > OAuth applications**:
63
641. Create an OAuth application for the T3 CLI.
652. Enable the **Public** option so authorization-code exchange uses PKCE.
663. Add `http://127.0.0.1:34338/callback` as an allowed redirect URI.
674. Enable the `openid`, `profile`, and `email` scopes.
685. Set `T3CODE_CLERK_CLI_OAUTH_CLIENT_ID` in the repository-root `.env` file and release build
69 environment to the generated public client ID.
70
71The CLI derives Clerk's frontend API URL from the publishable key and calls Clerk's
72`/oauth/authorize` and `/oauth/token` endpoints directly. The relay is not involved in the OAuth
73handshake; it only validates the issued Clerk bearer token when the CLI manages an environment link.
74
75The CLI supports these headless operations:
76
77```sh
78t3 connect login
79t3 connect link
80t3 connect status
81t3 connect unlink
82t3 connect logout
83t3 serve
84```
85
86`t3 connect login` opens the Clerk authorization flow and stores the CLI credential without enabling
87cloud exposure. `t3 connect link` installs the pinned managed `cloudflared` binary when needed,
88authorizes when needed, and records durable intent to expose the environment. It works without a
89running T3 server. The next `t3 serve` or `t3 start` reconciles the relay link and launches the
90managed tunnel. `t3 connect unlink` records disabled intent immediately, stops a reachable running
91connector, and attempts to revoke the relay-side environment record. It retains the stored CLI
92authorization so `t3 connect link` can re-enable exposure without another browser flow. `t3 connect
93logout` performs the same cleanup and removes the stored CLI authorization.
94
95The current OAuth callback listener binds to loopback port `34338`. When running the CLI over SSH,
96forward that port before running `t3 connect login` or `t3 connect link`:
97
98```sh
99ssh -L 34338:127.0.0.1:34338 <host>
100```
101
102A relay-hosted callback broker can remove this port-forward requirement later without changing the
103stored PKCE token model.
104
105## JWT Template
106
107In **Clerk Dashboard > JWT templates**, create a template with:
108
109| Setting | Value |
110| ------- | ---------------------------- |
111| Name | `t3-relay` |
112| Claims | `{ "aud": "t3-code-relay" }` |
113
114Set `T3CODE_CLERK_JWT_TEMPLATE=t3-relay` in the repository-root `.env`, and set
115`CLERK_JWT_AUDIENCE=t3-code-relay` in `infra/relay/.env`. Define `CLERK_JWT_TEMPLATE` and
116`CLERK_JWT_AUDIENCE` in the production relay deployment environment as well. The stable `aud` value
117is shared by production and non-production relay stages. The client-facing `T3CODE_RELAY_URL` still
118selects the concrete relay deployment, but changing that URL does not require a JWT template change.
119
120## Desktop OAuth Redirect Allowlist
121
122The desktop app opens OAuth in the system browser and returns to the app with a custom URL scheme.
123In **Clerk Dashboard > Native applications**, enable native application support and add these
124entries under the mobile SSO redirect allowlist:
125
126```text
127t3code-dev://auth/callback
128t3code://auth/callback
129```
130
131The first entry is for local desktop development. The second is for packaged desktop builds.
132The app also adds a request-scoped `t3_state` query parameter and validates it on callback. Initial
133sign-in and linked-account OAuth flows both return through this bridge. The desktop provider keeps
134Clerk's stock profile component, replaces its renderer-page callback with the custom-scheme callback,
135and opens the provider URL in the system browser. Do not add the local renderer URL as an OAuth
136redirect: an external browser cannot use it to reopen the packaged app.
137
138The current mobile UI uses Clerk's native authentication view. If a future mobile browser OAuth
139flow uses a custom redirect URI, add that exact URI to the same allowlist.
140
141## Enable Waitlist Access
142
143For a private beta where people should request access, use **Clerk Dashboard > Waitlist**:
144
1451. Toggle on **Enable waitlist** and save.
1462. Review requests on the same page and select **Invite** or **Deny**.
147
148Approved signed-in users manage T3 Connect under **Connections**. The web and desktop sidebars do
149not expose a dedicated account or waitlist control. Signed-out users reach Clerk's waitlist and
150sign-in flow contextually from the T3 Connect controls on the Connections page.
151
152On mobile, signed-out users open **Settings > T3 Account** to reach `/settings/waitlist` within the
153Settings form sheet. It submits enrollment through Clerk's `useWaitlist()` flow because the prebuilt
154`<Waitlist />` component is web-only in the Expo SDK. Approved users can use **Sign in** from that
155screen.
156
157## Alternative: Known-User Allowlist
158
159For a closed beta where all permitted users are known in advance, use an allowlist instead of a
160request-and-approval waitlist:
161
162To restrict the beta to permitted email addresses or domains:
163
1641. In **Clerk Dashboard > Restrictions > Allowlist**, add each permitted email address or email
165 domain.
1662. Enable the allowlist and save.
1673. Alternatively, enable **Restricted mode** when all new users must be explicitly invited or
168 manually created without a waitlist request flow.
169
170Do not enable an empty allowlist: it blocks all new sign-ups.
171
172Clerk allowlists control who can sign up. They do not revoke an existing user's active cloud
173access. To remove an already-created user's access, ban that user in Clerk so their active
174sessions are ended and future sign-ins are rejected.
docs/operations/release.md+2-2
......@@ -10,7 +10,7 @@ This document covers the unified release workflow for stable and nightly desktop
1010 - scheduled nightly check every three hours
1111 - manual `workflow_dispatch` for either channel
1212- Runs quality gates first: lint, typecheck, test.
13- Reads the shared production T3 Cloud relay URL and Clerk client configuration before packaging clients.
13- Reads the shared production T3 Connect relay URL and Clerk client configuration before packaging clients.
1414- Builds four artifacts in parallel for both channels:
1515 - macOS `arm64` DMG
1616 - macOS `x64` DMG
......@@ -30,7 +30,7 @@ This document covers the unified release workflow for stable and nightly desktop
3030 - nightly releases are aliased to the `nightly` hosted app channel
3131- Signing is optional and auto-detected per platform from secrets.
3232
33## T3 Cloud relay deployment
33## T3 Connect relay deployment
3434
3535The relay is a shared control plane versioned separately from client releases. Stable and nightly
3636client builds must point at the same relay so users see the same linked environments when switching
infra/relay/README.md+6-6
......@@ -1,15 +1,15 @@
1# T3 Code Cloud Relay
1# T3 Connect Relay
22
33> [!WARNING]
4> T3 Code Cloud is currently in private beta. Join the waitlist in the app under Settings > T3 Cloud.
4> T3 Connect is currently in private beta. Join the waitlist in the app under Settings > T3 Connect.
55
6The relay is the hosted control plane for T3 Code Cloud. It helps clients discover and connect to
6The relay is the hosted control plane for T3 Connect. It helps clients discover and connect to
77remote environments, manages the cloud-side records needed for those connections, and delivers
88optional mobile notifications and Live Activities.
99
1010The relay is intentionally not in the hot path for normal T3 Code traffic. After a client connects,
1111regular API and WebSocket traffic goes directly between that client and the selected environment.
12See the [cloud architecture overview](../../docs/t3-code-cloud-auth-flow.html) for the larger system
12See the [T3 Connect architecture overview](../../docs/cloud/t3-code-connect-auth-flow.html) for the larger system
1313design.
1414
1515## Responsibilities
......@@ -159,8 +159,8 @@ and hosted web builds.
159159
160160See:
161161
162- [T3 Cloud Clerk Setup](../../docs/t3-cloud-clerk.md) for Clerk keys, JWT templates, and waitlist
162- [T3 Connect Clerk Setup](../../docs/cloud/t3-connect-clerk.md) for Clerk keys, JWT templates, and waitlist
163163 setup.
164164- [Relay Observability](../../docs/relay-observability.md) for deployment tracing and diagnostics.
165- [Cloud Architecture Overview](../../docs/t3-code-cloud-auth-flow.html) for the full link,
165- [T3 Connect Architecture Overview](../../docs/cloud/t3-code-connect-auth-flow.html) for the full link,
166166 connect, endpoint, and notification flows.
infra/relay/src/environments/EnvironmentConnector.test.ts+2-2
......@@ -300,7 +300,7 @@ describe("EnvironmentConnector", () => {
300300 environmentId: "env-connector-test",
301301 });
302302
303 expect(seenUrls).toEqual(["https://env.example.test/api/t3-cloud/health"]);
303 expect(seenUrls).toEqual(["https://env.example.test/api/t3-connect/health"]);
304304 expect(seenProofs[0]).toMatchObject({
305305 iss: "https://relay.example.test",
306306 aud: "t3-env:env-connector-test",
......@@ -663,7 +663,7 @@ describe("EnvironmentConnector", () => {
663663 deviceId: "device-123",
664664 });
665665
666 expect(seenUrls).toEqual(["https://env.example.test/api/t3-cloud/mint-credential"]);
666 expect(seenUrls).toEqual(["https://env.example.test/api/t3-connect/mint-credential"]);
667667 expect(seenProofs[0]).toMatchObject({
668668 iss: "https://relay.example.test",
669669 aud: "t3-env:env-connector-test",
infra/relay/src/environments/EnvironmentConnector.ts+26-24
......@@ -458,7 +458,7 @@ const make = Effect.gen(function* () {
458458 );
459459 const checkedAt = DateTime.formatIso(now);
460460 const environmentClient = yield* makeEnvironmentClient(endpoint.httpBaseUrl);
461 const responseOption = yield* environmentClient.cloud.health({ payload: { proof } }).pipe(
461 const responseOption = yield* environmentClient.connect.health({ payload: { proof } }).pipe(
462462 withoutRedirects,
463463 Effect.match({
464464 onFailure: (cause) => ({ _tag: "Failure" as const, cause }),
......@@ -591,30 +591,32 @@ const make = Effect.gen(function* () {
591591 ),
592592 );
593593 const environmentClient = yield* makeEnvironmentClient(endpoint.httpBaseUrl);
594 const decoded = yield* environmentClient.cloud.t3MintCredential({ payload: { proof } }).pipe(
595 withoutRedirects,
596 Effect.mapError(
597 (cause) =>
598 new EnvironmentMintRequestFailed({
599 environmentId: input.environmentId,
600 operation: "connect",
601 cause,
594 const decoded = yield* environmentClient.connect
595 .t3MintCredential({ payload: { proof } })
596 .pipe(
597 withoutRedirects,
598 Effect.mapError(
599 (cause) =>
600 new EnvironmentMintRequestFailed({
601 environmentId: input.environmentId,
602 operation: "connect",
603 cause,
604 }),
605 ),
606 Effect.timeoutOption(Duration.millis(ENVIRONMENT_MINT_REQUEST_TIMEOUT_MS)),
607 Effect.flatMap(
608 Option.match({
609 onNone: () =>
610 Effect.fail(
611 new EnvironmentMintRequestTimedOut({
612 environmentId: input.environmentId,
613 timeoutMs: ENVIRONMENT_MINT_REQUEST_TIMEOUT_MS,
614 }),
615 ),
616 onSome: Effect.succeed,
602617 }),
603 ),
604 Effect.timeoutOption(Duration.millis(ENVIRONMENT_MINT_REQUEST_TIMEOUT_MS)),
605 Effect.flatMap(
606 Option.match({
607 onNone: () =>
608 Effect.fail(
609 new EnvironmentMintRequestTimedOut({
610 environmentId: input.environmentId,
611 timeoutMs: ENVIRONMENT_MINT_REQUEST_TIMEOUT_MS,
612 }),
613 ),
614 onSome: Effect.succeed,
615 }),
616 ),
617 );
618 ),
619 );
618620 const verified = yield* verifyEnvironmentResponse({
619621 response: decoded,
620622 environmentId: input.environmentId,
packages/client-runtime/src/managedRelayState.ts+4-4
......@@ -53,7 +53,7 @@ export function createManagedRelaySession(input: {
5353 try: input.readClerkToken,
5454 catch: (cause) =>
5555 new ManagedRelaySessionError({
56 message: "Could not obtain the T3 Cloud session token.",
56 message: "Could not obtain the T3 Connect session token.",
5757 cause,
5858 }),
5959 }),
......@@ -76,7 +76,7 @@ function readSessionClerkToken(
7676 ? Effect.succeed(token)
7777 : Effect.fail(
7878 new ManagedRelaySessionError({
79 message: "The T3 Cloud session token is unavailable.",
79 message: "The T3 Connect session token is unavailable.",
8080 }),
8181 ),
8282 ),
......@@ -121,7 +121,7 @@ function requireClerkToken(
121121 if (!session || session.accountId !== accountId) {
122122 return Effect.fail(
123123 new ManagedRelaySessionError({
124 message: "Sign in to T3 Cloud before loading relay data.",
124 message: "Sign in to T3 Connect before loading relay data.",
125125 }),
126126 );
127127 }
......@@ -190,7 +190,7 @@ export function readManagedRelaySnapshotState<A>(
190190 let error: string | null = null;
191191 if (result._tag === "Failure") {
192192 const cause = Cause.squash(result.cause);
193 error = cause instanceof Error ? cause.message : "Could not load T3 Cloud data.";
193 error = cause instanceof Error ? cause.message : "Could not load T3 Connect data.";
194194 }
195195 return {
196196 data: Option.getOrNull(AsyncResult.value(result)),
packages/contracts/src/environmentHttp.ts+10-10
......@@ -439,9 +439,9 @@ export class EnvironmentOrchestrationHttpApi extends HttpApiGroup.make("orchestr
439439 }).middleware(EnvironmentAuthenticatedAuth),
440440 ) {}
441441
442export class EnvironmentCloudHttpApi extends HttpApiGroup.make("cloud")
442export class EnvironmentConnectHttpApi extends HttpApiGroup.make("connect")
443443 .add(
444 HttpApiEndpoint.post("linkProof", "/api/cloud/link-proof", {
444 HttpApiEndpoint.post("linkProof", "/api/connect/link-proof", {
445445 headers: OptionalBearerHeaders,
446446 payload: RelayLinkProofRequest,
447447 success: RelayEnvironmentLinkProof,
......@@ -449,7 +449,7 @@ export class EnvironmentCloudHttpApi extends HttpApiGroup.make("cloud")
449449 }).middleware(EnvironmentAuthenticatedAuth),
450450 )
451451 .add(
452 HttpApiEndpoint.post("relayConfig", "/api/cloud/relay-config", {
452 HttpApiEndpoint.post("relayConfig", "/api/connect/relay-config", {
453453 headers: OptionalBearerHeaders,
454454 payload: RelayEnvironmentConfigRequest,
455455 success: EnvironmentCloudRelayConfigResult,
......@@ -457,21 +457,21 @@ export class EnvironmentCloudHttpApi extends HttpApiGroup.make("cloud")
457457 }).middleware(EnvironmentAuthenticatedAuth),
458458 )
459459 .add(
460 HttpApiEndpoint.get("linkState", "/api/cloud/link-state", {
460 HttpApiEndpoint.get("linkState", "/api/connect/link-state", {
461461 headers: OptionalBearerHeaders,
462462 success: EnvironmentCloudLinkStateResult,
463463 error: EnvironmentHttpCloudErrors,
464464 }).middleware(EnvironmentAuthenticatedAuth),
465465 )
466466 .add(
467 HttpApiEndpoint.post("unlink", "/api/cloud/unlink", {
467 HttpApiEndpoint.post("unlink", "/api/connect/unlink", {
468468 headers: OptionalBearerHeaders,
469469 success: EnvironmentCloudRelayConfigResult,
470470 error: EnvironmentHttpCloudErrors,
471471 }).middleware(EnvironmentAuthenticatedAuth),
472472 )
473473 .add(
474 HttpApiEndpoint.post("preferences", "/api/cloud/preferences", {
474 HttpApiEndpoint.post("preferences", "/api/connect/preferences", {
475475 headers: OptionalBearerHeaders,
476476 payload: EnvironmentCloudPreferencesRequest,
477477 success: EnvironmentCloudLinkStateResult,
......@@ -479,21 +479,21 @@ export class EnvironmentCloudHttpApi extends HttpApiGroup.make("cloud")
479479 }).middleware(EnvironmentAuthenticatedAuth),
480480 )
481481 .add(
482 HttpApiEndpoint.post("health", "/api/t3-cloud/health", {
482 HttpApiEndpoint.post("health", "/api/t3-connect/health", {
483483 payload: RelayCloudEnvironmentHealthRequest,
484484 success: RelayEnvironmentHealthResponse,
485485 error: EnvironmentHttpCloudErrors,
486486 }),
487487 )
488488 .add(
489 HttpApiEndpoint.post("mintCredential", "/api/cloud/mint-credential", {
489 HttpApiEndpoint.post("mintCredential", "/api/connect/mint-credential", {
490490 payload: RelayCloudMintCredentialRequest,
491491 success: RelayEnvironmentMintResponse,
492492 error: EnvironmentHttpCloudErrors,
493493 }),
494494 )
495495 .add(
496 HttpApiEndpoint.post("t3MintCredential", "/api/t3-cloud/mint-credential", {
496 HttpApiEndpoint.post("t3MintCredential", "/api/t3-connect/mint-credential", {
497497 payload: RelayCloudMintCredentialRequest,
498498 success: RelayEnvironmentMintResponse,
499499 error: EnvironmentHttpCloudErrors,
......@@ -504,4 +504,4 @@ export class EnvironmentHttpApi extends HttpApi.make("environment")
504504 .add(EnvironmentMetadataHttpApi)
505505 .add(EnvironmentAuthHttpApi)
506506 .add(EnvironmentOrchestrationHttpApi)
507 .add(EnvironmentCloudHttpApi) {}
507 .add(EnvironmentConnectHttpApi) {}
packages/contracts/src/relay.ts+1-1
......@@ -537,7 +537,7 @@ export class RelayEnvironmentPrincipal extends Context.Service<
537537const RelayClientBearerAuthorization = HttpApiSecurity.http({ scheme: "bearer" }).pipe(
538538 HttpApiSecurity.annotate(
539539 OpenApi.Description,
540 "Clerk session or OAuth bearer token for the signed-in T3 Cloud user.",
540 "Clerk session or OAuth bearer token for the signed-in T3 Connect user.",
541541 ),
542542);
543543