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,5 +1,5 @@
1# Optional: T3 Cloud source builds1# Optional: T3 Connect source builds
2# Leave these unset to disable optional T3 Cloud features in local source builds.2# Leave these unset to disable optional T3 Connect features in local source builds.
3# Release builds inject their public values at build time. Do not add server-side3# Release builds inject their public values at build time. Do not add server-side
4# secrets to this file.4# secrets to this file.
55
.github/workflows/deploy-relay.yml+1-1
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1name: Deploy T3 Cloud relay1name: Deploy T3 Connect relay
22
3on:3on:
4 push:4 push:
.github/workflows/release.yml+1-1
...@@ -167,7 +167,7 @@ jobs:...@@ -167,7 +167,7 @@ jobs:
167 --github-output167 --github-output
168168
169 relay_public_config:169 relay_public_config:
170 name: Resolve T3 Cloud public config170 name: Resolve T3 Connect public config
171 needs: preflight171 needs: preflight
172 if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' }}172 if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' }}
173 runs-on: blacksmith-8vcpu-ubuntu-2404173 runs-on: blacksmith-8vcpu-ubuntu-2404
apps/desktop/src/window/DesktopWindow.test.ts+1-1
...@@ -165,7 +165,7 @@ describe("DesktopWindow", () => {...@@ -165,7 +165,7 @@ describe("DesktopWindow", () => {
165 assert.isTrue(165 assert.isTrue(
166 DesktopWindow.isSameOriginRendererNavigation({166 DesktopWindow.isSameOriginRendererNavigation({
167 applicationUrl: "http://127.0.0.1:3773/",167 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",
169 }),169 }),
170 );170 );
171 assert.isFalse(171 assert.isFalse(
apps/mobile/README.md+1-1
...@@ -16,7 +16,7 @@ This app has three variants:...@@ -16,7 +16,7 @@ This app has three variants:
1616
17Run commands from `apps/mobile`.17Run commands from `apps/mobile`.
1818
19T3 Cloud is optional and disabled in a fresh clone. Public configuration belongs in the19T3 Connect is optional and disabled in a fresh clone. Public configuration belongs in the
20repository-root `.env` or `.env.local`, not an `apps/mobile/.env` file. See20repository-root `.env` or `.env.local`, not an `apps/mobile/.env` file. See
21[`../../.env.example`](../../.env.example).21[`../../.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", () => {...@@ -572,7 +572,7 @@ describe("mobile cloud link environment client", () => {
572 if (String(url).endsWith("/v1/client/environment-link-challenges")) {572 if (String(url).endsWith("/v1/client/environment-link-challenges")) {
573 return Promise.resolve(Response.json(validLinkChallengeResponse()));573 return Promise.resolve(Response.json(validLinkChallengeResponse()));
574 }574 }
575 if (String(url).endsWith("/api/cloud/link-proof")) {575 if (String(url).endsWith("/api/connect/link-proof")) {
576 return Promise.resolve(Response.json(validLinkProof()));576 return Promise.resolve(Response.json(validLinkProof()));
577 }577 }
578 return Promise.resolve(Response.json(validLinkResponse("env-other")));578 return Promise.resolve(Response.json(validLinkResponse("env-other")));
...@@ -631,7 +631,7 @@ describe("mobile cloud link environment client", () => {...@@ -631,7 +631,7 @@ describe("mobile cloud link environment client", () => {
631 if (String(url).endsWith("/v1/client/environment-link-challenges")) {631 if (String(url).endsWith("/v1/client/environment-link-challenges")) {
632 return Promise.resolve(Response.json(validLinkChallengeResponse()));632 return Promise.resolve(Response.json(validLinkChallengeResponse()));
633 }633 }
634 if (String(url).endsWith("/api/cloud/link-proof")) {634 if (String(url).endsWith("/api/connect/link-proof")) {
635 return Promise.resolve(Response.json(validLinkProof()));635 return Promise.resolve(Response.json(validLinkProof()));
636 }636 }
637 return Promise.resolve(637 return Promise.resolve(
...@@ -669,7 +669,7 @@ describe("mobile cloud link environment client", () => {...@@ -669,7 +669,7 @@ describe("mobile cloud link environment client", () => {
669 if (String(url).endsWith("/v1/client/environment-link-challenges")) {669 if (String(url).endsWith("/v1/client/environment-link-challenges")) {
670 return Promise.resolve(Response.json(validLinkChallengeResponse()));670 return Promise.resolve(Response.json(validLinkChallengeResponse()));
671 }671 }
672 if (String(url).endsWith("/api/cloud/link-proof")) {672 if (String(url).endsWith("/api/connect/link-proof")) {
673 return Promise.resolve(Response.json(validLinkProof()));673 return Promise.resolve(Response.json(validLinkProof()));
674 }674 }
675 return Promise.resolve(675 return Promise.resolve(
...@@ -713,7 +713,7 @@ describe("mobile cloud link environment client", () => {...@@ -713,7 +713,7 @@ describe("mobile cloud link environment client", () => {
713 if (String(url).endsWith("/v1/client/environment-link-challenges")) {713 if (String(url).endsWith("/v1/client/environment-link-challenges")) {
714 return Promise.resolve(Response.json(validLinkChallengeResponse()));714 return Promise.resolve(Response.json(validLinkChallengeResponse()));
715 }715 }
716 if (String(url).endsWith("/api/cloud/link-proof")) {716 if (String(url).endsWith("/api/connect/link-proof")) {
717 return Promise.resolve(Response.json(validLinkProof()));717 return Promise.resolve(Response.json(validLinkProof()));
718 }718 }
719 if (String(url).endsWith("/v1/client/environment-links")) {719 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: {...@@ -301,7 +301,7 @@ export function linkEnvironmentToCloud(input: {
301 ),301 ),
302 );302 );
303 const environmentClient = yield* makeEnvironmentHttpApiClient(input.connection.httpBaseUrl);303 const environmentClient = yield* makeEnvironmentHttpApiClient(input.connection.httpBaseUrl);
304 const proof = yield* environmentClient.cloud304 const proof = yield* environmentClient.connect
305 .linkProof({305 .linkProof({
306 headers: { authorization: `Bearer ${localBearerToken}` },306 headers: { authorization: `Bearer ${localBearerToken}` },
307 payload: {307 payload: {
...@@ -336,7 +336,7 @@ export function linkEnvironmentToCloud(input: {...@@ -336,7 +336,7 @@ export function linkEnvironmentToCloud(input: {
336 link,336 link,
337 });337 });
338338
339 yield* environmentClient.cloud339 yield* environmentClient.connect
340 .relayConfig({340 .relayConfig({
341 headers: { authorization: `Bearer ${localBearerToken}` },341 headers: { authorization: `Bearer ${localBearerToken}` },
342 payload: {342 payload: {
apps/server/src/bin.test.ts+26-24
...@@ -38,11 +38,11 @@ import { environmentAuthenticatedAuthLayer } from "./auth/http.ts";...@@ -38,11 +38,11 @@ import { environmentAuthenticatedAuthLayer } from "./auth/http.ts";
38const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer);38const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer);
39class ProjectCliHttpApi extends HttpApi.make("environment").add(EnvironmentOrchestrationHttpApi) {}39class ProjectCliHttpApi extends HttpApi.make("environment").add(EnvironmentOrchestrationHttpApi) {}
4040
41const cloudCli = makeCli({ cloudEnabled: true });41const connectCli = makeCli({ cloudEnabled: true });
42const noCloudCli = makeCli({ cloudEnabled: false });42const noConnectCli = makeCli({ cloudEnabled: false });
43const runCli = (args: ReadonlyArray<string>, command = cli) =>43const runCli = (args: ReadonlyArray<string>, command = cli) =>
44 Command.runWith(command, { version: "0.0.0" })(args);44 Command.runWith(command, { version: "0.0.0" })(args);
45const runCloudCli = (args: ReadonlyArray<string>) => runCli(args, cloudCli);45const runConnectCli = (args: ReadonlyArray<string>) => runCli(args, connectCli);
46const runCliWithRuntime = (args: ReadonlyArray<string>) =>46const runCliWithRuntime = (args: ReadonlyArray<string>) =>
47 runCli(args).pipe(Effect.provide(CliRuntimeLayer));47 runCli(args).pipe(Effect.provide(CliRuntimeLayer));
4848
...@@ -179,9 +179,9 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => {...@@ -179,9 +179,9 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => {
179 }),179 }),
180 );180 );
181181
182 it.effect("rejects cloud commands when public cloud configuration is missing", () =>182 it.effect("rejects connect commands when public configuration is missing", () =>
183 Effect.gen(function* () {183 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
186 if (!CliError.isCliError(error)) {186 if (!CliError.isCliError(error)) {
187 assert.fail(`Expected CliError, got ${String(error)}`);187 assert.fail(`Expected CliError, got ${String(error)}`);
...@@ -189,20 +189,20 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => {...@@ -189,20 +189,20 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => {
189 if (error._tag !== "ShowHelp") {189 if (error._tag !== "ShowHelp") {
190 assert.fail(`Expected ShowHelp, got ${error._tag}`);190 assert.fail(`Expected ShowHelp, got ${error._tag}`);
191 }191 }
192 assert.deepEqual(error.commandPath, ["t3", "cloud"]);192 assert.deepEqual(error.commandPath, ["t3", "connect"]);
193 assert.include(error.errors[0]?.message ?? "", "missing T3 Cloud public configuration");193 assert.include(error.errors[0]?.message ?? "", "missing T3 Connect public configuration");
194194
195 const output = (yield* TestConsole.errorLines).join("\n");195 const output = (yield* TestConsole.errorLines).join("\n");
196 assert.include(output, "ERROR");196 assert.include(output, "ERROR");
197 assert.include(output, "missing T3 Cloud public configuration");197 assert.include(output, "missing T3 Connect public configuration");
198 }).pipe(Effect.provide(Layer.mergeAll(CliRuntimeLayer, TestConsole.layer))),198 }).pipe(Effect.provide(Layer.mergeAll(CliRuntimeLayer, TestConsole.layer))),
199 );199 );
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", () =>
202 Effect.gen(function* () {202 Effect.gen(function* () {
203 const baseDir = mkdtempSync(join(tmpdir(), "t3-cli-cloud-status-test-"));203 const baseDir = mkdtempSync(join(tmpdir(), "t3-cli-cloud-status-test-"));
204 const { output } = yield* captureStdout(204 const { output } = yield* captureStdout(
205 runCloudCli(["cloud", "status", "--base-dir", baseDir, "--json"]),205 runConnectCli(["connect", "status", "--base-dir", baseDir, "--json"]),
206 );206 );
207 // @effect-diagnostics-next-line preferSchemaOverJson:off - CLI JSON output is decoded as a presentation DTO.207 // @effect-diagnostics-next-line preferSchemaOverJson:off - CLI JSON output is decoded as a presentation DTO.
208 const status = JSON.parse(output) as {208 const status = JSON.parse(output) as {
...@@ -221,21 +221,21 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => {...@@ -221,21 +221,21 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => {
221 }),221 }),
222 );222 );
223223
224 it.effect("reports actionable human-readable headless cloud state", () =>224 it.effect("reports actionable human-readable headless connect state", () =>
225 Effect.gen(function* () {225 Effect.gen(function* () {
226 const baseDir = mkdtempSync(join(tmpdir(), "t3-cli-cloud-status-human-test-"));226 const baseDir = mkdtempSync(join(tmpdir(), "t3-cli-cloud-status-human-test-"));
227 const { output } = yield* captureStdout(227 const { output } = yield* captureStdout(
228 runCloudCli(["cloud", "status", "--base-dir", baseDir]),228 runConnectCli(["connect", "status", "--base-dir", baseDir]),
229 );229 );
230230
231 assert.include(output, "T3 Cloud\n Exposure: disabled");231 assert.include(output, "T3 Connect\n Exposure: disabled");
232 assert.include(output, " Authorization: missing");232 assert.include(output, " Authorization: missing");
233 assert.include(output, " Environment link: not provisioned");233 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.");
235 }),235 }),
236 );236 );
237237
238 it.effect("logs in to headless cloud without enabling exposure", () =>238 it.effect("logs in to headless connect without enabling access", () =>
239 Effect.gen(function* () {239 Effect.gen(function* () {
240 const baseDir = mkdtempSync(join(tmpdir(), "t3-cli-cloud-login-test-"));240 const baseDir = mkdtempSync(join(tmpdir(), "t3-cli-cloud-login-test-"));
241 const { secretsDir } = yield* deriveServerPaths(baseDir, undefined);241 const { secretsDir } = yield* deriveServerPaths(baseDir, undefined);
...@@ -250,9 +250,11 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => {...@@ -250,9 +250,11 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => {
250 }),250 }),
251 );251 );
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 );
254 const status = yield* captureStdout(256 const status = yield* captureStdout(
255 runCloudCli(["cloud", "status", "--base-dir", baseDir, "--json"]),257 runConnectCli(["connect", "status", "--base-dir", baseDir, "--json"]),
256 );258 );
257 // @effect-diagnostics-next-line preferSchemaOverJson:off - CLI JSON output is decoded as a presentation DTO.259 // @effect-diagnostics-next-line preferSchemaOverJson:off - CLI JSON output is decoded as a presentation DTO.
258 const decoded = JSON.parse(status.output) as {260 const decoded = JSON.parse(status.output) as {
...@@ -260,24 +262,24 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => {...@@ -260,24 +262,24 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => {
260 readonly authenticated: boolean;262 readonly authenticated: boolean;
261 };263 };
262264
263 assert.equal(login.output, "Signed in to T3 Cloud.");265 assert.equal(login.output, "Signed in to T3 Connect.");
264 assert.isFalse(decoded.desired);266 assert.isFalse(decoded.desired);
265 assert.isTrue(decoded.authenticated);267 assert.isTrue(decoded.authenticated);
266 }),268 }),
267 );269 );
268270
269 it.effect("disables headless cloud exposure without a running server", () =>271 it.effect("disables headless connect without a running server", () =>
270 Effect.gen(function* () {272 Effect.gen(function* () {
271 const baseDir = mkdtempSync(join(tmpdir(), "t3-cli-cloud-unlink-test-"));273 const baseDir = mkdtempSync(join(tmpdir(), "t3-cli-cloud-unlink-test-"));
272 const { output } = yield* captureStdout(274 const { output } = yield* captureStdout(
273 runCloudCli(["cloud", "unlink", "--base-dir", baseDir]),275 runConnectCli(["connect", "unlink", "--base-dir", baseDir]),
274 );276 );
275277
276 assert.equal(output, "T3 Cloud exposure is disabled locally.");278 assert.equal(output, "T3 Connect is disabled locally.");
277 }),279 }),
278 );280 );
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", () =>
281 Effect.gen(function* () {283 Effect.gen(function* () {
282 const baseDir = mkdtempSync(join(tmpdir(), "t3-cli-cloud-logout-test-"));284 const baseDir = mkdtempSync(join(tmpdir(), "t3-cli-cloud-logout-test-"));
283 const { secretsDir } = yield* deriveServerPaths(baseDir, undefined);285 const { secretsDir } = yield* deriveServerPaths(baseDir, undefined);
...@@ -286,10 +288,10 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => {...@@ -286,10 +288,10 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => {
286 writeFileSync(tokenPath, "invalid persisted token");288 writeFileSync(tokenPath, "invalid persisted token");
287289
288 const { output } = yield* captureStdout(290 const { output } = yield* captureStdout(
289 runCloudCli(["cloud", "logout", "--base-dir", baseDir]),291 runConnectCli(["connect", "logout", "--base-dir", baseDir]),
290 );292 );
291293
292 assert.equal(output, "Signed out of T3 Cloud locally.");294 assert.equal(output, "Signed out of T3 Connect locally.");
293 assert.isFalse(existsSync(tokenPath));295 assert.isFalse(existsSync(tokenPath));
294 }),296 }),
295 );297 );
apps/server/src/bin.ts+10-10
...@@ -8,7 +8,7 @@ import * as CliError from "effect/unstable/cli/CliError";...@@ -8,7 +8,7 @@ import * as CliError from "effect/unstable/cli/CliError";
8import * as NetService from "@t3tools/shared/Net";8import * as NetService from "@t3tools/shared/Net";
9import packageJson from "../package.json" with { type: "json" };9import packageJson from "../package.json" with { type: "json" };
10import { authCommand } from "./cli/auth.ts";10import { authCommand } from "./cli/auth.ts";
11import { cloudCommand } from "./cli/cloud.ts";11import { connectCommand } from "./cli/connect.ts";
12import { hasCloudPublicConfig } from "./cloud/publicConfig.ts";12import { hasCloudPublicConfig } from "./cloud/publicConfig.ts";
13import { sharedServerCommandFlags } from "./cli/config.ts";13import { sharedServerCommandFlags } from "./cli/config.ts";
14import { projectCommand } from "./cli/project.ts";14import { projectCommand } from "./cli/project.ts";
...@@ -16,23 +16,23 @@ import { runServerCommand, serveCommand, startCommand } from "./cli/server.ts";...@@ -16,23 +16,23 @@ import { runServerCommand, serveCommand, startCommand } from "./cli/server.ts";
1616
17const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer);17const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer);
1818
19const cloudPublicConfigMissingMessage =19const connectPublicConfigMissingMessage =
20 "T3 Cloud commands are unavailable: this build is missing T3 Cloud public configuration.";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 {
23 override get message() {23 override get message() {
24 return cloudPublicConfigMissingMessage;24 return connectPublicConfigMissingMessage;
25 }25 }
26}26}
2727
28const cloudUnavailableCommand = Command.make("cloud").pipe(28const connectUnavailableCommand = Command.make("connect").pipe(
29 Command.withDescription("T3 Cloud is unavailable in builds without public cloud configuration."),29 Command.withDescription("T3 Connect is unavailable in builds without public configuration."),
30 Command.withHidden,30 Command.withHidden,
31 Command.withHandler(() =>31 Command.withHandler(() =>
32 Effect.fail(32 Effect.fail(
33 new CliError.ShowHelp({33 new CliError.ShowHelp({
34 commandPath: ["t3", "cloud"],34 commandPath: ["t3", "connect"],
35 errors: [new CloudPublicConfigMissingError({ cause: cloudPublicConfigMissingMessage })],35 errors: [new ConnectPublicConfigMissingError({ cause: connectPublicConfigMissingMessage })],
36 }),36 }),
37 ),37 ),
38 ),38 ),
...@@ -47,7 +47,7 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) =>...@@ -47,7 +47,7 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) =>
47 serveCommand,47 serveCommand,
48 authCommand,48 authCommand,
49 projectCommand,49 projectCommand,
50 cloudEnabled ? cloudCommand : cloudUnavailableCommand,50 cloudEnabled ? connectCommand : connectUnavailableCommand,
51 ]),51 ]),
52 );52 );
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* () {...@@ -96,7 +96,7 @@ const make = Effect.gen(function* () {
9696
97 const clear = secrets97 const clear = secrets
98 .remove(CLOUD_CLI_OAUTH_TOKEN_SECRET)98 .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
101 const read = Effect.fn("cloud.cli_token.read")(function* () {101 const read = Effect.fn("cloud.cli_token.read")(function* () {
102 const encoded = yield* secrets.get(CLOUD_CLI_OAUTH_TOKEN_SECRET);102 const encoded = yield* secrets.get(CLOUD_CLI_OAUTH_TOKEN_SECRET);
...@@ -146,7 +146,7 @@ const make = Effect.gen(function* () {...@@ -146,7 +146,7 @@ const make = Effect.gen(function* () {
146 const url = new URL(request.originalUrl, metadata.redirectUri);146 const url = new URL(request.originalUrl, metadata.redirectUri);
147 const code = url.searchParams.get("code");147 const code = url.searchParams.get("code");
148 if (url.searchParams.get("state") !== state || !code) {148 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.", {
150 status: 400,150 status: 400,
151 });151 });
152 }152 }
...@@ -154,7 +154,7 @@ const make = Effect.gen(function* () {...@@ -154,7 +154,7 @@ const make = Effect.gen(function* () {
154 return yield* HttpServerResponse.html`154 return yield* HttpServerResponse.html`
155<html>155<html>
156 <body style="font-family: sans-serif; text-align: center; margin-top: 50px;">156 <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>
158 <p>You can close this window and return to your terminal.</p>158 <p>You can close this window and return to your terminal.</p>
159 </body>159 </body>
160</html>160</html>
...@@ -182,13 +182,13 @@ const make = Effect.gen(function* () {...@@ -182,13 +182,13 @@ const make = Effect.gen(function* () {
182 authorizationUrl.searchParams.set("state", state);182 authorizationUrl.searchParams.set("state", state);
183 authorizationUrl.searchParams.set("code_challenge", challenge);183 authorizationUrl.searchParams.set("code_challenge", challenge);
184 authorizationUrl.searchParams.set("code_challenge_method", "S256");184 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`);
186 const code = yield* Deferred.await(callback).pipe(186 const code = yield* Deferred.await(callback).pipe(
187 Effect.timeout(CLOUD_CLI_OAUTH_CALLBACK_TIMEOUT),187 Effect.timeout(CLOUD_CLI_OAUTH_CALLBACK_TIMEOUT),
188 Effect.catchTag("TimeoutError", () =>188 Effect.catchTag("TimeoutError", () =>
189 Effect.fail(189 Effect.fail(
190 new CloudCliTokenManagerError({190 new CloudCliTokenManagerError({
191 message: "Timed out waiting for T3 Cloud authorization.",191 message: "Timed out waiting for T3 Connect authorization.",
192 }),192 }),
193 ),193 ),
194 ),194 ),
...@@ -213,12 +213,12 @@ const make = Effect.gen(function* () {...@@ -213,12 +213,12 @@ const make = Effect.gen(function* () {
213 });213 });
214214
215 const getExisting = semaphore.withPermits(1)(215 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.")),
217 );217 );
218 const hasCredential = semaphore.withPermits(1)(218 const hasCredential = semaphore.withPermits(1)(
219 read().pipe(219 read().pipe(
220 Effect.map(Option.isSome),220 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."),
222 ),222 ),
223 );223 );
224 const get = semaphore.withPermits(1)(224 const get = semaphore.withPermits(1)(
...@@ -227,7 +227,7 @@ const make = Effect.gen(function* () {...@@ -227,7 +227,7 @@ const make = Effect.gen(function* () {
227 return Option.isSome(token)227 return Option.isSome(token)
228 ? token.value228 ? token.value
229 : yield* Effect.scoped(login()).pipe(Effect.flatMap(persist));229 : 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.")),
231 );231 );
232232
233 return CloudCliTokenManager.of({ get, getExisting, hasCredential, clear });233 return CloudCliTokenManager.of({ get, getExisting, hasCredential, clear });
apps/server/src/cloud/http.test.ts+1-1
...@@ -76,7 +76,7 @@ describe("reconcileDesiredCloudLink", () => {...@@ -76,7 +76,7 @@ describe("reconcileDesiredCloudLink", () => {
7676
77 expect(error).toMatchObject({77 expect(error).toMatchObject({
78 _tag: "EnvironmentHttpUnauthorizedError",78 _tag: "EnvironmentHttpUnauthorizedError",
79 message: "Run `t3 cloud link` to authorize this environment.",79 message: "Run `t3 connect link` to authorize this environment.",
80 });80 });
81 }).pipe(81 }).pipe(
82 Effect.provideService(82 Effect.provideService(
apps/server/src/cloud/http.ts+6-6
...@@ -509,7 +509,7 @@ const relayClientRequest = <A>(...@@ -509,7 +509,7 @@ const relayClientRequest = <A>(
509 Effect.mapError(509 Effect.mapError(
510 (cause) =>510 (cause) =>
511 new EnvironmentHttpInternalServerError({511 new EnvironmentHttpInternalServerError({
512 message: `T3 Cloud relay request failed: ${String(cause)}`,512 message: `T3 Connect relay request failed: ${String(cause)}`,
513 }),513 }),
514 ),514 ),
515 );515 );
...@@ -535,7 +535,7 @@ const reconcileDesiredCloudLinkWith = Effect.fn("environment.cloud.reconcileDesi...@@ -535,7 +535,7 @@ const reconcileDesiredCloudLinkWith = Effect.fn("environment.cloud.reconcileDesi
535 onNone: () =>535 onNone: () =>
536 Effect.fail(536 Effect.fail(
537 new EnvironmentHttpUnauthorizedError({537 new EnvironmentHttpUnauthorizedError({
538 message: "Run `t3 cloud link` to authorize this environment.",538 message: "Run `t3 connect link` to authorize this environment.",
539 }),539 }),
540 ),540 ),
541 onSome: Effect.succeed,541 onSome: Effect.succeed,
...@@ -595,7 +595,7 @@ const reconcileDesiredCloudLinkWith = Effect.fn("environment.cloud.reconcileDesi...@@ -595,7 +595,7 @@ const reconcileDesiredCloudLinkWith = Effect.fn("environment.cloud.reconcileDesi
595 CloudCliTokenManagerError: (error) =>595 CloudCliTokenManagerError: (error) =>
596 failEnvironmentCloudInternalError(error.message)(error.cause),596 failEnvironmentCloudInternalError(error.message)(error.cause),
597 SecretStoreError: failEnvironmentCloudInternalError(597 SecretStoreError: failEnvironmentCloudInternalError(
598 "Could not persist desired T3 Cloud link state.",598 "Could not persist desired T3 Connect link state.",
599 ),599 ),
600 }),600 }),
601);601);
...@@ -874,7 +874,7 @@ const cloudMintCredentialHandler = Effect.fn("environment.cloud.mintCredential")...@@ -874,7 +874,7 @@ const cloudMintCredentialHandler = Effect.fn("environment.cloud.mintCredential")
874 scopes: AuthStandardClientScopes,874 scopes: AuthStandardClientScopes,
875 subject: "cloud-connect",875 subject: "cloud-connect",
876 ttl: Duration.minutes(2),876 ttl: Duration.minutes(2),
877 label: "T3 Cloud connect",877 label: "T3 Connect connect",
878 proofKeyThumbprint: proof.clientProofKeyThumbprint,878 proofKeyThumbprint: proof.clientProofKeyThumbprint,
879 });879 });
880 const responsePayload = {880 const responsePayload = {
...@@ -924,9 +924,9 @@ const cloudMintCredentialHandler = Effect.fn("environment.cloud.mintCredential")...@@ -924,9 +924,9 @@ const cloudMintCredentialHandler = Effect.fn("environment.cloud.mintCredential")
924 }),924 }),
925);925);
926926
927export const cloudHttpApiLayer = HttpApiBuilder.group(927export const connectHttpApiLayer = HttpApiBuilder.group(
928 EnvironmentHttpApi,928 EnvironmentHttpApi,
929 "cloud",929 "connect",
930 Effect.fnUntraced(function* (handlers) {930 Effect.fnUntraced(function* (handlers) {
931 const dependencies = yield* cloudHttpDependencies;931 const dependencies = yield* cloudHttpDependencies;
932 return handlers932 return handlers
apps/server/src/relay/AgentAwarenessRelay.ts+3-3
...@@ -303,7 +303,7 @@ const make = Effect.gen(function* () {...@@ -303,7 +303,7 @@ const make = Effect.gen(function* () {
303 }303 }
304 const relayConfig = yield* readRelayConfig.pipe(Effect.orElseSucceed(() => null));304 const relayConfig = yield* readRelayConfig.pipe(Effect.orElseSucceed(() => null));
305 if (!relayConfig) {305 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", {
307 threadId,307 threadId,
308 });308 });
309 return;309 return;
...@@ -421,7 +421,7 @@ const make = Effect.gen(function* () {...@@ -421,7 +421,7 @@ const make = Effect.gen(function* () {
421 }421 }
422 const relayConfig = yield* readRelayConfig.pipe(Effect.orElseSucceed(() => null));422 const relayConfig = yield* readRelayConfig.pipe(Effect.orElseSucceed(() => null));
423 if (!relayConfig) {423 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");
425 return false;425 return false;
426 }426 }
427 const environmentId = yield* serverEnvironment.getEnvironmentId;427 const environmentId = yield* serverEnvironment.getEnvironmentId;
...@@ -459,7 +459,7 @@ const make = Effect.gen(function* () {...@@ -459,7 +459,7 @@ const make = Effect.gen(function* () {
459 function* () {459 function* () {
460 const relayConfig = yield* readRelayConfig.pipe(Effect.orElseSucceed(() => null));460 const relayConfig = yield* readRelayConfig.pipe(Effect.orElseSucceed(() => null));
461 if (!relayConfig) {461 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");
463 } else {463 } else {
464 yield* Effect.logInfo("agent activity publishing enabled", {464 yield* Effect.logInfo("agent activity publishing enabled", {
465 relayUrl: relayConfig.url,465 relayUrl: relayConfig.url,
apps/server/src/server.test.ts+50-50
...@@ -782,7 +782,7 @@ const buildAppUnderTest = (options?: {...@@ -782,7 +782,7 @@ const buildAppUnderTest = (options?: {
782 ),782 ),
783 Layer.provide(783 Layer.provide(
784 Layer.mock(CloudCliTokenManager.CloudCliTokenManager)({784 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.")),
786 getExisting: Effect.succeed(Option.none()),786 getExisting: Effect.succeed(Option.none()),
787 hasCredential: Effect.succeed(false),787 hasCredential: Effect.succeed(false),
788 clear: Effect.void,788 clear: Effect.void,
...@@ -1713,7 +1713,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -1713,7 +1713,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
1713 yield* buildAppUnderTest();1713 yield* buildAppUnderTest();
17141714
1715 const ownerCookie = yield* getAuthenticatedSessionCookieHeader();1715 const ownerCookie = yield* getAuthenticatedSessionCookieHeader();
1716 const linkProofUrl = yield* getHttpServerUrl("/api/cloud/link-proof");1716 const linkProofUrl = yield* getHttpServerUrl("/api/connect/link-proof");
1717 const linkProofResponse = yield* fetchEffect(linkProofUrl, {1717 const linkProofResponse = yield* fetchEffect(linkProofUrl, {
1718 method: "POST",1718 method: "POST",
1719 headers: {1719 headers: {
...@@ -1750,7 +1750,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -1750,7 +1750,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
1750 yield* buildAppUnderTest();1750 yield* buildAppUnderTest();
17511751
1752 const ownerCookie = yield* getAuthenticatedSessionCookieHeader();1752 const ownerCookie = yield* getAuthenticatedSessionCookieHeader();
1753 const linkProofUrl = yield* getHttpServerUrl("/api/cloud/link-proof");1753 const linkProofUrl = yield* getHttpServerUrl("/api/connect/link-proof");
1754 const serverPort = Number(new URL(linkProofUrl).port);1754 const serverPort = Number(new URL(linkProofUrl).port);
1755 const linkProofResponse = yield* fetchEffect(linkProofUrl, {1755 const linkProofResponse = yield* fetchEffect(linkProofUrl, {
1756 method: "POST",1756 method: "POST",
...@@ -1762,10 +1762,10 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -1762,10 +1762,10 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
1762 challenge: "relay-link-challenge",1762 challenge: "relay-link-challenge",
1763 relayIssuer: "https://relay.example.test",1763 relayIssuer: "https://relay.example.test",
1764 endpoint: {1764 endpoint: {
1765 httpBaseUrl: linkProofUrl.replace("/api/cloud/link-proof", ""),1765 httpBaseUrl: linkProofUrl.replace("/api/connect/link-proof", ""),
1766 wsBaseUrl: linkProofUrl1766 wsBaseUrl: linkProofUrl
1767 .replace("http://", "ws://")1767 .replace("http://", "ws://")
1768 .replace("/api/cloud/link-proof", "/ws"),1768 .replace("/api/connect/link-proof", "/ws"),
1769 providerKind: "manual",1769 providerKind: "manual",
1770 },1770 },
1771 origin: {1771 origin: {
...@@ -1789,9 +1789,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -1789,9 +1789,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
1789 Effect.gen(function* () {1789 Effect.gen(function* () {
1790 yield* buildAppUnderTest();1790 yield* buildAppUnderTest();
17911791
1792 const linkProofUrl = yield* getHttpServerUrl("/api/cloud/link-proof");1792 const linkProofUrl = yield* getHttpServerUrl("/api/connect/link-proof");
1793 const serverPort = Number(new URL(linkProofUrl).port);1793 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", {
1795 headers: {1795 headers: {
1796 cookie: yield* getAuthenticatedSessionCookieHeader(),1796 cookie: yield* getAuthenticatedSessionCookieHeader(),
1797 "content-type": "application/json",1797 "content-type": "application/json",
...@@ -1833,9 +1833,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -1833,9 +1833,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
1833 Effect.gen(function* () {1833 Effect.gen(function* () {
1834 yield* buildAppUnderTest();1834 yield* buildAppUnderTest();
18351835
1836 const linkProofUrl = yield* getHttpServerUrl("/api/cloud/link-proof");1836 const linkProofUrl = yield* getHttpServerUrl("/api/connect/link-proof");
1837 const serverPort = Number(new URL(linkProofUrl).port);1837 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", {
1839 headers: {1839 headers: {
1840 cookie: yield* getAuthenticatedSessionCookieHeader(),1840 cookie: yield* getAuthenticatedSessionCookieHeader(),
1841 "content-type": "application/json",1841 "content-type": "application/json",
...@@ -1875,9 +1875,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -1875,9 +1875,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
1875 Effect.gen(function* () {1875 Effect.gen(function* () {
1876 yield* buildAppUnderTest();1876 yield* buildAppUnderTest();
18771877
1878 const linkProofUrl = yield* getHttpServerUrl("/api/cloud/link-proof");1878 const linkProofUrl = yield* getHttpServerUrl("/api/connect/link-proof");
1879 const serverPort = Number(new URL(linkProofUrl).port);1879 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", {
1881 headers: {1881 headers: {
1882 cookie: yield* getAuthenticatedSessionCookieHeader(),1882 cookie: yield* getAuthenticatedSessionCookieHeader(),
1883 "content-type": "application/json",1883 "content-type": "application/json",
...@@ -1918,7 +1918,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -1918,7 +1918,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
1918 yield* buildAppUnderTest();1918 yield* buildAppUnderTest();
19191919
1920 const ownerCookie = yield* getAuthenticatedSessionCookieHeader();1920 const ownerCookie = yield* getAuthenticatedSessionCookieHeader();
1921 const linkProofUrl = yield* getHttpServerUrl("/api/cloud/link-proof");1921 const linkProofUrl = yield* getHttpServerUrl("/api/connect/link-proof");
1922 const serverPort = Number(new URL(linkProofUrl).port);1922 const serverPort = Number(new URL(linkProofUrl).port);
1923 const linkProofResponse = yield* fetchEffect(linkProofUrl, {1923 const linkProofResponse = yield* fetchEffect(linkProofUrl, {
1924 method: "POST",1924 method: "POST",
...@@ -1962,7 +1962,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -1962,7 +1962,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
1962 });1962 });
1963 const credential = (yield* credentialResponse.json) as { readonly credential: string };1963 const credential = (yield* credentialResponse.json) as { readonly credential: string };
1964 const pairedCookie = yield* getAuthenticatedSessionCookieHeader(credential.credential);1964 const pairedCookie = yield* getAuthenticatedSessionCookieHeader(credential.credential);
1965 const linkStateUrl = yield* getHttpServerUrl("/api/cloud/link-state");1965 const linkStateUrl = yield* getHttpServerUrl("/api/connect/link-state");
1966 const response = yield* fetchEffect(linkStateUrl, {1966 const response = yield* fetchEffect(linkStateUrl, {
1967 headers: { cookie: pairedCookie },1967 headers: { cookie: pairedCookie },
1968 });1968 });
...@@ -2028,7 +2028,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -2028,7 +2028,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
2028 yield* buildAppUnderTest();2028 yield* buildAppUnderTest();
20292029
2030 const ownerCookie = yield* getAuthenticatedSessionCookieHeader();2030 const ownerCookie = yield* getAuthenticatedSessionCookieHeader();
2031 const preferencesUrl = yield* getHttpServerUrl("/api/cloud/preferences");2031 const preferencesUrl = yield* getHttpServerUrl("/api/connect/preferences");
2032 const ownerResponse = yield* fetchEffect(preferencesUrl, {2032 const ownerResponse = yield* fetchEffect(preferencesUrl, {
2033 method: "POST",2033 method: "POST",
2034 headers: {2034 headers: {
...@@ -2072,7 +2072,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -2072,7 +2072,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
2072 yield* buildAppUnderTest();2072 yield* buildAppUnderTest();
20732073
2074 const ownerCookie = yield* getAuthenticatedSessionCookieHeader();2074 const ownerCookie = yield* getAuthenticatedSessionCookieHeader();
2075 const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config");2075 const relayConfigUrl = yield* getHttpServerUrl("/api/connect/relay-config");
2076 const relayConfigResponse = yield* fetchEffect(relayConfigUrl, {2076 const relayConfigResponse = yield* fetchEffect(relayConfigUrl, {
2077 method: "POST",2077 method: "POST",
2078 headers: {2078 headers: {
...@@ -2107,7 +2107,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -2107,7 +2107,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
2107 publicKeyEncoding: { format: "pem", type: "spki" },2107 publicKeyEncoding: { format: "pem", type: "spki" },
2108 });2108 });
2109 const ownerCookie = yield* getAuthenticatedSessionCookieHeader();2109 const ownerCookie = yield* getAuthenticatedSessionCookieHeader();
2110 const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config");2110 const relayConfigUrl = yield* getHttpServerUrl("/api/connect/relay-config");
2111 const postRelayConfig = (body: {2111 const postRelayConfig = (body: {
2112 readonly relayUrl: string;2112 readonly relayUrl: string;
2113 readonly relayIssuer?: string;2113 readonly relayIssuer?: string;
...@@ -2184,7 +2184,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -2184,7 +2184,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
2184 publicKeyEncoding: { format: "pem", type: "spki" },2184 publicKeyEncoding: { format: "pem", type: "spki" },
2185 });2185 });
2186 const ownerCookie = yield* getAuthenticatedSessionCookieHeader();2186 const ownerCookie = yield* getAuthenticatedSessionCookieHeader();
2187 const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config");2187 const relayConfigUrl = yield* getHttpServerUrl("/api/connect/relay-config");
2188 const postRelayConfig = (cloudUserId: string, environmentCredential: string) =>2188 const postRelayConfig = (cloudUserId: string, environmentCredential: string) =>
2189 fetchEffect(relayConfigUrl, {2189 fetchEffect(relayConfigUrl, {
2190 method: "POST",2190 method: "POST",
...@@ -2227,8 +2227,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -2227,8 +2227,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
2227 publicKeyEncoding: { format: "pem", type: "spki" },2227 publicKeyEncoding: { format: "pem", type: "spki" },
2228 });2228 });
2229 const ownerCookie = yield* getAuthenticatedSessionCookieHeader();2229 const ownerCookie = yield* getAuthenticatedSessionCookieHeader();
2230 const linkStateUrl = yield* getHttpServerUrl("/api/cloud/link-state");2230 const linkStateUrl = yield* getHttpServerUrl("/api/connect/link-state");
2231 const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config");2231 const relayConfigUrl = yield* getHttpServerUrl("/api/connect/relay-config");
22322232
2233 const initialResponse = yield* fetchEffect(linkStateUrl, {2233 const initialResponse = yield* fetchEffect(linkStateUrl, {
2234 headers: {2234 headers: {
...@@ -2284,7 +2284,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -2284,7 +2284,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
2284 Effect.gen(function* () {2284 Effect.gen(function* () {
2285 yield* buildAppUnderTest();2285 yield* buildAppUnderTest();
22862286
2287 const reconcileUrl = yield* getHttpServerUrl("/api/cloud/reconcile");2287 const reconcileUrl = yield* getHttpServerUrl("/api/connect/reconcile");
2288 const response = yield* fetchEffect(reconcileUrl, {2288 const response = yield* fetchEffect(reconcileUrl, {
2289 method: "POST",2289 method: "POST",
2290 });2290 });
...@@ -2321,9 +2321,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -2321,9 +2321,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
2321 publicKeyEncoding: { format: "pem", type: "spki" },2321 publicKeyEncoding: { format: "pem", type: "spki" },
2322 });2322 });
2323 const ownerCookie = yield* getAuthenticatedSessionCookieHeader();2323 const ownerCookie = yield* getAuthenticatedSessionCookieHeader();
2324 const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config");2324 const relayConfigUrl = yield* getHttpServerUrl("/api/connect/relay-config");
2325 const unlinkUrl = yield* getHttpServerUrl("/api/cloud/unlink");2325 const unlinkUrl = yield* getHttpServerUrl("/api/connect/unlink");
2326 const linkStateUrl = yield* getHttpServerUrl("/api/cloud/link-state");2326 const linkStateUrl = yield* getHttpServerUrl("/api/connect/link-state");
23272327
2328 const relayConfigResponse = yield* fetchEffect(relayConfigUrl, {2328 const relayConfigResponse = yield* fetchEffect(relayConfigUrl, {
2329 method: "POST",2329 method: "POST",
...@@ -2398,7 +2398,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -2398,7 +2398,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
2398 publicKeyEncoding: { format: "pem", type: "spki" },2398 publicKeyEncoding: { format: "pem", type: "spki" },
2399 });2399 });
2400 const ownerCookie = yield* getAuthenticatedSessionCookieHeader();2400 const ownerCookie = yield* getAuthenticatedSessionCookieHeader();
2401 const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config");2401 const relayConfigUrl = yield* getHttpServerUrl("/api/connect/relay-config");
2402 const relayConfigResponse = yield* fetchEffect(relayConfigUrl, {2402 const relayConfigResponse = yield* fetchEffect(relayConfigUrl, {
2403 method: "POST",2403 method: "POST",
2404 headers: {2404 headers: {
...@@ -2424,7 +2424,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -2424,7 +2424,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
2424 issuedAt: DateTime.formatIso(now),2424 issuedAt: DateTime.formatIso(now),
2425 expiresAt: DateTime.formatIso(DateTime.add(now, { minutes: 5 })),2425 expiresAt: DateTime.formatIso(DateTime.add(now, { minutes: 5 })),
2426 });2426 });
2427 const mintUrl = yield* getHttpServerUrl("/api/cloud/mint-credential");2427 const mintUrl = yield* getHttpServerUrl("/api/connect/mint-credential");
2428 const postMint = () =>2428 const postMint = () =>
2429 fetchEffect(mintUrl, {2429 fetchEffect(mintUrl, {
2430 method: "POST",2430 method: "POST",
...@@ -2448,7 +2448,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -2448,7 +2448,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
2448 }).pipe(Effect.provide(NodeHttpServer.layerTest)),2448 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
2449 );2449 );
24502450
2451 it.effect("serves the documented T3 Cloud mint credential endpoint", () =>2451 it.effect("serves the documented T3 Connect mint credential endpoint", () =>
2452 Effect.gen(function* () {2452 Effect.gen(function* () {
2453 yield* buildAppUnderTest();2453 yield* buildAppUnderTest();
24542454
...@@ -2457,7 +2457,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -2457,7 +2457,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
2457 publicKeyEncoding: { format: "pem", type: "spki" },2457 publicKeyEncoding: { format: "pem", type: "spki" },
2458 });2458 });
2459 const ownerCookie = yield* getAuthenticatedSessionCookieHeader();2459 const ownerCookie = yield* getAuthenticatedSessionCookieHeader();
2460 const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config");2460 const relayConfigUrl = yield* getHttpServerUrl("/api/connect/relay-config");
2461 const relayConfigResponse = yield* fetchEffect(relayConfigUrl, {2461 const relayConfigResponse = yield* fetchEffect(relayConfigUrl, {
2462 method: "POST",2462 method: "POST",
2463 headers: {2463 headers: {
...@@ -2484,7 +2484,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -2484,7 +2484,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
2484 issuedAt: DateTime.formatIso(now),2484 issuedAt: DateTime.formatIso(now),
2485 expiresAt: DateTime.formatIso(DateTime.add(now, { minutes: 5 })),2485 expiresAt: DateTime.formatIso(DateTime.add(now, { minutes: 5 })),
2486 });2486 });
2487 const mintUrl = yield* getHttpServerUrl("/api/t3-cloud/mint-credential");2487 const mintUrl = yield* getHttpServerUrl("/api/t3-connect/mint-credential");
2488 const response = yield* fetchEffect(mintUrl, {2488 const response = yield* fetchEffect(mintUrl, {
2489 method: "POST",2489 method: "POST",
2490 headers: {2490 headers: {
...@@ -2507,7 +2507,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -2507,7 +2507,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
2507 }).pipe(Effect.provide(NodeHttpServer.layerTest)),2507 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
2508 );2508 );
25092509
2510 it.effect("serves signed T3 Cloud environment health checks", () =>2510 it.effect("serves signed T3 Connect environment health checks", () =>
2511 Effect.gen(function* () {2511 Effect.gen(function* () {
2512 yield* buildAppUnderTest();2512 yield* buildAppUnderTest();
25132513
...@@ -2516,7 +2516,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -2516,7 +2516,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
2516 publicKeyEncoding: { format: "pem", type: "spki" },2516 publicKeyEncoding: { format: "pem", type: "spki" },
2517 });2517 });
2518 const ownerCookie = yield* getAuthenticatedSessionCookieHeader();2518 const ownerCookie = yield* getAuthenticatedSessionCookieHeader();
2519 const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config");2519 const relayConfigUrl = yield* getHttpServerUrl("/api/connect/relay-config");
2520 const relayConfigResponse = yield* fetchEffect(relayConfigUrl, {2520 const relayConfigResponse = yield* fetchEffect(relayConfigUrl, {
2521 method: "POST",2521 method: "POST",
2522 headers: {2522 headers: {
...@@ -2542,7 +2542,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -2542,7 +2542,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
2542 issuedAt: DateTime.formatIso(now),2542 issuedAt: DateTime.formatIso(now),
2543 expiresAt: DateTime.formatIso(DateTime.add(now, { minutes: 5 })),2543 expiresAt: DateTime.formatIso(DateTime.add(now, { minutes: 5 })),
2544 });2544 });
2545 const healthUrl = yield* getHttpServerUrl("/api/t3-cloud/health");2545 const healthUrl = yield* getHttpServerUrl("/api/t3-connect/health");
2546 const response = yield* fetchEffect(healthUrl, {2546 const response = yield* fetchEffect(healthUrl, {
2547 method: "POST",2547 method: "POST",
2548 headers: {2548 headers: {
...@@ -2576,7 +2576,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -2576,7 +2576,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
2576 publicKeyEncoding: { format: "pem", type: "spki" },2576 publicKeyEncoding: { format: "pem", type: "spki" },
2577 });2577 });
2578 const ownerCookie = yield* getAuthenticatedSessionCookieHeader();2578 const ownerCookie = yield* getAuthenticatedSessionCookieHeader();
2579 const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config");2579 const relayConfigUrl = yield* getHttpServerUrl("/api/connect/relay-config");
2580 const relayConfigResponse = yield* fetchEffect(relayConfigUrl, {2580 const relayConfigResponse = yield* fetchEffect(relayConfigUrl, {
2581 method: "POST",2581 method: "POST",
2582 headers: {2582 headers: {
...@@ -2602,7 +2602,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -2602,7 +2602,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
2602 issuedAt: DateTime.formatIso(now),2602 issuedAt: DateTime.formatIso(now),
2603 expiresAt: DateTime.formatIso(DateTime.add(now, { minutes: 5 })),2603 expiresAt: DateTime.formatIso(DateTime.add(now, { minutes: 5 })),
2604 });2604 });
2605 const healthUrl = yield* getHttpServerUrl("/api/t3-cloud/health");2605 const healthUrl = yield* getHttpServerUrl("/api/t3-connect/health");
2606 const postHealth = () =>2606 const postHealth = () =>
2607 fetchEffect(healthUrl, {2607 fetchEffect(healthUrl, {
2608 method: "POST",2608 method: "POST",
...@@ -2637,7 +2637,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -2637,7 +2637,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
2637 publicKeyEncoding: { format: "pem", type: "spki" },2637 publicKeyEncoding: { format: "pem", type: "spki" },
2638 });2638 });
2639 const ownerCookie = yield* getAuthenticatedSessionCookieHeader();2639 const ownerCookie = yield* getAuthenticatedSessionCookieHeader();
2640 const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config");2640 const relayConfigUrl = yield* getHttpServerUrl("/api/connect/relay-config");
2641 const relayConfigResponse = yield* fetchEffect(relayConfigUrl, {2641 const relayConfigResponse = yield* fetchEffect(relayConfigUrl, {
2642 method: "POST",2642 method: "POST",
2643 headers: {2643 headers: {
...@@ -2656,7 +2656,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -2656,7 +2656,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
2656 assert.equal(relayConfigResponse.status, 200);2656 assert.equal(relayConfigResponse.status, 200);
26572657
2658 const now = yield* DateTime.now;2658 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");
2660 const postMint = (request: ReturnType<typeof makeCloudMintCredentialRequest>) =>2660 const postMint = (request: ReturnType<typeof makeCloudMintCredentialRequest>) =>
2661 fetchEffect(mintUrl, {2661 fetchEffect(mintUrl, {
2662 method: "POST",2662 method: "POST",
...@@ -2717,7 +2717,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -2717,7 +2717,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
2717 publicKeyEncoding: { format: "pem", type: "spki" },2717 publicKeyEncoding: { format: "pem", type: "spki" },
2718 });2718 });
2719 const ownerCookie = yield* getAuthenticatedSessionCookieHeader();2719 const ownerCookie = yield* getAuthenticatedSessionCookieHeader();
2720 const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config");2720 const relayConfigUrl = yield* getHttpServerUrl("/api/connect/relay-config");
2721 const relayConfigResponse = yield* fetchEffect(relayConfigUrl, {2721 const relayConfigResponse = yield* fetchEffect(relayConfigUrl, {
2722 method: "POST",2722 method: "POST",
2723 headers: {2723 headers: {
...@@ -2756,7 +2756,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -2756,7 +2756,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
2756 issuedAt: DateTime.formatIso(now),2756 issuedAt: DateTime.formatIso(now),
2757 expiresAt: DateTime.formatIso(DateTime.add(now, { minutes: 5 })),2757 expiresAt: DateTime.formatIso(DateTime.add(now, { minutes: 5 })),
2758 });2758 });
2759 const healthUrl = yield* getHttpServerUrl("/api/t3-cloud/health");2759 const healthUrl = yield* getHttpServerUrl("/api/t3-connect/health");
2760 const healthResponse = yield* fetchEffect(healthUrl, {2760 const healthResponse = yield* fetchEffect(healthUrl, {
2761 method: "POST",2761 method: "POST",
2762 headers: {2762 headers: {
...@@ -2786,7 +2786,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -2786,7 +2786,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
2786 publicKeyEncoding: { format: "pem", type: "spki" },2786 publicKeyEncoding: { format: "pem", type: "spki" },
2787 });2787 });
2788 const ownerCookie = yield* getAuthenticatedSessionCookieHeader();2788 const ownerCookie = yield* getAuthenticatedSessionCookieHeader();
2789 const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config");2789 const relayConfigUrl = yield* getHttpServerUrl("/api/connect/relay-config");
2790 const relayConfigResponse = yield* fetchEffect(relayConfigUrl, {2790 const relayConfigResponse = yield* fetchEffect(relayConfigUrl, {
2791 method: "POST",2791 method: "POST",
2792 headers: {2792 headers: {
...@@ -2804,7 +2804,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -2804,7 +2804,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
2804 assert.equal(relayConfigResponse.status, 200);2804 assert.equal(relayConfigResponse.status, 200);
28052805
2806 const now = yield* DateTime.now;2806 const now = yield* DateTime.now;
2807 const mintUrl = yield* getHttpServerUrl("/api/cloud/mint-credential");2807 const mintUrl = yield* getHttpServerUrl("/api/connect/mint-credential");
2808 const postMint = (request: ReturnType<typeof makeCloudMintCredentialRequest>) =>2808 const postMint = (request: ReturnType<typeof makeCloudMintCredentialRequest>) =>
2809 fetchEffect(mintUrl, {2809 fetchEffect(mintUrl, {
2810 method: "POST",2810 method: "POST",
...@@ -2853,7 +2853,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -2853,7 +2853,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
2853 publicKeyEncoding: { format: "pem", type: "spki" },2853 publicKeyEncoding: { format: "pem", type: "spki" },
2854 });2854 });
2855 const ownerCookie = yield* getAuthenticatedSessionCookieHeader();2855 const ownerCookie = yield* getAuthenticatedSessionCookieHeader();
2856 const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config");2856 const relayConfigUrl = yield* getHttpServerUrl("/api/connect/relay-config");
2857 const relayConfigResponse = yield* fetchEffect(relayConfigUrl, {2857 const relayConfigResponse = yield* fetchEffect(relayConfigUrl, {
2858 method: "POST",2858 method: "POST",
2859 headers: {2859 headers: {
...@@ -2871,7 +2871,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -2871,7 +2871,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
2871 assert.equal(relayConfigResponse.status, 200);2871 assert.equal(relayConfigResponse.status, 200);
28722872
2873 const now = yield* DateTime.now;2873 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");
2875 const response = yield* fetchEffect(mintUrl, {2875 const response = yield* fetchEffect(mintUrl, {
2876 method: "POST",2876 method: "POST",
2877 headers: {2877 headers: {
...@@ -2904,7 +2904,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -2904,7 +2904,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
2904 publicKeyEncoding: { format: "pem", type: "spki" },2904 publicKeyEncoding: { format: "pem", type: "spki" },
2905 });2905 });
2906 const ownerCookie = yield* getAuthenticatedSessionCookieHeader();2906 const ownerCookie = yield* getAuthenticatedSessionCookieHeader();
2907 const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config");2907 const relayConfigUrl = yield* getHttpServerUrl("/api/connect/relay-config");
2908 const relayConfigResponse = yield* fetchEffect(relayConfigUrl, {2908 const relayConfigResponse = yield* fetchEffect(relayConfigUrl, {
2909 method: "POST",2909 method: "POST",
2910 headers: {2910 headers: {
...@@ -2922,7 +2922,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -2922,7 +2922,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
2922 assert.equal(relayConfigResponse.status, 200);2922 assert.equal(relayConfigResponse.status, 200);
29232923
2924 const now = yield* DateTime.now;2924 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");
2926 const response = yield* fetchEffect(mintUrl, {2926 const response = yield* fetchEffect(mintUrl, {
2927 method: "POST",2927 method: "POST",
2928 headers: {2928 headers: {
...@@ -2955,7 +2955,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -2955,7 +2955,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
2955 publicKeyEncoding: { format: "pem", type: "spki" },2955 publicKeyEncoding: { format: "pem", type: "spki" },
2956 });2956 });
2957 const ownerCookie = yield* getAuthenticatedSessionCookieHeader();2957 const ownerCookie = yield* getAuthenticatedSessionCookieHeader();
2958 const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config");2958 const relayConfigUrl = yield* getHttpServerUrl("/api/connect/relay-config");
2959 const relayConfigResponse = yield* fetchEffect(relayConfigUrl, {2959 const relayConfigResponse = yield* fetchEffect(relayConfigUrl, {
2960 method: "POST",2960 method: "POST",
2961 headers: {2961 headers: {
...@@ -2973,7 +2973,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -2973,7 +2973,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
2973 assert.equal(relayConfigResponse.status, 200);2973 assert.equal(relayConfigResponse.status, 200);
29742974
2975 const now = yield* DateTime.now;2975 const now = yield* DateTime.now;
2976 const healthUrl = yield* getHttpServerUrl("/api/t3-cloud/health");2976 const healthUrl = yield* getHttpServerUrl("/api/t3-connect/health");
2977 const postHealth = (request: ReturnType<typeof makeCloudEnvironmentHealthRequest>) =>2977 const postHealth = (request: ReturnType<typeof makeCloudEnvironmentHealthRequest>) =>
2978 fetchEffect(healthUrl, {2978 fetchEffect(healthUrl, {
2979 method: "POST",2979 method: "POST",
...@@ -3020,7 +3020,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -3020,7 +3020,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
3020 publicKeyEncoding: { format: "pem", type: "spki" },3020 publicKeyEncoding: { format: "pem", type: "spki" },
3021 });3021 });
3022 const ownerCookie = yield* getAuthenticatedSessionCookieHeader();3022 const ownerCookie = yield* getAuthenticatedSessionCookieHeader();
3023 const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config");3023 const relayConfigUrl = yield* getHttpServerUrl("/api/connect/relay-config");
3024 const relayConfigResponse = yield* fetchEffect(relayConfigUrl, {3024 const relayConfigResponse = yield* fetchEffect(relayConfigUrl, {
3025 method: "POST",3025 method: "POST",
3026 headers: {3026 headers: {
...@@ -3038,7 +3038,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -3038,7 +3038,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
3038 assert.equal(relayConfigResponse.status, 200);3038 assert.equal(relayConfigResponse.status, 200);
30393039
3040 const now = yield* DateTime.now;3040 const now = yield* DateTime.now;
3041 const healthUrl = yield* getHttpServerUrl("/api/t3-cloud/health");3041 const healthUrl = yield* getHttpServerUrl("/api/t3-connect/health");
3042 const response = yield* fetchEffect(healthUrl, {3042 const response = yield* fetchEffect(healthUrl, {
3043 method: "POST",3043 method: "POST",
3044 headers: {3044 headers: {
...@@ -3070,7 +3070,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -3070,7 +3070,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
3070 publicKeyEncoding: { format: "pem", type: "spki" },3070 publicKeyEncoding: { format: "pem", type: "spki" },
3071 });3071 });
3072 const ownerCookie = yield* getAuthenticatedSessionCookieHeader();3072 const ownerCookie = yield* getAuthenticatedSessionCookieHeader();
3073 const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config");3073 const relayConfigUrl = yield* getHttpServerUrl("/api/connect/relay-config");
3074 const relayConfigResponse = yield* fetchEffect(relayConfigUrl, {3074 const relayConfigResponse = yield* fetchEffect(relayConfigUrl, {
3075 method: "POST",3075 method: "POST",
3076 headers: {3076 headers: {
...@@ -3088,7 +3088,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -3088,7 +3088,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
3088 assert.equal(relayConfigResponse.status, 200);3088 assert.equal(relayConfigResponse.status, 200);
30893089
3090 const now = yield* DateTime.now;3090 const now = yield* DateTime.now;
3091 const healthUrl = yield* getHttpServerUrl("/api/t3-cloud/health");3091 const healthUrl = yield* getHttpServerUrl("/api/t3-connect/health");
3092 const response = yield* fetchEffect(healthUrl, {3092 const response = yield* fetchEffect(healthUrl, {
3093 method: "POST",3093 method: "POST",
3094 headers: {3094 headers: {
...@@ -3280,7 +3280,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -3280,7 +3280,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
3280 config: { devUrl: new URL(crossOriginClientOrigin) },3280 config: { devUrl: new URL(crossOriginClientOrigin) },
3281 });3281 });
32823282
3283 const linkProofUrl = yield* getHttpServerUrl("/api/cloud/link-proof");3283 const linkProofUrl = yield* getHttpServerUrl("/api/connect/link-proof");
3284 const response = yield* fetchEffect(linkProofUrl, {3284 const response = yield* fetchEffect(linkProofUrl, {
3285 method: "OPTIONS",3285 method: "OPTIONS",
3286 headers: {3286 headers: {
apps/server/src/server.ts+4-4
...@@ -69,7 +69,7 @@ import { ServerEnvironmentLive } from "./environment/Layers/ServerEnvironment.ts...@@ -69,7 +69,7 @@ import { ServerEnvironmentLive } from "./environment/Layers/ServerEnvironment.ts
69import { authHttpApiLayer, environmentAuthenticatedAuthLayer } from "./auth/http.ts";69import { authHttpApiLayer, environmentAuthenticatedAuthLayer } from "./auth/http.ts";
70import * as ServerSecretStore from "./auth/ServerSecretStore.ts";70import * as ServerSecretStore from "./auth/ServerSecretStore.ts";
71import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";71import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";
72import { cloudHttpApiLayer, reconcileDesiredCloudLink } from "./cloud/http.ts";72import { connectHttpApiLayer, reconcileDesiredCloudLink } from "./cloud/http.ts";
73import * as CloudManagedEndpointRuntime from "./cloud/ManagedEndpointRuntime.ts";73import * as CloudManagedEndpointRuntime from "./cloud/ManagedEndpointRuntime.ts";
74import * as CloudCliTokenManager from "./cloud/CliTokenManager.ts";74import * as CloudCliTokenManager from "./cloud/CliTokenManager.ts";
75import * as CloudCliState from "./cloud/CliState.ts";75import * as CloudCliState from "./cloud/CliState.ts";
...@@ -321,7 +321,7 @@ const RuntimeServicesLive = ServerRuntimeStartupLive.pipe(...@@ -321,7 +321,7 @@ const RuntimeServicesLive = ServerRuntimeStartupLive.pipe(
321export const makeRoutesLayer = Layer.mergeAll(321export const makeRoutesLayer = Layer.mergeAll(
322 HttpApiBuilder.layer(EnvironmentHttpApi).pipe(322 HttpApiBuilder.layer(EnvironmentHttpApi).pipe(
323 Layer.provide(authHttpApiLayer),323 Layer.provide(authHttpApiLayer),
324 Layer.provide(cloudHttpApiLayer),324 Layer.provide(connectHttpApiLayer),
325 Layer.provide(orchestrationHttpApiLayer),325 Layer.provide(orchestrationHttpApiLayer),
326 Layer.provide(serverEnvironmentHttpApiLayer),326 Layer.provide(serverEnvironmentHttpApiLayer),
327 Layer.provide(environmentAuthenticatedAuthLayer),327 Layer.provide(environmentAuthenticatedAuthLayer),
...@@ -429,9 +429,9 @@ export const makeServerLayer = Layer.unwrap(...@@ -429,9 +429,9 @@ export const makeServerLayer = Layer.unwrap(
429 Effect.sleep("250 millis").pipe(429 Effect.sleep("250 millis").pipe(
430 Effect.andThen(reconcileDesiredCloudLink(`http://127.0.0.1:${address.port}`)),430 Effect.andThen(reconcileDesiredCloudLink(`http://127.0.0.1:${address.port}`)),
431 Effect.retry({ times: 4 }),431 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")),
433 Effect.catch((cause) =>433 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", {
435 cause,435 cause,
436 }),436 }),
437 ),437 ),
apps/web/src/cloud/linkEnvironment.test.ts+5-5
...@@ -514,7 +514,7 @@ describe("web cloud link environment client", () => {...@@ -514,7 +514,7 @@ describe("web cloud link environment client", () => {
514 expect(fetchMock.mock.calls[0]?.[1]?.credentials).not.toBe("include");514 expect(fetchMock.mock.calls[0]?.[1]?.credentials).not.toBe("include");
515515
516 expect(String(fetchMock.mock.calls[1]?.[0])).toBe(516 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",
518 );518 );
519 expect(fetchMock.mock.calls[1]?.[1]).toMatchObject({519 expect(fetchMock.mock.calls[1]?.[1]).toMatchObject({
520 method: "POST",520 method: "POST",
...@@ -553,7 +553,7 @@ describe("web cloud link environment client", () => {...@@ -553,7 +553,7 @@ describe("web cloud link environment client", () => {
553 });553 });
554554
555 expect(String(fetchMock.mock.calls[3]?.[0])).toBe(555 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",
557 );557 );
558 expect(fetchMock.mock.calls[3]?.[1]).toMatchObject({558 expect(fetchMock.mock.calls[3]?.[1]).toMatchObject({
559 method: "POST",559 method: "POST",
...@@ -615,7 +615,7 @@ describe("web cloud link environment client", () => {...@@ -615,7 +615,7 @@ describe("web cloud link environment client", () => {
615 publishAgentActivity: false,615 publishAgentActivity: false,
616 });616 });
617 expect(String(fetchMock.mock.calls[0]?.[0])).toBe(617 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",
619 );619 );
620 expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({620 expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({
621 method: "GET",621 method: "GET",
...@@ -654,7 +654,7 @@ describe("web cloud link environment client", () => {...@@ -654,7 +654,7 @@ describe("web cloud link environment client", () => {
654 }),654 }),
655 );655 );
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");
658 expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({658 expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({
659 method: "POST",659 method: "POST",
660 credentials: "include",660 credentials: "include",
...@@ -698,7 +698,7 @@ describe("web cloud link environment client", () => {...@@ -698,7 +698,7 @@ describe("web cloud link environment client", () => {
698 );698 );
699699
700 expect(fetchMock).toHaveBeenCalledTimes(2);700 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");
702 expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({702 expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({
703 method: "POST",703 method: "POST",
704 credentials: "include",704 credentials: "include",
apps/web/src/cloud/linkEnvironment.ts+7-7
...@@ -451,7 +451,7 @@ export function readPrimaryCloudLinkState(): Effect.Effect<...@@ -451,7 +451,7 @@ export function readPrimaryCloudLinkState(): Effect.Effect<
451 return null;451 return null;
452 }452 }
453 const client = yield* makeEnvironmentHttpApiClient(resolvePrimaryEnvironmentHttpUrl("/"));453 const client = yield* makeEnvironmentHttpApiClient(resolvePrimaryEnvironmentHttpUrl("/"));
454 return yield* client.cloud454 return yield* client.connect
455 .linkState({ headers: {} })455 .linkState({ headers: {} })
456 .pipe(456 .pipe(
457 withPrimaryEnvironmentRequestInit,457 withPrimaryEnvironmentRequestInit,
...@@ -465,7 +465,7 @@ export function updatePrimaryCloudPreferences(input: {...@@ -465,7 +465,7 @@ export function updatePrimaryCloudPreferences(input: {
465}): Effect.Effect<CloudLinkState, CloudEnvironmentLinkError, HttpClient.HttpClient> {465}): Effect.Effect<CloudLinkState, CloudEnvironmentLinkError, HttpClient.HttpClient> {
466 return Effect.gen(function* () {466 return Effect.gen(function* () {
467 const client = yield* makeEnvironmentHttpApiClient(resolvePrimaryEnvironmentHttpUrl("/"));467 const client = yield* makeEnvironmentHttpApiClient(resolvePrimaryEnvironmentHttpUrl("/"));
468 return yield* client.cloud468 return yield* client.connect
469 .preferences({469 .preferences({
470 headers: {},470 headers: {},
471 payload: input,471 payload: input,
...@@ -488,7 +488,7 @@ export function unlinkPrimaryEnvironmentFromCloud(input: {...@@ -488,7 +488,7 @@ export function unlinkPrimaryEnvironmentFromCloud(input: {
488 });488 });
489 }489 }
490 const client = yield* makeEnvironmentHttpApiClient(resolvePrimaryEnvironmentHttpUrl("/"));490 const client = yield* makeEnvironmentHttpApiClient(resolvePrimaryEnvironmentHttpUrl("/"));
491 yield* client.cloud491 yield* client.connect
492 .unlink({ headers: {} })492 .unlink({ headers: {} })
493 .pipe(493 .pipe(
494 withPrimaryEnvironmentRequestInit,494 withPrimaryEnvironmentRequestInit,
...@@ -568,7 +568,7 @@ export function linkEnvironmentToCloud(input: {...@@ -568,7 +568,7 @@ export function linkEnvironmentToCloud(input: {
568 ),568 ),
569 ),569 ),
570 );570 );
571 const proof = yield* environmentClient.cloud571 const proof = yield* environmentClient.connect
572 .linkProof({572 .linkProof({
573 headers,573 headers,
574 payload: {574 payload: {
...@@ -604,7 +604,7 @@ export function linkEnvironmentToCloud(input: {...@@ -604,7 +604,7 @@ export function linkEnvironmentToCloud(input: {
604 link,604 link,
605 });605 });
606606
607 yield* environmentClient.cloud607 yield* environmentClient.connect
608 .relayConfig({608 .relayConfig({
609 headers,609 headers,
610 payload: {610 payload: {
...@@ -656,7 +656,7 @@ export function linkPrimaryEnvironmentToCloud(input: {...@@ -656,7 +656,7 @@ export function linkPrimaryEnvironmentToCloud(input: {
656 ),656 ),
657 ),657 ),
658 );658 );
659 const proof = yield* environmentClient.cloud659 const proof = yield* environmentClient.connect
660 .linkProof({660 .linkProof({
661 headers: {},661 headers: {},
662 payload: {662 payload: {
...@@ -695,7 +695,7 @@ export function linkPrimaryEnvironmentToCloud(input: {...@@ -695,7 +695,7 @@ export function linkPrimaryEnvironmentToCloud(input: {
695 link,695 link,
696 });696 });
697697
698 yield* environmentClient.cloud698 yield* environmentClient.connect
699 .relayConfig({699 .relayConfig({
700 headers: {},700 headers: {},
701 payload: {701 payload: {
apps/web/src/cloud/primaryCloudLinkState.ts+1-1
...@@ -55,7 +55,7 @@ export function usePrimaryCloudLinkState() {...@@ -55,7 +55,7 @@ export function usePrimaryCloudLinkState() {
55 let error: string | null = null;55 let error: string | null = null;
56 if (result._tag === "Failure") {56 if (result._tag === "Failure") {
57 const cause = Cause.squash(result.cause);57 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.";
59 }59 }
6060
61 return {61 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() {...@@ -69,8 +69,8 @@ export function RelayClientInstallDialog() {
69 </DialogTitle>69 </DialogTitle>
70 <DialogDescription>70 <DialogDescription>
71 {isInstalling71 {isInstalling
72 ? "T3 Code is preparing this environment for secure access 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 Cloud."}73 : "T3 Code needs the relay client to make this environment available through T3 Connect."}
74 </DialogDescription>74 </DialogDescription>
75 </DialogHeader>75 </DialogHeader>
76 <DialogPanel scrollFade={false}>76 <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";...@@ -76,6 +76,7 @@ import { stackedThreadToast, toastManager } from "../ui/toast";
76import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";76import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
77import { Button } from "../ui/button";77import { Button } from "../ui/button";
78import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty";78import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty";
79import { useT3ConnectAuthPrompt } from "../clerk/useT3ConnectAuthPrompt";
79import { Group, GroupSeparator } from "../ui/group";80import { Group, GroupSeparator } from "../ui/group";
80import { AnimatedHeight } from "../AnimatedHeight";81import { AnimatedHeight } from "../AnimatedHeight";
81import {82import {
...@@ -121,6 +122,7 @@ import {...@@ -121,6 +122,7 @@ import {
121 connectManagedCloudEnvironment,122 connectManagedCloudEnvironment,
122 linkPrimaryEnvironmentToCloud,123 linkPrimaryEnvironmentToCloud,
123 unlinkPrimaryEnvironmentFromCloud,124 unlinkPrimaryEnvironmentFromCloud,
125 updatePrimaryCloudPreferences,
124} from "~/cloud/linkEnvironment";126} from "~/cloud/linkEnvironment";
125import {127import {
126 refreshManagedRelayEnvironments,128 refreshManagedRelayEnvironments,
...@@ -1614,7 +1616,7 @@ function CloudLinkSwitch({...@@ -1614,7 +1616,7 @@ function CloudLinkSwitch({
1614}) {1616}) {
1615 const control = (1617 const control = (
1616 <Switch1618 <Switch
1617 aria-label="Enable T3 Cloud"1619 aria-label="Enable T3 Connect"
1618 checked={checked}1620 checked={checked}
1619 disabled={disabled}1621 disabled={disabled}
1620 {...(onCheckedChange ? { onCheckedChange } : {})}1622 {...(onCheckedChange ? { onCheckedChange } : {})}
...@@ -1632,9 +1634,11 @@ function CloudLinkSwitch({...@@ -1632,9 +1634,11 @@ function CloudLinkSwitch({
16321634
1633function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: boolean }) {1635function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: boolean }) {
1634 const { getToken, isSignedIn } = useAuth();1636 const { getToken, isSignedIn } = useAuth();
1637 const { authPrompt, openAuthPrompt } = useT3ConnectAuthPrompt();
1635 const primaryCloudLinkState = usePrimaryCloudLinkState();1638 const primaryCloudLinkState = usePrimaryCloudLinkState();
1636 const [operationError, setOperationError] = useState<string | null>(null);1639 const [operationError, setOperationError] = useState<string | null>(null);
1637 const [isUpdating, setIsUpdating] = useState(false);1640 const [isUpdating, setIsUpdating] = useState(false);
1641 const [isUpdatingPreference, setIsUpdatingPreference] = useState(false);
16381642
1639 const updateLink = async (enabled: boolean) => {1643 const updateLink = async (enabled: boolean) => {
1640 setIsUpdating(true);1644 setIsUpdating(true);
...@@ -1643,7 +1647,7 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b...@@ -1643,7 +1647,7 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b
1643 const clerkToken = await getToken(resolveRelayClerkTokenOptions());1647 const clerkToken = await getToken(resolveRelayClerkTokenOptions());
1644 if (enabled) {1648 if (enabled) {
1645 if (!clerkToken) {1649 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.");
1647 }1651 }
1648 await webRuntime.runPromise(linkPrimaryEnvironmentToCloud({ clerkToken }));1652 await webRuntime.runPromise(linkPrimaryEnvironmentToCloud({ clerkToken }));
1649 } else {1653 } else {
...@@ -1655,48 +1659,103 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b...@@ -1655,48 +1659,103 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b
1655 refreshManagedRelayEnvironments();1659 refreshManagedRelayEnvironments();
1656 toastManager.add({1660 toastManager.add({
1657 type: "success",1661 type: "success",
1658 title: enabled ? "T3 Cloud linked" : "T3 Cloud unlinked",1662 title: enabled ? "T3 Connect linked" : "T3 Connect unlinked",
1659 description: enabled1663 description: enabled
1660 ? "This environment is available through T3 Cloud."1664 ? "This environment is available through T3 Connect."
1661 : "This environment is no longer available through T3 Cloud.",1665 : "This environment is no longer available through T3 Connect.",
1662 });1666 });
1663 } catch (cause) {1667 } 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.";
1665 setOperationError(message);1670 setOperationError(message);
1666 toastManager.add({1671 toastManager.add({
1667 type: "error",1672 type: "error",
1668 title: "Could not update T3 Cloud",1673 title: "Could not update T3 Connect",
1669 description: message,1674 description: message,
1670 });1675 });
1671 } finally {1676 } finally {
1672 setIsUpdating(false);1677 setIsUpdating(false);
1673 }1678 }
1674 };1679 };
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 };
1675 const disabledReason = !isSignedIn1703 const disabledReason = !isSignedIn
1676 ? "Sign in from T3 Cloud settings to manage this environment."1704 ? "Sign in to T3 Connect"
1677 : !canManageRelay1705 : !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."
1679 : null;1707 : null;
1680 const linked = primaryCloudLinkState.data?.linked ?? false;1708 const linked = primaryCloudLinkState.data?.linked ?? false;
16811709
1682 return (1710 return (
1683 <SettingsRow1711 <>
1684 title="T3 Cloud"1712 <SettingsRow
1685 description={1713 title="T3 Connect"
1686 linked1714 description={
1687 ? "This environment is available to your other devices through T3 Cloud."1715 linked
1688 : "Make this environment available to your other devices through T3 Cloud."1716 ? "This environment is available to your other devices through T3 Connect."
1689 }1717 : "Make this environment available to your other devices through T3 Connect."
1690 status={operationError ?? primaryCloudLinkState.error}1718 }
1691 control={1719 status={operationError ?? primaryCloudLinkState.error}
1692 <CloudLinkSwitch1720 control={
1693 checked={linked}1721 <CloudLinkSwitch
1694 disabled={!canManageRelay || !isSignedIn || primaryCloudLinkState.isPending || isUpdating}1722 checked={linked}
1695 disabledReason={disabledReason}1723 disabled={
1696 onCheckedChange={(enabled) => void updateLink(enabled)}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 }
1697 />1755 />
1698 }1756 ) : null}
1699 />1757 {authPrompt}
1758 </>
1700 );1759 );
1701}1760}
17021761
...@@ -1704,7 +1763,13 @@ function CloudLinkRow({ canManageRelay }: { readonly canManageRelay: boolean })...@@ -1704,7 +1763,13 @@ function CloudLinkRow({ canManageRelay }: { readonly canManageRelay: boolean })
1704 return hasCloudPublicConfig() ? <ConfiguredCloudLinkRow canManageRelay={canManageRelay} /> : null;1763 return hasCloudPublicConfig() ? <ConfiguredCloudLinkRow canManageRelay={canManageRelay} /> : null;
1705}1764}
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}) {
1708 return (1773 return (
1709 <Empty className="min-h-52">1774 <Empty className="min-h-52">
1710 <EmptyMedia variant="icon">1775 <EmptyMedia variant="icon">
...@@ -1713,9 +1778,24 @@ function EmptyRemoteEnvironments({ cloudEnabled = true }: { readonly cloudEnable...@@ -1713,9 +1778,24 @@ function EmptyRemoteEnvironments({ cloudEnabled = true }: { readonly cloudEnable
1713 <EmptyHeader>1778 <EmptyHeader>
1714 <EmptyTitle>No saved remote environments</EmptyTitle>1779 <EmptyTitle>No saved remote environments</EmptyTitle>
1715 <EmptyDescription>1780 <EmptyDescription>
1716 {cloudEnabled1781 Click “Add environment” to pair another environment
1717 ? "Click “Add environment” to pair another environment, or connect one from T3 Cloud."1782 {cloudEnabled ? (
1718 : "Click “Add environment” to pair another environment."}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 .
1719 </EmptyDescription>1799 </EmptyDescription>
1720 </EmptyHeader>1800 </EmptyHeader>
1721 </Empty>1801 </Empty>
...@@ -1743,7 +1823,8 @@ function ConfiguredCloudRemoteEnvironmentRows({...@@ -1743,7 +1823,8 @@ function ConfiguredCloudRemoteEnvironmentRows({
1743 readonly primaryEnvironmentId: EnvironmentId | null;1823 readonly primaryEnvironmentId: EnvironmentId | null;
1744 readonly savedEnvironmentIds: ReadonlyArray<EnvironmentId>;1824 readonly savedEnvironmentIds: ReadonlyArray<EnvironmentId>;
1745}) {1825}) {
1746 const { getToken } = useAuth();1826 const { getToken, isSignedIn } = useAuth();
1827 const { authPrompt, openAuthPrompt } = useT3ConnectAuthPrompt();
1747 const environmentsState = useManagedRelayEnvironments();1828 const environmentsState = useManagedRelayEnvironments();
1748 const [connectingEnvironmentId, setConnectingEnvironmentId] = useState<EnvironmentId | null>(1829 const [connectingEnvironmentId, setConnectingEnvironmentId] = useState<EnvironmentId | null>(
1749 null,1830 null,
...@@ -1755,7 +1836,7 @@ function ConfiguredCloudRemoteEnvironmentRows({...@@ -1755,7 +1836,7 @@ function ConfiguredCloudRemoteEnvironmentRows({
1755 try {1836 try {
1756 const clerkToken = await getToken(resolveRelayClerkTokenOptions());1837 const clerkToken = await getToken(resolveRelayClerkTokenOptions());
1757 if (!clerkToken) {1838 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.");
1759 }1840 }
1760 const connection = await webRuntime.runPromise(1841 const connection = await webRuntime.runPromise(
1761 connectManagedCloudEnvironment({ clerkToken, environment }),1842 connectManagedCloudEnvironment({ clerkToken, environment }),
...@@ -1764,14 +1845,14 @@ function ConfiguredCloudRemoteEnvironmentRows({...@@ -1764,14 +1845,14 @@ function ConfiguredCloudRemoteEnvironmentRows({
1764 toastManager.add({1845 toastManager.add({
1765 type: "success",1846 type: "success",
1766 title: "Environment connected",1847 title: "Environment connected",
1767 description: `${connection.label} is available through T3 Cloud.`,1848 description: `${connection.label} is available through T3 Connect.`,
1768 });1849 });
1769 } catch (cause) {1850 } catch (cause) {
1770 toastManager.add({1851 toastManager.add({
1771 type: "error",1852 type: "error",
1772 title: "Could not connect environment",1853 title: "Could not connect environment",
1773 description:1854 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.",
1775 });1856 });
1776 } finally {1857 } finally {
1777 setConnectingEnvironmentId(null);1858 setConnectingEnvironmentId(null);
...@@ -1789,7 +1870,12 @@ function ConfiguredCloudRemoteEnvironmentRows({...@@ -1789,7 +1870,12 @@ function ConfiguredCloudRemoteEnvironmentRows({
1789 }1870 }
17901871
1791 if (savedEnvironmentIds.length === 0 && connectableEnvironments.length === 0) {1872 if (savedEnvironmentIds.length === 0 && connectableEnvironments.length === 0) {
1792 return <EmptyRemoteEnvironments />;1873 return (
1874 <>
1875 <EmptyRemoteEnvironments {...(!isSignedIn ? { onConnectFromCloud: openAuthPrompt } : {})} />
1876 {authPrompt}
1877 </>
1878 );
1793 }1879 }
17941880
1795 return connectableEnvironments.map((environment) => (1881 return connectableEnvironments.map((environment) => (
...@@ -1799,11 +1885,11 @@ function ConfiguredCloudRemoteEnvironmentRows({...@@ -1799,11 +1885,11 @@ function ConfiguredCloudRemoteEnvironmentRows({
1799 <div className="flex items-center gap-2">1885 <div className="flex items-center gap-2">
1800 <ConnectionStatusDot1886 <ConnectionStatusDot
1801 dotClassName="bg-muted-foreground/35"1887 dotClassName="bg-muted-foreground/35"
1802 tooltipText="Available through T3 Cloud"1888 tooltipText="Available through T3 Connect"
1803 />1889 />
1804 <p className="truncate text-sm font-medium">{environment.label}</p>1890 <p className="truncate text-sm font-medium">{environment.label}</p>
1805 </div>1891 </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>
1807 </div>1893 </div>
1808 <Button1894 <Button
1809 size="sm"1895 size="sm"
apps/web/src/components/settings/SettingsSidebarNav.tsx+18-26
...@@ -3,7 +3,6 @@ import {...@@ -3,7 +3,6 @@ import {
3 ArchiveIcon,3 ArchiveIcon,
4 ArrowLeftIcon,4 ArrowLeftIcon,
5 BotIcon,5 BotIcon,
6 CloudIcon,
7 GitBranchIcon,6 GitBranchIcon,
8 KeyboardIcon,7 KeyboardIcon,
9 Link2Icon,8 Link2Icon,
...@@ -21,15 +20,13 @@ import {...@@ -21,15 +20,13 @@ import {
21 SidebarSeparator,20 SidebarSeparator,
22 useSidebar,21 useSidebar,
23} from "../ui/sidebar";22} from "../ui/sidebar";
24import { Badge } from "../ui/badge";23import { T3ConnectSidebarAvatar, T3ConnectSidebarSignIn } from "../clerk/T3ConnectSidebarSignIn";
25import { hasCloudPublicConfig } from "../../cloud/publicConfig";
2624
27export type SettingsSectionPath =25export type SettingsSectionPath =
28 | "/settings/general"26 | "/settings/general"
29 | "/settings/keybindings"27 | "/settings/keybindings"
30 | "/settings/providers"28 | "/settings/providers"
31 | "/settings/source-control"29 | "/settings/source-control"
32 | "/settings/cloud"
33 | "/settings/connections"30 | "/settings/connections"
34 | "/settings/archived";31 | "/settings/archived";
3532
...@@ -37,13 +34,11 @@ export const SETTINGS_NAV_ITEMS: ReadonlyArray<{...@@ -37,13 +34,11 @@ export const SETTINGS_NAV_ITEMS: ReadonlyArray<{
37 label: string;34 label: string;
38 to: SettingsSectionPath;35 to: SettingsSectionPath;
39 icon: ComponentType<{ className?: string }>;36 icon: ComponentType<{ className?: string }>;
40 badgeLabel?: string;
41}> = [37}> = [
42 { label: "General", to: "/settings/general", icon: Settings2Icon },38 { label: "General", to: "/settings/general", icon: Settings2Icon },
43 { label: "Keybindings", to: "/settings/keybindings", icon: KeyboardIcon },39 { label: "Keybindings", to: "/settings/keybindings", icon: KeyboardIcon },
44 { label: "Providers", to: "/settings/providers", icon: BotIcon },40 { label: "Providers", to: "/settings/providers", icon: BotIcon },
45 { label: "Source Control", to: "/settings/source-control", icon: GitBranchIcon },41 { label: "Source Control", to: "/settings/source-control", icon: GitBranchIcon },
46 { label: "T3 Cloud", to: "/settings/cloud", icon: CloudIcon, badgeLabel: "Private Beta" },
47 { label: "Connections", to: "/settings/connections", icon: Link2Icon },42 { label: "Connections", to: "/settings/connections", icon: Link2Icon },
48 { label: "Archive", to: "/settings/archived", icon: ArchiveIcon },43 { label: "Archive", to: "/settings/archived", icon: ArchiveIcon },
49];44];
...@@ -77,9 +72,7 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {...@@ -77,9 +72,7 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {
77 <SidebarContent className="overflow-x-hidden">72 <SidebarContent className="overflow-x-hidden">
78 <SidebarGroup className="px-2 py-3">73 <SidebarGroup className="px-2 py-3">
79 <SidebarMenu>74 <SidebarMenu>
80 {SETTINGS_NAV_ITEMS.filter(75 {SETTINGS_NAV_ITEMS.map((item) => {
81 (item) => item.to !== "/settings/cloud" || hasCloudPublicConfig(),
82 ).map((item) => {
83 const Icon = item.icon;76 const Icon = item.icon;
84 const isActive = pathname === item.to;77 const isActive = pathname === item.to;
85 return (78 return (
...@@ -102,11 +95,6 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {...@@ -102,11 +95,6 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {
102 }95 }
103 />96 />
104 <span className="truncate">{item.label}</span>97 <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}
110 </SidebarMenuButton>98 </SidebarMenuButton>
111 </SidebarMenuItem>99 </SidebarMenuItem>
112 );100 );
...@@ -117,18 +105,22 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {...@@ -117,18 +105,22 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {
117105
118 <SidebarSeparator />106 <SidebarSeparator />
119 <SidebarFooter className="p-2">107 <SidebarFooter className="p-2">
120 <SidebarMenu>108 <T3ConnectSidebarSignIn />
121 <SidebarMenuItem>109 <div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-1">
122 <SidebarMenuButton110 <SidebarMenu className="min-w-0">
123 size="sm"111 <SidebarMenuItem>
124 className="gap-2 px-2 py-2 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"112 <SidebarMenuButton
125 onClick={handleBackClick}113 size="sm"
126 >114 className="gap-2 px-2 py-2 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
127 <ArrowLeftIcon className="size-4" />115 onClick={handleBackClick}
128 <span>Back</span>116 >
129 </SidebarMenuButton>117 <ArrowLeftIcon className="size-4" />
130 </SidebarMenuItem>118 <span>Back</span>
131 </SidebarMenu>119 </SidebarMenuButton>
120 </SidebarMenuItem>
121 </SidebarMenu>
122 <T3ConnectSidebarAvatar />
123 </div>
132 </SidebarFooter>124 </SidebarFooter>
133 </>125 </>
134 );126 );
apps/web/src/environments/runtime/service.ts+1-1
...@@ -1582,7 +1582,7 @@ async function ensureSavedEnvironmentConnection(...@@ -1582,7 +1582,7 @@ async function ensureSavedEnvironmentConnection(
1582 await removeSavedEnvironmentBearerToken(activeRecord.environmentId);1582 await removeSavedEnvironmentBearerToken(activeRecord.environmentId);
1583 throw new Error(1583 throw new Error(
1584 activeCredential.current.method === "dpop"1584 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."
1586 : "Saved environment credential expired. Pair it again.",1586 : "Saved environment credential expired. Pair it again.",
1587 {1587 {
1588 cause: error,1588 cause: error,
apps/web/src/main.tsx+1-2
...@@ -27,7 +27,6 @@ if (isElectron) {...@@ -27,7 +27,6 @@ if (isElectron) {
27document.title = APP_DISPLAY_NAME;27document.title = APP_DISPLAY_NAME;
2828
29const clerkPublishableKey = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY as string | undefined;29const clerkPublishableKey = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY as string | undefined;
30const cloudWaitlistUrl = "/settings/cloud";
3130
32const app = <RouterProvider router={router} />;31const app = <RouterProvider router={router} />;
3332
...@@ -39,7 +38,7 @@ ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(...@@ -39,7 +38,7 @@ ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
39 <ManagedRelayAuthProvider>{app}</ManagedRelayAuthProvider>38 <ManagedRelayAuthProvider>{app}</ManagedRelayAuthProvider>
40 </DesktopClerkProvider>39 </DesktopClerkProvider>
41 ) : (40 ) : (
42 <ClerkProvider publishableKey={clerkPublishableKey} waitlistUrl={cloudWaitlistUrl}>41 <ClerkProvider publishableKey={clerkPublishableKey}>
43 <ManagedRelayAuthProvider>{app}</ManagedRelayAuthProvider>42 <ManagedRelayAuthProvider>{app}</ManagedRelayAuthProvider>
44 </ClerkProvider>43 </ClerkProvider>
45 )44 )
apps/web/src/routeTree.gen.ts-21
...@@ -19,7 +19,6 @@ import { Route as SettingsKeybindingsRouteImport } from './routes/settings.keybi...@@ -19,7 +19,6 @@ import { Route as SettingsKeybindingsRouteImport } from './routes/settings.keybi
19import { Route as SettingsGeneralRouteImport } from './routes/settings.general'19import { Route as SettingsGeneralRouteImport } from './routes/settings.general'
20import { Route as SettingsDiagnosticsRouteImport } from './routes/settings.diagnostics'20import { Route as SettingsDiagnosticsRouteImport } from './routes/settings.diagnostics'
21import { Route as SettingsConnectionsRouteImport } from './routes/settings.connections'21import { Route as SettingsConnectionsRouteImport } from './routes/settings.connections'
22import { Route as SettingsCloudRouteImport } from './routes/settings.cloud'
23import { Route as SettingsArchivedRouteImport } from './routes/settings.archived'22import { Route as SettingsArchivedRouteImport } from './routes/settings.archived'
24import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId'23import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId'
25import { Route as ChatEnvironmentIdThreadIdRouteImport } from './routes/_chat.$environmentId.$threadId'24import { Route as ChatEnvironmentIdThreadIdRouteImport } from './routes/_chat.$environmentId.$threadId'
...@@ -73,11 +72,6 @@ const SettingsConnectionsRoute = SettingsConnectionsRouteImport.update({...@@ -73,11 +72,6 @@ const SettingsConnectionsRoute = SettingsConnectionsRouteImport.update({
73 path: '/connections',72 path: '/connections',
74 getParentRoute: () => SettingsRoute,73 getParentRoute: () => SettingsRoute,
75} as any)74} as any)
76const SettingsCloudRoute = SettingsCloudRouteImport.update({
77 id: '/cloud',
78 path: '/cloud',
79 getParentRoute: () => SettingsRoute,
80} as any)
81const SettingsArchivedRoute = SettingsArchivedRouteImport.update({75const SettingsArchivedRoute = SettingsArchivedRouteImport.update({
82 id: '/archived',76 id: '/archived',
83 path: '/archived',77 path: '/archived',
...@@ -100,7 +94,6 @@ export interface FileRoutesByFullPath {...@@ -100,7 +94,6 @@ export interface FileRoutesByFullPath {
100 '/pair': typeof PairRoute94 '/pair': typeof PairRoute
101 '/settings': typeof SettingsRouteWithChildren95 '/settings': typeof SettingsRouteWithChildren
102 '/settings/archived': typeof SettingsArchivedRoute96 '/settings/archived': typeof SettingsArchivedRoute
103 '/settings/cloud': typeof SettingsCloudRoute
104 '/settings/connections': typeof SettingsConnectionsRoute97 '/settings/connections': typeof SettingsConnectionsRoute
105 '/settings/diagnostics': typeof SettingsDiagnosticsRoute98 '/settings/diagnostics': typeof SettingsDiagnosticsRoute
106 '/settings/general': typeof SettingsGeneralRoute99 '/settings/general': typeof SettingsGeneralRoute
...@@ -114,7 +107,6 @@ export interface FileRoutesByTo {...@@ -114,7 +107,6 @@ export interface FileRoutesByTo {
114 '/pair': typeof PairRoute107 '/pair': typeof PairRoute
115 '/settings': typeof SettingsRouteWithChildren108 '/settings': typeof SettingsRouteWithChildren
116 '/settings/archived': typeof SettingsArchivedRoute109 '/settings/archived': typeof SettingsArchivedRoute
117 '/settings/cloud': typeof SettingsCloudRoute
118 '/settings/connections': typeof SettingsConnectionsRoute110 '/settings/connections': typeof SettingsConnectionsRoute
119 '/settings/diagnostics': typeof SettingsDiagnosticsRoute111 '/settings/diagnostics': typeof SettingsDiagnosticsRoute
120 '/settings/general': typeof SettingsGeneralRoute112 '/settings/general': typeof SettingsGeneralRoute
...@@ -131,7 +123,6 @@ export interface FileRoutesById {...@@ -131,7 +123,6 @@ export interface FileRoutesById {
131 '/pair': typeof PairRoute123 '/pair': typeof PairRoute
132 '/settings': typeof SettingsRouteWithChildren124 '/settings': typeof SettingsRouteWithChildren
133 '/settings/archived': typeof SettingsArchivedRoute125 '/settings/archived': typeof SettingsArchivedRoute
134 '/settings/cloud': typeof SettingsCloudRoute
135 '/settings/connections': typeof SettingsConnectionsRoute126 '/settings/connections': typeof SettingsConnectionsRoute
136 '/settings/diagnostics': typeof SettingsDiagnosticsRoute127 '/settings/diagnostics': typeof SettingsDiagnosticsRoute
137 '/settings/general': typeof SettingsGeneralRoute128 '/settings/general': typeof SettingsGeneralRoute
...@@ -149,7 +140,6 @@ export interface FileRouteTypes {...@@ -149,7 +140,6 @@ export interface FileRouteTypes {
149 | '/pair'140 | '/pair'
150 | '/settings'141 | '/settings'
151 | '/settings/archived'142 | '/settings/archived'
152 | '/settings/cloud'
153 | '/settings/connections'143 | '/settings/connections'
154 | '/settings/diagnostics'144 | '/settings/diagnostics'
155 | '/settings/general'145 | '/settings/general'
...@@ -163,7 +153,6 @@ export interface FileRouteTypes {...@@ -163,7 +153,6 @@ export interface FileRouteTypes {
163 | '/pair'153 | '/pair'
164 | '/settings'154 | '/settings'
165 | '/settings/archived'155 | '/settings/archived'
166 | '/settings/cloud'
167 | '/settings/connections'156 | '/settings/connections'
168 | '/settings/diagnostics'157 | '/settings/diagnostics'
169 | '/settings/general'158 | '/settings/general'
...@@ -179,7 +168,6 @@ export interface FileRouteTypes {...@@ -179,7 +168,6 @@ export interface FileRouteTypes {
179 | '/pair'168 | '/pair'
180 | '/settings'169 | '/settings'
181 | '/settings/archived'170 | '/settings/archived'
182 | '/settings/cloud'
183 | '/settings/connections'171 | '/settings/connections'
184 | '/settings/diagnostics'172 | '/settings/diagnostics'
185 | '/settings/general'173 | '/settings/general'
...@@ -269,13 +257,6 @@ declare module '@tanstack/react-router' {...@@ -269,13 +257,6 @@ declare module '@tanstack/react-router' {
269 preLoaderRoute: typeof SettingsConnectionsRouteImport257 preLoaderRoute: typeof SettingsConnectionsRouteImport
270 parentRoute: typeof SettingsRoute258 parentRoute: typeof SettingsRoute
271 }259 }
272 '/settings/cloud': {
273 id: '/settings/cloud'
274 path: '/cloud'
275 fullPath: '/settings/cloud'
276 preLoaderRoute: typeof SettingsCloudRouteImport
277 parentRoute: typeof SettingsRoute
278 }
279 '/settings/archived': {260 '/settings/archived': {
280 id: '/settings/archived'261 id: '/settings/archived'
281 path: '/archived'262 path: '/archived'
...@@ -316,7 +297,6 @@ const ChatRouteWithChildren = ChatRoute._addFileChildren(ChatRouteChildren)...@@ -316,7 +297,6 @@ const ChatRouteWithChildren = ChatRoute._addFileChildren(ChatRouteChildren)
316297
317interface SettingsRouteChildren {298interface SettingsRouteChildren {
318 SettingsArchivedRoute: typeof SettingsArchivedRoute299 SettingsArchivedRoute: typeof SettingsArchivedRoute
319 SettingsCloudRoute: typeof SettingsCloudRoute
320 SettingsConnectionsRoute: typeof SettingsConnectionsRoute300 SettingsConnectionsRoute: typeof SettingsConnectionsRoute
321 SettingsDiagnosticsRoute: typeof SettingsDiagnosticsRoute301 SettingsDiagnosticsRoute: typeof SettingsDiagnosticsRoute
322 SettingsGeneralRoute: typeof SettingsGeneralRoute302 SettingsGeneralRoute: typeof SettingsGeneralRoute
...@@ -327,7 +307,6 @@ interface SettingsRouteChildren {...@@ -327,7 +307,6 @@ interface SettingsRouteChildren {
327307
328const SettingsRouteChildren: SettingsRouteChildren = {308const SettingsRouteChildren: SettingsRouteChildren = {
329 SettingsArchivedRoute: SettingsArchivedRoute,309 SettingsArchivedRoute: SettingsArchivedRoute,
330 SettingsCloudRoute: SettingsCloudRoute,
331 SettingsConnectionsRoute: SettingsConnectionsRoute,310 SettingsConnectionsRoute: SettingsConnectionsRoute,
332 SettingsDiagnosticsRoute: SettingsDiagnosticsRoute,311 SettingsDiagnosticsRoute: SettingsDiagnosticsRoute,
333 SettingsGeneralRoute: SettingsGeneralRoute,312 SettingsGeneralRoute: SettingsGeneralRoute,
apps/web/src/routes/_chat.index.tsx+4-7
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import { createFileRoute } from "@tanstack/react-router";1import { createFileRoute, Link } from "@tanstack/react-router";
2import { LinkIcon, PlusIcon } from "lucide-react";2import { LinkIcon, PlusIcon } from "lucide-react";
33
4import { NoActiveThreadState } from "../components/NoActiveThreadState";4import { NoActiveThreadState } from "../components/NoActiveThreadState";
...@@ -52,16 +52,13 @@ function HostedStaticOnboardingState() {...@@ -52,16 +52,13 @@ function HostedStaticOnboardingState() {
52 </EmptyTitle>52 </EmptyTitle>
53 <EmptyDescription className="mt-2 text-sm leading-relaxed text-muted-foreground/78">53 <EmptyDescription className="mt-2 text-sm leading-relaxed text-muted-foreground/78">
54 {cloudEnabled54 {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."
56 : "Add a reachable backend manually to start working from this browser."}56 : "Add a reachable backend manually to start working from this browser."}
57 </EmptyDescription>57 </EmptyDescription>
58 <div className="mt-6 flex justify-center">58 <div className="mt-6 flex justify-center">
59 <Button59 <Button render={<Link to="/settings/connections" />} size="sm">
60 render={<a href={cloudEnabled ? "/settings/cloud" : "/settings/connections"} />}
61 size="sm"
62 >
63 <PlusIcon className="size-4" />60 <PlusIcon className="size-4" />
64 {cloudEnabled ? "Open T3 Cloud" : "Add environment"}61 {cloudEnabled ? "Open Connections" : "Add environment"}
65 </Button>62 </Button>
66 </div>63 </div>
67 </EmptyHeader>64 </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 @@...@@ -2,7 +2,7 @@
22
3- [Getting started](./getting-started/quick-start.md)3- [Getting started](./getting-started/quick-start.md)
4- [Architecture](./architecture/overview.md)4- [Architecture](./architecture/overview.md)
5- [Cloud](./cloud/t3-cloud-clerk.md)5- [T3 Connect](./cloud/t3-connect-clerk.md)
6- [Integrations](./integrations/source-control-providers.md)6- [Integrations](./integrations/source-control-providers.md)
7- [Mobile](./mobile/app.md)7- [Mobile](./mobile/app.md)
8- [Operations](./operations/ci.md)8- [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...@@ -10,7 +10,7 @@ This document covers the unified release workflow for stable and nightly desktop
10 - scheduled nightly check every three hours10 - scheduled nightly check every three hours
11 - manual `workflow_dispatch` for either channel11 - manual `workflow_dispatch` for either channel
12- Runs quality gates first: lint, typecheck, test.12- 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.
14- Builds four artifacts in parallel for both channels:14- Builds four artifacts in parallel for both channels:
15 - macOS `arm64` DMG15 - macOS `arm64` DMG
16 - macOS `x64` DMG16 - macOS `x64` DMG
...@@ -30,7 +30,7 @@ This document covers the unified release workflow for stable and nightly desktop...@@ -30,7 +30,7 @@ This document covers the unified release workflow for stable and nightly desktop
30 - nightly releases are aliased to the `nightly` hosted app channel30 - nightly releases are aliased to the `nightly` hosted app channel
31- Signing is optional and auto-detected per platform from secrets.31- Signing is optional and auto-detected per platform from secrets.
3232
33## T3 Cloud relay deployment33## T3 Connect relay deployment
3434
35The relay is a shared control plane versioned separately from client releases. Stable and nightly35The relay is a shared control plane versioned separately from client releases. Stable and nightly
36client builds must point at the same relay so users see the same linked environments when switching36client 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,15 +1,15 @@
1# T3 Code Cloud Relay1# T3 Connect Relay
22
3> [!WARNING]3> [!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 to6The relay is the hosted control plane for T3 Connect. It helps clients discover and connect to
7remote environments, manages the cloud-side records needed for those connections, and delivers7remote environments, manages the cloud-side records needed for those connections, and delivers
8optional mobile notifications and Live Activities.8optional mobile notifications and Live Activities.
99
10The relay is intentionally not in the hot path for normal T3 Code traffic. After a client connects,10The relay is intentionally not in the hot path for normal T3 Code traffic. After a client connects,
11regular API and WebSocket traffic goes directly between that client and the selected environment.11regular 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 system12See the [T3 Connect architecture overview](../../docs/cloud/t3-code-connect-auth-flow.html) for the larger system
13design.13design.
1414
15## Responsibilities15## Responsibilities
...@@ -159,8 +159,8 @@ and hosted web builds....@@ -159,8 +159,8 @@ and hosted web builds.
159159
160See:160See:
161161
162- [T3 Cloud Clerk Setup](../../docs/t3-cloud-clerk.md) for Clerk keys, JWT templates, and waitlist162- [T3 Connect Clerk Setup](../../docs/cloud/t3-connect-clerk.md) for Clerk keys, JWT templates, and waitlist
163 setup.163 setup.
164- [Relay Observability](../../docs/relay-observability.md) for deployment tracing and diagnostics.164- [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,
166 connect, endpoint, and notification flows.166 connect, endpoint, and notification flows.
infra/relay/src/environments/EnvironmentConnector.test.ts+2-2
...@@ -300,7 +300,7 @@ describe("EnvironmentConnector", () => {...@@ -300,7 +300,7 @@ describe("EnvironmentConnector", () => {
300 environmentId: "env-connector-test",300 environmentId: "env-connector-test",
301 });301 });
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"]);
304 expect(seenProofs[0]).toMatchObject({304 expect(seenProofs[0]).toMatchObject({
305 iss: "https://relay.example.test",305 iss: "https://relay.example.test",
306 aud: "t3-env:env-connector-test",306 aud: "t3-env:env-connector-test",
...@@ -663,7 +663,7 @@ describe("EnvironmentConnector", () => {...@@ -663,7 +663,7 @@ describe("EnvironmentConnector", () => {
663 deviceId: "device-123",663 deviceId: "device-123",
664 });664 });
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"]);
667 expect(seenProofs[0]).toMatchObject({667 expect(seenProofs[0]).toMatchObject({
668 iss: "https://relay.example.test",668 iss: "https://relay.example.test",
669 aud: "t3-env:env-connector-test",669 aud: "t3-env:env-connector-test",
infra/relay/src/environments/EnvironmentConnector.ts+26-24
...@@ -458,7 +458,7 @@ const make = Effect.gen(function* () {...@@ -458,7 +458,7 @@ const make = Effect.gen(function* () {
458 );458 );
459 const checkedAt = DateTime.formatIso(now);459 const checkedAt = DateTime.formatIso(now);
460 const environmentClient = yield* makeEnvironmentClient(endpoint.httpBaseUrl);460 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(
462 withoutRedirects,462 withoutRedirects,
463 Effect.match({463 Effect.match({
464 onFailure: (cause) => ({ _tag: "Failure" as const, cause }),464 onFailure: (cause) => ({ _tag: "Failure" as const, cause }),
...@@ -591,30 +591,32 @@ const make = Effect.gen(function* () {...@@ -591,30 +591,32 @@ const make = Effect.gen(function* () {
591 ),591 ),
592 );592 );
593 const environmentClient = yield* makeEnvironmentClient(endpoint.httpBaseUrl);593 const environmentClient = yield* makeEnvironmentClient(endpoint.httpBaseUrl);
594 const decoded = yield* environmentClient.cloud.t3MintCredential({ payload: { proof } }).pipe(594 const decoded = yield* environmentClient.connect
595 withoutRedirects,595 .t3MintCredential({ payload: { proof } })
596 Effect.mapError(596 .pipe(
597 (cause) =>597 withoutRedirects,
598 new EnvironmentMintRequestFailed({598 Effect.mapError(
599 environmentId: input.environmentId,599 (cause) =>
600 operation: "connect",600 new EnvironmentMintRequestFailed({
601 cause,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,
602 }),617 }),
603 ),618 ),
604 Effect.timeoutOption(Duration.millis(ENVIRONMENT_MINT_REQUEST_TIMEOUT_MS)),619 );
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 const verified = yield* verifyEnvironmentResponse({620 const verified = yield* verifyEnvironmentResponse({
619 response: decoded,621 response: decoded,
620 environmentId: input.environmentId,622 environmentId: input.environmentId,
packages/client-runtime/src/managedRelayState.ts+4-4
...@@ -53,7 +53,7 @@ export function createManagedRelaySession(input: {...@@ -53,7 +53,7 @@ export function createManagedRelaySession(input: {
53 try: input.readClerkToken,53 try: input.readClerkToken,
54 catch: (cause) =>54 catch: (cause) =>
55 new ManagedRelaySessionError({55 new ManagedRelaySessionError({
56 message: "Could not obtain the T3 Cloud session token.",56 message: "Could not obtain the T3 Connect session token.",
57 cause,57 cause,
58 }),58 }),
59 }),59 }),
...@@ -76,7 +76,7 @@ function readSessionClerkToken(...@@ -76,7 +76,7 @@ function readSessionClerkToken(
76 ? Effect.succeed(token)76 ? Effect.succeed(token)
77 : Effect.fail(77 : Effect.fail(
78 new ManagedRelaySessionError({78 new ManagedRelaySessionError({
79 message: "The T3 Cloud session token is unavailable.",79 message: "The T3 Connect session token is unavailable.",
80 }),80 }),
81 ),81 ),
82 ),82 ),
...@@ -121,7 +121,7 @@ function requireClerkToken(...@@ -121,7 +121,7 @@ function requireClerkToken(
121 if (!session || session.accountId !== accountId) {121 if (!session || session.accountId !== accountId) {
122 return Effect.fail(122 return Effect.fail(
123 new ManagedRelaySessionError({123 new ManagedRelaySessionError({
124 message: "Sign in to T3 Cloud before loading relay data.",124 message: "Sign in to T3 Connect before loading relay data.",
125 }),125 }),
126 );126 );
127 }127 }
...@@ -190,7 +190,7 @@ export function readManagedRelaySnapshotState<A>(...@@ -190,7 +190,7 @@ export function readManagedRelaySnapshotState<A>(
190 let error: string | null = null;190 let error: string | null = null;
191 if (result._tag === "Failure") {191 if (result._tag === "Failure") {
192 const cause = Cause.squash(result.cause);192 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.";
194 }194 }
195 return {195 return {
196 data: Option.getOrNull(AsyncResult.value(result)),196 data: Option.getOrNull(AsyncResult.value(result)),
packages/contracts/src/environmentHttp.ts+10-10
...@@ -439,9 +439,9 @@ export class EnvironmentOrchestrationHttpApi extends HttpApiGroup.make("orchestr...@@ -439,9 +439,9 @@ export class EnvironmentOrchestrationHttpApi extends HttpApiGroup.make("orchestr
439 }).middleware(EnvironmentAuthenticatedAuth),439 }).middleware(EnvironmentAuthenticatedAuth),
440 ) {}440 ) {}
441441
442export class EnvironmentCloudHttpApi extends HttpApiGroup.make("cloud")442export class EnvironmentConnectHttpApi extends HttpApiGroup.make("connect")
443 .add(443 .add(
444 HttpApiEndpoint.post("linkProof", "/api/cloud/link-proof", {444 HttpApiEndpoint.post("linkProof", "/api/connect/link-proof", {
445 headers: OptionalBearerHeaders,445 headers: OptionalBearerHeaders,
446 payload: RelayLinkProofRequest,446 payload: RelayLinkProofRequest,
447 success: RelayEnvironmentLinkProof,447 success: RelayEnvironmentLinkProof,
...@@ -449,7 +449,7 @@ export class EnvironmentCloudHttpApi extends HttpApiGroup.make("cloud")...@@ -449,7 +449,7 @@ export class EnvironmentCloudHttpApi extends HttpApiGroup.make("cloud")
449 }).middleware(EnvironmentAuthenticatedAuth),449 }).middleware(EnvironmentAuthenticatedAuth),
450 )450 )
451 .add(451 .add(
452 HttpApiEndpoint.post("relayConfig", "/api/cloud/relay-config", {452 HttpApiEndpoint.post("relayConfig", "/api/connect/relay-config", {
453 headers: OptionalBearerHeaders,453 headers: OptionalBearerHeaders,
454 payload: RelayEnvironmentConfigRequest,454 payload: RelayEnvironmentConfigRequest,
455 success: EnvironmentCloudRelayConfigResult,455 success: EnvironmentCloudRelayConfigResult,
...@@ -457,21 +457,21 @@ export class EnvironmentCloudHttpApi extends HttpApiGroup.make("cloud")...@@ -457,21 +457,21 @@ export class EnvironmentCloudHttpApi extends HttpApiGroup.make("cloud")
457 }).middleware(EnvironmentAuthenticatedAuth),457 }).middleware(EnvironmentAuthenticatedAuth),
458 )458 )
459 .add(459 .add(
460 HttpApiEndpoint.get("linkState", "/api/cloud/link-state", {460 HttpApiEndpoint.get("linkState", "/api/connect/link-state", {
461 headers: OptionalBearerHeaders,461 headers: OptionalBearerHeaders,
462 success: EnvironmentCloudLinkStateResult,462 success: EnvironmentCloudLinkStateResult,
463 error: EnvironmentHttpCloudErrors,463 error: EnvironmentHttpCloudErrors,
464 }).middleware(EnvironmentAuthenticatedAuth),464 }).middleware(EnvironmentAuthenticatedAuth),
465 )465 )
466 .add(466 .add(
467 HttpApiEndpoint.post("unlink", "/api/cloud/unlink", {467 HttpApiEndpoint.post("unlink", "/api/connect/unlink", {
468 headers: OptionalBearerHeaders,468 headers: OptionalBearerHeaders,
469 success: EnvironmentCloudRelayConfigResult,469 success: EnvironmentCloudRelayConfigResult,
470 error: EnvironmentHttpCloudErrors,470 error: EnvironmentHttpCloudErrors,
471 }).middleware(EnvironmentAuthenticatedAuth),471 }).middleware(EnvironmentAuthenticatedAuth),
472 )472 )
473 .add(473 .add(
474 HttpApiEndpoint.post("preferences", "/api/cloud/preferences", {474 HttpApiEndpoint.post("preferences", "/api/connect/preferences", {
475 headers: OptionalBearerHeaders,475 headers: OptionalBearerHeaders,
476 payload: EnvironmentCloudPreferencesRequest,476 payload: EnvironmentCloudPreferencesRequest,
477 success: EnvironmentCloudLinkStateResult,477 success: EnvironmentCloudLinkStateResult,
...@@ -479,21 +479,21 @@ export class EnvironmentCloudHttpApi extends HttpApiGroup.make("cloud")...@@ -479,21 +479,21 @@ export class EnvironmentCloudHttpApi extends HttpApiGroup.make("cloud")
479 }).middleware(EnvironmentAuthenticatedAuth),479 }).middleware(EnvironmentAuthenticatedAuth),
480 )480 )
481 .add(481 .add(
482 HttpApiEndpoint.post("health", "/api/t3-cloud/health", {482 HttpApiEndpoint.post("health", "/api/t3-connect/health", {
483 payload: RelayCloudEnvironmentHealthRequest,483 payload: RelayCloudEnvironmentHealthRequest,
484 success: RelayEnvironmentHealthResponse,484 success: RelayEnvironmentHealthResponse,
485 error: EnvironmentHttpCloudErrors,485 error: EnvironmentHttpCloudErrors,
486 }),486 }),
487 )487 )
488 .add(488 .add(
489 HttpApiEndpoint.post("mintCredential", "/api/cloud/mint-credential", {489 HttpApiEndpoint.post("mintCredential", "/api/connect/mint-credential", {
490 payload: RelayCloudMintCredentialRequest,490 payload: RelayCloudMintCredentialRequest,
491 success: RelayEnvironmentMintResponse,491 success: RelayEnvironmentMintResponse,
492 error: EnvironmentHttpCloudErrors,492 error: EnvironmentHttpCloudErrors,
493 }),493 }),
494 )494 )
495 .add(495 .add(
496 HttpApiEndpoint.post("t3MintCredential", "/api/t3-cloud/mint-credential", {496 HttpApiEndpoint.post("t3MintCredential", "/api/t3-connect/mint-credential", {
497 payload: RelayCloudMintCredentialRequest,497 payload: RelayCloudMintCredentialRequest,
498 success: RelayEnvironmentMintResponse,498 success: RelayEnvironmentMintResponse,
499 error: EnvironmentHttpCloudErrors,499 error: EnvironmentHttpCloudErrors,
...@@ -504,4 +504,4 @@ export class EnvironmentHttpApi extends HttpApi.make("environment")...@@ -504,4 +504,4 @@ export class EnvironmentHttpApi extends HttpApi.make("environment")
504 .add(EnvironmentMetadataHttpApi)504 .add(EnvironmentMetadataHttpApi)
505 .add(EnvironmentAuthHttpApi)505 .add(EnvironmentAuthHttpApi)
506 .add(EnvironmentOrchestrationHttpApi)506 .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<...@@ -537,7 +537,7 @@ export class RelayEnvironmentPrincipal extends Context.Service<
537const RelayClientBearerAuthorization = HttpApiSecurity.http({ scheme: "bearer" }).pipe(537const RelayClientBearerAuthorization = HttpApiSecurity.http({ scheme: "bearer" }).pipe(
538 HttpApiSecurity.annotate(538 HttpApiSecurity.annotate(
539 OpenApi.Description,539 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.",
541 ),541 ),
542);542);
543543